fix(room-preview): make the preview card actually populate + richer
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 14s

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 14:46:21 -04:00
parent 7fd3164b1f
commit 0c6003fb87
5 changed files with 157 additions and 78 deletions
+25 -7
View File
@@ -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<ReturnType<MatrixClient['getRoomSummary']>>;
// 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<ReturnType<MatrixClient['getRoomSummary']>> & {
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({
+58 -20
View File
@@ -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>(
</Box>
<Box grow="Yes" direction="Column" gap="100">
<RoomCardName>{roomName}</RoomCardName>
<RoomCardTopic onClick={openTopic} onKeyDown={onEnterOrSpace(openTopic)} tabIndex={0}>
{roomTopic}
</RoomCardTopic>
{aliasLine && (
<Text size="T200" priority="300" truncate>
{aliasLine}
</Text>
)}
{roomTopic ? (
<RoomCardTopic
onClick={openTopic}
onKeyDown={onEnterOrSpace(openTopic)}
tabIndex={0}
style={hero ? { WebkitLineClamp: 10 } : undefined}
>
{roomTopic}
</RoomCardTopic>
) : (
<Text size="T200" priority="300" style={{ opacity: 0.6 }}>
No description
</Text>
)}
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -281,7 +313,7 @@ export const RoomCard = as<'div', RoomCardProps>(
escapeDeactivates: stopPropagation,
}}
>
{renderTopicViewer(roomName, roomTopic, closeTopic)}
{renderTopicViewer(roomName, roomTopic ?? '', closeTopic)}
</FocusTrap>
</OverlayCenter>
</Overlay>
@@ -292,7 +324,7 @@ export const RoomCard = as<'div', RoomCardProps>(
<Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
</Box>
)}
{!joinedRoom && (chip || encrypted) && (
{!joinedRoom && (chip || encrypted || worldReadable) && (
<Box gap="100" wrap="Wrap">
{chip && (
<Badge variant="Secondary" fill="Soft" outlined>
@@ -304,6 +336,11 @@ export const RoomCard = as<'div', RoomCardProps>(
<Text size="L400">Encrypted</Text>
</Badge>
)}
{worldReadable && (
<Badge variant="Secondary" fill="Soft" outlined>
<Text size="L400">Readable</Text>
</Badge>
)}
</Box>
)}
{typeof joinedRoomId === 'string' && (
@@ -321,9 +358,10 @@ export const RoomCard = as<'div', RoomCardProps>(
{typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && (
<Button
onClick={action}
variant="Secondary"
variant={hero ? 'Primary' : 'Secondary'}
fill={hero ? 'Solid' : undefined}
size="300"
disabled={acting}
disabled={actionDisabled}
before={acting && <Spinner size="50" variant="Secondary" fill="Soft" />}
>
<Text size="B300" truncate>
@@ -1,5 +1,5 @@
import React from 'react';
import { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, toRem } from 'folds';
import { useAtomValue } from 'jotai';
import { RoomCard } from '../../components/room-card';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
@@ -32,53 +32,68 @@ export function JoinBeforeNavigate({
return (
<Page>
<PageHeader balance>
<Box grow="Yes" gap="200">
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Text size="H3" truncate>
{roomIdOrAlias}
</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}
joinRule={summary?.join_rule}
encrypted={!!summary?.['im.nheko.summary.encryption']}
viaServers={viaServers}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias} via={viaServers}>
{(summary, state) => (
<>
<PageHeader balance>
<Box grow="Yes" gap="200">
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton onClick={onBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
onView={handleView}
/>
)}
</RoomSummaryLoader>
</Box>
</Scroll>
</Box>
</Box>
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
<Text size="H3" truncate>
{summary?.name || summary?.canonical_alias || roomIdOrAlias}
</Text>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover" size="0">
<Box
style={{ height: '100%' }}
grow="Yes"
alignItems="Center"
justifyContent="Center"
>
{state.loading && !summary ? (
<Spinner size="600" variant="Secondary" />
) : (
<RoomCard
hero
style={{ maxWidth: toRem(420), width: '100%' }}
roomIdOrAlias={roomIdOrAlias}
allRooms={allRooms}
avatarUrl={summary?.avatar_url}
name={summary?.name}
canonicalAlias={summary?.canonical_alias}
topic={summary?.topic}
memberCount={summary?.num_joined_members}
roomType={summary?.room_type}
joinRule={summary?.join_rule}
encrypted={!!summary?.['im.nheko.summary.encryption']}
worldReadable={summary?.world_readable}
membership={summary?.membership}
viaServers={viaServers}
renderTopicViewer={(name, topic, requestClose) => (
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
)}
onView={handleView}
/>
)}
</Box>
</Scroll>
</Box>
</>
)}
</RoomSummaryLoader>
</Page>
);
}
+7 -2
View File
@@ -36,7 +36,7 @@ import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils';
import { getSpaceRoomPath, withSearchParam } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
@@ -411,8 +411,13 @@ export function Lobby() {
const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
const rId = evt.currentTarget.getAttribute('data-room-id');
if (!rId) return;
// Preview chips carry the room's resident servers so getRoomSummary can
// resolve a room the homeserver isn't already in (else the preview is sparse).
const via = evt.currentTarget.getAttribute('data-via');
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
let path = getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId));
if (via) path = withSearchParam(path, { viaServers: via });
navigate(path);
};
const togglePinToSidebar = useCallback(
+5 -2
View File
@@ -102,14 +102,17 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
// room isn't joined — renders the preview card instead of the timeline.
function RoomPreviewChip({
roomId,
via,
onOpen,
}: {
roomId: string;
via?: string[];
onOpen: MouseEventHandler<HTMLButtonElement>;
}) {
return (
<Chip
data-room-id={roomId}
data-via={via && via.length > 0 ? via.join(',') : undefined}
onClick={onOpen}
variant="Secondary"
fill="None"
@@ -391,7 +394,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
</Box>
) : (
<Box shrink="No" gap="100" alignItems="Center">
<RoomPreviewChip roomId={roomId} onOpen={onOpen} />
<RoomPreviewChip roomId={roomId} via={content.via} onOpen={onOpen} />
<RoomJoinButton roomId={roomId} via={content.via} />
</Box>
)
@@ -439,7 +442,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
joinRule={summary.join_rule}
options={
<Box shrink="No" gap="100" alignItems="Center">
<RoomPreviewChip roomId={roomId} onOpen={onOpen} />
<RoomPreviewChip roomId={roomId} via={content.via} onOpen={onOpen} />
<RoomJoinButton roomId={roomId} via={content.via} />
</Box>
}