style(rooms): redesign the join-before-navigate screen as a full-width Dawn invite hero with accept and decline

This commit is contained in:
heaven 2026-06-04 14:00:46 +03:00
parent 15ce5f4fb9
commit 08456b63ad
2 changed files with 210 additions and 26 deletions

View file

@ -0,0 +1,53 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// A calm centred invite hero: full-width on mobile, a narrow reading column on
// desktop. Replaces the cramped 364px RoomCard that floated in an empty page.
export const Hero = style({
width: '100%',
maxWidth: toRem(440),
margin: 'auto',
padding: `${config.space.S700} ${config.space.S400}`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
textAlign: 'center',
gap: config.space.S400,
});
export const Eyebrow = style({
display: 'block',
textTransform: 'uppercase',
letterSpacing: '0.1em',
fontSize: toRem(11),
fontWeight: config.fontWeight.W600,
color: color.Surface.OnContainer,
opacity: 0.5,
});
export const Topic = style({
color: color.SurfaceVariant.OnContainer,
opacity: 0.8,
maxWidth: toRem(380),
display: '-webkit-box',
WebkitLineClamp: 5,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
whiteSpace: 'pre-wrap',
});
export const MemberCount = style({
fontFamily: 'var(--font-mono)',
fontVariantNumeric: 'tabular-nums',
color: color.SurfaceVariant.OnContainer,
opacity: 0.7,
});
// Full-width action stack — comfortable touch targets on native.
export const Actions = style({
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: config.space.S200,
marginTop: config.space.S300,
});

View file

@ -1,15 +1,38 @@
import React from 'react'; import React, { useCallback } from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds'; import {
Avatar,
Badge,
Box,
Button,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
color,
toRem,
} from 'folds';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { RoomCard } from '../../components/room-card'; import { MatrixError, Room } from 'matrix-js-sdk';
import { RoomTopicViewer } from '../../components/room-topic-viewer'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import * as css from './JoinBeforeNavigate.css';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { Page, PageHeader } from '../../components/page'; import { Page, PageHeader } from '../../components/page';
import { RoomSummaryLoader } from '../../components/RoomSummaryLoader'; import { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { getRoomAvatarUrl } from '../../utils/room';
import { RoomType } from '../../../types/matrix/room';
import { millify } from '../../plugins/millify';
type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] }; type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] };
export function JoinBeforeNavigate({ export function JoinBeforeNavigate({
@ -17,11 +40,20 @@ export function JoinBeforeNavigate({
eventId, eventId,
viaServers, viaServers,
}: JoinBeforeNavigateProps) { }: JoinBeforeNavigateProps) {
const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const allRooms = useAtomValue(allRoomsAtom); const allRooms = useAtomValue(allRoomsAtom);
const { navigateRoom, navigateSpace } = useRoomNavigate(); const { navigateRoom, navigateSpace } = useRoomNavigate();
const navigate = useNavigate();
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias);
// For a pending invite the room already exists locally (membership "invite"),
// so resolve it for the richer avatar / member count / membership signal.
const room = mx.getRoom(joinedRoomId ?? roomIdOrAlias) ?? undefined;
const invited = room?.getMyMembership() === 'invite';
const handleView = (roomId: string) => { const handleView = (roomId: string) => {
if (mx.getRoom(roomId)?.isSpaceRoom()) { if (mx.getRoom(roomId)?.isSpaceRoom()) {
navigateSpace(roomId); navigateSpace(roomId);
@ -30,6 +62,21 @@ export function JoinBeforeNavigate({
navigateRoom(roomId, eventId); navigateRoom(roomId, eventId);
}; };
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers])
);
const joining =
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
const [declineState, decline] = useAsyncCallback<void, MatrixError, []>(
useCallback(async () => {
if (room) await mx.leave(room.roomId);
navigate('/');
}, [mx, room, navigate])
);
const declining =
declineState.status === AsyncStatus.Loading || declineState.status === AsyncStatus.Success;
return ( return (
<Page> <Page>
<PageHeader balance> <PageHeader balance>
@ -46,35 +93,119 @@ export function JoinBeforeNavigate({
)} )}
</Box> </Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200"> <Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Text size="H3" truncate> <Text size="H4" truncate>
{roomIdOrAlias} {room?.name ?? t('Explore.join')}
</Text> </Text>
</Box> </Box>
</Box> </Box>
</PageHeader> </PageHeader>
<Box grow="Yes"> <Box grow="Yes">
<Scroll hideTrack visibility="Hover" size="0"> <Scroll hideTrack visibility="Hover" size="0">
<Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}> <RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
{(summary) => ( {(summary) => {
<RoomCard const avatarUrl = room
style={{ maxWidth: toRem(364), width: '100%' }} ? getRoomAvatarUrl(mx, room, 96, useAuthentication)
roomIdOrAlias={roomIdOrAlias} : (summary?.avatar_url &&
allRooms={allRooms} mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop')) ||
avatarUrl={summary?.avatar_url} undefined;
name={summary?.name} const name =
topic={summary?.topic} room?.name ?? summary?.name ?? getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
memberCount={summary?.num_joined_members} const topic = summary?.topic;
roomType={summary?.room_type} const memberCount = room?.getJoinedMemberCount() ?? summary?.num_joined_members;
viaServers={viaServers} const isSpace = room?.isSpaceRoom() || summary?.room_type === RoomType.Space;
renderTopicViewer={(name, topic, requestClose) => ( const errored = joinState.status === AsyncStatus.Error;
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
)} let primaryLabel = t('Explore.join');
onView={handleView} if (joining) primaryLabel = t('Explore.joining');
else if (invited) primaryLabel = t('Direct.invite_accept');
return (
<div className={css.Hero}>
<Avatar size="500" radii="500" style={{ width: toRem(84), height: toRem(84) }}>
<RoomAvatar
roomId={room?.roomId ?? roomIdOrAlias}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => (
<RoomIcon
size="600"
roomType={isSpace ? RoomType.Space : undefined}
filled
/> />
)} )}
</RoomSummaryLoader> />
</Avatar>
{invited && <span className={css.Eyebrow}>{t('Room.invite')}</span>}
<Box direction="Column" gap="200" alignItems="Center">
<Text size="H3" align="Center">
{name}
</Text>
{isSpace && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">{t('Explore.space_badge')}</Text>
</Badge>
)}
{typeof memberCount === 'number' && (
<Box gap="100" alignItems="Center">
<Icon size="50" src={Icons.User} />
<Text size="T200" as="span" className={css.MemberCount}>
{t('Explore.members_count', {
count: memberCount,
formattedCount: millify(memberCount),
})}
</Text>
</Box> </Box>
)}
</Box>
{topic && (
<Text size="T300" className={css.Topic} align="Center">
{topic}
</Text>
)}
<div className={css.Actions}>
{typeof joinedRoomId === 'string' ? (
<Button onClick={() => handleView(joinedRoomId)} variant="Primary" size="500">
<Text size="B500">{t('Explore.view')}</Text>
</Button>
) : (
<Button
onClick={join}
variant="Primary"
size="500"
disabled={joining}
before={joining && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B500">{primaryLabel}</Text>
</Button>
)}
{invited && typeof joinedRoomId !== 'string' && (
<Button
onClick={decline}
variant="Secondary"
fill="Soft"
size="500"
disabled={declining}
before={declining && <Spinner size="200" variant="Secondary" />}
>
<Text size="B500">{t('Direct.invite_decline')}</Text>
</Button>
)}
{errored && (
<Text size="T200" align="Center" style={{ color: color.Critical.Main }}>
{joinState.error.message || t('Explore.join_error_unknown')}
</Text>
)}
</div>
</div>
);
}}
</RoomSummaryLoader>
</Scroll> </Scroll>
</Box> </Box>
</Page> </Page>