diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.css.ts b/src/app/features/join-before-navigate/JoinBeforeNavigate.css.ts new file mode 100644 index 00000000..55ce1eed --- /dev/null +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.css.ts @@ -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, +}); diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 028cd560..e539f3da 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -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( + useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + ); + const joining = + joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; + + const [declineState, decline] = useAsyncCallback( + useCallback(async () => { + if (room) await mx.leave(room.roomId); + navigate('/'); + }, [mx, room, navigate]) + ); + const declining = + declineState.status === AsyncStatus.Loading || declineState.status === AsyncStatus.Success; + return ( @@ -46,35 +93,119 @@ export function JoinBeforeNavigate({ )} - - {roomIdOrAlias} + + {room?.name ?? t('Explore.join')} - - - {(summary) => ( - ( - + + {(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 ( +
+ + ( + + )} + /> + + + {invited && {t('Room.invite')}} + + + + {name} + + {isSpace && ( + + {t('Explore.space_badge')} + + )} + {typeof memberCount === 'number' && ( + + + + {t('Explore.members_count', { + count: memberCount, + formattedCount: millify(memberCount), + })} + + + )} + + + {topic && ( + + {topic} + )} - onView={handleView} - /> - )} - - + +
+ {typeof joinedRoomId === 'string' ? ( + + ) : ( + + )} + + {invited && typeof joinedRoomId !== 'string' && ( + + )} + + {errored && ( + + {joinState.error.message || t('Explore.join_error_unknown')} + + )} +
+
+ ); + }} +