import React, { ReactNode, useCallback, useRef, useState } from 'react'; import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { Avatar, Badge, Box, Button, Dialog, Icon, Icons, Overlay, OverlayBackdrop, OverlayCenter, Spinner, Text, as, color, config, } from 'folds'; import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; import * as css from './style.css'; import { RoomAvatar } from '../room-avatar'; import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { nameInitials } from '../../utils/common'; import { millify } from '../../plugins/millify'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { onEnterOrSpace, stopPropagation } from '../../utils/keyboard'; import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; type GridColumnCount = '1' | '2' | '3'; const getGridColumnCount = (gridWidth: number): GridColumnCount => { if (gridWidth <= 498) return '1'; if (gridWidth <= 748) return '2'; return '3'; }; const setGridColumnCount = (grid: HTMLElement, count: GridColumnCount): void => { grid.style.setProperty('grid-template-columns', `repeat(${count}, 1fr)`); }; export function RoomCardGrid({ children }: { children: ReactNode }) { const gridRef = useRef(null); useElementSizeObserver( useCallback(() => gridRef.current, []), useCallback((width, _, target) => setGridColumnCount(target, getGridColumnCount(width)), []), ); return ( {children} ); } export const RoomCardBase = as<'div'>(({ className, ...props }, ref) => ( )); export const RoomCardName = as<'h6'>(({ ...props }, ref) => ( )); export const RoomCardTopic = as<'p'>(({ className, ...props }, ref) => ( )); function ErrorDialog({ title, message, children, }: { title: string; message: string; children: (openError: () => void) => ReactNode; }) { const [viewError, setViewError] = useState(false); const closeError = () => setViewError(false); const openError = () => setViewError(true); return ( <> {children(openError)} }> {title} {message} ); } type RoomCardProps = { roomIdOrAlias: string; allRooms: string[]; avatarUrl?: string; name?: string; topic?: string; memberCount?: number; 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; }; // Map a room's join rule to a short preview chip. Public is the common case and // gets no chip (avoids noise); the rest tell the user how entry works. const joinRuleLabel = (joinRule?: string): string | undefined => { if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') return 'Restricted'; if (joinRule === JoinRule.Knock) return 'Ask to join'; if (joinRule === JoinRule.Invite) return 'Invite only'; if (joinRule === JoinRule.Private) return 'Private'; return undefined; }; export const RoomCard = as<'div', RoomCardProps>( ( { roomIdOrAlias, allRooms, avatarUrl, name, topic, memberCount, roomType, joinRule, encrypted, canonicalAlias, worldReadable, membership, hero, viaServers, onView, renderTopicViewer, ...props }, ref, ) => { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const joinedRoomId = useJoinedRoomId(allRooms, roomIdOrAlias); const joinedRoom = mx.getRoom(joinedRoomId); const [topicEvent, setTopicEvent] = useState(() => joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined, ); // 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; // 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( mx, useCallback( (event) => { if ( joinedRoom && event.getRoomId() === joinedRoom.roomId && event.getType() === StateEvent.RoomTopic ) { setTopicEvent(getStateEvent(joinedRoom, StateEvent.RoomTopic)); } }, [joinedRoom], ), ); const [joinState, join] = useAsyncCallback( useCallback( () => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers], ), ); const [knockState, knock] = useAsyncCallback<{ room_id: string }, MatrixError, []>( useCallback( () => mx.knockRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers], ), ); // 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; 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); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); return ( ( {nameInitials(roomName)} )} /> {(roomType === RoomType.Space || joinedRoom?.isSpaceRoom()) && ( Space )} {roomName} {aliasLine && ( {aliasLine} )} {roomTopic ? ( {roomTopic} ) : ( No description )} }> {renderTopicViewer(roomName, roomTopic ?? '', closeTopic)} {typeof joinedMemberCount === 'number' && ( {`${millify(joinedMemberCount)} Members`} )} {!joinedRoom && (chip || encrypted || worldReadable) && ( {chip && ( {chip} )} {encrypted && ( Encrypted )} {worldReadable && ( Readable )} )} {typeof joinedRoomId === 'string' && ( )} {typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && ( )} {typeof joinedRoomId !== 'string' && actionState.status === AsyncStatus.Error && ( {(openError) => ( )} )} ); }, );