redesign(p3c): collapse Home into universal Direct, drop legacy layouts, gate room flavour on member-count, and clear orphan settings.
This commit is contained in:
parent
5bf0aeb00b
commit
103d6ad8a1
52 changed files with 641 additions and 1773 deletions
|
|
@ -41,53 +41,52 @@ src/
|
|||
|
||||
## Pages & Routing (`src/app/pages/`)
|
||||
|
||||
Router in `Router.tsx`. Each top-level tab (`/home/`, `/direct/`, `/space/...`, `/explore/`, `/inbox/`) is wrapped in `PageRoot` with a `nav` prop (the tab's PageNav — e.g. `Home`, `Direct`) and an `<Outlet/>` for the active room/sub-route.
|
||||
Router in `Router.tsx`. Each top-level tab (`/direct/`, `/space/...`, `/explore/`, `/inbox/`) is wrapped in `PageRoot` with a `nav` prop (the tab's PageNav — e.g. `Direct`) and an `<Outlet/>` for the active room/sub-route.
|
||||
|
||||
- **Auth** (`auth/login/`, `auth/register/`, `auth/reset-password/`) — NOTE: bistable layout fragility, see `bugs.md`. Recent fixes: c466848, e6623b2, cf5ee9a, 9b41cbb. **Don't change `body`/`#root` background CSS-vars** — auth uses computed sizes for safe-area painting.
|
||||
- **Client** (`client/`) — main layout after login (wrapped by `ClientLayout` = nav + content)
|
||||
- `home/` — Home timeline (path: `/home/`, root `/` redirects here)
|
||||
- `direct/` — DMs (path: `/direct/`)
|
||||
- `space/` — Space view (path: `/:spaceIdOrAlias/`)
|
||||
- `home/` — **Redirect-only shim after P3c.** `HomeRouteRoomProvider` redirects `/home/{roomId}/` to `/direct/{roomId}/` so cold-start push deep links and pre-P3c bookmarks resolve. No Home page or PageNav exists. The `/home/_create`, `/home/_join`, `/home/_search` routes redirect to `/direct/`.
|
||||
- `direct/` — Universal room list (path: `/direct/`). After P3c contains every joined «orphan» non-space room (1:1 DMs, group DMs, group rooms, bridged chats, anything that used to live in `/home/`) plus every `m.direct`-tagged non-space room — implementation-wise `useOrphanRooms ∪ useDirects`, see [`useDirectRooms.ts`](../../src/app/pages/client/direct/useDirectRooms.ts) for the full union semantics. **Non-`m.direct` rooms that are space children stay only in the parent space tab** — they are not duplicated in `/direct/`. See `dm_1x1_redesign.md` §6.8.
|
||||
- `space/` — Space view (path: `/:spaceIdOrAlias/`). Spaces (= future Channels) keep their own tab and child-room route; they render Stream-style timelines too.
|
||||
- `explore/` — Public rooms (path: `/explore/`)
|
||||
- `inbox/` — Notifications, invites (path: `/inbox/`)
|
||||
- `create/` — New room/space (path: `/create/`)
|
||||
- `sidebar/` — Tab components (`HomeTab`, `DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`)
|
||||
- `sidebar/` — Tab components (`DirectTab`, `SpaceTabs`, `ExploreTab`, `CreateTab`, `SearchTab`, `UnverifiedTab`, `InboxTab`, `SettingsTab`). The legacy `HomeTab` was removed in P3c.
|
||||
- `WelcomePage.tsx` — Empty state (vojo SVG + version). **Mobile is intentionally null** at the index route (`{mobile ? null : <Route index element={<WelcomePage />} />}`) — by design, not a bug.
|
||||
- `SidebarNav.tsx` — global 66px icon-rail (DirectTab, HomeTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab)
|
||||
- `SidebarNav.tsx` — global 66px icon-rail (DirectTab, SpaceTabs, …, sticky bottom: SearchTab, UnverifiedTab, InboxTab, SettingsTab). Earmarked for removal in a follow-up `sidebar_cleanup` plan once the new Direct/Channels surfaces are self-sufficient.
|
||||
- `SyncStatus.tsx`, `SpecVersions.tsx` — Connection status
|
||||
|
||||
### DM-specific routing path
|
||||
### Universal Stream routing + DM classification (post-P3c)
|
||||
|
||||
```
|
||||
/direct/:roomIdOrAlias
|
||||
→ PageRoot (nav=Direct, outlet=…)
|
||||
→ DirectRouteRoomProvider (sets IsDirectRoomProvider=true, runs useAutoDirectSync)
|
||||
→ DirectRouteRoomProvider (sets IsOneOnOneProvider=room.getInvitedAndJoinedMemberCount() === 2)
|
||||
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
|
||||
→ RoomTimeline + RoomViewTyping + RoomInput
|
||||
```
|
||||
|
||||
`useAutoDirectSync` (commit 84eeac9) round-trips `m.direct` on join — **important**: for invited users the room renders first as non-direct, then flips to direct when sync resolves. Any DM-only branching must therefore go through the **shared four-source predicate `isDirectStreamRoom(mx, room, mDirects)` in [src/app/utils/room.ts](../../src/app/utils/room.ts)** (introduced in DM redesign P3a). The four sources, all synchronous and first-frame-safe, are:
|
||||
After P3c the Stream layout is the only timeline layout. There is no DM-vs-non-DM render gate. The classification that used to drive Stream now collapses into a single **member-count** check, mirroring Element-Web's tier-2 pattern (`room.getInvitedAndJoinedMemberCount() === 2`):
|
||||
|
||||
1. `mDirectAtom.has(roomId)` — returning user, populated on first paint via `IndexedDBStore.startup()` await before `startClient()`. Hydrated by `useBindMDirectAtom` from a `useEffect`, so empty for one frame on cold start.
|
||||
2. `room.getDMInviter()` — matrix-js-sdk SDK helper for invite-state with `is_direct: true` in member content's `prev_content`.
|
||||
3. `isDirectInvite(room, myUserId)` — reads current member content for `is_direct: true`, covering the just-joined case before `useAutoDirectSync` round-trips.
|
||||
4. `mx.getAccountData('m.direct')` — direct SDK fallback for the cold-start one-frame race when the atom is still being hydrated; the SDK has the data synchronously after `IndexedDBStore.startup()`.
|
||||
- 1:1 rooms (member-count = 2) get peer-style header chrome (peer avatar fallback in `useRoomAvatar(room, isOneOnOne)`), the `DmCallButton`, and unconditionally hide membership/nick/avatar syslines.
|
||||
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines.
|
||||
- Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.
|
||||
|
||||
Four call sites use this predicate symmetrically: `useIsDirectStream(room)` (render-side gate in `RoomTimeline.tsx`), `HomeRouteRoomProvider` (cold-start push redirect from `/home/{DM}/` to `/direct/`), `DirectRouteRoomProvider` (validates the destination), `useRoomNavigate.navigateRoom` (imperative routing). All four agree on the first frame so cold-start push for an invited DM lands on `/direct/` with the Stream layout already mounted, no transient `JoinBeforeNavigate` flash.
|
||||
`useAutoDirectSync` (commit 84eeac9) still round-trips `m.direct` on join — **interop only**, so other Matrix clients (Element, FluffyChat) still categorize the same room as a DM. Vojo no longer reads `m.direct` for UI classification; the `mDirectAtom` is kept alive for `useDirectRooms` ordering and other read-only consumers but its truth is no longer load-bearing for the layout.
|
||||
|
||||
For the per-row Stream gate, prefer the `isStream` prop chain (RoomTimeline computes `useIsDirectStream(room)` once and threads it through `<Message>` / `<Event>` props) over calling the hook per row — this avoids subscribing every row to the `mDirectAtom` in non-DM rooms. Don't gate on `useIsDirectRoom()` alone — it misses the invited-user case. Bridged Telegram DMs that lack `is_direct: true` on invite are deferred to a future plan (separate Telegram tab/namespace). See `docs/plans/dm_1x1_redesign.md` §6.5 for the full rationale.
|
||||
Use `useIsOneOnOne()` from `hooks/useRoom.ts` whenever you need the 1:1 vs group split — it reads the `IsOneOnOneProvider` context value set per route. Do NOT introduce a new `useIsDirect*` helper or the four-source `m.direct` gate that P3c removed (`isDirectStreamRoom`, `useIsDirectStream`, `IsDirectRoomProvider`, `useIsDirectRoom`). See `docs/plans/dm_1x1_redesign.md` §6.8 for the full rationale.
|
||||
|
||||
## Features (`src/app/features/`)
|
||||
|
||||
| Dir | Purpose |
|
||||
|-----|---------|
|
||||
| `room/` | Core room view — **RoomTimeline.tsx** (~1890 LOC), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (~587 LOC), MembersDrawer, MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
|
||||
| `room/message/` | `Message.tsx` (~1335 LOC) — selects layout per the `isStream` prop (DM rooms → `<StreamLayout/>`) or per the persisted `messageLayout` setting (non-DM → Compact/Bubble/Modern), renders edit/delete/react menu, mention/hashtag links, reactions viewer. The DM-stream gate is computed once in `RoomTimeline.tsx` via `useIsDirectStream(room)` and threaded as a prop so we don't subscribe every row to `mDirectAtom`. |
|
||||
| `room/` | Core room view — **RoomTimeline.tsx** (~1700 LOC after P3c collapse), **RoomInput.tsx** (~691 LOC), **RoomViewHeader.tsx** (~587 LOC), MembersDrawer, MessageEditor, RoomTombstone, RoomViewTyping, CallChatView, CommandAutocomplete |
|
||||
| `room/message/` | `Message.tsx` (~1170 LOC after P3c) — renders Stream layout unconditionally for every room (1:1 DM, group DM, non-DM, bridged). No layout switch, no `isStream` gate. Renders edit/delete/react menu, mention/hashtag links, reactions viewer. The legacy `Compact`/`Bubble` layouts and `MessageLayout` enum are gone; `Modern.tsx` survives only as a card-preview layout for pin-menu / message-search / inbox. |
|
||||
| `room-nav/` | `RoomNavItem.tsx` (~435 LOC) — list-row component, used by Home/Direct/Spaces. Carries call-room behaviour (`useCallSession`, `useCallMembers`, `useCallStart`) |
|
||||
| `room-settings/` | Room-specific settings page |
|
||||
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
|
||||
| `space-settings/` | Space-specific settings |
|
||||
| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). The `MessageLayout` dropdown was removed in DM redesign P3a — non-DM rooms still honour the persisted enum, but no UI surfaces it. General now hosts a `Settings.message_spacing_dm_note` description explaining the spacing override in DM rooms. **Logout lives here only.** |
|
||||
| `settings/` | User settings (general, account, notifications, devices, emojis, about, dev-tools). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` were removed in P3c — Stream is now the only layout, and user-settings cleanup migration drops orphan persisted fields on first load. `hideMembershipEvents` / `hideNickAvatarEvents` survive — they still gate the group-room syslines. **Logout lives here only.** |
|
||||
| `lobby/` | Space/room lobby view |
|
||||
| `search/` | Global search |
|
||||
| `message-search/` | In-room message search |
|
||||
|
|
@ -105,8 +104,8 @@ For the per-row Stream gate, prefer the `isStream` prop chain (RoomTimeline comp
|
|||
|
||||
## Key Components (`src/app/components/`)
|
||||
|
||||
- `message/` — Message rendering. Layout variants live in `message/layout/{Modern,Compact,Bubble,Stream}.tsx` + `layout.css.ts` recipes. **DM rooms** render the Stream layout unconditionally (gated by `useIsDirectStream` four-source predicate, see DM-specific routing path above). **Non-DM rooms** select per `MessageLayout` enum in `state/settings.ts` (Modern=0, Compact=1, Bubble=2). `EventContent.tsx` has its own layout switch for membership/state events plus a `IsStreamProvider` context that flips it to the thin Stream sysline render in DM rooms.
|
||||
- `message/MessageStatus.tsx` — **Vojo-specific**: WhatsApp-style delivery checkmarks (commit 0c4cfb9). Pairs with `hooks/useMessageStatus.ts` (receipt-derivation).
|
||||
- `message/` — Message rendering. Only `Stream.tsx` and `Modern.tsx` layouts ship after P3c (`Compact`/`Bubble` deleted along with `MessageLayout` enum). Every timeline row uses `<StreamLayout/>`. `Modern.tsx` survives as the card-preview layout for pin-menu, message-search and inbox notifications — those are not timelines. `EventContent.tsx` is a single-branch sysline renderer (the legacy `IsStreamProvider` context was deleted; rail metadata flows in via `railStart` / `railEnd` props directly).
|
||||
- `message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` — **Kept but no longer rendered in the timeline.** P3c retired the WhatsApp-style checkmarks because the Stream-rail dot now encodes the same delivery / read state via colour and opacity. `useMessageStatus` is still consumed inside `useDotColor.ts`, so the file is load-bearing.
|
||||
- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
|
||||
- `emoji-board/` — Emoji picker
|
||||
- `image-pack-view/` — Custom emoji pack management
|
||||
|
|
@ -131,12 +130,12 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab
|
|||
|
||||
| Path | Commits | Notes |
|
||||
|---|---|---|
|
||||
| `features/room/RoomViewHeader.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point |
|
||||
| `features/room/RoomViewHeader.tsx::DmCallButton` | calls phases 0-5.35 | Outgoing DM voice call entry point. Gated on **`useIsOneOnOne() && mDirectAtom.has(roomId) && !isBridgedRoom(room)`** after P3c — three guards: (1) strictly 2-member non-space, (2) aligned with the call lifecycle hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which still gate ring delivery and caller-side auto-hangup on `m.direct`, (3) explicit MSC2346 `m.bridge` exclusion so mautrix-telegram puppet rooms never expose a call button (Telegram has no Matrix-RTC equivalent). Lifecycle migration to a member-count gate stays load-bearing per §7 and is deferred to a separate plan. |
|
||||
| `features/call/*` (CallEmbed, CallView) | 91afffc, e8188d7, fab533e | Element Call widget; **don't unmount/remount the widget root on header redesign** — Android FGS is keyed on `joined` state |
|
||||
| `features/call-status/IncomingCallStrip.tsx` | dabe5ca, 91afffc | Mounted as sibling in `Router.tsx:184` (not inside header). Decline / accept / mid-ring backgrounding handled here |
|
||||
| `state/callEmbed.ts` (`callEmbedAtom`, `callChatAtom`) | call phases | Embed lifecycle |
|
||||
| `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | 0c4cfb9 | WhatsApp checkmarks |
|
||||
| `hooks/usePushNotifications.ts` | dabe5ca, 84eeac9 | Push tap routing: warm web/native paths resolve DMs to Direct via `navigateRoom` / `getDirectRoomPath` (lines 309 / 387); SW cold-start opens `/home/{roomId}/` and `HomeRouteRoomProvider` redirects to `/direct/` when `mDirectAtom` has the room. FSI hand-off |
|
||||
| `hooks/usePushNotifications.ts` | dabe5ca, 84eeac9 | Push tap routing: warm web/native paths resolve DMs to Direct via `navigateRoom` / `getDirectRoomPath` (lines 309 / 387); SW cold-start opens `/home/{roomId}/` and after P3c `HomeRouteRoomProvider` redirects every non-space room to `/direct/` (the `m.direct` predicate was lifted in P3c §6.7). FSI hand-off |
|
||||
| `hooks/useRoomNavigate.ts` | dce6be9 | Back-stack collapse via `replace`. **Path-based** (`pathnameRef.current === target ? replace : push`, line 49-55) — not tab-ID-keyed, so removing `*Tab.tsx` rendering does not break back-stack |
|
||||
| `hooks/useAndroidBackButton.ts`, `components/BackRouteHandler.tsx` | dce6be9 | Hardware back integration |
|
||||
| `hooks/useAutoDirectSync.ts` + `utils/matrix.ts` | 84eeac9 | m.direct sync on join (DM rooms shown correctly for invited users) |
|
||||
|
|
@ -149,7 +148,7 @@ These are vojo additions on top of stock Cinny — they crossed many recent stab
|
|||
|
||||
**Jotai** atoms in `src/app/state/`:
|
||||
|
||||
- `settings.ts` — User preferences (`MessageLayout` enum: Modern=0, Compact=1, Bubble=2; `themeId`, `useSystemTheme`, `monochromeMode`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum is canonical for non-DM rooms; **DM rooms ignore it entirely** and force the Stream layout regardless. The default for new users is `MessageLayout.Bubble`.
|
||||
- `settings.ts` — User preferences (`themeId`, `useSystemTheme`, `monochromeMode`, `hideMembershipEvents`, `hideNickAvatarEvents`, …). Persisted to `localStorage['settings']`. The `MessageLayout` enum + `messageSpacing` + `legacyUsernameColor` fields were dropped in P3c; the new `dawn-p3c-cleanup` migration in `getSettings()` strips the orphan keys from existing users' persisted JSON on first load.
|
||||
- `sessions.ts` — Active session
|
||||
- `upload.ts` — Upload progress (in-memory)
|
||||
- `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
|
||||
|
|
@ -194,7 +193,7 @@ The only CSS `@media` queries in the app target `(hover: hover) and (pointer: fi
|
|||
```tsx
|
||||
const mx = useMatrixClient(); // Get SDK instance
|
||||
const room = useRoom(); // Current room
|
||||
const isDirect = useIsDirectRoom(); // From IsDirectRoomProvider context
|
||||
const isOneOnOne = useIsOneOnOne(); // From IsOneOnOneProvider context
|
||||
const stateEvent = useStateEvent(room, StateEvent.Type); // Room state
|
||||
const powerLevels = usePowerLevels(room); // Permissions
|
||||
```
|
||||
|
|
@ -216,7 +215,7 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
|
|||
- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
|
||||
- **slate 0.123** — Rich text editor
|
||||
- **@tanstack/react-query 5** — Data fetching
|
||||
- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, `Home`, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
|
||||
- **@tanstack/react-virtual 3** — Virtual scrolling — used for **list panels** (`Direct.tsx`, space lists, etc.). Note: `RoomTimeline.tsx` does NOT use this; it uses an in-house `useVirtualPaginator` + `IntersectionObserver`.
|
||||
- **i18next 23 + react-i18next 15** — Localisation
|
||||
- **Capacitor 8.3** — Native Android wrapper
|
||||
- **@capacitor/browser 8.0** — External link handling in native
|
||||
|
|
@ -240,3 +239,86 @@ i18next + `react-i18next`. Translations in `public/locales/{en,ru}/*.json`, orga
|
|||
- Android release / AAB: similar but `assembleRelease` / `bundleRelease`
|
||||
- VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates web deploy
|
||||
- Android-specific build chain, edge-to-edge, safe-area, FGS, FCM ring registry, push-strings Gradle task — see `android.md`
|
||||
|
||||
## Refactor checklist for AI agents
|
||||
|
||||
Encoded from P3c retrospective (2026-04-28) — three classes of bug that ate
|
||||
three rounds of code review. Hit this checklist **before** declaring a
|
||||
refactor done; each item is cheap to verify and would have caught a real
|
||||
BLOCKER if applied earlier in P3c.
|
||||
|
||||
### 1. Plan-trust = trust-but-verify
|
||||
|
||||
When the plan says "X is automatic" or "Y migrates correctly," **don't
|
||||
believe it without grepping the affected subsystem**. Plans encode
|
||||
intentions; the actual code may have load-bearing assumptions the plan
|
||||
didn't audit.
|
||||
|
||||
- For each utterance of «handle Y по новому пути», `grep -rn` for the OLD
|
||||
path (atom, helper, gate function) and ensure no lingering callsites read
|
||||
it with the OLD semantic.
|
||||
- Especially for **load-bearing surfaces** (calls, push, FSI, edge-to-edge,
|
||||
service worker, native bridges): the plan §7 «не трогаем» list is a
|
||||
**hint that the surface contains hidden coupling**, not a license to
|
||||
ignore it. If your refactor changes _any_ visibility/classification gate
|
||||
that touches calls or push, walk every call/push hook by hand.
|
||||
|
||||
P3c blocker example: plan §6.8 said «DmCallButton после P3c гейтится на
|
||||
useIsOneOnOne()» without auditing `useIncomingRtcNotifications` /
|
||||
`useCallerAutoHangup` which still gated on `m.direct`. Three rounds of
|
||||
review later we caught it. Cost: one wasted round.
|
||||
|
||||
### 2. Systematic consumer audit before renaming/repurposing
|
||||
|
||||
Before changing the **semantic** of a hook, atom, or context-value (not
|
||||
just renaming — semantic shift), run a `grep -rn` for every consumer and
|
||||
classify each:
|
||||
|
||||
```
|
||||
grep -rn "useFoo\|fooAtom\|FooContext" src/
|
||||
```
|
||||
|
||||
For each callsite, answer:
|
||||
- (a) Does the new semantic still match this callsite's intent?
|
||||
- (b) Does the callsite use the symbol for ITS original semantic, or for a
|
||||
DIFFERENT semantic that happens to overlap with the old name?
|
||||
|
||||
P3c examples missed initially:
|
||||
- `useDirectRooms` was used in `UserChips::MutualRoomsChip` for «split
|
||||
mutual DMs vs mutual rooms» — needed m.direct semantic, not universal-
|
||||
Direct. Mechanical rename broke the split.
|
||||
- `mDirects.has(room.roomId)` in `RoomIntro`, `RoomSettings`, `RoomProfile`,
|
||||
`SpaceSettings` for peer-avatar fallback — needed member-count semantic
|
||||
consistent with `RoomViewHeader`. Mechanical preservation of m.direct
|
||||
diverged the chrome.
|
||||
|
||||
### 3. Reactivity audit for context values from mutable objects
|
||||
|
||||
If you put a `room.X()`-style call into a Provider's `value=`, and `room`
|
||||
is the matrix-js-sdk `Room` object (mutable, fires events), **the value is
|
||||
a static snapshot at first render**. Subsequent state changes won't flow
|
||||
through the context until the Provider re-renders for a different reason.
|
||||
|
||||
Symptoms: «UI не обновляется когда X меняется», «надо перезайти в комнату
|
||||
чтобы X применился».
|
||||
|
||||
Fix pattern: extract Provider into an inner component that subscribes to
|
||||
the relevant matrix-js-sdk event (e.g. `RoomStateEvent.Members`,
|
||||
`RoomEvent.Receipt`, `MatrixEventEvent.Decrypted`) via `useState +
|
||||
useEffect`. Capture the emitter ref **inside** the effect for cleanup
|
||||
leak-safety against rare snapshot replacements. Reference impl:
|
||||
[`hooks/useIsOneOnOneRoom.ts`](../../src/app/hooks/useIsOneOnOneRoom.ts)
|
||||
+ [`pages/client/direct/RoomProvider.tsx`](../../src/app/pages/client/direct/RoomProvider.tsx)
|
||||
(the `ResolvedRoomProvider` split pattern).
|
||||
|
||||
P3c blocker example: `IsOneOnOneProvider` value initially computed as
|
||||
`room.getInvitedAndJoinedMemberCount() === 2` at provider mount — froze for
|
||||
the entire route. Inviting a 3rd into a 1:1 didn't flip chrome until
|
||||
navigation. Round-2 review caught it.
|
||||
|
||||
### 4. Don't defer mechanical cleanups «to P6» when one-pass is cheap
|
||||
|
||||
Each `// TODO: rename X` left in the diff multiplies into a 5-file rename
|
||||
sweep later. If a prop name diverges from its semantic during refactor,
|
||||
fix in the same commit if it's < 5 callsites. P6 cleanup is hopeful, not
|
||||
guaranteed.
|
||||
|
|
|
|||
|
|
@ -128,9 +128,6 @@
|
|||
"hide_activity": "Hide Typing & Read Receipts",
|
||||
"hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.",
|
||||
"messages": "Messages",
|
||||
"message_spacing": "Message Spacing",
|
||||
"message_spacing_dm_note": "Direct messages always use a fixed spacing so the timeline rail stays continuous.",
|
||||
"legacy_username_color": "Legacy Username Color",
|
||||
"hide_membership": "Hide Membership Change",
|
||||
"hide_profile": "Hide Profile Change",
|
||||
"disable_media_auto_load": "Disable Media Auto Load",
|
||||
|
|
|
|||
|
|
@ -128,9 +128,6 @@
|
|||
"hide_activity": "Скрыть набор текста и уведомления о прочтении",
|
||||
"hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.",
|
||||
"messages": "Сообщения",
|
||||
"message_spacing": "Интервал сообщений",
|
||||
"message_spacing_dm_note": "В личных чатах используется фиксированный интервал, чтобы линия таймлайна оставалась непрерывной.",
|
||||
"legacy_username_color": "Классический цвет имени",
|
||||
"hide_membership": "Скрыть изменения участников",
|
||||
"hide_profile": "Скрыть изменения профиля",
|
||||
"disable_media_auto_load": "Отключить автозагрузку медиа",
|
||||
|
|
|
|||
|
|
@ -1,34 +1,18 @@
|
|||
import { Box, Icon, IconSrc, color } from 'folds';
|
||||
import React, { ReactNode, createContext, useContext, useRef } from 'react';
|
||||
import React, { ReactNode, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
|
||||
import { MessageLayout } from '../../../state/settings';
|
||||
import * as layoutCss from '../layout/layout.css';
|
||||
import { useStreamLayoutDebug } from '../layout/streamDebug';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
|
||||
// IsStreamProvider lets the Event wrapper (in features/room/message/Message.tsx)
|
||||
// declare «this state event lives inside a Stream-layout DM». EventContent
|
||||
// reads it and switches to a sysline render — a thin one-line system row, not
|
||||
// a bubble. Plumbed through context so EventContent's API doesn't grow a
|
||||
// `room` prop (it doesn't need the room itself, only the gate decision).
|
||||
type StreamContext = {
|
||||
enabled: boolean;
|
||||
railStart?: boolean;
|
||||
railEnd?: boolean;
|
||||
};
|
||||
|
||||
const IsStreamContext = createContext<StreamContext>({ enabled: false });
|
||||
export const IsStreamProvider = IsStreamContext.Provider;
|
||||
|
||||
export type EventContentProps = {
|
||||
messageLayout: MessageLayout;
|
||||
time: ReactNode;
|
||||
iconSrc: IconSrc;
|
||||
content: ReactNode;
|
||||
railStart?: boolean;
|
||||
railEnd?: boolean;
|
||||
};
|
||||
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
|
||||
const { enabled: isStream, railStart, railEnd } = useContext(IsStreamContext);
|
||||
export function EventContent({ time, iconSrc, content, railStart, railEnd }: EventContentProps) {
|
||||
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const timeRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -45,12 +29,10 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
|
|||
dot: dotRef,
|
||||
content: bodyRef,
|
||||
},
|
||||
isStream
|
||||
true
|
||||
);
|
||||
|
||||
if (isStream) {
|
||||
// Sysline = thin one-line state-event row that lives ON the rail. RoomTimeline
|
||||
// passes compact time in Stream mode so the 64px rail column stays stable.
|
||||
// Sysline = thin one-line state-event row that lives ON the rail.
|
||||
return (
|
||||
<div
|
||||
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
|
||||
|
|
@ -86,40 +68,6 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
|
|||
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
|
||||
<div className={layoutCss.StreamSyslineBody}>{content}</div>
|
||||
</Box>
|
||||
{/* `messageLayout` is intentionally unused in stream-mode. */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const beforeJSX = (
|
||||
<Box gap="300" justifyContent="SpaceBetween" alignItems="Center" grow="Yes">
|
||||
{messageLayout === MessageLayout.Compact && time}
|
||||
<Box
|
||||
grow={messageLayout === MessageLayout.Compact ? undefined : 'Yes'}
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
<Icon style={{ opacity: 0.6 }} size="50" src={iconSrc} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const msgContentJSX = (
|
||||
<Box justifyContent="SpaceBetween" alignItems="Baseline" gap="200">
|
||||
{content}
|
||||
{messageLayout !== MessageLayout.Compact && time}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (messageLayout === MessageLayout.Compact) {
|
||||
return <CompactLayout before={beforeJSX}>{msgContentJSX}</CompactLayout>;
|
||||
}
|
||||
if (messageLayout === MessageLayout.Bubble) {
|
||||
return (
|
||||
<BubbleLayout hideBubble before={beforeJSX}>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
);
|
||||
}
|
||||
return <ModernLayout before={beforeJSX}>{msgContentJSX}</ModernLayout>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Box, ContainerColor, as, color } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
|
||||
type BubbleArrowProps = {
|
||||
variant: ContainerColor;
|
||||
};
|
||||
function BubbleLeftArrow({ variant }: BubbleArrowProps) {
|
||||
return (
|
||||
<svg
|
||||
className={css.BubbleLeftArrow}
|
||||
width="9"
|
||||
height="8"
|
||||
viewBox="0 0 9 8"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.00004 8V0H4.82847C3.04666 0 2.15433 2.15428 3.41426 3.41421L8.00004 8H9.00004Z"
|
||||
fill={color[variant].Container}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
type BubbleLayoutProps = {
|
||||
hideBubble?: boolean;
|
||||
before?: ReactNode;
|
||||
header?: ReactNode;
|
||||
};
|
||||
|
||||
export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||
({ hideBubble, before, header, children, ...props }, ref) => (
|
||||
<Box gap="300" {...props} ref={ref}>
|
||||
<Box className={css.BubbleBefore} shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
{header}
|
||||
{hideBubble ? (
|
||||
children
|
||||
) : (
|
||||
<Box>
|
||||
<Box
|
||||
className={
|
||||
hideBubble
|
||||
? undefined
|
||||
: classNames(css.BubbleContent, before ? css.BubbleContentArrowLeft : undefined)
|
||||
}
|
||||
direction="Column"
|
||||
>
|
||||
{before ? <BubbleLeftArrow variant="SurfaceVariant" /> : null}
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Box, as } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
|
||||
type CompactLayoutProps = {
|
||||
before?: ReactNode;
|
||||
};
|
||||
|
||||
export const CompactLayout = as<'div', CompactLayoutProps>(
|
||||
({ before, children, ...props }, ref) => (
|
||||
<Box gap="200" {...props} ref={ref}>
|
||||
<Box className={css.CompactHeader} gap="200" shrink="No">
|
||||
{before}
|
||||
</Box>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
|
@ -3,16 +3,13 @@ import classNames from 'classnames';
|
|||
import { as } from 'folds';
|
||||
import * as css from './layout.css';
|
||||
import { useStreamLayoutDebug } from './streamDebug';
|
||||
import type { MessageSpacing } from '../../../state/settings';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
|
||||
// Stream rows ignore the persisted `messageSpacing` setting and use a fixed
|
||||
// gap so the rail-bridge offsets in layout.css.ts (StreamRailBridgeY = S400)
|
||||
// always match the gap between rows. Settings.message_spacing_dm_note
|
||||
// surfaces this in the UI. Keep all three Stream call sites
|
||||
// (RoomTimeline.tsx StreamDayDivider wrapper, Message.tsx Message MessageBase,
|
||||
// Message.tsx Event MessageBase) on this single constant.
|
||||
export const STREAM_MESSAGE_SPACING: MessageSpacing = '400';
|
||||
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
||||
// layout.css.ts (StreamRailBridgeY = S400) always match the gap between rows.
|
||||
// All three Stream call sites — RoomTimeline.tsx StreamDayDivider wrapper plus
|
||||
// Message.tsx Message / Event MessageBase — share this single constant.
|
||||
export const STREAM_MESSAGE_SPACING = '400' as const;
|
||||
|
||||
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
|
||||
//
|
||||
|
|
@ -108,10 +105,8 @@ export const StreamLayout = as<'div', StreamLayoutProps>(
|
|||
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
|
||||
|
||||
// Debug helper is dev-only and behind a localStorage opt-in (see
|
||||
// streamDebug.ts). StreamLayout is only ever mounted when the parent
|
||||
// already decided this row is in a DM stream, so `active` is implicitly
|
||||
// true here — pass it explicitly to mirror the sysline call site
|
||||
// (EventContent.tsx) which threads `isStream` through.
|
||||
// streamDebug.ts). After P3c every timeline row goes through StreamLayout,
|
||||
// so `active` is unconditionally true here.
|
||||
useStreamLayoutDebug(
|
||||
'message',
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
export * from './Modern';
|
||||
export * from './Compact';
|
||||
export * from './Bubble';
|
||||
export * from './Stream';
|
||||
export * from './Base';
|
||||
|
|
|
|||
|
|
@ -2,11 +2,6 @@ import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanill
|
|||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const StickySection = style({
|
||||
position: 'sticky',
|
||||
top: config.space.S100,
|
||||
});
|
||||
|
||||
const SpacingVar = createVar();
|
||||
const SpacingVariant = styleVariants({
|
||||
'0': {
|
||||
|
|
@ -108,15 +103,6 @@ export const MessageBase = recipe({
|
|||
|
||||
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
|
||||
|
||||
export const CompactHeader = style([
|
||||
DefaultReset,
|
||||
StickySection,
|
||||
{
|
||||
maxWidth: toRem(170),
|
||||
width: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const AvatarBase = style({
|
||||
paddingTop: toRem(4),
|
||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
|
|
@ -134,33 +120,6 @@ export const ModernBefore = style({
|
|||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
export const BubbleBefore = style({
|
||||
minWidth: toRem(36),
|
||||
});
|
||||
|
||||
export const BubbleContent = style({
|
||||
maxWidth: toRem(800),
|
||||
padding: config.space.S200,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
borderRadius: config.radii.R500,
|
||||
position: 'relative',
|
||||
});
|
||||
|
||||
export const BubbleContentArrowLeft = style({
|
||||
borderTopLeftRadius: 0,
|
||||
});
|
||||
|
||||
export const BubbleLeftArrow = style({
|
||||
width: toRem(9),
|
||||
height: toRem(8),
|
||||
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: toRem(-8),
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const Username = style({
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { as, ContainerColor, toRem } from 'folds';
|
||||
import { randomNumberBetween } from '../../../utils/common';
|
||||
import { LinePlaceholder } from './LinePlaceholder';
|
||||
import { CompactLayout } from '../layout';
|
||||
|
||||
export const CompactPlaceholder = as<'div', { variant?: ContainerColor }>(
|
||||
({ variant, ...props }, ref) => {
|
||||
const nameSize = useMemo(() => randomNumberBetween(40, 100), []);
|
||||
const msgSize = useMemo(() => randomNumberBetween(120, 500), []);
|
||||
|
||||
return (
|
||||
<CompactLayout
|
||||
{...props}
|
||||
ref={ref}
|
||||
before={
|
||||
<>
|
||||
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(50) }} />
|
||||
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(nameSize) }} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<LinePlaceholder variant={variant} style={{ maxWidth: toRem(msgSize) }} />
|
||||
</CompactLayout>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
export * from './LinePlaceholder';
|
||||
export * from './CompactPlaceholder';
|
||||
export * from './DefaultPlaceholder';
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
|
|
@ -13,7 +12,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
|
|
@ -28,11 +27,15 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
|||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
// Match RoomViewHeader's peer-avatar logic — pull the fallback only when the
|
||||
// room is strictly 1:1, not when it carries an `m.direct` flag. Bridged
|
||||
// Telegram 1:1s and just-promoted self-DMs both lack the flag but have
|
||||
// member-count = 2, so this picks them up correctly.
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
|
||||
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
const avatarMxc = useRoomAvatar(room, isOneOnOne);
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
|
|
@ -30,7 +31,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { openExternalUrl } from '../../utils/capacitor';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
|
|
@ -237,7 +238,10 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
|||
const mutualRoomsState = useMutualRooms(userId);
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
const closeUserRoomProfile = useCloseUserRoomProfile();
|
||||
const directs = useDirectRooms();
|
||||
// Read m.direct directly here, NOT useDirectRooms — after P3c the latter
|
||||
// returns ALL non-space rooms (universal Direct list), which would collapse
|
||||
// the «mutual DMs» vs «mutual rooms» split in this profile sheet.
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||
|
|
@ -268,7 +272,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
|||
data.spaces.push(room);
|
||||
return;
|
||||
}
|
||||
if (directs.includes(room.roomId)) {
|
||||
if (mDirects.has(room.roomId)) {
|
||||
data.directs.push(room);
|
||||
return;
|
||||
}
|
||||
|
|
@ -276,7 +280,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
|||
});
|
||||
}
|
||||
return data;
|
||||
}, [mutualRoomsState, getRoom, directs, mx]);
|
||||
}, [mutualRoomsState, getRoom, mDirects, mx]);
|
||||
|
||||
if (
|
||||
userId === mx.getSafeUserId() ||
|
||||
|
|
@ -288,7 +292,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
|||
|
||||
const renderItem = (room: Room) => {
|
||||
const { roomId } = room;
|
||||
const dm = directs.includes(roomId);
|
||||
const dm = mDirects.has(roomId);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
|||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import * as css from './styles.css';
|
||||
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
||||
import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||
|
||||
|
|
@ -14,11 +14,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
|||
const room = useRoom();
|
||||
const callEmbed = useCallEmbed();
|
||||
const callJoined = useCallJoined(callEmbed);
|
||||
const direct = useIsDirectRoom();
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
const startCall = useCallStart(direct);
|
||||
const startCall = useCallStart(isOneOnOne);
|
||||
const joining = callEmbed?.roomId === room.roomId && !callJoined;
|
||||
|
||||
const disabled = inOtherCall || !canJoin;
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import {
|
|||
TextArea,
|
||||
} from 'folds';
|
||||
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Linkify from 'linkify-react';
|
||||
import classNames from 'classnames';
|
||||
|
|
@ -26,7 +25,7 @@ import {
|
|||
useRoomName,
|
||||
useRoomTopic,
|
||||
} from '../../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { isOneOnOneRoom } from '../../../utils/room';
|
||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
|
|
@ -270,9 +269,12 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
|||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = useRoom();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
|
||||
const avatar = useRoomAvatar(room, directs.has(room.roomId));
|
||||
// Peer-avatar fallback follows the member-count gate, mirroring the room
|
||||
// header and RoomSettings (post-P3c). RoomProfile renders inside the
|
||||
// RoomSettings modal which is mounted globally, so the IsOneOnOneProvider
|
||||
// context isn't in scope — call the helper directly.
|
||||
const avatar = useRoomAvatar(room, isOneOnOneRoom(room));
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const joinRule = useRoomJoinRule(room);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
|
||||
import { Trans, useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
|
@ -16,9 +15,9 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||
import { useRooms } from '../../state/hooks/roomList';
|
||||
import { useNonSpaceRooms } from '../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { isOneOnOneRoom } from '../../utils/room';
|
||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||
import { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
|
|
@ -53,11 +52,13 @@ export function MessageSearch({
|
|||
}: MessageSearchProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
||||
// Post-P3c: search scope is «every joined non-space room», not «non-DM
|
||||
// rooms». `m.direct` is interop-only after the universal Direct collapse —
|
||||
// see plan §6.8 / §6.8b. Pre-P3c this was `useRooms(...)` which excluded
|
||||
// `m.direct`-tagged rooms from search scope, leaving DM searches broken.
|
||||
const allRooms = useNonSpaceRooms(mx, allRoomsAtom);
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
|
@ -297,7 +298,7 @@ export function MessageSearch({
|
|||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={urlPreview}
|
||||
onOpen={navigateRoom}
|
||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
||||
legacyUsernameColor={isOneOnOneRoom(groupRoom)}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import { useAtom, useAtomValue } from 'jotai';
|
|||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { getDirectRoomAvatarUrl } from '../../utils/room';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, isOneOnOneRoom } from '../../utils/room';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
|
|
@ -367,7 +367,18 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
|
|||
<Avatar size="300" radii="400">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)}
|
||||
src={
|
||||
isOneOnOneRoom(room)
|
||||
? // 1:1 — fall back to the peer's avatar when the room
|
||||
// itself has no custom one. Mirrors RoomViewHeader's
|
||||
// `useRoomAvatar(room, isOneOnOne)` semantics.
|
||||
getDirectRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
: // Group / non-1:1 — use the room avatar only. Without
|
||||
// this guard `getDirectRoomAvatarUrl` would surface the
|
||||
// first non-self member's avatar in groups without a
|
||||
// custom room avatar, making them look like 1:1 chats.
|
||||
getRoomAvatarUrl(mx, room, 96, useAuthentication)
|
||||
}
|
||||
alt={roomName}
|
||||
renderFallback={() => (
|
||||
<Text as="span" size="H6">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { JoinRule } from 'matrix-js-sdk';
|
||||
|
|
@ -9,7 +8,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { isOneOnOneRoom } from '../../utils/room';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { General } from './general';
|
||||
import { Members } from '../common-settings/members';
|
||||
|
|
@ -67,9 +66,11 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
// Peer-avatar fallback follows the member-count gate, mirroring the room
|
||||
// header (post-P3c). Settings is a globally-mounted modal, so the
|
||||
// IsOneOnOneProvider context isn't in scope here — call the helper directly.
|
||||
const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room));
|
||||
const roomName = useRoomName(room);
|
||||
const joinRuleContent = useRoomJoinRule(room);
|
||||
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
|
|
@ -133,8 +133,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const direct = useIsDirectRoom();
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
const commands = useCommands(mx, room);
|
||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
|
@ -159,8 +158,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
const replyPowerColor = replyPowerTag?.color
|
||||
? accessibleTagColors.get(replyPowerTag.color)
|
||||
: undefined;
|
||||
const replyUsernameColor =
|
||||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||
const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||
|
||||
const [uploadBoard, setUploadBoard] = useState(true);
|
||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
||||
|
|
|
|||
|
|
@ -31,17 +31,13 @@ import { SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc/CallMembershi
|
|||
import to from 'await-to-js';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Chip,
|
||||
ContainerColor,
|
||||
Icon,
|
||||
Icons,
|
||||
Line,
|
||||
Scroll,
|
||||
Text,
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
|
|
@ -55,7 +51,6 @@ import { useAlive } from '../../hooks/useAlive';
|
|||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
CompactPlaceholder,
|
||||
Reply,
|
||||
MessageBase,
|
||||
MessageUnsupportedContent,
|
||||
|
|
@ -87,7 +82,7 @@ import {
|
|||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||
|
|
@ -120,8 +115,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
|||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { useIsDirectStream } from '../../hooks/useIsDirectStream';
|
||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
|
|
@ -144,16 +138,6 @@ const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
|||
)
|
||||
);
|
||||
|
||||
const TimelineDivider = as<'div', { variant?: ContainerColor | 'Inherit' }>(
|
||||
({ variant, children, ...props }, ref) => (
|
||||
<Box gap="100" justifyContent="Center" alignItems="Center" {...props} ref={ref}>
|
||||
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
|
||||
{children}
|
||||
<Line style={{ flexGrow: 1 }} variant={variant} size="300" />
|
||||
</Box>
|
||||
)
|
||||
);
|
||||
|
||||
export const getLiveTimeline = (room: Room): EventTimeline =>
|
||||
room.getUnfilteredTimelineSet().getLiveTimeline();
|
||||
|
||||
|
|
@ -438,15 +422,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const direct = useIsDirectRoom();
|
||||
// Stream gate — drives the day-divider Stream variant so the rail flows
|
||||
// through day boundaries without the legacy TimelineDivider gap. Other
|
||||
// RoomTimeline render-tree decisions (placeholders, RoomIntro, outline-
|
||||
// attachment) stay on the existing path until P3b.
|
||||
const isStream = useIsDirectStream(room);
|
||||
// After P3c every room renders Stream. The DM-vs-group split that drove the
|
||||
// membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
|
||||
// not the persisted `m.direct` flag — see plan §6.8.
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
|
|
@ -617,12 +596,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
useCallback(
|
||||
(mEvt: MatrixEvent) => {
|
||||
// «Sending while scrolled up jumps the timeline to live» — Telegram /
|
||||
// WhatsApp / Slack pattern. Introduced for the DM Stream redesign so
|
||||
// own messages always land in view. Scoped to Stream rooms (DMs)
|
||||
// intentionally — channels / Spaces keep the legacy «pin scroll on
|
||||
// history» behaviour until a separate plan tackles them.
|
||||
// WhatsApp / Slack pattern. After P3c every room renders Stream, so
|
||||
// the own-message follow-to-bottom guard is universal.
|
||||
const isOwnLiveStreamMessage =
|
||||
isStream &&
|
||||
mEvt.getSender() === mx.getUserId() &&
|
||||
!reactionOrEditEvent(mEvt) &&
|
||||
(mEvt.getType() === MessageEvent.RoomMessage ||
|
||||
|
|
@ -675,7 +651,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
setUnreadInfo(getRoomUnreadInfo(room));
|
||||
}
|
||||
},
|
||||
[mx, room, unreadInfo, hideActivity, isStream]
|
||||
[mx, room, unreadInfo, hideActivity]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -1113,8 +1089,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
|
|
@ -1138,7 +1112,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
onClick={handleOpenReply}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
legacyUsernameColor={isOneOnOne}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1158,12 +1132,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
showDeveloperTools={showDeveloperTools}
|
||||
memberPowerTag={getMemberPowerTag(senderId)}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
legacyUsernameColor={isOneOnOne}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
|
|
@ -1178,7 +1151,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
outlineAttachment
|
||||
/>
|
||||
)}
|
||||
</Message>
|
||||
|
|
@ -1218,8 +1191,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
|
|
@ -1243,7 +1214,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
onClick={handleOpenReply}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
legacyUsernameColor={isOneOnOne}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1263,12 +1234,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
showDeveloperTools={showDeveloperTools}
|
||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
legacyUsernameColor={isOneOnOne}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
{(() => {
|
||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||
|
|
@ -1308,7 +1278,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
outlineAttachment
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1351,8 +1321,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||
|
|
@ -1380,12 +1348,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
showDeveloperTools={showDeveloperTools}
|
||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||
accessibleTagColors={accessiblePowerTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
legacyUsernameColor={isOneOnOne}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
|
|
@ -1414,6 +1381,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
streamRailStart,
|
||||
streamRailEnd
|
||||
) => {
|
||||
// 1:1 rooms always hide membership/nick/avatar syslines — they are
|
||||
// pure noise in DMs. Group rooms (3+) respect the per-user settings.
|
||||
if (isOneOnOne) return null;
|
||||
const membershipChanged = isMembershipChanged(mEvent);
|
||||
if (membershipChanged && hideMembershipEvents) return null;
|
||||
if (!membershipChanged && hideNickAvatarEvents) return null;
|
||||
|
|
@ -1421,12 +1391,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const highlighted = focusItem?.index === item && focusItem.highlight;
|
||||
const parsed = parseMemberEvent(mEvent);
|
||||
const iconSrc =
|
||||
isStream && parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon;
|
||||
parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon;
|
||||
|
||||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1440,17 +1410,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={iconSrc}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1479,7 +1446,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1493,17 +1460,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={Icons.Hash}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1533,7 +1497,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1547,17 +1511,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={Icons.Hash}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1587,7 +1548,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1601,17 +1562,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={Icons.Hash}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1649,7 +1607,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1663,17 +1621,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1700,7 +1655,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1714,17 +1669,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={Icons.Code}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1753,7 +1705,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const timeJSX = (
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
@ -1767,17 +1719,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
messageSpacing={messageSpacing}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
hideReadReceipts={hideActivity}
|
||||
showDeveloperTools={showDeveloperTools}
|
||||
streamRailStart={streamRailStart}
|
||||
streamRailEnd={streamRailEnd}
|
||||
isStream={isStream}
|
||||
>
|
||||
<EventContent
|
||||
messageLayout={messageLayout}
|
||||
time={timeJSX}
|
||||
railStart={streamRailStart}
|
||||
railEnd={streamRailEnd}
|
||||
iconSrc={Icons.Code}
|
||||
content={
|
||||
<Box grow="Yes" direction="Column">
|
||||
|
|
@ -1826,6 +1775,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
if (eventType === 'org.matrix.msc4310.rtc.decline') return false;
|
||||
|
||||
if (eventType === StateEvent.RoomMember) {
|
||||
// Mirror the membership-sysline gate from the renderer above so the
|
||||
// rail-endpoint scan and the actual render agree on visibility.
|
||||
if (isOneOnOne) return false;
|
||||
const membershipChanged = isMembershipChanged(event);
|
||||
if (membershipChanged && hideMembershipEvents) return false;
|
||||
if (!membershipChanged && hideNickAvatarEvents) return false;
|
||||
|
|
@ -1879,7 +1831,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const { before: streamRenderableItemHasBefore, after: streamRenderableItemHasAfter } = (() => {
|
||||
const before = new Map<number, boolean>();
|
||||
const after = new Map<number, boolean>();
|
||||
if (!isStream) return { before, after };
|
||||
|
||||
const items = getItems();
|
||||
const renderableFlags = items.map((item) => {
|
||||
|
|
@ -1944,15 +1895,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
// back-pagination is possible — otherwise the «origin» dot would be a
|
||||
// lie about an earlier untouched history.
|
||||
const streamRailStart =
|
||||
isStream &&
|
||||
rangeAtStart &&
|
||||
!canPaginateBack &&
|
||||
streamRenderableItemHasBefore.get(item) === false;
|
||||
rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false;
|
||||
const streamRailEnd =
|
||||
isStream &&
|
||||
liveTimelineLinked &&
|
||||
rangeAtEnd &&
|
||||
streamRenderableItemHasAfter.get(item) !== true;
|
||||
liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
|
|
@ -1970,19 +1915,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
|
||||
const newDividerJSX =
|
||||
!isStream && newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
||||
<Badge as="span" size="500" variant="Success" fill="Solid" radii="300">
|
||||
<Text size="L400">{t('Room.new_messages')}</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
</MessageBase>
|
||||
) : null;
|
||||
if (isStream && newDivider && eventJSX) {
|
||||
// TODO(P3b): replace the legacy full-width unread divider with a Stream-native
|
||||
// first-unread affordance, e.g. dot ring/pulse/brightness on the rail.
|
||||
if (newDivider && eventJSX) {
|
||||
// TODO(P3c-followup): replace the legacy full-width unread divider with
|
||||
// a Stream-native first-unread affordance — dot ring/pulse/brightness on
|
||||
// the rail. The «Jump to unread» chip stays functional in the meantime.
|
||||
newDivider = false;
|
||||
}
|
||||
|
||||
|
|
@ -1992,34 +1928,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
return timeDayMonthYear(mEvent.getTs());
|
||||
})();
|
||||
|
||||
const renderDayDivider = () => {
|
||||
if (isStream) {
|
||||
return (
|
||||
const renderDayDivider = () => (
|
||||
<MessageBase space={STREAM_MESSAGE_SPACING}>
|
||||
<StreamDayDivider label={dayLabel} />
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<MessageBase space={messageSpacing}>
|
||||
<TimelineDivider variant="Surface">
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">{dayLabel}</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
</MessageBase>
|
||||
);
|
||||
};
|
||||
|
||||
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
|
||||
|
||||
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
||||
if (newDividerJSX) newDivider = false;
|
||||
if (dayDividerJSX) dayDivider = false;
|
||||
|
||||
if (eventJSX && dayDividerJSX) {
|
||||
dayDivider = false;
|
||||
return (
|
||||
<React.Fragment key={mEventId}>
|
||||
{newDividerJSX}
|
||||
{dayDividerJSX}
|
||||
{eventJSX}
|
||||
</React.Fragment>
|
||||
|
|
@ -2063,34 +1983,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
|
||||
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
|
||||
}`,
|
||||
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${toRem(
|
||||
64
|
||||
)}`,
|
||||
}}
|
||||
>
|
||||
<RoomIntro room={room} />
|
||||
</div>
|
||||
)}
|
||||
{(canPaginateBack || !rangeAtStart) &&
|
||||
(messageLayout === MessageLayout.Compact ? (
|
||||
<>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase ref={observeBackAnchor}>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
</>
|
||||
) : (
|
||||
{(canPaginateBack || !rangeAtStart) && (
|
||||
<>
|
||||
<MessageBase>
|
||||
<DefaultPlaceholder key={getItems().length} />
|
||||
|
|
@ -2102,30 +2003,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<DefaultPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
|
||||
{getItems().map(eventRenderer)}
|
||||
|
||||
{(!liveTimelineLinked || !rangeAtEnd) &&
|
||||
(messageLayout === MessageLayout.Compact ? (
|
||||
<>
|
||||
<MessageBase ref={observeFrontAnchor}>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
<MessageBase>
|
||||
<CompactPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
</>
|
||||
) : (
|
||||
{(!liveTimelineLinked || !rangeAtEnd) && (
|
||||
<>
|
||||
<MessageBase ref={observeFrontAnchor}>
|
||||
<DefaultPlaceholder key={getItems().length} />
|
||||
|
|
@ -2137,7 +2019,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<DefaultPlaceholder key={getItems().length} />
|
||||
</MessageBase>
|
||||
</>
|
||||
))}
|
||||
)}
|
||||
<span ref={atBottomAnchorRef} />
|
||||
</Box>
|
||||
</Scroll>
|
||||
|
|
|
|||
|
|
@ -27,13 +27,15 @@ import { useNavigate } from 'react-router-dom';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { isBridgedRoom } from '../../utils/room';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
|
|
@ -324,12 +326,28 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
const space = useSpaceOptionally();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const direct = useIsDirectRoom();
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
// Call surface is intentionally narrower than 1:1 chrome. Three gates:
|
||||
// 1. `isOneOnOne` — only strictly 2-member non-space rooms; group calls
|
||||
// ship as a separate plan after channels.md.
|
||||
// 2. `mDirectAtom.has(roomId)` — aligns visibility with the lifecycle
|
||||
// hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`) which
|
||||
// still gate ring delivery and caller-side auto-hangup on `m.direct`.
|
||||
// Moving the lifecycle to a member-count gate is a separate plan
|
||||
// because those hooks are load-bearing per dm_1x1_redesign.md §7.
|
||||
// 3. `!isBridgedRoom(room)` — defense in depth for bridged DMs (mautrix-
|
||||
// telegram puppet rooms, etc.). Bridged networks like Telegram have no
|
||||
// Matrix-RTC equivalent, so even if a bridge config writes `m.direct`
|
||||
// for its puppet rooms (`bridge.sync_direct_chat_list: true`) we must
|
||||
// not expose a call button that physically can't connect.
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const callButtonVisible =
|
||||
isOneOnOne && mDirects.has(room.roomId) && !isBridgedRoom(room);
|
||||
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const encryptedRoom = !!encryptionEvent;
|
||||
const avatarMxc = useRoomAvatar(room, direct);
|
||||
const avatarMxc = useRoomAvatar(room, isOneOnOne);
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const avatarUrl = avatarMxc
|
||||
|
|
@ -441,8 +459,14 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{direct && <DmCallButton room={room} />}
|
||||
{!encryptedRoom && (
|
||||
{callButtonVisible && <DmCallButton room={room} />}
|
||||
{/* In-room search currently only resolves when there's a parent
|
||||
space — the legacy /home/search redirect drops the `?rooms=…`
|
||||
query, so a non-space click would land on /direct/ with no
|
||||
search UI mounted. P4 lifts search into the `…` menu and the
|
||||
global SearchModalRenderer; until then we hide the chrome
|
||||
outside spaces. */}
|
||||
{!encryptedRoom && space && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
|
|
@ -38,27 +37,15 @@ import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
|||
import classNames from 'classnames';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
AvatarBase,
|
||||
BubbleLayout,
|
||||
CompactLayout,
|
||||
IsStreamProvider,
|
||||
MessageBase,
|
||||
MessageStatus,
|
||||
ModernLayout,
|
||||
STREAM_MESSAGE_SPACING,
|
||||
StreamLayout,
|
||||
Time,
|
||||
Username,
|
||||
UsernameBold,
|
||||
} from '../../../components/message';
|
||||
import {
|
||||
canEditEvent,
|
||||
getEventEdits,
|
||||
getMemberAvatarMxc,
|
||||
getMemberDisplayName,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import { useDotColor } from '../../../hooks/useDotColor';
|
||||
|
|
@ -70,17 +57,13 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { ReactionViewer } from '../reaction-viewer';
|
||||
import { MessageEditor } from './MessageEditor';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
||||
|
|
@ -670,8 +653,6 @@ export type MessageProps = {
|
|||
canPinEvent?: boolean;
|
||||
imagePackRooms?: Room[];
|
||||
relations?: Relations;
|
||||
messageLayout: MessageLayout;
|
||||
messageSpacing: MessageSpacing;
|
||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onReplyClick: (
|
||||
|
|
@ -691,7 +672,6 @@ export type MessageProps = {
|
|||
dateFormatString: string;
|
||||
streamRailStart?: boolean;
|
||||
streamRailEnd?: boolean;
|
||||
isStream?: boolean;
|
||||
};
|
||||
export const Message = as<'div', MessageProps>(
|
||||
(
|
||||
|
|
@ -707,8 +687,6 @@ export const Message = as<'div', MessageProps>(
|
|||
canPinEvent,
|
||||
imagePackRooms,
|
||||
relations,
|
||||
messageLayout,
|
||||
messageSpacing,
|
||||
onUserClick,
|
||||
onUsernameClick,
|
||||
onReplyClick,
|
||||
|
|
@ -725,7 +703,6 @@ export const Message = as<'div', MessageProps>(
|
|||
dateFormatString,
|
||||
streamRailStart,
|
||||
streamRailEnd,
|
||||
isStream = false,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
|
|
@ -733,7 +710,6 @@ export const Message = as<'div', MessageProps>(
|
|||
) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
|
||||
const [hover, setHover] = useState(false);
|
||||
|
|
@ -745,111 +721,16 @@ export const Message = as<'div', MessageProps>(
|
|||
const isOwnMessage = senderId === mx.getUserId();
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
||||
|
||||
const tagColor = memberPowerTag?.color
|
||||
? accessibleTagColors?.get(memberPowerTag.color)
|
||||
: undefined;
|
||||
const tagIconSrc = memberPowerTag?.icon
|
||||
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
||||
: undefined;
|
||||
|
||||
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
||||
|
||||
const isBubble = messageLayout === MessageLayout.Bubble;
|
||||
const dot = useDotColor(room, mEvent, isStream, hideReadReceipts);
|
||||
const dot = useDotColor(room, mEvent, true, hideReadReceipts);
|
||||
const screenSize = useScreenSizeContext();
|
||||
const isMobile = screenSize === ScreenSize.Mobile;
|
||||
const showTimeInHeader = !isBubble || !isOwnMessage;
|
||||
|
||||
// headerJSX / avatarJSX / bubbleMetaJSX feed only the legacy non-Stream
|
||||
// layouts (Compact/Bubble/Modern). Skip the React-element allocation in
|
||||
// Stream rooms — Stream builds its own author chip / time strip and never
|
||||
// reads these variables.
|
||||
const headerJSX = !isStream && !collapse && (
|
||||
<Box
|
||||
gap="300"
|
||||
direction={messageLayout === MessageLayout.Compact ? 'RowReverse' : 'Row'}
|
||||
justifyContent="SpaceBetween"
|
||||
alignItems="Baseline"
|
||||
grow="Yes"
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Username
|
||||
as="button"
|
||||
style={{ color: usernameColor }}
|
||||
data-user-id={senderId}
|
||||
onContextMenu={onUserClick}
|
||||
onClick={onUsernameClick}
|
||||
>
|
||||
<Text as="span" size={isBubble ? 'T300' : 'T400'} truncate>
|
||||
<UsernameBold>{senderDisplayName}</UsernameBold>
|
||||
</Text>
|
||||
</Username>
|
||||
{tagIconSrc && <PowerIcon size="100" iconSrc={tagIconSrc} />}
|
||||
</Box>
|
||||
{showTimeInHeader && (
|
||||
<Box shrink="No" gap="100" alignItems="Center">
|
||||
{messageLayout === MessageLayout.Modern && hover && (
|
||||
<>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{senderId}
|
||||
</Text>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
|
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact={messageLayout === MessageLayout.Compact}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
{isOwnMessage && (
|
||||
<MessageStatus room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
const avatarJSX = !isStream && !collapse && messageLayout !== MessageLayout.Compact && (
|
||||
<AvatarBase
|
||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||
>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
onClick={onUserClick}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined
|
||||
: undefined
|
||||
}
|
||||
alt={senderDisplayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarBase>
|
||||
);
|
||||
|
||||
const bubbleMetaJSX = !isStream && isBubble && isOwnMessage && (
|
||||
<span className={css.BubbleMeta}>
|
||||
<Time
|
||||
ts={mEvent.getTs()}
|
||||
compact
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
<MessageStatus room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
|
||||
</span>
|
||||
);
|
||||
|
||||
const msgContentJSX = (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
|
|
@ -914,11 +795,9 @@ export const Message = as<'div', MessageProps>(
|
|||
|
||||
return (
|
||||
<MessageBase
|
||||
className={classNames(css.MessageBase, className, {
|
||||
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
||||
})}
|
||||
className={classNames(css.MessageBase, className)}
|
||||
tabIndex={0}
|
||||
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
|
||||
space={STREAM_MESSAGE_SPACING}
|
||||
collapse={collapse}
|
||||
highlight={highlight}
|
||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||
|
|
@ -1167,7 +1046,6 @@ export const Message = as<'div', MessageProps>(
|
|||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
{isStream ? (
|
||||
<StreamLayout
|
||||
time={
|
||||
<Time
|
||||
|
|
@ -1204,39 +1082,6 @@ export const Message = as<'div', MessageProps>(
|
|||
>
|
||||
{msgContentJSX}
|
||||
</StreamLayout>
|
||||
) : (
|
||||
<>
|
||||
{messageLayout === MessageLayout.Compact && (
|
||||
<CompactLayout before={headerJSX} onContextMenu={handleContextMenu}>
|
||||
{msgContentJSX}
|
||||
</CompactLayout>
|
||||
)}
|
||||
{isBubble && isOwnMessage && (
|
||||
<Box gap="200" alignItems="End">
|
||||
<BubbleLayout
|
||||
style={{ flexGrow: 1, minWidth: 0 }}
|
||||
before={avatarJSX}
|
||||
header={headerJSX}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
{bubbleMetaJSX}
|
||||
</Box>
|
||||
)}
|
||||
{isBubble && !isOwnMessage && (
|
||||
<BubbleLayout before={avatarJSX} header={headerJSX} onContextMenu={handleContextMenu}>
|
||||
{msgContentJSX}
|
||||
</BubbleLayout>
|
||||
)}
|
||||
{messageLayout !== MessageLayout.Compact && !isBubble && (
|
||||
<ModernLayout before={avatarJSX} onContextMenu={handleContextMenu}>
|
||||
{headerJSX}
|
||||
{msgContentJSX}
|
||||
</ModernLayout>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
|
@ -1247,12 +1092,8 @@ export type EventProps = {
|
|||
mEvent: MatrixEvent;
|
||||
highlight: boolean;
|
||||
canDelete?: boolean;
|
||||
messageSpacing: MessageSpacing;
|
||||
hideReadReceipts?: boolean;
|
||||
showDeveloperTools?: boolean;
|
||||
streamRailStart?: boolean;
|
||||
streamRailEnd?: boolean;
|
||||
isStream?: boolean;
|
||||
};
|
||||
export const Event = as<'div', EventProps>(
|
||||
(
|
||||
|
|
@ -1262,12 +1103,8 @@ export const Event = as<'div', EventProps>(
|
|||
mEvent,
|
||||
highlight,
|
||||
canDelete,
|
||||
messageSpacing,
|
||||
hideReadReceipts,
|
||||
showDeveloperTools,
|
||||
streamRailStart,
|
||||
streamRailEnd,
|
||||
isStream = false,
|
||||
children,
|
||||
...props
|
||||
},
|
||||
|
|
@ -1306,7 +1143,7 @@ export const Event = as<'div', EventProps>(
|
|||
<MessageBase
|
||||
className={classNames(css.MessageBase, className)}
|
||||
tabIndex={0}
|
||||
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
|
||||
space={STREAM_MESSAGE_SPACING}
|
||||
autoCollapse
|
||||
highlight={highlight}
|
||||
selected={!!menuAnchor}
|
||||
|
|
@ -1393,11 +1230,7 @@ export const Event = as<'div', EventProps>(
|
|||
</Menu>
|
||||
</div>
|
||||
)}
|
||||
<IsStreamProvider
|
||||
value={{ enabled: isStream, railStart: streamRailStart, railEnd: streamRailEnd }}
|
||||
>
|
||||
<div onContextMenu={handleContextMenu}>{children}</div>
|
||||
</IsStreamProvider>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@ import { DefaultReset, config, toRem } from 'folds';
|
|||
export const MessageBase = style({
|
||||
position: 'relative',
|
||||
});
|
||||
export const MessageBaseBubbleCollapsed = style({
|
||||
paddingTop: 0,
|
||||
});
|
||||
|
||||
export const MessageOptionsBase = style([
|
||||
DefaultReset,
|
||||
|
|
@ -24,14 +21,6 @@ export const MessageOptionsBar = style([
|
|||
},
|
||||
]);
|
||||
|
||||
export const BubbleAvatarBase = style({
|
||||
paddingTop: 0,
|
||||
});
|
||||
|
||||
export const MessageAvatar = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const MessageQuickReaction = style({
|
||||
minWidth: toRem(32),
|
||||
});
|
||||
|
|
@ -55,11 +44,3 @@ export const ReactionsContainer = style({
|
|||
export const ReactionsTooltipText = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
export const BubbleMeta = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: toRem(2),
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
|||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
||||
import { useIsOneOnOne } from '../../../hooks/useRoom';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
import {
|
||||
|
|
@ -277,8 +277,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
|
||||
const direct = useIsDirectRoom();
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const isOneOnOne = useIsOneOnOne();
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
|
@ -499,7 +498,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||
canPinEvent={canPinEvent}
|
||||
getMemberPowerTag={getMemberPowerTag}
|
||||
accessibleTagColors={accessibleTagColors}
|
||||
legacyUsernameColor={legacyUsernameColor || direct}
|
||||
legacyUsernameColor={isOneOnOne}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -31,12 +31,11 @@ import FocusTrap from 'focus-trap-react';
|
|||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { DateFormat, MessageSpacing, settingsAtom } from '../../../state/settings';
|
||||
import { DateFormat, settingsAtom } from '../../../state/settings';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { KeySymbol } from '../../../utils/key-symbol';
|
||||
import { isMacOS } from '../../../utils/user-agent';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
|
||||
|
|
@ -533,81 +532,8 @@ function Editor() {
|
|||
);
|
||||
}
|
||||
|
||||
function SelectMessageSpacing() {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||
const messageSpacingItems = useMessageSpacingItems();
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (layout: MessageSpacing) => {
|
||||
setMessageSpacing(layout);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Text size="T300">
|
||||
{messageSpacingItems.find((i) => i.spacing === messageSpacing)?.name ?? messageSpacing}
|
||||
</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{messageSpacingItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.spacing}
|
||||
size="300"
|
||||
variant={messageSpacing === item.spacing ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(item.spacing)}
|
||||
>
|
||||
<Text size="T300">{item.name}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Messages() {
|
||||
const { t } = useTranslation();
|
||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
||||
settingsAtom,
|
||||
'legacyUsernameColor'
|
||||
);
|
||||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||||
settingsAtom,
|
||||
'hideMembershipEvents'
|
||||
|
|
@ -624,25 +550,6 @@ function Messages() {
|
|||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">{t('Settings.messages')}</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title={t('Settings.message_spacing')}
|
||||
description={t('Settings.message_spacing_dm_note')}
|
||||
after={<SelectMessageSpacing />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title={t('Settings.legacy_username_color')}
|
||||
after={
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={legacyUsernameColor}
|
||||
onChange={setLegacyUsernameColor}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title={t('Settings.hide_membership')}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
||||
import { JoinRule } from 'matrix-js-sdk';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
|
|
@ -8,7 +7,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { isOneOnOneRoom } from '../../utils/room';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
|
|
@ -64,9 +63,10 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||
const room = useRoom();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const roomAvatar = useRoomAvatar(room, mDirects.has(room.roomId));
|
||||
// `isOneOnOneRoom` excludes spaces, so this resolves to `false` for the
|
||||
// space-settings drawer — kept on the helper for symmetry with RoomSettings.
|
||||
const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room));
|
||||
const roomName = useRoomName(room);
|
||||
const joinRuleContent = useRoomJoinRule(room);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,47 +0,0 @@
|
|||
import { useMatch } from 'react-router-dom';
|
||||
import {
|
||||
getHomeCreatePath,
|
||||
getHomeJoinPath,
|
||||
getHomePath,
|
||||
getHomeSearchPath,
|
||||
} from '../../pages/pathUtils';
|
||||
|
||||
export const useHomeSelected = (): boolean => {
|
||||
const homeMatch = useMatch({
|
||||
path: getHomePath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!homeMatch;
|
||||
};
|
||||
|
||||
export const useHomeCreateSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getHomeCreatePath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const useHomeJoinSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getHomeJoinPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
||||
export const useHomeSearchSelected = (): boolean => {
|
||||
const match = useMatch({
|
||||
path: getHomeSearchPath(),
|
||||
caseSensitive: true,
|
||||
end: false,
|
||||
});
|
||||
|
||||
return !!match;
|
||||
};
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { isDirectStreamRoom } from '../utils/room';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
// Render-side DM gate. The actual four-source decision tree lives in
|
||||
// `isDirectStreamRoom` (utils/room.ts) so HomeRouteRoomProvider (cold-start
|
||||
// push redirect), DirectRouteRoomProvider (destination validation), and
|
||||
// useRoomNavigate.navigateRoom (imperative routing) can reuse the same
|
||||
// logic without re-implementing it. All four call sites must stay in sync —
|
||||
// any divergence reintroduces the cold-start race that flashes
|
||||
// JoinBeforeNavigate before the room renders.
|
||||
// Don't replace useIsDirectRoom() with this hook globally — that one is the
|
||||
// route-level context value driven by DirectRouteRoomProvider; this one is the
|
||||
// presentation-level gate for picking the Stream visual.
|
||||
export const useIsDirectStream = (room: Room): boolean => {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
return isDirectStreamRoom(mx, room, mDirects);
|
||||
};
|
||||
34
src/app/hooks/useIsOneOnOneRoom.ts
Normal file
34
src/app/hooks/useIsOneOnOneRoom.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import { Room, RoomStateEvent } from 'matrix-js-sdk';
|
||||
import { isOneOnOneRoom } from '../utils/room';
|
||||
|
||||
// Reactive «is this room strictly 2 members» — re-runs whenever a member event
|
||||
// lands so live demotions (1:1 → 3-person via invite) and promotions (peer
|
||||
// leaves → solo) flip the chrome immediately. Without the subscription, the
|
||||
// `IsOneOnOneProvider` value would freeze at mount-time for the active route
|
||||
// and `DmCallButton` / peer-avatar fallback / membership-sysline gate would
|
||||
// stay stale until the user navigates away.
|
||||
//
|
||||
// `RoomStateEvent.Members` is emitted on every `m.room.member` state event
|
||||
// (join, invite, leave, knock, ban, profile change). Profile-only updates are
|
||||
// no-ops here because they don't change `getInvitedAndJoinedMemberCount()`.
|
||||
export const useIsOneOnOneRoom = (room: Room): boolean => {
|
||||
const [value, setValue] = useState<boolean>(() => isOneOnOneRoom(room));
|
||||
|
||||
useEffect(() => {
|
||||
// Capture `currentState` once per effect run so the cleanup detaches
|
||||
// from the same emitter we attached to. matrix-js-sdk replaces
|
||||
// `room.currentState` on a fresh `/sync` snapshot in rare cases (room
|
||||
// upgrade + late state catch-up); without the capture, the cleanup would
|
||||
// call removeListener on the new state and leak the old subscription.
|
||||
const state = room.currentState;
|
||||
setValue(isOneOnOneRoom(room));
|
||||
const handler = () => setValue(isOneOnOneRoom(room));
|
||||
state.on(RoomStateEvent.Members, handler);
|
||||
return () => {
|
||||
state.removeListener(RoomStateEvent.Members, handler);
|
||||
};
|
||||
}, [room]);
|
||||
|
||||
return value;
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { useMemo } from 'react';
|
||||
import { MessageSpacing } from '../state/settings';
|
||||
|
||||
export type MessageSpacingItem = {
|
||||
name: string;
|
||||
spacing: MessageSpacing;
|
||||
};
|
||||
|
||||
export const useMessageSpacingItems = (): MessageSpacingItem[] =>
|
||||
useMemo(
|
||||
() => [
|
||||
{
|
||||
spacing: '0',
|
||||
name: 'None',
|
||||
},
|
||||
{
|
||||
spacing: '100',
|
||||
name: 'Ultra Small',
|
||||
},
|
||||
{
|
||||
spacing: '200',
|
||||
name: 'Extra Small',
|
||||
},
|
||||
{
|
||||
spacing: '300',
|
||||
name: 'Small',
|
||||
},
|
||||
{
|
||||
spacing: '400',
|
||||
name: 'Normal',
|
||||
},
|
||||
{
|
||||
spacing: '500',
|
||||
name: 'Large',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
|
@ -11,12 +11,13 @@ export function useRoom(): Room {
|
|||
return room;
|
||||
}
|
||||
|
||||
const IsDirectRoomContext = createContext<boolean>(false);
|
||||
// «Strictly two-member room» — Element-Web tier-2 pattern via
|
||||
// `room.getInvitedAndJoinedMemberCount() === 2`. Server-side authoritative,
|
||||
// no race against `m.direct` account-data hydration. Drives peer-avatar
|
||||
// fallback in the room header, DM call button visibility, and per-author
|
||||
// username colour in reply chips. See docs/plans/dm_1x1_redesign.md §6.8.
|
||||
const IsOneOnOneContext = createContext<boolean>(false);
|
||||
|
||||
export const IsDirectRoomProvider = IsDirectRoomContext.Provider;
|
||||
export const IsOneOnOneProvider = IsOneOnOneContext.Provider;
|
||||
|
||||
export const useIsDirectRoom = () => {
|
||||
const direct = useContext(IsDirectRoomContext);
|
||||
|
||||
return direct;
|
||||
};
|
||||
export const useIsOneOnOne = () => useContext(IsOneOnOneContext);
|
||||
|
|
|
|||
|
|
@ -2,16 +2,10 @@ import { useCallback, useRef } from 'react';
|
|||
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||
import {
|
||||
getDirectRoomPath,
|
||||
getHomeRoomPath,
|
||||
getSpacePath,
|
||||
getSpaceRoomPath,
|
||||
} from '../pages/pathUtils';
|
||||
import { getDirectRoomPath, getSpacePath, getSpaceRoomPath } from '../pages/pathUtils';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getOrphanParents, guessPerfectParent, isDirectStreamRoom } from '../utils/room';
|
||||
import { getOrphanParents, guessPerfectParent, isSpace } from '../utils/room';
|
||||
import { roomToParentsAtom } from '../state/room/roomToParents';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
|
|
@ -21,7 +15,6 @@ export const useRoomNavigate = () => {
|
|||
const location = useLocation();
|
||||
const mx = useMatrixClient();
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const spaceSelectedId = useSelectedSpace();
|
||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||
|
||||
|
|
@ -88,23 +81,18 @@ export const useRoomNavigate = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Mirror the four-source DM gate used by useIsDirectStream and
|
||||
// HomeRouteRoomProvider so imperative navigation (push tap, search
|
||||
// result, inbox click) sends DMs to /direct/ even on the first frame
|
||||
// before mDirectAtom has hydrated. Fall back to the simple atom check
|
||||
// when the SDK doesn't know the room yet (e.g. peeking).
|
||||
// After P3c the Direct tab is universal — every non-space leaf room
|
||||
// routes through /direct/. Space roots stay on their own /{spaceId}/
|
||||
// route handled by navigateSpace.
|
||||
const targetRoom = mx.getRoom(roomId);
|
||||
const isDirect = targetRoom
|
||||
? isDirectStreamRoom(mx, targetRoom, mDirects)
|
||||
: mDirects.has(roomId);
|
||||
if (isDirect) {
|
||||
safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||
if (targetRoom && isSpace(targetRoom)) {
|
||||
safeNavigate(getSpacePath(roomIdOrAlias), opts);
|
||||
return;
|
||||
}
|
||||
|
||||
safeNavigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
||||
safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||
},
|
||||
[mx, safeNavigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
||||
[mx, safeNavigate, spaceSelectedId, roomToParents, developerTools]
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ import {
|
|||
} from './pathUtils';
|
||||
import { getMxIdServer, isUserId } from '../utils/matrix';
|
||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
||||
import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home';
|
||||
import { HomeRouteRoomProvider } from './client/home';
|
||||
import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||
|
|
@ -71,7 +71,6 @@ import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotifica
|
|||
import { SpaceSettingsRenderer } from '../features/space-settings';
|
||||
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
|
||||
import { CreateRoomModalRenderer } from '../features/create-room';
|
||||
import { HomeCreateRoom } from './client/home/CreateRoom';
|
||||
import { Create } from './client/create';
|
||||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||
import { SearchModalRenderer } from '../features/search';
|
||||
|
|
@ -201,24 +200,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
</AuthRouteThemeManager>
|
||||
}
|
||||
>
|
||||
<Route
|
||||
path={HOME_PATH}
|
||||
element={
|
||||
<PageRoot
|
||||
nav={
|
||||
<MobileFriendlyPageNav path={HOME_PATH}>
|
||||
<Home />
|
||||
</MobileFriendlyPageNav>
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</PageRoot>
|
||||
}
|
||||
>
|
||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||
<Route path={_CREATE_PATH} element={<HomeCreateRoom />} />
|
||||
<Route path={_JOIN_PATH} element={<p>join</p>} />
|
||||
<Route path={_SEARCH_PATH} element={<HomeSearch />} />
|
||||
{/* Legacy /home/ tree — kept only as a redirect surface so cold-start
|
||||
push deep links and pre-P3c bookmarks resolve cleanly. The Home
|
||||
page itself is gone; HomeRouteRoomProvider redirects /home/{roomId}/
|
||||
into /direct/{roomId}/ on mount. See plan §6.7 / §8 P3c. */}
|
||||
<Route path={HOME_PATH}>
|
||||
<Route index element={<Navigate to={DIRECT_PATH} replace />} />
|
||||
<Route path={_CREATE_PATH} element={<Navigate to={getDirectCreatePath()} replace />} />
|
||||
<Route path={_JOIN_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
|
||||
<Route path={_SEARCH_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
|
||||
<Route
|
||||
path={_ROOM_PATH}
|
||||
element={
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
} from '../../components/sidebar';
|
||||
import {
|
||||
DirectTab,
|
||||
HomeTab,
|
||||
SpaceTabs,
|
||||
InboxTab,
|
||||
ExploreTab,
|
||||
|
|
@ -28,7 +27,6 @@ export function SidebarNav() {
|
|||
scrollable={
|
||||
<Scroll ref={scrollRef} variant="Background" size="0">
|
||||
<SidebarStack>
|
||||
<HomeTab />
|
||||
<DirectTab />
|
||||
</SidebarStack>
|
||||
<SpaceTabs scrollRef={scrollRef} />
|
||||
|
|
|
|||
|
|
@ -1,35 +1,37 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { isDirectStreamRoom } from '../../../utils/room';
|
||||
import { isSpace } from '../../../utils/room';
|
||||
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
||||
|
||||
// Inner provider hosts the reactive 1:1 subscription. Hooks can't run inside
|
||||
// the early-return path of the parent, so we split the component once `room`
|
||||
// is known to exist and not be a space.
|
||||
function ResolvedRoomProvider({ room, children }: { room: Room; children: ReactNode }) {
|
||||
const isOneOnOne = useIsOneOnOneRoom(room);
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsOneOnOneProvider value={isOneOnOne}>{children}</IsOneOnOneProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const { roomIdOrAlias, eventId } = useParams();
|
||||
const roomId = useSelectedRoom();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
// Symmetric with HomeRouteRoomProvider's redirect predicate and
|
||||
// useRoomNavigate's destination check — all three use the same four-source
|
||||
// `isDirectStreamRoom` helper. Without this, on cold-start race (mDirectAtom
|
||||
// hydrated from useEffect, still empty on the first render after a fresh
|
||||
// invite) HomeRouteRoomProvider redirects to /direct/, this validator then
|
||||
// disagrees on the same frame and falls through to JoinBeforeNavigate. See
|
||||
// docs/plans/dm_1x1_redesign.md §6.5 / §6.7.
|
||||
if (!room || !isDirectStreamRoom(mx, room, mDirects)) {
|
||||
// After P3c the Direct tab is universal — any joined non-space room renders
|
||||
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
|
||||
if (!room || isSpace(room)) {
|
||||
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,52 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useMemo } from 'react';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useDirects } from '../../../state/hooks/roomList';
|
||||
import { useDirects, useOrphanRooms } from '../../../state/hooks/roomList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
|
||||
export const useDirectRooms = () => {
|
||||
// After P3c the Direct tab is universal: every joined non-space «orphan»
|
||||
// room renders here, regardless of `m.direct`. Implementation =
|
||||
// `useOrphanRooms ∪ useDirects`:
|
||||
//
|
||||
// - `useOrphanRooms` = `isRoom && !mDirects.has && !roomToParents.has` →
|
||||
// non-space rooms that don't live inside any space and aren't m.direct-
|
||||
// tagged. The formerly-Home tab.
|
||||
// - `useDirects` = `isRoom && mDirects.has` → every m.direct-tagged
|
||||
// non-space room. **No `roomToParents` filter** — a room that is BOTH a
|
||||
// space child AND m.direct-tagged appears here. This is intentional and
|
||||
// pre-dates P3c: the m.direct flag wins over the space-child membership
|
||||
// for routing purposes (user sees the DM in /direct/, opening it loses
|
||||
// space context). The same room is hidden from the parent space's child-
|
||||
// room navigation list (`useChildRoomScopeFactory` excludes m.direct);
|
||||
// it stays reachable from space message search via
|
||||
// `useRecursiveChildNonSpaceRoomScopeFactory` (post-P3c universal).
|
||||
//
|
||||
// Duplicates inside the union are deduped via Set semantics below. The
|
||||
// product call (DM-tagged-space-child resolves to /direct/, not /space/{id}/)
|
||||
// is open for revision — see desired_features §21 (Channels plan).
|
||||
export const useDirectRooms = (): string[] => {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
return directs;
|
||||
return useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
orphanRooms.forEach((id) => {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
});
|
||||
directs.forEach((id) => {
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id);
|
||||
out.push(id);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [orphanRooms, directs]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import React from 'react';
|
||||
import { Box, Icon, Icons, Scroll, IconButton } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Page,
|
||||
PageContent,
|
||||
PageContentCenter,
|
||||
PageHeader,
|
||||
PageHero,
|
||||
PageHeroSection,
|
||||
} from '../../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
import { CreateRoomForm } from '../../../features/create-room';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
|
||||
export function HomeCreateRoom() {
|
||||
const { t } = useTranslation();
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<PageHeader balance outlined={false}>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
)}
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<PageHeroSection>
|
||||
<Box direction="Column" gap="700">
|
||||
<PageHero
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title={t('Home.create_room')}
|
||||
subTitle={t('Home.create_room_subtitle')}
|
||||
/>
|
||||
<CreateRoomForm onCreate={navigateRoom} />
|
||||
</Box>
|
||||
</PageHeroSection>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
import React, { MouseEventHandler, forwardRef, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { factoryRoomIdByActivity, factoryRoomIdByAtoZ } from '../../../utils/sort';
|
||||
import {
|
||||
NavButton,
|
||||
NavCategory,
|
||||
NavCategoryHeader,
|
||||
NavEmptyCenter,
|
||||
NavEmptyLayout,
|
||||
NavItem,
|
||||
NavItemContent,
|
||||
NavLink,
|
||||
} from '../../../components/nav';
|
||||
import {
|
||||
encodeSearchParamValueArray,
|
||||
getExplorePath,
|
||||
getHomeCreatePath,
|
||||
getHomeRoomPath,
|
||||
getHomeSearchPath,
|
||||
withSearchParam,
|
||||
} from '../../pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import {
|
||||
useHomeCreateSelected,
|
||||
useHomeSearchSelected,
|
||||
} from '../../../hooks/router/useHomeSelected';
|
||||
import { useHomeRooms } from './useHomeRooms';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav';
|
||||
import { makeNavCategoryId } from '../../../state/closedNavCategories';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||
import { PageNav, PageNavHeader, PageNavContent } from '../../../components/page';
|
||||
import { useRoomsUnread } from '../../../state/hooks/unread';
|
||||
import { markAsRead } from '../../../utils/notifications';
|
||||
import { useClosedNavCategoriesAtom } from '../../../state/hooks/closedNavCategories';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const orphanRooms = useHomeRooms();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!unread) return;
|
||||
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Home.mark_as_read')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
function HomeHeader() {
|
||||
const { t } = useTranslation();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageNavHeader>
|
||||
<Box alignItems="Center" grow="Yes" gap="300">
|
||||
<Box grow="Yes">
|
||||
<Text size="H4" truncate>
|
||||
{t('Home.home')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<IconButton aria-pressed={!!menuAnchor} variant="Background" onClick={handleOpenMenu}>
|
||||
<Icon src={Icons.VerticalDots} size="200" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageNavHeader>
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeEmpty() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<NavEmptyCenter>
|
||||
<NavEmptyLayout
|
||||
icon={<Icon size="600" src={Icons.Hash} />}
|
||||
title={
|
||||
<Text size="H5" align="Center">
|
||||
{t('Home.no_rooms')}
|
||||
</Text>
|
||||
}
|
||||
content={
|
||||
<Text size="T300" align="Center">
|
||||
{t('Home.no_rooms_desc')}
|
||||
</Text>
|
||||
}
|
||||
options={
|
||||
<>
|
||||
<Button onClick={() => navigate(getHomeCreatePath())} variant="Secondary" size="300">
|
||||
<Text size="B300" truncate>
|
||||
{t('Home.create_room')}
|
||||
</Text>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate(getExplorePath())}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{t('Home.explore_community')}
|
||||
</Text>
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</NavEmptyCenter>
|
||||
);
|
||||
}
|
||||
|
||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||
export function Home() {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
useNavToActivePathMapper('home');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rooms = useHomeRooms();
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const selectedRoomId = useSelectedRoom();
|
||||
const createRoomSelected = useHomeCreateSelected();
|
||||
const searchSelected = useHomeSearchSelected();
|
||||
const noRoomToDisplay = rooms.length === 0;
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom());
|
||||
|
||||
const sortedRooms = useMemo(() => {
|
||||
const items = Array.from(rooms).sort(
|
||||
closedCategories.has(DEFAULT_CATEGORY_ID)
|
||||
? factoryRoomIdByActivity(mx)
|
||||
: factoryRoomIdByAtoZ(mx)
|
||||
);
|
||||
if (closedCategories.has(DEFAULT_CATEGORY_ID)) {
|
||||
return items.filter((rId) => roomToUnread.has(rId) || rId === selectedRoomId);
|
||||
}
|
||||
return items;
|
||||
}, [mx, rooms, closedCategories, roomToUnread, selectedRoomId]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: sortedRooms.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 38,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
|
||||
return (
|
||||
<PageNav>
|
||||
<HomeHeader />
|
||||
{noRoomToDisplay ? (
|
||||
<HomeEmpty />
|
||||
) : (
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<Box direction="Column" gap="300">
|
||||
<NavCategory>
|
||||
<NavItem variant="Background" radii="400" aria-selected={createRoomSelected}>
|
||||
<NavButton onClick={() => navigate(getHomeCreatePath())}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{t('Home.create_room')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
<UseStateProvider initial={false}>
|
||||
{(open, setOpen) => (
|
||||
<>
|
||||
<NavItem variant="Background" radii="400">
|
||||
<NavButton onClick={() => setOpen(true)}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Link} size="100" />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{t('Home.join_with_address')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavButton>
|
||||
</NavItem>
|
||||
{open && (
|
||||
<JoinAddressPrompt
|
||||
onCancel={() => setOpen(false)}
|
||||
onOpen={(roomIdOrAlias, viaServers, eventId) => {
|
||||
setOpen(false);
|
||||
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
||||
navigate(
|
||||
viaServers
|
||||
? withSearchParam<_RoomSearchParams>(path, {
|
||||
viaServers: encodeSearchParamValueArray(viaServers),
|
||||
})
|
||||
: path
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
<NavItem variant="Background" radii="400" aria-selected={searchSelected}>
|
||||
<NavLink to={getHomeSearchPath()}>
|
||||
<NavItemContent>
|
||||
<Box as="span" grow="Yes" alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="400">
|
||||
<Icon src={Icons.Search} size="100" filled={searchSelected} />
|
||||
</Avatar>
|
||||
<Box as="span" grow="Yes">
|
||||
<Text as="span" size="Inherit" truncate>
|
||||
{t('Home.message_search')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</NavItemContent>
|
||||
</NavLink>
|
||||
</NavItem>
|
||||
</NavCategory>
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||
data-category-id={DEFAULT_CATEGORY_ID}
|
||||
onClick={handleCategoryClick}
|
||||
>
|
||||
{t('Home.rooms')}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
height: virtualizer.getTotalSize(),
|
||||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const roomId = sortedRooms[vItem.index];
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const selected = selectedRoomId === roomId;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selected}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId
|
||||
)}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</NavCategory>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
)}
|
||||
</PageNav>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,38 +1,42 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||
import { useHomeRooms } from './useHomeRooms';
|
||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { getDirectRoomPath } from '../../pathUtils';
|
||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
||||
import { isDirectStreamRoom } from '../../../utils/room';
|
||||
import { isSpace } from '../../../utils/room';
|
||||
|
||||
export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||
export function HomeRouteRoomProvider({ children: _children }: { children: ReactNode }) {
|
||||
const mx = useMatrixClient();
|
||||
const rooms = useHomeRooms();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
// **Subscribe to allRoomsAtom even though we don't use the value**: cold-
|
||||
// start push opens `/home/{newRoomId}/` before /sync brings the brand-new
|
||||
// invite. On first render `mx.getRoom(roomId)` returns null, the provider
|
||||
// falls through to JoinBeforeNavigate, and JoinBeforeNavigate has no auto-
|
||||
// redirect — user is stuck staring at a RoomCard. The pre-P3c version
|
||||
// pulled `useHomeRooms()` here, which transitively read `allRoomsAtom`
|
||||
// through jotai and re-rendered when /sync populated the room. P3c removed
|
||||
// `useHomeRooms` along with the Home page; we restore the subscription
|
||||
// explicitly so the redirect re-evaluates after /sync. See plan §6.7 / §6.8
|
||||
// / round-5 review notes.
|
||||
useAtomValue(allRoomsAtom);
|
||||
|
||||
const { roomIdOrAlias, eventId } = useParams();
|
||||
const viaServers = useSearchParamsViaServers();
|
||||
const roomId = useSelectedRoom();
|
||||
const room = mx.getRoom(roomId);
|
||||
|
||||
// Cold-start push routing lands on /home/{roomId} (sw.ts has no access to
|
||||
// mDirectAtom). For DM rooms we redirect to /direct/. The shared four-source
|
||||
// predicate matches the render-side `useIsDirectStream` so the redirect
|
||||
// decision and the Stream layout decision agree on the first paint instead
|
||||
// of one frame later. See plan §6.5 / §6.7.
|
||||
if (room && isDirectStreamRoom(mx, room, mDirects)) {
|
||||
// After P3c the Home tab is gone — every non-space room redirects to the
|
||||
// unified Direct list. /home/{roomId}/ stays as a deep-link compatibility
|
||||
// shim for cold-start push (sw.ts cannot read mDirectAtom).
|
||||
if (room && !isSpace(room)) {
|
||||
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
|
||||
}
|
||||
|
||||
if (!room || !rooms.includes(room.roomId)) {
|
||||
return (
|
||||
<JoinBeforeNavigate
|
||||
roomIdOrAlias={roomIdOrAlias!}
|
||||
|
|
@ -41,10 +45,3 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsDirectRoomProvider value={false}>{children}</IsDirectRoomProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
import React, { useRef } from 'react';
|
||||
import { Box, Icon, Icons, Text, Scroll, IconButton } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||
import { MessageSearch } from '../../../features/message-search';
|
||||
import { useHomeRooms } from './useHomeRooms';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { BackRouteHandler } from '../../../components/BackRouteHandler';
|
||||
|
||||
export function HomeSearch() {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const rooms = useHomeRooms();
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" basis="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack}>
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
</Box>
|
||||
<Box justifyContent="Center" alignItems="Center" gap="200">
|
||||
{screenSize !== ScreenSize.Mobile && <Icon size="400" src={Icons.Search} />}
|
||||
<Text size="H3" truncate>
|
||||
{t('Search.message_search')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box grow="Yes" basis="No" />
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box style={{ position: 'relative' }} grow="Yes">
|
||||
<Scroll ref={scrollRef} hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<PageContentCenter>
|
||||
<MessageSearch
|
||||
defaultRoomsFilterName={t('Search.home')}
|
||||
allowGlobal
|
||||
rooms={rooms}
|
||||
scrollRef={scrollRef}
|
||||
/>
|
||||
</PageContentCenter>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1 @@
|
|||
export * from './Home';
|
||||
export * from './Search';
|
||||
export * from './RoomProvider';
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
import { useAtomValue } from 'jotai';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
||||
|
||||
export const useHomeRooms = () => {
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const rooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||
return rooms;
|
||||
};
|
||||
|
|
@ -40,6 +40,7 @@ import {
|
|||
getMemberAvatarMxc,
|
||||
getMemberDisplayName,
|
||||
getRoomAvatarUrl,
|
||||
isOneOnOneRoom,
|
||||
} from '../../../utils/room';
|
||||
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
||||
import { useInterval } from '../../../hooks/useInterval';
|
||||
|
|
@ -90,7 +91,6 @@ import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
|||
import { useTheme } from '../../../hooks/useTheme';
|
||||
import { PowerIcon } from '../../../components/power';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import {
|
||||
getPowerTagIconSrc,
|
||||
useAccessiblePowerTagColors,
|
||||
|
|
@ -569,11 +569,9 @@ export function Notifications() {
|
|||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
|
@ -734,7 +732,7 @@ export function Notifications() {
|
|||
hideActivity={hideActivity}
|
||||
onOpen={navigateRoom}
|
||||
legacyUsernameColor={
|
||||
legacyUsernameColor || mDirects.has(groupRoom.roomId)
|
||||
isOneOnOneRoom(groupRoom)
|
||||
}
|
||||
hour24Clock={hour24Clock}
|
||||
dateFormatString={dateFormatString}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,7 @@ import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRe
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useDirects } from '../../../state/hooks/roomList';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { getDirectPath, joinPathComponent } from '../../pathUtils';
|
||||
import { isNativePlatform } from '../../../utils/capacitor';
|
||||
|
|
@ -66,12 +63,14 @@ const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }
|
|||
export function DirectTab() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
||||
// After P3c the Direct tab is universal — every joined non-space room lives
|
||||
// here. The badge / mark-as-read scope must mirror the panel itself
|
||||
// (`useDirectRooms()` = orphan ∪ m.direct), otherwise unread groups would
|
||||
// count toward the panel but not the sidebar dot.
|
||||
const directs = useDirectRooms();
|
||||
const directUnread = useRoomsUnread(directs, roomToUnreadAtom);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,150 +0,0 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useOrphanRooms } from '../../../state/hooks/roomList';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { getHomePath, joinPathComponent } from '../../pathUtils';
|
||||
import { isNativePlatform } from '../../../utils/capacitor';
|
||||
import { useRoomsUnread } from '../../../state/hooks/unread';
|
||||
import {
|
||||
SidebarAvatar,
|
||||
SidebarItem,
|
||||
SidebarItemBadge,
|
||||
SidebarItemTooltip,
|
||||
} from '../../../components/sidebar';
|
||||
import { useHomeSelected } from '../../../hooks/router/useHomeSelected';
|
||||
import { UnreadBadge } from '../../../components/unread-badge';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
|
||||
import { useHomeRooms } from '../home/useHomeRooms';
|
||||
import { markAsRead } from '../../../utils/notifications';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
const HomeMenu = forwardRef<HTMLDivElement, HomeMenuProps>(({ requestClose }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const orphanRooms = useHomeRooms();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
if (!unread) return;
|
||||
orphanRooms.forEach((rId) => markAsRead(mx, rId, hideActivity));
|
||||
requestClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
onClick={handleMarkAsRead}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
||||
radii="300"
|
||||
aria-disabled={!unread}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Home.mark_as_read')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
);
|
||||
});
|
||||
|
||||
export function HomeTab() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const mx = useMatrixClient();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||
const homeUnread = useRoomsUnread(orphanRooms, roomToUnreadAtom);
|
||||
const homeSelected = useHomeSelected();
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
|
||||
const handleHomeClick = () => {
|
||||
// On native, tab tap is "switch tab", not "stack on top" — replace keeps
|
||||
// the app back-stack (see useAndroidBackButton) reflecting actual content
|
||||
// navigation, not tab toggles. Same in Direct/Inbox/Explore/Space tabs.
|
||||
// On web we keep PUSH so browser-back behaves the way desktop users
|
||||
// expect: back undoes the tab switch.
|
||||
const navOpts = { replace: isNativePlatform() };
|
||||
const activePath = navToActivePath.get('home');
|
||||
if (activePath && screenSize !== ScreenSize.Mobile) {
|
||||
navigate(joinPathComponent(activePath), navOpts);
|
||||
return;
|
||||
}
|
||||
|
||||
navigate(getHomePath(), navOpts);
|
||||
};
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const cords = evt.currentTarget.getBoundingClientRect();
|
||||
setMenuAnchor((currentState) => {
|
||||
if (currentState) return undefined;
|
||||
return cords;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SidebarItem active={homeSelected}>
|
||||
<SidebarItemTooltip tooltip={t('Home.home')}>
|
||||
{(triggerRef) => (
|
||||
<SidebarAvatar
|
||||
as="button"
|
||||
ref={triggerRef}
|
||||
outlined
|
||||
onClick={handleHomeClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Icon src={Icons.Home} filled={homeSelected} />
|
||||
</SidebarAvatar>
|
||||
)}
|
||||
</SidebarItemTooltip>
|
||||
{homeUnread && (
|
||||
<SidebarItemBadge hasCount={homeUnread.total > 0}>
|
||||
<UnreadBadge highlight={homeUnread.highlight > 0} count={homeUnread.total} />
|
||||
</SidebarItemBadge>
|
||||
)}
|
||||
{menuAnchor && (
|
||||
<PopOut
|
||||
anchor={menuAnchor}
|
||||
position="Right"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
onDeactivate: () => setMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<HomeMenu requestClose={() => setMenuAnchor(undefined)} />
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SidebarItem>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,3 @@
|
|||
export * from './HomeTab';
|
||||
export * from './DirectTab';
|
||||
export * from './SpaceTabs';
|
||||
export * from './InboxTab';
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||
import { useSpace } from '../../../hooks/useSpace';
|
||||
|
|
@ -10,16 +11,27 @@ import { getAllParents, getSpaceChildren } from '../../../utils/room';
|
|||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { useIsOneOnOneRoom } from '../../../hooks/useIsOneOnOneRoom';
|
||||
|
||||
// Inner provider hosts the reactive 1:1 subscription. Mirrors
|
||||
// direct/RoomProvider.tsx — the hook can't run before the early-return
|
||||
// guards, so we split the component once we know the room exists.
|
||||
function ResolvedRoomProvider({ room, children }: { room: Room; children: ReactNode }) {
|
||||
const isOneOnOne = useIsOneOnOneRoom(room);
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsOneOnOneProvider value={isOneOnOne}>{children}</IsOneOnOneProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||
const mx = useMatrixClient();
|
||||
const space = useSpace();
|
||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
|
||||
const { roomIdOrAlias, eventId } = useParams();
|
||||
|
|
@ -39,12 +51,10 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
|
||||
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
|
||||
// allow to view space timeline
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
// Allow viewing the space's own timeline. `isOneOnOneRoom` already guards
|
||||
// spaces (it returns false for `isSpace(room)`), so the value is stable
|
||||
// here regardless of member count.
|
||||
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
|
||||
}
|
||||
|
||||
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
||||
|
|
@ -66,9 +76,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RoomProvider key={room.roomId} value={room}>
|
||||
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
||||
</RoomProvider>
|
||||
);
|
||||
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import { useAtomValue } from 'jotai';
|
|||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||
import { MessageSearch } from '../../../features/message-search';
|
||||
import { useSpace } from '../../../hooks/useSpace';
|
||||
import { useRecursiveChildRoomScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
|
||||
import {
|
||||
useRecursiveChildNonSpaceRoomScopeFactory,
|
||||
useSpaceChildren,
|
||||
} from '../../../state/hooks/roomList';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
|
|
@ -20,12 +22,15 @@ export function SpaceSearch() {
|
|||
const space = useSpace();
|
||||
const screenSize = useScreenSizeContext();
|
||||
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
// Post-P3c: child-room search reaches every non-space room descendant,
|
||||
// including `m.direct`-tagged ones. Pre-P3c this filter excluded DMs from
|
||||
// space message search — that filter is interop-only after the universal
|
||||
// Direct collapse, see plan §6.8.
|
||||
const rooms = useSpaceChildren(
|
||||
allRoomsAtom,
|
||||
space.roomId,
|
||||
useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
|
||||
useRecursiveChildNonSpaceRoomScopeFactory(mx, roomToParents)
|
||||
);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -83,6 +83,24 @@ export const useRecursiveChildRoomScopeFactory = (
|
|||
[mx, mDirects, roomToParents]
|
||||
);
|
||||
|
||||
// Universal-Direct-aware child-room scope (P3c). Mirrors
|
||||
// `useRecursiveChildRoomScopeFactory` but drops the `m.direct` exclusion so
|
||||
// space message search can reach `m.direct`-tagged child rooms — after P3c
|
||||
// `m.direct` is interop-only, not a UI classifier (see plan §6.8). Use this
|
||||
// for «search across all non-space rooms reachable from a space root», not
|
||||
// the legacy filter-DMs-out behaviour.
|
||||
export const useRecursiveChildNonSpaceRoomScopeFactory = (
|
||||
mx: MatrixClient,
|
||||
roomToParents: RoomToParents
|
||||
): SpaceChildSelectorFactory =>
|
||||
useCallback(
|
||||
(parentId: string) => (roomId) =>
|
||||
isRoom(mx.getRoom(roomId)) &&
|
||||
roomToParents.has(roomId) &&
|
||||
getAllParents(roomToParents, roomId).has(parentId),
|
||||
[mx, roomToParents]
|
||||
);
|
||||
|
||||
export const useChildDirectScopeFactory = (
|
||||
mx: MatrixClient,
|
||||
mDirects: Set<string>,
|
||||
|
|
@ -147,6 +165,19 @@ export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<s
|
|||
return useSelectedRooms(roomsAtom, selector);
|
||||
};
|
||||
|
||||
// Universal-Direct-aware «every joined non-space room» (P3c). Mirrors
|
||||
// `useRooms` but without the `m.direct` exclusion — used for surfaces that
|
||||
// classify rooms by space-vs-room rather than DM-vs-room. Message search
|
||||
// scope is the canonical consumer: after P3c every joined non-space room
|
||||
// must be reachable from MessageSearch, including `m.direct`-tagged ones.
|
||||
export const useNonSpaceRooms = (mx: MatrixClient, roomsAtom: RoomsAtom) => {
|
||||
const selector: RoomSelector = useCallback(
|
||||
(roomId: string) => isRoom(mx.getRoom(roomId)),
|
||||
[mx]
|
||||
);
|
||||
return useSelectedRooms(roomsAtom, selector);
|
||||
};
|
||||
|
||||
export const useOrphanRooms = (
|
||||
mx: MatrixClient,
|
||||
roomsAtom: RoomsAtom,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,6 @@ export type DateFormat =
|
|||
| 'YYYY/MM/DD'
|
||||
| 'YYYY-MM-DD'
|
||||
| '';
|
||||
export type MessageSpacing = '0' | '100' | '200' | '300' | '400' | '500';
|
||||
export enum MessageLayout {
|
||||
Modern = 0,
|
||||
Compact = 1,
|
||||
Bubble = 2,
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
themeId?: string;
|
||||
|
|
@ -28,15 +22,12 @@ export interface Settings {
|
|||
isPeopleDrawer: boolean;
|
||||
memberSortFilterIndex: number;
|
||||
enterForNewline: boolean;
|
||||
messageLayout: MessageLayout;
|
||||
messageSpacing: MessageSpacing;
|
||||
hideMembershipEvents: boolean;
|
||||
hideNickAvatarEvents: boolean;
|
||||
mediaAutoLoad: boolean;
|
||||
urlPreview: boolean;
|
||||
encUrlPreview: boolean;
|
||||
showHiddenEvents: boolean;
|
||||
legacyUsernameColor: boolean;
|
||||
|
||||
isNotificationSounds: boolean;
|
||||
|
||||
|
|
@ -49,6 +40,7 @@ export interface Settings {
|
|||
}
|
||||
|
||||
const DAWN_MIGRATION_KEY = 'dawn-redesign-v1';
|
||||
const P3C_CLEANUP_KEY = 'dawn-p3c-cleanup';
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
themeId: undefined,
|
||||
|
|
@ -63,15 +55,12 @@ const defaultSettings: Settings = {
|
|||
isPeopleDrawer: true,
|
||||
memberSortFilterIndex: 0,
|
||||
enterForNewline: false,
|
||||
messageLayout: MessageLayout.Bubble,
|
||||
messageSpacing: '400',
|
||||
hideMembershipEvents: false,
|
||||
hideNickAvatarEvents: true,
|
||||
mediaAutoLoad: true,
|
||||
urlPreview: true,
|
||||
encUrlPreview: false,
|
||||
showHiddenEvents: false,
|
||||
legacyUsernameColor: false,
|
||||
|
||||
isNotificationSounds: true,
|
||||
|
||||
|
|
@ -114,6 +103,22 @@ export const getSettings = (): Settings => {
|
|||
setSettings(merged);
|
||||
}
|
||||
|
||||
// P3c cleanup migration: strip orphan persisted fields removed by the
|
||||
// universal Stream collapse (`messageLayout`, `messageSpacing`,
|
||||
// `legacyUsernameColor`). Symmetric to the Dawn migration above — synchronous
|
||||
// sweep of stale keys at first load, stamped so it runs exactly once.
|
||||
if (!merged.migrationsApplied?.[P3C_CLEANUP_KEY]) {
|
||||
const orphan = merged as unknown as Record<string, unknown>;
|
||||
delete orphan.messageLayout;
|
||||
delete orphan.messageSpacing;
|
||||
delete orphan.legacyUsernameColor;
|
||||
merged.migrationsApplied = {
|
||||
...(merged.migrationsApplied ?? {}),
|
||||
[P3C_CLEANUP_KEY]: true,
|
||||
};
|
||||
setSettings(merged);
|
||||
}
|
||||
|
||||
return merged;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -72,29 +72,6 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool
|
|||
return content?.is_direct === true;
|
||||
};
|
||||
|
||||
// Synchronous «is this a DM that should render in the Stream layout?» predicate.
|
||||
// Combines four first-frame-safe sources so every DM gate in the app — render
|
||||
// (`useIsDirectStream`), cold-start push redirect (HomeRouteRoomProvider),
|
||||
// imperative navigation (useRoomNavigate) — agrees on the answer:
|
||||
// 1. Live `mDirectAtom` Set (caller passes it in to keep this a pure helper).
|
||||
// 2. `room.getDMInviter()` for invite-state members with is_direct: true.
|
||||
// 3. `isDirectInvite(room, myUserId)` for current member content is_direct.
|
||||
// 4. `mx.getAccountData('m.direct')` SDK fallback for the first frame after
|
||||
// cold-start when the atom is still being hydrated by useBindMDirectAtom.
|
||||
// See docs/plans/dm_1x1_redesign.md §6.5 / §6.7.
|
||||
export const isDirectStreamRoom = (
|
||||
mx: MatrixClient,
|
||||
room: Room,
|
||||
mDirects: Set<string>
|
||||
): boolean => {
|
||||
if (mDirects.has(room.roomId)) return true;
|
||||
if (room.getDMInviter()) return true;
|
||||
if (isDirectInvite(room, mx.getUserId())) return true;
|
||||
const event = getAccountData(mx, AccountDataEvent.Direct);
|
||||
if (event && getMDirects(event).has(room.roomId)) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isSpace = (room: Room | null): boolean => {
|
||||
if (!room) return false;
|
||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
||||
|
|
@ -116,6 +93,35 @@ export const isUnsupportedRoom = (room: Room | null): boolean => {
|
|||
return event.getContent().type !== undefined && event.getContent().type !== RoomType.Space;
|
||||
};
|
||||
|
||||
// «Strictly two-member non-space room» — Element-Web tier-2 pattern. Drives
|
||||
// Vojo's 1:1-vs-group split after the P3c collapse: peer-avatar fallback in
|
||||
// the room header, DM call button visibility, reply username colour,
|
||||
// membership-row suppression. Server-side authoritative (no race against
|
||||
// `m.direct` account data hydration). Counts both joined AND invited so a
|
||||
// freshly-invited 1:1 classifies correctly before the peer accepts. The
|
||||
// `isSpace` guard prevents the developer-tools «view space timeline» path
|
||||
// (space/RoomProvider.tsx) from accidentally rendering 1:1 chrome on a
|
||||
// two-member space root.
|
||||
export const isOneOnOneRoom = (room: Room): boolean =>
|
||||
!isSpace(room) && room.getInvitedAndJoinedMemberCount() === 2;
|
||||
|
||||
// «Is this a bridged room?» (mautrix-telegram, mautrix-whatsapp, mautrix-signal,
|
||||
// matrix-appservice-irc, etc.) — checks for the canonical MSC2346 `m.bridge`
|
||||
// state event plus the still-deployed unstable `uk.half-shot.bridge` prefix.
|
||||
// Bridges write one of these on every bridged DM/portal so the existence test
|
||||
// is unambiguous. Used to suppress surfaces that have no equivalent on the
|
||||
// bridge side — primarily `DmCallButton`: Telegram has no Matrix-RTC
|
||||
// equivalent, so even a 2-member native-feeling DM must never expose the
|
||||
// call button if the room is actually a bridge puppet. Each `m.bridge` event
|
||||
// is keyed by the bridge bot mxid (state_key); we don't care about the value,
|
||||
// only about presence.
|
||||
export const isBridgedRoom = (room: Room): boolean => {
|
||||
const stable = getStateEvents(room, StateEvent.RoomBridge);
|
||||
if (stable.length > 0) return true;
|
||||
const unstable = getStateEvents(room, StateEvent.RoomBridgeUnstable);
|
||||
return unstable.length > 0;
|
||||
};
|
||||
|
||||
export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
mEvent.getType() === StateEvent.SpaceChild &&
|
||||
|
|
|
|||
|
|
@ -34,6 +34,15 @@ export enum StateEvent {
|
|||
RoomTombstone = 'm.room.tombstone',
|
||||
GroupCallPrefix = 'org.matrix.msc3401.call',
|
||||
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
|
||||
// MSC2346 — bridge metadata. Both stable (`m.bridge`) and unstable
|
||||
// (`uk.half-shot.bridge`) keys exist in the wild; mautrix-* and most
|
||||
// long-running bridges write at least one of them. Used by Vojo to detect
|
||||
// bridged rooms (e.g. mautrix-telegram puppet rooms) so we can suppress
|
||||
// surfaces that don't translate across the bridge — voice calls being the
|
||||
// most obvious example: Telegram has no Matrix-RTC equivalent, so a 1:1
|
||||
// bridged room must never expose `DmCallButton`.
|
||||
RoomBridge = 'm.bridge',
|
||||
RoomBridgeUnstable = 'uk.half-shot.bridge',
|
||||
|
||||
SpaceChild = 'm.space.child',
|
||||
SpaceParent = 'm.space.parent',
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue