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 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 19:40:31 -07:00
parent 955b3ea71d
commit b6bb6e56b9
6 changed files with 563 additions and 18 deletions
+155 -1
View File
@@ -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 <body>. Custom properties resolve by nearest
ancestor, so overrides declared only on :root (<html>) 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 <body> 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
============================================================ */
@@ -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<IProps, IState> {
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: {
@@ -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<IRoomProps, IRoomState> {
// 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<IRoomProps, IRoomState> {
}
}
// 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 | null> = [
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<IRoomProps, IRoomState> {
// (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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
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 = (
<>
<AccessibleButton
className="mx_BlapCenteredThread_back"
onClick={this.onBlapUncenterThread}
>
<ChevronLeftIcon width="18" height="18" />
<span>
Back to <b>#{this.state.room.name}</b>
</span>
</AccessibleButton>
<ThreadView
room={this.state.room}
mxEvent={this.state.blapCenteredThreadEvent}
resizeNotifier={this.context.resizeNotifier}
permalinkCreator={this.getPermalinkCreatorForRoom()}
e2eStatus={this.state.e2eStatus}
onClose={this.onBlapUncenterThread}
blapCentered={true}
/>
</>
);
} else {
mainSplitBody = timelineBody;
}
break;
case MainSplitContentType.MaximisedWidget:
mainSplitContentClassName = "mx_MainSplit_maximisedWidget";
@@ -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<IProps, IState> {
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 (
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("common|thread")}
</Heading>
{!this.props.blapCentered && (
<AccessibleButton
className="mx_BlapThreadCenterButton"
title="Open as page"
onClick={this.onBlapCenter}
>
<ExpandIcon width="20" height="20" />
</AccessibleButton>
)}
<ThreadListContextMenu mxEvent={this.props.mxEvent} permalinkCreator={this.props.permalinkCreator} />
</div>
);
@@ -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<Record<SectionId, boolean>> {
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 (
<div
className={"mx_YapRoomList_header mx_YapRoomList_header_toggle" + (collapsed ? " mx_YapRoomList_header_collapsed" : "")}
role="button"
tabIndex={0}
aria-expanded={!collapsed}
onClick={onToggle}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onToggle();
}}
>
<span className="mx_YapRoomList_header_chevron" aria-hidden="true">
</span>
{label}
</div>
);
}
/** 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 (
<div
className="mx_YapRoomRow mx_YapThreadRow"
role="treeitem"
tabIndex={0}
title={threadLabel(thread)}
onClick={onClick}
>
<span className="mx_YapThreadRow_glyph" aria-hidden="true">
</span>
<span className="mx_YapRoomRow_name">{threadLabel(thread)}</span>
{count > 0 && (
<span className={"mx_YapRoomRow_badge" + (mention ? " mx_YapRoomRow_badge_mention" : "")}>
{count}
</span>
)}
</div>
);
}
function InviteRow({ room }: { room: Room }): JSX.Element {
return (
<div
@@ -299,6 +454,17 @@ export function YapRoomList(): JSX.Element {
};
}, []);
// The "open thread keeps its row" rule depends on focus mode and the right
// panel — neither goes through the room list store, so re-render on both.
useEffect(() => 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 (
<div className="mx_YapRoomList" role="tree">
{activeSpaceRoom && (
@@ -403,19 +595,34 @@ export function YapRoomList(): JSX.Element {
<InviteRow key={room.roomId} room={room} />
))}
{channels.length > 0 && <div className="mx_YapRoomList_header">Text Channels</div>}
{channels.map((room) => (
<ChannelRow
key={room.roomId}
room={room}
activeRoomId={activeRoomId}
drag={makeDrag(room.roomId)}
dropPos={dragOver?.id === room.roomId ? (dragOver.after ? "after" : "before") : null}
{channels.length > 0 && (
<SectionHeader
label="Text Channels"
collapsed={!!collapsed.channels}
onToggle={() => toggleSection("channels")}
/>
)}
{(collapsed.channels ? visibleWhenCollapsed(channels, activeRoomId) : channels).map((room) => (
<React.Fragment key={room.roomId}>
<ChannelRow
room={room}
activeRoomId={activeRoomId}
drag={makeDrag(room.roomId)}
dropPos={dragOver?.id === room.roomId ? (dragOver.after ? "after" : "before") : null}
/>
{!collapsed.channels &&
activeThreads(room).map((t) => <ThreadRow key={t.id} room={room} thread={t} />)}
</React.Fragment>
))}
{voice.length > 0 && <div className="mx_YapRoomList_header">Voice Channels</div>}
{voice.map((room) => (
{voice.length > 0 && (
<SectionHeader
label="Voice Channels"
collapsed={!!collapsed.voice}
onToggle={() => toggleSection("voice")}
/>
)}
{(collapsed.voice ? visibleWhenCollapsed(voice, activeRoomId) : voice).map((room) => (
<VoiceRoomRow
key={room.roomId}
room={room}
@@ -425,8 +632,14 @@ export function YapRoomList(): JSX.Element {
/>
))}
{dms.length > 0 && <div className="mx_YapRoomList_header">Direct Messages</div>}
{dms.map((room) => (
{dms.length > 0 && (
<SectionHeader
label="Direct Messages"
collapsed={!!collapsed.dms}
onToggle={() => toggleSection("dms")}
/>
)}
{(collapsed.dms ? visibleWhenCollapsed(dms, activeRoomId) : dms).map((room) => (
<DmRow
key={room.roomId}
room={room}
+55
View File
@@ -0,0 +1,55 @@
/*
Blap: hand-off slot for "open this thread centered in the main pane".
A sidebar thread click dispatches ViewRoom and then needs the thread focused —
but RoomView may not be mounted yet when a plain dispatcher action would arrive,
so the request is parked here and consumed by RoomView when it loads the room.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
let pending: { roomId: string; event: MatrixEvent } | null = null;
export function setPendingBlapThreadFocus(event: MatrixEvent): void {
const roomId = event.getRoomId();
if (!roomId) return;
pending = { roomId, event };
}
/** Returns (and clears) the pending thread root for this room, if any. */
export function consumePendingBlapThreadFocus(roomId?: string | null): MatrixEvent | undefined {
if (pending && pending.roomId === roomId) {
const event = pending.event;
pending = null;
return event;
}
return undefined;
}
// Whether thread focus mode is currently active, and for which room/thread —
// kept by RoomView; read by MatrixChat (so ShowThread stays centered instead of
// opening the sidebar copy) and by YapRoomList (the open thread keeps its row).
let centered: { roomId: string; threadId?: string } | null = null;
const centeredListeners = new Set<() => 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);
};
}