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:
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user