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/`)
|
## 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.
|
- **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)
|
- **Client** (`client/`) — main layout after login (wrapped by `ClientLayout` = nav + content)
|
||||||
- `home/` — Home timeline (path: `/home/`, root `/` redirects here)
|
- `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/` — DMs (path: `/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/`)
|
- `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/`)
|
- `explore/` — Public rooms (path: `/explore/`)
|
||||||
- `inbox/` — Notifications, invites (path: `/inbox/`)
|
- `inbox/` — Notifications, invites (path: `/inbox/`)
|
||||||
- `create/` — New room/space (path: `/create/`)
|
- `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.
|
- `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
|
- `SyncStatus.tsx`, `SpecVersions.tsx` — Connection status
|
||||||
|
|
||||||
### DM-specific routing path
|
### Universal Stream routing + DM classification (post-P3c)
|
||||||
|
|
||||||
```
|
```
|
||||||
/direct/:roomIdOrAlias
|
/direct/:roomIdOrAlias
|
||||||
→ PageRoot (nav=Direct, outlet=…)
|
→ 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)
|
→ Room.tsx (RoomViewHeader + RoomView, screen-size branching for MembersDrawer)
|
||||||
→ RoomTimeline + RoomViewTyping + RoomInput
|
→ 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.
|
- 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.
|
||||||
2. `room.getDMInviter()` — matrix-js-sdk SDK helper for invite-state with `is_direct: true` in member content's `prev_content`.
|
- Group rooms (member-count > 2) get the room-style header (no peer fallback), no `DmCallButton`, and respect the `hideMembershipEvents` / `hideNickAvatarEvents` user settings for syslines.
|
||||||
3. `isDirectInvite(room, myUserId)` — reads current member content for `is_direct: true`, covering the just-joined case before `useAutoDirectSync` round-trips.
|
- Bridged Telegram puppet rooms automatically classify correctly because the gate is server-side authoritative.
|
||||||
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()`.
|
|
||||||
|
|
||||||
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/`)
|
## Features (`src/app/features/`)
|
||||||
|
|
||||||
| Dir | Purpose |
|
| 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/` | 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` (~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/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-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 |
|
| `room-settings/` | Room-specific settings page |
|
||||||
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
|
| `common-settings/` | Shared settings: general, members, permissions, emojis-stickers, developer-tools |
|
||||||
| `space-settings/` | Space-specific settings |
|
| `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 |
|
| `lobby/` | Space/room lobby view |
|
||||||
| `search/` | Global search |
|
| `search/` | Global search |
|
||||||
| `message-search/` | In-room message 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/`)
|
## 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/` — 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` — **Vojo-specific**: WhatsApp-style delivery checkmarks (commit 0c4cfb9). Pairs with `hooks/useMessageStatus.ts` (receipt-derivation).
|
- `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.
|
- `editor/` — Slate-based rich text editor. Autocomplete: `RoomMentionAutocomplete`, `UserMentionAutocomplete`, `EmoticonAutocomplete`, `CommandAutocomplete`. `Editor.tsx` is the Slate root — preserve.
|
||||||
- `emoji-board/` — Emoji picker
|
- `emoji-board/` — Emoji picker
|
||||||
- `image-pack-view/` — Custom emoji pack management
|
- `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 |
|
| 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/*` (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 |
|
| `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 |
|
| `state/callEmbed.ts` (`callEmbedAtom`, `callChatAtom`) | call phases | Embed lifecycle |
|
||||||
| `components/message/MessageStatus.tsx` + `hooks/useMessageStatus.ts` | 0c4cfb9 | WhatsApp checkmarks |
|
| `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/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/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) |
|
| `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/`:
|
**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
|
- `sessions.ts` — Active session
|
||||||
- `upload.ts` — Upload progress (in-memory)
|
- `upload.ts` — Upload progress (in-memory)
|
||||||
- `room/` — `roomInputDrafts` (in-memory), `roomToParents`, `roomToUnread`
|
- `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
|
```tsx
|
||||||
const mx = useMatrixClient(); // Get SDK instance
|
const mx = useMatrixClient(); // Get SDK instance
|
||||||
const room = useRoom(); // Current room
|
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 stateEvent = useStateEvent(room, StateEvent.Type); // Room state
|
||||||
const powerLevels = usePowerLevels(room); // Permissions
|
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)
|
- **vanilla-extract** — Type-safe CSS (compile-time → no runtime theme switching without reload)
|
||||||
- **slate 0.123** — Rich text editor
|
- **slate 0.123** — Rich text editor
|
||||||
- **@tanstack/react-query 5** — Data fetching
|
- **@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
|
- **i18next 23 + react-i18next 15** — Localisation
|
||||||
- **Capacitor 8.3** — Native Android wrapper
|
- **Capacitor 8.3** — Native Android wrapper
|
||||||
- **@capacitor/browser 8.0** — External link handling in native
|
- **@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`
|
- Android release / AAB: similar but `assembleRelease` / `bundleRelease`
|
||||||
- VSCode task `Deploy to vojo.chat` (Ctrl+Shift+D) automates web deploy
|
- 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`
|
- 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": "Hide Typing & Read Receipts",
|
||||||
"hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.",
|
"hide_activity_desc": "Turn off both typing status and read receipts to keep your activity private.",
|
||||||
"messages": "Messages",
|
"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_membership": "Hide Membership Change",
|
||||||
"hide_profile": "Hide Profile Change",
|
"hide_profile": "Hide Profile Change",
|
||||||
"disable_media_auto_load": "Disable Media Auto Load",
|
"disable_media_auto_load": "Disable Media Auto Load",
|
||||||
|
|
|
||||||
|
|
@ -128,9 +128,6 @@
|
||||||
"hide_activity": "Скрыть набор текста и уведомления о прочтении",
|
"hide_activity": "Скрыть набор текста и уведомления о прочтении",
|
||||||
"hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.",
|
"hide_activity_desc": "Отключить статус набора и отчёты о прочтении для сохранения приватности.",
|
||||||
"messages": "Сообщения",
|
"messages": "Сообщения",
|
||||||
"message_spacing": "Интервал сообщений",
|
|
||||||
"message_spacing_dm_note": "В личных чатах используется фиксированный интервал, чтобы линия таймлайна оставалась непрерывной.",
|
|
||||||
"legacy_username_color": "Классический цвет имени",
|
|
||||||
"hide_membership": "Скрыть изменения участников",
|
"hide_membership": "Скрыть изменения участников",
|
||||||
"hide_profile": "Скрыть изменения профиля",
|
"hide_profile": "Скрыть изменения профиля",
|
||||||
"disable_media_auto_load": "Отключить автозагрузку медиа",
|
"disable_media_auto_load": "Отключить автозагрузку медиа",
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,18 @@
|
||||||
import { Box, Icon, IconSrc, color } from 'folds';
|
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 classNames from 'classnames';
|
||||||
import { BubbleLayout, CompactLayout, ModernLayout } from '..';
|
|
||||||
import { MessageLayout } from '../../../state/settings';
|
|
||||||
import * as layoutCss from '../layout/layout.css';
|
import * as layoutCss from '../layout/layout.css';
|
||||||
import { useStreamLayoutDebug } from '../layout/streamDebug';
|
import { useStreamLayoutDebug } from '../layout/streamDebug';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
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 = {
|
export type EventContentProps = {
|
||||||
messageLayout: MessageLayout;
|
|
||||||
time: ReactNode;
|
time: ReactNode;
|
||||||
iconSrc: IconSrc;
|
iconSrc: IconSrc;
|
||||||
content: ReactNode;
|
content: ReactNode;
|
||||||
|
railStart?: boolean;
|
||||||
|
railEnd?: boolean;
|
||||||
};
|
};
|
||||||
export function EventContent({ messageLayout, time, iconSrc, content }: EventContentProps) {
|
export function EventContent({ time, iconSrc, content, railStart, railEnd }: EventContentProps) {
|
||||||
const { enabled: isStream, railStart, railEnd } = useContext(IsStreamContext);
|
|
||||||
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
const compact = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const timeRef = useRef<HTMLDivElement>(null);
|
const timeRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -45,12 +29,10 @@ export function EventContent({ messageLayout, time, iconSrc, content }: EventCon
|
||||||
dot: dotRef,
|
dot: dotRef,
|
||||||
content: bodyRef,
|
content: bodyRef,
|
||||||
},
|
},
|
||||||
isStream
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isStream) {
|
// Sysline = thin one-line state-event row that lives ON the rail.
|
||||||
// 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.
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(layoutCss.StreamRoot({ compact }), layoutCss.StreamSysline)}
|
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} />
|
<Icon style={{ opacity: 0.5, flexShrink: 0 }} size="50" src={iconSrc} />
|
||||||
<div className={layoutCss.StreamSyslineBody}>{content}</div>
|
<div className={layoutCss.StreamSyslineBody}>{content}</div>
|
||||||
</Box>
|
</Box>
|
||||||
{/* `messageLayout` is intentionally unused in stream-mode. */}
|
|
||||||
</div>
|
</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 } from 'folds';
|
||||||
import * as css from './layout.css';
|
import * as css from './layout.css';
|
||||||
import { useStreamLayoutDebug } from './streamDebug';
|
import { useStreamLayoutDebug } from './streamDebug';
|
||||||
import type { MessageSpacing } from '../../../state/settings';
|
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
|
||||||
// Stream rows ignore the persisted `messageSpacing` setting and use a fixed
|
// Stream rows use a fixed `S400` gap so the rail-bridge offsets in
|
||||||
// gap so the rail-bridge offsets in layout.css.ts (StreamRailBridgeY = S400)
|
// layout.css.ts (StreamRailBridgeY = S400) always match the gap between rows.
|
||||||
// always match the gap between rows. Settings.message_spacing_dm_note
|
// All three Stream call sites — RoomTimeline.tsx StreamDayDivider wrapper plus
|
||||||
// surfaces this in the UI. Keep all three Stream call sites
|
// Message.tsx Message / Event MessageBase — share this single constant.
|
||||||
// (RoomTimeline.tsx StreamDayDivider wrapper, Message.tsx Message MessageBase,
|
export const STREAM_MESSAGE_SPACING = '400' as const;
|
||||||
// Message.tsx Event MessageBase) on this single constant.
|
|
||||||
export const STREAM_MESSAGE_SPACING: MessageSpacing = '400';
|
|
||||||
|
|
||||||
// Stream layout — DM redesign (docs/plans/dm_1x1_redesign.md §6.5b).
|
// 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);
|
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement);
|
||||||
|
|
||||||
// Debug helper is dev-only and behind a localStorage opt-in (see
|
// Debug helper is dev-only and behind a localStorage opt-in (see
|
||||||
// streamDebug.ts). StreamLayout is only ever mounted when the parent
|
// streamDebug.ts). After P3c every timeline row goes through StreamLayout,
|
||||||
// already decided this row is in a DM stream, so `active` is implicitly
|
// so `active` is unconditionally true here.
|
||||||
// true here — pass it explicitly to mirror the sysline call site
|
|
||||||
// (EventContent.tsx) which threads `isStream` through.
|
|
||||||
useStreamLayoutDebug(
|
useStreamLayoutDebug(
|
||||||
'message',
|
'message',
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
export * from './Modern';
|
export * from './Modern';
|
||||||
export * from './Compact';
|
|
||||||
export * from './Bubble';
|
|
||||||
export * from './Stream';
|
export * from './Stream';
|
||||||
export * from './Base';
|
export * from './Base';
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,6 @@ import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanill
|
||||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const StickySection = style({
|
|
||||||
position: 'sticky',
|
|
||||||
top: config.space.S100,
|
|
||||||
});
|
|
||||||
|
|
||||||
const SpacingVar = createVar();
|
const SpacingVar = createVar();
|
||||||
const SpacingVariant = styleVariants({
|
const SpacingVariant = styleVariants({
|
||||||
'0': {
|
'0': {
|
||||||
|
|
@ -108,15 +103,6 @@ export const MessageBase = recipe({
|
||||||
|
|
||||||
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
|
export type MessageBaseVariants = RecipeVariants<typeof MessageBase>;
|
||||||
|
|
||||||
export const CompactHeader = style([
|
|
||||||
DefaultReset,
|
|
||||||
StickySection,
|
|
||||||
{
|
|
||||||
maxWidth: toRem(170),
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const AvatarBase = style({
|
export const AvatarBase = style({
|
||||||
paddingTop: toRem(4),
|
paddingTop: toRem(4),
|
||||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||||
|
|
@ -134,33 +120,6 @@ export const ModernBefore = style({
|
||||||
minWidth: toRem(36),
|
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({
|
export const Username = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
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 './LinePlaceholder';
|
||||||
export * from './CompactPlaceholder';
|
|
||||||
export * from './DefaultPlaceholder';
|
export * from './DefaultPlaceholder';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useState } from 'react';
|
||||||
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
|
import { Avatar, Box, Button, Spinner, Text, as } from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
import { IRoomCreateContent, Membership, StateEvent } from '../../../types/matrix/room';
|
||||||
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||||
|
|
@ -13,7 +12,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { RoomAvatar } from '../room-avatar';
|
import { RoomAvatar } from '../room-avatar';
|
||||||
import { nameInitials } from '../../utils/common';
|
import { nameInitials } from '../../utils/common';
|
||||||
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
@ -28,11 +27,15 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
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 [invitePrompt, setInvitePrompt] = useState(false);
|
||||||
|
|
||||||
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
const createEvent = getStateEvent(room, StateEvent.RoomCreate);
|
||||||
const avatarMxc = useRoomAvatar(room, mDirects.has(room.roomId));
|
const avatarMxc = useRoomAvatar(room, isOneOnOne);
|
||||||
const name = useRoomName(room);
|
const name = useRoomName(room);
|
||||||
const topic = useRoomTopic(room);
|
const topic = useRoomTopic(room);
|
||||||
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
const avatarHttpUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
|
@ -30,7 +31,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||||
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
import { useMutualRooms, useMutualRoomsSupport } from '../../hooks/useMutualRooms';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useDirectRooms } from '../../pages/client/direct/useDirectRooms';
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { openExternalUrl } from '../../utils/capacitor';
|
import { openExternalUrl } from '../../utils/capacitor';
|
||||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
|
|
@ -237,7 +238,10 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||||
const mutualRoomsState = useMutualRooms(userId);
|
const mutualRoomsState = useMutualRooms(userId);
|
||||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
const closeUserRoomProfile = useCloseUserRoomProfile();
|
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 useAuthentication = useMediaAuthentication();
|
||||||
|
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
|
|
@ -268,7 +272,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||||
data.spaces.push(room);
|
data.spaces.push(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (directs.includes(room.roomId)) {
|
if (mDirects.has(room.roomId)) {
|
||||||
data.directs.push(room);
|
data.directs.push(room);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -276,7 +280,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
}, [mutualRoomsState, getRoom, directs, mx]);
|
}, [mutualRoomsState, getRoom, mDirects, mx]);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
userId === mx.getSafeUserId() ||
|
userId === mx.getSafeUserId() ||
|
||||||
|
|
@ -288,7 +292,7 @@ export function MutualRoomsChip({ userId }: { userId: string }) {
|
||||||
|
|
||||||
const renderItem = (room: Room) => {
|
const renderItem = (room: Room) => {
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
const dm = directs.includes(roomId);
|
const dm = mDirects.has(roomId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
|
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 { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEmbed';
|
||||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||||
|
|
||||||
|
|
@ -14,11 +14,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
const callJoined = useCallJoined(callEmbed);
|
||||||
const direct = useIsDirectRoom();
|
const isOneOnOne = useIsOneOnOne();
|
||||||
|
|
||||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||||
|
|
||||||
const startCall = useCallStart(direct);
|
const startCall = useCallStart(isOneOnOne);
|
||||||
const joining = callEmbed?.roomId === room.roomId && !callJoined;
|
const joining = callEmbed?.roomId === room.roomId && !callJoined;
|
||||||
|
|
||||||
const disabled = inOtherCall || !canJoin;
|
const disabled = inOtherCall || !canJoin;
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
TextArea,
|
TextArea,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
import React, { FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
|
@ -26,7 +25,7 @@ import {
|
||||||
useRoomName,
|
useRoomName,
|
||||||
useRoomTopic,
|
useRoomTopic,
|
||||||
} from '../../../hooks/useRoomMeta';
|
} from '../../../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { isOneOnOneRoom } from '../../../utils/room';
|
||||||
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
import { BreakWord, LineClamp3 } from '../../../styles/Text.css';
|
||||||
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
||||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||||
|
|
@ -270,9 +269,12 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = useRoom();
|
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 name = useRoomName(room);
|
||||||
const topic = useRoomTopic(room);
|
const topic = useRoomTopic(room);
|
||||||
const joinRule = useRoomJoinRule(room);
|
const joinRule = useRoomJoinRule(room);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
|
import React, { RefObject, useEffect, useMemo, useRef } from 'react';
|
||||||
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
|
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem } from 'folds';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
@ -16,9 +15,9 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../components/scroll-top-container';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../pages/pathUtils';
|
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 { allRoomsAtom } from '../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../state/mDirectList';
|
import { isOneOnOneRoom } from '../../utils/room';
|
||||||
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
|
||||||
import { SearchResultGroup } from './SearchResultGroup';
|
import { SearchResultGroup } from './SearchResultGroup';
|
||||||
import { SearchInput } from './SearchInput';
|
import { SearchInput } from './SearchInput';
|
||||||
|
|
@ -53,11 +52,13 @@ export function MessageSearch({
|
||||||
}: MessageSearchProps) {
|
}: MessageSearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
// Post-P3c: search scope is «every joined non-space room», not «non-DM
|
||||||
const allRooms = useRooms(mx, allRoomsAtom, mDirects);
|
// 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 [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
|
||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
@ -297,7 +298,7 @@ export function MessageSearch({
|
||||||
mediaAutoLoad={mediaAutoLoad}
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
urlPreview={urlPreview}
|
urlPreview={urlPreview}
|
||||||
onOpen={navigateRoom}
|
onOpen={navigateRoom}
|
||||||
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
|
legacyUsernameColor={isOneOnOneRoom(groupRoom)}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||||
import { RoomAvatar } from '../../components/room-avatar';
|
import { RoomAvatar } from '../../components/room-avatar';
|
||||||
import { getDirectRoomAvatarUrl } from '../../utils/room';
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, isOneOnOneRoom } from '../../utils/room';
|
||||||
import { nameInitials } from '../../utils/common';
|
import { nameInitials } from '../../utils/common';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
|
|
@ -367,7 +367,18 @@ export function DmStreamRow({ room, selected, notificationMode, linkPath }: DmSt
|
||||||
<Avatar size="300" radii="400">
|
<Avatar size="300" radii="400">
|
||||||
<RoomAvatar
|
<RoomAvatar
|
||||||
roomId={room.roomId}
|
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}
|
alt={roomName}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<Text as="span" size="H6">
|
<Text as="span" size="H6">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JoinRule } from 'matrix-js-sdk';
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
|
|
@ -9,7 +8,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
|
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 { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
import { Members } from '../common-settings/members';
|
import { Members } from '../common-settings/members';
|
||||||
|
|
@ -67,9 +66,11 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 roomName = useRoomName(room);
|
||||||
const joinRuleContent = useRoomJoinRule(room);
|
const joinRuleContent = useRoomJoinRule(room);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -111,7 +111,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
|
|
@ -133,8 +133,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
const isOneOnOne = useIsOneOnOne();
|
||||||
const direct = useIsDirectRoom();
|
|
||||||
const commands = useCommands(mx, room);
|
const commands = useCommands(mx, room);
|
||||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
@ -159,8 +158,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const replyPowerColor = replyPowerTag?.color
|
const replyPowerColor = replyPowerTag?.color
|
||||||
? accessibleTagColors.get(replyPowerTag.color)
|
? accessibleTagColors.get(replyPowerTag.color)
|
||||||
: undefined;
|
: undefined;
|
||||||
const replyUsernameColor =
|
const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||||
legacyUsernameColor || direct ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
|
||||||
|
|
||||||
const [uploadBoard, setUploadBoard] = useState(true);
|
const [uploadBoard, setUploadBoard] = useState(true);
|
||||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(roomId));
|
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 to from 'await-to-js';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
Badge,
|
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
ContainerColor,
|
|
||||||
Icon,
|
Icon,
|
||||||
Icons,
|
Icons,
|
||||||
Line,
|
|
||||||
Scroll,
|
Scroll,
|
||||||
Text,
|
Text,
|
||||||
as,
|
as,
|
||||||
color,
|
|
||||||
config,
|
config,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
|
|
@ -55,7 +51,6 @@ import { useAlive } from '../../hooks/useAlive';
|
||||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
|
||||||
Reply,
|
Reply,
|
||||||
MessageBase,
|
MessageBase,
|
||||||
MessageUnsupportedContent,
|
MessageUnsupportedContent,
|
||||||
|
|
@ -87,7 +82,7 @@ import {
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||||
import { Reactions, Message, Event, EncryptedContent } from './message';
|
import { Reactions, Message, Event, EncryptedContent } from './message';
|
||||||
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
|
||||||
|
|
@ -120,8 +115,7 @@ import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||||
import { useIsDirectStream } from '../../hooks/useIsDirectStream';
|
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
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 =>
|
export const getLiveTimeline = (room: Room): EventTimeline =>
|
||||||
room.getUnfilteredTimelineSet().getLiveTimeline();
|
room.getUnfilteredTimelineSet().getLiveTimeline();
|
||||||
|
|
||||||
|
|
@ -438,15 +422,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
|
// After P3c every room renders Stream. The DM-vs-group split that drove the
|
||||||
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
// membership-sysline gate flips to a 1:1 vs N>2 check via `useIsOneOnOne`,
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
// not the persisted `m.direct` flag — see plan §6.8.
|
||||||
const direct = useIsDirectRoom();
|
const isOneOnOne = useIsOneOnOne();
|
||||||
// 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);
|
|
||||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
|
|
@ -617,12 +596,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
useCallback(
|
useCallback(
|
||||||
(mEvt: MatrixEvent) => {
|
(mEvt: MatrixEvent) => {
|
||||||
// «Sending while scrolled up jumps the timeline to live» — Telegram /
|
// «Sending while scrolled up jumps the timeline to live» — Telegram /
|
||||||
// WhatsApp / Slack pattern. Introduced for the DM Stream redesign so
|
// WhatsApp / Slack pattern. After P3c every room renders Stream, so
|
||||||
// own messages always land in view. Scoped to Stream rooms (DMs)
|
// the own-message follow-to-bottom guard is universal.
|
||||||
// intentionally — channels / Spaces keep the legacy «pin scroll on
|
|
||||||
// history» behaviour until a separate plan tackles them.
|
|
||||||
const isOwnLiveStreamMessage =
|
const isOwnLiveStreamMessage =
|
||||||
isStream &&
|
|
||||||
mEvt.getSender() === mx.getUserId() &&
|
mEvt.getSender() === mx.getUserId() &&
|
||||||
!reactionOrEditEvent(mEvt) &&
|
!reactionOrEditEvent(mEvt) &&
|
||||||
(mEvt.getType() === MessageEvent.RoomMessage ||
|
(mEvt.getType() === MessageEvent.RoomMessage ||
|
||||||
|
|
@ -675,7 +651,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
setUnreadInfo(getRoomUnreadInfo(room));
|
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}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
messageLayout={messageLayout}
|
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
edit={editId === mEventId}
|
edit={editId === mEventId}
|
||||||
|
|
@ -1138,7 +1112,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={isOneOnOne}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1158,12 +1132,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
memberPowerTag={getMemberPowerTag(senderId)}
|
memberPowerTag={getMemberPowerTag(senderId)}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={isOneOnOne}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
streamRailStart={streamRailStart}
|
streamRailStart={streamRailStart}
|
||||||
streamRailEnd={streamRailEnd}
|
streamRailEnd={streamRailEnd}
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1178,7 +1151,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
urlPreview={showUrlPreview}
|
urlPreview={showUrlPreview}
|
||||||
htmlReactParserOptions={htmlReactParserOptions}
|
htmlReactParserOptions={htmlReactParserOptions}
|
||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Message>
|
</Message>
|
||||||
|
|
@ -1218,8 +1191,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
data-message-id={mEventId}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
messageLayout={messageLayout}
|
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
edit={editId === mEventId}
|
edit={editId === mEventId}
|
||||||
|
|
@ -1243,7 +1214,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
onClick={handleOpenReply}
|
onClick={handleOpenReply}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={isOneOnOne}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -1263,12 +1234,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={isOneOnOne}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
streamRailStart={streamRailStart}
|
streamRailStart={streamRailStart}
|
||||||
streamRailEnd={streamRailEnd}
|
streamRailEnd={streamRailEnd}
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
if (mEvent.isRedacted()) return <RedactedContent />;
|
if (mEvent.isRedacted()) return <RedactedContent />;
|
||||||
|
|
@ -1308,7 +1278,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
urlPreview={showUrlPreview}
|
urlPreview={showUrlPreview}
|
||||||
htmlReactParserOptions={htmlReactParserOptions}
|
htmlReactParserOptions={htmlReactParserOptions}
|
||||||
linkifyOpts={linkifyOpts}
|
linkifyOpts={linkifyOpts}
|
||||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
outlineAttachment
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1351,8 +1321,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
data-message-id={mEventId}
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
messageLayout={messageLayout}
|
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
|
|
@ -1380,12 +1348,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
memberPowerTag={getMemberPowerTag(mEvent.getSender() ?? '')}
|
||||||
accessibleTagColors={accessiblePowerTagColors}
|
accessibleTagColors={accessiblePowerTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={isOneOnOne}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
streamRailStart={streamRailStart}
|
streamRailStart={streamRailStart}
|
||||||
streamRailEnd={streamRailEnd}
|
streamRailEnd={streamRailEnd}
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
{mEvent.isRedacted() ? (
|
{mEvent.isRedacted() ? (
|
||||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||||
|
|
@ -1414,6 +1381,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
streamRailStart,
|
streamRailStart,
|
||||||
streamRailEnd
|
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);
|
const membershipChanged = isMembershipChanged(mEvent);
|
||||||
if (membershipChanged && hideMembershipEvents) return null;
|
if (membershipChanged && hideMembershipEvents) return null;
|
||||||
if (!membershipChanged && hideNickAvatarEvents) 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 highlighted = focusItem?.index === item && focusItem.highlight;
|
||||||
const parsed = parseMemberEvent(mEvent);
|
const parsed = parseMemberEvent(mEvent);
|
||||||
const iconSrc =
|
const iconSrc =
|
||||||
isStream && parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon;
|
parsed.icon === Icons.ArrowGoRightPlus ? Icons.ArrowGoRight : parsed.icon;
|
||||||
|
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1440,17 +1410,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={iconSrc}
|
iconSrc={iconSrc}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1479,7 +1446,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1493,17 +1460,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={Icons.Hash}
|
iconSrc={Icons.Hash}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1533,7 +1497,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1547,17 +1511,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={Icons.Hash}
|
iconSrc={Icons.Hash}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1587,7 +1548,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1601,17 +1562,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={Icons.Hash}
|
iconSrc={Icons.Hash}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1649,7 +1607,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1663,17 +1621,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
iconSrc={callJoined ? Icons.Phone : Icons.PhoneDown}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1700,7 +1655,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1714,17 +1669,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={Icons.Code}
|
iconSrc={Icons.Code}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
|
|
@ -1753,7 +1705,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const timeJSX = (
|
const timeJSX = (
|
||||||
<Time
|
<Time
|
||||||
ts={mEvent.getTs()}
|
ts={mEvent.getTs()}
|
||||||
compact={isStream || messageLayout === MessageLayout.Compact}
|
compact
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
@ -1767,17 +1719,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
messageSpacing={messageSpacing}
|
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
hideReadReceipts={hideActivity}
|
hideReadReceipts={hideActivity}
|
||||||
showDeveloperTools={showDeveloperTools}
|
showDeveloperTools={showDeveloperTools}
|
||||||
streamRailStart={streamRailStart}
|
|
||||||
streamRailEnd={streamRailEnd}
|
|
||||||
isStream={isStream}
|
|
||||||
>
|
>
|
||||||
<EventContent
|
<EventContent
|
||||||
messageLayout={messageLayout}
|
|
||||||
time={timeJSX}
|
time={timeJSX}
|
||||||
|
railStart={streamRailStart}
|
||||||
|
railEnd={streamRailEnd}
|
||||||
iconSrc={Icons.Code}
|
iconSrc={Icons.Code}
|
||||||
content={
|
content={
|
||||||
<Box grow="Yes" direction="Column">
|
<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 === 'org.matrix.msc4310.rtc.decline') return false;
|
||||||
|
|
||||||
if (eventType === StateEvent.RoomMember) {
|
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);
|
const membershipChanged = isMembershipChanged(event);
|
||||||
if (membershipChanged && hideMembershipEvents) return false;
|
if (membershipChanged && hideMembershipEvents) return false;
|
||||||
if (!membershipChanged && hideNickAvatarEvents) 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: streamRenderableItemHasBefore, after: streamRenderableItemHasAfter } = (() => {
|
||||||
const before = new Map<number, boolean>();
|
const before = new Map<number, boolean>();
|
||||||
const after = new Map<number, boolean>();
|
const after = new Map<number, boolean>();
|
||||||
if (!isStream) return { before, after };
|
|
||||||
|
|
||||||
const items = getItems();
|
const items = getItems();
|
||||||
const renderableFlags = items.map((item) => {
|
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
|
// back-pagination is possible — otherwise the «origin» dot would be a
|
||||||
// lie about an earlier untouched history.
|
// lie about an earlier untouched history.
|
||||||
const streamRailStart =
|
const streamRailStart =
|
||||||
isStream &&
|
rangeAtStart && !canPaginateBack && streamRenderableItemHasBefore.get(item) === false;
|
||||||
rangeAtStart &&
|
|
||||||
!canPaginateBack &&
|
|
||||||
streamRenderableItemHasBefore.get(item) === false;
|
|
||||||
const streamRailEnd =
|
const streamRailEnd =
|
||||||
isStream &&
|
liveTimelineLinked && rangeAtEnd && streamRenderableItemHasAfter.get(item) !== true;
|
||||||
liveTimelineLinked &&
|
|
||||||
rangeAtEnd &&
|
|
||||||
streamRenderableItemHasAfter.get(item) !== true;
|
|
||||||
|
|
||||||
const eventJSX = reactionOrEditEvent(mEvent)
|
const eventJSX = reactionOrEditEvent(mEvent)
|
||||||
? null
|
? null
|
||||||
|
|
@ -1970,19 +1915,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
prevEvent = mEvent;
|
prevEvent = mEvent;
|
||||||
isPrevRendered = !!eventJSX;
|
isPrevRendered = !!eventJSX;
|
||||||
|
|
||||||
const newDividerJSX =
|
if (newDivider && eventJSX) {
|
||||||
!isStream && newDivider && eventJSX && eventSender !== mx.getUserId() ? (
|
// TODO(P3c-followup): replace the legacy full-width unread divider with
|
||||||
<MessageBase space={messageSpacing}>
|
// a Stream-native first-unread affordance — dot ring/pulse/brightness on
|
||||||
<TimelineDivider style={{ color: color.Success.Main }} variant="Inherit">
|
// the rail. The «Jump to unread» chip stays functional in the meantime.
|
||||||
<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.
|
|
||||||
newDivider = false;
|
newDivider = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1992,34 +1928,18 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
return timeDayMonthYear(mEvent.getTs());
|
return timeDayMonthYear(mEvent.getTs());
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const renderDayDivider = () => {
|
const renderDayDivider = () => (
|
||||||
if (isStream) {
|
|
||||||
return (
|
|
||||||
<MessageBase space={STREAM_MESSAGE_SPACING}>
|
<MessageBase space={STREAM_MESSAGE_SPACING}>
|
||||||
<StreamDayDivider label={dayLabel} />
|
<StreamDayDivider label={dayLabel} />
|
||||||
</MessageBase>
|
</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;
|
const dayDividerJSX = dayDivider && eventJSX ? renderDayDivider() : null;
|
||||||
|
|
||||||
if (eventJSX && (newDividerJSX || dayDividerJSX)) {
|
if (eventJSX && dayDividerJSX) {
|
||||||
if (newDividerJSX) newDivider = false;
|
dayDivider = false;
|
||||||
if (dayDividerJSX) dayDivider = false;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={mEventId}>
|
<React.Fragment key={mEventId}>
|
||||||
{newDividerJSX}
|
|
||||||
{dayDividerJSX}
|
{dayDividerJSX}
|
||||||
{eventJSX}
|
{eventJSX}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
@ -2063,34 +1983,15 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
{!canPaginateBack && rangeAtStart && getItems().length > 0 && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${
|
padding: `${config.space.S700} ${config.space.S400} ${config.space.S600} ${toRem(
|
||||||
messageLayout === MessageLayout.Compact ? config.space.S400 : toRem(64)
|
64
|
||||||
}`,
|
)}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RoomIntro room={room} />
|
<RoomIntro room={room} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(canPaginateBack || !rangeAtStart) &&
|
{(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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<MessageBase>
|
<MessageBase>
|
||||||
<DefaultPlaceholder key={getItems().length} />
|
<DefaultPlaceholder key={getItems().length} />
|
||||||
|
|
@ -2102,30 +2003,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<DefaultPlaceholder key={getItems().length} />
|
<DefaultPlaceholder key={getItems().length} />
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
|
|
||||||
{getItems().map(eventRenderer)}
|
{getItems().map(eventRenderer)}
|
||||||
|
|
||||||
{(!liveTimelineLinked || !rangeAtEnd) &&
|
{(!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>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
<>
|
||||||
<MessageBase ref={observeFrontAnchor}>
|
<MessageBase ref={observeFrontAnchor}>
|
||||||
<DefaultPlaceholder key={getItems().length} />
|
<DefaultPlaceholder key={getItems().length} />
|
||||||
|
|
@ -2137,7 +2019,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<DefaultPlaceholder key={getItems().length} />
|
<DefaultPlaceholder key={getItems().length} />
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
</>
|
</>
|
||||||
))}
|
)}
|
||||||
<span ref={atBottomAnchorRef} />
|
<span ref={atBottomAnchorRef} />
|
||||||
</Box>
|
</Box>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|
|
||||||
|
|
@ -27,13 +27,15 @@ import { useNavigate } from 'react-router-dom';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { mDirectAtom } from '../../state/mDirectList';
|
||||||
|
import { isBridgedRoom } from '../../utils/room';
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
|
|
@ -324,12 +326,28 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [pinMenuAnchor, setPinMenuAnchor] = 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 pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||||
const encryptedRoom = !!encryptionEvent;
|
const encryptedRoom = !!encryptionEvent;
|
||||||
const avatarMxc = useRoomAvatar(room, direct);
|
const avatarMxc = useRoomAvatar(room, isOneOnOne);
|
||||||
const name = useRoomName(room);
|
const name = useRoomName(room);
|
||||||
const topic = useRoomTopic(room);
|
const topic = useRoomTopic(room);
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
|
|
@ -441,8 +459,14 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
{direct && <DmCallButton room={room} />}
|
{callButtonVisible && <DmCallButton room={room} />}
|
||||||
{!encryptedRoom && (
|
{/* 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
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -38,27 +37,15 @@ import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import {
|
import {
|
||||||
AvatarBase,
|
|
||||||
BubbleLayout,
|
|
||||||
CompactLayout,
|
|
||||||
IsStreamProvider,
|
|
||||||
MessageBase,
|
MessageBase,
|
||||||
MessageStatus,
|
|
||||||
ModernLayout,
|
|
||||||
STREAM_MESSAGE_SPACING,
|
STREAM_MESSAGE_SPACING,
|
||||||
StreamLayout,
|
StreamLayout,
|
||||||
Time,
|
Time,
|
||||||
Username,
|
Username,
|
||||||
UsernameBold,
|
UsernameBold,
|
||||||
} from '../../../components/message';
|
} from '../../../components/message';
|
||||||
import {
|
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
|
||||||
canEditEvent,
|
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
getEventEdits,
|
|
||||||
getMemberAvatarMxc,
|
|
||||||
getMemberDisplayName,
|
|
||||||
} from '../../../utils/room';
|
|
||||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import { useDotColor } from '../../../hooks/useDotColor';
|
import { useDotColor } from '../../../hooks/useDotColor';
|
||||||
|
|
@ -70,17 +57,13 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { ReactionViewer } from '../reaction-viewer';
|
import { ReactionViewer } from '../reaction-viewer';
|
||||||
import { MessageEditor } from './MessageEditor';
|
import { MessageEditor } from './MessageEditor';
|
||||||
import { UserAvatar } from '../../../components/user-avatar';
|
|
||||||
import { copyToClipboard } from '../../../utils/dom';
|
import { copyToClipboard } from '../../../utils/dom';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
||||||
import { getViaServers } from '../../../plugins/via-servers';
|
import { getViaServers } from '../../../plugins/via-servers';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
|
||||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||||
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
import { MemberPowerTag, StateEvent } from '../../../../types/matrix/room';
|
||||||
import { PowerIcon } from '../../../components/power';
|
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
|
||||||
|
|
||||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||||
|
|
||||||
|
|
@ -670,8 +653,6 @@ export type MessageProps = {
|
||||||
canPinEvent?: boolean;
|
canPinEvent?: boolean;
|
||||||
imagePackRooms?: Room[];
|
imagePackRooms?: Room[];
|
||||||
relations?: Relations;
|
relations?: Relations;
|
||||||
messageLayout: MessageLayout;
|
|
||||||
messageSpacing: MessageSpacing;
|
|
||||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onReplyClick: (
|
onReplyClick: (
|
||||||
|
|
@ -691,7 +672,6 @@ export type MessageProps = {
|
||||||
dateFormatString: string;
|
dateFormatString: string;
|
||||||
streamRailStart?: boolean;
|
streamRailStart?: boolean;
|
||||||
streamRailEnd?: boolean;
|
streamRailEnd?: boolean;
|
||||||
isStream?: boolean;
|
|
||||||
};
|
};
|
||||||
export const Message = as<'div', MessageProps>(
|
export const Message = as<'div', MessageProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -707,8 +687,6 @@ export const Message = as<'div', MessageProps>(
|
||||||
canPinEvent,
|
canPinEvent,
|
||||||
imagePackRooms,
|
imagePackRooms,
|
||||||
relations,
|
relations,
|
||||||
messageLayout,
|
|
||||||
messageSpacing,
|
|
||||||
onUserClick,
|
onUserClick,
|
||||||
onUsernameClick,
|
onUsernameClick,
|
||||||
onReplyClick,
|
onReplyClick,
|
||||||
|
|
@ -725,7 +703,6 @@ export const Message = as<'div', MessageProps>(
|
||||||
dateFormatString,
|
dateFormatString,
|
||||||
streamRailStart,
|
streamRailStart,
|
||||||
streamRailEnd,
|
streamRailEnd,
|
||||||
isStream = false,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -733,7 +710,6 @@ export const Message = as<'div', MessageProps>(
|
||||||
) => {
|
) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
|
|
@ -745,111 +721,16 @@ export const Message = as<'div', MessageProps>(
|
||||||
const isOwnMessage = senderId === mx.getUserId();
|
const isOwnMessage = senderId === mx.getUserId();
|
||||||
const senderDisplayName =
|
const senderDisplayName =
|
||||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
|
||||||
|
|
||||||
const tagColor = memberPowerTag?.color
|
const tagColor = memberPowerTag?.color
|
||||||
? accessibleTagColors?.get(memberPowerTag.color)
|
? accessibleTagColors?.get(memberPowerTag.color)
|
||||||
: undefined;
|
: undefined;
|
||||||
const tagIconSrc = memberPowerTag?.icon
|
|
||||||
? getPowerTagIconSrc(mx, useAuthentication, memberPowerTag.icon)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
const usernameColor = legacyUsernameColor ? colorMXID(senderId) : tagColor;
|
||||||
|
|
||||||
const isBubble = messageLayout === MessageLayout.Bubble;
|
const dot = useDotColor(room, mEvent, true, hideReadReceipts);
|
||||||
const dot = useDotColor(room, mEvent, isStream, hideReadReceipts);
|
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const isMobile = screenSize === ScreenSize.Mobile;
|
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 = (
|
const msgContentJSX = (
|
||||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||||
|
|
@ -914,11 +795,9 @@ export const Message = as<'div', MessageProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className, {
|
className={classNames(css.MessageBase, className)}
|
||||||
[css.MessageBaseBubbleCollapsed]: messageLayout === MessageLayout.Bubble && collapse,
|
|
||||||
})}
|
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
|
space={STREAM_MESSAGE_SPACING}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
selected={!!menuAnchor || !!emojiBoardAnchor}
|
selected={!!menuAnchor || !!emojiBoardAnchor}
|
||||||
|
|
@ -1167,7 +1046,6 @@ export const Message = as<'div', MessageProps>(
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isStream ? (
|
|
||||||
<StreamLayout
|
<StreamLayout
|
||||||
time={
|
time={
|
||||||
<Time
|
<Time
|
||||||
|
|
@ -1204,39 +1082,6 @@ export const Message = as<'div', MessageProps>(
|
||||||
>
|
>
|
||||||
{msgContentJSX}
|
{msgContentJSX}
|
||||||
</StreamLayout>
|
</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>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1247,12 +1092,8 @@ export type EventProps = {
|
||||||
mEvent: MatrixEvent;
|
mEvent: MatrixEvent;
|
||||||
highlight: boolean;
|
highlight: boolean;
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
messageSpacing: MessageSpacing;
|
|
||||||
hideReadReceipts?: boolean;
|
hideReadReceipts?: boolean;
|
||||||
showDeveloperTools?: boolean;
|
showDeveloperTools?: boolean;
|
||||||
streamRailStart?: boolean;
|
|
||||||
streamRailEnd?: boolean;
|
|
||||||
isStream?: boolean;
|
|
||||||
};
|
};
|
||||||
export const Event = as<'div', EventProps>(
|
export const Event = as<'div', EventProps>(
|
||||||
(
|
(
|
||||||
|
|
@ -1262,12 +1103,8 @@ export const Event = as<'div', EventProps>(
|
||||||
mEvent,
|
mEvent,
|
||||||
highlight,
|
highlight,
|
||||||
canDelete,
|
canDelete,
|
||||||
messageSpacing,
|
|
||||||
hideReadReceipts,
|
hideReadReceipts,
|
||||||
showDeveloperTools,
|
showDeveloperTools,
|
||||||
streamRailStart,
|
|
||||||
streamRailEnd,
|
|
||||||
isStream = false,
|
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
},
|
},
|
||||||
|
|
@ -1306,7 +1143,7 @@ export const Event = as<'div', EventProps>(
|
||||||
<MessageBase
|
<MessageBase
|
||||||
className={classNames(css.MessageBase, className)}
|
className={classNames(css.MessageBase, className)}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
space={isStream ? STREAM_MESSAGE_SPACING : messageSpacing}
|
space={STREAM_MESSAGE_SPACING}
|
||||||
autoCollapse
|
autoCollapse
|
||||||
highlight={highlight}
|
highlight={highlight}
|
||||||
selected={!!menuAnchor}
|
selected={!!menuAnchor}
|
||||||
|
|
@ -1393,11 +1230,7 @@ export const Event = as<'div', EventProps>(
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<IsStreamProvider
|
|
||||||
value={{ enabled: isStream, railStart: streamRailStart, railEnd: streamRailEnd }}
|
|
||||||
>
|
|
||||||
<div onContextMenu={handleContextMenu}>{children}</div>
|
<div onContextMenu={handleContextMenu}>{children}</div>
|
||||||
</IsStreamProvider>
|
|
||||||
</MessageBase>
|
</MessageBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@ import { DefaultReset, config, toRem } from 'folds';
|
||||||
export const MessageBase = style({
|
export const MessageBase = style({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
});
|
});
|
||||||
export const MessageBaseBubbleCollapsed = style({
|
|
||||||
paddingTop: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const MessageOptionsBase = style([
|
export const MessageOptionsBase = style([
|
||||||
DefaultReset,
|
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({
|
export const MessageQuickReaction = style({
|
||||||
minWidth: toRem(32),
|
minWidth: toRem(32),
|
||||||
});
|
});
|
||||||
|
|
@ -55,11 +44,3 @@ export const ReactionsContainer = style({
|
||||||
export const ReactionsTooltipText = style({
|
export const ReactionsTooltipText = style({
|
||||||
wordBreak: 'break-word',
|
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 { useTheme } from '../../../hooks/useTheme';
|
||||||
import { PowerIcon } from '../../../components/power';
|
import { PowerIcon } from '../../../components/power';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
import { useIsDirectRoom } from '../../../hooks/useRoom';
|
import { useIsOneOnOne } from '../../../hooks/useRoom';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
import {
|
import {
|
||||||
|
|
@ -277,8 +277,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
|
|
||||||
const direct = useIsDirectRoom();
|
const isOneOnOne = useIsOneOnOne();
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
|
||||||
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
|
|
@ -499,7 +498,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
getMemberPowerTag={getMemberPowerTag}
|
getMemberPowerTag={getMemberPowerTag}
|
||||||
accessibleTagColors={accessibleTagColors}
|
accessibleTagColors={accessibleTagColors}
|
||||||
legacyUsernameColor={legacyUsernameColor || direct}
|
legacyUsernameColor={isOneOnOne}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -31,12 +31,11 @@ import FocusTrap from 'focus-trap-react';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
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 { SettingTile } from '../../../components/setting-tile';
|
||||||
import { KeySymbol } from '../../../utils/key-symbol';
|
import { KeySymbol } from '../../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../../utils/user-agent';
|
import { isMacOS } from '../../../utils/user-agent';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
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() {
|
function Messages() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [legacyUsernameColor, setLegacyUsernameColor] = useSetting(
|
|
||||||
settingsAtom,
|
|
||||||
'legacyUsernameColor'
|
|
||||||
);
|
|
||||||
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
const [hideMembershipEvents, setHideMembershipEvents] = useSetting(
|
||||||
settingsAtom,
|
settingsAtom,
|
||||||
'hideMembershipEvents'
|
'hideMembershipEvents'
|
||||||
|
|
@ -624,25 +550,6 @@ function Messages() {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">{t('Settings.messages')}</Text>
|
<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">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title={t('Settings.hide_membership')}
|
title={t('Settings.hide_membership')}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
import { Avatar, Box, config, Icon, IconButton, Icons, IconSrc, MenuItem, Text } from 'folds';
|
||||||
import { JoinRule } from 'matrix-js-sdk';
|
import { JoinRule } from 'matrix-js-sdk';
|
||||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||||
|
|
@ -8,7 +7,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
|
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 { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
|
|
@ -64,9 +63,10 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
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 roomName = useRoomName(room);
|
||||||
const joinRuleContent = useRoomJoinRule(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;
|
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 = () => {
|
export const useIsOneOnOne = () => useContext(IsOneOnOneContext);
|
||||||
const direct = useContext(IsDirectRoomContext);
|
|
||||||
|
|
||||||
return direct;
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,10 @@ import { useCallback, useRef } from 'react';
|
||||||
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
import { NavigateOptions, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||||
import {
|
import { getDirectRoomPath, getSpacePath, getSpaceRoomPath } from '../pages/pathUtils';
|
||||||
getDirectRoomPath,
|
|
||||||
getHomeRoomPath,
|
|
||||||
getSpacePath,
|
|
||||||
getSpaceRoomPath,
|
|
||||||
} from '../pages/pathUtils';
|
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
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 { roomToParentsAtom } from '../state/room/roomToParents';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
|
||||||
import { useSelectedSpace } from './router/useSelectedSpace';
|
import { useSelectedSpace } from './router/useSelectedSpace';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
|
@ -21,7 +15,6 @@ export const useRoomNavigate = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
const spaceSelectedId = useSelectedSpace();
|
const spaceSelectedId = useSelectedSpace();
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
|
|
@ -88,23 +81,18 @@ export const useRoomNavigate = () => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirror the four-source DM gate used by useIsDirectStream and
|
// After P3c the Direct tab is universal — every non-space leaf room
|
||||||
// HomeRouteRoomProvider so imperative navigation (push tap, search
|
// routes through /direct/. Space roots stay on their own /{spaceId}/
|
||||||
// result, inbox click) sends DMs to /direct/ even on the first frame
|
// route handled by navigateSpace.
|
||||||
// before mDirectAtom has hydrated. Fall back to the simple atom check
|
|
||||||
// when the SDK doesn't know the room yet (e.g. peeking).
|
|
||||||
const targetRoom = mx.getRoom(roomId);
|
const targetRoom = mx.getRoom(roomId);
|
||||||
const isDirect = targetRoom
|
if (targetRoom && isSpace(targetRoom)) {
|
||||||
? isDirectStreamRoom(mx, targetRoom, mDirects)
|
safeNavigate(getSpacePath(roomIdOrAlias), opts);
|
||||||
: mDirects.has(roomId);
|
|
||||||
if (isDirect) {
|
|
||||||
safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
safeNavigate(getHomeRoomPath(roomIdOrAlias, eventId), opts);
|
safeNavigate(getDirectRoomPath(roomIdOrAlias, eventId), opts);
|
||||||
},
|
},
|
||||||
[mx, safeNavigate, spaceSelectedId, roomToParents, mDirects, developerTools]
|
[mx, safeNavigate, spaceSelectedId, roomToParents, developerTools]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ import {
|
||||||
} from './pathUtils';
|
} from './pathUtils';
|
||||||
import { getMxIdServer, isUserId } from '../utils/matrix';
|
import { getMxIdServer, isUserId } from '../utils/matrix';
|
||||||
import { ClientBindAtoms, ClientLayout, ClientRoot } from './client';
|
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 { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct';
|
||||||
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space';
|
||||||
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
import { Explore, FeaturedRooms, PublicRooms } from './client/explore';
|
||||||
|
|
@ -71,7 +71,6 @@ import { ClientRoomsNotificationPreferences } from './client/ClientRoomsNotifica
|
||||||
import { SpaceSettingsRenderer } from '../features/space-settings';
|
import { SpaceSettingsRenderer } from '../features/space-settings';
|
||||||
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
|
import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
|
||||||
import { CreateRoomModalRenderer } from '../features/create-room';
|
import { CreateRoomModalRenderer } from '../features/create-room';
|
||||||
import { HomeCreateRoom } from './client/home/CreateRoom';
|
|
||||||
import { Create } from './client/create';
|
import { Create } from './client/create';
|
||||||
import { CreateSpaceModalRenderer } from '../features/create-space';
|
import { CreateSpaceModalRenderer } from '../features/create-space';
|
||||||
import { SearchModalRenderer } from '../features/search';
|
import { SearchModalRenderer } from '../features/search';
|
||||||
|
|
@ -201,24 +200,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
</AuthRouteThemeManager>
|
</AuthRouteThemeManager>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Route
|
{/* Legacy /home/ tree — kept only as a redirect surface so cold-start
|
||||||
path={HOME_PATH}
|
push deep links and pre-P3c bookmarks resolve cleanly. The Home
|
||||||
element={
|
page itself is gone; HomeRouteRoomProvider redirects /home/{roomId}/
|
||||||
<PageRoot
|
into /direct/{roomId}/ on mount. See plan §6.7 / §8 P3c. */}
|
||||||
nav={
|
<Route path={HOME_PATH}>
|
||||||
<MobileFriendlyPageNav path={HOME_PATH}>
|
<Route index element={<Navigate to={DIRECT_PATH} replace />} />
|
||||||
<Home />
|
<Route path={_CREATE_PATH} element={<Navigate to={getDirectCreatePath()} replace />} />
|
||||||
</MobileFriendlyPageNav>
|
<Route path={_JOIN_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
|
||||||
}
|
<Route path={_SEARCH_PATH} element={<Navigate to={DIRECT_PATH} replace />} />
|
||||||
>
|
|
||||||
<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 />} />
|
|
||||||
<Route
|
<Route
|
||||||
path={_ROOM_PATH}
|
path={_ROOM_PATH}
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
} from '../../components/sidebar';
|
} from '../../components/sidebar';
|
||||||
import {
|
import {
|
||||||
DirectTab,
|
DirectTab,
|
||||||
HomeTab,
|
|
||||||
SpaceTabs,
|
SpaceTabs,
|
||||||
InboxTab,
|
InboxTab,
|
||||||
ExploreTab,
|
ExploreTab,
|
||||||
|
|
@ -28,7 +27,6 @@ export function SidebarNav() {
|
||||||
scrollable={
|
scrollable={
|
||||||
<Scroll ref={scrollRef} variant="Background" size="0">
|
<Scroll ref={scrollRef} variant="Background" size="0">
|
||||||
<SidebarStack>
|
<SidebarStack>
|
||||||
<HomeTab />
|
|
||||||
<DirectTab />
|
<DirectTab />
|
||||||
</SidebarStack>
|
</SidebarStack>
|
||||||
<SpaceTabs scrollRef={scrollRef} />
|
<SpaceTabs scrollRef={scrollRef} />
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,37 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { isSpace } from '../../../utils/room';
|
||||||
import { isDirectStreamRoom } 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 }) {
|
export function DirectRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
|
|
||||||
const { roomIdOrAlias, eventId } = useParams();
|
const { roomIdOrAlias, eventId } = useParams();
|
||||||
const roomId = useSelectedRoom();
|
const roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
// Symmetric with HomeRouteRoomProvider's redirect predicate and
|
// After P3c the Direct tab is universal — any joined non-space room renders
|
||||||
// useRoomNavigate's destination check — all three use the same four-source
|
// here. Spaces (= future Channels) keep their own /{spaceId}/ route.
|
||||||
// `isDirectStreamRoom` helper. Without this, on cold-start race (mDirectAtom
|
if (!room || isSpace(room)) {
|
||||||
// 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)) {
|
|
||||||
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
|
return <JoinBeforeNavigate roomIdOrAlias={roomIdOrAlias!} eventId={eventId} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
|
||||||
<IsDirectRoomProvider value>{children}</IsDirectRoomProvider>
|
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,52 @@
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { mDirectAtom } from '../../../state/mDirectList';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
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 mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
const orphanRooms = useOrphanRooms(mx, allRoomsAtom, mDirects, roomToParents);
|
||||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
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 React, { ReactNode } from 'react';
|
||||||
import { Navigate, useParams } from 'react-router-dom';
|
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { Navigate, useParams } from 'react-router-dom';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useHomeRooms } from './useHomeRooms';
|
|
||||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { getDirectRoomPath } from '../../pathUtils';
|
import { getDirectRoomPath } from '../../pathUtils';
|
||||||
import { getCanonicalAliasOrRoomId } from '../../../utils/matrix';
|
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 mx = useMatrixClient();
|
||||||
const rooms = useHomeRooms();
|
// **Subscribe to allRoomsAtom even though we don't use the value**: cold-
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
// 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 { roomIdOrAlias, eventId } = useParams();
|
||||||
const viaServers = useSearchParamsViaServers();
|
const viaServers = useSearchParamsViaServers();
|
||||||
const roomId = useSelectedRoom();
|
const roomId = useSelectedRoom();
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
|
|
||||||
// Cold-start push routing lands on /home/{roomId} (sw.ts has no access to
|
// After P3c the Home tab is gone — every non-space room redirects to the
|
||||||
// mDirectAtom). For DM rooms we redirect to /direct/. The shared four-source
|
// unified Direct list. /home/{roomId}/ stays as a deep-link compatibility
|
||||||
// predicate matches the render-side `useIsDirectStream` so the redirect
|
// shim for cold-start push (sw.ts cannot read mDirectAtom).
|
||||||
// decision and the Stream layout decision agree on the first paint instead
|
if (room && !isSpace(room)) {
|
||||||
// of one frame later. See plan §6.5 / §6.7.
|
|
||||||
if (room && isDirectStreamRoom(mx, room, mDirects)) {
|
|
||||||
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
const alias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||||
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
|
return <Navigate to={getDirectRoomPath(alias, eventId)} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!room || !rooms.includes(room.roomId)) {
|
|
||||||
return (
|
return (
|
||||||
<JoinBeforeNavigate
|
<JoinBeforeNavigate
|
||||||
roomIdOrAlias={roomIdOrAlias!}
|
roomIdOrAlias={roomIdOrAlias!}
|
||||||
|
|
@ -40,11 +44,4 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
viaServers={viaServers}
|
viaServers={viaServers}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
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';
|
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,
|
getMemberAvatarMxc,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
getRoomAvatarUrl,
|
getRoomAvatarUrl,
|
||||||
|
isOneOnOneRoom,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
import { ScrollTopContainer } from '../../../components/scroll-top-container';
|
||||||
import { useInterval } from '../../../hooks/useInterval';
|
import { useInterval } from '../../../hooks/useInterval';
|
||||||
|
|
@ -90,7 +91,6 @@ import { usePowerLevelTags } from '../../../hooks/usePowerLevelTags';
|
||||||
import { useTheme } from '../../../hooks/useTheme';
|
import { useTheme } from '../../../hooks/useTheme';
|
||||||
import { PowerIcon } from '../../../components/power';
|
import { PowerIcon } from '../../../components/power';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
import colorMXID from '../../../../util/colorMXID';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
|
||||||
import {
|
import {
|
||||||
getPowerTagIconSrc,
|
getPowerTagIconSrc,
|
||||||
useAccessiblePowerTagColors,
|
useAccessiblePowerTagColors,
|
||||||
|
|
@ -569,11 +569,9 @@ export function Notifications() {
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||||
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
|
|
||||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
|
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
@ -734,7 +732,7 @@ export function Notifications() {
|
||||||
hideActivity={hideActivity}
|
hideActivity={hideActivity}
|
||||||
onOpen={navigateRoom}
|
onOpen={navigateRoom}
|
||||||
legacyUsernameColor={
|
legacyUsernameColor={
|
||||||
legacyUsernameColor || mDirects.has(groupRoom.roomId)
|
isOneOnOneRoom(groupRoom)
|
||||||
}
|
}
|
||||||
hour24Clock={hour24Clock}
|
hour24Clock={hour24Clock}
|
||||||
dateFormatString={dateFormatString}
|
dateFormatString={dateFormatString}
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,7 @@ import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRe
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { useDirects } from '../../../state/hooks/roomList';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
|
||||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||||
import { getDirectPath, joinPathComponent } from '../../pathUtils';
|
import { getDirectPath, joinPathComponent } from '../../pathUtils';
|
||||||
import { isNativePlatform } from '../../../utils/capacitor';
|
import { isNativePlatform } from '../../../utils/capacitor';
|
||||||
|
|
@ -66,12 +63,14 @@ const DirectMenu = forwardRef<HTMLDivElement, DirectMenuProps>(({ requestClose }
|
||||||
export function DirectTab() {
|
export function DirectTab() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mx = useMatrixClient();
|
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
const navToActivePath = useAtomValue(useNavToActivePathAtom());
|
||||||
|
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
// After P3c the Direct tab is universal — every joined non-space room lives
|
||||||
const directs = useDirects(mx, allRoomsAtom, mDirects);
|
// 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 directUnread = useRoomsUnread(directs, roomToUnreadAtom);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
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 './DirectTab';
|
||||||
export * from './SpaceTabs';
|
export * from './SpaceTabs';
|
||||||
export * from './InboxTab';
|
export * from './InboxTab';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
import { IsOneOnOneProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
import { JoinBeforeNavigate } from '../../../features/join-before-navigate';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
|
|
@ -10,16 +11,27 @@ import { getAllParents, getSpaceChildren } from '../../../utils/room';
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
import { useSearchParamsViaServers } from '../../../hooks/router/useSearchParamsViaServers';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { useSetting } from '../../../state/hooks/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 }) {
|
export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
const [developerTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
|
const [roomToParents, setRoomToParents] = useAtom(roomToParentsAtom);
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
const allRooms = useAtomValue(allRoomsAtom);
|
const allRooms = useAtomValue(allRoomsAtom);
|
||||||
|
|
||||||
const { roomIdOrAlias, eventId } = useParams();
|
const { roomIdOrAlias, eventId } = useParams();
|
||||||
|
|
@ -39,12 +51,10 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
|
if (developerTools && room.isSpaceRoom() && room.roomId === space.roomId) {
|
||||||
// allow to view space timeline
|
// Allow viewing the space's own timeline. `isOneOnOneRoom` already guards
|
||||||
return (
|
// spaces (it returns false for `isSpace(room)`), so the value is stable
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
// here regardless of member count.
|
||||||
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
if (!getAllParents(roomToParents, room.roomId).has(space.roomId)) {
|
||||||
|
|
@ -66,9 +76,5 @@ export function SpaceRouteRoomProvider({ children }: { children: ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <ResolvedRoomProvider room={room}>{children}</ResolvedRoomProvider>;
|
||||||
<RoomProvider key={room.roomId} value={room}>
|
|
||||||
<IsDirectRoomProvider value={mDirects.has(room.roomId)}>{children}</IsDirectRoomProvider>
|
|
||||||
</RoomProvider>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ import { useAtomValue } from 'jotai';
|
||||||
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
|
||||||
import { MessageSearch } from '../../../features/message-search';
|
import { MessageSearch } from '../../../features/message-search';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
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 { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||||
import { mDirectAtom } from '../../../state/mDirectList';
|
|
||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
|
|
@ -20,12 +22,15 @@ export function SpaceSearch() {
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
|
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
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(
|
const rooms = useSpaceChildren(
|
||||||
allRoomsAtom,
|
allRoomsAtom,
|
||||||
space.roomId,
|
space.roomId,
|
||||||
useRecursiveChildRoomScopeFactory(mx, mDirects, roomToParents)
|
useRecursiveChildNonSpaceRoomScopeFactory(mx, roomToParents)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,24 @@ export const useRecursiveChildRoomScopeFactory = (
|
||||||
[mx, mDirects, roomToParents]
|
[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 = (
|
export const useChildDirectScopeFactory = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
mDirects: Set<string>,
|
mDirects: Set<string>,
|
||||||
|
|
@ -147,6 +165,19 @@ export const useRooms = (mx: MatrixClient, roomsAtom: RoomsAtom, mDirects: Set<s
|
||||||
return useSelectedRooms(roomsAtom, selector);
|
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 = (
|
export const useOrphanRooms = (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomsAtom: RoomsAtom,
|
roomsAtom: RoomsAtom,
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,6 @@ export type DateFormat =
|
||||||
| 'YYYY/MM/DD'
|
| 'YYYY/MM/DD'
|
||||||
| '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 {
|
export interface Settings {
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
|
|
@ -28,15 +22,12 @@ export interface Settings {
|
||||||
isPeopleDrawer: boolean;
|
isPeopleDrawer: boolean;
|
||||||
memberSortFilterIndex: number;
|
memberSortFilterIndex: number;
|
||||||
enterForNewline: boolean;
|
enterForNewline: boolean;
|
||||||
messageLayout: MessageLayout;
|
|
||||||
messageSpacing: MessageSpacing;
|
|
||||||
hideMembershipEvents: boolean;
|
hideMembershipEvents: boolean;
|
||||||
hideNickAvatarEvents: boolean;
|
hideNickAvatarEvents: boolean;
|
||||||
mediaAutoLoad: boolean;
|
mediaAutoLoad: boolean;
|
||||||
urlPreview: boolean;
|
urlPreview: boolean;
|
||||||
encUrlPreview: boolean;
|
encUrlPreview: boolean;
|
||||||
showHiddenEvents: boolean;
|
showHiddenEvents: boolean;
|
||||||
legacyUsernameColor: boolean;
|
|
||||||
|
|
||||||
isNotificationSounds: boolean;
|
isNotificationSounds: boolean;
|
||||||
|
|
||||||
|
|
@ -49,6 +40,7 @@ export interface Settings {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DAWN_MIGRATION_KEY = 'dawn-redesign-v1';
|
const DAWN_MIGRATION_KEY = 'dawn-redesign-v1';
|
||||||
|
const P3C_CLEANUP_KEY = 'dawn-p3c-cleanup';
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
themeId: undefined,
|
themeId: undefined,
|
||||||
|
|
@ -63,15 +55,12 @@ const defaultSettings: Settings = {
|
||||||
isPeopleDrawer: true,
|
isPeopleDrawer: true,
|
||||||
memberSortFilterIndex: 0,
|
memberSortFilterIndex: 0,
|
||||||
enterForNewline: false,
|
enterForNewline: false,
|
||||||
messageLayout: MessageLayout.Bubble,
|
|
||||||
messageSpacing: '400',
|
|
||||||
hideMembershipEvents: false,
|
hideMembershipEvents: false,
|
||||||
hideNickAvatarEvents: true,
|
hideNickAvatarEvents: true,
|
||||||
mediaAutoLoad: true,
|
mediaAutoLoad: true,
|
||||||
urlPreview: true,
|
urlPreview: true,
|
||||||
encUrlPreview: false,
|
encUrlPreview: false,
|
||||||
showHiddenEvents: false,
|
showHiddenEvents: false,
|
||||||
legacyUsernameColor: false,
|
|
||||||
|
|
||||||
isNotificationSounds: true,
|
isNotificationSounds: true,
|
||||||
|
|
||||||
|
|
@ -114,6 +103,22 @@ export const getSettings = (): Settings => {
|
||||||
setSettings(merged);
|
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;
|
return merged;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -72,29 +72,6 @@ export const isDirectInvite = (room: Room | null, myUserId: string | null): bool
|
||||||
return content?.is_direct === true;
|
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 => {
|
export const isSpace = (room: Room | null): boolean => {
|
||||||
if (!room) return false;
|
if (!room) return false;
|
||||||
const event = getStateEvent(room, StateEvent.RoomCreate);
|
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;
|
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 {
|
export function isValidChild(mEvent: MatrixEvent): boolean {
|
||||||
return (
|
return (
|
||||||
mEvent.getType() === StateEvent.SpaceChild &&
|
mEvent.getType() === StateEvent.SpaceChild &&
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ export enum StateEvent {
|
||||||
RoomTombstone = 'm.room.tombstone',
|
RoomTombstone = 'm.room.tombstone',
|
||||||
GroupCallPrefix = 'org.matrix.msc3401.call',
|
GroupCallPrefix = 'org.matrix.msc3401.call',
|
||||||
GroupCallMemberPrefix = 'org.matrix.msc3401.call.member',
|
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',
|
SpaceChild = 'm.space.child',
|
||||||
SpaceParent = 'm.space.parent',
|
SpaceParent = 'm.space.parent',
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue