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 { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import React, { useCallback } from 'react';
import {
Avatar,
Badge,
Box,
Button,
Icon,
IconButton,
Icons,
Scroll,
Spinner,
Text,
color,
toRem,
} from 'folds';
import { useAtomValue } from 'jotai';
import { RoomCard } from '../../components/room-card';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { MatrixError, Room } from 'matrix-js-sdk';
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 { RoomSummaryLoader } from '../../components/RoomSummaryLoader';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useJoinedRoomId } from '../../hooks/useJoinedRoomId';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
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[] };
export function JoinBeforeNavigate({
@ -17,11 +40,20 @@ export function JoinBeforeNavigate({
eventId,
viaServers,
}: JoinBeforeNavigateProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const allRooms = useAtomValue(allRoomsAtom);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const navigate = useNavigate();
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) => {
if (mx.getRoom(roomId)?.isSpaceRoom()) {
navigateSpace(roomId);
@ -30,6 +62,21 @@ export function JoinBeforeNavigate({
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 (
<Page>
<PageHeader balance>
@ -46,35 +93,119 @@ export function JoinBeforeNavigate({
)}
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Text size="H3" truncate>
{roomIdOrAlias}
<Text size="H4" truncate>
{room?.name ?? t('Explore.join')}
</Text>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover" size="0">
<Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
{(summary) => (
<RoomCard
style={{ maxWidth: toRem(364), width: '100%' }}
roomIdOrAlias={roomIdOrAlias}
allRooms={allRooms}
avatarUrl={summary?.avatar_url}
name={summary?.name}
topic={summary?.topic}
memberCount={summary?.num_joined_members}
roomType={summary?.room_type}
viaServers={viaServers}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
{(summary) => {
const avatarUrl = room
? getRoomAvatarUrl(mx, room, 96, useAuthentication)
: (summary?.avatar_url &&
mxcUrlToHttp(mx, summary.avatar_url, useAuthentication, 96, 96, 'crop')) ||
undefined;
const name =
room?.name ?? summary?.name ?? getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
const topic = summary?.topic;
const memberCount = room?.getJoinedMemberCount() ?? summary?.num_joined_members;
const isSpace = room?.isSpaceRoom() || summary?.room_type === RoomType.Space;
const errored = joinState.status === AsyncStatus.Error;
let primaryLabel = t('Explore.join');
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
/>
)}
/>
</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>
{topic && (
<Text size="T300" className={css.Topic} align="Center">
{topic}
</Text>
)}
onView={handleView}
/>
)}
</RoomSummaryLoader>
</Box>
<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>
</Box>
</Page>