From b6bb6e56b92c84e78c767f21f857957f005de5c9 Mon Sep 17 00:00:00 2001 From: enki Date: Fri, 10 Jul 2026 19:40:31 -0700 Subject: [PATCH] blap: thread focus mode, room-list thread rows, fix round - Thread focus mode: threads open centered in the main pane (expand button, room-list thread rows, and any ShowThread while focus mode is active); back bar / Escape / channel click return to the timeline. Centering strips the sidebar's thread cards so no duplicate remains. - Room list: active-thread rows under channels (unread threads plus the one currently open), collapsible section headers with per-space persistence, live space-membership DM filtering (fixes the DM section capping at ~6 rooms after a space switch). - CSS: Compound token overrides now also target the body theme class so remapped tokens stop shadowing back to green; reply-quote restyle; min-width on the room body so the message action bar can't render off-pane with a thread panel open in narrow windows (edit "did nothing"). Co-Authored-By: Claude Fable 5 --- apps/web/res/css/yap/_yap.pcss | 156 ++++++++++- .../src/components/structures/MatrixChat.tsx | 9 + .../src/components/structures/RoomView.tsx | 97 ++++++- .../src/components/structures/ThreadView.tsx | 21 +- .../views/rooms/RoomListPanel/YapRoomList.tsx | 243 ++++++++++++++++-- apps/web/src/stores/BlapThreadFocus.ts | 55 ++++ 6 files changed, 563 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/stores/BlapThreadFocus.ts diff --git a/apps/web/res/css/yap/_yap.pcss b/apps/web/res/css/yap/_yap.pcss index 7519da514a..78f7ea7940 100644 --- a/apps/web/res/css/yap/_yap.pcss +++ b/apps/web/res/css/yap/_yap.pcss @@ -8,7 +8,14 @@ * consume the --yap-* tokens defined here. */ @layer yap { - :root { + /* Compound defines its dark tokens on `.cpd-theme-dark.cpd-theme-dark`, and + element-web puts that class on . Custom properties resolve by nearest + ancestor, so overrides declared only on :root () are shadowed by + Compound's body-level values for everything inside the app (this is how the + send button / unread bar stayed green). Declare on the theme-class elements + too so our values win on and inside nested cpd-theme-* wrappers. */ + :root, + [class*="cpd-theme-"] { /* ---- Typography ---- */ --yap-font-sans: "Hanken Grotesk", "Inter", system-ui, sans-serif; --yap-font-mono: "JetBrains Mono", "Courier New", monospace; @@ -504,6 +511,44 @@ text-transform: uppercase; color: var(--yap-text-faint); } + /* threads nested under their channel (Discord-style) */ + .mx_YapThreadRow { + margin-left: 22px; + padding-block: 3px; + font-size: 13px; + color: var(--yap-text-muted); + } + .mx_YapThreadRow .mx_YapRoomRow_name { + font-weight: 500; + } + .mx_YapThreadRow_glyph { + color: var(--yap-text-faint); + font-size: 13px; + width: 16px; + text-align: center; + flex-shrink: 0; + } + + /* collapsible section headers (Discord-style) */ + .mx_YapRoomList_header_toggle { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; + } + .mx_YapRoomList_header_toggle:hover { + color: var(--yap-text-secondary); + } + .mx_YapRoomList_header_chevron { + display: inline-block; + font-size: 9px; + line-height: 1; + transition: transform 0.15s ease; + } + .mx_YapRoomList_header_collapsed .mx_YapRoomList_header_chevron { + transform: rotate(-90deg); + } /* in-call members shown under a voice channel (who's in before you join) */ .mx_YapVoiceMembers { display: flex; @@ -820,6 +865,115 @@ font-variant-numeric: tabular-nums; } + /* Never let the chat pane collapse when the right panel (threads, members…) + is open in a smaller window: below ~340px the message lines shrink so far + that the hover action bar (Edit, React…) positions outside the pane — + invisible and unclickable ("clicking edit does nothing"). The panel + resizer yields to this minimum instead. */ + .mx_MainSplit > .mx_RoomView_body { + min-width: 340px; + } + + /* ============================================================ + Focused thread view — a thread taking over the main pane + (Discord "open as page"), plus the header button that opens it. + ============================================================ */ + .mx_BlapCenteredThread { + display: flex; + flex-direction: column; + min-height: 0; + } + .mx_BlapCenteredThread_back { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 16px; + color: var(--yap-text-secondary); + font-size: 13.5px; + border-bottom: 1px solid var(--yap-hairline); + } + .mx_BlapCenteredThread_back:hover { + color: var(--yap-text-primary); + } + .mx_BlapCenteredThread_back svg { + color: var(--yap-accent); + } + .mx_BlapCenteredThread .mx_ThreadView { + flex: 1 1 0; + min-height: 0; + /* keep long threads readable in the wide pane */ + max-width: 960px; + width: 100%; + margin: 0 auto; + } + /* the back bar owns "leave this view" in centered mode — drop the card's × + and let the timeline breathe a little */ + .mx_BlapCenteredThread .mx_BaseCard_close { + display: none; + } + .mx_BlapCenteredThread .mx_BaseCard_header { + border-bottom: none; + } + + /* the "open as page" button in the thread panel header */ + .mx_BlapThreadCenterButton { + display: flex; + align-items: center; + color: var(--yap-text-muted); + margin-left: auto; + } + .mx_BlapThreadCenterButton:hover { + color: var(--yap-accent); + } + /* the header title already flexes; keep the context menu at the edge */ + .mx_ThreadView .mx_BaseCard_header_title { + display: flex; + align-items: center; + } + + /* ============================================================ + Reply quotes — Blap card (matches the thread chip vocabulary): + soft rounded card + accent edge instead of Element's bare + 2px username-colored border. + ============================================================ */ + .mx_ReplyChain { + background-color: rgba(255, 255, 255, 0.035); + border-left: 3px solid var(--yap-accent); + border-radius: 10px; + padding: 5px 10px; + margin-bottom: 4px; + } + .mx_ReplyChain .mx_ReplyTile > a { + color: var(--yap-text-secondary); + } + .mx_ReplyChain .mx_ReplyChain_show.mx_AccessibleButton_kind_link_inline { + color: var(--yap-text-muted); + font-size: 11.5px; + } + /* the quoted sender name reads as a Blap label, not a rainbow username */ + .mx_ReplyChain .mx_DisambiguatedProfile > span { + color: var(--yap-accent) !important; + font-weight: 600; + } + /* composer reply preview ("Replying to …") gets the same card */ + .mx_ReplyPreview { + background-color: var(--yap-surface-composer); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 13px 13px 0 0; + margin: 0 16px; + } + .mx_ReplyPreview .mx_ReplyPreview_header { + color: var(--yap-text-muted); + } + /* markdown blockquotes match the quote language too */ + .mx_EventTile_content blockquote { + border-left: 3px solid var(--yap-accent); + background-color: rgba(255, 255, 255, 0.03); + border-radius: 0 8px 8px 0; + margin: 4px 0; + padding: 4px 10px; + } + /* ============================================================ Thread summary — chip ============================================================ */ diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index 71094c9163..04da58ead0 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -113,6 +113,7 @@ import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import { CallStore } from "../../stores/CallStore"; import { type IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; import { type ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; +import { getBlapCenteredRoomId } from "../../stores/BlapThreadFocus"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { TimelineRenderingType } from "../../contexts/RoomContext"; @@ -945,6 +946,14 @@ export default class MatrixChat extends React.PureComponent { case Action.ShowThread: { const { rootEvent, initialEvent, highlighted, scrollIntoView, push } = payload as ShowThreadPayload; + // Blap: while thread focus mode is active in this room, threads keep + // opening centered — a sidebar copy next to the focused view is the + // confusing case, and the back bar still returns to the channel. + if (rootEvent.getRoomId() && rootEvent.getRoomId() === getBlapCenteredRoomId()) { + dis.dispatch({ action: "blap_center_thread", event: rootEvent }); + break; + } + const threadViewCard = { phase: RightPanelPhases.ThreadView, state: { diff --git a/apps/web/src/components/structures/RoomView.tsx b/apps/web/src/components/structures/RoomView.tsx index dd5e071b97..39a07c229f 100644 --- a/apps/web/src/components/structures/RoomView.tsx +++ b/apps/web/src/components/structures/RoomView.tsx @@ -88,6 +88,9 @@ import { containsEmoji } from "../../effects/utils"; import { CHAT_EFFECTS } from "../../effects"; import { CallView } from "../views/voip/CallView"; import { BlapCallColumnHeader } from "../views/voip/BlapCallColumnHeader"; +import ThreadView from "./ThreadView"; +import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left"; +import { consumePendingBlapThreadFocus, setBlapCenteredThread } from "../../stores/BlapThreadFocus"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import Notifier from "../../Notifier"; import { showToast as showNotificationsToast } from "../../toasts/DesktopNotificationsToast"; @@ -238,6 +241,8 @@ export interface IRoomState { // Blap: true when someone in this room's call is screen sharing (signalled from // our Element Call fork via localStorage). Drives the call-takes-prominence layout. callScreenSharing?: boolean; + // Blap: thread root currently focused in the MAIN pane (Discord-style page view). + blapCenteredThreadEvent?: MatrixEvent; canPeek: boolean; canSelfRedact: boolean; showApps: boolean; @@ -655,6 +660,11 @@ export class RoomView extends React.Component { // Blap: pick up any in-progress screenshare for the room we're switching to // (storage events only fire on change, so read the current value directly). callScreenSharing: this.readBlapScreenSharing(roomId), + // Blap: pick up a thread focus parked by the sidebar (survives the room + // switch — a dispatcher action would race RoomView mounting). Tracked in + // an instance field because this handler fires several times per room + // load, faster than setState commits. + blapCenteredThreadEvent: this.updateBlapCenteredThread(roomId), roomAlias: roomAlias, roomLoading: roomLoading, roomLoadError, @@ -1054,6 +1064,45 @@ export class RoomView extends React.Component { } } + // Blap: authoritative copy of the centered-thread root; state mirrors this. + private blapCenteredThread?: MatrixEvent; + + // Blap: when a thread takes over the main pane, remove the sidebar's thread + // cards. ShowThread seeds the stack as [ThreadPanel, ThreadView], so popping + // only the view would reveal the Threads list next to the centered thread. + private static blapCloseSidebarThreadCards(roomId: string): void { + const store = RightPanelStore.instance; + const threadPhases: Array = [ + RightPanelPhases.ThreadView, + RightPanelPhases.ThreadPanel, + ]; + while (threadPhases.includes(store.currentCardForRoom(roomId).phase)) { + store.popCard(roomId); + } + // Don't leave the panel open showing an empty stack. + if (store.currentCardForRoom(roomId).phase === null) store.hide(roomId); + } + + private updateBlapCenteredThread(roomId?: string | null): MatrixEvent | undefined { + const pendingFocus = consumePendingBlapThreadFocus(roomId); + if (pendingFocus) { + this.blapCenteredThread = pendingFocus; + // The room-list entry point can't close the right panel itself — a + // restored per-room thread card would duplicate the centered thread. + if (roomId) RoomView.blapCloseSidebarThreadCards(roomId); + } + if (this.blapCenteredThread?.getRoomId() !== roomId) this.blapCenteredThread = undefined; + setBlapCenteredThread(this.blapCenteredThread ? (roomId ?? null) : null, this.blapCenteredThread?.getId()); + return this.blapCenteredThread; + } + + // Blap: leave the focused main-pane thread view, back to the channel timeline. + private onBlapUncenterThread = (): void => { + this.blapCenteredThread = undefined; + setBlapCenteredThread(null); + this.setState({ blapCenteredThreadEvent: undefined }); + }; + private onBlapScreenShareStorage = (e: StorageEvent): void => { const roomId = this.state.roomId; if (!roomId) return; @@ -1098,6 +1147,9 @@ export class RoomView extends React.Component { // (We could use isMounted, but facebook have deprecated that.) this.unmounted = true; + // Blap: focus mode dies with the room view. + setBlapCenteredThread(null); + this.context.legacyCallHandler.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); // update the scroll map before we get unmounted @@ -1258,6 +1310,20 @@ export class RoomView extends React.Component { showApps: payload.show, }); break; + case "blap_center_thread": { + // Blap: focus a thread in the main pane (Discord-style "open as page"). + const rootEvent = payload.event as MatrixEvent | undefined; + if (!rootEvent) break; + this.blapCenteredThread = rootEvent; + setBlapCenteredThread(rootEvent.getRoomId() ?? null, rootEvent.getId()); + this.setState({ blapCenteredThreadEvent: rootEvent }); + // Close the sidebar's thread cards — the centered view replaces them. + RoomView.blapCloseSidebarThreadCards(rootEvent.getRoomId()!); + break; + } + case "blap_uncenter_thread": + this.onBlapUncenterThread(); + break; case "reply_to_event": if ( !this.unmounted && @@ -2712,7 +2778,36 @@ export class RoomView extends React.Component { switch (mainSplitContentType) { case MainSplitContentType.Timeline: mainSplitContentClassName = "mx_MainSplit_timeline"; - mainSplitBody = timelineBody; + // Blap: a thread can take over the main pane (Discord's focused + // thread view). The channel timeline comes back via the back bar, + // Escape, or opening another room. + if (this.state.blapCenteredThreadEvent?.getRoomId() === this.state.room.roomId) { + mainSplitContentClassName = "mx_MainSplit_timeline mx_BlapCenteredThread"; + mainSplitBody = ( + <> + + + + Back to #{this.state.room.name} + + + + + ); + } else { + mainSplitBody = timelineBody; + } break; case MainSplitContentType.MaximisedWidget: mainSplitContentClassName = "mx_MainSplit_maximisedWidget"; diff --git a/apps/web/src/components/structures/ThreadView.tsx b/apps/web/src/components/structures/ThreadView.tsx index 6c0a04bb4b..4f1bb101bf 100644 --- a/apps/web/src/components/structures/ThreadView.tsx +++ b/apps/web/src/components/structures/ThreadView.tsx @@ -44,7 +44,8 @@ import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import Measured from "../views/elements/Measured"; import PosthogTrackers from "../../PosthogTrackers"; -import { type ButtonEvent } from "../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; +import ExpandIcon from "@vector-im/compound-design-tokens/assets/web/icons/expand"; import Spinner from "../views/elements/Spinner"; import { type ComposerInsertPayload, ComposerType } from "../../dispatcher/payloads/ComposerInsertPayload"; import Heading from "../views/typography/Heading"; @@ -63,6 +64,9 @@ interface IProps { initialEvent?: MatrixEvent; isInitialEventHighlighted?: boolean; initialEventScrollIntoView?: boolean; + // Blap: true when rendered as the focused main-pane view — hides the + // "Open as page" button (we're already there). + blapCentered?: boolean; } interface IState { @@ -346,12 +350,27 @@ export default class ThreadView extends React.Component { return relation; } + // Blap: open this thread as a focused view in the main pane (Discord-style). + private onBlapCenter = (): void => { + dis.dispatch({ action: "blap_center_thread", event: this.props.mxEvent }); + this.props.onClose(); + }; + private renderThreadViewHeader = (): JSX.Element => { return (
{_t("common|thread")} + {!this.props.blapCentered && ( + + + + )}
); diff --git a/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx b/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx index 4870f400f4..9835dad295 100644 --- a/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx +++ b/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx @@ -6,7 +6,14 @@ falling back to RoomListStoreV3's default sort for any unplaced room. Call presence via CallStore (read-only). */ import React, { useEffect, useRef, useState, type JSX } from "react"; -import { type Room, EventType, RoomStateEvent, KnownMembership } from "matrix-js-sdk/src/matrix"; +import { + type Room, + type Thread, + EventType, + RoomStateEvent, + KnownMembership, + NotificationCountType, +} from "matrix-js-sdk/src/matrix"; import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../stores/room-list-v3/RoomListStoreV3"; import defaultDispatcher from "../../../../dispatcher/dispatcher"; @@ -18,15 +25,30 @@ import MemberAvatar from "../../avatars/MemberAvatar"; import AudioIcon from "@vector-im/compound-design-tokens/assets/web/icons/audio"; import { SdkContextClass } from "../../../../contexts/SDKContext"; import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore"; +import { NotificationLevel } from "../../../../stores/notifications/NotificationLevel"; import { UPDATE_EVENT } from "../../../../stores/AsyncStore"; import { useCall, useParticipatingMembers } from "../../../../hooks/useCall"; import SpaceStore from "../../../../stores/spaces/SpaceStore"; +import { + setPendingBlapThreadFocus, + getBlapCenteredThreadId, + onBlapCenteredThreadChange, +} from "../../../../stores/BlapThreadFocus"; +import RightPanelStore from "../../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases"; import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore"; function currentRooms(): Room[] { + // Filter by space membership LIVE (SpaceStore.isRoomInSpace) instead of using + // getSortedRoomsInActiveSpace(): the store caches an isInActiveSpace flag per room + // that is only recomputed on space *switch*. With lazy-loaded sync only ~6 space + // members (the sync "heroes" + self) are known at that moment, so DMs with + // everyone else were missing until the next space switch — the "DM section caps + // at 6 in random order" bug. + const spaceKey = SpaceStore.instance.activeSpace; return RoomListStoreV3.instance - .getSortedRoomsInActiveSpace() - .sections.flatMap((s) => s.rooms) + .getSortedRooms() + .filter((room) => SpaceStore.instance.isRoomInSpace(spaceKey, room.roomId)) .filter((room) => { const m = room.getMyMembership(); return m === KnownMembership.Join || m === KnownMembership.Invite; @@ -89,6 +111,9 @@ function PlatformBadge({ room }: { room: Room }): JSX.Element | null { } function viewRoom(roomId: string): void { + // Blap: navigating via the room list always lands on the channel timeline — + // clicking the active channel while a thread is centered acts like "back". + defaultDispatcher.dispatch({ action: "blap_uncenter_thread" }); defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: roomId, metricsTrigger: "RoomList" }); } @@ -134,6 +159,24 @@ function setRoomOrder(spaceId: string, ids: string[]): void { /* ignore */ } } + +// ---- collapsed section state (per space, device-local) ---- +type SectionId = "channels" | "voice" | "dms"; +const collapseKey = (spaceId: string): string => `yap_sections_collapsed_${spaceId}`; +function getCollapsed(spaceId: string): Partial> { + try { + return JSON.parse(localStorage.getItem(collapseKey(spaceId)) ?? "{}"); + } catch { + return {}; + } +} +function setCollapsed(spaceId: string, section: SectionId, collapsed: boolean): void { + try { + localStorage.setItem(collapseKey(spaceId), JSON.stringify({ ...getCollapsed(spaceId), [section]: collapsed })); + } catch { + /* ignore */ + } +} /** Sort rooms by the manual order; unplaced rooms keep their (stable) default order at the end. */ function applyManualOrder(rooms: Room[], order: string[]): Room[] { const idx = new Map(order.map((id, i) => [id, i])); @@ -253,6 +296,118 @@ function DmRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element { ); } +/** Discord-style collapsible section header (chevron rotates when collapsed). */ +function SectionHeader({ + label, + collapsed, + onToggle, +}: { + label: string; + collapsed: boolean; + onToggle: () => void; +}): JSX.Element { + return ( +
{ + if (e.key === "Enter" || e.key === " ") onToggle(); + }} + > + + {label} +
+ ); +} + +/** When a section is collapsed, notifying + active rooms stay visible (Discord + * behavior). Plain "activity" (grey dot) is NOT enough to pin a room, otherwise + * collapsed sections would almost never shrink. */ +function visibleWhenCollapsed(rooms: Room[], activeRoomId: string | null): Room[] { + return rooms.filter( + (r) => + r.roomId === activeRoomId || + RoomNotificationStateStore.instance.getRoomState(r).level >= NotificationLevel.Notification, + ); +} + +// ---- Discord-style threads-under-channel rows ---- +const MAX_THREADS_PER_CHANNEL = 8; + +/** The thread the user has open in this room right now (centered or sidebar), if any. */ +function openThreadId(room: Room): string | undefined { + const centeredId = getBlapCenteredThreadId(room.roomId); + if (centeredId) return centeredId; + const card = RightPanelStore.instance.currentCardForRoom(room.roomId); + if (card.phase === RightPanelPhases.ThreadView) return card.state?.threadHeadEvent?.getId(); + return undefined; +} + +/** Threads worth showing under a channel: unread ones, plus the one currently open — no timers. */ +function activeThreads(room: Room): Thread[] { + const openId = openThreadId(room); + return room + .getThreads() + .filter( + (t) => + t.id === openId || + room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) > 0, + ) + .sort( + (a, b) => + (b.replyToEvent?.getTs() ?? b.rootEvent?.getTs() ?? 0) - + (a.replyToEvent?.getTs() ?? a.rootEvent?.getTs() ?? 0), + ) + .slice(0, MAX_THREADS_PER_CHANNEL); +} + +/** Matrix threads have no titles, so label them with the root message. */ +function threadLabel(thread: Thread): string { + const body = thread.rootEvent?.getContent()?.body as string | undefined; + return body?.replace(/\s+/g, " ").trim() || "Thread"; +} + +function ThreadRow({ room, thread }: { room: Room; thread: Thread }): JSX.Element { + const count = room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); + const mention = room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) > 0; + const onClick = (): void => { + // Already viewing this channel: RoomView is mounted, center directly + // (viewRoom would uncenter, and a same-room ViewRoom may not re-emit). + if (thread.rootEvent && SdkContextClass.instance.roomViewStore.getRoomId() === room.roomId) { + defaultDispatcher.dispatch({ action: "blap_center_thread", event: thread.rootEvent }); + return; + } + // park the focus request first — RoomView consumes it when the room loads + // (a dispatcher action would race RoomView mounting on room switch) + if (thread.rootEvent) setPendingBlapThreadFocus(thread.rootEvent); + viewRoom(room.roomId); + }; + return ( +
+ + {threadLabel(thread)} + {count > 0 && ( + + {count} + + )} +
+ ); +} + function InviteRow({ room }: { room: Room }): JSX.Element { return (
onBlapCenteredThreadChange(() => setTick((t) => t + 1)), []); + useEffect(() => { + const update = (): void => setTick((t) => t + 1); + RightPanelStore.instance.on(UPDATE_EVENT, update); + return () => { + RightPanelStore.instance.off(UPDATE_EVENT, update); + }; + }, []); + const activeSpaceRoom = SpaceStore.instance.activeSpaceRoom; // Use the active space KEY (not the room id) so manual ordering also works in // Home and other meta-spaces, where activeSpaceRoom is null. @@ -358,6 +524,13 @@ export function YapRoomList(): JSX.Element { ? `Since you last looked, ${newRoomCount} new room${newRoomCount === 1 ? "" : "s"}` : undefined; + const collapsed = spaceId ? getCollapsed(spaceId) : {}; + const toggleSection = (section: SectionId): void => { + if (!spaceId) return; + setCollapsed(spaceId, section, !collapsed[section]); + setTick((t) => t + 1); + }; + const onBrowse = (): void => { if (!activeSpaceRoom) return; markRoomsSeen(activeSpaceRoom.roomId, spaceChildRoomIds(activeSpaceRoom)); @@ -368,6 +541,9 @@ export function YapRoomList(): JSX.Element { useEffect(() => { const space = SpaceStore.instance.activeSpaceRoom; if (!space) return; + // Make sure the full (lazy-loaded) member list is fetched, so every DM + // partner counts as "in the space" — not just the sync heroes. + space.loadMembersIfNeeded().catch(() => {}); if (localStorage.getItem(seenKey(space.roomId)) === null) { markRoomsSeen(space.roomId, spaceChildRoomIds(space)); } @@ -378,6 +554,22 @@ export function YapRoomList(): JSX.Element { }; }, [activeSpaceRoom?.roomId]); + // SpaceStore emits the space's roomId when its membership info changes (e.g. the + // full member list finishes loading). The room list store doesn't listen to this, + // so re-read the rooms ourselves or the DM section stays stuck on the few members + // known at space-switch time. + useEffect(() => { + if (!spaceId) return; + const update = (): void => { + setRooms(currentRooms()); + setTick((t) => t + 1); + }; + SpaceStore.instance.on(spaceId, update); + return () => { + SpaceStore.instance.off(spaceId, update); + }; + }, [spaceId]); + return (
{activeSpaceRoom && ( @@ -403,19 +595,34 @@ export function YapRoomList(): JSX.Element { ))} - {channels.length > 0 &&
Text Channels
} - {channels.map((room) => ( - 0 && ( + toggleSection("channels")} /> + )} + {(collapsed.channels ? visibleWhenCollapsed(channels, activeRoomId) : channels).map((room) => ( + + + {!collapsed.channels && + activeThreads(room).map((t) => )} + ))} - {voice.length > 0 &&
Voice Channels
} - {voice.map((room) => ( + {voice.length > 0 && ( + toggleSection("voice")} + /> + )} + {(collapsed.voice ? visibleWhenCollapsed(voice, activeRoomId) : voice).map((room) => ( ))} - {dms.length > 0 &&
Direct Messages
} - {dms.map((room) => ( + {dms.length > 0 && ( + toggleSection("dms")} + /> + )} + {(collapsed.dms ? visibleWhenCollapsed(dms, activeRoomId) : dms).map((room) => ( void>(); + +export function setBlapCenteredThread(roomId: string | null, threadId?: string): void { + const next = roomId ? { roomId, threadId } : null; + if (next?.roomId === centered?.roomId && next?.threadId === centered?.threadId) return; + centered = next; + centeredListeners.forEach((listener) => listener()); +} + +export function getBlapCenteredRoomId(): string | null { + return centered?.roomId ?? null; +} + +export function getBlapCenteredThreadId(roomId: string): string | undefined { + return centered?.roomId === roomId ? centered.threadId : undefined; +} + +/** Subscribe to focus-mode changes; returns the unsubscribe. */ +export function onBlapCenteredThreadChange(listener: () => void): () => void { + centeredListeners.add(listener); + return () => { + centeredListeners.delete(listener); + }; +}