feat(channels): split bridge-space rooms into collapsible chats/groups/channels sections with tinted icons, counts, and an indented tree guide

This commit is contained in:
heaven 2026-06-10 22:10:04 +03:00
parent 4dcfe7fd1d
commit 6d48ed0341
5 changed files with 461 additions and 38 deletions

View file

@ -475,7 +475,10 @@
"workspace_switcher_member_count_one": "{{count}} member",
"workspace_switcher_member_count_other": "{{count}} members",
"workspace_footer_subtitle": "Community",
"create_channel": "Create channel"
"create_channel": "Create channel",
"category_chats": "Chats",
"category_groups": "Groups",
"category_broadcasts": "Channels"
},
"Call": {
"start": "Start call",

View file

@ -479,7 +479,10 @@
"workspace_switcher_member_count_many": "{{count}} участников",
"workspace_switcher_member_count_other": "{{count}} участника",
"workspace_footer_subtitle": "Сообщество",
"create_channel": "Создать канал"
"create_channel": "Создать канал",
"category_chats": "Чаты",
"category_groups": "Группы",
"category_broadcasts": "Каналы"
},
"Call": {
"start": "Позвонить",

View file

@ -0,0 +1,117 @@
import { recipe } from '@vanilla-extract/recipes';
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// Category headers in the channels pane, Dawn-canon take two: the list rows
// in docs/design/…/stream-v2-dawn.jsx carry small tinted square glyphs and
// pill-shaped tabular badges — the headers borrow exactly that vocabulary
// (icon chip + regular app font + count pill + rotating chevron) instead of
// the folds Chip pill. Typography stays the app default on purpose.
export const SectionHeader = style({
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
background: 'transparent',
width: '100%',
display: 'flex',
alignItems: 'center',
gap: toRem(8),
padding: `${toRem(4)} ${toRem(8)}`,
borderRadius: toRem(8),
cursor: 'pointer',
font: 'inherit',
color: color.Background.OnContainer,
transition: 'background-color 120ms ease-out',
selectors: {
'&:hover': {
backgroundColor: color.Background.ContainerHover,
},
'&:focus-visible': {
outline: `2px solid ${color.Primary.Main}`,
outlineOffset: toRem(-2),
},
},
});
// Tinted square glyph — same idiom as the mock's letter-avatars in the bots
// sidebar (rounded square, accent-on-tint). One accent per section kind.
export const SectionIcon = recipe({
base: {
width: toRem(26),
height: toRem(26),
borderRadius: toRem(7),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
},
variants: {
tint: {
chats: { background: 'rgba(122, 182, 217, 0.16)', color: '#7ab6d9' },
groups: { background: 'rgba(125, 211, 168, 0.16)', color: '#7dd3a8' },
broadcasts: { background: 'rgba(212, 184, 138, 0.18)', color: '#d4b88a' },
neutral: { background: color.Background.Container, color: color.Background.OnContainer },
},
},
defaultVariants: { tint: 'neutral' },
});
export const SectionLabel = style({
fontSize: toRem(13.5),
lineHeight: toRem(18),
fontWeight: 600,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
minWidth: 0,
});
// Count pill — the mock's unread-badge shape (rounded, tabular nums) in a
// neutral container tone, so it reads as «size of section», not «unread».
export const SectionCount = style({
marginLeft: 'auto',
minWidth: toRem(18),
padding: `0 ${toRem(6)}`,
height: toRem(16),
borderRadius: toRem(99),
background: color.Background.Container,
color: color.Background.OnContainer,
opacity: 0.75,
fontSize: toRem(10.5),
lineHeight: toRem(16),
fontWeight: 600,
fontVariantNumeric: 'tabular-nums',
textAlign: 'center',
flexShrink: 0,
});
export const SectionChevron = recipe({
base: {
flexShrink: 0,
display: 'inline-flex',
color: color.Background.OnContainer,
opacity: 0.55,
transition: 'transform 150ms ease-out',
},
variants: {
closed: {
true: { transform: 'rotate(-90deg)' },
},
},
});
// Tight vertical rhythm between sections — the previous S400 padding on top
// of the chip's own chrome was the «почему такой геп» complaint.
export const SectionGap = style({
paddingTop: config.space.S200,
});
// Rooms nest under their section: indented to the right with a hairline
// guide dropping from the section icon's centerline (header padding 8px +
// half of the 26px icon ≈ 21px) — the Fleet tree-guide idiom, and the
// visual cue that rows belong to the шторка above.
export const SectionChildren = style({
marginLeft: toRem(20),
paddingLeft: toRem(8),
borderLeft: `1px solid ${color.Background.ContainerLine}`,
});

View file

@ -1,19 +1,19 @@
import React, { MutableRefObject, useCallback, useMemo } from 'react';
import React, { Fragment, MouseEventHandler, MutableRefObject, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue } from 'jotai';
import { Box, config } from 'folds';
import { Box } from 'folds';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { mDirectAtom } from '../../../state/mDirectList';
import { NavCategory, NavCategoryHeader } from '../../../components/nav';
import { NavCategory } from '../../../components/nav';
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
import { useSpace } from '../../../hooks/useSpace';
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
import { RoomNavItem } from '../../../features/room-nav';
import { makeNavCategoryId } from '../../../state/closedNavCategories';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
import { useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
import { HierarchyItem, useSpaceJoinedHierarchy } from '../../../hooks/useSpaceHierarchy';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
import { useCallEmbed } from '../../../hooks/useCallEmbed';
@ -22,6 +22,155 @@ import {
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { getChannelsRoomPath } from '../../pathUtils';
import { BridgedRoomKind, getBridgedRoomKind } from '../../../utils/room';
import * as css from './ChannelsList.css';
type SectionTint = 'chats' | 'groups' | 'broadcasts' | 'neutral';
// Collapsible-category order for bridge spaces. mautrix puts ALL portals —
// DMs, groups and broadcast channels — under one space; instead of a single
// «Каналы» root category we split the root section into one category per
// portal kind (same collapsible-шторка idiom). Synthetic parent tokens keep
// the collapse state distinct per kind in closedNavCategories without
// colliding with real room ids.
const BRIDGED_SECTIONS: Array<{
kind: BridgedRoomKind;
token: string;
labelKey: string;
tint: SectionTint;
}> = [
{ kind: 'dm', token: 'vojo.bridged.dm', labelKey: 'Channels.category_chats', tint: 'chats' },
{
kind: 'group',
token: 'vojo.bridged.group',
labelKey: 'Channels.category_groups',
tint: 'groups',
},
{
kind: 'broadcast',
token: 'vojo.bridged.broadcast',
labelKey: 'Channels.category_broadcasts',
tint: 'broadcasts',
},
];
// 16px stroke glyphs for the tinted section squares — same visual job as the
// colored letter-avatars in the Dawn sidebar mock.
const SECTION_ICONS: Record<SectionTint, React.ReactNode> = {
chats: (
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<path
d="M3.5 9.4c0-3.3 2.9-5.9 6.5-5.9s6.5 2.6 6.5 5.9-2.9 5.9-6.5 5.9c-.8 0-1.6-.1-2.3-.4L4.6 16l.9-2.7c-1.2-1-2-2.4-2-3.9Z"
strokeLinejoin="round"
/>
</svg>
),
groups: (
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<circle cx="7.2" cy="7.4" r="2.6" />
<path d="M2.8 15.6c.7-2.3 2.4-3.5 4.4-3.5s3.7 1.2 4.4 3.5" strokeLinecap="round" />
<circle cx="13.8" cy="7.4" r="2.2" />
<path d="M13.6 12.1c1.8.1 3.2 1.2 3.8 3.2" strokeLinecap="round" />
</svg>
),
broadcasts: (
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<path d="M3.5 8.2v3.6l2.8.6 7.2 3V4.6l-7.2 3-2.8.6Z" strokeLinejoin="round" />
<path d="M16 8a3.4 3.4 0 0 1 0 4" strokeLinecap="round" />
<path d="M6.8 12.8l.9 3.4" strokeLinecap="round" />
</svg>
),
neutral: (
<svg
width="16"
height="16"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
aria-hidden="true"
>
<path d="M7.5 3.5 6 16.5M14 3.5l-1.5 13M4 7.5h13M3 12.5h13" strokeLinecap="round" />
</svg>
),
};
type SectionHeaderProps = {
categoryId: string;
label: string;
closed: boolean;
onClick: MouseEventHandler<HTMLButtonElement>;
tint?: SectionTint;
count?: number;
first?: boolean;
};
// Dawn-canon section header: tinted square glyph + regular app type + count
// pill (the mock's badge shape in a neutral tone) + rotating chevron. The
// whole row is the collapse target; vertical rhythm is deliberately tight —
// the previous chip-on-padding stack read as a big gap between sections.
function ChannelsSectionHeader({
categoryId,
label,
closed,
onClick,
tint = 'neutral',
count,
first,
}: SectionHeaderProps) {
return (
<div className={first ? undefined : css.SectionGap}>
<button
type="button"
className={css.SectionHeader}
data-category-id={categoryId}
onClick={onClick}
aria-expanded={!closed}
>
<span className={css.SectionIcon({ tint })}>{SECTION_ICONS[tint]}</span>
<span className={css.SectionLabel}>{label}</span>
{count !== undefined ? <span className={css.SectionCount}>{count}</span> : null}
<span className={css.SectionChevron({ closed })}>
<svg
width="12"
height="12"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
aria-hidden="true"
>
<path d="M4 6 L8 10 L12 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</button>
</div>
);
}
type ChannelsListProps = {
// Kept for API compatibility with PageNavContent's scrollRef wiring even
@ -71,6 +220,14 @@ export function ChannelsList({ scrollRef: _scrollRef }: ChannelsListProps) {
[mx, allJoinedRooms]
);
// A room stays visible inside a closed category when it demands attention —
// same rule the upstream hierarchy filter applies to real sub-spaces.
const showRoomAnyway = useCallback(
(roomId: string): boolean =>
roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId,
[roomToUnread, selectedRoomId, callEmbed]
);
const hierarchy = useSpaceJoinedHierarchy(
space.roomId,
getRoom,
@ -79,11 +236,9 @@ export function ChannelsList({ scrollRef: _scrollRef }: ChannelsListProps) {
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
return false;
}
const showRoomAnyway =
roomToUnread.has(roomId) || roomId === selectedRoomId || callEmbed?.roomId === roomId;
return !showRoomAnyway;
return !showRoomAnyway(roomId);
},
[space.roomId, closedCategories, roomToUnread, selectedRoomId, callEmbed]
[space.roomId, closedCategories, showRoomAnyway]
),
useCallback(
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
@ -98,41 +253,145 @@ export function ChannelsList({ scrollRef: _scrollRef }: ChannelsListProps) {
const getToLink = (roomId: string) =>
getChannelsRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
// Classify the ROOT section's rooms by the type their bridge stamped into
// `m.bridge` (dm / group / broadcast — see getBridgedRoomKind). Sub-space
// sections (e.g. Telegram forum supergroups) keep their own headers and are
// not re-bucketed. When the root section doesn't mix at least two kinds
// (non-bridge spaces, single-kind spaces), rendering falls back to the
// plain «Каналы» root category exactly as before.
const rootKinds = useMemo(() => {
const kinds = new Map<string, BridgedRoomKind | undefined>();
hierarchy.forEach((item) => {
if ('space' in item && item.space) return;
if (item.parentId !== space.roomId) return;
const room = getRoom(item.roomId);
if (!room) return;
kinds.set(item.roomId, getBridgedRoomKind(mx, room));
});
return kinds;
}, [hierarchy, getRoom, mx, space.roomId]);
const splitRootByKind = useMemo(() => {
const present = new Set<BridgedRoomKind>();
rootKinds.forEach((kind) => {
if (kind) present.add(kind);
});
return present.size >= 2;
}, [rootKinds]);
// Buckets preserve hierarchy order. Unbridged rooms inside a bridge space
// (rare) land with broadcasts under «Каналы» so nothing silently vanishes.
const rootBuckets = useMemo(() => {
if (!splitRootByKind) return null;
const buckets: Record<BridgedRoomKind, HierarchyItem[]> = {
dm: [],
group: [],
broadcast: [],
};
hierarchy.forEach((item) => {
if ('space' in item && item.space) return;
if (item.parentId !== space.roomId) return;
buckets[rootKinds.get(item.roomId) ?? 'broadcast'].push(item);
});
return buckets;
}, [splitRootByKind, hierarchy, rootKinds, space.roomId]);
const renderRoom = (roomId: string) => {
const room = mx.getRoom(roomId);
if (!room) return null;
return (
<RoomNavItem
key={roomId}
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
);
};
// Render plan: group the flat hierarchy into header+rooms sections so each
// section's rooms can nest inside the indented guide block (SectionChildren)
// under its шторка. Bridge-space root expands into the per-kind sections.
type RenderSection =
| { type: 'bridged'; def: typeof BRIDGED_SECTIONS[number]; items: HierarchyItem[] }
| { type: 'plain'; headerRoomId: string; label: string; rooms: string[] };
const sections: RenderSection[] = [];
hierarchy.forEach((item) => {
const room = mx.getRoom(item.roomId);
if (!room) return;
if (room.isSpaceRoom()) {
if (item.roomId === space.roomId && rootBuckets) {
BRIDGED_SECTIONS.forEach((def) => {
const items = rootBuckets[def.kind];
if (items.length > 0) sections.push({ type: 'bridged', def, items });
});
return;
}
sections.push({
type: 'plain',
headerRoomId: item.roomId,
label: item.roomId === space.roomId ? t('Channels.root_category') : room.name ?? '',
rooms: [],
});
return;
}
// Root rooms of a bridge space live inside the per-kind sections.
if (rootBuckets && item.parentId === space.roomId) return;
const last = sections[sections.length - 1];
if (last && last.type === 'plain') last.rooms.push(item.roomId);
});
return (
<Box direction="Column" gap="300">
<NavCategory>
{hierarchy.map((item, index) => {
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
if (room.isSpaceRoom()) {
const categoryId = makeNavCategoryId(space.roomId, roomId);
{sections.map((section, index) => {
if (section.type === 'bridged') {
const { def, items } = section;
const categoryId = makeNavCategoryId(space.roomId, def.token);
const closed = closedCategories.has(categoryId);
const visibleItems = closed
? items.filter((item) => showRoomAnyway(item.roomId))
: items;
return (
<div key={roomId} style={{ paddingTop: index === 0 ? undefined : config.space.S400 }}>
<NavCategoryHeader>
<RoomNavCategoryButton
data-category-id={categoryId}
onClick={handleCategoryClick}
closed={closedCategories.has(categoryId)}
>
{roomId === space.roomId ? t('Channels.root_category') : room?.name}
</RoomNavCategoryButton>
</NavCategoryHeader>
</div>
<Fragment key={def.token}>
<ChannelsSectionHeader
categoryId={categoryId}
label={t(def.labelKey)}
closed={closed}
onClick={handleCategoryClick}
tint={def.tint}
count={items.length}
first={index === 0}
/>
{visibleItems.length > 0 ? (
<div className={css.SectionChildren}>
{visibleItems.map((item) => renderRoom(item.roomId))}
</div>
) : null}
</Fragment>
);
}
const categoryId = makeNavCategoryId(space.roomId, section.headerRoomId);
return (
<RoomNavItem
key={roomId}
room={room}
selected={selectedRoomId === roomId}
showAvatar={mDirects.has(roomId)}
direct={mDirects.has(roomId)}
linkPath={getToLink(roomId)}
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
/>
<Fragment key={section.headerRoomId}>
<ChannelsSectionHeader
categoryId={categoryId}
label={section.label}
closed={closedCategories.has(categoryId)}
onClick={handleCategoryClick}
first={index === 0}
/>
{section.rooms.length > 0 ? (
<div className={css.SectionChildren}>
{section.rooms.map((roomId) => renderRoom(roomId))}
</div>
) : null}
</Fragment>
);
})}
</NavCategory>

View file

@ -151,6 +151,47 @@ export const getBridgeNetworkName = (room: Room): string | undefined => {
return names[0];
};
// What kind of remote chat a bridge portal mirrors. Sourced from the type the
// bridge itself stamps into `m.bridge` (bridgev2 portal.go writes
// `com.beeper.room_type[.v2]: "dm" | "group_dm" | …` and `channel.id` in the
// `<peerType>:<id>` shape). Member counting is NOT a usable signal here — the
// bridge bot sits in every portal, so even a Telegram 1:1 has three members.
export type BridgedRoomKind = 'dm' | 'group' | 'broadcast';
export const getBridgedRoomKind = (mx: MatrixClient, room: Room): BridgedRoomKind | undefined => {
const events = [
...getStateEvents(room, StateEvent.RoomBridge),
...getStateEvents(room, StateEvent.RoomBridgeUnstable),
];
if (events.length === 0) return undefined;
for (let i = 0; i < events.length; i += 1) {
const content = events[i].getContent<{
'com.beeper.room_type'?: unknown;
'com.beeper.room_type.v2'?: unknown;
channel?: { id?: unknown };
}>();
const roomType = content['com.beeper.room_type.v2'] ?? content['com.beeper.room_type'];
if (roomType === 'dm' || roomType === 'group_dm') return 'dm';
// Fallback for portals predating the room_type field: the telegram
// connector's portal ids are `user:<id>` / `chat:<id>` / `channel:<id>`.
const channelId = content.channel?.id;
if (typeof channelId === 'string' && channelId.startsWith('user:')) return 'dm';
}
// Group vs broadcast: Telegram supergroups and broadcast channels share the
// same peer type, but in a broadcast the bridge gives ordinary members no
// posting rights — «can I post here?» is exactly the distinction a reader
// cares about.
const powerLevels =
getStateEvent(room, StateEvent.RoomPowerLevels)?.getContent<IPowerLevelsContent>() ?? {};
const myUserId = mx.getUserId() ?? '';
const myLevel = powerLevels.users?.[myUserId] ?? powerLevels.users_default ?? 0;
const messageLevel =
powerLevels.events?.[MessageEvent.RoomMessage] ?? powerLevels.events_default ?? 0;
return myLevel >= messageLevel ? 'group' : 'broadcast';
};
export function isValidChild(mEvent: MatrixEvent): boolean {
return (
mEvent.getType() === StateEvent.SpaceChild &&