From 0c6003fb87a628dd30d01e90af9ad3b34cb04b3a Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 7 Jul 2026 14:46:21 -0400 Subject: [PATCH] fix(room-preview): make the preview card actually populate + richer Two review agents found the preview card degrades to 'id + logo + Join' for real reasons. Fixes: - **via was dropped at the summary fetch** (root cause). RoomSummaryLoader now accepts + forwards `via` to getRoomSummary (and keys the query on it); JoinBeforeNavigate passes viaServers; the lobby Preview chip carries data-via and Lobby.handleOpenRoom appends it to the navigated URL. Without this, previews of rooms the HS isn't already in came back sparse/404. - **No loading/error state** -> RoomSummaryLoader now surfaces {loading,error}; JoinBeforeNavigate shows a Spinner while loading instead of the degraded card. - **Room id leaked as name AND topic AND header** -> stop using the raw `!id` as the topic fallback (show 'No description'); name falls back to canonical_alias then alias-localpart; the page header shows the summary name. - **Richer, membership-aware card**: forward canonical_alias (shown under the name), world_readable ('Readable' badge), and membership -> the button now shows Accept invite / Requested / Banned correctly instead of a Join that lies; a 'hero' layout for the single-preview page (primary Join button, unclamped topic). Removed dead `|| undefined ||`. IRoomSummary re-adds canonical_alias (the SDK type Omit<>s it though MSC3266 returns it). 738 tests pass, build clean. Co-Authored-By: Claude Opus 4.8 --- src/app/components/RoomSummaryLoader.tsx | 32 +++-- src/app/components/room-card/RoomCard.tsx | 78 +++++++++---- .../JoinBeforeNavigate.tsx | 109 ++++++++++-------- src/app/features/lobby/Lobby.tsx | 9 +- src/app/features/lobby/RoomItem.tsx | 7 +- 5 files changed, 157 insertions(+), 78 deletions(-) diff --git a/src/app/components/RoomSummaryLoader.tsx b/src/app/components/RoomSummaryLoader.tsx index efdce9300..986ab6ca9 100644 --- a/src/app/components/RoomSummaryLoader.tsx +++ b/src/app/components/RoomSummaryLoader.tsx @@ -6,24 +6,42 @@ import { useMatrixClient } from '../hooks/useMatrixClient'; import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary'; import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback'; -export type IRoomSummary = Awaited>; +// MSC3266 returns canonical_alias at runtime, but the SDK's RoomSummary type +// Omit<>s it — add it back so callers can use it. +export type IRoomSummary = Awaited> & { + canonical_alias?: string; +}; + +export type RoomSummaryState = { loading: boolean; error: unknown }; type RoomSummaryLoaderProps = { roomIdOrAlias: string; - children: (roomSummary?: IRoomSummary) => ReactNode; + /** + * Resident servers to route the summary request through. REQUIRED for a room + * the local homeserver isn't already in (the exact case a preview exists for) — + * without it the summary comes back sparse or 404s. + */ + via?: string[]; + children: (roomSummary: IRoomSummary | undefined, state: RoomSummaryState) => ReactNode; }; -export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) { +export function RoomSummaryLoader({ roomIdOrAlias, via, children }: RoomSummaryLoaderProps) { const mx = useMatrixClient(); - const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]); + const fetchSummary = useCallback( + () => mx.getRoomSummary(roomIdOrAlias, via), + [mx, roomIdOrAlias, via], + ); - const { data } = useQuery({ - queryKey: [roomIdOrAlias, `summary`], + const { data, isLoading, error } = useQuery({ + // `via` is part of the key so a no-via failure isn't reused once we have servers. + queryKey: [roomIdOrAlias, 'summary', via ?? []], queryFn: fetchSummary, + retry: 1, + staleTime: 5 * 60 * 1000, }); - return children(data); + return children(data, { loading: isLoading, error }); } export function LocalRoomSummaryLoader({ diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 22d557563..9683b97d1 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -141,6 +141,12 @@ type RoomCardProps = { roomType?: string; joinRule?: string; encrypted?: boolean; + canonicalAlias?: string; + worldReadable?: boolean; + /** The viewer's own membership over federation (leave/invite/knock/ban). */ + membership?: string; + /** Larger, centered treatment for the single-room preview page. */ + hero?: boolean; viaServers?: string[]; onView?: (roomId: string) => void; renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode; @@ -168,6 +174,10 @@ export const RoomCard = as<'div', RoomCardProps>( roomType, joinRule, encrypted, + canonicalAlias, + worldReadable, + membership, + hero, viaServers, onView, renderTopicViewer, @@ -183,16 +193,19 @@ export const RoomCard = as<'div', RoomCardProps>( joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined, ); - const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias; - const fallbackTopic = roomIdOrAlias; + // Name falls back to the alias (readable), then the alias localpart, then the + // raw id — never the raw `!id` when a nicer form exists. + const fallbackName = canonicalAlias || getMxIdLocalPart(roomIdOrAlias) || roomIdOrAlias; const avatar = joinedRoom ? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication) : avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop'); const roomName = joinedRoom?.name || name || fallbackName; - const roomTopic = - (topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic; + // No topic → render a muted placeholder, NOT the raw room id. + const roomTopic = (topicEvent?.getContent().topic as string) || topic || undefined; + // Only show an alias line when it adds info beyond the displayed name. + const aliasLine = canonicalAlias && canonicalAlias !== roomName ? canonicalAlias : undefined; const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount; useStateEventCallback( @@ -225,19 +238,22 @@ export const RoomCard = as<'div', RoomCardProps>( ); // A knock-rule room can't be joined directly — request to join instead. const isKnock = joinRule === JoinRule.Knock; + // Membership known from the summary survives reloads (unlike local knockState). + const invited = membership === 'invite'; + const alreadyKnocked = membership === 'knock'; + const banned = membership === 'ban'; const action = isKnock ? knock : join; const actionState = isKnock ? knockState : joinState; const acting = actionState.status === AsyncStatus.Loading || actionState.status === AsyncStatus.Success; - let actionLabel = acting ? 'Joining' : 'Join'; - if (isKnock) { - actionLabel = - knockState.status === AsyncStatus.Success - ? 'Requested' - : acting - ? 'Requesting' - : 'Request to join'; - } + const requested = alreadyKnocked || knockState.status === AsyncStatus.Success; + let actionLabel: string; + if (banned) actionLabel = 'Banned'; + else if (invited) actionLabel = acting ? 'Joining' : 'Accept invite'; + else if (isKnock) + actionLabel = requested ? 'Requested' : acting ? 'Requesting' : 'Request to join'; + else actionLabel = acting ? 'Joining' : 'Join'; + const actionDisabled = banned || requested || acting; const chip = joinRuleLabel(joinRule); const [viewTopic, setViewTopic] = useState(false); @@ -267,9 +283,25 @@ export const RoomCard = as<'div', RoomCardProps>( {roomName} - - {roomTopic} - + {aliasLine && ( + + {aliasLine} + + )} + {roomTopic ? ( + + {roomTopic} + + ) : ( + + No description + + )} }> @@ -281,7 +313,7 @@ export const RoomCard = as<'div', RoomCardProps>( escapeDeactivates: stopPropagation, }} > - {renderTopicViewer(roomName, roomTopic, closeTopic)} + {renderTopicViewer(roomName, roomTopic ?? '', closeTopic)} @@ -292,7 +324,7 @@ export const RoomCard = as<'div', RoomCardProps>( {`${millify(joinedMemberCount)} Members`} )} - {!joinedRoom && (chip || encrypted) && ( + {!joinedRoom && (chip || encrypted || worldReadable) && ( {chip && ( @@ -304,6 +336,11 @@ export const RoomCard = as<'div', RoomCardProps>( Encrypted )} + {worldReadable && ( + + Readable + + )} )} {typeof joinedRoomId === 'string' && ( @@ -321,9 +358,10 @@ export const RoomCard = as<'div', RoomCardProps>( {typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && (