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 <noreply@anthropic.com>
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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}>
|
||||
{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,6 +32,9 @@ export function JoinBeforeNavigate({
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias} via={viaServers}>
|
||||
{(summary, state) => (
|
||||
<>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box shrink="No">
|
||||
@@ -47,27 +50,37 @@ export function JoinBeforeNavigate({
|
||||
</Box>
|
||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
{roomIdOrAlias}
|
||||
{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">
|
||||
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
|
||||
{(summary) => (
|
||||
<Box
|
||||
style={{ height: '100%' }}
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
{state.loading && !summary ? (
|
||||
<Spinner size="600" variant="Secondary" />
|
||||
) : (
|
||||
<RoomCard
|
||||
style={{ maxWidth: toRem(364), width: '100%' }}
|
||||
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} />
|
||||
@@ -75,10 +88,12 @@ export function JoinBeforeNavigate({
|
||||
onView={handleView}
|
||||
/>
|
||||
)}
|
||||
</RoomSummaryLoader>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</RoomSummaryLoader>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user