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:
parent
4dcfe7fd1d
commit
6d48ed0341
5 changed files with 461 additions and 38 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Позвонить",
|
||||
|
|
|
|||
117
src/app/pages/client/channels/ChannelsList.css.ts
Normal file
117
src/app/pages/client/channels/ChannelsList.css.ts
Normal 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}`,
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue