From 6d48ed0341874c6ccd5b15359f2eaaffafd417ac Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 10 Jun 2026 22:10:04 +0300 Subject: [PATCH] feat(channels): split bridge-space rooms into collapsible chats/groups/channels sections with tinted icons, counts, and an indented tree guide --- public/locales/en.json | 5 +- public/locales/ru.json | 5 +- .../pages/client/channels/ChannelsList.css.ts | 117 +++++++ .../pages/client/channels/ChannelsList.tsx | 331 ++++++++++++++++-- src/app/utils/room.ts | 41 +++ 5 files changed, 461 insertions(+), 38 deletions(-) create mode 100644 src/app/pages/client/channels/ChannelsList.css.ts diff --git a/public/locales/en.json b/public/locales/en.json index f7127904..92de94d3 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -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", diff --git a/public/locales/ru.json b/public/locales/ru.json index 6bded616..c287e293 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -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": "Позвонить", diff --git a/src/app/pages/client/channels/ChannelsList.css.ts b/src/app/pages/client/channels/ChannelsList.css.ts new file mode 100644 index 00000000..ba89d437 --- /dev/null +++ b/src/app/pages/client/channels/ChannelsList.css.ts @@ -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}`, +}); diff --git a/src/app/pages/client/channels/ChannelsList.tsx b/src/app/pages/client/channels/ChannelsList.tsx index 42ed8e89..197607fc 100644 --- a/src/app/pages/client/channels/ChannelsList.tsx +++ b/src/app/pages/client/channels/ChannelsList.tsx @@ -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 = { + chats: ( + + ), + groups: ( + + ), + broadcasts: ( + + ), + neutral: ( + + ), +}; + +type SectionHeaderProps = { + categoryId: string; + label: string; + closed: boolean; + onClick: MouseEventHandler; + 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 ( +
+ +
+ ); +} 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(); + 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(); + 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 = { + 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 ( + + ); + }; + + // 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 ( - {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 ( -
- - - {roomId === space.roomId ? t('Channels.root_category') : room?.name} - - -
+ + + {visibleItems.length > 0 ? ( +
+ {visibleItems.map((item) => renderRoom(item.roomId))} +
+ ) : null} +
); } + const categoryId = makeNavCategoryId(space.roomId, section.headerRoomId); return ( - + + + {section.rooms.length > 0 ? ( +
+ {section.rooms.map((roomId) => renderRoom(roomId))} +
+ ) : null} +
); })}
diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index 7a51095b..de7dc07e 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -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 +// `:` 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:` / `chat:` / `channel:`. + 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() ?? {}; + 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 &&