From f2e3223c5fb1fbf638e6229f6eca10ab2e4cf272 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:06:12 +0100 Subject: [PATCH 01/40] Delete the rail, its tabs, useSidebarItems, steward-groups and the Home, Direct and Space list panes The navigator that replaces them lands next. SpaceTombstone moves to its own file; the Lobby pin-to-sidebar state and the m.cinny.spaces writer go with the rail; ClientLayout loses its nav slot. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/lobby/HierarchyItemMenu.tsx | 18 - src/app/features/lobby/Lobby.tsx | 34 - src/app/features/lobby/SpaceHierarchy.tsx | 6 - .../steward-groups/stewardGroups.test.ts | 63 -- .../features/steward-groups/stewardGroups.ts | 91 -- .../steward-groups/useStewardGroups.ts | 71 -- src/app/hooks/useSidebarItems.ts | 138 --- src/app/pages/MobileFriendly.tsx | 22 - src/app/pages/client/ClientLayout.tsx | 13 +- src/app/pages/client/SidebarNav.tsx | 56 -- src/app/pages/client/direct/Direct.tsx | 280 ------ src/app/pages/client/direct/index.ts | 1 - src/app/pages/client/home/Home.tsx | 362 -------- src/app/pages/client/home/index.ts | 1 - src/app/pages/client/sidebar/CreateTab.tsx | 134 --- src/app/pages/client/sidebar/DirectTab.tsx | 138 --- src/app/pages/client/sidebar/ExploreTab.tsx | 64 -- src/app/pages/client/sidebar/HomeTab.tsx | 140 --- src/app/pages/client/sidebar/SearchTab.tsx | 23 - src/app/pages/client/sidebar/SpaceTabs.tsx | 866 ------------------ src/app/pages/client/sidebar/index.ts | 5 - src/app/pages/client/space/Space.tsx | 571 ------------ src/app/pages/client/space/SpaceTombstone.tsx | 79 ++ src/app/pages/client/space/index.ts | 2 +- src/types/matrix/accountData.ts | 1 - src/types/matrix/room.ts | 1 + 26 files changed, 83 insertions(+), 3097 deletions(-) delete mode 100644 src/app/features/steward-groups/stewardGroups.test.ts delete mode 100644 src/app/features/steward-groups/stewardGroups.ts delete mode 100644 src/app/features/steward-groups/useStewardGroups.ts delete mode 100644 src/app/hooks/useSidebarItems.ts delete mode 100644 src/app/pages/client/SidebarNav.tsx delete mode 100644 src/app/pages/client/direct/Direct.tsx delete mode 100644 src/app/pages/client/home/Home.tsx delete mode 100644 src/app/pages/client/sidebar/CreateTab.tsx delete mode 100644 src/app/pages/client/sidebar/DirectTab.tsx delete mode 100644 src/app/pages/client/sidebar/ExploreTab.tsx delete mode 100644 src/app/pages/client/sidebar/HomeTab.tsx delete mode 100644 src/app/pages/client/sidebar/SearchTab.tsx delete mode 100644 src/app/pages/client/sidebar/SpaceTabs.tsx delete mode 100644 src/app/pages/client/space/Space.tsx create mode 100644 src/app/pages/client/space/SpaceTombstone.tsx diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index c6aff741..1802e3b0 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -200,16 +200,12 @@ type HierarchyItemMenuProps = { joined: boolean; powerLevels?: IPowerLevels; canEditChild: boolean; - pinned?: boolean; - onTogglePin?: (roomId: string) => void; }; export function HierarchyItemMenu({ item, joined, powerLevels, canEditChild, - pinned, - onTogglePin, }: HierarchyItemMenuProps) { const mx = useMatrixClient(); const [menuAnchor, setMenuAnchor] = useState(); @@ -264,20 +260,6 @@ export function HierarchyItemMenu({ {joined && ( - {onTogglePin && ( - { - onTogglePin(item.roomId); - handleRequestClose(); - }} - > - - {pinned ? 'Unpin from Sidebar' : 'Pin to Sidebar'} - - - )} { - const sideSpaces = sidebarItems.flatMap((item) => { - if (typeof item === 'string') return item; - return item.content; - }); - - return new Set(sideSpaces); - }, [sidebarItems]); - const [spacesItems, setSpacesItem] = useState>(() => new Map()); useElementSizeObserver( @@ -415,18 +395,6 @@ export function Lobby() { navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId))); }; - const togglePinToSidebar = useCallback( - (rId: string) => { - const newItems = sidebarItemWithout(sidebarItems, rId); - if (!sidebarSpaces.has(rId)) { - newItems.push(rId); - } - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); - }, - [mx, sidebarItems, sidebarSpaces] - ); - return ( @@ -499,8 +467,6 @@ export function Lobby() { disabledReorder={reordering} nextSpaceId={nextSpaceId} getRoom={getRoom} - pinned={sidebarSpaces.has(item.space.roomId)} - togglePinToSidebar={togglePinToSidebar} onSpacesFound={handleSpacesFound} onOpenRoom={handleOpenRoom} /> diff --git a/src/app/features/lobby/SpaceHierarchy.tsx b/src/app/features/lobby/SpaceHierarchy.tsx index 280b8a5a..9a283378 100644 --- a/src/app/features/lobby/SpaceHierarchy.tsx +++ b/src/app/features/lobby/SpaceHierarchy.tsx @@ -35,8 +35,6 @@ type SpaceHierarchyProps = { disabledReorder?: boolean; nextSpaceId?: string; getRoom: (roomId: string) => Room | undefined; - pinned: boolean; - togglePinToSidebar: (roomId: string) => void; onSpacesFound: (spaceItems: IHierarchyRoom[]) => void; onOpenRoom: MouseEventHandler; }; @@ -58,8 +56,6 @@ export const SpaceHierarchy = forwardRef( disabledReorder, nextSpaceId, getRoom, - pinned, - togglePinToSidebar, onOpenRoom, onSpacesFound, }, @@ -136,8 +132,6 @@ export const SpaceHierarchy = forwardRef( canEditChild={ !!parentPermissions?.stateEvent(StateEvent.SpaceChild, mx.getSafeUserId()) } - pinned={pinned} - onTogglePin={togglePinToSidebar} /> ) } diff --git a/src/app/features/steward-groups/stewardGroups.test.ts b/src/app/features/steward-groups/stewardGroups.test.ts deleted file mode 100644 index d0d4b7c3..00000000 --- a/src/app/features/steward-groups/stewardGroups.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { groupByStewards, parseSteward, StewardEntry } from './stewardGroups'; - -const room = (roomId: string, parentId = '!projects') => ({ roomId, parentId }); -const space = (roomId: string) => ({ roomId, space: true as const }); - -const base = { - spaceId: '!top', - isGroupOpen: () => true, - keepVisible: () => false, -}; - -const names = (out: StewardEntry<{ roomId: string }>[]) => - out.map((e) => (e.type === 'group' ? `G:${e.label}:${e.count}` : e.item.roomId)); - -describe('groupByStewards', () => { - const stewards: Record = { '!a': 'Void', '!b': 'Eureka', '!c': 'Void' }; - const stewardOf = (id: string) => stewards[id]; - - it('groups by steward, sorted by name, with unstewarded rooms first', () => { - const out = groupByStewards([room('!a'), room('!x'), room('!b'), room('!c')], { - ...base, - stewardOf, - }); - expect(names(out)).toEqual(['!x', 'G:Eureka:1', '!b', 'G:Void:2', '!a', '!c']); - }); - - it('collapsed groups hide rooms except kept-visible ones', () => { - const out = groupByStewards([room('!a'), room('!b'), room('!c')], { - ...base, - stewardOf, - isGroupOpen: () => false, - keepVisible: (id) => id === '!c', - }); - expect(names(out)).toEqual(['G:Eureka:1', 'G:Void:2', '!c']); - }); - - it('degrades to the original order when no room has steward state', () => { - const out = groupByStewards([room('!1'), space('!s'), room('!2', '!s')], { - ...base, - stewardOf: () => undefined, - }); - expect(names(out)).toEqual(['!1', '!s', '!2']); - }); - - it('does not merge runs separated by a space header', () => { - const out = groupByStewards([room('!a'), space('!s'), room('!c', '!s')], { - ...base, - stewardOf, - }); - expect(out.filter((e) => e.type === 'group')).toHaveLength(2); - }); -}); - -describe('parseSteward', () => { - it('accepts a string and rejects junk', () => { - expect(parseSteward({ steward: ' Void ' })).toBe('Void'); - expect(parseSteward({ steward: 3 })).toBeUndefined(); - expect(parseSteward({ steward: ' ' })).toBeUndefined(); - expect(parseSteward(undefined)).toBeUndefined(); - expect(parseSteward(null)).toBeUndefined(); - }); -}); diff --git a/src/app/features/steward-groups/stewardGroups.ts b/src/app/features/steward-groups/stewardGroups.ts deleted file mode 100644 index c95dd935..00000000 --- a/src/app/features/steward-groups/stewardGroups.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * Splits runs of rooms that carry an `org.gamecult.steward` state event into - * collapsible per-steward groups. Pure: the caller supplies the lookups, so the - * grouping is testable without a Matrix client. - */ -export const STEWARD_EVENT_TYPE = 'org.gamecult.steward'; - -export type HierarchyLike = { roomId: string; parentId?: string; space?: true }; - -export type StewardEntry = - | { type: 'item'; item: T } - | { type: 'group'; id: string; label: string; open: boolean; count: number }; - -export const makeStewardGroupId = (spaceId: string, parentId: string, steward: string) => - `${spaceId}|${parentId}|steward:${steward}`; - -export type StewardGroupOptions = { - spaceId: string; - /** steward name of a room, or undefined when the room has no (valid) steward state */ - stewardOf: (roomId: string) => string | undefined; - isGroupOpen: (groupId: string) => boolean; - /** rooms that stay visible inside a collapsed group (selected, unread, in a call) */ - keepVisible: (roomId: string) => boolean; -}; - -const byLabel = (a: string, b: string) => - a.localeCompare(b, undefined, { sensitivity: 'base' }) || a.localeCompare(b); - -export const groupByStewards = ( - items: T[], - { spaceId, stewardOf, isGroupOpen, keepVisible }: StewardGroupOptions -): StewardEntry[] => { - const out: StewardEntry[] = []; - - const flush = (run: T[]) => { - if (run.length === 0) return; - const groups = new Map(); - const loose: T[] = []; - run.forEach((item) => { - const steward = stewardOf(item.roomId); - if (steward === undefined) { - loose.push(item); - return; - } - const list = groups.get(steward); - if (list) list.push(item); - else groups.set(steward, [item]); - }); - - // No steward state anywhere in this run: leave the list exactly as Cinny built it. - if (groups.size === 0) { - run.forEach((item) => out.push({ type: 'item', item })); - return; - } - - loose.forEach((item) => out.push({ type: 'item', item })); - Array.from(groups.keys()) - .sort(byLabel) - .forEach((steward) => { - const rooms = groups.get(steward) ?? []; - const id = makeStewardGroupId(spaceId, rooms[0].parentId ?? spaceId, steward); - const open = isGroupOpen(id); - out.push({ type: 'group', id, label: steward, open, count: rooms.length }); - rooms - .filter((item) => open || keepVisible(item.roomId)) - .forEach((item) => out.push({ type: 'item', item })); - }); - }; - - let run: T[] = []; - items.forEach((item) => { - if (item.space) { - flush(run); - run = []; - out.push({ type: 'item', item }); - return; - } - run.push(item); - }); - flush(run); - return out; -}; - -/** Reads `steward` from raw state event content; anything unusable is "no steward". */ -export const parseSteward = (content: unknown): string | undefined => { - if (typeof content !== 'object' || content === null) return undefined; - const { steward } = content as { steward?: unknown }; - if (typeof steward !== 'string') return undefined; - const trimmed = steward.trim(); - return trimmed === '' ? undefined : trimmed; -}; diff --git a/src/app/features/steward-groups/useStewardGroups.ts b/src/app/features/steward-groups/useStewardGroups.ts deleted file mode 100644 index ae1d2a3d..00000000 --- a/src/app/features/steward-groups/useStewardGroups.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; -import { atomWithStorage } from 'jotai/utils'; -import { useAtom } from 'jotai'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { - groupByStewards, - HierarchyLike, - parseSteward, - StewardEntry, - STEWARD_EVENT_TYPE, -} from './stewardGroups'; - -// Explicit open/closed choices by group id. Absent = default: closed on phones, open elsewhere. -const stewardGroupChoicesAtom = atomWithStorage>( - 'gamecultStewardGroupOpen', - {} -); - -type Options = { - mx: MatrixClient; - spaceId: string; - items: T[]; - keepVisible: (roomId: string) => boolean; -}; - -export const useStewardGroups = ({ - mx, - spaceId, - items, - keepVisible, -}: Options) => { - const mobile = useScreenSizeContext() === ScreenSize.Mobile; - const [choices, setChoices] = useAtom(stewardGroupChoicesAtom); - const [stateVersion, setStateVersion] = useState(0); - - useEffect(() => { - const onState = (mEvent: MatrixEvent) => { - if (mEvent.getType() === STEWARD_EVENT_TYPE) setStateVersion((v) => v + 1); - }; - mx.on(RoomStateEvent.Events, onState); - return () => { - mx.removeListener(RoomStateEvent.Events, onState); - }; - }, [mx]); - - const stewardOf = useCallback( - (roomId: string) => - // stateVersion is read so a late-arriving steward event re-derives this lookup - stateVersion >= 0 - ? parseSteward( - mx.getRoom(roomId)?.currentState.getStateEvents(STEWARD_EVENT_TYPE, '')?.getContent() - ) - : undefined, - [mx, stateVersion] - ); - - const isGroupOpen = useCallback((id: string) => choices[id] ?? !mobile, [choices, mobile]); - - const entries: StewardEntry[] = useMemo( - () => groupByStewards(items, { spaceId, stewardOf, isGroupOpen, keepVisible }), - [items, spaceId, stewardOf, isGroupOpen, keepVisible] - ); - - const toggleGroup = useCallback( - (id: string) => setChoices((prev) => ({ ...prev, [id]: !(prev[id] ?? !mobile) })), - [setChoices, mobile] - ); - - return { entries, toggleGroup }; -}; diff --git a/src/app/hooks/useSidebarItems.ts b/src/app/hooks/useSidebarItems.ts deleted file mode 100644 index 16aba225..00000000 --- a/src/app/hooks/useSidebarItems.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Dispatch, SetStateAction, useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; -import { AccountDataEvent } from '../../types/matrix/accountData'; -import { useMatrixClient } from './useMatrixClient'; -import { getAccountData, isSpace } from '../utils/room'; -import { Membership } from '../../types/matrix/room'; -import { useAccountDataCallback } from './useAccountDataCallback'; - -export type ISidebarFolder = { - name?: string; - id: string; - content: string[]; -}; -export type TSidebarItem = string | ISidebarFolder; -export type SidebarItems = Array; - -export type InCinnySpacesContent = { - shortcut?: string[]; - sidebar?: SidebarItems; -}; - -export const parseSidebar = ( - mx: MatrixClient, - orphanSpaces: string[], - content?: InCinnySpacesContent -) => { - const sidebar = content?.sidebar ?? content?.shortcut ?? []; - const orphans = new Set(orphanSpaces); - - const items: SidebarItems = []; - - const safeToAdd = (spaceId: string): boolean => { - if (typeof spaceId !== 'string') return false; - const space = mx.getRoom(spaceId); - if (space?.getMyMembership() !== Membership.Join) return false; - return isSpace(space); - }; - - sidebar.forEach((item) => { - if (typeof item === 'string') { - if (safeToAdd(item) && !items.includes(item)) { - orphans.delete(item); - items.push(item); - } - return; - } - if ( - typeof item === 'object' && - typeof item.id === 'string' && - Array.isArray(item.content) && - !items.find((i) => (typeof i === 'string' ? false : i.id === item.id)) - ) { - const safeContent = item.content.filter(safeToAdd); - safeContent.forEach((i) => orphans.delete(i)); - items.push({ - ...item, - content: Array.from(new Set(safeContent)), - }); - } - }); - - orphans.forEach((spaceId) => items.push(spaceId)); - return items; -}; - -export const useSidebarItems = ( - orphanSpaces: string[] -): [SidebarItems, Dispatch>] => { - const mx = useMatrixClient(); - - const [sidebarItems, setSidebarItems] = useState(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - return parseSidebar(mx, orphanSpaces, inCinnySpacesContent); - }); - - useEffect(() => { - const inCinnySpacesContent = getAccountData( - mx, - AccountDataEvent.CinnySpaces - )?.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, inCinnySpacesContent)); - }, [mx, orphanSpaces]); - - useAccountDataCallback( - mx, - useCallback( - (mEvent) => { - if (mEvent.getType() === AccountDataEvent.CinnySpaces) { - const newContent = mEvent.getContent(); - setSidebarItems(parseSidebar(mx, orphanSpaces, newContent)); - } - }, - [mx, orphanSpaces] - ) - ); - - return [sidebarItems, setSidebarItems]; -}; - -export const sidebarItemWithout = (items: SidebarItems, roomId: string) => { - const newItems: SidebarItems = items - .map((item) => { - if (typeof item === 'string') { - if (item === roomId) return null; - return item; - } - if (item.content.includes(roomId)) { - const newContent = item.content.filter((id) => id !== roomId); - if (newContent.length === 0) return null; - return { - ...item, - content: newContent, - }; - } - return item; - }) - .filter((item) => item !== null) as SidebarItems; - - return newItems; -}; - -export const makeCinnySpacesContent = ( - mx: MatrixClient, - items: SidebarItems -): InCinnySpacesContent => { - const currentInSpaces = - getAccountData(mx, AccountDataEvent.CinnySpaces)?.getContent() ?? {}; - - const newSpacesContent: InCinnySpacesContent = { - ...currentInSpaces, - sidebar: items, - }; - - return newSpacesContent; -}; diff --git a/src/app/pages/MobileFriendly.tsx b/src/app/pages/MobileFriendly.tsx index ca947ac6..225b1b80 100644 --- a/src/app/pages/MobileFriendly.tsx +++ b/src/app/pages/MobileFriendly.tsx @@ -1,28 +1,6 @@ import { ReactNode } from 'react'; import { useMatch } from 'react-router-dom'; import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize'; -import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths'; - -type MobileFriendlyClientNavProps = { - children: ReactNode; -}; -export function MobileFriendlyClientNav({ children }: MobileFriendlyClientNavProps) { - const screenSize = useScreenSizeContext(); - const homeMatch = useMatch({ path: HOME_PATH, caseSensitive: true, end: true }); - const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: true }); - const spaceMatch = useMatch({ path: SPACE_PATH, caseSensitive: true, end: true }); - const exploreMatch = useMatch({ path: EXPLORE_PATH, caseSensitive: true, end: true }); - const inboxMatch = useMatch({ path: INBOX_PATH, caseSensitive: true, end: true }); - - if ( - screenSize === ScreenSize.Mobile && - !(homeMatch || directMatch || spaceMatch || exploreMatch || inboxMatch) - ) { - return null; - } - - return children; -} type MobileFriendlyPageNavProps = { path: string; diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 4a077ba6..e1d83c95 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -1,15 +1,6 @@ import React, { ReactNode } from 'react'; import { Box } from 'folds'; -type ClientLayoutProps = { - nav: ReactNode; - children: ReactNode; -}; -export function ClientLayout({ nav, children }: ClientLayoutProps) { - return ( - - {nav} - {children} - - ); +export function ClientLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/src/app/pages/client/SidebarNav.tsx b/src/app/pages/client/SidebarNav.tsx deleted file mode 100644 index a2e1b682..00000000 --- a/src/app/pages/client/SidebarNav.tsx +++ /dev/null @@ -1,56 +0,0 @@ -import React, { useRef } from 'react'; -import { Scroll } from 'folds'; - -import { - Sidebar, - SidebarContent, - SidebarStackSeparator, - SidebarStack, -} from '../../components/sidebar'; -import { - DirectTab, - HomeTab, - SpaceTabs, - InboxTab, - ExploreTab, - SettingsTab, - UnverifiedTab, - SearchTab, -} from './sidebar'; -import { CreateTab } from './sidebar/CreateTab'; - -export function SidebarNav() { - const scrollRef = useRef(null); - - return ( - - - - - - - - - - - - - - } - sticky={ - <> - - - - - - - - - } - /> - - ); -} diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx deleted file mode 100644 index 04edf109..00000000 --- a/src/app/pages/client/direct/Direct.tsx +++ /dev/null @@ -1,280 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { factoryRoomIdByActivity } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, -} from '../../../components/nav'; -import { getDirectCreatePath, getDirectRoomPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useDirectRooms } from './useDirectRooms'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const orphanRooms = useDirectRooms(); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function DirectHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Direct Messages - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function DirectEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Direct Messages - - } - content={ - - You do not have any direct messages yet. - - } - options={ - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('direct', 'direct'); -export function Direct() { - const mx = useMatrixClient(); - useNavToActivePathMapper('direct'); - const scrollRef = useRef(null); - const directs = useDirectRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const createDirectSelected = useDirectCreateSelected(); - - const selectedRoomId = useSelectedRoom(); - const noRoomToDisplay = directs.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedDirects = useMemo(() => { - const items = Array.from(directs).sort(factoryRoomIdByActivity(mx)); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, directs, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedDirects.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getDirectCreatePath())}> - - - - - - - - Create Chat - - - - - - - - - - - Chats - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedDirects[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/direct/index.ts b/src/app/pages/client/direct/index.ts index d247bbc0..aaa8c929 100644 --- a/src/app/pages/client/direct/index.ts +++ b/src/app/pages/client/direct/index.ts @@ -1,3 +1,2 @@ -export * from './Direct'; export * from './RoomProvider'; export * from './DirectCreate'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx deleted file mode 100644 index d87db963..00000000 --- a/src/app/pages/client/home/Home.tsx +++ /dev/null @@ -1,362 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { useAtom, useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort'; -import { - NavButton, - NavCategory, - NavCategoryHeader, - NavEmptyCenter, - NavEmptyLayout, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { - encodeSearchParamValueArray, - getExplorePath, - getHomeCreatePath, - getHomeRoomPath, - getHomeSearchPath, - withSearchParam, -} from '../../pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useHomeCreateSelected, - useHomeSearchSelected, -} from '../../../hooks/router/useHomeSelected'; -import { useHomeRooms } from './useHomeRooms'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { markAsRead } from '../../../utils/notifications'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -function HomeHeader() { - const [menuAnchor, setMenuAnchor] = useState(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - Home - - - - - - - - - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - - ); -} - -function HomeEmpty() { - const navigate = useNavigate(); - - return ( - - } - title={ - - No Rooms - - } - content={ - - You do not have any rooms yet. - - } - options={ - <> - - - - } - /> - - ); -} - -const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room'); -export function Home() { - const mx = useMatrixClient(); - useNavToActivePathMapper('home'); - const scrollRef = useRef(null); - const rooms = useHomeRooms(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const navigate = useNavigate(); - - const selectedRoomId = useSelectedRoom(); - const createRoomSelected = useHomeCreateSelected(); - const searchSelected = useHomeSearchSelected(); - const noRoomToDisplay = rooms.length === 0; - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const sortedRooms = useMemo(() => { - const items = Array.from(rooms).sort( - closedCategories.has(DEFAULT_CATEGORY_ID) - ? factoryRoomIdByActivity(mx) - : factoryRoomIdByAtoZ(mx) - ); - if (closedCategories.has(DEFAULT_CATEGORY_ID)) { - return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId); - } - return items; - }, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]); - - const virtualizer = useVirtualizer({ - count: sortedRooms.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 38, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - return ( - - - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getHomeCreatePath())}> - - - - - - - - Create Room - - - - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - - Join with Address - - - - - - - {open && ( - setOpen(false)} - onOpen={(roomIdOrAlias, viaServers, eventId) => { - setOpen(false); - const path = getHomeRoomPath(roomIdOrAlias, eventId); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - - - - - - - - - Message Search - - - - - - - - - - - Rooms - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = sortedRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - - - ); - })} -
-
-
-
- )} -
- ); -} diff --git a/src/app/pages/client/home/index.ts b/src/app/pages/client/home/index.ts index 80764481..5669f658 100644 --- a/src/app/pages/client/home/index.ts +++ b/src/app/pages/client/home/index.ts @@ -1,3 +1,2 @@ -export * from './Home'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/app/pages/client/sidebar/CreateTab.tsx b/src/app/pages/client/sidebar/CreateTab.tsx deleted file mode 100644 index e6575cb4..00000000 --- a/src/app/pages/client/sidebar/CreateTab.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import React, { MouseEventHandler, useState } from 'react'; -import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useNavigate } from 'react-router-dom'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { stopPropagation } from '../../../utils/keyboard'; -import { SequenceCard } from '../../../components/sequence-card'; -import { SettingTile } from '../../../components/setting-tile'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { - encodeSearchParamValueArray, - getCreatePath, - getSpacePath, - withSearchParam, -} from '../../pathUtils'; -import { useCreateSelected } from '../../../hooks/router/useCreateSelected'; -import { JoinAddressPrompt } from '../../../components/join-address-prompt'; -import { _RoomSearchParams } from '../../paths'; - -export function CreateTab() { - const createSelected = useCreateSelected(); - - const navigate = useNavigate(); - const [menuCords, setMenuCords] = useState(); - const [joinAddress, setJoinAddress] = useState(false); - - const handleMenu: MouseEventHandler = (evt) => { - setMenuCords(menuCords ? undefined : evt.currentTarget.getBoundingClientRect()); - }; - - const handleCreateSpace = () => { - navigate(getCreatePath()); - setMenuCords(undefined); - }; - - const handleJoinWithAddress = () => { - setJoinAddress(true); - setMenuCords(undefined); - }; - - return ( - - - {(triggerRef) => ( - setMenuCords(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => - evt.key === 'ArrowDown' || evt.key === 'ArrowRight', - isKeyBackward: (evt: KeyboardEvent) => - evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', - escapeDeactivates: stopPropagation, - }} - > - - - - }> - Create Space - - Build a space for your community. - - - - - }> - Join with Address - - Become a part of existing community. - - - - - - - } - > - - - - {joinAddress && ( - setJoinAddress(false)} - onOpen={(roomIdOrAlias, viaServers) => { - setJoinAddress(false); - const path = getSpacePath(roomIdOrAlias); - navigate( - viaServers - ? withSearchParam<_RoomSearchParams>(path, { - viaServers: encodeSearchParamValueArray(viaServers), - }) - : path - ); - }} - /> - )} - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/DirectTab.tsx b/src/app/pages/client/sidebar/DirectTab.tsx deleted file mode 100644 index dcd44e2f..00000000 --- a/src/app/pages/client/sidebar/DirectTab.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import FocusTrap from 'focus-trap-react'; -import { useAtomValue } from 'jotai'; -import { useDirects } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getDirectPath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useDirectSelected } from '../../../hooks/router/useDirectSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useDirectRooms } from '../direct/useDirectRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { settingsAtom } from '../../../state/settings'; -import { useSetting } from '../../../state/hooks/settings'; - -type DirectMenuProps = { - requestClose: () => void; -}; -const DirectMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useDirectRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function DirectTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - const directUnread = useRoomsUnread(directs, roomToUnreadAtom); - const [menuAnchor, setMenuAnchor] = useState(); - - const directSelected = useDirectSelected(); - - const handleDirectClick = () => { - const activePath = navToActivePath.get('direct'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getDirectPath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - return ( - - - {(triggerRef) => ( - - - - )} - - {directUnread && ( - 0}> - 0} count={directUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/ExploreTab.tsx b/src/app/pages/client/sidebar/ExploreTab.tsx deleted file mode 100644 index a45b5830..00000000 --- a/src/app/pages/client/sidebar/ExploreTab.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useNavigate } from 'react-router-dom'; -import { useAtomValue } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { useExploreSelected } from '../../../hooks/router/useExploreSelected'; -import { - getExploreFeaturedPath, - getExplorePath, - getExploreServerPath, - joinPathComponent, -} from '../../pathUtils'; -import { useClientConfig } from '../../../hooks/useClientConfig'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getMxIdServer } from '../../../utils/matrix'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; - -export function ExploreTab() { - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const clientConfig = useClientConfig(); - const navigate = useNavigate(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const exploreSelected = useExploreSelected(); - - const handleExploreClick = () => { - if (screenSize === ScreenSize.Mobile) { - navigate(getExplorePath()); - return; - } - - const activePath = navToActivePath.get('explore'); - if (activePath) { - navigate(joinPathComponent(activePath)); - return; - } - - if (clientConfig.featuredCommunities?.openAsDefault) { - navigate(getExploreFeaturedPath()); - return; - } - const userId = mx.getUserId(); - const userServer = userId ? getMxIdServer(userId) : undefined; - if (userServer) { - navigate(getExploreServerPath(userServer)); - return; - } - navigate(getExplorePath()); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/HomeTab.tsx b/src/app/pages/client/sidebar/HomeTab.tsx deleted file mode 100644 index 778b729e..00000000 --- a/src/app/pages/client/sidebar/HomeTab.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; -import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds'; -import { useAtomValue } from 'jotai'; -import FocusTrap from 'focus-trap-react'; -import { useOrphanRooms } from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { getHomePath, joinPathComponent } from '../../pathUtils'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, -} from '../../../components/sidebar'; -import { useHomeSelected } from '../../../hooks/router/useHomeSelected'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useHomeRooms } from '../home/useHomeRooms'; -import { markAsRead } from '../../../utils/notifications'; -import { stopPropagation } from '../../../utils/keyboard'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; - -type HomeMenuProps = { - requestClose: () => void; -}; -const HomeMenu = forwardRef(({ requestClose }, ref) => { - const orphanRooms = useHomeRooms(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const mx = useMatrixClient(); - - const handleMarkAsRead = () => { - if (!unread) return; - orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity)); - requestClose(); - }; - - return ( - - - } - radii="300" - aria-disabled={!unread} - > - - Mark as Read - - - - - ); -}); - -export function HomeTab() { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - - const mDirects = useAtomValue(mDirectAtom); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents); - const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom); - const homeSelected = useHomeSelected(); - const [menuAnchor, setMenuAnchor] = useState(); - - const handleHomeClick = () => { - const activePath = navToActivePath.get('home'); - if (activePath && screenSize !== ScreenSize.Mobile) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getHomePath()); - }; - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - - {(triggerRef) => ( - - - - )} - - {homeUnread && ( - 0}> - 0} count={homeUnread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} diff --git a/src/app/pages/client/sidebar/SearchTab.tsx b/src/app/pages/client/sidebar/SearchTab.tsx deleted file mode 100644 index 7ceb5c49..00000000 --- a/src/app/pages/client/sidebar/SearchTab.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from 'react'; -import { Icon, Icons } from 'folds'; -import { useAtom } from 'jotai'; -import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar'; -import { searchModalAtom } from '../../../state/searchModal'; - -export function SearchTab() { - const [opened, setOpen] = useAtom(searchModalAtom); - - const open = () => setOpen(true); - - return ( - - - {(triggerRef) => ( - - - - )} - - - ); -} diff --git a/src/app/pages/client/sidebar/SpaceTabs.tsx b/src/app/pages/client/sidebar/SpaceTabs.tsx deleted file mode 100644 index 6fc528a1..00000000 --- a/src/app/pages/client/sidebar/SpaceTabs.tsx +++ /dev/null @@ -1,866 +0,0 @@ -import React, { - MouseEventHandler, - ReactNode, - RefObject, - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import { useNavigate } from 'react-router-dom'; -import { - Box, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Text, - config, - toRem, -} from 'folds'; -import { useAtom, useAtomValue } from 'jotai'; -import { Room } from 'matrix-js-sdk'; -import { - draggable, - dropTargetForElements, - monitorForElements, -} from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import { - attachInstruction, - extractInstruction, - Instruction, -} from '@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item'; -import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; -import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; -import FocusTrap from 'focus-trap-react'; -import { - useOrphanSpaces, - useRecursiveChildScopeFactory, - useSpaceChildren, -} from '../../../state/hooks/roomList'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { getSpaceLobbyPath, getSpacePath, joinPathComponent } from '../../pathUtils'; -import { - SidebarAvatar, - SidebarItem, - SidebarItemBadge, - SidebarItemTooltip, - SidebarStack, - SidebarStackSeparator, - SidebarFolder, - SidebarFolderDropTarget, -} from '../../../components/sidebar'; -import { RoomUnreadProvider, RoomsUnreadProvider } from '../../../components/RoomUnreadProvider'; -import { useSelectedSpace } from '../../../hooks/router/useSelectedSpace'; -import { UnreadBadge } from '../../../components/unread-badge'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { RoomAvatar } from '../../../components/room-avatar'; -import { nameInitials, randomStr } from '../../../utils/common'; -import { - ISidebarFolder, - SidebarItems, - TSidebarItem, - makeCinnySpacesContent, - parseSidebar, - sidebarItemWithout, - useSidebarItems, -} from '../../../hooks/useSidebarItems'; -import { AccountDataEvent } from '../../../../types/matrix/accountData'; -import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; -import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; -import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { markAsRead } from '../../../utils/notifications'; -import { copyToClipboard } from '../../../utils/dom'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { getRoomAvatarUrl } from '../../../utils/room'; -import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; - onUnpin?: (roomId: string) => void; -}; -const SpaceMenu = forwardRef( - ({ room, requestClose, onUnpin }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleUnpin = () => { - onUnpin?.(room.roomId); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - return ( - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - {onUnpin && ( - } - > - - Unpin - - - )} - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - - - ); - } -); - -type InstructionType = Instruction['type']; -type FolderDraggable = { - folder: ISidebarFolder; - spaceId?: string; - open?: boolean; -}; -type SidebarDraggable = string | FolderDraggable; - -const useDraggableItem = ( - item: SidebarDraggable, - targetRef: RefObject, - onDragging: (item?: SidebarDraggable) => void, - dragHandleRef?: RefObject -): boolean => { - const [dragging, setDragging] = useState(false); - - useEffect(() => { - const target = targetRef.current; - const dragHandle = dragHandleRef?.current ?? undefined; - - return !target - ? undefined - : draggable({ - element: target, - dragHandle, - getInitialData: () => ({ item }), - onDragStart: () => { - setDragging(true); - onDragging?.(item); - }, - onDrop: () => { - setDragging(false); - onDragging?.(undefined); - }, - }); - }, [targetRef, dragHandleRef, item, onDragging]); - - return dragging; -}; - -const useDropTarget = ( - item: SidebarDraggable, - targetRef: RefObject -): Instruction | undefined => { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: ({ input, element }) => { - const block: Instruction['type'][] = ['reparent']; - if (typeof item === 'object' && item.spaceId) block.push('make-child'); - - const insData = attachInstruction( - {}, - { - input, - element, - currentLevel: 0, - indentPerLevel: 0, - mode: 'standard', - block, - } - ); - - const instruction: Instruction | null = extractInstruction(insData); - setDropState(instruction ?? undefined); - - return { - item, - instructionType: instruction ? instruction.type : undefined, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef]); - - return dropState; -}; - -function useDropTargetInstruction( - item: SidebarDraggable, - targetRef: RefObject, - instructionType: T -): T | undefined { - const [dropState, setDropState] = useState(); - - useEffect(() => { - const target = targetRef.current; - if (!target) return undefined; - - return dropTargetForElements({ - element: target, - canDrop: ({ source }) => { - const dragItem = source.data.item as SidebarDraggable; - return dragItem !== item; - }, - getData: () => { - setDropState(instructionType); - - return { - item, - instructionType, - }; - }, - onDragLeave: () => setDropState(undefined), - onDrop: () => setDropState(undefined), - }); - }, [item, targetRef, instructionType]); - - return dropState; -} - -const useDnDMonitor = ( - scrollRef: RefObject, - onDragging: (dragItem?: SidebarDraggable) => void, - onReorder: ( - draggable: SidebarDraggable, - container: SidebarDraggable, - instruction: InstructionType - ) => void -) => { - useEffect(() => { - const scrollElement = scrollRef.current; - if (!scrollElement) { - throw Error('Scroll element ref not configured'); - } - - return combine( - monitorForElements({ - onDrop: ({ source, location }) => { - onDragging(undefined); - const { dropTargets } = location.current; - if (dropTargets.length === 0) return; - const item = source.data.item as SidebarDraggable; - const containerItem = dropTargets[0].data.item as SidebarDraggable; - const instructionType = dropTargets[0].data.instructionType as - | InstructionType - | undefined; - if (!instructionType) return; - onReorder(item, containerItem, instructionType); - }, - }), - autoScrollForElements({ - element: scrollElement, - }) - ); - }, [scrollRef, onDragging, onReorder]); -}; - -type SpaceTabProps = { - space: Room; - selected: boolean; - onClick: MouseEventHandler; - folder?: ISidebarFolder; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; - onUnpin?: (roomId: string) => void; -}; -function SpaceTab({ - space, - selected, - onClick, - folder, - onDragging, - disabled, - onUnpin, -}: SpaceTabProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const targetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo( - () => - folder - ? { - folder, - spaceId: space.roomId, - } - : space.roomId, - [folder, space] - ); - - useDraggableItem(spaceDraggable, targetRef, onDragging); - const dropState = useDropTarget(spaceDraggable, targetRef); - const dropType = dropState?.type; - - const [menuAnchor, setMenuAnchor] = useState(); - - const handleContextMenu: MouseEventHandler = (evt) => { - evt.preventDefault(); - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - - {(unread) => ( - - - {(triggerRef) => ( - - ( - {nameInitials(space.name, 2)} - )} - /> - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - onUnpin={onUnpin} - /> - - } - /> - )} - - )} - - ); -} - -type OpenedSpaceFolderProps = { - folder: ISidebarFolder; - onClose: MouseEventHandler; - children?: ReactNode; -}; -function OpenedSpaceFolder({ folder, onClose, children }: OpenedSpaceFolderProps) { - const aboveTargetRef = useRef(null); - const belowTargetRef = useRef(null); - - const spaceDraggable: SidebarDraggable = useMemo(() => ({ folder, open: true }), [folder]); - - const orderAbove = useDropTargetInstruction(spaceDraggable, aboveTargetRef, 'reorder-above'); - const orderBelow = useDropTargetInstruction(spaceDraggable, belowTargetRef, 'reorder-below'); - - return ( - - - - - - - - {children} - - - ); -} - -type ClosedSpaceFolderProps = { - folder: ISidebarFolder; - selected: boolean; - onOpen: MouseEventHandler; - onDragging: (dragItem?: SidebarDraggable) => void; - disabled?: boolean; -}; -function ClosedSpaceFolder({ - folder, - selected, - onOpen, - onDragging, - disabled, -}: ClosedSpaceFolderProps) { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const handlerRef = useRef(null); - - const spaceDraggable: FolderDraggable = useMemo(() => ({ folder }), [folder]); - useDraggableItem(spaceDraggable, handlerRef, onDragging); - const dropState = useDropTarget(spaceDraggable, handlerRef); - const dropType = dropState?.type; - - const tooltipName = - folder.name ?? folder.content.map((i) => mx.getRoom(i)?.name ?? '').join(', ') ?? 'Unnamed'; - - return ( - - {(unread) => ( - - - {(tooltipRef) => ( - - {folder.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - - return ( - - ( - - {nameInitials(space.name, 2)} - - )} - /> - - ); - })} - - )} - - {unread && ( - 0}> - 0} count={unread.total} /> - - )} - - )} - - ); -} - -type SpaceTabsProps = { - scrollRef: RefObject; -}; -export function SpaceTabs({ scrollRef }: SpaceTabsProps) { - const navigate = useNavigate(); - const mx = useMatrixClient(); - const screenSize = useScreenSizeContext(); - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const [sidebarItems, localEchoSidebarItem] = useSidebarItems(orphanSpaces); - const navToActivePath = useAtomValue(useNavToActivePathAtom()); - const [openedFolder, setOpenedFolder] = useAtom(useOpenedSidebarFolderAtom()); - const [draggingItem, setDraggingItem] = useState(); - - useDnDMonitor( - scrollRef, - setDraggingItem, - useCallback( - (item, containerItem, instructionType) => { - const newItems: SidebarItems = []; - - const matchDest = (sI: TSidebarItem, dI: SidebarDraggable): boolean => { - if (typeof sI === 'string' && typeof dI === 'string') { - return sI === dI; - } - if (typeof sI === 'object' && typeof dI === 'object') { - return sI.id === dI.folder.id; - } - return false; - }; - const itemAsFolderContent = (i: SidebarDraggable): string[] => { - if (typeof i === 'string') { - return [i]; - } - if (i.spaceId) { - return [i.spaceId]; - } - return [...i.folder.content]; - }; - - sidebarItems.forEach((i) => { - const sameFolders = - typeof item === 'object' && - typeof containerItem === 'object' && - item.folder.id === containerItem.folder.id; - - // remove draggable space from current position or folder - if (!sameFolders && matchDest(i, item)) { - if (typeof item === 'object' && item.spaceId) { - const folderContent = item.folder.content.filter((s) => s !== item.spaceId); - if (folderContent.length === 0) { - // remove open state from local storage - setOpenedFolder({ type: 'DELETE', id: item.folder.id }); - return; - } - newItems.push({ - ...item.folder, - content: folderContent, - }); - } - return; - } - if (matchDest(i, containerItem)) { - // we can make child only if - // container item is space or closed folder - if (instructionType === 'make-child') { - const child: string[] = itemAsFolderContent(item); - if (typeof containerItem === 'string') { - const folder: ISidebarFolder = { - id: randomStr(), - content: [containerItem].concat(child), - }; - newItems.push(folder); - return; - } - newItems.push({ - ...containerItem.folder, - content: containerItem.folder.content.concat(child), - }); - return; - } - - // drop inside opened folder - // or reordering inside same folder - if (typeof containerItem === 'object' && containerItem.spaceId) { - const child = itemAsFolderContent(item); - const newContent: string[] = []; - containerItem.folder.content - .filter((sId) => !child.includes(sId)) - .forEach((sId) => { - if (sId === containerItem.spaceId) { - if (instructionType === 'reorder-below') { - newContent.push(sId, ...child); - } - if (instructionType === 'reorder-above') { - newContent.push(...child, sId); - } - return; - } - newContent.push(sId); - }); - const folder = { - ...containerItem.folder, - content: newContent, - }; - - newItems.push(folder); - return; - } - - // drop above or below space or closed/opened folder - if (typeof item === 'string') { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item); - if (instructionType === 'reorder-above') newItems.push(i); - } else if (item.spaceId) { - if (instructionType === 'reorder-above') { - newItems.push(item.spaceId); - } - if (sameFolders && typeof i === 'object') { - // remove from folder if placing around itself - const newI = { ...i, content: i.content.filter((sId) => sId !== item.spaceId) }; - if (newI.content.length > 0) newItems.push(newI); - } else { - newItems.push(i); - } - if (instructionType === 'reorder-below') { - newItems.push(item.spaceId); - } - } else { - if (instructionType === 'reorder-below') newItems.push(i); - newItems.push(item.folder); - if (instructionType === 'reorder-above') newItems.push(i); - } - return; - } - newItems.push(i); - }); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces] - ) - ); - - const selectedSpaceId = useSelectedSpace(); - - const handleSpaceClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetSpaceId = target.getAttribute('data-id'); - if (!targetSpaceId) return; - - const spacePath = getSpacePath(getCanonicalAliasOrRoomId(mx, targetSpaceId)); - if (screenSize === ScreenSize.Mobile) { - navigate(spacePath); - return; - } - - const activePath = navToActivePath.get(targetSpaceId); - if (activePath && activePath.pathname.startsWith(spacePath)) { - navigate(joinPathComponent(activePath)); - return; - } - - navigate(getSpaceLobbyPath(getCanonicalAliasOrRoomId(mx, targetSpaceId))); - }; - - const handleFolderToggle: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const targetFolderId = target.getAttribute('data-id'); - if (!targetFolderId) return; - - setOpenedFolder({ - type: openedFolder.has(targetFolderId) ? 'DELETE' : 'PUT', - id: targetFolderId, - }); - }; - - const handleUnpin = useCallback( - (roomId: string) => { - if (orphanSpaces.includes(roomId)) return; - const newItems = sidebarItemWithout(sidebarItems, roomId); - - const newSpacesContent = makeCinnySpacesContent(mx, newItems); - localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); - mx.setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); - }, - [mx, sidebarItems, orphanSpaces, localEchoSidebarItem] - ); - - if (sidebarItems.length === 0) return null; - return ( - <> - - - {sidebarItems.map((item) => { - if (typeof item === 'object') { - if (openedFolder.has(item.id)) { - return ( - - {item.content.map((sId) => { - const space = mx.getRoom(sId); - if (!space) return null; - return ( - - ); - })} - - ); - } - - return ( - - ); - } - - const space = mx.getRoom(item); - if (!space) return null; - - return ( - - ); - })} - - - ); -} diff --git a/src/app/pages/client/sidebar/index.ts b/src/app/pages/client/sidebar/index.ts index d44cfaa2..3cf570d9 100644 --- a/src/app/pages/client/sidebar/index.ts +++ b/src/app/pages/client/sidebar/index.ts @@ -1,8 +1,3 @@ -export * from './HomeTab'; -export * from './DirectTab'; -export * from './SpaceTabs'; export * from './InboxTab'; -export * from './ExploreTab'; export * from './SettingsTab'; export * from './UnverifiedTab'; -export * from './SearchTab'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx deleted file mode 100644 index c822ff38..00000000 --- a/src/app/pages/client/space/Space.tsx +++ /dev/null @@ -1,571 +0,0 @@ -import React, { - MouseEventHandler, - forwardRef, - useCallback, - useMemo, - useRef, - useState, -} from 'react'; -import { useAtom, useAtomValue } from 'jotai'; -import { - Avatar, - Box, - Button, - Icon, - IconButton, - Icons, - Line, - Menu, - MenuItem, - PopOut, - RectCords, - Spinner, - Text, - color, - config, - toRem, -} from 'folds'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import { JoinRule, Room } from 'matrix-js-sdk'; -import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; -import FocusTrap from 'focus-trap-react'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../../state/mDirectList'; -import { - NavCategory, - NavCategoryHeader, - NavItem, - NavItemContent, - NavLink, -} from '../../../components/nav'; -import { getSpaceLobbyPath, getSpaceRoomPath, getSpaceSearchPath } from '../../pathUtils'; -import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix'; -import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom'; -import { - useSpaceLobbySelected, - useSpaceSearchSelected, -} from '../../../hooks/router/useSelectedSpace'; -import { useSpace } from '../../../hooks/useSpace'; -import { VirtualTile } from '../../../components/virtualizer'; -import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; -import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; -import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; -import { useStewardGroups } from '../../../features/steward-groups/useStewardGroups'; -import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; -import { useRoomName } from '../../../hooks/useRoomMeta'; -import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy'; -import { allRoomsAtom } from '../../../state/room-list/roomList'; -import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList'; -import { roomToParentsAtom } from '../../../state/room/roomToParents'; -import { markAsRead } from '../../../utils/notifications'; -import { useRoomsUnread } from '../../../state/hooks/unread'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { LeaveSpacePrompt } from '../../../components/leave-space-prompt'; -import { copyToClipboard } from '../../../utils/dom'; -import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories'; -import { useStateEvent } from '../../../hooks/useStateEvent'; -import { Membership, StateEvent } from '../../../../types/matrix/room'; -import { stopPropagation } from '../../../utils/keyboard'; -import { getMatrixToRoom } from '../../../plugins/matrix-to'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; -import { InviteUserPrompt } from '../../../components/invite-user-prompt'; -import { useCallEmbed } from '../../../hooks/useCallEmbed'; - -type SpaceMenuProps = { - room: Room; - requestClose: () => void; -}; -const SpaceMenu = forwardRef(({ room, requestClose }, ref) => { - const mx = useMatrixClient(); - const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); - const [developerTools] = useSetting(settingsAtom, 'developerTools'); - const roomToParents = useAtomValue(roomToParentsAtom); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - const canInvite = permissions.action('invite', mx.getSafeUserId()); - const openSpaceSettings = useOpenSpaceSettings(); - const { navigateRoom } = useRoomNavigate(); - - const [invitePrompt, setInvitePrompt] = useState(false); - - const allChild = useSpaceChildren( - allRoomsAtom, - room.roomId, - useRecursiveChildScopeFactory(mx, roomToParents) - ); - const unread = useRoomsUnread(allChild, roomToUnreadAtom); - - const handleMarkAsRead = () => { - allChild.forEach((childRoomId) => markAsRead(mx, childRoomId, hideActivity)); - requestClose(); - }; - - const handleCopyLink = () => { - const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); - const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); - requestClose(); - }; - - const handleInvite = () => { - setInvitePrompt(true); - }; - - const handleRoomSettings = () => { - openSpaceSettings(room.roomId); - requestClose(); - }; - - const handleOpenTimeline = () => { - navigateRoom(room.roomId); - requestClose(); - }; - - return ( - - - {invitePrompt && room && ( - { - setInvitePrompt(false); - requestClose(); - }} - /> - )} - } - radii="300" - disabled={!unread} - > - - Mark as Read - - - - - - } - radii="300" - aria-pressed={invitePrompt} - disabled={!canInvite} - > - - Invite - - - } - radii="300" - > - - Copy Link - - - } - radii="300" - > - - Space Settings - - - {developerTools && ( - } - radii="300" - > - - Event Timeline - - - )} - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - Leave Space - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - - - ); -}); - -function SpaceHeader() { - const space = useSpace(); - const spaceName = useRoomName(space); - const [menuAnchor, setMenuAnchor] = useState(); - - const joinRules = useStateEvent( - space, - StateEvent.RoomJoinRules - )?.getContent(); - - const handleOpenMenu: MouseEventHandler = (evt) => { - const cords = evt.currentTarget.getBoundingClientRect(); - setMenuAnchor((currentState) => { - if (currentState) return undefined; - return cords; - }); - }; - - return ( - <> - - - - - {spaceName} - - {joinRules?.join_rule !== JoinRule.Public && } - - - - - - - - - {menuAnchor && ( - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} /> - - } - /> - )} - - ); -} - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} - -export function Space() { - const mx = useMatrixClient(); - const space = useSpace(); - useNavToActivePathMapper(space.roomId); - const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); - const scrollRef = useRef(null); - const mDirects = useAtomValue(mDirectAtom); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const allRooms = useAtomValue(allRoomsAtom); - const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - - const tombstoneEvent = useStateEvent(space, StateEvent.RoomTombstone); - const selectedRoomId = useSelectedRoom(); - const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); - const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); - const callEmbed = useCallEmbed(); - - const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); - - const getRoom = useCallback( - (rId: string): Room | undefined => { - if (allJoinedRooms.has(rId)) { - return mx.getRoom(rId) ?? undefined; - } - return undefined; - }, - [mx, allJoinedRooms] - ); - - const hierarchy = useSpaceJoinedHierarchy( - space.roomId, - getRoom, - useCallback( - (parentId, roomId) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { - return false; - } - const showRoomAnyway = - roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId; - return !showRoomAnyway; - }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed] - ), - useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] - ) - ); - - const { entries, toggleGroup } = useStewardGroups({ - mx, - spaceId: space.roomId, - items: hierarchy, - keepVisible: useCallback( - (rId: string) => roomToUnread.has(rId) || rId === selectedRoomId || callEmbed?.roomId === rId, - [roomToUnread, selectedRoomId, callEmbed] - ), - }); - - const virtualizer = useVirtualizer({ - count: entries.length, - getScrollElement: () => scrollRef.current, - estimateSize: () => 0, - overscan: 10, - }); - - const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => - closedCategories.has(categoryId) - ); - - const getToLink = (roomId: string) => - getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); - - return ( - - - - - {tombstoneEvent && ( - - )} - - - - - - - - - - - Lobby - - - - - - - - - - - - - - - - Message Search - - - - - - - - - {virtualizer.getVirtualItems().map((vItem) => { - const entry = entries[vItem.index]; - if (!entry) return null; - if (entry.type === 'group') { - return ( - - - toggleGroup(entry.id)} - closed={!entry.open} - > - {entry.label} · {entry.count} - - - - ); - } - const { roomId } = entry.item; - const room = mx.getRoom(roomId); - if (!room) return null; - - if (room.isSpaceRoom()) { - const categoryId = makeNavCategoryId(space.roomId, roomId); - - return ( - -
- - - {roomId === space.roomId ? 'Rooms' : room?.name} - - -
-
- ); - } - - return ( - - - - ); - })} -
-
-
-
- ); -} diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx new file mode 100644 index 00000000..3e2d13bd --- /dev/null +++ b/src/app/pages/client/space/SpaceTombstone.tsx @@ -0,0 +1,79 @@ +import React, { useCallback } from 'react'; +import { Box, Button, Spinner, Text, color, config } from 'folds'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { Membership } from '../../../../types/matrix/room'; +import { getViaServers } from '../../../plugins/via-servers'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { ContainerColor } from '../../../styles/ContainerColor.css'; +import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { BreakWord } from '../../../styles/Text.css'; + +type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; +export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { + const mx = useMatrixClient(); + const { navigateSpace } = useRoomNavigate(); + + const [joinState, handleJoin] = useAsyncCallback( + useCallback(() => { + const currentRoom = mx.getRoom(roomId); + const via = currentRoom ? getViaServers(currentRoom) : []; + return mx.joinRoom(replacementRoomId, { + viaServers: via, + }); + }, [mx, roomId, replacementRoomId]) + ); + const replacementRoom = mx.getRoom(replacementRoomId); + + const handleOpen = () => { + if (replacementRoom) navigateSpace(replacementRoom.roomId); + if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); + }; + + return ( + + + Space Upgraded + This space has been replaced and is no longer active. + {joinState.status === AsyncStatus.Error && ( + + {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} + + )} + + + {replacementRoom?.getMyMembership() === Membership.Join || + joinState.status === AsyncStatus.Success ? ( + + ) : ( + + )} + + + ); +} + diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 0e32d93b..9b79693e 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,4 @@ export * from './SpaceProvider'; -export * from './Space'; +export * from './SpaceTombstone'; export * from './Search'; export * from './RoomProvider'; diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 98715996..1f5f0608 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,7 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - CinnySpaces = 'in.cinny.spaces', ElementRecentEmoji = 'io.element.recent_emoji', diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index c73c0f0b..81d35bd0 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -36,6 +36,7 @@ export enum StateEvent { GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', + GameCultStewardships = 'org.gamecult.stewardships', SpaceParent = 'm.space.parent', PoniesRoomEmotes = 'im.ponies.room_emotes', From e90b70e966398444bfffd74971d6c2ea3f3ce003 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:06:44 +0100 Subject: [PATCH 02/40] Estate navigator: one structure-first room list from the Space hierarchy, with one-tap Join buildEstate is the only code that decides which rooms the navigator lists, in which tier and steward group, and whether each is joined, invited or joinable. The server hierarchy is the access rule. The Home, Direct and Space nav slots mount it through EstatePageNav. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 225 ++++++++++++++++++ src/app/features/estate/buildEstate.test.ts | 140 +++++++++++ src/app/features/estate/buildEstate.ts | 162 +++++++++++++ src/app/features/estate/index.ts | 1 + .../features/estate/joinEstateRoom.test.ts | 61 +++++ src/app/features/estate/joinEstateRoom.ts | 29 +++ src/app/features/estate/useEstate.ts | 110 +++++++++ src/app/hooks/useSpaceHierarchy.ts | 9 +- src/app/pages/Router.tsx | 30 +-- src/app/pages/client/EstatePageNav.tsx | 15 ++ 10 files changed, 757 insertions(+), 25 deletions(-) create mode 100644 src/app/features/estate/EstateNavigator.tsx create mode 100644 src/app/features/estate/buildEstate.test.ts create mode 100644 src/app/features/estate/buildEstate.ts create mode 100644 src/app/features/estate/index.ts create mode 100644 src/app/features/estate/joinEstateRoom.test.ts create mode 100644 src/app/features/estate/joinEstateRoom.ts create mode 100644 src/app/features/estate/useEstate.ts create mode 100644 src/app/pages/client/EstatePageNav.tsx diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx new file mode 100644 index 00000000..211f0c3a --- /dev/null +++ b/src/app/features/estate/EstateNavigator.tsx @@ -0,0 +1,225 @@ +import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAtom, useAtomValue } from 'jotai'; +import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { MatrixClient } from 'matrix-js-sdk'; +import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; +import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; +import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; +import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; +import { + getDirectRoomPath, + getHomeRoomPath, + getHomeSearchPath, + getSpaceRoomPath, +} from '../../pages/pathUtils'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; +import { useCallEmbed } from '../../hooks/useCallEmbed'; +import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { + getRoomNotificationMode, + useRoomsNotificationPreferencesContext, +} from '../../hooks/useRoomsNotificationPreferences'; +import { useClosedNavCategoriesAtom } from '../../state/hooks/closedNavCategories'; +import { makeNavCategoryId } from '../../state/closedNavCategories'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { EstateRoom, EstateTier } from './buildEstate'; +import { activateEstateRoom } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; + +const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => { + const alias = getCanonicalAliasOrRoomId(mx, room.roomId); + if (room.parentId === null) return getDirectRoomPath(alias); + // A Space the user has not joined has no Space route; the room still resolves from Home. + if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); + } + return getHomeRoomPath(alias); +}; + +function JoinRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const navigate = useNavigate(); + const [state, join] = useAsyncCallback( + useCallback(async () => { + const result = await activateEstateRoom(mx, room); + if (result.kind === 'refused') throw new Error(result.message); + navigate(estateRoomPath(mx, room)); + }, [mx, navigate, room]) + ); + const busy = state.status === AsyncStatus.Loading; + + return ( + + + + + {room.name} + + + {state.status === AsyncStatus.Error && ( + + {state.error.message} + + )} + : } + onClick={join} + disabled={busy} + aria-label={`Join ${room.name}`} + > + Join + + + + ); +} + +function EstateRow({ room }: { room: EstateRoom }) { + const mx = useMatrixClient(); + const selectedRoomId = useSelectedRoom(); + const notificationPreferences = useRoomsNotificationPreferencesContext(); + const mxRoom = room.state === 'joined' ? mx.getRoom(room.roomId) : null; + + if (!mxRoom) return ; + return ( + + ); +} + +type CollapsibleProps = { + categoryId: string; + label: string; + rooms: EstateRoom[]; + children: ReactNode; +}; +function Collapsible({ categoryId, label, rooms, children }: CollapsibleProps) { + const mobile = useScreenSizeContext() === ScreenSize.Mobile; + const selectedRoomId = useSelectedRoom(); + const roomToUnread = useAtomValue(roomToUnreadAtom); + const callRoomId = useCallEmbed()?.roomId; + const [toggled, setToggled] = useAtom(useClosedNavCategoriesAtom()); + + const active = rooms.some( + (r) => r.roomId === selectedRoomId || r.roomId === callRoomId || roomToUnread.has(r.roomId) + ); + // Phones start collapsed unless the section holds something live; wide screens start open. + // A tap flips that default; the flip is remembered per device and per screen class. + const key = makeNavCategoryId('estate', mobile ? 'phone' : 'wide', categoryId); + const closed = (mobile && !active) !== toggled.has(key); + + const handleClick: MouseEventHandler = () => + setToggled({ type: toggled.has(key) ? 'DELETE' : 'PUT', categoryId: key }); + + return ( + + + + {label} + + + {!closed && children} + + ); +} + +function Tier({ tier }: { tier: EstateTier }) { + const allRooms = useMemo(() => [...tier.loose, ...tier.groups.flatMap((g) => g.rooms)], [tier]); + return ( + + {tier.loose.map((r) => ( + + ))} + {tier.groups.map((g) => ( + + {g.rooms.map((r) => ( + + ))} + + ))} + + ); +} + +function EstateTiers({ rootId }: { rootId: string }) { + const { fetching, error, tiers } = useEstate(rootId); + const scrollRef = useRef(null); + + return ( + + + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + {error.message} + + )} + + + ); +} + +/** The single navigator: every nav slot that lists rooms mounts this. */ +export function EstateNavigator() { + const navigate = useNavigate(); + const { rootId, error } = useEstateRootId(); + const mx = useMatrixClient(); + const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; + + return ( + + + + + + {title} + + + navigate(getHomeSearchPath())} + > + + + + + {rootId ? ( + + ) : ( + + {error ? {error.message} : } + + )} + + + + + + + ); +} diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts new file mode 100644 index 00000000..0593e195 --- /dev/null +++ b/src/app/features/estate/buildEstate.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate'; + +const ROOT = '!root:gamecult.org'; +const PROJECTS = '!projects:gamecult.org'; + +const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({ + room_id: id, + name, + room_type: 'm.space', + children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })), +}); +const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ + room_id: id, + name, + canonical_alias: alias, +}); + +const hierarchy = [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General', '#general:gamecult.org'), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']), + room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), + room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), + room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), +]; + +const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] }; + +const input = (over: Partial = {}): EstateInput => ({ + rootId: ROOT, + hierarchy, + stewardships, + membership: new Map(), + directRoomIds: [], + ...over, +}); + +const find = (tiers: ReturnType, roomId: string) => + tiers + .flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)]) + .find((r) => r.roomId === roomId); + +describe('buildEstate', () => { + it('lists an invited project room as invited', () => { + const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })); + expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); + }); + + it('lists a hierarchy room the user has not joined as joinable, beside joined ones', () => { + const tiers = buildEstate(input({ membership: new Map([['!general:gamecult.org', 'join']]) })); + expect(find(tiers, '!general:gamecult.org')?.state).toBe('joined'); + expect(find(tiers, '!aeth:gamecult.org')?.state).toBe('joinable'); + expect(find(tiers, '!aeth:gamecult.org')?.via).toEqual(['gamecult.org']); + }); + + it('omits a room absent from the hierarchy even when the client store knows it', () => { + const hidden = '!estate-secret:gamecult.org'; + const tiers = buildEstate( + input({ + membership: new Map([[hidden, 'join']]), + // The root names the room as a child, but the server did not return it. + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, hidden]), + ...hierarchy.slice(1), + ], + }) + ); + expect(find(tiers, hidden)).toBeUndefined(); + }); + + it('lists every room the server returned, whatever the user may join', () => { + const tiers = buildEstate(input()); + expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); + }); + + it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ + ['eureka', ['!cultlib:gamecult.org']], + ['void', ['!aeth:gamecult.org']], + ]); + }); + + it('sorts steward groups by display label', () => { + const projects = buildEstate( + input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); + }); + + it('lists a room with no alias match loose in its tier', () => { + const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; + expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); + }); + + it('degrades to loose rows when the stewardships are unreadable', () => { + const projects = buildEstate(input({ stewardships: undefined })).find( + (t) => t.id === PROJECTS + )!; + expect(projects.groups).toEqual([]); + expect(projects.loose).toHaveLength(3); + }); + + it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { + const second = '!second:gamecult.org'; + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS, second]), + ...hierarchy.slice(1), + space(second, 'Workshop', ['!w1:gamecult.org']), + room('!w1:gamecult.org', 'Bench'), + ], + directRoomIds: ['!dm:gamecult.org'], + membership: new Map([['!dm:gamecult.org', 'join']]), + }) + ); + expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']); + expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); + expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); + }); + + it('is identical for identical inputs', () => { + expect(buildEstate(input())).toEqual(buildEstate(input())); + }); +}); + +describe('stewardOfRoom', () => { + it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => { + expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka'); + expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void'); + }); + + it('has no steward without an alias, a match or readable stewardships', () => { + expect(stewardOfRoom(undefined, stewardships)).toBeUndefined(); + expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined(); + expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined(); + }); +}); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts new file mode 100644 index 00000000..096ea9ad --- /dev/null +++ b/src/app/features/estate/buildEstate.ts @@ -0,0 +1,162 @@ +/** + * The estate index: the only code that decides which rooms the navigator lists, + * in which tier and steward group, and whether each is joined, invited or joinable. + * + * The server's /hierarchy of the root Space is the access rule. A room the + * hierarchy does not return is never listed, whatever the client store knows; + * a room it returns is never hidden by the client. + */ + +export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships'; + +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: { via?: string[] } }[]; +}; + +/** Mind localpart to the repos she stewards, as projected on the root Space. */ +export type Stewardships = Record; + +export type EstateMembership = 'joined' | 'invited' | 'joinable'; + +export type EstateRoom = { + roomId: string; + name: string; + state: EstateMembership; + /** Servers to join through, from the hierarchy's children_state. */ + via: string[]; + /** The Space tier the room sits in; null for direct messages. */ + parentId: string | null; +}; + +export type EstateGroup = { + mind: string; + label: string; + rooms: EstateRoom[]; +}; + +export type EstateTier = { + id: string; + label: string; + /** Rooms without a steward. */ + loose: EstateRoom[]; + groups: EstateGroup[]; +}; + +export type EstateInput = { + rootId: string; + /** Rooms from the server's hierarchy of the root, in hierarchy order. */ + hierarchy: readonly EstateHierarchyRoom[]; + stewardships: Stewardships | undefined; + /** The user's own membership per room id, from the client store. */ + membership: ReadonlyMap; + directRoomIds: readonly string[]; + /** Display names for Minds, keyed by localpart. */ + mindLabels?: ReadonlyMap; +}; + +export const DIRECT_TIER_ID = 'direct'; + +const SPACE_TYPE = 'm.space'; + +const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase(); + +/** + * The single place a room's steward is derived: the canonical alias localpart, + * case-insensitively, against the repo names in the stewardships lists. + * (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.) + */ +export const stewardOfRoom = ( + canonicalAlias: string | undefined, + stewardships: Stewardships | undefined +): string | undefined => { + if (!canonicalAlias || !stewardships) return undefined; + const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); + const mind = Object.keys(stewardships).find((m) => { + const repos = stewardships[m]; + return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local); + }); + return mind; +}; + +const stateOf = ( + roomId: string, + membership: ReadonlyMap +): EstateMembership => { + const m = membership.get(roomId); + if (m === 'join') return 'joined'; + if (m === 'invite') return 'invited'; + return 'joinable'; +}; + +const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alias || r.room_id; + +export const buildEstate = ({ + rootId, + hierarchy, + stewardships, + membership, + directRoomIds, + mindLabels, +}: EstateInput): EstateTier[] => { + const direct = new Set(directRoomIds); + const byId = new Map(hierarchy.map((r) => [r.room_id, r])); + const spaces = hierarchy.filter((r) => r.room_type === SPACE_TYPE); + const root = byId.get(rootId); + + // Root first, then every sub-Space in hierarchy order. + const tierSpaces = root ? [root, ...spaces.filter((s) => s.room_id !== rootId)] : spaces; + + const tiers: EstateTier[] = tierSpaces.map((space) => { + const childIds = new Set((space.children_state ?? []).map((c) => c.state_key)); + const tier: EstateTier = { id: space.room_id, label: roomLabel(space), loose: [], groups: [] }; + const groups = new Map(); + + hierarchy.forEach((r) => { + if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; + const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? []; + const room: EstateRoom = { + roomId: r.room_id, + name: roomLabel(r), + state: stateOf(r.room_id, membership), + via, + parentId: space.room_id, + }; + const mind = stewardOfRoom(r.canonical_alias, stewardships); + if (!mind) { + tier.loose.push(room); + return; + } + let group = groups.get(mind); + if (!group) { + group = { mind, label: mindLabels?.get(mind) ?? mind, rooms: [] }; + groups.set(mind, group); + } + group.rooms.push(room); + }); + + tier.groups = [...groups.values()].sort((a, b) => + a.label.localeCompare(b.label, undefined, { sensitivity: 'base' }) + ); + return tier; + }); + + const directTier: EstateTier = { + id: DIRECT_TIER_ID, + label: 'Direct Messages', + loose: directRoomIds.map((roomId) => ({ + roomId, + name: roomId, + state: stateOf(roomId, membership), + via: [], + parentId: null, + })), + groups: [], + }; + + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); + return directTier.loose.length > 0 ? [...listed, directTier] : listed; +}; diff --git a/src/app/features/estate/index.ts b/src/app/features/estate/index.ts new file mode 100644 index 00000000..761586c4 --- /dev/null +++ b/src/app/features/estate/index.ts @@ -0,0 +1 @@ +export * from './EstateNavigator'; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts new file mode 100644 index 00000000..e063b337 --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import { activateEstateRoom } from './joinEstateRoom'; + +const room = (state: 'joined' | 'invited' | 'joinable') => ({ + roomId: '!r:gamecult.org', + state, + via: ['gamecult.org'], +}); + +/** A client that records every call; anything but joinRoom is a peek or a side door. */ +const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:gamecult.org' })) => { + const calls: string[] = []; + const mx = new Proxy({} as Record, { + get: (_t, prop: string) => { + if (prop === 'joinRoom') { + return (...args: unknown[]) => { + calls.push('joinRoom'); + return joinRoom(...args); + }; + } + return (...args: unknown[]) => { + void args; + calls.push(prop); + throw new Error(`unexpected client call: ${prop}`); + }; + }, + }); + return { mx: mx as never, calls, joinRoom }; +}; + +describe('activateEstateRoom', () => { + it('opens a joined room without touching the client', async () => { + const { mx, calls } = recordingClient(); + expect(await activateEstateRoom(mx, room('joined'))).toEqual({ + kind: 'open', + roomId: '!r:gamecult.org', + }); + expect(calls).toEqual([]); + }); + + it.each(['invited', 'joinable'] as const)( + 'joins a %s room on the tap, through the hierarchy servers, and nothing else', + async (state) => { + const { mx, calls, joinRoom } = recordingClient(); + const result = await activateEstateRoom(mx, room(state)); + expect(result).toEqual({ kind: 'joined', roomId: '!r:gamecult.org' }); + expect(joinRoom).toHaveBeenCalledWith('!r:gamecult.org', { viaServers: ['gamecult.org'] }); + expect(calls).toEqual(['joinRoom']); // no peekInRoom, no read, no second path + } + ); + + it('reports the server refusal and does not decide access itself', async () => { + const refusal = Object.assign(new Error('M_FORBIDDEN'), { + data: { error: 'You are not invited to this room.' }, + }); + const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result).toEqual({ kind: 'refused', message: 'You are not invited to this room.' }); + expect(joinRoom).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts new file mode 100644 index 00000000..aedfc70b --- /dev/null +++ b/src/app/features/estate/joinEstateRoom.ts @@ -0,0 +1,29 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { EstateRoom } from './buildEstate'; + +export type EstateActivation = + | { kind: 'open'; roomId: string } + | { kind: 'joined'; roomId: string } + | { kind: 'refused'; message: string }; + +type JoinClient = Pick; + +/** + * What a tap on an estate row does. A joined room opens; any other room is + * joined, and only here (plus a first post in the room view). Opening a room + * never joins it and the client has no peek path. The server decides whether + * the join is allowed; its refusal is reported, the row is not hidden. + */ +export const activateEstateRoom = async ( + mx: JoinClient, + room: Pick +): Promise => { + if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + try { + const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); + return { kind: 'joined', roomId: joined.roomId }; + } catch (e) { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + } +}; diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts new file mode 100644 index 00000000..3968d974 --- /dev/null +++ b/src/app/features/estate/useEstate.ts @@ -0,0 +1,110 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { useAtomValue } from 'jotai'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useClientConfig } from '../../hooks/useClientConfig'; +import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; +import { useForceUpdate } from '../../hooks/useForceUpdate'; +import { getStateEvent } from '../../utils/room'; +import { useStateEventCallback } from '../../hooks/useStateEventCallback'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { allInvitesAtom } from '../../state/room-list/inviteList'; +import { mDirectAtom } from '../../state/mDirectList'; +import { StateEvent } from '../../../types/matrix/room'; +import { buildEstate, EstateTier, Stewardships } from './buildEstate'; + +const HIERARCHY_DEPTH = 10; + +const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined => + mx + .getRooms() + .find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId; + +/** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ +export const useEstateRootId = (): { rootId?: string; error?: Error } => { + const mx = useMatrixClient(); + const alias = useClientConfig().featuredCommunities?.spaces?.[0]; + const loaded = alias && !alias.startsWith('#') ? alias : alias && findLoadedRoom(mx, alias); + const resolved = useQuery({ + queryKey: ['estate-root', alias], + queryFn: async () => (await mx.getRoomIdForAlias(alias!)).room_id, + enabled: !!alias && !loaded, + }); + if (!alias) return { error: new Error('No estate root Space is configured') }; + return { rootId: loaded || resolved.data, error: resolved.error ?? undefined }; +}; + +export type Estate = { + fetching: boolean; + error: Error | null; + tiers: EstateTier[]; +}; + +/** + * Feeds buildEstate: the server hierarchy of the root Space, the root's + * stewardships from the client store, the user's membership and m.direct. + * Recomputes on membership, Space child and stewardships changes. + */ +export const useEstate = (rootId: string): Estate => { + const mx = useMatrixClient(); + const queryClient = useQueryClient(); + const { fetching, error, rooms } = useFetchSpaceHierarchyLevel(rootId, true, HIERARCHY_DEPTH); + const joined = useAtomValue(allRoomsAtom); + const invited = useAtomValue(allInvitesAtom); + const mDirects = useAtomValue(mDirectAtom); + + const [stewardshipsVersion, bumpStewardships] = useForceUpdate(); + + const refetchHierarchy = useCallback( + () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), + [queryClient, rootId] + ); + useStateEventCallback( + mx, + useCallback( + (event) => { + if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); + if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships(); + }, + [refetchHierarchy, bumpStewardships] + ) + ); + // What the server lets this user see changes with invitations. + useEffect(() => { + refetchHierarchy(); + }, [invited.length, refetchHierarchy]); + + const stewardships = useMemo( + () => { + const rootRoom = mx.getRoom(rootId); + return rootRoom + ? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() + : undefined; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [mx, rootId, stewardshipsVersion, joined, invited] + ); + + const tiers = useMemo(() => { + const membership = new Map(); + invited.forEach((id) => membership.set(id, 'invite')); + joined.forEach((id) => membership.set(id, 'join')); + const domain = mx.getDomain(); + const mindLabels = new Map(); + Object.keys(stewardships ?? {}).forEach((mind) => { + const name = mx.getUser(`@${mind}:${domain}`)?.displayName; + if (name) mindLabels.set(mind, name); + }); + return buildEstate({ + rootId, + hierarchy: Array.from(rooms.values()), + stewardships, + membership, + directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), + mindLabels, + }); + }, [mx, rootId, rooms, stewardships, joined, invited, mDirects]); + + return { fetching, error, tiers }; +}; diff --git a/src/app/hooks/useSpaceHierarchy.ts b/src/app/hooks/useSpaceHierarchy.ts index ad34e3f4..59889c1c 100644 --- a/src/app/hooks/useSpaceHierarchy.ts +++ b/src/app/hooks/useSpaceHierarchy.ts @@ -276,7 +276,8 @@ export type FetchSpaceHierarchyLevelData = { }; export const useFetchSpaceHierarchyLevel = ( roomId: string, - enable: boolean + enable: boolean, + maxDepth = 1 ): FetchSpaceHierarchyLevelData => { const mx = useMatrixClient(); const pageNoRef = useRef(0); @@ -286,13 +287,13 @@ export const useFetchSpaceHierarchyLevel = ( string[], string | undefined > = useCallback( - ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, 1, false, pageParam), - [roomId, mx] + ({ pageParam }) => mx.getRoomHierarchy(roomId, PER_PAGE_COUNT, maxDepth, false, pageParam), + [roomId, maxDepth, mx] ); const queryResponse = useInfiniteQuery({ refetchOnMount: enable, - queryKey: [roomId, 'hierarchy_level'], + queryKey: [roomId, 'hierarchy_level', maxDepth], initialPageParam: undefined, queryFn: fetchLevel, getNextPageParam: (result) => { diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 4de42081..ae5eac70 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -40,19 +40,19 @@ import { getSpaceLobbyPath, } from './pathUtils'; import { ClientBindAtoms, ClientLayout, ClientRoot } from './client'; -import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; -import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; -import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; +import { HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { DirectCreate, DirectRouteRoomProvider } from './client/direct'; +import { RouteSpaceProvider, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; import { Notifications, Inbox, Invites } from './client/inbox'; import { setAfterLoginRedirectPath } from './afterLoginRedirectPath'; import { Room } from '../features/room'; import { Lobby } from '../features/lobby'; import { WelcomePage } from './client/WelcomePage'; -import { SidebarNav } from './client/SidebarNav'; +import { EstatePageNav } from './client/EstatePageNav'; import { PageRoot } from '../components/page'; import { ScreenSize } from '../hooks/useScreenSize'; -import { MobileFriendlyPageNav, MobileFriendlyClientNav } from './MobileFriendly'; +import { MobileFriendlyPageNav } from './MobileFriendly'; import { ClientInitStorageAtom } from './client/ClientInitStorageAtom'; import { ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { AuthRouteThemeManager, UnAuthRouteThemeManager } from './ThemeManager'; @@ -127,13 +127,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - - - } - > + @@ -159,9 +153,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ - - + } > @@ -186,9 +178,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) element={ - - + } > @@ -212,9 +202,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - - + } > diff --git a/src/app/pages/client/EstatePageNav.tsx b/src/app/pages/client/EstatePageNav.tsx new file mode 100644 index 00000000..470cbdd7 --- /dev/null +++ b/src/app/pages/client/EstatePageNav.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { MobileFriendlyPageNav } from '../MobileFriendly'; +import { EstateNavigator } from '../../features/estate'; + +/** + * Where the navigator mounts in a route's nav slot. The wide-screen placement + * question (ratatoskr:question:desktop-navigator-placement) is answered here. + */ +export function EstatePageNav({ path }: { path: string }) { + return ( + + + + ); +} From f810a83a281fc643e429cb794f13b84617c20f92 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:08:47 +0100 Subject: [PATCH 03/40] Estate: keep the new files free of unresolved-SDK type errors Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 5 ++--- src/app/features/estate/joinEstateRoom.ts | 5 +++-- src/app/features/estate/useEstate.ts | 22 +++++++++++++-------- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 211f0c3a..811f531b 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -2,7 +2,6 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } fro import { useNavigate } from 'react-router-dom'; import { useAtom, useAtomValue } from 'jotai'; import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; -import { MatrixClient } from 'matrix-js-sdk'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; @@ -30,7 +29,7 @@ import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; -const estateRoomPath = (mx: MatrixClient, room: EstateRoom): string => { +const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); if (room.parentId === null) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. @@ -62,7 +61,7 @@ function JoinRow({ room }: { room: EstateRoom }) { {state.status === AsyncStatus.Error && ( - {state.error.message} + {(state.error as Error).message} )} ; +type JoinClient = { + joinRoom(roomId: string, opts: { viaServers: string[] }): Promise<{ roomId: string }>; +}; /** * What a tap on an estate row does. A joined room opens; any other room is diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 3968d974..fc3d8009 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useAtomValue } from 'jotai'; import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; @@ -12,14 +11,19 @@ import { allRoomsAtom } from '../../state/room-list/roomList'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { mDirectAtom } from '../../state/mDirectList'; import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateTier, Stewardships } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate'; const HIERARCHY_DEPTH = 10; -const findLoadedRoom = (mx: MatrixClient, alias: string): string | undefined => - mx - .getRooms() - .find((r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias))?.roomId; +type AliasedRoom = { roomId: string; getCanonicalAlias(): string | null; getAltAliases(): string[] }; + +const findLoadedRoom = ( + mx: ReturnType, + alias: string +): string | undefined => + (mx.getRooms() as AliasedRoom[]).find( + (r) => r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias) + )?.roomId; /** The estate's root Space: featuredCommunities.spaces[0] from the client config. */ export const useEstateRootId = (): { rootId?: string; error?: Error } => { @@ -79,7 +83,9 @@ export const useEstate = (rootId: string): Estate => { () => { const rootRoom = mx.getRoom(rootId); return rootRoom - ? getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() + ? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as + | Stewardships + | undefined) : undefined; }, // eslint-disable-next-line react-hooks/exhaustive-deps @@ -98,7 +104,7 @@ export const useEstate = (rootId: string): Estate => { }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()), + hierarchy: Array.from(rooms.values()) as EstateHierarchyRoom[], stewardships, membership, directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), From 4d0ee789745a0153f82adc7a8bb53da0622c13ec Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:10:41 +0100 Subject: [PATCH 04/40] Estate: lint and type fixes in the new files Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 3 +-- src/app/features/estate/useEstate.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index e063b337..8976f325 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -18,8 +18,7 @@ const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:game return joinRoom(...args); }; } - return (...args: unknown[]) => { - void args; + return () => { calls.push(prop); throw new Error(`unexpected client call: ${prop}`); }; diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index fc3d8009..6ffccd1e 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -104,7 +104,7 @@ export const useEstate = (rootId: string): Estate => { }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()) as EstateHierarchyRoom[], + hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[], stewardships, membership, directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), From c848626caaf16e09acf9d0951138c2433fe925a2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:11:35 +0100 Subject: [PATCH 05/40] Estate: prettier, and tests that kill the surviving mutants Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/buildEstate.test.ts | 81 ++++++++++++++++++- src/app/features/estate/buildEstate.ts | 6 +- .../features/estate/joinEstateRoom.test.ts | 13 +++ src/app/features/estate/useEstate.ts | 6 +- src/app/pages/Router.tsx | 18 +---- src/app/pages/client/space/SpaceTombstone.tsx | 1 - src/types/matrix/accountData.ts | 1 - 7 files changed, 101 insertions(+), 25 deletions(-) diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 0593e195..27964a7b 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -19,7 +19,11 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => const hierarchy = [ space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org']), + space(PROJECTS, 'Projects', [ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + '!misc:gamecult.org', + ]), room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), @@ -43,7 +47,9 @@ const find = (tiers: ReturnType, roomId: string) => describe('buildEstate', () => { it('lists an invited project room as invited', () => { - const tiers = buildEstate(input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) })); + const tiers = buildEstate( + input({ membership: new Map([['!cultlib:gamecult.org', 'invite']]) }) + ); expect(find(tiers, '!cultlib:gamecult.org')?.state).toBe('invited'); }); @@ -84,7 +90,12 @@ describe('buildEstate', () => { it('sorts steward groups by display label', () => { const projects = buildEstate( - input({ mindLabels: new Map([['eureka', 'Zed'], ['void', 'Alpha']]) }) + input({ + mindLabels: new Map([ + ['eureka', 'Zed'], + ['void', 'Alpha'], + ]), + }) ).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'Zed']); }); @@ -116,11 +127,73 @@ describe('buildEstate', () => { membership: new Map([['!dm:gamecult.org', 'join']]), }) ); - expect(tiers.map((t) => t.label)).toEqual(['GameCult', 'Projects', 'Workshop', 'Direct Messages']); + expect(tiers.map((t) => t.label)).toEqual([ + 'GameCult', + 'Projects', + 'Workshop', + 'Direct Messages', + ]); expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); }); + it('takes join servers from the matching children_state entry', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org', '!b:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { via: ['one.example'] } }, + { state_key: '!b:gamecult.org', content: { via: ['two.example'] } }, + ], + }, + room('!a:gamecult.org', 'A'), + room('!b:gamecult.org', 'B'), + ], + }) + ); + expect(find(tiers, '!a:gamecult.org')?.via).toEqual(['one.example']); + expect(find(tiers, '!b:gamecult.org')?.via).toEqual(['two.example']); + }); + + it('puts every room of one Mind in a single group', () => { + const tiers = buildEstate( + input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }) + ); + const projects = tiers.find((t) => t.id === PROJECTS)!; + expect(projects.groups).toHaveLength(1); + expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ + '!cultlib:gamecult.org', + '!aeth:gamecult.org', + ]); + }); + + it('sorts group labels without regard to case', () => { + const projects = buildEstate( + input({ mindLabels: new Map([['eureka', 'beta'], ['void', 'Alpha']]) }) + ).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); + }); + + it('omits a tier whose Space returned no rooms and the Direct tier when there are no DMs', () => { + const tiers = buildEstate( + input({ + hierarchy: [ + space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), + room('!general:gamecult.org', 'General'), + space(PROJECTS, 'Projects', ['!hidden:gamecult.org']), + ], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT]); + }); + + it('puts direct messages in the final tier, off any Space', () => { + const tiers = buildEstate(input({ directRoomIds: ['!dm:gamecult.org'] })); + expect(tiers[tiers.length - 1]).toMatchObject({ id: 'direct', groups: [] }); + }); + it('is identical for identical inputs', () => { expect(buildEstate(input())).toEqual(buildEstate(input())); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 096ea9ad..9ca9048c 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -7,8 +7,6 @@ * a room it returns is never hidden by the client. */ -export const STEWARDSHIPS_EVENT_TYPE = 'org.gamecult.stewardships'; - export type EstateHierarchyRoom = { room_id: string; name?: string; @@ -77,7 +75,9 @@ export const stewardOfRoom = ( const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); const mind = Object.keys(stewardships).find((m) => { const repos = stewardships[m]; - return Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local); + return ( + Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local) + ); }); return mind; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8976f325..fae21e5b 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -48,6 +48,19 @@ describe('activateEstateRoom', () => { } ); + it('falls back to the error message, then to a generic one', async () => { + const named = recordingClient(vi.fn().mockRejectedValue(new Error('Boom'))); + expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Boom', + }); + const bare = recordingClient(vi.fn().mockRejectedValue({})); + expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ + kind: 'refused', + message: 'Could not join', + }); + }); + it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { data: { error: 'You are not invited to this room.' }, diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 6ffccd1e..99927ba8 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -15,7 +15,11 @@ import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './bu const HIERARCHY_DEPTH = 10; -type AliasedRoom = { roomId: string; getCanonicalAlias(): string | null; getAltAliases(): string[] }; +type AliasedRoom = { + roomId: string; + getCanonicalAlias(): string | null; + getAltAliases(): string[]; +}; const findLoadedRoom = ( mx: ReturnType, diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index ae5eac70..159162dd 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -151,11 +151,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - } - > + }> } @@ -176,11 +172,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) - } - > + }> } @@ -200,11 +192,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) path={SPACE_PATH} element={ - - } - > + }> diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx index 3e2d13bd..16bcdf63 100644 --- a/src/app/pages/client/space/SpaceTombstone.tsx +++ b/src/app/pages/client/space/SpaceTombstone.tsx @@ -76,4 +76,3 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
); } - diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 1f5f0608..36c1e21b 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -3,7 +3,6 @@ export enum AccountDataEvent { Direct = 'm.direct', IgnoredUserList = 'm.ignored_user_list', - ElementRecentEmoji = 'io.element.recent_emoji', PoniesUserEmotes = 'im.ponies.user_emotes', From 067cb10a90097a300ce6f54dd875d4b084e3f46b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:13:32 +0100 Subject: [PATCH 06/40] Estate: prettier on the new tests Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/buildEstate.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 27964a7b..20129cbf 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -158,9 +158,7 @@ describe('buildEstate', () => { }); it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate( - input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } }) - ); + const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } })); const projects = tiers.find((t) => t.id === PROJECTS)!; expect(projects.groups).toHaveLength(1); expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ @@ -171,7 +169,12 @@ describe('buildEstate', () => { it('sorts group labels without regard to case', () => { const projects = buildEstate( - input({ mindLabels: new Map([['eureka', 'beta'], ['void', 'Alpha']]) }) + input({ + mindLabels: new Map([ + ['eureka', 'beta'], + ['void', 'Alpha'], + ]), + }) ).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => g.label)).toEqual(['Alpha', 'beta']); }); From e8efd3ce760003f290828efd32fe55bc015d5ca4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:10 +0100 Subject: [PATCH 07/40] Delete SpaceTombstone: nothing renders it Co-Authored-By: Claude Sonnet 5.5 --- src/app/pages/client/space/SpaceTombstone.tsx | 78 ------------------- src/app/pages/client/space/index.ts | 1 - 2 files changed, 79 deletions(-) delete mode 100644 src/app/pages/client/space/SpaceTombstone.tsx diff --git a/src/app/pages/client/space/SpaceTombstone.tsx b/src/app/pages/client/space/SpaceTombstone.tsx deleted file mode 100644 index 16bcdf63..00000000 --- a/src/app/pages/client/space/SpaceTombstone.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import React, { useCallback } from 'react'; -import { Box, Button, Spinner, Text, color, config } from 'folds'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { Membership } from '../../../../types/matrix/room'; -import { getViaServers } from '../../../plugins/via-servers'; -import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; -import { ContainerColor } from '../../../styles/ContainerColor.css'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { BreakWord } from '../../../styles/Text.css'; - -type SpaceTombstoneProps = { roomId: string; replacementRoomId: string }; -export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProps) { - const mx = useMatrixClient(); - const { navigateSpace } = useRoomNavigate(); - - const [joinState, handleJoin] = useAsyncCallback( - useCallback(() => { - const currentRoom = mx.getRoom(roomId); - const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); - }, [mx, roomId, replacementRoomId]) - ); - const replacementRoom = mx.getRoom(replacementRoomId); - - const handleOpen = () => { - if (replacementRoom) navigateSpace(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateSpace(joinState.data.roomId); - }; - - return ( - - - Space Upgraded - This space has been replaced and is no longer active. - {joinState.status === AsyncStatus.Error && ( - - {(joinState.error as any)?.message ?? 'Failed to join replacement space!'} - - )} - - - {replacementRoom?.getMyMembership() === Membership.Join || - joinState.status === AsyncStatus.Success ? ( - - ) : ( - - )} - - - ); -} diff --git a/src/app/pages/client/space/index.ts b/src/app/pages/client/space/index.ts index 9b79693e..477d60ff 100644 --- a/src/app/pages/client/space/index.ts +++ b/src/app/pages/client/space/index.ts @@ -1,4 +1,3 @@ export * from './SpaceProvider'; -export * from './SpaceTombstone'; export * from './Search'; export * from './RoomProvider'; From 41527f6ba387a4ee88d75997581b2031a45b6572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:10 +0100 Subject: [PATCH 08/40] config.json: the GameCult Space is the featured root, so a wholesale deploy resolves the estate Co-Authored-By: Claude Sonnet 5.5 --- config.json | 12 +----------- src/app/features/estate/config.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 11 deletions(-) create mode 100644 src/app/features/estate/config.test.ts diff --git a/config.json b/config.json index a61bb966..e945d1c4 100644 --- a/config.json +++ b/config.json @@ -5,17 +5,7 @@ "featuredCommunities": { "openAsDefault": false, - "spaces": [ - "#cinny:matrix.org", - "#community:matrix.org", - "#space:unredacted.org", - "#librewolf-community:matrix.org", - "#stickers-and-emojis:tastytea.de", - "#videogames:waywardinn.com", - "#science-space:matrix.org", - "#libregaming-games:tchncs.de", - "#mathematics-on:matrix.org" - ], + "spaces": ["#gamecult:gamecult.org"], "rooms": [ "#tuwunel:grin.hu", "#freesoftware:matrix.org", diff --git a/src/app/features/estate/config.test.ts b/src/app/features/estate/config.test.ts new file mode 100644 index 00000000..4304e1c2 --- /dev/null +++ b/src/app/features/estate/config.test.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'fs'; +import { describe, expect, it } from 'vitest'; + +describe('repo config.json', () => { + it('names the GameCult Space first, so a wholesale deploy resolves the estate root', () => { + const config = JSON.parse(readFileSync('config.json', 'utf8')); + expect(config.featuredCommunities.spaces[0]).toBe('#gamecult:gamecult.org'); + }); +}); From 0b7ded9c991547fbd360fd8cf4ee1246eca2dbca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:46:11 +0100 Subject: [PATCH 09/40] Estate: stewards from the hierarchy's Space child content, joins wait for the store, room finder on search, Create room and Join with address, Elsewhere group, invited DMs Steward groups read org.gamecult.steward from the m.space.child content the hierarchy returns, so an invited-only member gets them; root-state stewardships and GameCultStewardships are deleted. Tapping Join waits for the client store to hold the room before navigating (no invite-card frame). The header search opens the room finder on every width. Create room and Join with address are navigator actions; joined non-DM rooms outside the Space sit under Elsewhere; invited DMs list in Direct with Join. harness/estate holds the fake-homeserver Playwright pin. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.js | 76 +++++++++ harness/estate/fakehs.js | 108 ++++++++++++ harness/estate/run.sh | 15 ++ src/app/features/estate/EstateNavigator.tsx | 112 +++++++++++-- src/app/features/estate/buildEstate.test.ts | 154 +++++++++++++++--- src/app/features/estate/buildEstate.ts | 88 ++++++---- .../features/estate/joinEstateRoom.test.ts | 54 +++++- src/app/features/estate/joinEstateRoom.ts | 33 ++++ src/app/features/estate/useEstate.ts | 62 +++---- src/types/matrix/room.ts | 1 - 10 files changed, 601 insertions(+), 102 deletions(-) create mode 100644 harness/estate/drive.js create mode 100644 harness/estate/fakehs.js create mode 100644 harness/estate/run.sh diff --git a/harness/estate/drive.js b/harness/estate/drive.js new file mode 100644 index 00000000..674cb15e --- /dev/null +++ b/harness/estate/drive.js @@ -0,0 +1,76 @@ +// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator. +const { chromium } = require('playwright'); +const fs = require('fs'); +const out = (n) => `/out/${n}`; +const tl = []; +const fails = []; +const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); }; +(async () => { + const b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); + await pg.waitForTimeout(2000); + const text = () => pg.evaluate(() => document.body.innerText); + const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); }; + const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } }; + + // Entry points + const t0 = await text(); + check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions'); + await pg.getByText('Join with Address').first().click(); + await pg.waitForTimeout(400); + check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); + await pg.keyboard.press('Escape'); + await pg.waitForTimeout(300); + if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click(); + + // Header search is the room finder, on a phone + await pg.locator('[aria-label="Find a room"]').first().click(); + await pg.waitForTimeout(500); + check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search'); + await snap('finder'); + await pg.keyboard.press('Escape'); + await pg.waitForTimeout(300); + + // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM + for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); + await snap('expanded'); + const t1 = (await text()).toLowerCase(); + check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); + check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere'); + check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join'); + + // Join: no invite card at any frame, then the room + await pg.locator('[aria-label="Join Aetheria"]').first().click(); + const t = Date.now(); + let card = 0, room = null; + while (Date.now() - t < 6000) { + const body = await text(); + if (/Summary card/.test(body)) card++; + if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t; + await pg.waitForTimeout(40); + } + await snap('joined'); + check(card === 0, `no invite card at any frame after the tap (saw ${card})`); + check(room !== null, `the room opened (after ${room} ms)`); + + // Join the DM: lands in the room too + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(3000); + await open('Direct Messages'); + if (await pg.locator('[aria-label="Join Void"]').count()) { + await pg.locator('[aria-label="Join Void"]').first().click(); + let dmCard = 0; const t2 = Date.now(); + while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); } + check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card'); + } + await b.close(); +})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => { + fs.writeFileSync(out('timeline.txt'), tl.join('\n')); + console.log(tl.join('\n')); + process.exitCode = fails.length ? 1 : 0; +}); diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.js new file mode 100644 index 00000000..641a21f4 --- /dev/null +++ b/harness/estate/fakehs.js @@ -0,0 +1,108 @@ +// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh). +// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the +// Space, so the client cannot read its state), joined to #general and to #meeting, a room +// outside the Space, and invited to a DM. The steward of each project room is carried only +// in the m.space.child content of the hierarchy, as the accounts script writes it. +const http = require('http'), fs = require('fs'), path = require('path'); +const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const log = []; +const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; +const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; +const DM = '!dm:test', MEETING = '!meeting:test'; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; +const pendingJoin = []; +let ev = 0; +const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); +const strip = (id, name, space, dm) => [ + { type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + ...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []), + ...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []), + { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, + { type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, +]; +const joinedRoom = (id, name, extra = [], space = false) => ({ + state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, + timeline: { events: [{ type: 'm.room.message', sender: '@admin:test', content: { msgtype: 'm.text', body: `welcome to ${name}` }, event_id: `$m${++ev}`, origin_server_ts: Date.now() }], limited: false }, + ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {}, +}); +const invites = () => { + const o = {}; + o[ROOT] = { invite_state: { events: strip(ROOT, 'GameCult', true) } }; + o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; + o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; + PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); + o[DM] = { invite_state: { events: strip(DM, null, false, true) } }; + return o; +}; +const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); +const hierarchy = () => [ + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }), + hroom(GEN, 'General', '#general:test'), + hroom(OP, 'Operator', '#operator:test'), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }), + ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), +]; +let syncN = 1; +const json = (res, code, body) => { res.writeHead(code, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': '*', 'Access-Control-Allow-Methods': '*' }); res.end(JSON.stringify(body)); }; +const mime = { '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css', '.html': 'text/html', '.json': 'application/json', '.wasm': 'application/wasm', '.svg': 'image/svg+xml', '.png': 'image/png', '.woff2': 'font/woff2' }; +const flush = () => fs.writeFileSync(process.env.HSLOG, log.join('\n')); +http.createServer((req, res) => { + const u = new URL(req.url, 'http://x'); const p = decodeURIComponent(u.pathname); + if (req.method === 'OPTIONS') return json(res, 200, {}); + if (!p.startsWith('/_matrix') && !p.startsWith('/.well-known')) { + let f = path.join(DIST, p === '/' ? 'index.html' : p); + if (!fs.existsSync(f) || fs.statSync(f).isDirectory()) f = path.join(DIST, 'index.html'); + res.writeHead(200, { 'Content-Type': mime[path.extname(f)] || 'application/octet-stream' }); + return fs.createReadStream(f).pipe(res); + } + let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + log.push(`${Date.now()} ${req.method} ${p}`); + if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); + if (p.endsWith('/sync')) { + if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`); + const since = u.searchParams.get('since'); + if (!since) { + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting'); + const inv = invites(); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + } + const wait = () => { + if (pendingJoin.length) { + const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id }; + const join = {}; join[id] = joinedRoom(id, r.name); + log.push(`${Date.now()} SYNC delivers join ${id}`); + return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); + } + return json(res, 200, { next_batch: since, rooms: {} }); + }; + return setTimeout(wait, pendingJoin.length ? 2500 : 1500); + } + const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); + if (req.method === 'POST' && jm) { + const id = jm[1]; + log.push(`${Date.now()} JOIN ${id}`); + if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); + pendingJoin.push(id); + return json(res, 200, { room_id: id }); + } + if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); + if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); + if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); + if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); + if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); + if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); + if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); + if (p.includes('/members')) return json(res, 200, { chunk: [] }); + if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); + if (p.includes('/capabilities')) return json(res, 200, { capabilities: {} }); + if (p.includes('/devices')) return json(res, 200, { devices: [] }); + if (p.includes('summary')) return json(res, 200, { room_id: p.split('/').pop(), name: 'Summary card', num_joined_members: 1, membership: 'invite', join_rule: 'invite' }); + if (p.includes('/login')) return json(res, 200, { flows: [{ type: 'm.login.password' }] }); + return json(res, 200, {}); + }); +}).listen(PORT, () => console.log('fakehs up')); +setInterval(flush, 500); diff --git a/harness/estate/run.sh b/harness/estate/run.sh new file mode 100644 index 00000000..085b17dc --- /dev/null +++ b/harness/estate/run.sh @@ -0,0 +1,15 @@ +# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: +# bash /src/harness/estate/run.sh +set -e +cd /src +mkdir -p /out +npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 +npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log +printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 +cp /src/harness/estate/drive.js /tmp/pw/ +HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 & +HS=$! +sleep 2 +node /tmp/pw/drive.js; echo DRIVE_RC=$? +kill $HS diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 811f531b..90b3e529 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -1,18 +1,35 @@ import React, { MouseEventHandler, ReactNode, useCallback, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAtom, useAtomValue } from 'jotai'; -import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { Avatar, Box, Chip, Icon, IconButton, Icons, Spinner, Text, color } from 'folds'; import { PageNav, PageNavContent, PageNavHeader } from '../../components/page'; -import { NavCategory, NavCategoryHeader, NavItem, NavItemContent } from '../../components/nav'; +import { + NavButton, + NavCategory, + NavCategoryHeader, + NavItem, + NavItemContent, +} from '../../components/nav'; +import { UseStateProvider } from '../../components/UseStateProvider'; +import { JoinAddressPrompt } from '../../components/join-address-prompt'; import { RoomNavCategoryButton, RoomNavItem } from '../room-nav'; import { InboxTab, SettingsTab, UnverifiedTab } from '../../pages/client/sidebar'; import { + encodeSearchParamValueArray, getDirectRoomPath, + getHomeCreatePath, getHomeRoomPath, - getHomeSearchPath, getSpaceRoomPath, + withSearchParam, } from '../../pages/pathUtils'; -import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; +import { _RoomSearchParams } from '../../pages/paths'; +import { searchModalAtom } from '../../state/searchModal'; +import { isDirectInvite } from '../../utils/room'; +import { + addRoomIdToMDirect, + getCanonicalAliasOrRoomId, + guessDmRoomUserId, +} from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; @@ -26,12 +43,15 @@ import { makeNavCategoryId } from '../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; -import { activateEstateRoom } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; + +/** How long a tap waits for the client store to hold the room it just joined. */ +const JOIN_ARRIVAL_TIMEOUT_MS = 20000; import { useEstate, useEstateRootId } from './useEstate'; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); - if (room.parentId === null) return getDirectRoomPath(alias); + if (room.direct) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); @@ -44,8 +64,19 @@ function JoinRow({ room }: { room: EstateRoom }) { const navigate = useNavigate(); const [state, join] = useAsyncCallback( useCallback(async () => { + const userId = mx.getSafeUserId(); + const dmRoom = mx.getRoom(room.roomId); + const dmUserId = + room.direct && dmRoom && isDirectInvite(dmRoom, userId) + ? guessDmRoomUserId(dmRoom, userId) + : undefined; const result = await activateEstateRoom(mx, room); if (result.kind === 'refused') throw new Error(result.message); + // joinRoom resolves before the store holds the room; opening it now shows the invite card. + if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { + throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.'); + } + if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); }, [mx, navigate, room]) ); @@ -91,7 +122,7 @@ function EstateRow({ room }: { room: EstateRoom }) { @@ -157,6 +188,64 @@ function Tier({ tier }: { tier: EstateTier }) { ); } +function EntryPoints() { + const navigate = useNavigate(); + return ( + + + navigate(getHomeCreatePath())}> + + + + + + + Create Room + + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + Join with Address + + + + + + {open && ( + setOpen(false)} + onOpen={(roomIdOrAlias, viaServers, eventId) => { + setOpen(false); + const path = getHomeRoomPath(roomIdOrAlias, eventId); + navigate( + viaServers + ? withSearchParam<_RoomSearchParams>(path, { + viaServers: encodeSearchParamValueArray(viaServers), + }) + : path + ); + }} + /> + )} + + )} + + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); const scrollRef = useRef(null); @@ -164,6 +253,7 @@ function EstateTiers({ rootId }: { rootId: string }) { return ( + {tiers.map((tier) => ( ))} @@ -184,7 +274,7 @@ function EstateTiers({ rootId }: { rootId: string }) { /** The single navigator: every nav slot that lists rooms mounts this. */ export function EstateNavigator() { - const navigate = useNavigate(); + const openRoomFinder = useSetAtom(searchModalAtom); const { rootId, error } = useEstateRootId(); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; @@ -200,8 +290,8 @@ export function EstateNavigator() { navigate(getHomeSearchPath())} + aria-label="Find a room" + onClick={() => openRoomFinder(true)} > diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 20129cbf..bdc47e5c 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -1,14 +1,26 @@ import { describe, expect, it } from 'vitest'; -import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfRoom } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateInput, stewardOfChild } from './buildEstate'; const ROOT = '!root:gamecult.org'; const PROJECTS = '!projects:gamecult.org'; -const space = (id: string, name: string, children: string[]): EstateHierarchyRoom => ({ +/** A Space whose child entries may carry the steward Mind, as the accounts script writes it. */ +const space = ( + id: string, + name: string, + children: string[], + stewards: Record = {} +): EstateHierarchyRoom => ({ room_id: id, name, room_type: 'm.space', - children_state: children.map((c) => ({ state_key: c, content: { via: ['gamecult.org'] } })), + children_state: children.map((c) => ({ + state_key: c, + content: { + via: ['gamecult.org'], + ...(stewards[c] ? { 'org.gamecult.steward': { mind: stewards[c], repo: 'x' } } : {}), + }, + })), }); const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => ({ room_id: id, @@ -19,22 +31,20 @@ const room = (id: string, name: string, alias?: string): EstateHierarchyRoom => const hierarchy = [ space(ROOT, 'GameCult', ['!general:gamecult.org', PROJECTS]), room('!general:gamecult.org', 'General', '#general:gamecult.org'), - space(PROJECTS, 'Projects', [ - '!cultlib:gamecult.org', - '!aeth:gamecult.org', - '!misc:gamecult.org', - ]), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org', '!misc:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'void' } + ), room('!cultlib:gamecult.org', 'CultLib', '#cultlib:gamecult.org'), room('!aeth:gamecult.org', 'Aetheria', '#Aetheria:gamecult.org'), room('!misc:gamecult.org', 'Misc', '#misc:gamecult.org'), ]; -const stewardships = { eureka: ['GameCult/CultLib'], void: ['GameCult/aetheria'] }; - const input = (over: Partial = {}): EstateInput => ({ rootId: ROOT, hierarchy, - stewardships, membership: new Map(), directRoomIds: [], ...over, @@ -80,7 +90,7 @@ describe('buildEstate', () => { expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); }); - it('groups rooms by steward Mind from stewardships plus canonical alias, case-insensitively', () => { + it('groups rooms by steward Mind from the steward on each room's Space child entry', () => { const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ ['eureka', ['!cultlib:gamecult.org']], @@ -105,12 +115,35 @@ describe('buildEstate', () => { expect(projects.loose.map((r) => r.roomId)).toEqual(['!misc:gamecult.org']); }); - it('degrades to loose rows when the stewardships are unreadable', () => { - const projects = buildEstate(input({ stewardships: undefined })).find( - (t) => t.id === PROJECTS - )!; - expect(projects.groups).toEqual([]); - expect(projects.loose).toHaveLength(3); + it('groups an invited-only user by the same child-entry stewards, with no root state', () => { + const invitee = new Map([ + [ROOT, 'invite'], + [PROJECTS, 'invite'], + ['!cultlib:gamecult.org', 'invite'], + ['!aeth:gamecult.org', 'invite'], + ]); + const projects = buildEstate(input({ membership: invitee })).find((t) => t.id === PROJECTS)!; + expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.state)])).toEqual([ + ['eureka', ['invited']], + ['void', ['invited']], + ]); + }); + + it('ignores a steward that is not a non-empty string', () => { + const odd = (steward: unknown): EstateHierarchyRoom[] => [ + { + ...space(ROOT, 'GameCult', ['!a:gamecult.org']), + children_state: [ + { state_key: '!a:gamecult.org', content: { 'org.gamecult.steward': { mind: steward } } }, + ], + }, + room('!a:gamecult.org', 'A'), + ]; + [42, '', undefined, null].forEach((m) => { + const t = buildEstate(input({ hierarchy: odd(m) }))[0]; + expect(t.groups).toEqual([]); + expect(t.loose).toHaveLength(1); + }); }); it('makes one tier per sub-Space in hierarchy order, root rooms first, Direct last', () => { @@ -133,6 +166,7 @@ describe('buildEstate', () => { 'Workshop', 'Direct Messages', ]); + expect(tiers[3].loose[0].direct).toBe(true); expect(tiers[0].loose.map((r) => r.name)).toEqual(['General']); expect(tiers[3].loose[0]).toMatchObject({ roomId: '!dm:gamecult.org', parentId: null }); }); @@ -158,7 +192,20 @@ describe('buildEstate', () => { }); it('puts every room of one Mind in a single group', () => { - const tiers = buildEstate(input({ stewardships: { eureka: ['CultLib', 'Aetheria'] } })); + const tiers = buildEstate( + input({ + hierarchy: [ + ...hierarchy.slice(0, 2), + space( + PROJECTS, + 'Projects', + ['!cultlib:gamecult.org', '!aeth:gamecult.org'], + { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' } + ), + ...hierarchy.slice(3, 5), + ], + }) + ); const projects = tiers.find((t) => t.id === PROJECTS)!; expect(projects.groups).toHaveLength(1); expect(projects.groups[0].rooms.map((r) => r.roomId)).toEqual([ @@ -202,15 +249,68 @@ describe('buildEstate', () => { }); }); -describe('stewardOfRoom', () => { - it('matches the alias localpart to the repo name, ignoring case and the org prefix', () => { - expect(stewardOfRoom('#CULTLIB:gamecult.org', stewardships)).toBe('eureka'); - expect(stewardOfRoom('#aetheria:gamecult.org', stewardships)).toBe('void'); +describe('stewardOfChild', () => { + it('reads the Mind from the org.gamecult.steward key of the child content', () => { + expect(stewardOfChild({ via: ['x'], 'org.gamecult.steward': { mind: 'void' } })).toBe('void'); }); - it('has no steward without an alias, a match or readable stewardships', () => { - expect(stewardOfRoom(undefined, stewardships)).toBeUndefined(); - expect(stewardOfRoom('#unknown:gamecult.org', stewardships)).toBeUndefined(); - expect(stewardOfRoom('#cultlib:gamecult.org', { eureka: 'GameCult/CultLib' })).toBeUndefined(); + it('has no steward without the key, content or a string Mind', () => { + expect(stewardOfChild(undefined)).toBeUndefined(); + expect(stewardOfChild({ via: ['x'] })).toBeUndefined(); + expect(stewardOfChild({ 'org.gamecult.steward': {} })).toBeUndefined(); + }); +}); + +describe('Elsewhere', () => { + const strays = (over: Partial) => + buildEstate(input({ membership: new Map([['!adhoc:x', 'join']]), ...over })).find( + (t) => t.id === 'elsewhere' + ); + + it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { + const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!; + expect(t.label).toBe('Elsewhere'); + expect(t.loose).toEqual([ + { roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false }, + ]); + }); + + it('does not repeat a room the hierarchy lists or a DM, and is last before Direct', () => { + const tiers = buildEstate( + input({ + membership: new Map([ + ['!general:gamecult.org', 'join'], + ['!dm:x', 'join'], + ['!adhoc:x', 'join'], + ]), + strayRoomIds: ['!general:gamecult.org', '!dm:x', '!adhoc:x'], + directRoomIds: ['!dm:x'], + }) + ); + expect(tiers.map((t) => t.id)).toEqual([ROOT, PROJECTS, 'elsewhere', 'direct']); + expect(tiers[2].loose.map((r) => r.roomId)).toEqual(['!adhoc:x']); + }); + + it('never lists a room the user has not joined', () => { + expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined(); + }); + + it('is absent when there are no strays', () => { + expect(strays({})).toBeUndefined(); + }); +}); + +describe('direct invites', () => { + it('lists an invited DM in the Direct tier as invited, named from the store', () => { + const tiers = buildEstate( + input({ + membership: new Map([['!dm:x', 'invite']]), + directRoomIds: ['!dm:x'], + roomNames: new Map([['!dm:x', 'Void']]), + }) + ); + expect(tiers[tiers.length - 1].loose).toEqual([ + { roomId: '!dm:x', name: 'Void', state: 'invited', via: [], parentId: null, direct: true }, + ]); }); }); diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9ca9048c..9edbf548 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -4,7 +4,12 @@ * * The server's /hierarchy of the root Space is the access rule. A room the * hierarchy does not return is never listed, whatever the client store knows; - * a room it returns is never hidden by the client. + * a room it returns is never hidden by the client. The one licensed addition is + * the user's own joined rooms that sit outside the Space (the Elsewhere tier). + * + * A room's steward is read from the hierarchy too: the content of the + * m.space.child entry that lists the room carries org.gamecult.steward, which an + * invited-only member can read (she cannot read the Space's state). */ export type EstateHierarchyRoom = { @@ -12,11 +17,16 @@ export type EstateHierarchyRoom = { name?: string; canonical_alias?: string; room_type?: string; - children_state?: { state_key: string; content?: { via?: string[] } }[]; + children_state?: { state_key: string; content?: SpaceChildContent }[]; }; -/** Mind localpart to the repos she stewards, as projected on the root Space. */ -export type Stewardships = Record; +export const STEWARD_KEY = 'org.gamecult.steward'; + +export type SpaceChildContent = { + via?: string[]; + /** The Mind who stewards the room, as the accounts script writes it. */ + [STEWARD_KEY]?: { mind?: unknown }; +}; export type EstateMembership = 'joined' | 'invited' | 'joinable'; @@ -26,8 +36,9 @@ export type EstateRoom = { state: EstateMembership; /** Servers to join through, from the hierarchy's children_state. */ via: string[]; - /** The Space tier the room sits in; null for direct messages. */ + /** The Space tier the room sits in; null for direct messages and Elsewhere rooms. */ parentId: string | null; + direct: boolean; }; export type EstateGroup = { @@ -48,38 +59,27 @@ export type EstateInput = { rootId: string; /** Rooms from the server's hierarchy of the root, in hierarchy order. */ hierarchy: readonly EstateHierarchyRoom[]; - stewardships: Stewardships | undefined; /** The user's own membership per room id, from the client store. */ membership: ReadonlyMap; + /** Joined and invited direct messages. */ directRoomIds: readonly string[]; + /** Joined non-DM, non-Space rooms, whether or not the hierarchy lists them. */ + strayRoomIds?: readonly string[]; + /** Names for rooms the hierarchy does not name (direct and stray rooms). */ + roomNames?: ReadonlyMap; /** Display names for Minds, keyed by localpart. */ mindLabels?: ReadonlyMap; }; export const DIRECT_TIER_ID = 'direct'; +export const ELSEWHERE_TIER_ID = 'elsewhere'; const SPACE_TYPE = 'm.space'; -const repoName = (repo: string): string => repo.split('/').pop()!.toLowerCase(); - -/** - * The single place a room's steward is derived: the canonical alias localpart, - * case-insensitively, against the repo names in the stewardships lists. - * (Follow-up ratatoskr:follow_up:stewardships-carry-room-ids swaps this to room ids.) - */ -export const stewardOfRoom = ( - canonicalAlias: string | undefined, - stewardships: Stewardships | undefined -): string | undefined => { - if (!canonicalAlias || !stewardships) return undefined; - const local = canonicalAlias.replace(/^#/, '').split(':')[0].toLowerCase(); - const mind = Object.keys(stewardships).find((m) => { - const repos = stewardships[m]; - return ( - Array.isArray(repos) && repos.some((r) => typeof r === 'string' && repoName(r) === local) - ); - }); - return mind; +/** The Mind a Space child entry names as the room's steward, if any. */ +export const stewardOfChild = (content: SpaceChildContent | undefined): string | undefined => { + const mind = content?.[STEWARD_KEY]?.mind; + return typeof mind === 'string' && mind !== '' ? mind : undefined; }; const stateOf = ( @@ -97,9 +97,10 @@ const roomLabel = (r: EstateHierarchyRoom): string => r.name || r.canonical_alia export const buildEstate = ({ rootId, hierarchy, - stewardships, membership, directRoomIds, + strayRoomIds = [], + roomNames, mindLabels, }: EstateInput): EstateTier[] => { const direct = new Set(directRoomIds); @@ -117,15 +118,16 @@ export const buildEstate = ({ hierarchy.forEach((r) => { if (r.room_type === SPACE_TYPE || !childIds.has(r.room_id) || direct.has(r.room_id)) return; - const via = space.children_state?.find((c) => c.state_key === r.room_id)?.content?.via ?? []; + const content = space.children_state?.find((c) => c.state_key === r.room_id)?.content; const room: EstateRoom = { roomId: r.room_id, name: roomLabel(r), state: stateOf(r.room_id, membership), - via, + via: content?.via ?? [], parentId: space.room_id, + direct: false, }; - const mind = stewardOfRoom(r.canonical_alias, stewardships); + const mind = stewardOfChild(content); if (!mind) { tier.loose.push(room); return; @@ -149,14 +151,36 @@ export const buildEstate = ({ label: 'Direct Messages', loose: directRoomIds.map((roomId) => ({ roomId, - name: roomId, + name: roomNames?.get(roomId) ?? roomId, state: stateOf(roomId, membership), via: [], parentId: null, + direct: true, })), groups: [], }; + const listedIds = new Set(hierarchy.map((r) => r.room_id)); + const elsewhereTier: EstateTier = { + id: ELSEWHERE_TIER_ID, + label: 'Elsewhere', + loose: strayRoomIds + .filter((id) => !listedIds.has(id) && !direct.has(id) && membership.get(id) === 'join') + .map((roomId) => ({ + roomId, + name: roomNames?.get(roomId) ?? roomId, + state: 'joined' as const, + via: [], + parentId: null, + direct: false, + })), + groups: [], + }; + const listed = tiers.filter((t) => t.loose.length > 0 || t.groups.length > 0); - return directTier.loose.length > 0 ? [...listed, directTier] : listed; + return [ + ...listed, + ...(elsewhereTier.loose.length > 0 ? [elsewhereTier] : []), + ...(directTier.loose.length > 0 ? [directTier] : []), + ]; }; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index fae21e5b..9bfddf1a 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -71,3 +71,55 @@ describe('activateEstateRoom', () => { expect(joinRoom).toHaveBeenCalledTimes(1); }); }); + +describe('awaitMyJoin', () => { + /** A store that gains the room when the test says so, emitting like the SDK. */ + const store = () => { + let membership: string | null = null; + const handlers = new Set<(room: { roomId: string }, m: string) => void>(); + return { + mx: { + getRoom: () => (membership ? { getMyMembership: () => membership! } : null), + on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), + off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + }, + handlers, + become: (roomId: string, m: string) => { + membership = m; + handlers.forEach((h) => h({ roomId }, m)); + }, + }; + }; + + it('resolves at once when the store already holds the joined room', async () => { + const s = store(); + s.become('!r:x', 'join'); + expect(await awaitMyJoin(s.mx, '!r:x', 1000)).toBe(true); + }); + + it('stays pending while the store still shows an invite, then resolves on join', async () => { + const s = store(); + let settled = false; + const p = awaitMyJoin(s.mx, '!r:x', 1000).then((v) => { + settled = true; + return v; + }); + s.become('!r:x', 'invite'); + s.become('!other:x', 'join'); + await Promise.resolve(); + expect(settled).toBe(false); + s.become('!r:x', 'join'); + expect(await p).toBe(true); + expect(s.handlers.size).toBe(0); + }); + + it('gives up after the timeout and stops listening', async () => { + vi.useFakeTimers(); + const s = store(); + const p = awaitMyJoin(s.mx, '!r:x', 500); + await vi.advanceTimersByTimeAsync(500); + expect(await p).toBe(false); + expect(s.handlers.size).toBe(0); + vi.useRealTimers(); + }); +}); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 2a5ed0d8..bec13126 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -28,3 +28,36 @@ export const activateEstateRoom = async ( return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; } }; + +type MembershipSource = { + getRoom(roomId: string): { getMyMembership(): string } | null; + on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; + off(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; +}; + +/** + * joinRoom resolves when the server accepts the join, before the client store + * holds the room; navigating then lands on the invite card. Resolves true once + * the store says the user's membership is join, false after timeoutMs. + */ +export const awaitMyJoin = ( + mx: MembershipSource, + roomId: string, + timeoutMs: number +): Promise => + new Promise((resolve) => { + if (mx.getRoom(roomId)?.getMyMembership() === 'join') { + resolve(true); + return; + } + const finish = (joined: boolean) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; + const onMembership = (room: { roomId: string }, membership: string) => { + if (room.roomId === roomId && membership === 'join') finish(true); + }; + const timer = setTimeout(() => finish(false), timeoutMs); + mx.on('Room.myMembership', onMembership); + }); diff --git a/src/app/features/estate/useEstate.ts b/src/app/features/estate/useEstate.ts index 99927ba8..d27d4710 100644 --- a/src/app/features/estate/useEstate.ts +++ b/src/app/features/estate/useEstate.ts @@ -4,14 +4,13 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useClientConfig } from '../../hooks/useClientConfig'; import { useFetchSpaceHierarchyLevel } from '../../hooks/useSpaceHierarchy'; -import { useForceUpdate } from '../../hooks/useForceUpdate'; -import { getStateEvent } from '../../utils/room'; +import { isDirectInvite, isRoom, isUnsupportedRoom } from '../../utils/room'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { allInvitesAtom } from '../../state/room-list/inviteList'; import { mDirectAtom } from '../../state/mDirectList'; import { StateEvent } from '../../../types/matrix/room'; -import { buildEstate, EstateHierarchyRoom, EstateTier, Stewardships } from './buildEstate'; +import { buildEstate, EstateHierarchyRoom, EstateTier, stewardOfChild } from './buildEstate'; const HIERARCHY_DEPTH = 10; @@ -50,9 +49,9 @@ export type Estate = { }; /** - * Feeds buildEstate: the server hierarchy of the root Space, the root's - * stewardships from the client store, the user's membership and m.direct. - * Recomputes on membership, Space child and stewardships changes. + * Feeds buildEstate: the server hierarchy of the root Space (which also carries + * each room's steward), the user's membership, m.direct and direct invites, and + * her joined rooms outside the Space. Recomputes on membership and Space child changes. */ export const useEstate = (rootId: string): Estate => { const mx = useMatrixClient(); @@ -62,8 +61,6 @@ export const useEstate = (rootId: string): Estate => { const invited = useAtomValue(allInvitesAtom); const mDirects = useAtomValue(mDirectAtom); - const [stewardshipsVersion, bumpStewardships] = useForceUpdate(); - const refetchHierarchy = useCallback( () => queryClient.invalidateQueries({ queryKey: [rootId, 'hierarchy_level'] }), [queryClient, rootId] @@ -73,9 +70,8 @@ export const useEstate = (rootId: string): Estate => { useCallback( (event) => { if (event.getType() === StateEvent.SpaceChild) refetchHierarchy(); - if (event.getType() === StateEvent.GameCultStewardships) bumpStewardships(); }, - [refetchHierarchy, bumpStewardships] + [refetchHierarchy] ) ); // What the server lets this user see changes with invitations. @@ -83,38 +79,44 @@ export const useEstate = (rootId: string): Estate => { refetchHierarchy(); }, [invited.length, refetchHierarchy]); - const stewardships = useMemo( - () => { - const rootRoom = mx.getRoom(rootId); - return rootRoom - ? (getStateEvent(rootRoom, StateEvent.GameCultStewardships)?.getContent() as - | Stewardships - | undefined) - : undefined; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [mx, rootId, stewardshipsVersion, joined, invited] - ); - const tiers = useMemo(() => { const membership = new Map(); invited.forEach((id) => membership.set(id, 'invite')); joined.forEach((id) => membership.set(id, 'join')); + const hierarchy = Array.from(rooms.values()) as unknown as EstateHierarchyRoom[]; const domain = mx.getDomain(); const mindLabels = new Map(); - Object.keys(stewardships ?? {}).forEach((mind) => { - const name = mx.getUser(`@${mind}:${domain}`)?.displayName; - if (name) mindLabels.set(mind, name); + hierarchy.forEach((r) => + r.children_state?.forEach((c) => { + const mind = stewardOfChild(c.content); + const name = mind && mx.getUser(`@${mind}:${domain}`)?.displayName; + if (mind && name) mindLabels.set(mind, name); + }) + ); + const userId = mx.getUserId(); + const directInvites = invited.filter((id) => isDirectInvite(mx.getRoom(id), userId)); + const directRoomIds = [ + ...new Set([...Array.from(mDirects).filter((id) => joined.includes(id)), ...directInvites]), + ]; + const strayRoomIds = joined.filter((id) => { + const r = mx.getRoom(id); + return !mDirects.has(id) && isRoom(r) && !isUnsupportedRoom(r); + }); + const roomNames = new Map(); + [...directInvites, ...strayRoomIds].forEach((id) => { + const name = mx.getRoom(id)?.name; + if (name) roomNames.set(id, name); }); return buildEstate({ rootId, - hierarchy: Array.from(rooms.values()) as unknown as EstateHierarchyRoom[], - stewardships, + hierarchy, membership, - directRoomIds: Array.from(mDirects).filter((id) => joined.includes(id)), + directRoomIds, + strayRoomIds, + roomNames, mindLabels, }); - }, [mx, rootId, rooms, stewardships, joined, invited, mDirects]); + }, [mx, rootId, rooms, joined, invited, mDirects]); return { fetching, error, tiers }; }; diff --git a/src/types/matrix/room.ts b/src/types/matrix/room.ts index 81d35bd0..c73c0f0b 100644 --- a/src/types/matrix/room.ts +++ b/src/types/matrix/room.ts @@ -36,7 +36,6 @@ export enum StateEvent { GroupCallMemberPrefix = 'org.matrix.msc3401.call.member', SpaceChild = 'm.space.child', - GameCultStewardships = 'org.gamecult.stewardships', SpaceParent = 'm.space.parent', PoniesRoomEmotes = 'im.ponies.room_emotes', From 1dfe38a33d2400e1b436fa49db08176ffb267789 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:48:31 +0100 Subject: [PATCH 10/40] Estate: lint order fixes, test title, harness out of prettier Co-Authored-By: Claude Sonnet 5.5 --- .prettierignore | 2 +- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/buildEstate.test.ts | 2 +- src/app/features/estate/buildEstate.ts | 16 ++++++++-------- src/app/features/estate/joinEstateRoom.ts | 11 ++++++----- 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/.prettierignore b/.prettierignore index e0aa18a1..3a4d7da1 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,4 @@ dist node_modules package.json package-lock.json -LICENSE \ No newline at end of file +LICENSEharness diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 90b3e529..1087c015 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -44,10 +44,10 @@ import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { useEstate, useEstateRootId } from './useEstate'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; -import { useEstate, useEstateRootId } from './useEstate'; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index bdc47e5c..29c58048 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -90,7 +90,7 @@ describe('buildEstate', () => { expect(tiers.flatMap((t) => [...t.loose, ...t.groups.flatMap((g) => g.rooms)])).toHaveLength(4); }); - it('groups rooms by steward Mind from the steward on each room's Space child entry', () => { + it('groups rooms by steward Mind from the steward on each Space child entry', () => { const projects = buildEstate(input()).find((t) => t.id === PROJECTS)!; expect(projects.groups.map((g) => [g.mind, g.rooms.map((r) => r.roomId)])).toEqual([ ['eureka', ['!cultlib:gamecult.org']], diff --git a/src/app/features/estate/buildEstate.ts b/src/app/features/estate/buildEstate.ts index 9edbf548..b9232beb 100644 --- a/src/app/features/estate/buildEstate.ts +++ b/src/app/features/estate/buildEstate.ts @@ -12,14 +12,6 @@ * invited-only member can read (she cannot read the Space's state). */ -export type EstateHierarchyRoom = { - room_id: string; - name?: string; - canonical_alias?: string; - room_type?: string; - children_state?: { state_key: string; content?: SpaceChildContent }[]; -}; - export const STEWARD_KEY = 'org.gamecult.steward'; export type SpaceChildContent = { @@ -28,6 +20,14 @@ export type SpaceChildContent = { [STEWARD_KEY]?: { mind?: unknown }; }; +export type EstateHierarchyRoom = { + room_id: string; + name?: string; + canonical_alias?: string; + room_type?: string; + children_state?: { state_key: string; content?: SpaceChildContent }[]; +}; + export type EstateMembership = 'joined' | 'invited' | 'joinable'; export type EstateRoom = { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index bec13126..df5284e3 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -50,14 +50,15 @@ export const awaitMyJoin = ( resolve(true); return; } - const finish = (joined: boolean) => { - clearTimeout(timer); - mx.off('Room.myMembership', onMembership); - resolve(joined); - }; + let finish: (joined: boolean) => void = () => undefined; const onMembership = (room: { roomId: string }, membership: string) => { if (room.roomId === roomId && membership === 'join') finish(true); }; const timer = setTimeout(() => finish(false), timeoutMs); + finish = (joined) => { + clearTimeout(timer); + mx.off('Room.myMembership', onMembership); + resolve(joined); + }; mx.on('Room.myMembership', onMembership); }); From 6eae929cd9cb7f9d1b54b8d64333e0ffb020b875 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:50:52 +0100 Subject: [PATCH 11/40] Estate: narrow parentId before the Space route lookup; prettier; ignore harness Co-Authored-By: Claude Sonnet 5.5 --- .prettierignore | 3 ++- src/app/features/estate/EstateNavigator.tsx | 2 +- src/app/features/estate/buildEstate.test.ts | 28 ++++++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/.prettierignore b/.prettierignore index 3a4d7da1..036ee8bc 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ dist node_modules package.json package-lock.json -LICENSEharness +LICENSE +harness diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 1087c015..f65ba827 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -53,7 +53,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom const alias = getCanonicalAliasOrRoomId(mx, room.roomId); if (room.direct) return getDirectRoomPath(alias); // A Space the user has not joined has no Space route; the room still resolves from Home. - if (mx.getRoom(room.parentId)?.getMyMembership() === 'join') { + if (room.parentId && mx.getRoom(room.parentId)?.getMyMembership() === 'join') { return getSpaceRoomPath(getCanonicalAliasOrRoomId(mx, room.parentId), alias); } return getHomeRoomPath(alias); diff --git a/src/app/features/estate/buildEstate.test.ts b/src/app/features/estate/buildEstate.test.ts index 29c58048..8f898a0f 100644 --- a/src/app/features/estate/buildEstate.test.ts +++ b/src/app/features/estate/buildEstate.test.ts @@ -196,12 +196,10 @@ describe('buildEstate', () => { input({ hierarchy: [ ...hierarchy.slice(0, 2), - space( - PROJECTS, - 'Projects', - ['!cultlib:gamecult.org', '!aeth:gamecult.org'], - { '!cultlib:gamecult.org': 'eureka', '!aeth:gamecult.org': 'eureka' } - ), + space(PROJECTS, 'Projects', ['!cultlib:gamecult.org', '!aeth:gamecult.org'], { + '!cultlib:gamecult.org': 'eureka', + '!aeth:gamecult.org': 'eureka', + }), ...hierarchy.slice(3, 5), ], }) @@ -268,10 +266,20 @@ describe('Elsewhere', () => { ); it('lists a joined non-DM room the hierarchy does not return, named from the store', () => { - const t = strays({ strayRoomIds: ['!adhoc:x'], roomNames: new Map([['!adhoc:x', 'Meeting']]) })!; + const t = strays({ + strayRoomIds: ['!adhoc:x'], + roomNames: new Map([['!adhoc:x', 'Meeting']]), + })!; expect(t.label).toBe('Elsewhere'); expect(t.loose).toEqual([ - { roomId: '!adhoc:x', name: 'Meeting', state: 'joined', via: [], parentId: null, direct: false }, + { + roomId: '!adhoc:x', + name: 'Meeting', + state: 'joined', + via: [], + parentId: null, + direct: false, + }, ]); }); @@ -292,7 +300,9 @@ describe('Elsewhere', () => { }); it('never lists a room the user has not joined', () => { - expect(strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] })).toBeUndefined(); + expect( + strays({ membership: new Map([['!adhoc:x', 'invite']]), strayRoomIds: ['!adhoc:x'] }) + ).toBeUndefined(); }); it('is absent when there are no strays', () => { From 1cb18a279533afd1b73f4eb4df4404080d2dc646 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:55:38 +0100 Subject: [PATCH 12/40] harness: CommonJS scripts under a type-module package Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/{drive.js => drive.cjs} | 0 harness/estate/{fakehs.js => fakehs.cjs} | 0 harness/estate/run.sh | 6 +++--- 3 files changed, 3 insertions(+), 3 deletions(-) rename harness/estate/{drive.js => drive.cjs} (100%) rename harness/estate/{fakehs.js => fakehs.cjs} (100%) diff --git a/harness/estate/drive.js b/harness/estate/drive.cjs similarity index 100% rename from harness/estate/drive.js rename to harness/estate/drive.cjs diff --git a/harness/estate/fakehs.js b/harness/estate/fakehs.cjs similarity index 100% rename from harness/estate/fakehs.js rename to harness/estate/fakehs.cjs diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 085b17dc..b37d3524 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -7,9 +7,9 @@ npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 -cp /src/harness/estate/drive.js /tmp/pw/ -HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.js /src/dist > /out/hs.out 2>&1 & +cp /src/harness/estate/drive.cjs /tmp/pw/ +HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.cjs /src/dist > /out/hs.out 2>&1 & HS=$! sleep 2 -node /tmp/pw/drive.js; echo DRIVE_RC=$? +node /tmp/pw/drive.cjs; echo DRIVE_RC=$? kill $HS From 11f0a4d325ae85152605a230eb541a36e393869c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:58:01 +0100 Subject: [PATCH 13/40] harness: reload instead of dismissing the address prompt Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 674cb15e..126781f3 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -24,9 +24,9 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o await pg.getByText('Join with Address').first().click(); await pg.waitForTimeout(400); check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); - await pg.keyboard.press('Escape'); - await pg.waitForTimeout(300); - if (await pg.locator('input[name="addressInput"]').count()) await pg.getByText('Cancel').first().click(); + await pg.reload(); + await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); + await pg.waitForTimeout(2000); // Header search is the room finder, on a phone await pg.locator('[aria-label="Find a room"]').first().click(); From d07c7f2327810084335b02f8cd7612bd5e0c2a9b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 17:59:53 +0100 Subject: [PATCH 14/40] harness: expand Elsewhere and Direct only when still collapsed Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 126781f3..6560b4e8 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -38,6 +38,8 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); + // A tier that was not rendered yet when its header was tapped is still collapsed. + if (!(await text()).toLowerCase().includes('meeting')) await open('Elsewhere'); await snap('expanded'); const t1 = (await text()).toLowerCase(); check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); @@ -61,7 +63,8 @@ const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!o // Join the DM: lands in the room too await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - await open('Direct Messages'); + if (!(await pg.locator('[aria-label="Join Void"]').count())) await open('Direct Messages'); + check((await pg.locator('[aria-label="Join Void"]').count()) === 1, 'the DM invite is still listed after the other join'); if (await pg.locator('[aria-label="Join Void"]').count()) { await pg.locator('[aria-label="Join Void"]').first().click(); let dmCard = 0; const t2 = Date.now(); From a058d2a2c6e2b075d3549f1d072d304d989d7a75 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:01:32 +0100 Subject: [PATCH 15/40] harness: the fake server echoes account data in the next sync Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/fakehs.cjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 641a21f4..f528f903 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -12,6 +12,7 @@ const DM = '!dm:test', MEETING = '!meeting:test'; const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; const pendingJoin = []; +const pendingAccountData = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, dm) => [ @@ -75,6 +76,7 @@ http.createServer((req, res) => { log.push(`${Date.now()} SYNC delivers join ${id}`); return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); } + if (pendingAccountData.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: {}, account_data: { events: pendingAccountData.splice(0) } }); return json(res, 200, { next_batch: since, rooms: {} }); }; return setTimeout(wait, pendingJoin.length ? 2500 : 1500); @@ -94,6 +96,12 @@ http.createServer((req, res) => { if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); + if (p.includes('/account_data/') && req.method === 'PUT') { + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { pendingAccountData.push({ type, content: JSON.parse(body) }); } catch (e) { /* ignore */ } + log.push(`${Date.now()} PUT account_data ${type}`); + return json(res, 200, {}); + } if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); if (p.includes('/members')) return json(res, 200, { chunk: [] }); From 0091e5e90d73d57a981d2b26a3a9eebd99d4c572 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:18 +0100 Subject: [PATCH 16/40] harness: Soul's s2 scenarios (unresolved root, slow DM and room joins, tap-target inventory) --- harness/estate/drive.cjs | 135 +++++++++++++++++++------------------- harness/estate/fakehs.cjs | 89 +++++++++++++------------ harness/estate/run.sh | 15 +++-- 3 files changed, 123 insertions(+), 116 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 6560b4e8..0dd254de 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,79 +1,82 @@ -// Drives the built client against fakehs.js on a phone viewport and asserts the estate navigator. const { chromium } = require('playwright'); const fs = require('fs'); -const out = (n) => `/out/${n}`; +const TAG = process.env.TAG; +const out = (n) => `/out/${TAG}-${n}`; const tl = []; -const fails = []; -const check = (ok, what) => { tl.push(`${ok ? 'PASS' : 'FAIL'} ${what}`); if (!ok) fails.push(what); }; +const T0 = Date.now(); +const t = () => `+${Date.now() - T0}ms`; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); const pg = await ctx.newPage(); + pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); }); await pg.goto('http://127.0.0.1:8080/'); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); - await pg.waitForTimeout(2000); + await pg.waitForTimeout(12000); const text = () => pg.evaluate(() => document.body.innerText); - const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); }; - const open = async (name) => { const h = pg.getByText(name, { exact: true }); if (await h.count()) { await h.first().click(); await pg.waitForTimeout(300); } }; - - // Entry points - const t0 = await text(); - check(/Create Room/.test(t0) && /Join with Address/.test(t0), 'Create Room and Join with Address are navigator actions'); - await pg.getByText('Join with Address').first().click(); - await pg.waitForTimeout(400); - check((await pg.locator('input[name="addressInput"]').count()) === 1, 'Join with Address opens its prompt'); - await pg.reload(); - await pg.getByText('Create Room').first().waitFor({ timeout: 30000 }); - await pg.waitForTimeout(2000); - - // Header search is the room finder, on a phone - await pg.locator('[aria-label="Find a room"]').first().click(); - await pg.waitForTimeout(500); - check((await pg.locator('input[placeholder="Search"]').count()) === 1 && !/#\/home\/search/.test(pg.url()), 'header search opens the room finder, not message search'); - await snap('finder'); - await pg.keyboard.press('Escape'); - await pg.waitForTimeout(300); - - // Groups for an invitee, from the hierarchy alone; Elsewhere; invited DM - for (const n of ['Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']) await open(n); - // A tier that was not rendered yet when its header was tapped is still collapsed. - if (!(await text()).toLowerCase().includes('meeting')) await open('Elsewhere'); - await snap('expanded'); - const t1 = (await text()).toLowerCase(); - check(t1.includes('nibu') && t1.includes('eureka'), 'steward groups render for an invitee who cannot read Space state'); - check(t1.includes('elsewhere') && t1.includes('meeting'), 'a joined room outside the Space is listed under Elsewhere'); - check(t1.includes('direct messages') && (await pg.locator('[aria-label="Join Void"]').count()) === 1, 'an invited DM is listed with Join'); - - // Join: no invite card at any frame, then the room - await pg.locator('[aria-label="Join Aetheria"]').first().click(); - const t = Date.now(); - let card = 0, room = null; - while (Date.now() - t < 6000) { - const body = await text(); - if (/Summary card/.test(body)) card++; - if (room === null && /welcome to Aetheria/.test(body)) room = Date.now() - t; - await pg.waitForTimeout(40); - } - await snap('joined'); - check(card === 0, `no invite card at any frame after the tap (saw ${card})`); - check(room !== null, `the room opened (after ${room} ms)`); - - // Join the DM: lands in the room too - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(3000); - if (!(await pg.locator('[aria-label="Join Void"]').count())) await open('Direct Messages'); - check((await pg.locator('[aria-label="Join Void"]').count()) === 1, 'the DM invite is still listed after the other join'); - if (await pg.locator('[aria-label="Join Void"]').count()) { - await pg.locator('[aria-label="Join Void"]').first().click(); - let dmCard = 0; const t2 = Date.now(); - while (Date.now() - t2 < 5000) { if (/Summary card/.test(await text())) dmCard++; await pg.waitForTimeout(40); } - check(dmCard === 0 && /\/direct\//.test(pg.url()), 'the invited DM opens in Direct with no invite card'); + const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); }; + const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })))); + const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44)))); + const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth })))); + await snap('1-landing'); await buttons(); await layout(); await small(); + if (process.env.NOROOT === '1') { + tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count())); + await b.close(); return; } + const reveal = async (sel) => { + const loc = pg.locator(sel); + if (await loc.count()) return loc; + for (let round = 0; round < 2; round++) for (const h of HEADERS) { + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) { tl.push(`revealed ${sel} via ${h}`); return loc; } } + } + tl.push(`could not reveal ${sel}`); return loc; + }; + // expand everything once for the inventory + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } } + await snap('2-expanded'); await buttons(); await layout(); await small(); + // room finder + const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first(); + await finder.click(); await pg.waitForTimeout(800); + tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder)))); + await snap('3-finder'); + await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + // join with frame poll + const joinPoll = async (name, ms) => { + const loc = await reveal(`[aria-label="Join ${name}"]`); + if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } + const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`); + let card = 0, frames = 0, urlAt = null, firstCardAt = null; + while (Date.now() - c < ms) { + const s = await text(); frames++; + if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; } + if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c; + await pg.waitForTimeout(40); + } + tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`); + await snap(`4-join-${name.replace(/\W/g, '')}`); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); + }; + await joinPoll('Commons', 6000); + await joinPoll('Aetheria', 6000); + await joinPoll('Friend DM', 6000); + // timeout path, room + const slow = async (name) => { + const loc = await reveal(`[aria-label="Join ${name}"]`); + if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } + await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); + await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); + tl.push(`timeout msg shown: ${(await text()).includes('has not arrived yet')}`); + await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); + tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); + }; + await slow('Odin'); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); + await slow('Slow DM'); + // wide: tier placement (wide tiers start open) + await pg.setViewportSize({ width: 1280, height: 800 }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide'); await b.close(); -})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); fails.push('error'); }).finally(() => { - fs.writeFileSync(out('timeline.txt'), tl.join('\n')); - console.log(tl.join('\n')); - process.exitCode = fails.length ? 1 : 0; -}); +})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n'))); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index f528f903..3b9ed641 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -1,26 +1,26 @@ -// Fake Matrix homeserver + static server for the built client (harness/estate/run.sh). -// Scenario: an invitee to the GameCult Space and 6 project rooms (never a member of the -// Space, so the client cannot read its state), joined to #general and to #meeting, a room -// outside the Space, and invited to a DM. The steward of each project room is carried only -// in the m.space.child content of the hierarchy, as the accounts script writes it. +// Soul s2 fake homeserver. Invitee with no root state; stewards only in m.space.child content. +// Rooms: GEN joined; OP invited; 6 project rooms invited (odin slow: join arrives at +24 s); +// PUB public joinable, not in store; STRAY joined outside hierarchy; DM1 invited direct (fast); +// DM2 invited direct (slow, +24 s). NOROOT=1: the root alias does not resolve. const http = require('http'), fs = require('fs'), path = require('path'); const DIST = process.argv[2], PORT = 8080, ME = '@t:test'; +const NOROOT = process.env.NOROOT === '1'; const log = []; const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; -const DM = '!dm:test', MEETING = '!meeting:test'; -const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1) })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test'; -const pendingJoin = []; -const pendingAccountData = []; -let ev = 0; +const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test'; +const SLOW = new Set([PROJ[5].id, DM2]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' }; +PROJ.forEach((r) => (NAMES[r.id] = r.name)); +const pending = []; const pendingAD = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); -const strip = (id, name, space, dm) => [ - { type: 'm.room.create', state_key: '', sender: '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, - ...(name ? [{ type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }] : []), - ...(dm ? [{ type: 'm.room.member', state_key: '@void:test', sender: '@void:test', content: { membership: 'join', displayname: 'Void' } }] : []), +const strip = (id, name, space, direct) => [ + { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, + { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, - { type: 'm.room.member', state_key: ME, sender: dm ? '@void:test' : '@admin:test', content: dm ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, + ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), + { type: 'm.room.member', state_key: ME, sender: direct ? '@friend:test' : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, ]; const joinedRoom = (id, name, extra = [], space = false) => ({ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, @@ -33,16 +33,18 @@ const invites = () => { o[PSP] = { invite_state: { events: strip(PSP, 'Projects', true) } }; o[OP] = { invite_state: { events: strip(OP, 'Operator') } }; PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); - o[DM] = { invite_state: { events: strip(DM, null, false, true) } }; + o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; + o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; return o; }; -const child = (id, alias) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(STEWARD[alias] ? { 'org.gamecult.steward': { repo: 'GameCult/' + alias, steward: '@' + STEWARD[alias] + ':test', mind: STEWARD[alias], source: 'x' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); +const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); const hroom = (id, name, alias, extra = {}) => ({ room_id: id, name, canonical_alias: alias, num_joined_members: 1, world_readable: false, guest_can_join: false, join_rule: 'invite', children_state: [], ...extra }); const hierarchy = () => [ - hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PSP)] }), + hroom(ROOT, 'GameCult', '#gamecult:test', { room_type: 'm.space', children_state: [child(GEN), child(OP), child(PUB), child(PSP)] }), hroom(GEN, 'General', '#general:test'), hroom(OP, 'Operator', '#operator:test'), - hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, r.alias.slice(1).split(':')[0])) }), + hroom(PUB, 'Commons', '#commons:test', { join_rule: 'public' }), + hroom(PSP, 'Projects', '#projects:test', { room_type: 'm.space', children_state: PROJ.map((r) => child(r.id, STEWARD[r.key])) }), ...PROJ.map((r) => hroom(r.id, r.name, r.alias)), ]; let syncN = 1; @@ -59,50 +61,47 @@ http.createServer((req, res) => { return fs.createReadStream(f).pipe(res); } let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { - log.push(`${Date.now()} ${req.method} ${p}`); + log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); if (p.endsWith('/sync')) { - if (!u.searchParams.get('since')) log.push(`${Date.now()} INITIAL SYNC`); const since = u.searchParams.get('since'); if (!since) { - const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[MEETING] = joinedRoom(MEETING, 'Meeting'); - const inv = invites(); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: inv, leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } - const wait = () => { - if (pendingJoin.length) { - const id = pendingJoin.shift(); const r = PROJ.find((x) => x.id === id) || { name: id === DM ? 'Void' : id }; - const join = {}; join[id] = joinedRoom(id, r.name); - log.push(`${Date.now()} SYNC delivers join ${id}`); - return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} } }); + const respond = () => { + const now = Date.now(); const join = {}; + for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { + const id = pending[i].id; pending.splice(i, 1); + const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`); } - if (pendingAccountData.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: {}, account_data: { events: pendingAccountData.splice(0) } }); + const ad = pendingAD.splice(0); + if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); }; - return setTimeout(wait, pendingJoin.length ? 2500 : 1500); + return setTimeout(respond, 300); } const jm = p.match(/\/rooms\/([^/]+)\/join$/) || p.match(/\/join\/([^/]+)$/); if (req.method === 'POST' && jm) { - const id = jm[1]; - log.push(`${Date.now()} JOIN ${id}`); + const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - pendingJoin.push(id); + pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) }); return json(res, 200, { room_id: id }); } + if (p.includes('/account_data/') && req.method === 'PUT') { + const type = decodeURIComponent(p.split('/account_data/')[1]); + try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {} + return json(res, 200, {}); + } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); - if (p.includes('/directory/room/')) return json(res, 200, { room_id: ROOT, servers: ['test'] }); + if (p.includes('/directory/room/')) return NOROOT ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'Room alias not found' }) : json(res, 200, { room_id: ROOT, servers: ['test'] }); if (p.endsWith('/keys/upload')) return json(res, 200, { one_time_key_counts: { signed_curve25519: 50 } }); if (p.endsWith('/keys/query')) return json(res, 200, { device_keys: {}, failures: {} }); if (p.includes('/room_keys/version')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/pushrules')) return json(res, 200, { global: { override: [], underride: [], room: [], sender: [], content: [] } }); if (p.includes('/filter')) return json(res, 200, { filter_id: '1' }); - if (p.includes('/account_data/') && req.method === 'PUT') { - const type = decodeURIComponent(p.split('/account_data/')[1]); - try { pendingAccountData.push({ type, content: JSON.parse(body) }); } catch (e) { /* ignore */ } - log.push(`${Date.now()} PUT account_data ${type}`); - return json(res, 200, {}); - } - if (p.includes('/account_data/')) return req.method === 'GET' ? json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }) : json(res, 200, {}); + if (p.includes('/account_data/')) return json(res, 404, { errcode: 'M_NOT_FOUND', error: 'none' }); if (p.includes('/messages')) return json(res, 200, { chunk: [], start: 't0', end: 't0' }); if (p.includes('/members')) return json(res, 200, { chunk: [] }); if (p.includes('/profile/')) return json(res, 200, { displayname: 'Tess' }); @@ -113,4 +112,4 @@ http.createServer((req, res) => { return json(res, 200, {}); }); }).listen(PORT, () => console.log('fakehs up')); -setInterval(flush, 500); +setInterval(flush, 300); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index b37d3524..af8bb5db 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,5 +1,6 @@ # Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: # bash /src/harness/estate/run.sh +# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s. set -e cd /src mkdir -p /out @@ -8,8 +9,12 @@ npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 cp /src/harness/estate/drive.cjs /tmp/pw/ -HSLOG=/out/hslog.txt node /src/harness/estate/fakehs.cjs /src/dist > /out/hs.out 2>&1 & -HS=$! -sleep 2 -node /tmp/pw/drive.cjs; echo DRIVE_RC=$? -kill $HS +runone () { # tag noroot + HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 & + HS=$! + sleep 2 + TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?" + kill $HS +} +runone root 0 +runone noroot 1 From 44774a3ba16165374a3f99ae035b8f3a40670466 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:45 +0100 Subject: [PATCH 17/40] estate navigator: entry points belong to the navigator, not the tiers; short wrapping errors; a DM joined after the timeout is filed on arrival; 44 px join targets on phones --- src/app/features/estate/EstateNavigator.tsx | 92 ++++++++++++++------- 1 file changed, 64 insertions(+), 28 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index f65ba827..63d0c0c2 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -48,6 +48,10 @@ import { useEstate, useEstateRootId } from './useEstate'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; +/** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ +const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; +/** Minimum tap target on phones. */ +const TOUCH_TARGET = 44; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -62,6 +66,7 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom function JoinRow({ room }: { room: EstateRoom }) { const mx = useMatrixClient(); const navigate = useNavigate(); + const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [state, join] = useAsyncCallback( useCallback(async () => { const userId = mx.getSafeUserId(); @@ -74,7 +79,14 @@ function JoinRow({ room }: { room: EstateRoom }) { if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - throw new Error('Joined, but the room has not arrived yet. Tap Join again in a moment.'); + // The join is accepted; when the room arrives the row becomes a joined row by itself, + // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). + if (dmUserId) { + awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) + .then((arrived) => arrived && addRoomIdToMDirect(mx, room.roomId, dmUserId)) + .catch(() => undefined); + } + throw new Error('Joined. The room is still on its way and will open from this list.'); } if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); @@ -84,14 +96,23 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + {room.name} {state.status === AsyncStatus.Error && ( - + {(state.error as Error).message} )} @@ -102,6 +123,7 @@ function JoinRow({ room }: { room: EstateRoom }) { before={busy ? : } onClick={join} disabled={busy} + style={mobile ? { minHeight: TOUCH_TARGET, minWidth: TOUCH_TARGET } : undefined} aria-label={`Join ${room.name}`} > Join @@ -248,27 +270,33 @@ function EntryPoints() { function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); - const scrollRef = useRef(null); return ( - - - - {tiers.map((tier) => ( - - ))} - {fetching && ( - - - - )} - {error && ( - - {error.message} - - )} - - + <> + {tiers.map((tier) => ( + + ))} + {fetching && ( + + + + )} + {error && ( + + )} + + ); +} + +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + ); } @@ -276,6 +304,7 @@ function EstateTiers({ rootId }: { rootId: string }) { export function EstateNavigator() { const openRoomFinder = useSetAtom(searchModalAtom); const { rootId, error } = useEstateRootId(); + const scrollRef = useRef(null); const mx = useMatrixClient(); const title = (rootId && mx.getRoom(rootId)?.name) || 'GameCult'; @@ -297,13 +326,20 @@ export function EstateNavigator() { - {rootId ? ( - - ) : ( - - {error ? {error.message} : } + + + + {rootId && } + {!rootId && error && ( + + )} + {!rootId && !error && ( + + + + )} - )} + From 2e179a77687548c95d6374c8870413de412769c5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:26:52 +0100 Subject: [PATCH 18/40] harness: print the verdict lines --- harness/estate/run.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index af8bb5db..c499cc5b 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -18,3 +18,10 @@ runone () { # tag noroot } runone root 0 runone noroot 1 +for t in root noroot; do + echo "=== $t" + grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true +done +echo "=== m.direct and joins (root)" +grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true +tail -1 /out/build.log From d7afdc520805e0fa4bc4391c79ed2a64c663c3b3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:30:54 +0100 Subject: [PATCH 19/40] estate navigator: 44 px tap targets on phones from one stylesheet rule over the navigator --- harness/estate/drive.cjs | 2 +- harness/estate/run.sh | 1 + .../features/estate/EstateNavigator.css.ts | 14 +++++++++++ src/app/features/estate/EstateNavigator.tsx | 25 +++++++++---------- 4 files changed, 28 insertions(+), 14 deletions(-) create mode 100644 src/app/features/estate/EstateNavigator.css.ts diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 0dd254de..fae012a6 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -68,7 +68,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); - tl.push(`timeout msg shown: ${(await text()).includes('has not arrived yet')}`); + tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); }; diff --git a/harness/estate/run.sh b/harness/estate/run.sh index c499cc5b..e044d07d 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -22,6 +22,7 @@ for t in root noroot; do echo "=== $t" grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true done +ls -la /out | head -30 echo "=== m.direct and joins (root)" grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true tail -1 /out/build.log diff --git a/src/app/features/estate/EstateNavigator.css.ts b/src/app/features/estate/EstateNavigator.css.ts new file mode 100644 index 00000000..157d69cd --- /dev/null +++ b/src/app/features/estate/EstateNavigator.css.ts @@ -0,0 +1,14 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; + +/** On phones every control inside the navigator is at least 44 px square to tap. */ +export const Targets = style({}); + +globalStyle(`${Targets} button, ${Targets} a`, { + '@media': { + [`screen and (max-width: ${MOBILE_BREAKPOINT}px)`]: { + minHeight: 44, + minWidth: 44, + }, + }, +}); diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 63d0c0c2..e36bfb04 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -45,13 +45,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { EstateRoom, EstateTier } from './buildEstate'; import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; import { useEstate, useEstateRootId } from './useEstate'; +import * as css from './EstateNavigator.css'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; /** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; -/** Minimum tap target on phones. */ -const TOUCH_TARGET = 44; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -66,7 +65,6 @@ const estateRoomPath = (mx: ReturnType, room: EstateRoom function JoinRow({ room }: { room: EstateRoom }) { const mx = useMatrixClient(); const navigate = useNavigate(); - const mobile = useScreenSizeContext() === ScreenSize.Mobile; const [state, join] = useAsyncCallback( useCallback(async () => { const userId = mx.getSafeUserId(); @@ -96,12 +94,7 @@ function JoinRow({ room }: { room: EstateRoom }) { return ( - + {room.name} @@ -123,7 +116,6 @@ function JoinRow({ room }: { room: EstateRoom }) { before={busy ? : } onClick={join} disabled={busy} - style={mobile ? { minHeight: TOUCH_TARGET, minWidth: TOUCH_TARGET } : undefined} aria-label={`Join ${room.name}`} > Join @@ -311,7 +303,7 @@ export function EstateNavigator() { return ( - + {title} @@ -327,7 +319,7 @@ export function EstateNavigator() { - + {rootId && } {!rootId && error && ( @@ -340,7 +332,14 @@ export function EstateNavigator() { )} - + From 98d74d564d8297db06840cc701877b88f48a0c47 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:35:36 +0100 Subject: [PATCH 20/40] estate navigator: define EstateError before its use --- src/app/features/estate/EstateNavigator.tsx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index e36bfb04..e81f5a67 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -260,6 +260,18 @@ function EntryPoints() { ); } +/** A short message that wraps inside the navigator; never a raw request error. */ +function EstateError({ message }: { message: string }) { + return ( + + {message} + + ); +} + function EstateTiers({ rootId }: { rootId: string }) { const { fetching, error, tiers } = useEstate(rootId); @@ -280,18 +292,6 @@ function EstateTiers({ rootId }: { rootId: string }) { ); } -/** A short message that wraps inside the navigator; never a raw request error. */ -function EstateError({ message }: { message: string }) { - return ( - - {message} - - ); -} - /** The single navigator: every nav slot that lists rooms mounts this. */ export function EstateNavigator() { const openRoomFinder = useSetAtom(searchModalAtom); From eeb9de8f401b2158ebc02cf72bb77a2c52d07561 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:36:44 +0100 Subject: [PATCH 21/40] estate navigator: the late DM filing returns void --- src/app/features/estate/EstateNavigator.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index e81f5a67..68575932 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -81,7 +81,9 @@ function JoinRow({ room }: { room: EstateRoom }) { // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). if (dmUserId) { awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) - .then((arrived) => arrived && addRoomIdToMDirect(mx, room.roomId, dmUserId)) + .then(async (arrived) => { + if (arrived) await addRoomIdToMDirect(mx, room.roomId, dmUserId); + }) .catch(() => undefined); } throw new Error('Joined. The room is still on its way and will open from this list.'); From bfb753e3e5e1562aa1bd11d2651adcd9a645d073 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:38:43 +0100 Subject: [PATCH 22/40] harness: print the unresolved-root message --- harness/estate/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index e044d07d..d4dd7cf4 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -23,6 +23,7 @@ for t in root noroot; do grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true done ls -la /out | head -30 +cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true echo "=== m.direct and joins (root)" grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true tail -1 /out/build.log From c91d0339b506131938dc775a3123b696c3c376ce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 18:44:39 +0100 Subject: [PATCH 23/40] harness: read the timeout message later in the pre-arrival window --- harness/estate/drive.cjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index fae012a6..5d69d02a 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -67,9 +67,9 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El const loc = await reveal(`[aria-label="Join ${name}"]`); if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); - await pg.waitForTimeout(21500); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); + await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); - await pg.waitForTimeout(5500); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); + await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); }; await slow('Odin'); From 3d4fb161c3cc98c036ac07f215e8efa10af20a70 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 19:41:38 +0100 Subject: [PATCH 24/40] one filer for direct messages: any joined room whose invite was marked is_direct is written to m.direct in one serialized write, on startup and on every sync; the per-tap late filer is gone (Soul s3 late-dm-filings-overwrite-each-other) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 32 +--- src/app/hooks/useCommands.ts | 12 +- src/app/hooks/useFileDirectRooms.ts | 20 +++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/utils/mDirect.test.ts | 154 +++++++++++++++++++ src/app/utils/matrix.ts | 121 +++++++++------ src/app/utils/room.ts | 12 ++ 7 files changed, 281 insertions(+), 72 deletions(-) create mode 100644 src/app/hooks/useFileDirectRooms.ts create mode 100644 src/app/utils/mDirect.test.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index 68575932..bf5cfc24 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -24,12 +24,7 @@ import { } from '../../pages/pathUtils'; import { _RoomSearchParams } from '../../pages/paths'; import { searchModalAtom } from '../../state/searchModal'; -import { isDirectInvite } from '../../utils/room'; -import { - addRoomIdToMDirect, - getCanonicalAliasOrRoomId, - guessDmRoomUserId, -} from '../../utils/matrix'; +import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useCallEmbed } from '../../hooks/useCallEmbed'; @@ -49,8 +44,6 @@ import * as css from './EstateNavigator.css'; /** How long a tap waits for the client store to hold the room it just joined. */ const JOIN_ARRIVAL_TIMEOUT_MS = 20000; -/** How long after a timed-out tap the store may still deliver the room and have its DM filed. */ -const LATE_ARRIVAL_WINDOW_MS = 10 * 60 * 1000; const estateRoomPath = (mx: ReturnType, room: EstateRoom): string => { const alias = getCanonicalAliasOrRoomId(mx, room.roomId); @@ -67,28 +60,14 @@ function JoinRow({ room }: { room: EstateRoom }) { const navigate = useNavigate(); const [state, join] = useAsyncCallback( useCallback(async () => { - const userId = mx.getSafeUserId(); - const dmRoom = mx.getRoom(room.roomId); - const dmUserId = - room.direct && dmRoom && isDirectInvite(dmRoom, userId) - ? guessDmRoomUserId(dmRoom, userId) - : undefined; const result = await activateEstateRoom(mx, room); if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself, - // so a DM is filed then (a failed write leaves it under Elsewhere, where it can be found). - if (dmUserId) { - awaitMyJoin(mx, room.roomId, LATE_ARRIVAL_WINDOW_MS) - .then(async (arrived) => { - if (arrived) await addRoomIdToMDirect(mx, room.roomId, dmUserId); - }) - .catch(() => undefined); - } + // The join is accepted; when the room arrives the row becomes a joined row by itself + // and a direct message is filed by useFileDirectRooms. throw new Error('Joined. The room is still on its way and will open from this list.'); } - if (dmUserId) await addRoomIdToMDirect(mx, room.roomId, dmUserId); navigate(estateRoomPath(mx, room)); }, [mx, navigate, room]) ); @@ -265,10 +244,7 @@ function EntryPoints() { /** A short message that wraps inside the navigator; never a raw request error. */ function EstateError({ message }: { message: string }) { return ( - + {message} ); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..798423c4 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -24,7 +24,7 @@ import { } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent } from '../utils/room'; +import { getStateEvent, wasInvitedAsDirect } from '../utils/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; @@ -411,6 +411,16 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Convert direct message to room', exe: async () => { await removeRoomIdFromMDirect(mx, room.roomId); + // The filer reads the invite's mark; clear it so the room stays a room. + const me = mx.getSafeUserId(); + if (wasInvitedAsDirect(room, me)) { + await mx.sendStateEvent( + room.roomId, + StateEvent.RoomMember as any, + { ...room.getMember(me)?.events?.member?.getContent(), is_direct: false }, + me + ); + } }, }, [Command.Delete]: { diff --git a/src/app/hooks/useFileDirectRooms.ts b/src/app/hooks/useFileDirectRooms.ts new file mode 100644 index 00000000..023316b5 --- /dev/null +++ b/src/app/hooks/useFileDirectRooms.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; +import { useMatrixClient } from './useMatrixClient'; +import { fileDirectRooms } from '../utils/matrix'; + +/** Keeps direct messages filed in m.direct: on startup, on every sync, on every membership change. */ +export const useFileDirectRooms = (): void => { + const mx = useMatrixClient(); + useEffect(() => { + const file = () => { + fileDirectRooms(mx).catch(() => undefined); + }; + file(); + mx.on('sync' as never, file); + mx.on('Room.myMembership' as never, file); + return () => { + mx.off('sync' as never, file); + mx.off('Room.myMembership' as never, file); + }; + }, [mx]); +}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index ce952bfc..5af097e4 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,6 +26,7 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { useFileDirectRooms } from '../../hooks/useFileDirectRooms'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -258,6 +259,7 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + useFileDirectRooms(); return ( <> diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts new file mode 100644 index 00000000..55fc8602 --- /dev/null +++ b/src/app/utils/mDirect.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; +import { addRoomIdToMDirect, fileDirectRooms, removeRoomIdFromMDirect } from './matrix'; +import { wasInvitedAsDirect } from './room'; + +const ME = '@me:test'; +type Content = Record; + +type FakeRoomOpts = { + membership?: string; + content?: Record; + prev?: Record; + friend?: string | null; +}; + +/** A joined room as the store holds it: my member event, and the friend once synced. */ +const fakeRoom = (roomId: string, o: FakeRoomOpts = {}) => { + const memberEvent = { + getContent: () => o.content ?? { membership: 'join' }, + getPrevContent: () => o.prev ?? {}, + }; + const friend = o.friend === undefined ? '@friend:test' : o.friend; + return { + roomId, + getMyMembership: () => o.membership ?? 'join', + getMember: () => ({ events: { member: memberEvent } }), + getJoinedMembers: () => + friend ? [{ userId: friend, events: { member: { getTs: () => 1 } } }] : [], + getLiveTimeline: () => ({ getState: () => undefined }), + }; +}; +const lateDm = (roomId: string, o: FakeRoomOpts = {}) => + fakeRoom(roomId, { prev: { membership: 'invite', is_direct: true }, ...o }); + +/** A client whose account-data write takes a while and lands only when it completes. */ +const fakeClient = (rooms: ReturnType[], initial?: Content) => { + let direct: Content | undefined = initial; + const writes: Content[] = []; + const mx = { + getSafeUserId: () => ME, + getRooms: () => rooms, + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_type: string, content: Content) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + }; + return { mx: mx as never, raw: mx, writes, current: () => direct }; +}; + +describe('wasInvitedAsDirect', () => { + it('reads the mark from the join event or from the invite it replaced', () => { + expect(wasInvitedAsDirect(fakeRoom('!a', { content: { is_direct: true } }) as never, ME)).toBe( + true + ); + expect(wasInvitedAsDirect(lateDm('!b') as never, ME)).toBe(true); + expect(wasInvitedAsDirect(fakeRoom('!c') as never, ME)).toBe(false); + }); + + it('ignores a mark on a state the member event has since left', () => { + const room = fakeRoom('!d', { + prev: { membership: 'join', is_direct: true }, + content: { is_direct: false }, + }); + expect(wasInvitedAsDirect(room as never, ME)).toBe(false); + }); +}); + +describe('fileDirectRooms', () => { + it('files two DMs that arrive in the same sync, none lost', async () => { + const c = fakeClient([lateDm('!dm2:test'), lateDm('!dm3:test')]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(1); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('serializes overlapping runs so a later write builds on the earlier one', async () => { + const c = fakeClient([lateDm('!dm2:test'), fakeRoom('!room:test')]); + await Promise.all([ + fileDirectRooms(c.mx), + addRoomIdToMDirect(c.mx, '!made:test', '@other:test'), + fileDirectRooms(c.mx), + ]); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'], '@other:test': ['!made:test'] }); + expect(c.writes).toHaveLength(2); + }); + + it('files a DM found at startup, after a reload while the join was in flight', async () => { + const reloaded = fakeClient([lateDm('!dm3:test')], { '@friend:test': ['!dm1:test'] }); + await fileDirectRooms(reloaded.mx); + expect(reloaded.current()).toEqual({ '@friend:test': ['!dm1:test', '!dm3:test'] }); + }); + + it('writes nothing when every marked room is already filed', async () => { + const c = fakeClient([lateDm('!dm2:test')], { '@friend:test': ['!dm2:test'] }); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + }); + + it('skips rooms that are not joined, not marked, or whose other party has not synced', async () => { + const c = fakeClient([ + lateDm('!invited:test', { membership: 'invite' }), + fakeRoom('!plain:test'), + lateDm('!nofriend:test', { friend: null }), + ]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + // the next sync delivers the friend; the same call files it + const later = fakeClient([lateDm('!nofriend:test')]); + await fileDirectRooms(later.mx); + expect(later.current()).toEqual({ '@friend:test': ['!nofriend:test'] }); + }); + + it('does not file a room whose mark was cleared when it was converted to a room', async () => { + const converted = fakeRoom('!conv:test', { + content: { membership: 'join', is_direct: false }, + prev: { membership: 'join', is_direct: true }, + }); + const c = fakeClient([converted]); + await fileDirectRooms(c.mx); + expect(c.writes).toHaveLength(0); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient([lateDm('!dm2:test')]); + const original = c.raw.setAccountData; + let fail = true; + c.raw.setAccountData = async (...a: [string, Content]) => { + if (fail) { + fail = false; + throw new Error('offline'); + } + return original(...a); + }; + await expect(fileDirectRooms(c.mx)).rejects.toThrow('offline'); + await fileDirectRooms(c.mx); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'] }); + }); +}); + +describe('m.direct edits', () => { + it('moves a room between users and removes it, one write at a time', async () => { + const c = fakeClient([], { '@a:test': ['!r:test'] }); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), + removeRoomIdFromMDirect(c.mx, '!gone:test'), + ]); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': ['!r:test'] }); + await removeRoomIdFromMDirect(c.mx, '!r:test'); + expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); + }); +}); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 4c86c4e2..f176764a 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -16,7 +16,7 @@ import { import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getStateEvent } from './room'; +import { getStateEvent, wasInvitedAsDirect } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -225,57 +225,92 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { return member1?.userId ?? myUserId; }; -export const addRoomIdToMDirect = async ( +type MDirectContent = Record; +type DirectEntry = { roomId: string; userId: string }; + +const mDirectWrites = new WeakMap>(); + +/** + * The only writer of m.direct. m.direct is one account-data event that every + * edit replaces whole, so edits run one at a time, each reading the content the + * previous one wrote. `edit` returns the new content, or undefined for no write. + */ +export const updateMDirect = ( + mx: MatrixClient, + edit: (current: MDirectContent) => MDirectContent | undefined +): Promise => { + const run = (mDirectWrites.get(mx) ?? Promise.resolve()).then(async () => { + const event = mx.getAccountData(AccountDataEvent.Direct as any); + const next = edit(event ? structuredClone(event.getContent()) : {}); + if (next) await mx.setAccountData(AccountDataEvent.Direct as any, next as any); + }); + mDirectWrites.set( + mx, + run.catch(() => undefined) + ); + return run; +}; + +/** A room can be a DM for one person only: it leaves every other user's list. */ +export const withDirectEntries = ( + current: MDirectContent, + entries: DirectEntry[] +): MDirectContent => { + const next = structuredClone(current); + entries.forEach(({ roomId, userId }) => { + Object.keys(next).forEach((targetUserId) => { + if (targetUserId !== userId) { + next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); + } + }); + const roomIds = next[userId] ?? []; + if (!roomIds.includes(roomId)) roomIds.push(roomId); + next[userId] = roomIds; + }); + return next; +}; + +export const addRoomIdToMDirect = ( mx: MatrixClient, roomId: string, userId: string -): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; +): Promise => + updateMDirect(mx, (current) => withDirectEntries(current, [{ roomId, userId }])); - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - // remove it from the lists of any others users - // (it can only be a DM room for one person) - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - - if (targetUserId !== userId) { - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } - } +/** + * The one filer of direct messages. Files every joined room whose invite was + * marked is_direct and which m.direct does not list, in a single write, once + * the other party is known. Run on startup and on every sync: a DM that + * arrives late, in a burst or after a reload is filed by the same check. + */ +export const fileDirectRooms = (mx: MatrixClient): Promise => { + const me = mx.getSafeUserId(); + return updateMDirect(mx, (current) => { + const filed = new Set(Object.values(current).flat()); + const entries = mx + .getRooms() + .filter( + (room: Room) => + room.getMyMembership() === 'join' && + !filed.has(room.roomId) && + wasInvitedAsDirect(room, me) + ) + .map((room: Room) => ({ roomId: room.roomId, userId: guessDmRoomUserId(room, me) })) + // The other party's membership has not synced yet; a later sync files it. + .filter((entry: DirectEntry) => entry.userId !== me); + return entries.length > 0 ? withDirectEntries(current, entries) : undefined; }); - - const roomIds = userIdToRoomIds[userId] || []; - if (roomIds.indexOf(roomId) === -1) { - roomIds.push(roomId); - } - userIdToRoomIds[userId] = roomIds; - - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); }; -export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string): Promise => { - const mDirectsEvent = mx.getAccountData(AccountDataEvent.Direct as any); - let userIdToRoomIds: Record = {}; - - if (typeof mDirectsEvent !== 'undefined') - userIdToRoomIds = structuredClone(mDirectsEvent.getContent()); - - Object.keys(userIdToRoomIds).forEach((targetUserId) => { - const roomIds = userIdToRoomIds[targetUserId]; - const indexOfRoomId = roomIds.indexOf(roomId); - if (indexOfRoomId > -1) { - roomIds.splice(indexOfRoomId, 1); - } +export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => + updateMDirect(mx, (current) => { + const next: MDirectContent = {}; + Object.keys(current).forEach((userId) => { + next[userId] = current[userId].filter((id) => id !== roomId); + }); + return next; }); - await mx.setAccountData(AccountDataEvent.Direct as any, userIdToRoomIds as any); -}; - export const mxcUrlToHttp = ( mx: MatrixClient, mxcUrl: string, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 05dfeb29..d4cd757c 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,6 +72,18 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; +/** + * Whether the invite that brought this user into the room was marked is_direct. + * The join event keeps the mark in its own content when the server copies it, + * and otherwise in the invite it replaced (unsigned.prev_content). + */ +export const wasInvitedAsDirect = (room: Room, myUserId: string): boolean => { + const event = room.getMember(myUserId)?.events?.member; + if (event?.getContent()?.is_direct === true) return true; + const prev = event?.getPrevContent(); + return prev?.membership === 'invite' && prev.is_direct === true; +}; + export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); From 282769943931fd1f3cf91c7f02803dcda1789a0b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:11:45 +0100 Subject: [PATCH 25/40] direct messages are listed in m.direct before the join is sent, from the invite event's is_direct; the arrival-time filer, its hook and the member-flag rewrite are deleted (Soul s4 converttoroom-refiled-by-filer, dm-mark-lost-after-member-rewrite) Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/EstateNavigator.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 63 +++++++ src/app/features/estate/joinEstateRoom.ts | 28 +++- src/app/hooks/useCommands.ts | 12 +- src/app/hooks/useFileDirectRooms.ts | 20 --- src/app/pages/client/ClientNonUIFeatures.tsx | 2 - src/app/utils/mDirect.test.ts | 155 ++++-------------- src/app/utils/matrix.ts | 51 +----- src/app/utils/room.ts | 12 -- 9 files changed, 128 insertions(+), 218 deletions(-) delete mode 100644 src/app/hooks/useFileDirectRooms.ts diff --git a/src/app/features/estate/EstateNavigator.tsx b/src/app/features/estate/EstateNavigator.tsx index bf5cfc24..6fa4bf48 100644 --- a/src/app/features/estate/EstateNavigator.tsx +++ b/src/app/features/estate/EstateNavigator.tsx @@ -64,8 +64,7 @@ function JoinRow({ room }: { room: EstateRoom }) { if (result.kind === 'refused') throw new Error(result.message); // joinRoom resolves before the store holds the room; opening it now shows the invite card. if (!(await awaitMyJoin(mx, room.roomId, JOIN_ARRIVAL_TIMEOUT_MS))) { - // The join is accepted; when the room arrives the row becomes a joined row by itself - // and a direct message is filed by useFileDirectRooms. + // The join is accepted; when the room arrives the row becomes a joined row by itself. throw new Error('Joined. The room is still on its way and will open from this list.'); } navigate(estateRoomPath(mx, room)); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 9bfddf1a..bf96a4af 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -18,6 +18,8 @@ const recordingClient = (joinRoom = vi.fn().mockResolvedValue({ roomId: '!r:game return joinRoom(...args); }; } + if (prop === 'getRoom') return () => null; + if (prop === 'getSafeUserId') return () => '@me:test'; return () => { calls.push(prop); throw new Error(`unexpected client call: ${prop}`); @@ -72,6 +74,67 @@ describe('activateEstateRoom', () => { }); }); +/** A client holding DM invites as the server sends them: the mark is in the invite event only. */ +const dmClient = (opts: { refuse?: boolean; invites: Record }) => { + let direct: Record | undefined; + const writes: Record[] = []; + const joined: string[] = []; + const mx = { + getSafeUserId: () => '@me:test', + getRoom: (roomId: string) => ({ + getMember: () => ({ + events: { + member: { + getContent: () => ({ membership: 'invite', is_direct: true }), + getSender: () => opts.invites[roomId], + }, + }, + }), + }), + getAccountData: () => (direct ? { getContent: () => direct } : undefined), + setAccountData: async (_t: string, content: Record) => { + await new Promise((r) => { + setTimeout(r, 5); + }); + writes.push(structuredClone(content)); + direct = structuredClone(content); + }, + joinRoom: async (roomId: string) => { + // the join lands only after m.direct has been written: record what it held + joined.push(JSON.stringify(direct ?? {})); + if (opts.refuse) throw new Error('M_FORBIDDEN'); + return { roomId }; + }, + }; + return { mx: mx as never, writes, joined, current: () => direct }; +}; +const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); + +describe('activateEstateRoom for a direct message invite', () => { + it('lists the room under the inviter before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('keeps both rooms when two DM joins are tapped together', async () => { + const c = dmClient({ invites: { '!dm2:test': '@friend:test', '!dm3:test': '@friend:test' } }); + const results = await Promise.all([ + activateEstateRoom(c.mx, tap('!dm2:test')), + activateEstateRoom(c.mx, tap('!dm3:test')), + ]); + expect(results.map((r) => r.kind)).toEqual(['joined', 'joined']); + expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); + }); + + it('takes the room out again when the join is refused', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'M_FORBIDDEN' }); + expect(c.current()).toEqual({ '@friend:test': [] }); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index df5284e3..36a9166e 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,31 +1,49 @@ import { EstateRoom } from './buildEstate'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { isDirectInvite } from '../../utils/room'; export type EstateActivation = | { kind: 'open'; roomId: string } | { kind: 'joined'; roomId: string } | { kind: 'refused'; message: string }; -type JoinClient = { - joinRoom(roomId: string, opts: { viaServers: string[] }): Promise<{ roomId: string }>; -}; +type JoinClient = Parameters[0]; /** * What a tap on an estate row does. A joined room opens; any other room is * joined, and only here (plus a first post in the room view). Opening a room * never joins it and the client has no peek path. The server decides whether * the join is allowed; its refusal is reported, the row is not hidden. + * + * An invite marked is_direct carries the mark in the invite event the client + * already holds, and the join event does not repeat it. So the room is listed + * in m.direct under the inviter before the join is sent, and taken out again if + * the join is refused. Nothing needs to recognise the DM after it arrives. */ export const activateEstateRoom = async ( mx: JoinClient, room: Pick ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; + const refusal = (e: unknown): EstateActivation => { + const err = e as { data?: { error?: string }; message?: string }; + return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + }; + const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; + const inviter = isDirectInvite(invite, mx.getSafeUserId()) + ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() + : undefined; + try { + if (inviter) await addRoomIdToMDirect(mx, room.roomId, inviter); + } catch (e) { + return refusal(e); + } try { const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - const err = e as { data?: { error?: string }; message?: string }; - return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; + if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); + return refusal(e); } }; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 798423c4..c02b0e94 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -24,7 +24,7 @@ import { } from '../utils/matrix'; import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; -import { getStateEvent, wasInvitedAsDirect } from '../utils/room'; +import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; import { createRoomEncryptionState } from '../components/create-room'; @@ -411,16 +411,6 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { description: 'Convert direct message to room', exe: async () => { await removeRoomIdFromMDirect(mx, room.roomId); - // The filer reads the invite's mark; clear it so the room stays a room. - const me = mx.getSafeUserId(); - if (wasInvitedAsDirect(room, me)) { - await mx.sendStateEvent( - room.roomId, - StateEvent.RoomMember as any, - { ...room.getMember(me)?.events?.member?.getContent(), is_direct: false }, - me - ); - } }, }, [Command.Delete]: { diff --git a/src/app/hooks/useFileDirectRooms.ts b/src/app/hooks/useFileDirectRooms.ts deleted file mode 100644 index 023316b5..00000000 --- a/src/app/hooks/useFileDirectRooms.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useEffect } from 'react'; -import { useMatrixClient } from './useMatrixClient'; -import { fileDirectRooms } from '../utils/matrix'; - -/** Keeps direct messages filed in m.direct: on startup, on every sync, on every membership change. */ -export const useFileDirectRooms = (): void => { - const mx = useMatrixClient(); - useEffect(() => { - const file = () => { - fileDirectRooms(mx).catch(() => undefined); - }; - file(); - mx.on('sync' as never, file); - mx.on('Room.myMembership' as never, file); - return () => { - mx.off('sync' as never, file); - mx.off('Room.myMembership' as never, file); - }; - }, [mx]); -}; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 5af097e4..ce952bfc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -26,7 +26,6 @@ import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { useSelectedRoom } from '../../hooks/router/useSelectedRoom'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { useFileDirectRooms } from '../../hooks/useFileDirectRooms'; function SystemEmojiFeature() { const [twitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); @@ -259,7 +258,6 @@ type ClientNonUIFeaturesProps = { }; export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { - useFileDirectRooms(); return ( <> diff --git a/src/app/utils/mDirect.test.ts b/src/app/utils/mDirect.test.ts index 55fc8602..1deaf37e 100644 --- a/src/app/utils/mDirect.test.ts +++ b/src/app/utils/mDirect.test.ts @@ -1,43 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { addRoomIdToMDirect, fileDirectRooms, removeRoomIdFromMDirect } from './matrix'; -import { wasInvitedAsDirect } from './room'; +import { addRoomIdToMDirect, removeRoomIdFromMDirect } from './matrix'; -const ME = '@me:test'; type Content = Record; -type FakeRoomOpts = { - membership?: string; - content?: Record; - prev?: Record; - friend?: string | null; -}; - -/** A joined room as the store holds it: my member event, and the friend once synced. */ -const fakeRoom = (roomId: string, o: FakeRoomOpts = {}) => { - const memberEvent = { - getContent: () => o.content ?? { membership: 'join' }, - getPrevContent: () => o.prev ?? {}, - }; - const friend = o.friend === undefined ? '@friend:test' : o.friend; - return { - roomId, - getMyMembership: () => o.membership ?? 'join', - getMember: () => ({ events: { member: memberEvent } }), - getJoinedMembers: () => - friend ? [{ userId: friend, events: { member: { getTs: () => 1 } } }] : [], - getLiveTimeline: () => ({ getState: () => undefined }), - }; -}; -const lateDm = (roomId: string, o: FakeRoomOpts = {}) => - fakeRoom(roomId, { prev: { membership: 'invite', is_direct: true }, ...o }); - /** A client whose account-data write takes a while and lands only when it completes. */ -const fakeClient = (rooms: ReturnType[], initial?: Content) => { +const fakeClient = (initial?: Content) => { let direct: Content | undefined = initial; const writes: Content[] = []; const mx = { - getSafeUserId: () => ME, - getRooms: () => rooms, getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_type: string, content: Content) => { await new Promise((r) => { @@ -47,102 +17,12 @@ const fakeClient = (rooms: ReturnType[], initial?: Content) => direct = structuredClone(content); }, }; - return { mx: mx as never, raw: mx, writes, current: () => direct }; + return { mx: mx as never, writes, current: () => direct }; }; -describe('wasInvitedAsDirect', () => { - it('reads the mark from the join event or from the invite it replaced', () => { - expect(wasInvitedAsDirect(fakeRoom('!a', { content: { is_direct: true } }) as never, ME)).toBe( - true - ); - expect(wasInvitedAsDirect(lateDm('!b') as never, ME)).toBe(true); - expect(wasInvitedAsDirect(fakeRoom('!c') as never, ME)).toBe(false); - }); - - it('ignores a mark on a state the member event has since left', () => { - const room = fakeRoom('!d', { - prev: { membership: 'join', is_direct: true }, - content: { is_direct: false }, - }); - expect(wasInvitedAsDirect(room as never, ME)).toBe(false); - }); -}); - -describe('fileDirectRooms', () => { - it('files two DMs that arrive in the same sync, none lost', async () => { - const c = fakeClient([lateDm('!dm2:test'), lateDm('!dm3:test')]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(1); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test', '!dm3:test'] }); - }); - - it('serializes overlapping runs so a later write builds on the earlier one', async () => { - const c = fakeClient([lateDm('!dm2:test'), fakeRoom('!room:test')]); - await Promise.all([ - fileDirectRooms(c.mx), - addRoomIdToMDirect(c.mx, '!made:test', '@other:test'), - fileDirectRooms(c.mx), - ]); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'], '@other:test': ['!made:test'] }); - expect(c.writes).toHaveLength(2); - }); - - it('files a DM found at startup, after a reload while the join was in flight', async () => { - const reloaded = fakeClient([lateDm('!dm3:test')], { '@friend:test': ['!dm1:test'] }); - await fileDirectRooms(reloaded.mx); - expect(reloaded.current()).toEqual({ '@friend:test': ['!dm1:test', '!dm3:test'] }); - }); - - it('writes nothing when every marked room is already filed', async () => { - const c = fakeClient([lateDm('!dm2:test')], { '@friend:test': ['!dm2:test'] }); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - }); - - it('skips rooms that are not joined, not marked, or whose other party has not synced', async () => { - const c = fakeClient([ - lateDm('!invited:test', { membership: 'invite' }), - fakeRoom('!plain:test'), - lateDm('!nofriend:test', { friend: null }), - ]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - // the next sync delivers the friend; the same call files it - const later = fakeClient([lateDm('!nofriend:test')]); - await fileDirectRooms(later.mx); - expect(later.current()).toEqual({ '@friend:test': ['!nofriend:test'] }); - }); - - it('does not file a room whose mark was cleared when it was converted to a room', async () => { - const converted = fakeRoom('!conv:test', { - content: { membership: 'join', is_direct: false }, - prev: { membership: 'join', is_direct: true }, - }); - const c = fakeClient([converted]); - await fileDirectRooms(c.mx); - expect(c.writes).toHaveLength(0); - }); - - it('keeps going after a failed write', async () => { - const c = fakeClient([lateDm('!dm2:test')]); - const original = c.raw.setAccountData; - let fail = true; - c.raw.setAccountData = async (...a: [string, Content]) => { - if (fail) { - fail = false; - throw new Error('offline'); - } - return original(...a); - }; - await expect(fileDirectRooms(c.mx)).rejects.toThrow('offline'); - await fileDirectRooms(c.mx); - expect(c.current()).toEqual({ '@friend:test': ['!dm2:test'] }); - }); -}); - describe('m.direct edits', () => { it('moves a room between users and removes it, one write at a time', async () => { - const c = fakeClient([], { '@a:test': ['!r:test'] }); + const c = fakeClient({ '@a:test': ['!r:test'] }); await Promise.all([ addRoomIdToMDirect(c.mx, '!r:test', '@b:test'), removeRoomIdFromMDirect(c.mx, '!gone:test'), @@ -151,4 +31,31 @@ describe('m.direct edits', () => { await removeRoomIdFromMDirect(c.mx, '!r:test'); expect(c.current()).toEqual({ '@a:test': [], '@b:test': [] }); }); + + it('serializes overlapping edits so each builds on the content the last one wrote', async () => { + const c = fakeClient(); + await Promise.all([ + addRoomIdToMDirect(c.mx, '!dm2:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm3:test', '@friend:test'), + addRoomIdToMDirect(c.mx, '!dm4:test', '@other:test'), + ]); + expect(c.current()).toEqual({ + '@friend:test': ['!dm2:test', '!dm3:test'], + '@other:test': ['!dm4:test'], + }); + expect(c.writes).toHaveLength(3); + }); + + it('keeps going after a failed write', async () => { + const c = fakeClient(); + const raw = c.mx as unknown as { setAccountData: (t: string, x: Content) => Promise }; + const original = raw.setAccountData; + raw.setAccountData = async () => { + raw.setAccountData = original; + throw new Error('offline'); + }; + await expect(addRoomIdToMDirect(c.mx, '!a:test', '@f:test')).rejects.toThrow('offline'); + await addRoomIdToMDirect(c.mx, '!a:test', '@f:test'); + expect(c.current()).toEqual({ '@f:test': ['!a:test'] }); + }); }); diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index f176764a..2859dbc3 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -16,7 +16,7 @@ import { import to from 'await-to-js'; import { IImageInfo, IThumbnailContent, IVideoInfo } from '../../types/matrix/common'; import { AccountDataEvent } from '../../types/matrix/accountData'; -import { getStateEvent, wasInvitedAsDirect } from './room'; +import { getStateEvent } from './room'; import { Membership, StateEvent } from '../../types/matrix/room'; const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/; @@ -226,7 +226,6 @@ export const guessDmRoomUserId = (room: Room, myUserId: string): string => { }; type MDirectContent = Record; -type DirectEntry = { roomId: string; userId: string }; const mDirectWrites = new WeakMap>(); @@ -252,12 +251,13 @@ export const updateMDirect = ( }; /** A room can be a DM for one person only: it leaves every other user's list. */ -export const withDirectEntries = ( - current: MDirectContent, - entries: DirectEntry[] -): MDirectContent => { - const next = structuredClone(current); - entries.forEach(({ roomId, userId }) => { +export const addRoomIdToMDirect = ( + mx: MatrixClient, + roomId: string, + userId: string +): Promise => + updateMDirect(mx, (current) => { + const next = structuredClone(current); Object.keys(next).forEach((targetUserId) => { if (targetUserId !== userId) { next[targetUserId] = next[targetUserId].filter((id) => id !== roomId); @@ -266,41 +266,8 @@ export const withDirectEntries = ( const roomIds = next[userId] ?? []; if (!roomIds.includes(roomId)) roomIds.push(roomId); next[userId] = roomIds; + return next; }); - return next; -}; - -export const addRoomIdToMDirect = ( - mx: MatrixClient, - roomId: string, - userId: string -): Promise => - updateMDirect(mx, (current) => withDirectEntries(current, [{ roomId, userId }])); - -/** - * The one filer of direct messages. Files every joined room whose invite was - * marked is_direct and which m.direct does not list, in a single write, once - * the other party is known. Run on startup and on every sync: a DM that - * arrives late, in a burst or after a reload is filed by the same check. - */ -export const fileDirectRooms = (mx: MatrixClient): Promise => { - const me = mx.getSafeUserId(); - return updateMDirect(mx, (current) => { - const filed = new Set(Object.values(current).flat()); - const entries = mx - .getRooms() - .filter( - (room: Room) => - room.getMyMembership() === 'join' && - !filed.has(room.roomId) && - wasInvitedAsDirect(room, me) - ) - .map((room: Room) => ({ roomId: room.roomId, userId: guessDmRoomUserId(room, me) })) - // The other party's membership has not synced yet; a later sync files it. - .filter((entry: DirectEntry) => entry.userId !== me); - return entries.length > 0 ? withDirectEntries(current, entries) : undefined; - }); -}; export const removeRoomIdFromMDirect = (mx: MatrixClient, roomId: string): Promise => updateMDirect(mx, (current) => { diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index d4cd757c..05dfeb29 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -72,18 +72,6 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool return content?.is_direct === true; }; -/** - * Whether the invite that brought this user into the room was marked is_direct. - * The join event keeps the mark in its own content when the server copies it, - * and otherwise in the invite it replaced (unsigned.prev_content). - */ -export const wasInvitedAsDirect = (room: Room, myUserId: string): boolean => { - const event = room.getMember(myUserId)?.events?.member; - if (event?.getContent()?.is_direct === true) return true; - const prev = event?.getPrevContent(); - return prev?.membership === 'invite' && prev.is_direct === true; -}; - export const isSpace = (room: Room | null): boolean => { if (!room) return false; const event = getStateEvent(room, StateEvent.RoomCreate); From e59cc93a2acde819d20fa79690d5396bf3d0b81f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:14:19 +0100 Subject: [PATCH 26/40] estate harness: Soul's fake homeserver with member-state echo, and the twodm, reload and convert direct-message scenarios Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive.cjs | 103 ++++++++++++-------------------------- harness/estate/fakehs.cjs | 24 +++++---- harness/estate/run.sh | 39 ++++++--------- 3 files changed, 61 insertions(+), 105 deletions(-) diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index 5d69d02a..ed9950ce 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,82 +1,41 @@ const { chromium } = require('playwright'); -const fs = require('fs'); -const TAG = process.env.TAG; -const out = (n) => `/out/${TAG}-${n}`; -const tl = []; -const T0 = Date.now(); -const t = () => `+${Date.now() - T0}ms`; +const MODE = process.env.MODE; // twodm | reload +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); - const pg = await ctx.newPage(); - pg.on('console', (m) => { if (m.type() === 'error' && !/OIDC|issuer|endpoint|supported|ERR_NAME|404/.test(m.text())) tl.push('console.error ' + m.text().slice(0, 200)); }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error' || /command|direct/i.test(m.text())) console.log('[console]', m.type(), m.text().slice(0, 200)); }); + const open = async () => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); }; await pg.goto('http://127.0.0.1:8080/'); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); - await pg.goto('http://127.0.0.1:8080/#/home/'); - await pg.waitForTimeout(12000); + await open(); const text = () => pg.evaluate(() => document.body.innerText); - const snap = async (n) => { await pg.screenshot({ path: out(n + '.png') }); tl.push(`--- ${n} ${t()} url=${pg.url()}\n${await text()}`); }; - const buttons = async () => tl.push('buttons ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button')].map((e) => { const r = e.getBoundingClientRect(); return [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; })))); - const small = async () => tl.push('SMALL ' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('button, a, [role="button"]')].filter((e) => e.getClientRects().length).map((e) => [e.getAttribute('aria-label') || e.innerText.slice(0, 30), Math.round(e.getBoundingClientRect().width), Math.round(e.getBoundingClientRect().height)]).filter((r) => r[2] < 44)))); - const layout = async () => tl.push('layout ' + JSON.stringify(await pg.evaluate(() => ({ vw: innerWidth, scrollW: document.documentElement.scrollWidth, bodyScrollW: document.body.scrollWidth })))); - await snap('1-landing'); await buttons(); await layout(); await small(); - if (process.env.NOROOT === '1') { - tl.push('has Create Room: ' + (await pg.getByText('Create Room', { exact: true }).count()) + ' Join with Address: ' + (await pg.getByText('Join with Address', { exact: true }).count()) + ' finder: ' + (await pg.locator('[aria-label="Find a room"]').count())); - await b.close(); return; + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + if (MODE === 'twodm') { + await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); + await pg.waitForTimeout(45000); + } else if (MODE === 'convert') { + await tap('Friend DM'); await pg.waitForTimeout(6000); + log('TIER before', await tierOf('Friend DM')); + await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); + const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); + await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); + log('ED_TEXT', JSON.stringify(await ed.first().innerText())); log('BODY', JSON.stringify((await text()).slice(-600))); + await pg.getByText('Convert direct message to room').first().click(); await pg.waitForTimeout(400); log('ED_PICKED', JSON.stringify(await ed.first().innerHTML()).slice(0, 300)); + await pg.keyboard.press('Enter'); log('SENT', t()); await pg.waitForTimeout(800); log('ED_TEXT2', JSON.stringify(await ed.first().innerText().catch(()=>''))); + if (await ed.first().innerText().catch(() => '')) { await pg.keyboard.press('Enter'); log('SENT2', t()); } + await pg.waitForTimeout(8000); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); + } else { + await tap('Slow DM'); await pg.waitForTimeout(3000); + log('RELOAD', t()); await open(); + await pg.waitForTimeout(30000); } - const reveal = async (sel) => { - const loc = pg.locator(sel); - if (await loc.count()) return loc; - for (let round = 0; round < 2; round++) for (const h of HEADERS) { - const hl = pg.getByText(h, { exact: true }); - if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) { tl.push(`revealed ${sel} via ${h}`); return loc; } } - } - tl.push(`could not reveal ${sel}`); return loc; - }; - // expand everything once for the inventory - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); tl.push('clicked header ' + h); } } - await snap('2-expanded'); await buttons(); await layout(); await small(); - // room finder - const finder = pg.locator('[aria-label="Find a room"], [aria-label="Search"]').first(); - await finder.click(); await pg.waitForTimeout(800); - tl.push('after finder url=' + pg.url() + ' dialogs=' + (await pg.locator('[role="dialog"]').count()) + ' inputs=' + JSON.stringify(await pg.evaluate(() => [...document.querySelectorAll('input')].map((i) => i.placeholder)))); - await snap('3-finder'); - await pg.keyboard.press('Escape'); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(3000); - // join with frame poll - const joinPoll = async (name, ms) => { - const loc = await reveal(`[aria-label="Join ${name}"]`); - if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } - const u0 = pg.url(); await loc.first().click(); const c = Date.now(); tl.push(`CLICK Join ${name} ${t()}`); - let card = 0, frames = 0, urlAt = null, firstCardAt = null; - while (Date.now() - c < ms) { - const s = await text(); frames++; - if (s.includes('Summary card')) { card++; if (firstCardAt === null) firstCardAt = Date.now() - c; } - if (urlAt === null && pg.url() !== u0) urlAt = Date.now() - c; - await pg.waitForTimeout(40); - } - tl.push(`POLL ${name}: frames=${frames} cardFrames=${card} firstCardAt=${firstCardAt} urlChangedAt=${urlAt} url=${pg.url()}`); - await snap(`4-join-${name.replace(/\W/g, '')}`); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); - }; - await joinPoll('Commons', 6000); - await joinPoll('Aetheria', 6000); - await joinPoll('Friend DM', 6000); - // timeout path, room - const slow = async (name) => { - const loc = await reveal(`[aria-label="Join ${name}"]`); - if (!(await loc.count())) { tl.push(`no Join ${name}`); return; } - await loc.first().click(); tl.push(`CLICK Join ${name} ${t()}`); - await pg.waitForTimeout(22800); await snap(`5-slow-${name.replace(/\W/g, '')}-21s`); - tl.push(`timeout msg shown: ${(await text()).includes('still on its way')}`); - await pg.waitForTimeout(5000); await snap(`5-slow-${name.replace(/\W/g, '')}-27s`); - tl.push(`after arrival: Join ${name} still present=${await pg.locator(`[aria-label="Join ${name}"]`).count()} url=${pg.url()}`); - }; - await slow('Odin'); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); - await slow('Slow DM'); - // wide: tier placement (wide tiers start open) - await pg.setViewportSize({ width: 1280, height: 800 }); - await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(6000); await snap('6-wide'); + for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); await b.close(); -})().catch((e) => { tl.push('ERROR ' + String(e.stack || e)); process.exitCode = 1; }).finally(() => fs.writeFileSync(out('timeline.txt'), tl.join('\n'))); +})().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 3b9ed641..33deaa64 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -9,11 +9,11 @@ const log = []; const P = ['aetheria', 'ghostlight', 'huginn', 'idunn', 'bifrost', 'odin']; const STEWARD = { aetheria: 'nibu', ghostlight: 'nibu', huginn: 'eureka', idunn: 'eureka' }; const PROJ = P.map((p, i) => ({ id: `!p${i}:test`, alias: `#${p}:test`, name: p[0].toUpperCase() + p.slice(1), key: p })); -const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test'; -const SLOW = new Set([PROJ[5].id, DM2]); -const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [STRAY]: 'Adhoc' }; +const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test', PUB = '!pub:test', STRAY = '!stray:test', DM1 = '!dm1:test', DM2 = '!dm2:test', DM3 = '!dm3:test'; +const SLOW = new Set([PROJ[5].id, DM2, DM3]); +const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const pending = []; const pendingAD = []; let ev = 0; +const held = []; const pendingState = []; const pending = []; const pendingAD = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, direct) => [ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, @@ -35,6 +35,7 @@ const invites = () => { PROJ.forEach((r) => (o[r.id] = { invite_state: { events: strip(r.id, r.name) } })); o[DM1] = { invite_state: { events: strip(DM1, NAMES[DM1], false, true) } }; o[DM2] = { invite_state: { events: strip(DM2, NAMES[DM2], false, true) } }; + o[DM3] = { invite_state: { events: strip(DM3, NAMES[DM3], false, true) } }; return o; }; const child = (id, steward) => ({ type: 'm.space.child', state_key: id, content: { via: ['test'], ...(steward ? { 'org.gamecult.steward': { mind: steward, repo: 'x', source: 'y', steward: 'z' } } : {}) }, sender: '@admin:test', origin_server_ts: 1 }); @@ -61,21 +62,23 @@ http.createServer((req, res) => { return fs.createReadStream(f).pipe(res); } let body = ''; req.on('data', (c) => (body += c)); req.on('end', () => { + if (req.method === 'PUT' && p.includes('m.direct')) console.log('MDIRECT_PUT ' + (Date.now()) + ' ' + body); log.push(`${Date.now()} ${req.method} ${p}${req.method === 'PUT' && p.includes('account_data') ? ' BODY ' + body : ''}`); if (p === '/_matrix/client/versions') return json(res, 200, { versions: ['v1.1', 'v1.5', 'v1.11'], unstable_features: {} }); if (p.endsWith('/sync')) { const since = u.searchParams.get('since'); if (!since) { const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: [] }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } const respond = () => { const now = Date.now(); const join = {}; for (let i = pending.length - 1; i >= 0; i--) if (pending[i].at <= now) { const id = pending[i].id; pending.splice(i, 1); - const extra = id === DM1 || id === DM2 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; - join[id] = joinedRoom(id, NAMES[id] || id, extra); log.push(`${now} SYNC delivers join ${id}`); + const extra = id === DM1 || id === DM2 || id === DM3 ? [se('m.room.member', { membership: 'join', displayname: 'Friend' }, '@friend:test', '@friend:test')] : []; + join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); } + for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } const ad = pendingAD.splice(0); if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); @@ -86,12 +89,15 @@ http.createServer((req, res) => { if (req.method === 'POST' && jm) { const id = jm[1]; log.push(`${Date.now()} JOIN ${id}`); if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); - pending.push({ id, at: Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + const prior = pending.find((q) => q.id === DM2 || q.id === DM3); + pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); return json(res, 200, { room_id: id }); } + const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); + if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } if (p.includes('/account_data/') && req.method === 'PUT') { const type = decodeURIComponent(p.split('/account_data/')[1]); - try { pendingAD.push({ type, content: JSON.parse(body) }); } catch (e) {} + try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index d4dd7cf4..f426895f 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,29 +1,20 @@ -# Run inside playwright:v1.48.2-jammy with the repo at /src and /out writable: -# bash /src/harness/estate/run.sh -# Scenario A: root resolves. Scenario B (NOROOT=1): the root alias 404s. +#!/usr/bin/env bash +# Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: +# builds the client, then drives three direct-message scenarios against fakehs.cjs. set -e cd /src -mkdir -p /out -npm ci --no-audit --no-fund --ignore-scripts >/out/npmci.log 2>&1 -npm run build > /out/build.log 2>&1; echo BUILD_RC=$? >> /out/build.log +npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } +npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } +echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json -mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/out/pw.log 2>&1 +mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 cp /src/harness/estate/drive.cjs /tmp/pw/ -runone () { # tag noroot - HSLOG=/out/hslog-$1.txt NOROOT=$2 node /src/harness/estate/fakehs.cjs /src/dist > /out/hs-$1.out 2>&1 & - HS=$! - sleep 2 - TAG=$1 NOROOT=$2 node /tmp/pw/drive.cjs; echo "DRIVE $1 RC=$?" - kill $HS -} -runone root 0 -runone noroot 1 -for t in root noroot; do - echo "=== $t" - grep -E '^(SMALL|layout|has Create|CLICK|POLL|timeout|after arrival|revealed|--- )' /out/$t-timeline.txt | cut -c1-1800 || true +set +e +for m in twodm reload convert; do + HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + kill $HS; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done -ls -la /out | head -30 -cat /out/noroot-timeline.txt | grep -m3 -i "could not" || true -echo "=== m.direct and joins (root)" -grep -E 'direct|JOIN' /out/hslog-root.txt | cut -c1-220 || true -tail -1 /out/build.log +echo DONE From 44395328e9f1276d07d5479012b7a3d23d641d9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 20:20:37 +0100 Subject: [PATCH 27/40] estate harness fake: account data is answered by the client's sync token, as a server does Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/fakehs.cjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index 33deaa64..c582d3ee 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -13,7 +13,7 @@ const ROOT = '!root:test', PSP = '!proj:test', GEN = '!gen:test', OP = '!op:test const SLOW = new Set([PROJ[5].id, DM2, DM3]); const NAMES = { [PUB]: 'Commons', [DM1]: 'Friend DM', [DM2]: 'Slow DM', [DM3]: 'Late DM', [STRAY]: 'Adhoc' }; PROJ.forEach((r) => (NAMES[r.id] = r.name)); -const held = []; const pendingState = []; const pending = []; const pendingAD = []; let ev = 0; +const held = []; const pendingState = []; const pending = []; const adLog = []; let ev = 0; const se = (type, content, sk = '', sender = '@admin:test') => ({ type, content, state_key: sk, sender, event_id: `$e${++ev}`, origin_server_ts: Date.now() }); const strip = (id, name, space, direct) => [ { type: 'm.room.create', state_key: '', sender: direct ? '@friend:test' : '@admin:test', content: space ? { type: 'm.space', room_version: '12' } : { room_version: '12' } }, @@ -69,7 +69,7 @@ http.createServer((req, res) => { const since = u.searchParams.get('since'); if (!since) { const join = {}; join[GEN] = joinedRoom(GEN, 'General'); join[STRAY] = joinedRoom(STRAY, 'Adhoc'); - return json(res, 200, { next_batch: 's1', rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); + return json(res, 200, { next_batch: `s${syncN}`, rooms: { join, invite: invites(), leave: {} }, account_data: { events: held.slice() }, presence: { events: [] }, to_device: { events: [] }, device_one_time_keys_count: { signed_curve25519: 50 } }); } const respond = () => { const now = Date.now(); const join = {}; @@ -79,7 +79,7 @@ http.createServer((req, res) => { join[id] = joinedRoom(id, NAMES[id] || id, extra); if (extra.length) { const me = join[id].state.events.find((e) => e.type === 'm.room.member' && e.state_key === ME); me.unsigned = { prev_content: { membership: 'invite', is_direct: true } }; } log.push(`${now} SYNC delivers join ${id}`); console.log('DELIVER ' + now + ' ' + id); } for (const s of pendingState.splice(0)) { join[s.id] = join[s.id] || { state: { events: [] }, timeline: { events: [], limited: false, prev_batch: 'p' }, ephemeral: { events: [] }, account_data: { events: [] }, unread_notifications: {}, summary: {} }; join[s.id].timeline.events.push(s.ev); console.log('STATE_ECHO ' + now + ' ' + s.id + ' ' + JSON.stringify(s.ev.content)); } - const ad = pendingAD.splice(0); + const k = Number(String(since).replace(/^s/, '')) || 0; const ad = adLog.filter((a) => a.after >= k).map((a) => ({ type: a.type, content: a.content })); // a server answers with what changed since the client's token, whichever client asks if (Object.keys(join).length || ad.length) return json(res, 200, { next_batch: `s${++syncN}`, rooms: { join, invite: {}, leave: {} }, account_data: { events: ad } }); return json(res, 200, { next_batch: since, rooms: {} }); }; @@ -97,7 +97,7 @@ http.createServer((req, res) => { if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } if (p.includes('/account_data/') && req.method === 'PUT') { const type = decodeURIComponent(p.split('/account_data/')[1]); - try { const c = JSON.parse(body); pendingAD.push({ type, content: c }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); From 719f062ef1214991b9b68a5727aeda2c399b2af3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:10:06 +0100 Subject: [PATCH 28/40] estate: the inbox Accept joins through activateEstateRoom; a join with no server answer keeps the DM listed Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 20 ++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 10 ++++++++- src/app/pages/client/inbox/Invites.tsx | 21 +++++++------------ 3 files changed, 33 insertions(+), 18 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index bf96a4af..19020db2 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -51,12 +51,12 @@ describe('activateEstateRoom', () => { ); it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient(vi.fn().mockRejectedValue(new Error('Boom'))); + const named = recordingClient(vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({})); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -65,6 +65,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { + httpStatus: 403, data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -75,7 +76,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; invites: Record }) => { +const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -102,7 +103,8 @@ const dmClient = (opts: { refuse?: boolean; invites: Record }) = joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw new Error('M_FORBIDDEN'); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, }; @@ -135,6 +137,16 @@ describe('activateEstateRoom for a direct message invite', () => { }); }); +describe('activateEstateRoom when the join gets no answer', () => { + it('keeps the room listed and shows a retryable message', async () => { + const c = dmClient({ drop: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 36a9166e..a3be428b 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -7,6 +7,9 @@ export type EstateActivation = | { kind: 'joined'; roomId: string } | { kind: 'refused'; message: string }; +const LOST_JOIN_RESPONSE = + 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + type JoinClient = Parameters[0]; /** @@ -18,7 +21,10 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused. Nothing needs to recognise the DM after it arrives. + * the join is refused by a server answer. A join that fails without one (lost + * response, dropped connection) may still have been accepted, so the entry + * stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. The inbox Accept goes through here too: one owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -29,6 +35,7 @@ export const activateEstateRoom = async ( const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; + const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -42,6 +49,7 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { + if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 20518e56..39dfcc72 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -46,11 +46,10 @@ import { import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; import { - addRoomIdToMDirect, getMxIdLocalPart, - guessDmRoomUserId, rateLimitedActions, } from '../../../utils/matrix'; +import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; import { onEnterOrSpace, stopPropagation } from '../../../utils/keyboard'; @@ -160,24 +159,20 @@ function InviteCard({ hideAvatar, }: InviteCardProps) { const mx = useMatrixClient(); - const userId = mx.getSafeUserId(); - const [viewTopic, setViewTopic] = useState(false); const closeTopic = () => setViewTopic(false); const openTopic = () => setViewTopic(true); const [joinState, join] = useAsyncCallback( useCallback(async () => { - const dmUserId = isDirectInvite(invite.room, userId) - ? guessDmRoomUserId(invite.room, userId) - : undefined; - - await mx.joinRoom(invite.roomId); - if (dmUserId) { - await addRoomIdToMDirect(mx, invite.roomId, dmUserId); - } + const result = await activateEstateRoom(mx, { + roomId: invite.roomId, + state: 'invited', + via: [], + }); + if (result.kind === 'refused') throw new Error(result.message); onNavigate(invite.roomId, invite.isSpace); - }, [mx, invite, userId, onNavigate]) + }, [mx, invite, onNavigate]) ); const [leaveState, leave] = useAsyncCallback, MatrixError, []>( useCallback(() => mx.leave(invite.roomId), [mx, invite]) From f6244a478104c233e7638278e6931891b4fd8101 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:10:50 +0100 Subject: [PATCH 29/40] estate harness: the join-failure and inbox Accept scenarios run with the committed estate run Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 58 +++++++++++++++++++++++++++++++ harness/estate/fakehs.cjs | 7 +++- harness/estate/run.sh | 16 +++++++-- 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 harness/estate/drive-failures.cjs diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs new file mode 100644 index 00000000..0504e8e6 --- /dev/null +++ b/harness/estate/drive-failures.cjs @@ -0,0 +1,58 @@ +// Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh +// against fakehs.cjs with the env flag each mode needs. +const { chromium } = require('playwright'); +const MODE = process.env.MODE; +const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +setTimeout(() => { console.log('DRIVER_TIMEOUT'); process.exit(3); }, 150000); +let b; +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); + const pg = await ctx.newPage(); pg.on('console', (m) => { if (m.type() === 'error') console.log('[console]', m.type(), m.text().slice(0, 160)); }); + const open = async (h = '#/home/') => { await pg.goto('http://127.0.0.1:8080/' + h); await pg.reload(); await pg.waitForTimeout(9000); }; + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await open(); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; + const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const acceptInInbox = async (name) => { + // reach the inbox through the navigator's own Inbox tab + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); + const tab = pg.locator('[aria-label="Inbox"], [aria-label*="nbox"]'); log('INBOX_TAB', await tab.count()); + if (await tab.count()) { await tab.first().click(); await pg.waitForTimeout(1500); } + const inv = pg.getByText('Invites', { exact: true }); if (await inv.count()) { await inv.first().click(); await pg.waitForTimeout(1500); } + log('URL', pg.url()); + if (!/invites/.test(pg.url())) { await pg.goto('http://127.0.0.1:8080/#/inbox/invites/'); await pg.waitForTimeout(3000); log('URL_FALLBACK', pg.url()); } + { const pub = pg.getByText('Public', { exact: true }); log('PUBLIC_FILTER', await pub.count()); if (await pub.count()) { await pub.first().click(); await pg.waitForTimeout(1500); } } + for (let r = 0; r < 3; r++) { const v = pg.getByText('View All', { exact: true }); if (!(await v.count())) break; await v.first().click(); await pg.waitForTimeout(800); } + const b0 = await body(); const i = b0.indexOf(name); log('INBOX has', name, i >= 0, JSON.stringify(b0.slice(Math.max(0, i - 120), i + 120))); + const card = pg.locator('div').filter({ hasText: name }).filter({ has: pg.getByText('Accept', { exact: true }) }).last(); + await card.getByText('Accept', { exact: true }).first().click(); log('ACCEPT', name, t()); + }; + if (MODE === 'pjfail') { + await tap('Friend DM'); await pg.waitForTimeout(3000); + const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + } else if (MODE === 'jdrop') { + await tap('Friend DM'); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + } else if (MODE === 'admin') { + await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + } else if (MODE === 'inboxadmin') { + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + } else if (MODE === 'inboxreload') { + await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); + await pg.waitForTimeout(30000); await tiers('inbox reload'); + } else if (MODE === 'pjreload') { + await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); + await pg.waitForTimeout(8000); await tiers('pjreload'); + const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + } + await b.close(); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(1)); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index c582d3ee..a8180204 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -20,7 +20,7 @@ const strip = (id, name, space, direct) => [ { type: 'm.room.name', state_key: '', sender: '@admin:test', content: { name } }, { type: 'm.room.join_rules', state_key: '', sender: '@admin:test', content: { join_rule: 'invite' } }, ...(direct ? [{ type: 'm.room.member', state_key: '@friend:test', sender: '@friend:test', content: { membership: 'join', displayname: 'Friend' } }] : []), - { type: 'm.room.member', state_key: ME, sender: direct ? '@friend:test' : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, + { type: 'm.room.member', state_key: ME, sender: direct ? (process.env.INVITER_ADMIN === '1' && id === DM1 ? '@admin:test' : '@friend:test') : '@admin:test', content: direct ? { membership: 'invite', is_direct: true } : { membership: 'invite' } }, ]; const joinedRoom = (id, name, extra = [], space = false) => ({ state: { events: [se('m.room.create', space ? { type: 'm.space', room_version: '12' } : { room_version: '12' }), se('m.room.name', { name }), se('m.room.join_rules', { join_rule: 'invite' }), se('m.room.member', { membership: 'join' }, ME, ME), ...extra] }, @@ -91,13 +91,18 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } + if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); } const sm = p.match(/\/rooms\/([^/]+)\/state\/m\.room\.member\/(.+)$/); if (req.method === 'PUT' && sm) { console.log('STATE_PUT ' + Date.now() + ' ' + sm[1] + ' ' + body); const e = se('m.room.member', JSON.parse(body), sm[2], sm[2]); e.unsigned = { prev_content: { membership: 'join' } }; setTimeout(() => pendingState.push({ id: sm[1], ev: e }), 400); return json(res, 200, { event_id: e.event_id }); } + if (p.includes('/account_data/') && req.method === 'PUT' && p.includes('m.direct') && process.env.FAIL_MDIRECT === '1' && !global.failedOnce) { global.failedOnce = 1; console.log('MDIRECT_FAIL ' + Date.now()); return json(res, 500, { errcode: 'M_UNKNOWN', error: 'Internal server error' }); } if (p.includes('/account_data/') && req.method === 'PUT') { + const DELAY = p.includes('m.direct') && process.env.DELAY_MDIRECT === '1'; const type = decodeURIComponent(p.split('/account_data/')[1]); try { const c = JSON.parse(body); adLog.push({ type, content: c, after: syncN }); const i = held.findIndex((h) => h.type === type); if (i >= 0) held.splice(i, 1); held.push({ type, content: c }); } catch (e) {} + if (DELAY) { console.log('MDIRECT_DELAYED ' + Date.now()); return setTimeout(() => json(res, 200, {}), 6000); } return json(res, 200, {}); } if (p.includes('/hierarchy')) return json(res, 200, { rooms: hierarchy() }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index f426895f..ef0fa3cd 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Runs inside mcr.microsoft.com/playwright:v1.48.2-jammy with the repo at /src: -# builds the client, then drives three direct-message scenarios against fakehs.cjs. +# builds the client, then drives the direct-message and join-failure scenarios against fakehs.cjs. set -e cd /src npm ci --no-audit --no-fund --ignore-scripts >/tmp/npmci.log 2>&1 || { tail -20 /tmp/npmci.log; exit 1; } @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -17,4 +17,16 @@ for m in twodm reload convert; do kill $HS; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done +failure() { m=$1; shift + env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & + HS=$!; sleep 2 + MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + kill $HS; sleep 1 + echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } +failure pjfail FAIL_MDIRECT=1 +failure pjreload DELAY_MDIRECT=1 +failure jdrop JOIN_DROP=1 +failure admin INVITER_ADMIN=1 +failure inboxadmin INVITER_ADMIN=1 +failure inboxreload JOIN_DELAY=1 echo DONE From cdfd43fe9ffd7e3f591babaa7b3d03582246f571 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:29:03 +0100 Subject: [PATCH 30/40] estate harness: the failure driver closes its browser on timeout; the run waits for each fake server to exit; prettier Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 2 +- harness/estate/run.sh | 4 ++-- src/app/features/estate/joinEstateRoom.test.ts | 4 +++- src/app/pages/client/inbox/Invites.tsx | 5 +---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 0504e8e6..e3e71449 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -5,8 +5,8 @@ const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; -setTimeout(() => { console.log('DRIVER_TIMEOUT'); process.exit(3); }, 150000); let b; +setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(3)); }, 240000); (async () => { b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index ef0fa3cd..26b026b8 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -14,14 +14,14 @@ for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" - kill $HS; sleep 1 + kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" - kill $HS; sleep 1 + kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 failure pjreload DELAY_MDIRECT=1 diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 19020db2..d206a608 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -51,7 +51,9 @@ describe('activateEstateRoom', () => { ); it('falls back to the error message, then to a generic one', async () => { - const named = recordingClient(vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 }))); + const named = recordingClient( + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index 39dfcc72..ca26bac3 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -45,10 +45,7 @@ import { } from '../../../utils/room'; import { nameInitials } from '../../../utils/common'; import { RoomAvatar } from '../../../components/room-avatar'; -import { - getMxIdLocalPart, - rateLimitedActions, -} from '../../../utils/matrix'; +import { getMxIdLocalPart, rateLimitedActions } from '../../../utils/matrix'; import { activateEstateRoom } from '../../../features/estate/joinEstateRoom'; import { Time } from '../../../components/message'; import { useElementSizeObserver } from '../../../hooks/useElementSizeObserver'; From 2c04bbefa327f56738fdb6e3e60029aa94136d3f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 21:51:32 +0100 Subject: [PATCH 31/40] estate harness: the failure driver never waits on a hung browser close Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-failures.cjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index e3e71449..9c81424a 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -6,7 +6,9 @@ const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; let b; -setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(3)); }, 240000); +// Close the browser, but never wait on it: a hung close must not hang the run. +const quit = (code) => Promise.race([Promise.resolve(b && b.close()).catch(() => undefined), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(code)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); (async () => { b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -54,5 +56,5 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); Promise.resolve(b && b.close() await pg.waitForTimeout(8000); await tiers('pjreload'); const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); } - await b.close(); -})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); Promise.resolve(b && b.close()).catch(() => undefined).finally(() => process.exit(1)); }); + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); From 7453a0bd323da8861ef015221320224eb5fb79a9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:28 +0100 Subject: [PATCH 32/40] estate: every join of a direct invite goes through the pre-join filing; only a Matrix 4xx with an errcode is a refusal The room card's Join (deep links, permalinks, history) and /join joined through mx.joinRoom and never filed the DM. They now go through activateEstateRoom. A gateway 5xx, or a status with no errcode, has an unknown outcome like a dropped connection: the entry stays and the tap is retryable. The harness gains Soul's s6 deeplink and gw504 drives. Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/drive-routes.cjs | 33 +++++++++++++++ harness/estate/fakehs.cjs | 1 + harness/estate/run.sh | 6 ++- src/app/components/room-card/RoomCard.tsx | 10 +++-- .../features/estate/joinEstateRoom.test.ts | 42 ++++++++++++++++--- src/app/features/estate/joinEstateRoom.ts | 38 +++++++++++++---- src/app/hooks/useCommands.ts | 3 +- 7 files changed, 114 insertions(+), 19 deletions(-) create mode 100644 harness/estate/drive-routes.cjs diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs new file mode 100644 index 00000000..05e89df1 --- /dev/null +++ b/harness/estate/drive-routes.cjs @@ -0,0 +1,33 @@ +const { chromium } = require('playwright'); +const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; +const log = (...a) => console.log(`[${MODE}]`, ...a); +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let b; const quit = (c) => Promise.race([Promise.resolve(b && b.close()).catch(() => 0), new Promise((r) => setTimeout(r, 5000))]).finally(() => process.exit(c)); +setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); +(async () => { + b = await chromium.launch(); + const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + const pg = await ctx.newPage(); + await pg.goto('http://127.0.0.1:8080/'); + await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); + await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); + const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); + const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + if (MODE === 'deeplink') { + for (const route of ['direct', 'home']) { + await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); + const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); + const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); + if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + } + await tiers('after deeplink join'); + } else if (MODE === 'gw504') { + const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); + for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); + const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); + await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + } + await quit(0); +})().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/fakehs.cjs b/harness/estate/fakehs.cjs index a8180204..4a6875e0 100644 --- a/harness/estate/fakehs.cjs +++ b/harness/estate/fakehs.cjs @@ -91,6 +91,7 @@ http.createServer((req, res) => { if (id === PROJ[1].id) return json(res, 403, { errcode: 'M_FORBIDDEN', error: 'You are not invited to this room.' }); const prior = pending.find((q) => q.id === DM2 || q.id === DM3); pending.push({ id, at: (id === DM3 || id === DM2) && prior ? prior.at : Date.now() + (SLOW.has(id) ? 24000 : 1000) }); + if (process.env.JOIN_504 === '1' && id === DM1) { console.log('JOIN_504 ' + Date.now() + ' ' + id); res.writeHead(504, {'Content-Type':'text/html'}); return res.end('504 Gateway Time-out'); } if (process.env.JOIN_DROP === '1' && id === DM1) { console.log('JOIN_DROPPED ' + Date.now() + ' ' + id); return req.socket.destroy(); } if (process.env.JOIN_DELAY === '1') { console.log('JOIN_DELAYED ' + Date.now() + ' ' + id); return setTimeout(() => json(res, 200, { room_id: id }), 6000); } return json(res, 200, { room_id: id }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 26b026b8..70fa5d13 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,7 +8,7 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /tmp/pw/ +cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & @@ -20,7 +20,7 @@ done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive-failures.cjs; echo "DRIVE $m RC=$?" + MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -30,3 +30,5 @@ failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 echo DONE +DRIVER=drive-routes failure deeplink +DRIVER=drive-routes failure gw504 JOIN_504=1 diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index 34a7e24b..a5e9b3a9 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { MatrixError } from 'matrix-js-sdk'; import { Avatar, Badge, @@ -31,6 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; +import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -197,8 +198,11 @@ export const RoomCard = as<'div', RoomCardProps>( ) ); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomIdOrAlias, { viaServers }), [mx, roomIdOrAlias, viaServers]) + const [joinState, join] = useAsyncCallback( + useCallback(async () => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); + if (result.kind === 'refused') throw new Error(result.message); + }, [mx, roomIdOrAlias, viaServers]) ); const joining = joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success; diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index d206a608..6fccae10 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin } from './joinEstateRoom'; +import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -52,13 +52,13 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 500 })) + vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 500 })); + const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -78,7 +78,7 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -105,7 +105,8 @@ const dmClient = (opts: { refuse?: boolean; drop?: boolean; invites: Record { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403 }); + if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -149,6 +150,37 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when a gateway answers instead of the server', () => { + it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { + const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).toMatch(/tap again to retry/); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); + + it('keeps the room listed on a 5xx even when it carries an errcode', async () => { + const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + +describe('joinEstateRoomIdOrAlias, the room card and /join', () => { + it('files a direct invite opened by its link before the join is sent', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const result = await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['test']); + expect(result).toEqual({ kind: 'joined', roomId: '!dm1:test' }); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('joins an alias or an unheld room without touching m.direct', async () => { + const { mx, calls } = recordingClient(); + await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index a3be428b..3cd3bf6d 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -8,7 +8,7 @@ export type EstateActivation = | { kind: 'refused'; message: string }; const LOST_JOIN_RESPONSE = - 'The connection dropped before the server answered. The join may have gone through; tap again to retry.'; + 'The server did not answer the join. It may have gone through; tap again to retry.'; type JoinClient = Parameters[0]; @@ -21,21 +21,28 @@ type JoinClient = Parameters[0]; * An invite marked is_direct carries the mark in the invite event the client * already holds, and the join event does not repeat it. So the room is listed * in m.direct under the inviter before the join is sent, and taken out again if - * the join is refused by a server answer. A join that fails without one (lost - * response, dropped connection) may still have been accepted, so the entry - * stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. The inbox Accept goes through here too: one owner for joining. + * the join is refused by a Matrix error answer (a 4xx carrying an errcode). A + * join that fails any other way (lost response, dropped connection, a gateway's + * 5xx or a status with no errcode) may still have been accepted upstream, so the + * entry stays and the tap is retryable. Nothing needs to recognise the DM after it + * arrives. Every join of a room the client holds an invite for goes through + * here: the inbox Accept, the room card, /join. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, - room: Pick + room: Pick & { via?: string[] } ): Promise => { if (room.state === 'joined') return { kind: 'open', roomId: room.roomId }; const refusal = (e: unknown): EstateActivation => { const err = e as { data?: { error?: string }; message?: string }; return { kind: 'refused', message: err.data?.error ?? err.message ?? 'Could not join' }; }; - const answered = (e: unknown) => typeof (e as { httpStatus?: unknown }).httpStatus === 'number'; + const refusedByServer = (e: unknown) => { + const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; + const status = err.httpStatus; + const errcode = err.errcode ?? err.data?.errcode; + return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) ? invite?.getMember(mx.getSafeUserId())?.events?.member?.getSender() @@ -49,12 +56,27 @@ export const activateEstateRoom = async ( const joined = await mx.joinRoom(room.roomId, { viaServers: room.via }); return { kind: 'joined', roomId: joined.roomId }; } catch (e) { - if (!answered(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; + if (!refusedByServer(e)) return { kind: 'refused', message: LOST_JOIN_RESPONSE }; if (inviter) await removeRoomIdFromMDirect(mx, room.roomId).catch(() => undefined); return refusal(e); } }; +/** + * activateEstateRoom for a caller that holds only an id or alias (room card, + * /join): a room the client has an invite for is an invited room. + */ +export const joinEstateRoomIdOrAlias = ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => + activateEstateRoom(mx, { + roomId: roomIdOrAlias, + state: mx.getRoom(roomIdOrAlias)?.getMyMembership() === 'invite' ? 'invited' : 'joinable', + via, + }); + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c02b0e94..15a21d1e 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -26,6 +26,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; +import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -233,7 +234,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) ); roomIdOrAliases.forEach(async (idOrAlias) => { - await mx.joinRoom(idOrAlias); + await joinEstateRoomIdOrAlias(mx, idOrAlias); }); }, }, From 873c0e30a2b84d3a686cf085db0b30cdaa7fc409 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:20:42 +0100 Subject: [PATCH 33/40] estate harness: run the s6 route drives last --- harness/estate/run.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 70fa5d13..379d28ca 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -29,6 +29,6 @@ failure jdrop JOIN_DROP=1 failure admin INVITER_ADMIN=1 failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 -echo DONE DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 +echo DONE From 00ec686b1b49a8cf2063de507fbd793a4f598c0c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:33:06 +0100 Subject: [PATCH 34/40] estate tests: the server-refusal and invited-room fixtures carry an errcode and a membership Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 6fccae10..07ed43ea 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -68,6 +68,7 @@ describe('activateEstateRoom', () => { it('reports the server refusal and does not decide access itself', async () => { const refusal = Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, + errcode: 'M_FORBIDDEN', data: { error: 'You are not invited to this room.' }, }); const { mx, joinRoom } = recordingClient(vi.fn().mockRejectedValue(refusal)); @@ -85,6 +86,7 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ + getMyMembership: () => 'invite', getMember: () => ({ events: { member: { From b20b95876252d0d60c0990308ac4e931b20e1521 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 31 Oct 2026 22:34:49 +0100 Subject: [PATCH 35/40] estate: prettier Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++---- src/app/features/estate/joinEstateRoom.ts | 4 ++- 2 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 07ed43ea..8e009ef0 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -52,13 +52,19 @@ describe('activateEstateRoom', () => { it('falls back to the error message, then to a generic one', async () => { const named = recordingClient( - vi.fn().mockRejectedValue(Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' })) + vi + .fn() + .mockRejectedValue( + Object.assign(new Error('Boom'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ) ); expect(await activateEstateRoom(named.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Boom', }); - const bare = recordingClient(vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' })); + const bare = recordingClient( + vi.fn().mockRejectedValue({ httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); expect(await activateEstateRoom(bare.mx, room('joinable'))).toEqual({ kind: 'refused', message: 'Could not join', @@ -79,7 +85,13 @@ describe('activateEstateRoom', () => { }); /** A client holding DM invites as the server sends them: the mark is in the invite event only. */ -const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; drop?: boolean; invites: Record }) => { +const dmClient = (opts: { + status?: number; + errcode?: string; + refuse?: boolean; + drop?: boolean; + invites: Record; +}) => { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; @@ -107,8 +119,13 @@ const dmClient = (opts: { status?: number; errcode?: string; refuse?: boolean; d joinRoom: async (roomId: string) => { // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); - if (opts.refuse) throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); - if (opts.status) throw Object.assign(new Error('Gateway Time-out'), { httpStatus: opts.status, errcode: opts.errcode }); + if (opts.refuse) + throw Object.assign(new Error('M_FORBIDDEN'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }); + if (opts.status) + throw Object.assign(new Error('Gateway Time-out'), { + httpStatus: opts.status, + errcode: opts.errcode, + }); if (opts.drop) throw new Error('fetch failed'); return { roomId }; }, @@ -162,7 +179,11 @@ describe('activateEstateRoom when a gateway answers instead of the server', () = }); it('keeps the room listed on a 5xx even when it carries an errcode', async () => { - const c = dmClient({ status: 500, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status: 500, + errcode: 'M_UNKNOWN', + invites: { '!dm1:test': '@friend:test' }, + }); await activateEstateRoom(c.mx, tap('!dm1:test')); expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); }); diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index 3cd3bf6d..d69df82c 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -41,7 +41,9 @@ export const activateEstateRoom = async ( const err = e as { httpStatus?: unknown; errcode?: unknown; data?: { errcode?: unknown } }; const status = err.httpStatus; const errcode = err.errcode ?? err.data?.errcode; - return typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string'; + return ( + typeof status === 'number' && status >= 400 && status < 500 && typeof errcode === 'string' + ); }; const invite = room.state === 'invited' ? mx.getRoom(room.roomId) : null; const inviter = isDirectInvite(invite, mx.getSafeUserId()) From 8f1bb54b4b1d48bb1c0b7a95663b6219aa162c71 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:35:35 +0100 Subject: [PATCH 36/40] estate: every client join goes through joinEstateRoomIdOrAlias; /join reports refusals RoomTombstone, RoomIntro and the lobby RoomItem/SpaceItem joined with mx.joinRoom beside the pre-join filing; they now call joinEstateRoomOrThrow, which delegates to the one path. /join hands each refusal to the user instead of discarding it. Tests pin the via pass-through and the 400/429/499 refusal bounds. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/room-card/RoomCard.tsx | 5 +- src/app/components/room-intro/RoomIntro.tsx | 3 +- .../features/estate/joinEstateRoom.test.ts | 68 ++++++++++++++++++- src/app/features/estate/joinEstateRoom.ts | 43 +++++++++++- src/app/features/lobby/RoomItem.tsx | 12 ++-- src/app/features/lobby/SpaceItem.tsx | 7 +- src/app/features/room/RoomTombstone.tsx | 7 +- src/app/hooks/useCommands.ts | 13 +--- 8 files changed, 124 insertions(+), 34 deletions(-) diff --git a/src/app/components/room-card/RoomCard.tsx b/src/app/components/room-card/RoomCard.tsx index a5e9b3a9..64aaaacb 100644 --- a/src/app/components/room-card/RoomCard.tsx +++ b/src/app/components/room-card/RoomCard.tsx @@ -31,7 +31,7 @@ import { RoomType, StateEvent } from '../../../types/matrix/room'; import { useJoinedRoomId } from '../../hooks/useJoinedRoomId'; import { useElementSizeObserver } from '../../hooks/useElementSizeObserver'; import { getRoomAvatarUrl, getStateEvent } from '../../utils/room'; -import { joinEstateRoomIdOrAlias } from '../../features/estate/joinEstateRoom'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -200,8 +200,7 @@ export const RoomCard = as<'div', RoomCardProps>( const [joinState, join] = useAsyncCallback( useCallback(async () => { - const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, viaServers); - if (result.kind === 'refused') throw new Error(result.message); + await joinEstateRoomOrThrow(mx, roomIdOrAlias, viaServers); }, [mx, roomIdOrAlias, viaServers]) ); const joining = diff --git a/src/app/components/room-intro/RoomIntro.tsx b/src/app/components/room-intro/RoomIntro.tsx index ce550992..2e4e3ebc 100644 --- a/src/app/components/room-intro/RoomIntro.tsx +++ b/src/app/components/room-intro/RoomIntro.tsx @@ -17,6 +17,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { InviteUserPrompt } from '../invite-user-prompt'; +import { joinEstateRoomOrThrow } from '../../features/estate/joinEstateRoom'; export type RoomIntroProps = { room: Room; @@ -43,7 +44,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) => const prevRoomId = createContent?.predecessor?.room_id; const [prevRoomState, joinPrevRoom] = useAsyncCallback( - useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]) + useCallback((roomId: string) => joinEstateRoomOrThrow(mx, roomId), [mx]) ); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 8e009ef0..cc1d232f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import { activateEstateRoom, awaitMyJoin, joinEstateRoomIdOrAlias } from './joinEstateRoom'; +import { + activateEstateRoom, + awaitMyJoin, + joinEstateRoomIdOrAlias, + joinEstateRoomOrThrow, + runJoinCommand, +} from './joinEstateRoom'; const room = (state: 'joined' | 'invited' | 'joinable') => ({ roomId: '!r:gamecult.org', @@ -95,6 +101,7 @@ const dmClient = (opts: { let direct: Record | undefined; const writes: Record[] = []; const joined: string[] = []; + const joinOpts: unknown[] = []; const mx = { getSafeUserId: () => '@me:test', getRoom: (roomId: string) => ({ @@ -116,7 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string) => { + joinRoom: async (roomId: string, opts?: unknown) => { + joinOpts.push(opts); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -130,7 +138,7 @@ const dmClient = (opts: { return { roomId }; }, }; - return { mx: mx as never, writes, joined, current: () => direct }; + return { mx: mx as never, writes, joined, joinOpts, current: () => direct }; }; const tap = (id: string) => ({ roomId: id, state: 'invited' as const, via: ['test'] }); @@ -169,6 +177,22 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom refusal bounds', () => { + it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result.kind).toBe('refused'); + expect((result as { message: string }).message).not.toMatch(/tap again to retry/); + expect(c.current()).toEqual({}); + }); + + it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { + const c = dmClient({ status, errcode: 'M_UNKNOWN', invites: { '!dm1:test': '@friend:test' } }); + await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(c.current()).toEqual({ '@friend:test': ['!dm1:test'] }); + }); +}); + describe('activateEstateRoom when a gateway answers instead of the server', () => { it.each([502, 503, 504, 404])('keeps the room listed on a %i with no errcode', async (status) => { const c = dmClient({ status, invites: { '!dm1:test': '@friend:test' } }); @@ -197,6 +221,12 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); }); + it('hands the via servers to the join', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await joinEstateRoomIdOrAlias(c.mx, '!dm1:test', ['other.org']); + expect(c.joinOpts).toEqual([{ viaServers: ['other.org'] }]); + }); + it('joins an alias or an unheld room without touching m.direct', async () => { const { mx, calls } = recordingClient(); await joinEstateRoomIdOrAlias(mx, '#room:gamecult.org'); @@ -204,6 +234,38 @@ describe('joinEstateRoomIdOrAlias, the room card and /join', () => { }); }); +describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', () => { + it('files a direct invite and answers the joined room id', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test', ['test'])).resolves.toBe('!dm1:test'); + expect(c.joined).toEqual([JSON.stringify({ '@friend:test': ['!dm1:test'] })]); + }); + + it('throws the refusal and unfiles the room', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); + expect(c.current()).toEqual({}); + }); +}); + +describe('runJoinCommand, /join', () => { + it('reports each refusal and joins the others', async () => { + const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test notanaddress', (m) => reported.push(m)); + expect(reported).toEqual(['M_FORBIDDEN']); + expect(c.joined).toHaveLength(1); + }); + + it('reports nothing when the join is accepted', async () => { + const c = dmClient({ invites: { '!dm1:test': '@friend:test' } }); + const reported: string[] = []; + await runJoinCommand(c.mx, '!dm1:test', (m) => reported.push(m)); + expect(reported).toEqual([]); + expect(c.joined).toHaveLength(1); + }); +}); + describe('awaitMyJoin', () => { /** A store that gains the room when the test says so, emitting like the SDK. */ const store = () => { diff --git a/src/app/features/estate/joinEstateRoom.ts b/src/app/features/estate/joinEstateRoom.ts index d69df82c..4e1629d0 100644 --- a/src/app/features/estate/joinEstateRoom.ts +++ b/src/app/features/estate/joinEstateRoom.ts @@ -1,5 +1,11 @@ import { EstateRoom } from './buildEstate'; -import { addRoomIdToMDirect, removeRoomIdFromMDirect } from '../../utils/matrix'; +import { + addRoomIdToMDirect, + isRoomAlias, + isRoomId, + removeRoomIdFromMDirect, +} from '../../utils/matrix'; +import { splitWithSpace } from '../../utils/common'; import { isDirectInvite } from '../../utils/room'; export type EstateActivation = @@ -25,8 +31,9 @@ type JoinClient = Parameters[0]; * join that fails any other way (lost response, dropped connection, a gateway's * 5xx or a status with no errcode) may still have been accepted upstream, so the * entry stays and the tap is retryable. Nothing needs to recognise the DM after it - * arrives. Every join of a room the client holds an invite for goes through - * here: the inbox Accept, the room card, /join. One owner for joining. + * arrives. Every client join goes through here: the estate tap, the inbox Accept, + * the room card, /join, the lobby and space Join buttons, the tombstone and + * predecessor links. One owner for joining. */ export const activateEstateRoom = async ( mx: JoinClient, @@ -79,6 +86,36 @@ export const joinEstateRoomIdOrAlias = ( via, }); +/** + * joinEstateRoomIdOrAlias for a caller that reports failure by throwing (a + * useAsyncCallback button): the joined room id, or an Error carrying the refusal. + */ +export const joinEstateRoomOrThrow = async ( + mx: JoinClient, + roomIdOrAlias: string, + via?: string[] +): Promise => { + const result = await joinEstateRoomIdOrAlias(mx, roomIdOrAlias, via); + if (result.kind === 'refused') throw new Error(result.message); + return result.roomId; +}; + +/** + * The /join command: every room id or alias in the payload is joined, and each + * refusal is handed to report, never dropped. + */ +export const runJoinCommand = async ( + mx: JoinClient, + payload: string, + report: (message: string) => void +): Promise => { + const addresses = splitWithSpace(payload).filter((a) => isRoomId(a) || isRoomAlias(a)); + const results = await Promise.all(addresses.map((a) => joinEstateRoomIdOrAlias(mx, a))); + results.forEach((r) => { + if (r.kind === 'refused') report(r.message); + }); +}; + type MembershipSource = { getRoom(roomId: string): { getMyMembership(): string } | null; on(event: 'Room.myMembership', handler: (room: { roomId: string }, m: string) => void): unknown; diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..132aaf8c 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,7 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; -import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; +import { JoinRule, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { SequenceCard } from '../../components/sequence-card'; @@ -38,6 +38,7 @@ import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { ItemDraggableTarget, useDraggableItem } from './DnD'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomJoinButtonProps = { roomId: string; @@ -46,8 +47,8 @@ type RoomJoinButtonProps = { function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; @@ -60,9 +61,8 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { - {joinState.error.data?.error || joinState.error.message} + {joinState.error.message} - {joinState.error.name} } @@ -75,7 +75,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { size="400" filled tabIndex={0} - aria-label={joinState.error.data?.error || joinState.error.message} + aria-label={joinState.error.message} /> )} diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index ed3eaff9..e1ffbfe5 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -18,7 +18,7 @@ import { } from 'folds'; import FocusTrap from 'focus-trap-react'; import classNames from 'classnames'; -import { MatrixError, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -38,6 +38,7 @@ import { useOpenCreateSpaceModal } from '../../state/hooks/createSpaceModal'; import { AddExistingModal } from '../add-existing'; import { CreateRoomType } from '../../components/create-room/types'; import { BetaNoticeBadge } from '../../components/BetaNoticeBadge'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; function SpaceProfileLoading() { return ( @@ -112,8 +113,8 @@ function UnjoinedSpaceProfile({ }: UnjoinedSpaceProfileProps) { const mx = useMatrixClient(); - const [joinState, join] = useAsyncCallback( - useCallback(() => mx.joinRoom(roomId, { viaServers: via }), [mx, roomId, via]) + const [joinState, join] = useAsyncCallback( + useCallback(() => joinEstateRoomOrThrow(mx, roomId, via), [mx, roomId, via]) ); const canJoin = joinState.status === AsyncStatus.Idle || joinState.status === AsyncStatus.Error; diff --git a/src/app/features/room/RoomTombstone.tsx b/src/app/features/room/RoomTombstone.tsx index 24f0b80e..3556e6fc 100644 --- a/src/app/features/room/RoomTombstone.tsx +++ b/src/app/features/room/RoomTombstone.tsx @@ -8,6 +8,7 @@ import { Membership } from '../../../types/matrix/room'; import { RoomInputPlaceholder } from './RoomInputPlaceholder'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { getViaServers } from '../../plugins/via-servers'; +import { joinEstateRoomOrThrow } from '../estate/joinEstateRoom'; type RoomTombstoneProps = { roomId: string; body?: string; replacementRoomId: string }; export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstoneProps) { @@ -18,16 +19,14 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone useCallback(() => { const currentRoom = mx.getRoom(roomId); const via = currentRoom ? getViaServers(currentRoom) : []; - return mx.joinRoom(replacementRoomId, { - viaServers: via, - }); + return joinEstateRoomOrThrow(mx, replacementRoomId, via); }, [mx, roomId, replacementRoomId]) ); const replacementRoom = mx.getRoom(replacementRoomId); const handleOpen = () => { if (replacementRoom) navigateRoom(replacementRoom.roomId); - if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data.roomId); + if (joinState.status === AsyncStatus.Success) navigateRoom(joinState.data); }; return ( diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index 15a21d1e..11f379dc 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -15,7 +15,6 @@ import { addRoomIdToMDirect, getDMRoomFor, guessDmRoomUserId, - isRoomAlias, isRoomId, isServerName, isUserId, @@ -26,7 +25,7 @@ import { useRoomNavigate } from './useRoomNavigate'; import { Membership, StateEvent } from '../../types/matrix/room'; import { getStateEvent } from '../utils/room'; import { splitWithSpace } from '../utils/common'; -import { joinEstateRoomIdOrAlias } from '../features/estate/joinEstateRoom'; +import { runJoinCommand } from '../features/estate/joinEstateRoom'; import { createRoomEncryptionState } from '../components/create-room'; export const SHRUG = '¯\\_(ツ)_/¯'; @@ -228,15 +227,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { [Command.Join]: { name: Command.Join, description: 'Join room with address. Example: /join address1 address2', - exe: async (payload) => { - const rawIds = splitWithSpace(payload); - const roomIdOrAliases = rawIds.filter( - (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias) - ); - roomIdOrAliases.forEach(async (idOrAlias) => { - await joinEstateRoomIdOrAlias(mx, idOrAlias); - }); - }, + exe: (payload) => runJoinCommand(mx, payload, (message) => window.alert(message)), }, [Command.Leave]: { name: Command.Leave, From 6950da865e12e7f3703cf7915ef3f2e153184f83 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:37:35 +0100 Subject: [PATCH 37/40] estate harness: every drive asserts the outcome it shows and run.sh fails when one does not Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 18 +++++++++++++++++ harness/estate/drive-failures.cjs | 33 +++++++++++++++++++++---------- harness/estate/drive-routes.cjs | 20 ++++++++++++++----- harness/estate/drive.cjs | 14 ++++++++++--- harness/estate/run.sh | 9 +++++---- 5 files changed, 72 insertions(+), 22 deletions(-) create mode 100644 harness/estate/check.cjs diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs new file mode 100644 index 00000000..172fa301 --- /dev/null +++ b/harness/estate/check.cjs @@ -0,0 +1,18 @@ +// Assertions for the estate drives: every drive states the outcome it exists to show and exits +// non-zero when the page or the fake homeserver's log says otherwise. +const fs = require('fs'); +const fails = []; +exports.expect = (label, ok, detail = '') => { + console.log(`[ASSERT] ${ok ? 'PASS' : 'FAIL'} ${label}${ok ? '' : ` :: ${detail}`}`); + if (!ok) fails.push(label); +}; +/** Exit code for the drive: 4 when any assertion failed. */ +exports.done = () => (fails.length ? 4 : 0); +/** The fake homeserver's request log (HSLOG is shared with the server process). */ +exports.hsLog = () => fs.readFileSync(process.env.HSLOG, 'utf8').split('\n'); +/** Bodies of the account-data PUTs for m.direct, in order. */ +exports.mDirectPuts = () => + exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); +/** Join requests the server saw for a room id. */ +exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 9c81424a..1172c853 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,6 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -21,7 +22,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM'), '| Slow DM', await tierOf('Slow DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2000); @@ -38,23 +40,34 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); }; if (MODE === 'pjfail') { await tap('Friend DM'); await pg.waitForTimeout(3000); - const bb = await body(); const k = bb.search(/Internal server error|Could not join|Joined\./); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('after retry'); + const bb = await body(); const k = bb.search(/Internal server error/); log('AFTER_FAIL', k >= 0 ? bb.slice(Math.max(0, k - 40), k + 80) : 'no message'); + expect('a failed m.direct write shows the refusal', k >= 0, bb.slice(0, 200)); + expect('no join is sent while the filing is failing', joinsOf('!dm1:test') === 0, String(joinsOf('!dm1:test'))); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('after retry'); + expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); + expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/fetch|Failed|network|Could not join|Joined\./i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - await pg.waitForTimeout(6000); await tiers('after drop'); await open(); await tiers('after reload'); + const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); + expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); + expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); + expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); } else if (MODE === 'admin') { - await tap('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin estate'); + await tap('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin estate'); + expect('an admin-invited DM is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxadmin') { - await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); await tiers('admin inbox'); + await acceptInInbox('Friend DM'); await pg.waitForTimeout(6000); const r = await tiers('admin inbox'); + expect('an admin-invited DM accepted in the inbox is filed under Direct Messages', isDm(r.friend), r.friend); } else if (MODE === 'inboxreload') { await acceptInInbox('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open('#/inbox/invites/'); - await pg.waitForTimeout(30000); await tiers('inbox reload'); + await pg.waitForTimeout(30000); const r = await tiers('inbox reload'); + expect('a DM accepted in the inbox is filed under Direct Messages after a reload', isDm(r.slow), r.slow); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); log('SLOW_DM_STILL_JOINABLE', await l.count()); + const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + expect('a join cut off by a reload stays joinable', n === 1, String(n)); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 05e89df1..5def6635 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,4 +1,5 @@ const { chromium } = require('playwright'); +const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,21 +14,30 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('TIER', tag, '| Friend DM', await tierOf('Friend DM')); }; + const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { await pg.goto(`http://127.0.0.1:8080/#/${route}/${encodeURIComponent('!dm1:test')}/`); await pg.waitForTimeout(4000); const bb = await body(); log('ROUTE', route, JSON.stringify(bb.slice(0, 300))); const j = pg.getByRole('button', { name: /^Join$/ }); log('JOIN_BUTTONS', route, await j.count()); - if (await j.count()) { await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } + if (await j.count()) { joined = true; await j.first().click(); log('CLICK_JOIN', route, t()); await pg.waitForTimeout(6000); break; } } - await tiers('after deeplink join'); + const f = await tiers('after deeplink join'); + const stamps = (re) => hsLog().filter((l) => re.test(l)).map((l) => Number(l.split(' ')[0])); + const put = stamps(/ PUT .*m\.direct BODY .*!dm1:test/)[0], join = stamps(/ JOIN !dm1:test$/)[0]; + expect('the deep link offers one Join button', joined, 'no Join button'); + expect('the DM is filed before the join is sent', put !== undefined && join !== undefined && put <= join, `put ${put} join ${join}`); + expect('the DM is filed under Direct Messages', isDm(f), f); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - await pg.waitForTimeout(4000); await tiers('after 504 (join delivered by sync)'); + expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); + expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); + expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); } - await quit(0); + await quit(done()); })().catch((e) => { log('ERROR', String(e.stack || e).slice(0, 300)); quit(1); }); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index ed9950ce..cf794323 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,8 +1,10 @@ const { chromium } = require('playwright'); +const { expect, done, isDm } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +let tierBefore; (async () => { const b = await chromium.launch(); const ctx = await b.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, deviceScaleFactor: 1 }); @@ -20,7 +22,7 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(45000); } else if (MODE === 'convert') { await tap('Friend DM'); await pg.waitForTimeout(6000); - log('TIER before', await tierOf('Friend DM')); + tierBefore = await tierOf('Friend DM'); log('TIER before', tierBefore); await pg.goto('http://127.0.0.1:8080/#/direct/!dm1:test/'); await pg.waitForTimeout(4000); const ed = pg.locator('[contenteditable="true"]'); log('EDITORS', await ed.count(), pg.url()); await ed.first().click(); await pg.keyboard.type('/converttoroom', { delay: 30 }); await pg.waitForTimeout(500); @@ -36,6 +38,12 @@ const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'El await pg.waitForTimeout(30000); } for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('TIER Slow DM', await tierOf('Slow DM'), '| Late DM', await tierOf('Late DM'), '| Friend DM', await tierOf('Friend DM')); - await b.close(); + const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); + log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); + if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } + else if (MODE === 'convert') { + expect('Friend DM was filed under Direct Messages before the conversion', isDm(tierBefore), tierBefore); + expect('the converted room left Direct Messages and is still listed', !isDm(friend) && friend !== 'absent', friend); + } else expect('Slow DM is filed under Direct Messages after the reload', isDm(slow), slow); + await b.close(); process.exitCode = done(); })().catch((e) => { log('ERROR', String(e.stack || e)); process.exitCode = 1; }); diff --git a/harness/estate/run.sh b/harness/estate/run.sh index 379d28ca..65242ab0 100644 --- a/harness/estate/run.sh +++ b/harness/estate/run.sh @@ -8,19 +8,19 @@ npm run build > /tmp/build.log 2>&1 || { tail -30 /tmp/build.log; exit 1; } echo BUILD_OK $(git rev-parse HEAD) printf '{"defaultHomeserver":0,"homeserverList":["127.0.0.1:8080"],"allowCustomHomeservers":true,"featuredCommunities":{"spaces":["#gamecult:test"],"rooms":[],"servers":["test"]},"hashRouter":{"enabled":true,"basename":"/"}}' > dist/config.json mkdir -p /tmp/pw && cd /tmp/pw && npm init -y >/dev/null && npm i --no-audit --no-fund playwright@1.48.2 >/tmp/pw.log 2>&1 -cp /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ +cp /src/harness/estate/check.cjs /src/harness/estate/drive.cjs /src/harness/estate/drive-failures.cjs /src/harness/estate/drive-routes.cjs /tmp/pw/ set +e for m in twodm reload convert; do HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/drive.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/drive.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT_PUT|DELIVER|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt done failure() { m=$1; shift env "$@" HSLOG=/tmp/hslog-$m.txt NOROOT=0 node /src/harness/estate/fakehs.cjs /src/dist > /tmp/hs-$m.out 2>&1 & HS=$!; sleep 2 - MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; echo "DRIVE $m RC=$?" + HSLOG=/tmp/hslog-$m.txt MODE=$m node /tmp/pw/${DRIVER:-drive-failures}.cjs; rc=$?; echo "DRIVE $m RC=$rc"; [ $rc -eq 0 ] || FAILED="$FAILED $m" kill $HS; wait $HS 2>/dev/null; sleep 1 echo "=== hs $m"; grep -E "MDIRECT|DELIVER|JOIN_|STATE_" /tmp/hs-$m.out; grep -E " JOIN " /tmp/hslog-$m.txt; } failure pjfail FAIL_MDIRECT=1 @@ -31,4 +31,5 @@ failure inboxadmin INVITER_ADMIN=1 failure inboxreload JOIN_DELAY=1 DRIVER=drive-routes failure deeplink DRIVER=drive-routes failure gw504 JOIN_504=1 -echo DONE +echo "FAILED_DRIVES:${FAILED:- none}" +[ -z "$FAILED" ] From 9b66b2b25075a469fe36239a81c560b89d468505 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 01:52:45 +0100 Subject: [PATCH 38/40] estate harness: read a row's tier from a fresh visit without toggling tiers closed; catch the retryable message before sync replaces the row Co-Authored-By: Claude Sonnet 5.5 --- harness/estate/check.cjs | 28 +++++++++++++++++++ harness/estate/drive-failures.cjs | 13 ++++----- harness/estate/drive-routes.cjs | 11 ++++---- harness/estate/drive.cjs | 5 ++-- .../features/estate/joinEstateRoom.test.ts | 10 +++++-- 5 files changed, 48 insertions(+), 19 deletions(-) diff --git a/harness/estate/check.cjs b/harness/estate/check.cjs index 172fa301..656d532e 100644 --- a/harness/estate/check.cjs +++ b/harness/estate/check.cjs @@ -15,4 +15,32 @@ exports.mDirectPuts = () => exports.hsLog().filter((l) => / PUT .*m\.direct BODY /.test(l)).map((l) => l.split(' BODY ')[1]); /** Join requests the server saw for a room id. */ exports.joinsOf = (roomId) => exports.hsLog().filter((l) => new RegExp(` JOIN ${roomId}$`).test(l)).length; +const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; +/** The tier header above a row, 'absent' when the page does not show the row, 'none-above' when no tier header precedes it. */ +const tierOf = (pg, name) => + pg.evaluate((n) => { + const s = document.body.innerText.split('\n').map((x) => x.trim()); + const i = s.indexOf(n); + if (i < 0) return 'absent'; + for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; + return 'none-above'; + }, name); +/** + * The tier a room is listed under, read from a fresh visit to the room list: a collapsed tier is + * opened only if the row is not already showing, so reading never closes what is open. + */ +exports.revealedTier = async (pg, name) => { + await pg.goto('http://127.0.0.1:8080/#/home/'); + await pg.waitForTimeout(2500); + let t = await tierOf(pg, name); + for (const h of HEADERS) { + if (t !== 'absent') break; + const hl = pg.getByText(h, { exact: true }); + if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); t = await tierOf(pg, name); } + } + return t; +}; +/** True when the page text matches re within ms of now (a message can be replaced by a row change). */ +exports.sawText = (pg, re, ms) => + pg.waitForFunction((src) => new RegExp(src, 'i').test(document.body.innerText), re.source, { timeout: ms, polling: 50 }).then(() => true, () => false); exports.isDm = (tier) => /^direct messages$/i.test(tier); diff --git a/harness/estate/drive-failures.cjs b/harness/estate/drive-failures.cjs index 1172c853..d53d88a4 100644 --- a/harness/estate/drive-failures.cjs +++ b/harness/estate/drive-failures.cjs @@ -1,7 +1,7 @@ // Failure-mode scenarios for the estate join paths (estate tap and inbox Accept), run by run.sh // against fakehs.cjs with the env flag each mode needs. const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, joinsOf, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -20,9 +20,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); await open(); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; + const tiers = async (tag) => { const r = { friend: await tierOf('Friend DM'), slow: await tierOf('Slow DM') }; log('TIER', tag, '| Friend DM', r.friend, '| Slow DM', r.slow); return r; }; const neverUnfiled = () => mDirectPuts().every((b) => b.includes('!dm1:test')); const acceptInInbox = async (name) => { // reach the inbox through the navigator's own Inbox tab @@ -47,9 +47,8 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); expect('the retry joins once', joinsOf('!dm1:test') === 1, String(joinsOf('!dm1:test'))); expect('Friend DM is filed under Direct Messages after the retry', isDm(r.friend), r.friend); } else if (MODE === 'jdrop') { - await tap('Friend DM'); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/tap again to retry/i); log('AFTER_DROP', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 80) : 'no message'); - expect('a dropped join shows the retryable message', k >= 0, bb.slice(0, 200)); + await tap('Friend DM'); const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_DROP', saw); + expect('a dropped join shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(6000); const r1 = await tiers('after drop'); await open(); const r2 = await tiers('after reload'); expect('the entry is kept after the drop', isDm(r1.friend) && neverUnfiled(), r1.friend); expect('Friend DM is under Direct Messages after the reload', isDm(r2.friend), r2.friend); @@ -66,7 +65,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 240000); } else if (MODE === 'pjreload') { await tap('Slow DM'); await pg.waitForTimeout(2000); log('RELOAD', t()); await open(); await pg.waitForTimeout(8000); await tiers('pjreload'); - const l = pg.locator('[aria-label="Join Slow DM"]'); const n = await l.count(); log('SLOW_DM_STILL_JOINABLE', n); + const n = await (await reveal('Slow DM')).count(); log('SLOW_DM_STILL_JOINABLE', n); expect('a join cut off by a reload stays joinable', n === 1, String(n)); } await quit(done()); diff --git a/harness/estate/drive-routes.cjs b/harness/estate/drive-routes.cjs index 5def6635..75c9a8df 100644 --- a/harness/estate/drive-routes.cjs +++ b/harness/estate/drive-routes.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm, mDirectPuts, joinsOf, hsLog } = require('./check.cjs'); +const { expect, done, isDm, mDirectPuts, hsLog, revealedTier, sawText } = require('./check.cjs'); const MODE = process.env.MODE; const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); const HEADERS = ['GameCult', 'Projects', 'nibu', 'eureka', 'Nibu', 'Eureka', 'Elsewhere', 'Direct Messages']; @@ -13,8 +13,7 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); await pg.evaluate(() => { localStorage.setItem('cinny_access_token', 'tok'); localStorage.setItem('cinny_device_id', 'DEV'); localStorage.setItem('cinny_user_id', '@t:test'); localStorage.setItem('cinny_hs_base_url', 'http://127.0.0.1:8080'); }); await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.reload(); await pg.waitForTimeout(9000); const body = async () => (await pg.evaluate(() => document.body.innerText)).replace(/\s+/g, ' '); - const tierOf = (n) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, n); - const tiers = async (tag) => { await pg.goto('http://127.0.0.1:8080/#/home/'); await pg.waitForTimeout(2500); for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const f = await tierOf('Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; + const tiers = async (tag) => { const f = await revealedTier(pg, 'Friend DM'); log('TIER', tag, '| Friend DM', f); return f; }; let joined = false; if (MODE === 'deeplink') { for (const route of ['direct', 'home']) { @@ -32,9 +31,9 @@ setTimeout(() => { console.log('DRIVER_TIMEOUT'); quit(3); }, 120000); } else if (MODE === 'gw504') { const HL = HEADERS; let l = pg.locator('[aria-label="Join Friend DM"]'); for (let r = 0; r < 2 && !(await l.count()); r++) for (const h of HL) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } - log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); await pg.waitForTimeout(4000); - const bb = await body(); const k = bb.search(/504|Gateway|connection dropped|Could not join/i); log('AFTER_504', k >= 0 ? bb.slice(Math.max(0, k - 60), k + 120) : 'no message'); - expect('a gateway timeout shows the retryable message', /tap again to retry/i.test(bb), bb.slice(0, 200)); + log('JOINCTRL', await l.count()); await l.first().click(); log('CLICK', t()); + const saw = await sawText(pg, /tap again to retry/, 3000); log('AFTER_504', saw); + expect('a gateway timeout shows the retryable message', saw, (await body()).slice(0, 200)); await pg.waitForTimeout(4000); const f = await tiers('after 504 (join delivered by sync)'); expect('the DM stays filed after a gateway timeout', mDirectPuts().every((b) => b.includes('!dm1:test')) && mDirectPuts().length > 0, JSON.stringify(mDirectPuts())); expect('the DM is filed under Direct Messages once sync delivers it', isDm(f), f); diff --git a/harness/estate/drive.cjs b/harness/estate/drive.cjs index cf794323..03bd2ad0 100644 --- a/harness/estate/drive.cjs +++ b/harness/estate/drive.cjs @@ -1,5 +1,5 @@ const { chromium } = require('playwright'); -const { expect, done, isDm } = require('./check.cjs'); +const { expect, done, isDm, revealedTier } = require('./check.cjs'); const MODE = process.env.MODE; // twodm | reload const T0 = Date.now(); const t = () => `+${Date.now() - T0}ms`; const log = (...a) => console.log(`[${MODE}]`, ...a); @@ -15,7 +15,7 @@ let tierBefore; await open(); const text = () => pg.evaluate(() => document.body.innerText); const reveal = async (name) => { const loc = pg.locator(`[aria-label="Join ${name}"]`); if (await loc.count()) return loc; for (let r = 0; r < 2; r++) for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); if (await loc.count()) return loc; } } return loc; }; - const tierOf = (name) => pg.evaluate((n) => { const s = document.body.innerText.split('\n').map((x) => x.trim()); const i = s.indexOf(n); if (i < 0) return 'absent'; for (let j = i; j >= 0; j--) if (/^(elsewhere|direct messages)$/i.test(s[j])) return s[j]; return 'none-above'; }, name); + const tierOf = (name) => revealedTier(pg, name); const tap = async (n) => { const l = await reveal(n); log('JOINCTRL', n, await l.count()); await l.first().click(); log('CLICK', n, t()); }; if (MODE === 'twodm') { await tap('Slow DM'); await pg.waitForTimeout(300); await tap('Late DM'); @@ -37,7 +37,6 @@ let tierBefore; log('RELOAD', t()); await open(); await pg.waitForTimeout(30000); } - for (const h of HEADERS) { const hl = pg.getByText(h, { exact: true }); if (await hl.count()) { await hl.first().click(); await pg.waitForTimeout(250); } } const slow = await tierOf('Slow DM'), late = await tierOf('Late DM'), friend = await tierOf('Friend DM'); log('TIER Slow DM', slow, '| Late DM', late, '| Friend DM', friend); if (MODE === 'twodm') { expect('Slow DM is filed under Direct Messages', isDm(slow), slow); expect('Late DM is filed under Direct Messages', isDm(late), late); } diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index cc1d232f..f3f868da 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -123,8 +123,8 @@ const dmClient = (opts: { writes.push(structuredClone(content)); direct = structuredClone(content); }, - joinRoom: async (roomId: string, opts?: unknown) => { - joinOpts.push(opts); + joinRoom: async (roomId: string, joinArgs?: unknown) => { + joinOpts.push(joinArgs); // the join lands only after m.direct has been written: record what it held joined.push(JSON.stringify(direct ?? {})); if (opts.refuse) @@ -179,7 +179,11 @@ describe('activateEstateRoom when the join gets no answer', () => { describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { - const c = dmClient({ status, errcode: 'M_LIMIT_EXCEEDED', invites: { '!dm1:test': '@friend:test' } }); + const c = dmClient({ + status, + errcode: 'M_LIMIT_EXCEEDED', + invites: { '!dm1:test': '@friend:test' }, + }); const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); From a4da78edc5dcc14e684a2c85780ab8975f50cd05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:00:38 +0100 Subject: [PATCH 39/40] estate tests: an unfiled room leaves its inviter's list empty, not the map Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/estate/joinEstateRoom.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index f3f868da..0bc61a9f 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -187,7 +187,7 @@ describe('activateEstateRoom refusal bounds', () => { const result = await activateEstateRoom(c.mx, tap('!dm1:test')); expect(result.kind).toBe('refused'); expect((result as { message: string }).message).not.toMatch(/tap again to retry/); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); it.each([399, 500])('keeps the room listed on a %i that carries an errcode', async (status) => { @@ -248,7 +248,7 @@ describe('joinEstateRoomOrThrow, the lobby, tombstone and predecessor buttons', it('throws the refusal and unfiles the room', async () => { const c = dmClient({ refuse: true, invites: { '!dm1:test': '@friend:test' } }); await expect(joinEstateRoomOrThrow(c.mx, '!dm1:test')).rejects.toThrow('M_FORBIDDEN'); - expect(c.current()).toEqual({}); + expect(c.current()).toEqual({ '@friend:test': [] }); }); }); From b33d8de95638b2057dc3f731a00ad8587f5757ea Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 02:04:14 +0100 Subject: [PATCH 40/40] estate tests: pin the failed-filing refusal, a non-DM refusal leaving m.direct alone, and awaitMyJoin's event name Co-Authored-By: Claude Sonnet 5.5 --- .../features/estate/joinEstateRoom.test.ts | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/app/features/estate/joinEstateRoom.test.ts b/src/app/features/estate/joinEstateRoom.test.ts index 0bc61a9f..932e7cce 100644 --- a/src/app/features/estate/joinEstateRoom.test.ts +++ b/src/app/features/estate/joinEstateRoom.test.ts @@ -96,6 +96,7 @@ const dmClient = (opts: { errcode?: string; refuse?: boolean; drop?: boolean; + failWrite?: boolean; invites: Record; }) => { let direct: Record | undefined; @@ -117,6 +118,7 @@ const dmClient = (opts: { }), getAccountData: () => (direct ? { getContent: () => direct } : undefined), setAccountData: async (_t: string, content: Record) => { + if (opts.failWrite) throw new Error('account data write failed'); await new Promise((r) => { setTimeout(r, 5); }); @@ -177,6 +179,29 @@ describe('activateEstateRoom when the join gets no answer', () => { }); }); +describe('activateEstateRoom when the filing cannot be written', () => { + it('reports the failure and sends no join', async () => { + const c = dmClient({ failWrite: true, invites: { '!dm1:test': '@friend:test' } }); + const result = await activateEstateRoom(c.mx, tap('!dm1:test')); + expect(result).toEqual({ kind: 'refused', message: 'account data write failed' }); + expect(c.joined).toEqual([]); + }); +}); + +describe('activateEstateRoom when a room that is not a direct message refuses', () => { + it('reports the refusal and leaves m.direct alone', async () => { + const joinRoom = vi + .fn() + .mockRejectedValue( + Object.assign(new Error('no'), { httpStatus: 403, errcode: 'M_FORBIDDEN' }) + ); + const { mx, calls } = recordingClient(joinRoom); + const result = await activateEstateRoom(mx, room('joinable')); + expect(result.kind).toBe('refused'); + expect(calls).toEqual(['joinRoom']); + }); +}); + describe('activateEstateRoom refusal bounds', () => { it.each([400, 429, 499])('unfiles the room on a %i that carries an errcode', async (status) => { const c = dmClient({ @@ -278,8 +303,12 @@ describe('awaitMyJoin', () => { return { mx: { getRoom: () => (membership ? { getMyMembership: () => membership! } : null), - on: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.add(h), - off: (_e: string, h: (room: { roomId: string }, m: string) => void) => handlers.delete(h), + on: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.add(h); + }, + off: (e: string, h: (room: { roomId: string }, m: string) => void) => { + if (e === 'Room.myMembership') handlers.delete(h); + }, }, handlers, become: (roomId: string, m: string) => {