diff --git a/apps/widget-discord/src/main.tsx b/apps/widget-discord/src/main.tsx index 40e2ff54..2a49583b 100644 --- a/apps/widget-discord/src/main.tsx +++ b/apps/widget-discord/src/main.tsx @@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; import { WidgetApi, buildCapabilities } from './widget-api'; +import { installSwipeForwarder } from './swipe-forward'; import './styles.css'; // Input-mode detector — see apps/widget-telegram/src/main.tsx for the @@ -58,5 +59,8 @@ if (!result.ok) { // with the cached-bundle remount path. See widget-telegram for full // rationale. const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId)); + // Forward the raw touch stream to the host so its swipe-back + // gesture works over this iframe — see swipe-forward.ts. + installSwipeForwarder(result.bootstrap.parentOrigin); render(, root); } diff --git a/apps/widget-discord/src/swipe-forward.ts b/apps/widget-discord/src/swipe-forward.ts new file mode 100644 index 00000000..11f4b344 --- /dev/null +++ b/apps/widget-discord/src/swipe-forward.ts @@ -0,0 +1,121 @@ +// Forwards the widget's raw touch stream to the Vojo host so the +// swipe-back-from-widget gesture works across the iframe boundary. An +// iframe is a separate browsing context — touches inside it NEVER bubble +// to the host document, so without this the host's interactive-pop +// gesture (src/app/components/swipe-back) is dead over the widget body. +// +// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch', +// data: { phase, x, y } }` posted to the parent with the pinned +// `parentOrigin` (same side-channel + origin discipline as +// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local +// clientX/Y; the host offsets them by the iframe's viewport rect. +// +// The host owns the real gesture state machine (dead-zone axis resolve, +// edge guard, distance commit). The ONLY logic duplicated here is the +// axis resolution needed to call preventDefault locally — the host +// cannot cancel this document's scroll, so once a single-finger drag +// resolves as horizontal-rightward we must suppress our own default +// handling or the widget's vertical scroll would fight the card slide. +// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync. +const DEAD_ZONE_PX = 12; + +type Phase = 'start' | 'move' | 'end' | 'cancel'; + +export function installSwipeForwarder(parentOrigin: string): void { + const post = (phase: Phase, x: number, y: number): void => { + window.parent.postMessage( + { api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } }, + parentOrigin + ); + }; + + let tracking = false; + let bailed = false; + let engaged = false; + let startX = 0; + let startY = 0; + + const cancel = (x: number, y: number): void => { + if (tracking && !bailed) post('cancel', x, y); + tracking = false; + bailed = true; + }; + + document.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) { + cancel(0, 0); + return; + } + const t = e.touches[0]; + tracking = true; + bailed = false; + engaged = false; + startX = t.clientX; + startY = t.clientY; + post('start', t.clientX, t.clientY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchmove', + (e) => { + if (!tracking || bailed) return; + if (e.touches.length !== 1) { + const t = e.touches[0]; + cancel(t.clientX, t.clientY); + return; + } + const t = e.touches[0]; + if (!engaged) { + const dx = t.clientX - startX; + const dy = t.clientY - startY; + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) { + // Still inside the dead-zone — keep feeding the host (its own + // machine waits the same way) but make no local decision yet. + post('move', t.clientX, t.clientY); + return; + } + // Vertical-dominant or leftward: the gesture is the widget's own + // (scroll / horizontal UI). Stop forwarding — the host's machine + // bails identically from the same data; the cancel is belt and + // braces against threshold drift. + if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) { + cancel(t.clientX, t.clientY); + return; + } + engaged = true; + } + // Horizontal-rightward drag — the host owns it now. Suppress the + // widget's own scroll for the rest of the touch. + if (e.cancelable) e.preventDefault(); + post('move', t.clientX, t.clientY); + }, + { passive: false } + ); + + document.addEventListener( + 'touchend', + (e) => { + if (!tracking || bailed) { + tracking = false; + return; + } + tracking = false; + const t = e.changedTouches[0]; + post('end', t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchcancel', + (e) => { + const t = e.changedTouches[0]; + cancel(t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); +} diff --git a/apps/widget-telegram/src/main.tsx b/apps/widget-telegram/src/main.tsx index 02e999e5..8923b2f7 100644 --- a/apps/widget-telegram/src/main.tsx +++ b/apps/widget-telegram/src/main.tsx @@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; import { WidgetApi } from './widget-api'; +import { installSwipeForwarder } from './swipe-forward'; import './styles.css'; // Input-mode detector for hover styling. CSS gates `:hover` and @@ -73,5 +74,8 @@ if (!result.ok) { // cached-bundle remount the request can race ahead of any useEffect — // construction at module-load closes that window. const api = new WidgetApi(result.bootstrap); + // Forward the raw touch stream to the host so its swipe-back + // gesture works over this iframe — see swipe-forward.ts. + installSwipeForwarder(result.bootstrap.parentOrigin); render(, root); } diff --git a/apps/widget-telegram/src/swipe-forward.ts b/apps/widget-telegram/src/swipe-forward.ts new file mode 100644 index 00000000..11f4b344 --- /dev/null +++ b/apps/widget-telegram/src/swipe-forward.ts @@ -0,0 +1,121 @@ +// Forwards the widget's raw touch stream to the Vojo host so the +// swipe-back-from-widget gesture works across the iframe boundary. An +// iframe is a separate browsing context — touches inside it NEVER bubble +// to the host document, so without this the host's interactive-pop +// gesture (src/app/components/swipe-back) is dead over the widget body. +// +// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch', +// data: { phase, x, y } }` posted to the parent with the pinned +// `parentOrigin` (same side-channel + origin discipline as +// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local +// clientX/Y; the host offsets them by the iframe's viewport rect. +// +// The host owns the real gesture state machine (dead-zone axis resolve, +// edge guard, distance commit). The ONLY logic duplicated here is the +// axis resolution needed to call preventDefault locally — the host +// cannot cancel this document's scroll, so once a single-finger drag +// resolves as horizontal-rightward we must suppress our own default +// handling or the widget's vertical scroll would fight the card slide. +// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync. +const DEAD_ZONE_PX = 12; + +type Phase = 'start' | 'move' | 'end' | 'cancel'; + +export function installSwipeForwarder(parentOrigin: string): void { + const post = (phase: Phase, x: number, y: number): void => { + window.parent.postMessage( + { api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } }, + parentOrigin + ); + }; + + let tracking = false; + let bailed = false; + let engaged = false; + let startX = 0; + let startY = 0; + + const cancel = (x: number, y: number): void => { + if (tracking && !bailed) post('cancel', x, y); + tracking = false; + bailed = true; + }; + + document.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) { + cancel(0, 0); + return; + } + const t = e.touches[0]; + tracking = true; + bailed = false; + engaged = false; + startX = t.clientX; + startY = t.clientY; + post('start', t.clientX, t.clientY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchmove', + (e) => { + if (!tracking || bailed) return; + if (e.touches.length !== 1) { + const t = e.touches[0]; + cancel(t.clientX, t.clientY); + return; + } + const t = e.touches[0]; + if (!engaged) { + const dx = t.clientX - startX; + const dy = t.clientY - startY; + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) { + // Still inside the dead-zone — keep feeding the host (its own + // machine waits the same way) but make no local decision yet. + post('move', t.clientX, t.clientY); + return; + } + // Vertical-dominant or leftward: the gesture is the widget's own + // (scroll / horizontal UI). Stop forwarding — the host's machine + // bails identically from the same data; the cancel is belt and + // braces against threshold drift. + if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) { + cancel(t.clientX, t.clientY); + return; + } + engaged = true; + } + // Horizontal-rightward drag — the host owns it now. Suppress the + // widget's own scroll for the rest of the touch. + if (e.cancelable) e.preventDefault(); + post('move', t.clientX, t.clientY); + }, + { passive: false } + ); + + document.addEventListener( + 'touchend', + (e) => { + if (!tracking || bailed) { + tracking = false; + return; + } + tracking = false; + const t = e.changedTouches[0]; + post('end', t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchcancel', + (e) => { + const t = e.changedTouches[0]; + cancel(t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); +} diff --git a/apps/widget-whatsapp/src/main.tsx b/apps/widget-whatsapp/src/main.tsx index 35dbac1d..73c72866 100644 --- a/apps/widget-whatsapp/src/main.tsx +++ b/apps/widget-whatsapp/src/main.tsx @@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap'; import { App } from './App'; import { createT } from './i18n'; import { WidgetApi } from './widget-api'; +import { installSwipeForwarder } from './swipe-forward'; import './styles.css'; // Input-mode detector for hover styling. CSS gates `:hover` and @@ -73,5 +74,8 @@ if (!result.ok) { // cached-bundle remount the request can race ahead of any useEffect — // construction at module-load closes that window. const api = new WidgetApi(result.bootstrap); + // Forward the raw touch stream to the host so its swipe-back + // gesture works over this iframe — see swipe-forward.ts. + installSwipeForwarder(result.bootstrap.parentOrigin); render(, root); } diff --git a/apps/widget-whatsapp/src/swipe-forward.ts b/apps/widget-whatsapp/src/swipe-forward.ts new file mode 100644 index 00000000..11f4b344 --- /dev/null +++ b/apps/widget-whatsapp/src/swipe-forward.ts @@ -0,0 +1,121 @@ +// Forwards the widget's raw touch stream to the Vojo host so the +// swipe-back-from-widget gesture works across the iframe boundary. An +// iframe is a separate browsing context — touches inside it NEVER bubble +// to the host document, so without this the host's interactive-pop +// gesture (src/app/components/swipe-back) is dead over the widget body. +// +// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch', +// data: { phase, x, y } }` posted to the parent with the pinned +// `parentOrigin` (same side-channel + origin discipline as +// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local +// clientX/Y; the host offsets them by the iframe's viewport rect. +// +// The host owns the real gesture state machine (dead-zone axis resolve, +// edge guard, distance commit). The ONLY logic duplicated here is the +// axis resolution needed to call preventDefault locally — the host +// cannot cancel this document's scroll, so once a single-finger drag +// resolves as horizontal-rightward we must suppress our own default +// handling or the widget's vertical scroll would fight the card slide. +// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync. +const DEAD_ZONE_PX = 12; + +type Phase = 'start' | 'move' | 'end' | 'cancel'; + +export function installSwipeForwarder(parentOrigin: string): void { + const post = (phase: Phase, x: number, y: number): void => { + window.parent.postMessage( + { api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } }, + parentOrigin + ); + }; + + let tracking = false; + let bailed = false; + let engaged = false; + let startX = 0; + let startY = 0; + + const cancel = (x: number, y: number): void => { + if (tracking && !bailed) post('cancel', x, y); + tracking = false; + bailed = true; + }; + + document.addEventListener( + 'touchstart', + (e) => { + if (e.touches.length !== 1) { + cancel(0, 0); + return; + } + const t = e.touches[0]; + tracking = true; + bailed = false; + engaged = false; + startX = t.clientX; + startY = t.clientY; + post('start', t.clientX, t.clientY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchmove', + (e) => { + if (!tracking || bailed) return; + if (e.touches.length !== 1) { + const t = e.touches[0]; + cancel(t.clientX, t.clientY); + return; + } + const t = e.touches[0]; + if (!engaged) { + const dx = t.clientX - startX; + const dy = t.clientY - startY; + if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) { + // Still inside the dead-zone — keep feeding the host (its own + // machine waits the same way) but make no local decision yet. + post('move', t.clientX, t.clientY); + return; + } + // Vertical-dominant or leftward: the gesture is the widget's own + // (scroll / horizontal UI). Stop forwarding — the host's machine + // bails identically from the same data; the cancel is belt and + // braces against threshold drift. + if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) { + cancel(t.clientX, t.clientY); + return; + } + engaged = true; + } + // Horizontal-rightward drag — the host owns it now. Suppress the + // widget's own scroll for the rest of the touch. + if (e.cancelable) e.preventDefault(); + post('move', t.clientX, t.clientY); + }, + { passive: false } + ); + + document.addEventListener( + 'touchend', + (e) => { + if (!tracking || bailed) { + tracking = false; + return; + } + tracking = false; + const t = e.changedTouches[0]; + post('end', t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); + + document.addEventListener( + 'touchcancel', + (e) => { + const t = e.changedTouches[0]; + cancel(t?.clientX ?? startX, t?.clientY ?? startY); + }, + { passive: true } + ); +} diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index cde2747b..a24c2d3f 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -152,15 +152,15 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g | Dir | Purpose | |-----|---------| | `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; the `…` overflow opens `room-actions/RoomActionsMenu` in an anchored folds PopOut — same chrome on desktop and mobile — restyled to a flat `ActionRow` vocabulary (`RoomActions.tsx`) on the dark-blue Vojo composer tone (folds Menu `variant="SurfaceVariant"` = #181a20); hosts mark-read/notifications/search/pinned/copy-link/settings/jump-to-time/invite/leave with the same nested popouts/overlays as upstream). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. | -| `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? : `), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `MessageEditor`, `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. | +| `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? : `), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. Editing happens IN the composer (Telegram-style edit banner in `RoomInput.tsx`, `roomIdToEditDraftAtomFamily`); the old in-timeline `MessageEditor` is gone. | | `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). | -| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Pairs with `pages/client/bots/`. | +| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Widget iframes also forward touch phases over the `io.vojo.bot-widget` side-channel (`swipe-touch` action; widget-side `apps/widget-*/src/swipe-forward.ts`, host-side `BotWidgetEmbed` → `components/swipe-back/externalSwipeFeed.ts`) so the mobile swipe-back gesture works over the widget body — contract notes in `BotWidgetEmbed.ts`. Pairs with `pages/client/bots/`. | | `share-target/` | **NEW.** Android/web system share-sheet hand-off. `ShareTargetStrip.tsx` is a top banner (mounted in `HorseshoeContainer`) shown while `pendingShareAtom` holds a payload; the next `RoomInput` mount consumes it (injects files + text, then nulls the atom). Native slot drained by `hooks/useShareTargetReceiver.ts`. | | `call/` | **In-room call pane** — `CallView` (prescreen/join screen + member list + livekit checks), `CallControls`/`Controls`/`PrescreenControls`/`CallMemberCard`. Mounted in `Room.tsx` via ``. Consumes `plugins/call` CallEmbed + `state/callEmbed`. Don't unmount/remount the widget root carelessly — Android FGS is keyed on `joined`. | | `call-status/` | **Global bottom call rail** — `IncomingCallStrip` (incoming-ring row) + `CallStatus` (active-call pill) + `CallControl`. Mounted via `pages/CallStatusRenderer.tsx` + `pages/IncomingCallStripRenderer.tsx` inside `HorseshoeContainer` (NOT directly in Router). Call **lifecycle** hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`, `usePendingCallActionConsumer`) run in Router's `IncomingCallsFeature()`. | -| `settings/` | User settings as 7 pages (`GeneralPage, AccountPage, NotificationPage, DevicesPage, EmojisStickersPage, DeveloperToolsPage, AboutPage`; `SETTINGS_PAGE_PARAM` deep-links). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` / `hour24Clock` / `dateFormatString` were removed — layout is no longer user-configurable and time/date derive from the runtime locale (`utils/time.ts`). `hideMembershipEvents` / `hideNickAvatarEvents` survive (gate group-room syslines). **Logout lives here only** (`LogoutDialog`). `MobileSettingsHorseshoe` + `SettingsScreen` are the mobile sheet / route entry. | +| `settings/` | User settings as 6 pages (`GeneralPage, AccountPage, NotificationPage, NetworkPage, DevicesPage, AboutPage`; `SETTINGS_PAGE_PARAM` deep-links; emojis-stickers and developer-tools pages were dropped from user settings). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` / `hour24Clock` / `dateFormatString` were removed — layout is no longer user-configurable and time/date derive from the runtime locale (`utils/time.ts`). `hideMembershipEvents` / `hideNickAvatarEvents` survive (gate group-room syslines). Logout confirm is `components/logout-dialog/` (self-contained Vojo card; opened from the settings menu row and `DeviceTile`'s current-device sign-out). `MobileSettingsHorseshoe` + `SettingsScreen` are the mobile sheet / route entry. | | `common-settings/` | **Shared** settings modules reused by both room and space settings: `general/` (RoomProfile, Address, Encryption, HistoryVisibility, JoinRules, Publish, Upgrade), `members/`, `permissions/` (Powers, PowersEditor, PermissionGroups), `emojis-stickers/` (RoomPacks), `developer-tools/` (SendRoomEvent, StateEventEditor). | -| `room-settings/` / `space-settings/` | Each defines only its own General + Permissions and **imports** Members/EmojisStickers/DeveloperTools from `common-settings`. Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. | +| `room-settings/` / `space-settings/` | Room settings is a SINGLE page (profile + join rules/history/encryption/voice) in a narrow `Modal500`; no nav, no permissions/dev-tools (Matrix-protocol surfaces dropped from rooms). Space settings keeps the full nav set (General/Members/Permissions/EmojisStickers/DeveloperTools via `common-settings`). Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. | | `lobby/` | Space lobby (`Lobby` + Hierarchy/Item + pragmatic-drag-and-drop reordering). Still routed under `SPACE_PATH/_LOBBY_PATH` and reached from the **legacy** Space tab — the new Channels surface does **not** use it. | | `search/` | Unified switcher (Cmd+K modal `Search.tsx` via `SearchModalRenderer` + the StreamHeader's `InlineRoomSearch`, both on `useRoomSearch.ts`). Searches local rooms/DMs/spaces (`useAsyncSearch`) **and** a **"People" section** from the homeserver user directory (`mx.searchUserDirectory`, debounced 300ms / ≥2 chars; exact `@user:server` falls back to `mx.getProfileInfo`, then a soft-added raw id). People are deduped against existing DMs (`getDMRoomFor`); clicking one opens-or-creates a DM via `create-chat/useCreateDirect.ts`. This is how you reach someone you haven't chatted with — the old "+ new chat" form is gone from the listing headers (see `stream-header/`). Findability of who appears is the server's lever: Synapse `user_directory.search_all_users` (federation only surfaces remote users the server already knows). | | `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). | @@ -213,11 +213,11 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem ### Badges / indicators / upload / preview -`unread-badge/`, `server-badge/` (**NEW**), `typing-indicator/` (**NEW**), `time-date/` (**NEW** — DatePicker/TimePicker/PickerColumn), `upload-card/`, `upload-board/` (**NEW**), `url-preview/`. +`unread-badge/`, `server-badge/` (**NEW**), `typing-indicator/` (**NEW**), `time-date/` (**NEW** — DatePicker/TimePicker/PickerColumn), `upload-card/` (compact renderer only — the floating `upload-board/` window is gone; attachments render inside the composer via `features/room/ComposerAttachments.tsx`), `url-preview/`. ### Prompts / dialogs -`invite-user-prompt/`, `leave-room-prompt/`, `leave-space-prompt/` (**NEW**), `full-screen-intent-prompt/` (**NEW** — Android FSI permission, 7-day cooldown), `push-permission-prompt/` (**NEW** — push permission, 7-day cooldown), `uia-stages/` (Dummy/Email/Password/ReCaptcha/RegistrationToken/SSO/Terms), `LogoutDialog`. (`join-address-prompt/` from the old doc **does not exist**.) +`invite-user-prompt/`, `leave-room-prompt/`, `leave-space-prompt/` (**NEW**), `full-screen-intent-prompt/` (**NEW** — Android FSI permission, 7-day cooldown), `push-permission-prompt/` (**NEW** — push permission, 7-day cooldown), `uia-stages/` (Dummy/Email/Password/ReCaptcha/RegistrationToken/SSO/Terms), `logout-dialog/`. (`join-address-prompt/` from the old doc **does not exist**.) ### Boot/runtime Loaders & Providers (top-level render-prop components) diff --git a/docs/ai/i18n.md b/docs/ai/i18n.md index a01f3939..3247b534 100644 --- a/docs/ai/i18n.md +++ b/docs/ai/i18n.md @@ -59,16 +59,30 @@ The developer reviews Russian translations as a native speaker would see them in - `Organisms` — Complex UI pieces - `Boot` — Boot/splash error screens (`ConfigConfigError`, `SpecVersions` error, `ClientRoot` init/start error) -**Still to localise** (snapshot taken 2026-04-14 — verify before acting): +**Still to localise** (snapshot taken 2026-06-12 — verify before acting): -- Room features: `MessageEditor`, `MembersDrawer`, `RoomTombstone` -- Lobby: `Lobby.tsx`, `RoomItem.tsx` +- Room features: `MembersDrawer`, `RoomTombstone` - `AddExisting` feature - System pages: `FeatureCheck`, `WelcomePage` - Auth: `OrDivider` -- Dialogs: `LogoutDialog`, `ManualVerification`, `BackupRestore` -- UIA stages: `ReCaptchaStage`, `EmailStage`, `RegistrationTokenStage` -- Components: `TimePicker`, `DatePicker`, `ImageViewer`, `PdfViewer`, `UploadCard`, `UserModeration` +- UIA stages: `ReCaptchaStage`, `RegistrationTokenStage` +- Components: `TimePicker`, `DatePicker`, `ImageViewer`, `PdfViewer` - Various `aria-label` attributes throughout +Localised 2026-06-12 (the hardcoded-English sweep): `UserModeration`, +call screens (`CallControls`/`PrescreenControls`/`CallView`/`CallMemberCard`), +lobby (`Lobby`/`RoomItem`/`SpaceItem`/`LobbyHeader`), `MessageSearch` +(aria-label), `LeaveSpacePrompt`, the device-verification cluster +(`DeviceVerification`/`DeviceVerificationSetup`/`ManualVerification`/ +`BackupRestore`/settings `Verification`), `ComposerAttachments` +(spoiler/retry/cancel/size-error — the attachment strip that replaced the +UploadBoard window), editor `Toolbar` tooltips, `EmailStage`, +`MediaViewerBody` aria-labels (`MediaViewer.*`/`Common.*`). `MessageEditor` +was deleted (editing moved into the composer); `LogoutDialog` was rewritten +as a localized self-contained card. Dead `Settings.email_*notification*` +strings removed from both locale files (the section is never rendered), as +were the keys orphaned by the dropped room-permissions page +(`RoomSettings.perm_*` room-only set), the user emoji-pack settings page, +and the old upload-size-limit concatenation fragments. + When the developer asks to localise a new area, check this list first and update it after finishing. diff --git a/public/locales/en.json b/public/locales/en.json index 74c4dbfc..d8921242 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -103,15 +103,24 @@ "reset_button": "Reset Password", "reset_error_fallback": "Failed to reset password.", "reset_success_message": "Password has been reset successfully. Please login with your new password.", - "reset_success_login": "Login" + "reset_success_login": "Login", + "uia_email_label": "Email", + "uia_email_send_button": "Send Verification Email", + "uia_email_cancel": "Cancel", + "uia_email_sending": "Sending verification email...", + "uia_email_verify_title": "Verify Email", + "uia_email_send_error": "Failed to send verification email request.", + "uia_email_sent_title": "Verification Request Sent", + "uia_email_sent_message": "Please check your email \"{{email}}\" and validate before continuing further.", + "uia_email_continue": "Continue", + "uia_email_provide_title": "Provide Email", + "uia_email_provide_message": "Please provide email to send verification request." }, "Settings": { "title": "Settings", "menu_general": "General", - "menu_account": "Account", "menu_notifications": "Notifications", "menu_devices": "Devices", - "menu_emojis_stickers": "Emojis & Stickers", "menu_about": "About", "network": { "menu": "Connection", @@ -224,10 +233,6 @@ "fsi_prompt_body": "Let Vojo show full-screen notifications so incoming calls wake the screen and appear over the lockscreen, like WhatsApp or Telegram. Open \"Full-screen notifications\" and turn Vojo on.", "fsi_prompt_later": "Not now", "fsi_prompt_open": "Open settings", - "email_notification": "Email Notification", - "email_no_email": "Your account does not have any email attached.", - "email_send_notif": "Send notification to your email.", - "email_send_notif_to": "Send notification to your email. (\"{{email}}\")", "unexpected_error": "Unexpected Error!", "all_messages": "All Messages", "one_to_one": "1-to-1 Chats", @@ -302,21 +307,11 @@ "import_desc": "Load password protected copy of encryption data from device to decrypt your messages.", "import": "Import", "decrypt": "Decrypt", - "emojis_stickers_title": "Emojis & Stickers", - "default_pack": "Default Pack", "unknown": "Unknown", "view": "View", - "favorite_packs": "Favorite Packs", - "select_pack": "Select Pack", - "select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.", "select": "Select", - "room_packs": "Room Packs", - "select_all": "Select All", - "unselect_all": "Unselect All", "no_packs": "No Packs", "no_packs_desc": "Packs from rooms will appear here. You do not have any rooms with packs yet.", - "apply_error": "Failed to apply changes! Please try again.", - "apply_ready": "Changes saved! Apply when ready.", "apply_changes": "Apply Changes", "about_title": "About", "about_tagline": "A messenger for everyone.", @@ -333,7 +328,58 @@ "notify_on_mention": "Mentions", "notify_on_mention_desc": "Notify me when someone mentions my name or username.", "room_announcements": "@room announcements", - "room_announcements_desc": "Notify me about @room messages." + "room_announcements_desc": "Notify me about @room messages.", + "verification_close": "Close", + "verification_accept_prompt": "Please accept the request from other device.", + "verification_waiting_accept": "Waiting for request to be accepted...", + "verification_click_accept": "Click accept to start the verification process.", + "verification_accept": "Accept", + "verification_request_accepted": "Verification request has been accepted.", + "verification_waiting_response": "Waiting for the response from other device...", + "verification_starting_emoji": "Starting verification using emoji comparison...", + "verification_compare_emoji_prompt": "Confirm the emoji below are displayed on both devices, in the same order:", + "verification_match": "They Match", + "verification_no_match": "Do not Match", + "verification_device_verified": "Your device is verified.", + "verification_okay": "Okay", + "verification_canceled": "Verification has been canceled.", + "verification_unexpected_error": "Unexpected Error! Verification is started but verifier is missing.", + "verification_setup_desc": "Generate a Recovery Key for verifying identity if you do not have access to other devices. Additionally, setup a passphrase as a memorable alternative.", + "verification_passphrase_optional": "Passphrase (Optional)", + "verification_continue": "Continue", + "verification_error_generic": "Unexpected Error!", + "verification_uia_unsupported": "Authentication steps to perform this action are not supported by client.", + "verification_recovery_key_store_desc": "Store the Recovery Key in a safe place for future use, as you will need it to verify your identity if you do not have access to other devices.", + "verification_recovery_key": "Recovery Key", + "verification_show": "Show", + "verification_hide": "Hide", + "verification_copy": "Copy", + "verification_download": "Download", + "verification_setup_title": "Setup Device Verification", + "verification_reset_title": "Reset Device Verification", + "verification_reset_permanent": "Resetting device verification is permanent.", + "verification_reset_warning": "Anyone you have verified with will see security alerts and your encryption backup will be lost. You almost certainly do not want to do this, unless you have lost Recovery Key or Recovery Passphrase and every device you can verify from.", + "verification_recovery_passphrase": "Recovery Passphrase", + "verification_select_method": "Select a verification method.", + "verification_provide_key": "Provide recovery key.", + "verification_verified_success": "Device verified!", + "verification_step_open": "Open {{settings}}.", + "verification_step_find": "Find this device in {{section}} section.", + "backup_connected": "Connected", + "backup_disconnected": "Disconnected", + "backup_syncing": "Syncing ({{amount}})", + "backup_restoring_percent": "Restoring: {{percent}}%", + "backup_trusted_decryption_key": "Backup has trusted decryption key.", + "backup_untrusted_decryption_key": "Backup does not have trusted decryption key!", + "backup_trusted_signature": "Backup is trusted by signature.", + "backup_untrusted_signature": "Backup does not have trusted signature!", + "backup_encryption_title": "Encryption Backup", + "backup_details": "Backup Details", + "backup_version": "Version: {{version}}", + "backup_keys_count": "Keys: {{amount}}", + "backup_none_value": "NIL", + "backup_restore": "Restore Backup", + "backup_none_on_server": "No backup present on server!" }, "Search": { "search": "Search", @@ -521,7 +567,20 @@ "bubble_cancelled_count_one": "{{count}} cancelled call", "bubble_cancelled_count_other": "{{count}} cancelled calls", "duration_minutes_seconds": "{{minutes}} min {{seconds}} sec", - "duration_seconds": "{{seconds}} sec" + "duration_seconds": "{{seconds}} sec", + "view_grid": "Grid View", + "view_spotlight": "Spotlight View", + "reactions": "Reactions", + "settings": "Settings", + "homeserver_no_calls": "Your homeserver does not support calling. But you can still join call started by others.", + "empty_be_first": "Voice chat’s empty — be the first to hop in!", + "no_permission_to_join": "You don't have permission to join!", + "already_in_other_call": "Already in another call — end the current call to join!", + "participant": "Participants", + "live_count": "{{count}} Live", + "collapse": "Collapse", + "others_count_one": "+{{count}} Other", + "others_count_other": "+{{count}} Others" }, "Room": { "delivery": { @@ -534,7 +593,6 @@ "collapse_avatar": "Collapse avatar", "expand_avatar": "Open avatar", "new_messages": "New Messages", - "jump_to_unread": "Jump to Unread", "mark_as_read": "Mark as Read", "jump_to_latest": "Jump to Latest", "message_render_error": "This message could not be displayed.", @@ -542,6 +600,13 @@ "yesterday": "Yesterday", "view_reactions": "View Reactions", "read_receipts": "Read Receipts", + "seen_by": "Seen by", + "seen_by_count_one": "{{count}} person", + "seen_by_count_other": "{{count}} people", + "seen_by_empty": "No one has seen this message yet.", + "editing_message": "Editing message", + "editing_cancel": "Cancel editing", + "no_post_permission": "You do not have permission to post in this room", "view_source": "View Source", "source_code": "Source Code", "copy_link": "Copy Link", @@ -652,7 +717,6 @@ "thread_summary_unread_other": "{{count}} unread", "thread_summary_highlight_one": "{{count}} mention", "thread_summary_highlight_other": "{{count}} mentions", - "no_post_permission": "You do not have permission to post in this room", "empty_dm": "The hardest part is the first message.", "empty_dm_alt_1": "You have to start somewhere.", "empty_dm_alt_2": "Someone has to go first.", @@ -693,7 +757,43 @@ "autocomplete_rooms": "Rooms", "autocomplete_emojis": "Emojis", "autocomplete_commands": "Commands", - "autocomplete_unknown_room": "Unknown Room" + "autocomplete_unknown_room": "Unknown Room", + "lobby_join": "Join", + "lobby_unknown": "Unknown", + "lobby_suggested": "Suggested", + "lobby_inaccessible": "Inaccessible", + "lobby_open_room": "Open Room", + "lobby_rooms": "Rooms", + "lobby_scroll_to_top": "Scroll to Top", + "lobby_reordering": "Reordering", + "lobby_space_settings": "Space Settings", + "lobby_leave_space": "Leave Space", + "lobby_members_count_one": "{{formattedCount}} Member", + "lobby_members_count_other": "{{formattedCount}} Members", + "msg_search_scroll_to_top": "Scroll to Top", + "leave_space_title": "Leave Space", + "leave_space_confirm": "Are you sure you want to leave this space?", + "leave_space_error": "Failed to leave space! {{error}}", + "upload_spoiler": "Spoiler", + "upload_retry": "Retry", + "upload_retry_label": "Retry Upload", + "upload_cancel_label": "Cancel Upload", + "upload_too_large": "File too large", + "format_bold": "Bold", + "format_italic": "Italic", + "format_underline": "Underline", + "format_strikethrough": "Strike Through", + "format_inline_code": "Inline Code", + "format_spoiler": "Spoiler", + "format_block_quote": "Block Quote", + "format_block_code": "Block Code", + "format_ordered_list": "Ordered List", + "format_unordered_list": "Unordered List", + "format_heading": "Heading {{level}}", + "format_exit_formatting": "Exit Formatting", + "format_exit": "Exit", + "format_markdown_disable": "Disable Markdown", + "format_markdown_enable": "Enable Markdown" }, "Inbox": { "invite_title": "Invite", @@ -890,38 +990,19 @@ "sort_z_to_a": "Z to A", "sort_newest": "Newest", "sort_oldest": "Oldest", - "perm_messages": "Messages", - "perm_send_messages": "Send Messages", - "perm_send_stickers": "Send Stickers", - "perm_send_reactions": "Send Reactions", - "perm_ping_room": "Ping @room", - "perm_pin_messages": "Pin Messages", "perm_other_message_events": "Other Message Events", - "perm_calls": "Calls", - "perm_join_call": "Join Call", "perm_moderation": "Moderation", "perm_invite": "Invite", "perm_kick": "Kick", "perm_ban": "Ban", - "perm_delete_others_messages": "Delete Others' Messages", - "perm_delete_self_messages": "Delete Self Messages", - "perm_room_overview": "Room Overview", - "perm_room_avatar": "Room Avatar", - "perm_room_name": "Room Name", - "perm_room_topic": "Room Topic", "perm_settings": "Settings", - "perm_change_room_access": "Change Room Access", "perm_publish_address": "Publish Address", "perm_change_all_permission": "Change All Permissions", "perm_edit_power_levels": "Edit Power Levels", - "perm_enable_encryption": "Enable Encryption", - "perm_history_visibility": "History Visibility", - "perm_upgrade_room": "Upgrade Room", "perm_other_settings": "Other Settings", "perm_other": "Other", "perm_manage_emojis_stickers": "Manage Emojis & Stickers", "perm_change_server_acls": "Change Server ACLs", - "perm_modify_widgets": "Modify Widgets", "founders": "Founders", "founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.", "power_levels": "Power Levels", @@ -1113,6 +1194,8 @@ "blocked_title": "Blocked User", "blocked_description": "You do not receive any messages or invites from this user.", "profile_title": "Profile", + "back": "Back", + "manage_powers": "Manage roles", "presence_online": "Online", "presence_unavailable": "Idle", "presence_offline": "Offline", @@ -1123,7 +1206,7 @@ "last_seen_hours_other": "Last seen {{count}} hours ago", "last_seen_yesterday": "Last seen yesterday at {{time}}", "last_seen_date": "Last seen {{date}}", - "row_id": "id", + "row_id": "nick", "row_server": "server", "row_role": "role", "row_mutual": "shared", @@ -1137,7 +1220,22 @@ "copy_user_link": "Copy user link", "copy_server": "Copy server", "explore_community": "Explore community", - "open_in_browser": "Open in browser" + "open_in_browser": "Open in browser", + "moderation_title": "Moderation", + "moderation_reason": "Reason", + "moderation_reason_label": "Reason:", + "moderation_no_reason": "No reason provided.", + "moderation_kicked_user": "Kicked User", + "moderation_banned_user": "Banned User", + "moderation_invited_user": "Invited User", + "moderation_kicked_by": "Kicked by:", + "moderation_banned_by": "Banned by:", + "moderation_invited_by": "Invited by:", + "moderation_unban": "Unban", + "moderation_cancel_invite": "Cancel Invite", + "moderation_invite": "Invite", + "moderation_kick": "Kick", + "moderation_ban": "Ban" }, "Share": { "share_text": "Ready to share text", @@ -1148,5 +1246,16 @@ "share_files": "Ready to share {{count}} files", "tap_chat_to_send": "Open a chat to drop it in", "cancel": "Cancel share" + }, + "Common": { + "close": "Close", + "retry": "Retry" + }, + "MediaViewer": { + "zoom_out": "Zoom out", + "zoom_in": "Zoom in", + "download": "Download", + "previous": "Previous", + "next": "Next" } } diff --git a/public/locales/ru.json b/public/locales/ru.json index e29567c1..8e59809f 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -103,15 +103,24 @@ "reset_button": "Сбросить пароль", "reset_error_fallback": "Не удалось сбросить пароль.", "reset_success_message": "Пароль успешно сброшен. Войдите с новым паролем.", - "reset_success_login": "Войти" + "reset_success_login": "Войти", + "uia_email_label": "Эл. почта", + "uia_email_send_button": "Отправить письмо для подтверждения", + "uia_email_cancel": "Отмена", + "uia_email_sending": "Отправка письма для подтверждения...", + "uia_email_verify_title": "Подтверждение почты", + "uia_email_send_error": "Не удалось отправить письмо для подтверждения.", + "uia_email_sent_title": "Письмо для подтверждения отправлено", + "uia_email_sent_message": "Проверьте почту \"{{email}}\" и подтвердите адрес, прежде чем продолжить.", + "uia_email_continue": "Продолжить", + "uia_email_provide_title": "Укажите эл. почту", + "uia_email_provide_message": "Укажите адрес эл. почты, чтобы отправить письмо для подтверждения." }, "Settings": { "title": "Настройки", "menu_general": "Общие", - "menu_account": "Аккаунт", "menu_notifications": "Уведомления", "menu_devices": "Устройства", - "menu_emojis_stickers": "Эмодзи и стикеры", "menu_about": "О приложении", "network": { "menu": "Соединение", @@ -224,10 +233,6 @@ "fsi_prompt_body": "Разрешите Vojo показывать полноэкранные уведомления — тогда входящие звонки будут будить экран и появляться поверх блокировки, как в WhatsApp или Telegram. Откройте «Уведомления поверх экрана блокировки» и включите переключатель для Vojo.", "fsi_prompt_later": "Позже", "fsi_prompt_open": "Открыть настройки", - "email_notification": "Уведомления по почте", - "email_no_email": "К вашему аккаунту не привязана электронная почта.", - "email_send_notif": "Отправлять уведомления на вашу почту.", - "email_send_notif_to": "Отправлять уведомления на вашу почту. (\"{{email}}\")", "unexpected_error": "Непредвиденная ошибка!", "all_messages": "Все сообщения", "one_to_one": "Личные чаты", @@ -302,21 +307,11 @@ "import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.", "import": "Импорт", "decrypt": "Расшифровать", - "emojis_stickers_title": "Эмодзи и стикеры", - "default_pack": "Пакет по умолчанию", "unknown": "Неизвестно", "view": "Открыть", - "favorite_packs": "Избранные пакеты", - "select_pack": "Выбрать пакет", - "select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.", "select": "Выбрать", - "room_packs": "Пакеты комнат", - "select_all": "Выбрать все", - "unselect_all": "Снять выделение", "no_packs": "Нет пакетов", "no_packs_desc": "Здесь появятся пакеты из комнат. У вас пока нет комнат с пакетами.", - "apply_error": "Не удалось применить изменения! Попробуйте снова.", - "apply_ready": "Изменения сохранены! Примените, когда будете готовы.", "apply_changes": "Применить изменения", "about_title": "О приложении", "about_tagline": "Вседоступный мессенджер.", @@ -333,7 +328,58 @@ "notify_on_mention": "Упоминания", "notify_on_mention_desc": "Уведомлять, когда упоминают моё имя или ник.", "room_announcements": "Объявления @room", - "room_announcements_desc": "Уведомлять о сообщениях с @room." + "room_announcements_desc": "Уведомлять о сообщениях с @room.", + "verification_close": "Закрыть", + "verification_accept_prompt": "Примите запрос на другом устройстве.", + "verification_waiting_accept": "Ожидание подтверждения запроса...", + "verification_click_accept": "Нажмите «Принять», чтобы начать верификацию.", + "verification_accept": "Принять", + "verification_request_accepted": "Запрос на верификацию принят.", + "verification_waiting_response": "Ожидание ответа от другого устройства...", + "verification_starting_emoji": "Начинаем верификацию сравнением эмодзи...", + "verification_compare_emoji_prompt": "Убедитесь, что на обоих устройствах показаны одни и те же эмодзи в одинаковом порядке:", + "verification_match": "Совпадают", + "verification_no_match": "Не совпадают", + "verification_device_verified": "Ваше устройство верифицировано.", + "verification_okay": "Понятно", + "verification_canceled": "Верификация отменена.", + "verification_unexpected_error": "Непредвиденная ошибка! Верификация началась, но обработчик проверки не найден.", + "verification_setup_desc": "Создайте ключ восстановления — он подтвердит вашу личность, если другие устройства недоступны. Дополнительно можно задать парольную фразу, которую проще запомнить.", + "verification_passphrase_optional": "Парольная фраза (необязательно)", + "verification_continue": "Продолжить", + "verification_error_generic": "Непредвиденная ошибка!", + "verification_uia_unsupported": "Клиент не поддерживает шаги аутентификации, необходимые для этого действия.", + "verification_recovery_key_store_desc": "Сохраните ключ восстановления в надёжном месте: он понадобится для подтверждения личности, если другие устройства будут недоступны.", + "verification_recovery_key": "Ключ восстановления", + "verification_show": "Показать", + "verification_hide": "Скрыть", + "verification_copy": "Скопировать", + "verification_download": "Скачать", + "verification_setup_title": "Настройка верификации устройства", + "verification_reset_title": "Сброс верификации устройства", + "verification_reset_permanent": "Сброс верификации устройства необратим.", + "verification_reset_warning": "Все, с кем вы проходили верификацию, увидят предупреждения безопасности, а резервная копия шифрования будет потеряна. Делайте это, только если вы потеряли ключ восстановления или парольную фразу и все устройства, с которых можно пройти верификацию.", + "verification_recovery_passphrase": "Парольная фраза восстановления", + "verification_select_method": "Выберите способ верификации.", + "verification_provide_key": "Введите ключ восстановления.", + "verification_verified_success": "Устройство верифицировано!", + "verification_step_open": "Откройте «{{settings}}».", + "verification_step_find": "Найдите это устройство в разделе «{{section}}».", + "backup_connected": "Подключено", + "backup_disconnected": "Отключено", + "backup_syncing": "Синхронизация ({{amount}})", + "backup_restoring_percent": "Восстановление: {{percent}}%", + "backup_trusted_decryption_key": "У резервной копии есть доверенный ключ расшифровки.", + "backup_untrusted_decryption_key": "У резервной копии нет доверенного ключа расшифровки!", + "backup_trusted_signature": "Резервная копия заверена доверенной подписью.", + "backup_untrusted_signature": "У резервной копии нет доверенной подписи!", + "backup_encryption_title": "Резервная копия шифрования", + "backup_details": "Сведения о резервной копии", + "backup_version": "Версия: {{version}}", + "backup_keys_count": "Ключей: {{amount}}", + "backup_none_value": "нет", + "backup_restore": "Восстановить резервную копию", + "backup_none_on_server": "На сервере нет резервной копии!" }, "Search": { "search": "Поиск", @@ -523,11 +569,28 @@ "bubble_missed_count_one": "{{count}} пропущенный звонок", "bubble_missed_count_few": "{{count}} пропущенных звонка", "bubble_missed_count_many": "{{count}} пропущенных звонков", + "bubble_missed_count_other": "{{count}} пропущенных звонков", "bubble_cancelled_count_one": "{{count}} отменённый звонок", "bubble_cancelled_count_few": "{{count}} отменённых звонка", "bubble_cancelled_count_many": "{{count}} отменённых звонков", + "bubble_cancelled_count_other": "{{count}} отменённых звонков", "duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек", - "duration_seconds": "{{seconds}} сек" + "duration_seconds": "{{seconds}} сек", + "view_grid": "Вид галереи", + "view_spotlight": "Вид докладчика", + "reactions": "Реакции", + "settings": "Настройки", + "homeserver_no_calls": "Ваш сервер не поддерживает звонки, но вы можете присоединиться к звонку, который начал кто-то другой.", + "empty_be_first": "В голосовом чате пусто — присоединяйтесь первым!", + "no_permission_to_join": "У вас нет прав, чтобы присоединиться!", + "already_in_other_call": "Вы уже в другом звонке — завершите его, чтобы присоединиться!", + "participant": "Участники", + "live_count": "{{count}} в эфире", + "collapse": "Свернуть", + "others_count_one": "Ещё {{count}} участник", + "others_count_few": "Ещё {{count}} участника", + "others_count_many": "Ещё {{count}} участников", + "others_count_other": "Ещё {{count}} участников" }, "Room": { "delivery": { @@ -540,7 +603,6 @@ "collapse_avatar": "Свернуть аватар", "expand_avatar": "Развернуть аватар", "new_messages": "Новые сообщения", - "jump_to_unread": "К непрочитанным", "mark_as_read": "Отметить прочитанным", "jump_to_latest": "К последним", "message_render_error": "Не удалось показать это сообщение.", @@ -548,6 +610,15 @@ "yesterday": "Вчера", "view_reactions": "Реакции", "read_receipts": "Подтверждения прочтения", + "seen_by": "Прочитали", + "seen_by_count_one": "{{count}} человек", + "seen_by_count_few": "{{count}} человека", + "seen_by_count_many": "{{count}} человек", + "seen_by_count_other": "{{count}} человек", + "seen_by_empty": "Это сообщение пока никто не прочитал.", + "editing_message": "Редактирование", + "editing_cancel": "Отменить редактирование", + "no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате", "view_source": "Исходный код", "source_code": "Исходный код", "copy_link": "Копировать ссылку", @@ -668,7 +739,6 @@ "thread_summary_highlight_few": "{{count}} упоминания", "thread_summary_highlight_many": "{{count}} упоминаний", "thread_summary_highlight_other": "{{count}} упоминания", - "no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате", "empty_dm": "Самое сложное — первое сообщение.", "empty_dm_alt_1": "С чего-то надо начать.", "empty_dm_alt_2": "Кто-то должен написать первым.", @@ -709,7 +779,45 @@ "autocomplete_rooms": "Комнаты", "autocomplete_emojis": "Эмодзи", "autocomplete_commands": "Команды", - "autocomplete_unknown_room": "Неизвестная комната" + "autocomplete_unknown_room": "Неизвестная комната", + "lobby_join": "Присоединиться", + "lobby_unknown": "Неизвестно", + "lobby_suggested": "Рекомендуется", + "lobby_inaccessible": "Недоступно", + "lobby_open_room": "Открыть комнату", + "lobby_rooms": "Комнаты", + "lobby_scroll_to_top": "Наверх", + "lobby_reordering": "Перемещение", + "lobby_space_settings": "Настройки сообщества", + "lobby_leave_space": "Покинуть сообщество", + "lobby_members_count_one": "{{formattedCount}} участник", + "lobby_members_count_few": "{{formattedCount}} участника", + "lobby_members_count_many": "{{formattedCount}} участников", + "lobby_members_count_other": "{{formattedCount}} участников", + "msg_search_scroll_to_top": "Наверх", + "leave_space_title": "Покинуть сообщество", + "leave_space_confirm": "Покинуть это сообщество?", + "leave_space_error": "Не удалось покинуть сообщество! {{error}}", + "upload_spoiler": "Спойлер", + "upload_retry": "Повторить", + "upload_retry_label": "Повторить загрузку", + "upload_cancel_label": "Отменить загрузку", + "upload_too_large": "Файл слишком большой", + "format_bold": "Жирный", + "format_italic": "Курсив", + "format_underline": "Подчёркнутый", + "format_strikethrough": "Зачёркнутый", + "format_inline_code": "Код", + "format_spoiler": "Спойлер", + "format_block_quote": "Цитата", + "format_block_code": "Блок кода", + "format_ordered_list": "Нумерованный список", + "format_unordered_list": "Маркированный список", + "format_heading": "Заголовок {{level}}", + "format_exit_formatting": "Выйти из форматирования", + "format_exit": "Выйти", + "format_markdown_disable": "Отключить Markdown", + "format_markdown_enable": "Включить Markdown" }, "Inbox": { "invite_title": "Пригласить", @@ -908,38 +1016,19 @@ "sort_z_to_a": "Я — А", "sort_newest": "Новые", "sort_oldest": "Старые", - "perm_messages": "Сообщения", - "perm_send_messages": "Отправка сообщений", - "perm_send_stickers": "Отправка стикеров", - "perm_send_reactions": "Отправка реакций", - "perm_ping_room": "Упоминание @room", - "perm_pin_messages": "Закрепление сообщений", "perm_other_message_events": "Прочие события сообщений", - "perm_calls": "Звонки", - "perm_join_call": "Присоединиться к звонку", "perm_moderation": "Модерация", "perm_invite": "Приглашение", "perm_kick": "Исключение", "perm_ban": "Бан", - "perm_delete_others_messages": "Удаление чужих сообщений", - "perm_delete_self_messages": "Удаление своих сообщений", - "perm_room_overview": "Обзор комнаты", - "perm_room_avatar": "Аватар комнаты", - "perm_room_name": "Название комнаты", - "perm_room_topic": "Тема комнаты", "perm_settings": "Настройки", - "perm_change_room_access": "Изменение доступа к комнате", "perm_publish_address": "Публикация адреса", "perm_change_all_permission": "Изменение всех прав", "perm_edit_power_levels": "Редактирование уровней власти", - "perm_enable_encryption": "Включение шифрования", - "perm_history_visibility": "Видимость истории", - "perm_upgrade_room": "Обновление комнаты", "perm_other_settings": "Прочие настройки", "perm_other": "Прочее", "perm_manage_emojis_stickers": "Управление эмодзи и стикерами", "perm_change_server_acls": "Изменение ACL серверов", - "perm_modify_widgets": "Изменение виджетов", "founders": "Основатели", "founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.", "power_levels": "Уровни власти", @@ -1131,6 +1220,8 @@ "blocked_title": "Заблокирован", "blocked_description": "Сообщения и приглашения от этого пользователя не приходят.", "profile_title": "Профиль", + "back": "Назад", + "manage_powers": "Управление ролями", "presence_online": "В сети", "presence_unavailable": "Не активен", "presence_offline": "Не в сети", @@ -1138,12 +1229,14 @@ "last_seen_minutes_one": "Был в сети {{count}} минуту назад", "last_seen_minutes_few": "Был в сети {{count}} минуты назад", "last_seen_minutes_many": "Был в сети {{count}} минут назад", + "last_seen_minutes_other": "Был в сети {{count}} минут назад", "last_seen_hours_one": "Был в сети {{count}} час назад", "last_seen_hours_few": "Был в сети {{count}} часа назад", "last_seen_hours_many": "Был в сети {{count}} часов назад", + "last_seen_hours_other": "Был в сети {{count}} часов назад", "last_seen_yesterday": "Был в сети вчера в {{time}}", "last_seen_date": "Был в сети {{date}}", - "row_id": "id", + "row_id": "ник", "row_server": "сервер", "row_role": "роль", "row_mutual": "общие", @@ -1154,11 +1247,27 @@ "row_mutual_count_one": "{{count}} чат", "row_mutual_count_few": "{{count}} чата", "row_mutual_count_many": "{{count}} чатов", + "row_mutual_count_other": "{{count}} чатов", "copy_user_id": "Скопировать ID", "copy_user_link": "Скопировать ссылку", "copy_server": "Скопировать сервер", "explore_community": "Открыть сервер", - "open_in_browser": "Открыть в браузере" + "open_in_browser": "Открыть в браузере", + "moderation_title": "Модерация", + "moderation_reason": "Причина", + "moderation_reason_label": "Причина:", + "moderation_no_reason": "Причина не указана.", + "moderation_kicked_user": "Пользователь исключён", + "moderation_banned_user": "Пользователь забанен", + "moderation_invited_user": "Пользователь приглашён", + "moderation_kicked_by": "Исключил(а):", + "moderation_banned_by": "Забанил(а):", + "moderation_invited_by": "Пригласил(а):", + "moderation_unban": "Разбанить", + "moderation_cancel_invite": "Отменить приглашение", + "moderation_invite": "Пригласить", + "moderation_kick": "Исключить", + "moderation_ban": "Забанить" }, "Share": { "share_text": "Готово к пересылке: текст", @@ -1169,5 +1278,16 @@ "share_files": "Готово к пересылке: {{count}} файлов", "tap_chat_to_send": "Откройте чат, чтобы отправить", "cancel": "Отменить" + }, + "Common": { + "close": "Закрыть", + "retry": "Повторить" + }, + "MediaViewer": { + "zoom_out": "Уменьшить", + "zoom_in": "Увеличить", + "download": "Скачать", + "previous": "Назад", + "next": "Вперёд" } } diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 068f437b..033f89cf 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -19,6 +19,7 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore'; import { InfoCard } from './info-card'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; @@ -35,6 +36,7 @@ type BackupStatusProps = { enabled: boolean; }; function BackupStatus({ enabled }: BackupStatusProps) { + const { t } = useTranslation(); return ( @@ -43,7 +45,7 @@ function BackupStatus({ enabled }: BackupStatusProps) { size="L400" style={{ color: enabled ? color.Success.Main : color.Critical.Main }} > - {enabled ? 'Connected' : 'Disconnected'} + {enabled ? t('Settings.backup_connected') : t('Settings.backup_disconnected')} ); @@ -52,21 +54,23 @@ type BackupSyncingProps = { count: number; }; function BackupSyncing({ count }: BackupSyncingProps) { + const { t } = useTranslation(); return ( - Syncing ({count}) + {t('Settings.backup_syncing', { amount: count })} ); } function BackupProgressFetching() { + const { t } = useTranslation(); return ( - Restoring: 0% + {t('Settings.backup_restoring_percent', { percent: 0 })} @@ -81,10 +85,15 @@ type BackupProgressProps = { downloaded: number; }; function BackupProgress({ total, downloaded }: BackupProgressProps) { + const { t } = useTranslation(); return ( - Restoring: {`${Math.round(percent(0, total, downloaded))}%`} + + {t('Settings.backup_restoring_percent', { + percent: Math.round(percent(0, total, downloaded)), + })} + @@ -103,6 +112,7 @@ type BackupTrustInfoProps = { backupInfo: KeyBackupInfo; }; function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) { + const { t } = useTranslation(); const trust = useKeyBackupTrust(crypto, backupInfo); if (!trust) return null; @@ -111,20 +121,20 @@ function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) { {trust.matchesDecryptionKey ? ( - Backup has trusted decryption key. + {t('Settings.backup_trusted_decryption_key')} ) : ( - Backup does not have trusted decryption key! + {t('Settings.backup_untrusted_decryption_key')} )} {trust.trusted ? ( - Backup has trusted by signature. + {t('Settings.backup_trusted_signature')} ) : ( - Backup does not have trusted signature! + {t('Settings.backup_untrusted_signature')} )} @@ -135,6 +145,7 @@ type BackupRestoreTileProps = { crypto: CryptoApi; }; export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { + const { t } = useTranslation(); const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom); const restoring = restoreProgress.status === BackupProgressStatus.Fetching || @@ -168,7 +179,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { return ( {remainingSession === 0 ? ( @@ -212,12 +223,20 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { - Version: {backupInfo?.version ?? 'NIL'} + + {t('Settings.backup_version', { + version: backupInfo?.version ?? t('Settings.backup_none_value'), + })} +
- Keys: {backupInfo?.count ?? 'NIL'} + + {t('Settings.backup_keys_count', { + amount: backupInfo?.count ?? t('Settings.backup_none_value'), + })} + } /> @@ -234,7 +253,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { } before={} > - Restore Backup + {t('Settings.backup_restore')}
@@ -251,7 +270,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { )} {!backupEnabled && backupInfo === null && ( - No backup present on server! + {t('Settings.backup_none_on_server')} )} {!syncFailure && !backupEnabled && backupInfo && ( diff --git a/src/app/components/DeviceVerification.tsx b/src/app/components/DeviceVerification.tsx index d2502ccf..dfd70aa0 100644 --- a/src/app/components/DeviceVerification.tsx +++ b/src/app/components/DeviceVerification.tsx @@ -22,6 +22,7 @@ import { Text, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { useVerificationRequestPhase, useVerificationRequestReceived, @@ -50,21 +51,23 @@ function WaitingMessage({ message }: WaitingMessageProps) { type VerificationUnexpectedProps = { message: string; onClose: () => void }; function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) { + const { t } = useTranslation(); return ( {message} ); } function VerificationWaitAccept() { + const { t } = useTranslation(); return ( - Please accept the request from other device. - + {t('Settings.verification_accept_prompt')} + ); } @@ -73,12 +76,13 @@ type VerificationAcceptProps = { onAccept: () => Promise; }; function VerificationAccept({ onAccept }: VerificationAcceptProps) { + const { t } = useTranslation(); const [acceptState, accept] = useAsyncCallback(onAccept); const accepting = acceptState.status === AsyncStatus.Loading; return ( - Click accept to start the verification process. + {t('Settings.verification_click_accept')} ); } function VerificationWaitStart() { + const { t } = useTranslation(); return ( - Verification request has been accepted. - + {t('Settings.verification_request_accepted')} + ); } @@ -105,18 +110,20 @@ type VerificationStartProps = { onStart: () => Promise; }; function AutoVerificationStart({ onStart }: VerificationStartProps) { + const { t } = useTranslation(); useEffect(() => { onStart(); }, [onStart]); return ( - + ); } function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { + const { t } = useTranslation(); const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData])); const confirming = @@ -124,7 +131,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) { return ( - Confirm the emoji below are displayed on both devices, in the same order: + {t('Settings.verification_compare_emoji_prompt')} } > - They Match + {t('Settings.verification_match')} @@ -177,6 +184,7 @@ type SasVerificationProps = { onCancel: () => void; }; function SasVerification({ verifier, onCancel }: SasVerificationProps) { + const { t } = useTranslation(); const [sasData, setSasData] = useState(); useVerifierShowSas(verifier, setSasData); @@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) { return ( - + ); } @@ -201,13 +209,14 @@ type VerificationDoneProps = { onExit: () => void; }; function VerificationDone({ onExit }: VerificationDoneProps) { + const { t } = useTranslation(); return (
- Your device is verified. + {t('Settings.verification_device_verified')}
); @@ -217,11 +226,12 @@ type VerificationCanceledProps = { onClose: () => void; }; function VerificationCanceled({ onClose }: VerificationCanceledProps) { + const { t } = useTranslation(); return ( - Verification has been canceled. + {t('Settings.verification_canceled')} ); @@ -232,6 +242,7 @@ type DeviceVerificationProps = { onExit: () => void; }; export function DeviceVerification({ request, onExit }: DeviceVerificationProps) { + const { t } = useTranslation(); const phase = useVerificationRequestPhase(request); const handleCancel = useCallback(() => { @@ -259,7 +270,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
- Device Verification + {t('Settings.device_verification')} @@ -283,7 +294,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps) ) : ( ))} diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 0769402e..35353311 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -16,6 +16,7 @@ import { import FileSaver from 'file-saver'; import to from 'await-to-js'; import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; +import { useTranslation } from 'react-i18next'; import { PasswordInput } from './password-input'; import { ContainerColor } from '../styles/ContainerColor.css'; import { copyToClipboard } from '../utils/dom'; @@ -71,6 +72,7 @@ type SetupVerificationProps = { onComplete: (recoveryKey: string) => void; }; function SetupVerification({ onComplete }: SetupVerificationProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const alive = useAlive(); @@ -181,12 +183,9 @@ function SetupVerification({ onComplete }: SetupVerificationProps) { return ( - - Generate a Recovery Key for verifying identity if you do not have access to other - devices. Additionally, setup a passphrase as a memorable alternative. - + {t('Settings.verification_setup_desc')} - Passphrase (Optional) + {t('Settings.verification_passphrase_optional')} {setupState.status === AsyncStatus.Error && ( - {setupState.error ? setupState.error.message : 'Unexpected Error!'} + + {setupState.error ? setupState.error.message : t('Settings.verification_error_generic')} + )} {nextAuthData !== null && uiaAction && ( ( - - Authentication steps to perform this action are not supported by client. - - )} + unsupported={() => {t('Settings.verification_uia_unsupported')}} > {(ongoingFlow) => ( { @@ -245,12 +243,9 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { return ( - - Store the Recovery Key in a safe place for future use, as you will need it to verify your - identity if you do not have access to other devices. - + {t('Settings.verification_recovery_key_store_desc')} - Recovery Key + {t('Settings.verification_recovery_key')} setShow(!show)} variant="Secondary" radii="Pill"> - {show ? 'Hide' : 'Show'} + + {show ? t('Settings.verification_hide') : t('Settings.verification_show')} + @@ -286,6 +283,7 @@ type DeviceVerificationSetupProps = { }; export const DeviceVerificationSetup = forwardRef( ({ onCancel }, ref) => { + const { t } = useTranslation(); const [recoveryKey, setRecoveryKey] = useState(); return ( @@ -299,7 +297,7 @@ export const DeviceVerificationSetup = forwardRef - Setup Device Verification + {t('Settings.verification_setup_title')} @@ -321,6 +319,7 @@ type DeviceVerificationResetProps = { }; export const DeviceVerificationReset = forwardRef( ({ onCancel }, ref) => { + const { t } = useTranslation(); const [reset, setReset] = useState(false); return ( @@ -334,7 +333,7 @@ export const DeviceVerificationReset = forwardRef - Reset Device Verification + {t('Settings.verification_reset_title')} @@ -356,16 +355,11 @@ export const DeviceVerificationReset = forwardRef ✋🧑‍🚒🤚 - Resetting device verification is permanent. - - Anyone you have verified with will see security alerts and your encryption backup - will be lost. You almost certainly do not want to do this, unless you have lost{' '} - Recovery Key or Recovery Passphrase and every device you can verify - from. - + {t('Settings.verification_reset_permanent')} + {t('Settings.verification_reset_warning')} )} diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx deleted file mode 100644 index 1d0d1ce9..00000000 --- a/src/app/components/LogoutDialog.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import React, { forwardRef, useCallback } from 'react'; -import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; -import { useTranslation } from 'react-i18next'; -import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; -import { logoutClient } from '../../client/initMatrix'; -import { useMatrixClient } from '../hooks/useMatrixClient'; -import { useCrossSigningActive } from '../hooks/useCrossSigning'; -import { InfoCard } from './info-card'; -import { - useDeviceVerificationStatus, - VerificationStatus, -} from '../hooks/useDeviceVerificationStatus'; - -type LogoutDialogProps = { - handleClose: () => void; -}; -export const LogoutDialog = forwardRef( - ({ handleClose }, ref) => { - const { t } = useTranslation(); - const mx = useMatrixClient(); - const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); - const crossSigningActive = useCrossSigningActive(); - const verificationStatus = useDeviceVerificationStatus( - mx.getCrypto(), - mx.getSafeUserId(), - mx.getDeviceId() ?? undefined - ); - - const [logoutState, logout] = useAsyncCallback( - useCallback(async () => { - await logoutClient(mx); - }, [mx]) - ); - - const ongoingLogout = logoutState.status === AsyncStatus.Loading; - - return ( - -
- - {t('Settings.logout')} - -
- - {hasEncryptedRoom && - (crossSigningActive ? ( - verificationStatus === VerificationStatus.Unverified && ( - - ) - ) : ( - - ))} - {t('Settings.logout_confirm')} - {logoutState.status === AsyncStatus.Error && ( - - {t('Settings.logout_failed', { message: logoutState.error.message })} - - )} - - - - - -
- ); - } -); diff --git a/src/app/components/ManualVerification.tsx b/src/app/components/ManualVerification.tsx index f7cde92b..91ad9631 100644 --- a/src/app/components/ManualVerification.tsx +++ b/src/app/components/ManualVerification.tsx @@ -13,6 +13,7 @@ import { color, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { stopPropagation } from '../utils/keyboard'; import { SettingTile } from './setting-tile'; import { SecretStorageKeyContent } from '../../types/matrix/accountData'; @@ -33,6 +34,7 @@ export function ManualVerificationMethodSwitcher({ value, onChange, }: ManualVerificationMethodSwitcherProps) { + const { t } = useTranslation(); const [menuCords, setMenuCords] = useState(); const handleMenu: MouseEventHandler = (evt) => { @@ -55,8 +57,10 @@ export function ManualVerificationMethodSwitcher({ onClick={handleMenu} > - {value === ManualVerificationMethod.RecoveryPassphrase && 'Recovery Passphrase'} - {value === ManualVerificationMethod.RecoveryKey && 'Recovery Key'} + {value === ManualVerificationMethod.RecoveryPassphrase && + t('Settings.verification_recovery_passphrase')} + {value === ManualVerificationMethod.RecoveryKey && + t('Settings.verification_recovery_key')} handleSelect(ManualVerificationMethod.RecoveryPassphrase)} > - Recovery Passphrase + {t('Settings.verification_recovery_passphrase')} handleSelect(ManualVerificationMethod.RecoveryKey)} > - Recovery Key + {t('Settings.verification_recovery_key')} @@ -120,6 +124,7 @@ export function ManualVerificationTile({ secretStorageKeyContent, options, }: ManualVerificationTileProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const hasPassphrase = !!secretStorageKeyContent.passphrase; @@ -154,8 +159,12 @@ export function ManualVerificationTile({ return ( {hasPassphrase && ( @@ -167,7 +176,7 @@ export function ManualVerificationTile({ /> {verifyState.status === AsyncStatus.Success ? ( - Device verified! + {t('Settings.verification_verified_success')} ) : ( diff --git a/src/app/components/Modal500.css.ts b/src/app/components/Modal500.css.ts new file mode 100644 index 00000000..be88a94d --- /dev/null +++ b/src/app/components/Modal500.css.ts @@ -0,0 +1,54 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Settings-window shell (room / space settings) — the self-styled Vojo card +// vocabulary (AiChatPrivacy / LogoutDialog): own surface, 16px radius, +// hairline border, deep shadow. Replaces the stock folds `Modal` whose flat +// square-ish chrome read as the old cinny dialog. The card only provides +// the shell; the content inside (PageRoot nav + page) paints its own panel +// tones. +export const Card = style({ + width: `calc(100vw - 2 * ${config.space.S400})`, + maxWidth: toRem(860), + height: '85vh', + maxHeight: toRem(700), + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + backgroundColor: color.Background.Container, + borderRadius: toRem(16), + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)', + '@media': { + // Narrow viewports: the window goes (near) full-screen — the rounded + // floating card needs breathing room it doesn't have on a phone. + // `--vojo-safe-top` is reset to 0 INSIDE the card (the TSX), so the + // status-bar clearance comes straight from env() here — the card is + // genuinely top-of-screen in this branch. + 'screen and (max-width: 600px)': { + width: '100vw', + maxWidth: '100vw', + height: '100dvh', + maxHeight: '100dvh', + borderRadius: 0, + border: 'none', + paddingTop: 'env(safe-area-inset-top, 0px)', + }, + }, +}); + +// Single-pane variant (room settings): no nav column, so the two-pane +// width reads as a stretched empty form. 560px ≈ the page pane's share of +// the wide layout (860 minus the ~300px nav). The media override keeps the +// phone branch full-screen — this class is emitted after `Card`, so its +// base maxWidth would otherwise beat Card's `100vw` media rule in the +// cascade. +export const CardNarrow = style({ + maxWidth: toRem(560), + '@media': { + 'screen and (max-width: 600px)': { + maxWidth: '100vw', + }, + }, +}); diff --git a/src/app/components/Modal500.tsx b/src/app/components/Modal500.tsx index 43dca162..584ba31d 100644 --- a/src/app/components/Modal500.tsx +++ b/src/app/components/Modal500.tsx @@ -1,14 +1,23 @@ import React, { ReactNode } from 'react'; +import classNames from 'classnames'; import FocusTrap from 'focus-trap-react'; -import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds'; +import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds'; import { stopPropagation } from '../utils/keyboard'; import { HorseshoeEnabledContext } from './page'; +import * as css from './Modal500.css'; type Modal500Props = { requestClose: () => void; + // Single-pane variant (room settings) — the wide default fits the + // two-pane nav+page shell (space settings). + narrow?: boolean; children: ReactNode; }; -export function Modal500({ requestClose, children }: Modal500Props) { + +// Settings-window shell (room / space settings). A self-styled Vojo card +// (see Modal500.css) instead of the stock folds Modal — near-fullscreen on +// phones, a floating rounded window on desktop. +export function Modal500({ requestClose, narrow, children }: Modal500Props) { return ( }> @@ -20,26 +29,27 @@ export function Modal500({ requestClose, children }: Modal500Props) { escapeDeactivates: stopPropagation, }} > - {/* PageRoot rendered inside the dialog (Settings, SpaceSettings, RoomSettings) would otherwise pick up the web horseshoe layout — void column + rounded - corners inside a fixed 500px shell. Disable horseshoe + corners inside a fixed shell. Disable horseshoe for everything inside the modal. */} {children} - + diff --git a/src/app/components/RoomNotificationSwitcher.tsx b/src/app/components/RoomNotificationSwitcher.tsx index 9f02debf..88299364 100644 --- a/src/app/components/RoomNotificationSwitcher.tsx +++ b/src/app/components/RoomNotificationSwitcher.tsx @@ -1,5 +1,5 @@ import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; -import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react'; +import React, { MouseEventHandler, ReactNode, useMemo, useRef, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { stopPropagation } from '../utils/keyboard'; @@ -55,22 +55,37 @@ export function RoomNotificationModeSwitcher({ const changing = modeState.status === AsyncStatus.Loading; const [menuCords, setMenuCords] = useState(); + // The trigger element of the currently-open popout. Lets the FocusTrap + // treat clicks on the trigger as "ours" instead of outside-clicks. + const anchorElRef = useRef(null); const open = !!menuCords; const handleToggleMenu: MouseEventHandler = (evt) => { - // Second click on the trigger CLOSES the popout instead of re-anchoring (reopening) it. `open` - // is the pre-click render value: even though focus-trap's `clickOutsideDeactivates` also fires - // `onDeactivate` for this same click, both paths resolve to "close" — so there's no reopen race - // regardless of listener order. + // Second click on the trigger CLOSES the popout instead of re-anchoring + // (reopening) it. The naive version was broken: focus-trap's + // `clickOutsideDeactivates` fires on MOUSEDOWN, flushing `open=false` + // and re-rendering before the trigger's CLICK handler ran — so the + // handler's closure always saw the closed state and re-opened. The + // `isAnchorTarget` guards below exclude the trigger from the trap's + // outside-click handling entirely, so this handler is the single owner + // of the trigger interaction and `open` really is the pre-click value. if (open) { setMenuCords(undefined); + anchorElRef.current = null; } else { + anchorElRef.current = evt.currentTarget; setMenuCords(evt.currentTarget.getBoundingClientRect()); } }; const handleClose = () => { setMenuCords(undefined); + anchorElRef.current = null; + }; + + const isAnchorTarget = (evt: MouseEvent | TouchEvent): boolean => { + const anchor = anchorElRef.current; + return !!anchor && evt.target instanceof Node && anchor.contains(evt.target); }; const handleSelect = (mode: RoomNotificationMode) => { @@ -90,7 +105,12 @@ export function RoomNotificationModeSwitcher({ focusTrapOptions={{ initialFocus: false, onDeactivate: handleClose, - clickOutsideDeactivates: true, + // Trigger clicks are NOT outside-clicks: deactivating on the + // trigger's mousedown closed the menu before the click could + // toggle, and the toggle re-opened it (the reopen bug). The + // trigger's own click handler owns open/close instead. + clickOutsideDeactivates: (evt) => !isAnchorTarget(evt), + allowOutsideClick: (evt) => isAnchorTarget(evt), isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown' || evt.key === 'ArrowRight', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft', diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 7d701c42..b96703a2 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -18,6 +18,7 @@ import { toRem, } from 'folds'; import React, { MouseEventHandler, ReactNode, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { ReactEditor, useSlate } from 'slate-react'; import { headingLevel, @@ -119,6 +120,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) { } export function HeadingBlockButton() { + const { t } = useTranslation(); const editor = useSlate(); const level = headingLevel(editor); const [anchor, setAnchor] = useState(); @@ -158,7 +160,12 @@ export function HeadingBlockButton() { } + tooltip={ + + } delay={500} > {(triggerRef) => ( @@ -173,7 +180,12 @@ export function HeadingBlockButton() { )} } + tooltip={ + + } delay={500} > {(triggerRef) => ( @@ -188,7 +200,12 @@ export function HeadingBlockButton() { )} } + tooltip={ + + } delay={500} > {(triggerRef) => ( @@ -224,6 +241,7 @@ export function HeadingBlockButton() { type ExitFormattingProps = { tooltip: ReactNode }; export function ExitFormatting({ tooltip }: ExitFormattingProps) { + const { t } = useTranslation(); const editor = useSlate(); const handleClick = () => { @@ -245,7 +263,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) { size="400" radii="300" > - {`Exit ${KeySymbol.Hyper}`} + {`${t('Room.format_exit')} ${KeySymbol.Hyper}`} )} @@ -253,6 +271,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) { } export function Toolbar() { + const { t } = useTranslation(); const editor = useSlate(); const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl'; const disableInline = isBlockActive(editor, BlockType.CodeBlock); @@ -271,32 +290,38 @@ export function Toolbar() { } + tooltip={} /> } + tooltip={} /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={} /> @@ -305,22 +330,30 @@ export function Toolbar() { } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> } + tooltip={ + + } /> @@ -330,7 +363,10 @@ export function Toolbar() { + } /> @@ -339,7 +375,15 @@ export function Toolbar() { } + tooltip={ + + } delay={500} > {(triggerRef) => ( diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 273d064b..db371808 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -371,7 +371,6 @@ type EmojiBoardProps = { onStickerSelect?: (mxc: string, shortcode: string, label: string) => void; allowTextCustomEmoji?: boolean; addToRecentEmoji?: boolean; - dock?: boolean; }; export function EmojiBoard({ @@ -385,7 +384,6 @@ export function EmojiBoard({ onStickerSelect, allowTextCustomEmoji, addToRecentEmoji = true, - dock, }: EmojiBoardProps) { const mx = useMatrixClient(); const { t } = useTranslation(); @@ -515,7 +513,6 @@ export function EmojiBoard({ }} > {onTabChange && } diff --git a/src/app/components/emoji-board/components/Layout.tsx b/src/app/components/emoji-board/components/Layout.tsx index ce047045..392d4a31 100644 --- a/src/app/components/emoji-board/components/Layout.tsx +++ b/src/app/components/emoji-board/components/Layout.tsx @@ -9,12 +9,11 @@ export const EmojiBoardLayout = as< header: ReactNode; sidebar?: ReactNode; children: ReactNode; - dock?: boolean; } ->(({ className, header, sidebar, children, dock, ...props }, ref) => ( +>(({ className, header, sidebar, children, ...props }, ref) => ( void }) { + return ( + + ); +} export function EmojiBoardTabs({ tab, @@ -17,30 +25,16 @@ export function EmojiBoardTabs({ const { t } = useTranslation(); return ( - onTabChange(EmojiBoardTab.Sticker)} - > - - {t('EmojiBoard.sticker')} - - - onTabChange(EmojiBoardTab.Emoji)} - > - - {t('EmojiBoard.emoji')} - - + /> + onTabChange(EmojiBoardTab.Sticker)} + /> ); } diff --git a/src/app/components/emoji-board/components/styles.css.ts b/src/app/components/emoji-board/components/styles.css.ts index 4669fcd7..f6ac21b1 100644 --- a/src/app/components/emoji-board/components/styles.css.ts +++ b/src/app/components/emoji-board/components/styles.css.ts @@ -1,44 +1,73 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { toRem, color, config, DefaultReset, FocusOutline } from 'folds'; /** * Layout */ +// The emoji/sticker WINDOW — self-styled Vojo card (same vocabulary as the +// other redesigned dialogs: AiChatPrivacy / LogoutDialog): own surface, +// 16px radius, hairline border, deep shadow. Opens as an anchored pop-out +// on desktop and as a centered overlay window on mobile/native (both the +// composer's emoji button and the message rail's reaction picker). export const Base = style({ maxWidth: toRem(432), width: `calc(100vw - 2 * ${config.space.S400})`, height: toRem(450), + maxHeight: '70vh', backgroundColor: color.Surface.Container, color: color.Surface.OnContainer, - // Dawn: a faint hairline + soft shadow instead of the stock solid-bordered - // floating card. - border: `${config.borderWidth.B300} solid rgba(255, 255, 255, 0.08)`, - borderRadius: config.radii.R400, - boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)', + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + borderRadius: toRem(16), + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)', overflow: 'hidden', }); -// Docked variant — the board lives inline at the top of the composer on native -// instead of floating as a pop-out. Full-width, capped height, no shadow/round -// corners (the composer pill already clips it); only a hairline parts it from -// the textarea row below. -export const BaseDock = style({ - maxWidth: 'unset', - width: '100%', - height: toRem(320), - maxHeight: '46vh', - border: 'none', - borderBottom: `${config.borderWidth.B300} solid rgba(255, 255, 255, 0.08)`, - borderRadius: 0, - boxShadow: 'none', -}); - export const Header = style({ padding: config.space.S300, paddingBottom: 0, }); +// Vojo segment tabs (Эмодзи / Стикеры) — the StreamHeader vocabulary: +// reset button, violet dot + label, weight bump on the active one. +export const Tab = style({ + appearance: 'none', + WebkitAppearance: 'none', + border: 'none', + background: 'transparent', + display: 'inline-flex', + alignItems: 'center', + gap: toRem(6), + padding: `${toRem(6)} ${toRem(8)}`, + borderRadius: toRem(8), + cursor: 'pointer', + font: 'inherit', + fontSize: toRem(14), + lineHeight: toRem(19), + fontWeight: 500, + color: color.Surface.OnContainer, + opacity: 0.6, + selectors: { + '&[aria-pressed="true"]': { + fontWeight: 600, + opacity: 1, + }, + }, +}); + +export const TabDot = style({ + width: toRem(6), + height: toRem(6), + borderRadius: '50%', + background: 'transparent', + flexShrink: 0, + selectors: { + [`${Tab}[aria-pressed="true"] &`]: { + background: color.Primary.Main, + }, + }, +}); + /** * Sidebar */ @@ -146,13 +175,16 @@ export const EmojiItem = style([ lineHeight: toRem(32), borderRadius: config.radii.R400, cursor: 'pointer', - - ':hover': { - backgroundColor: color.Surface.ContainerHover, - }, }, ]); +// Mouse-only hover — Android WebView synthesises a sticky `:hover` after a +// tap (alt-tap keeps the board open, so the tint would linger); same +// `data-input` gate as the rest of the app (src/index.tsx). +globalStyle(`:root[data-input="mouse"] ${EmojiItem}:hover`, { + backgroundColor: color.Surface.ContainerHover, +}); + export const StickerItem = style([ EmojiItem, { diff --git a/src/app/components/event-readers/EventReaders.css.ts b/src/app/components/event-readers/EventReaders.css.ts index 36f47b56..a2005b04 100644 --- a/src/app/components/event-readers/EventReaders.css.ts +++ b/src/app/components/event-readers/EventReaders.css.ts @@ -1,21 +1,139 @@ import { style } from '@vanilla-extract/css'; -import { DefaultReset, config } from 'folds'; +import { color, config, toRem } from 'folds'; -export const EventReaders = style([ - DefaultReset, - { - height: '100%', - }, -]); +// Read-receipts card — the self-styled Vojo card vocabulary (AiChatPrivacy / +// LogoutDialog): own surface/radius/border/shadow, badge header with no +// bottom-border bar. Replaces the old folds Modal + hardcoded «Seen by» +// header that read as a bare cinny dialog (and wasn't localized). +export const DialogCard = style({ + width: '90vw', + maxWidth: toRem(360), + maxHeight: '70vh', + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + backgroundColor: color.Surface.Container, + borderRadius: config.radii.R400, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)', +}); export const Header = style({ - paddingLeft: config.space.S400, - paddingRight: config.space.S300, - flexShrink: 0, + display: 'flex', + alignItems: 'flex-start', + gap: config.space.S300, + padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`, }); -export const Content = style({ - paddingLeft: config.space.S200, - paddingBottom: config.space.S400, +export const HeaderBadge = style({ + flexShrink: 0, + width: toRem(40), + height: toRem(40), + borderRadius: config.radii.R400, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: color.Primary.Container, + color: color.Primary.Main, +}); + +export const HeaderText = style({ + flexGrow: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(2), + paddingTop: toRem(2), +}); + +export const HeaderTitle = style({ + fontSize: toRem(18), + fontWeight: 700, + lineHeight: toRem(22), + color: color.Surface.OnContainer, +}); + +export const HeaderSubtitle = style({ + fontSize: toRem(13), + lineHeight: toRem(17), + color: color.SurfaceVariant.OnContainer, + opacity: 0.7, +}); + +export const BodyScroll = style({ + flexGrow: 1, + minHeight: 0, +}); + +export const Body = style({ + display: 'flex', + flexDirection: 'column', + gap: toRem(1), + padding: `0 ${config.space.S200} ${config.space.S300}`, +}); + +// Flat reader row — avatar + (name / read-time) column. Same flat-on-surface +// treatment as the ⋮ menu's ActionRow. +export const ReaderRow = style({ + width: '100%', + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + padding: `${config.space.S200} ${config.space.S300}`, + borderRadius: toRem(10), + background: 'transparent', + border: 'none', + textAlign: 'left', + font: 'inherit', + color: color.Surface.OnContainer, + cursor: 'pointer', + selectors: { + '&:hover': { backgroundColor: color.Surface.ContainerHover }, + '&:active': { backgroundColor: color.Surface.ContainerActive }, + '&:focus-visible': { + outline: `${toRem(2)} solid ${color.Primary.Main}`, + outlineOffset: toRem(-2), + }, + }, +}); + +export const ReaderText = style({ + flexGrow: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(1), +}); + +export const ReaderName = style({ + fontSize: toRem(14), + fontWeight: 600, + lineHeight: toRem(18), + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +// When the reader read it — the receipt timestamp, muted tabular numerals +// like every other technical metadata line in the Dawn vocabulary. +export const ReaderTime = style({ + fontSize: toRem(11.5), + lineHeight: toRem(15), + color: color.SurfaceVariant.OnContainer, + opacity: 0.65, + fontVariantNumeric: 'tabular-nums', + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +export const EmptyState = style({ + padding: `${config.space.S300} ${config.space.S300} ${config.space.S400}`, + fontSize: toRem(13.5), + lineHeight: toRem(19), + color: color.SurfaceVariant.OnContainer, + opacity: 0.7, + textAlign: 'center', }); diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index fbbba81c..5c64fddb 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -1,22 +1,21 @@ import React from 'react'; -import classNames from 'classnames'; +import FocusTrap from 'focus-trap-react'; import { Avatar, - Box, - Header, Icon, IconButton, Icons, - MenuItem, + Overlay, + OverlayBackdrop, + OverlayCenter, Scroll, - Text, - as, - config, } from 'folds'; import { Room } from 'matrix-js-sdk'; +import { useTranslation } from 'react-i18next'; import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; import { getMemberDisplayName } from '../../utils/room'; import { getMxIdLocalPart } from '../../utils/matrix'; +import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time'; import * as css from './EventReaders.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { UserAvatar } from '../user-avatar'; @@ -24,91 +23,143 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getMouseEventCords } from '../../utils/dom'; +import { stopPropagation } from '../../utils/keyboard'; export type EventReadersProps = { room: Room; eventId: string; requestClose: () => void; }; -export const EventReaders = as<'div', EventReadersProps>( - ({ className, room, eventId, requestClose, ...props }, ref) => { - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - const latestEventReaders = useRoomEventReaders(room, eventId); - const openProfile = useOpenUserRoomProfile(); - const space = useSpaceOptionally(); - const getName = (userId: string) => - getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; +// Read-receipts card («Прочитали») — self-contained Vojo card (owns its +// Overlay + FocusTrap; callers just conditionally render it). Each row is a +// reader with their receipt timestamp — the receipt is the user's CURRENT +// read marker, so for older messages it reads as «read at or before this +// time»; for the common case (checking your latest message) it's exact. +// Tapping a reader closes the card and opens their profile sheet — the +// profile surface (side pane / horseshoe) renders OUTSIDE this overlay, so +// leaving the card open would hide it behind the backdrop. +export function EventReaders({ room, eventId, requestClose }: EventReadersProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const latestEventReaders = useRoomEventReaders(room, eventId); + const openProfile = useOpenUserRoomProfile(); + const space = useSpaceOptionally(); - return ( - -
- - Seen by - - - - -
- - - - {latestEventReaders.map((readerId) => { - const name = getName(readerId); - const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); - const avatarUrl = avatarMxcUrl - ? mx.mxcUrlToHttp( - avatarMxcUrl, - 100, - 100, - 'crop', - undefined, - false, - useAuthentication - ) - : undefined; + const getName = (userId: string) => + getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; - return ( - ) => { - openProfile( - room.roomId, - space?.roomId, - readerId, - getMouseEventCords(event.nativeEvent), - 'Bottom' - ); - }} - before={ - - } - /> - - } - > - - {name} - - - ); - })} - - - -
- ); - } -); + const formatReadTime = (ts: number): string => { + if (today(ts)) return `${t('Room.today')}, ${timeHourMinute(ts)}`; + if (yesterday(ts)) return `${t('Room.yesterday')}, ${timeHourMinute(ts)}`; + return `${timeDayMonYear(ts)}, ${timeHourMinute(ts)}`; + }; + + return ( + }> + + +
+
+ +
+ {t('Room.seen_by')} + + {t('Room.seen_by_count', { count: latestEventReaders.length })} + +
+ + + +
+ + {latestEventReaders.length === 0 ? ( +
{t('Room.seen_by_empty')}
+ ) : ( + +
+ {latestEventReaders.map((readerId) => { + const name = getName(readerId); + const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); + const avatarUrl = avatarMxcUrl + ? mx.mxcUrlToHttp( + avatarMxcUrl, + 100, + 100, + 'crop', + undefined, + false, + useAuthentication + ) + : undefined; + // `ignoreSynthesized: true` — the default prefers the + // SDK's synthetic receipt, whose ts is the moment the + // reader last POSTED, not when they read. Real m.read + // only; rows without one just show no time. + const receiptTs = room.getReadReceiptForUserId(readerId, true)?.data?.ts; + + return ( + + ); + })} +
+
+ )} +
+
+
+
+ ); +} diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 8709b942..62762a74 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -17,6 +17,7 @@ import { Spinner, } from 'folds'; import { MatrixError } from 'matrix-js-sdk'; +import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; @@ -27,6 +28,7 @@ type LeaveSpacePromptProps = { onCancel: () => void; }; export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const [leaveState, leaveRoom] = useAsyncCallback( @@ -66,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP size="500" > - Leave Space + {t('Room.leave_space_title')} @@ -74,10 +76,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
- Are you sure you want to leave this space? + {t('Room.leave_space_confirm')} {leaveState.status === AsyncStatus.Error && ( - Failed to leave space! {leaveState.error.message} + {t('Room.leave_space_error', { error: leaveState.error.message })} )} @@ -96,7 +98,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP } > - {leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'} + {leaveState.status === AsyncStatus.Loading ? t('Room.leaving') : t('Room.leave')} diff --git a/src/app/components/logout-dialog/LogoutDialog.css.ts b/src/app/components/logout-dialog/LogoutDialog.css.ts new file mode 100644 index 00000000..71066f00 --- /dev/null +++ b/src/app/components/logout-dialog/LogoutDialog.css.ts @@ -0,0 +1,79 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// Logout confirmation — same self-styled Vojo card vocabulary as the AI +// privacy notice (AiChatPrivacy.css.ts): own surface/radius/border/shadow, +// branded badge header with NO bottom-border bar (the border bar is what made +// the old folds Dialog read as a generic cinny dialog). Narrower than the +// privacy card — it's a yes/no confirm, not a reading surface. +export const DialogCard = style({ + width: '90vw', + maxWidth: toRem(400), + display: 'flex', + flexDirection: 'column', + overflow: 'hidden', + backgroundColor: color.Surface.Container, + borderRadius: config.radii.R400, + border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, + boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)', +}); + +export const Header = style({ + flexShrink: 0, + display: 'flex', + alignItems: 'flex-start', + gap: config.space.S300, + padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`, +}); + +// Rounded-square badge in the critical tone — the destructive twin of the +// privacy card's fleet shield. +export const HeaderBadge = style({ + flexShrink: 0, + width: toRem(40), + height: toRem(40), + borderRadius: config.radii.R400, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: color.Critical.Container, + color: color.Critical.Main, +}); + +export const HeaderText = style({ + flexGrow: 1, + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(2), + paddingTop: toRem(2), +}); + +export const HeaderTitle = style({ + fontSize: toRem(18), + fontWeight: 700, + lineHeight: toRem(22), + color: color.Surface.OnContainer, +}); + +export const HeaderSubtitle = style({ + fontSize: toRem(13), + lineHeight: toRem(17), + color: color.SurfaceVariant.OnContainer, + opacity: 0.7, +}); + +export const Body = style({ + display: 'flex', + flexDirection: 'column', + gap: config.space.S400, + padding: `0 ${config.space.S400} ${config.space.S400}`, +}); + +// Telegram-style horizontal confirm row: Cancel first, the destructive +// action trailing at the end. +export const ButtonRow = style({ + display: 'flex', + justifyContent: 'flex-end', + gap: config.space.S200, +}); diff --git a/src/app/components/logout-dialog/LogoutDialog.tsx b/src/app/components/logout-dialog/LogoutDialog.tsx new file mode 100644 index 00000000..76ef26b4 --- /dev/null +++ b/src/app/components/logout-dialog/LogoutDialog.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useRef } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Spinner, + Text, + color, +} from 'folds'; +import { useTranslation } from 'react-i18next'; +import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; +import { logoutClient } from '../../../client/initMatrix'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useCrossSigningActive } from '../../hooks/useCrossSigning'; +import { InfoCard } from '../info-card'; +import { + useDeviceVerificationStatus, + VerificationStatus, +} from '../../hooks/useDeviceVerificationStatus'; +import { stopPropagation } from '../../utils/keyboard'; +import * as css from './LogoutDialog.css'; + +type LogoutDialogProps = { + handleClose: () => void; +}; + +// Logout confirmation — self-contained Vojo card (owns its Overlay + +// FocusTrap, callers just conditionally render it). Same chrome as the AI +// privacy notice: badge header without a border bar, horizontal confirm row. +// The encrypted-rooms key-loss warnings (InfoCard) stay — they are the only +// thing standing between an unverified device and losing message history. +export function LogoutDialog({ handleClose }: LogoutDialogProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent()); + const crossSigningActive = useCrossSigningActive(); + const verificationStatus = useDeviceVerificationStatus( + mx.getCrypto(), + mx.getSafeUserId(), + mx.getDeviceId() ?? undefined + ); + + const [logoutState, logout] = useAsyncCallback( + useCallback(async () => { + await logoutClient(mx); + }, [mx]) + ); + + const ongoingLogout = logoutState.status === AsyncStatus.Loading; + // focus-trap-react copies focusTrapOptions ONCE at mount — plain values + // here would freeze at `ongoingLogout = false` and let a backdrop click / + // Esc dismiss the dialog mid-logout (hiding a potential failure while the + // session is still alive). Function options are called at event time, so + // they read the live state through this ref. + const ongoingLogoutRef = useRef(ongoingLogout); + ongoingLogoutRef.current = ongoingLogout; + + return ( + }> + + { + if (!ongoingLogoutRef.current) handleClose(); + }, + clickOutsideDeactivates: () => !ongoingLogoutRef.current, + escapeDeactivates: (evt) => { + // Mid-logout the Esc must still be CONSUMED: just returning + // false would let the keydown bubble on to the surface + // hosting the dialog (the mobile settings sheet's window + // listener, route-level handlers) and tear it down — the + // exact dismissal this ref-guard exists to prevent. + if (ongoingLogoutRef.current) { + evt.stopPropagation(); + return false; + } + return stopPropagation(evt); + }, + }} + > +
+
+ +
+ {t('Settings.logout')} + {t('Settings.logout_confirm')} +
+ + + +
+ +
+ {hasEncryptedRoom && + (crossSigningActive ? ( + verificationStatus === VerificationStatus.Unverified && ( + + ) + ) : ( + + ))} + {logoutState.status === AsyncStatus.Error && ( + + {t('Settings.logout_failed', { message: logoutState.error.message })} + + )} + + + + +
+
+
+
+
+ ); +} diff --git a/src/app/components/logout-dialog/index.ts b/src/app/components/logout-dialog/index.ts new file mode 100644 index 00000000..364465fa --- /dev/null +++ b/src/app/components/logout-dialog/index.ts @@ -0,0 +1 @@ +export * from './LogoutDialog'; diff --git a/src/app/components/message/layout/Bubble.css.ts b/src/app/components/message/layout/Bubble.css.ts index c5c2d0a3..01b60b6d 100644 --- a/src/app/components/message/layout/Bubble.css.ts +++ b/src/app/components/message/layout/Bubble.css.ts @@ -85,13 +85,6 @@ export const MediaPlain = style({ maxWidth: '100%', }); -// Editing: the body is the composer card itself (wrapped in ChatComposer by the -// caller) — full width, no bubble background of our own. -export const EditContent = style({ - width: '100%', - minWidth: 0, -}); - // Tap-rail open (or context menu / emoji board open) → the message reads as // «selected», the same affordance long-press gives. A soft brand ring rather // than a fill so it works over both the bubble and bare peer text / media. diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx index 43f3c681..2866c280 100644 --- a/src/app/components/message/layout/Bubble.tsx +++ b/src/app/components/message/layout/Bubble.tsx @@ -19,9 +19,6 @@ export type BubbleLayoutProps = { // media AND for the tail of a same-minute series (grouped messages). A single // text message keeps its timestamp on the side. timeBelow?: boolean; - // While editing, the body IS a composer card — drop the bubble chrome so it - // reads as one box, and skip the timestamp. - editing?: boolean; // Reactions chip-row, floats under the message on the page background. reactions?: ReactNode; threadSummary?: ReactNode; @@ -45,7 +42,6 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( selected, mediaMode, timeBelow, - editing, reactions, threadSummary, readStatus, @@ -63,10 +59,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>( ); let body: ReactNode; - if (editing) { - // The child is already a composer card; render it full width, no chrome. - body =
{children}
; - } else if (mediaMode || timeBelow) { + if (mediaMode || timeBelow) { // Media, and the tail of a same-minute series: timestamp BELOW the content, // aligned to the message side by the Row (own → right, peer → left). body = ( diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts index 85776a32..0fafa9f9 100644 --- a/src/app/components/message/layout/Channel.css.ts +++ b/src/app/components/message/layout/Channel.css.ts @@ -1,24 +1,39 @@ -import { globalStyle, style } from '@vanilla-extract/css'; +import { createVar, globalStyle, style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; -// 40px circular avatar — Discord's cozy-mode avatar size. Consumers override -// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant -// keeps the CSS slot width and the inline override in sync. -export const CHANNEL_AVATAR_PX = 40; -const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX); +// Avatar diameter — Discord's cozy-mode 40px on desktop, compacted to 32px +// on narrow/native viewports (the 40px slot + 16px gutters wasted ~a third +// of a 360px screen on chrome — user request, redesign item 11). Exposed as +// a CSS var scoped on ChannelRow so the inline `` size override in +// Channel.tsx tracks the same breakpoint as the slot width without JS +// media-query plumbing. +export const CHANNEL_AVATAR_SIZE_VAR = createVar(); +const ChannelAvatarWidth = CHANNEL_AVATAR_SIZE_VAR; -// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the -// content, so the message column starts at 16 + 40 + 16 = 72px. +// Mirrors BubbleTimelineBand's desktop breakpoint (RoomTimeline.css.ts) — +// keep the two media queries in sync. +const MOBILE_MEDIA = 'screen and (max-width: 599px)'; + +// Discord cozy-mode geometry on desktop: avatar 16px from the list edge, +// 16px gap to the content, so the message column starts at 16 + 40 + 16 = +// 72px. On mobile everything tightens: 6px edge, 32px avatar, 10px gap → +// body column at 48px from the band edge (60px from the screen edge with +// the band's 12px native gutter), vs the old 84px. const ChannelEdgePad = toRem(16); const ChannelAvatarGap = toRem(16); +const ChannelEdgePadMobile = toRem(6); +const ChannelAvatarGapMobile = toRem(10); export const ChannelRow = style({ display: 'flex', alignItems: 'flex-start', gap: ChannelAvatarGap, + vars: { + [CHANNEL_AVATAR_SIZE_VAR]: toRem(40), + }, // Span the full message-column width so the hover highlight runs edge-to-edge // like Discord: cancel MessageBase's S400/S200 horizontal padding with negative - // margins, then re-add the 16px avatar gutter as paddingLeft (so the avatar's + // margins, then re-add the avatar gutter as paddingLeft (so the avatar's // left edge lands 16px from the column edge — Discord cozy). NB: the column is // the centred BubbleTimelineBand, so that edge is the band's content edge, not // the screen edge (the band adds 12px native / 40px desktop outside this). @@ -31,9 +46,9 @@ export const ChannelRow = style({ paddingTop: toRem(2), paddingBottom: toRem(2), minWidth: 0, - // Hover bg subtle so adjacent rows still read as distinct units. `@media - // (hover: hover)` keeps this inert on touch where there's no pointer. '@media': { + // Hover bg subtle so adjacent rows still read as distinct units. `@media + // (hover: hover)` keeps this inert on touch where there's no pointer. '(hover: hover) and (pointer: fine)': { selectors: { '&:hover': { @@ -41,12 +56,20 @@ export const ChannelRow = style({ }, }, }, + [MOBILE_MEDIA]: { + vars: { + [CHANNEL_AVATAR_SIZE_VAR]: toRem(32), + }, + gap: ChannelAvatarGapMobile, + paddingLeft: ChannelEdgePadMobile, + paddingRight: toRem(8), + }, }, }); // Fixed-width slot keeps the body column aligned across collapsed rows -// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`, -// so the body's left edge stays put). +// (where `avatar` is `undefined` — the slot still occupies the avatar +// diameter, so the body's left edge stays put). export const ChannelAvatarSlot = style({ width: ChannelAvatarWidth, flexShrink: 0, @@ -90,14 +113,23 @@ export const ChannelThreadSummary = style({ // continuous. export const ChannelSysline = style({ // Indent past the avatar gutter so the sysline body aligns with the message - // body column (72px). The sysline sits inside MessageBase's S400 (16px) left - // pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40) - // + gap (16) = 56 lands the content at 16 + 56 = 72px. - paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`, + // body column (72px desktop). The sysline sits inside MessageBase's S400 + // (16px) left pad (it has no edge-to-edge negative margin), so paddingLeft + // = avatar (40) + gap (16) = 56 lands the content at 16 + 56 = 72px. + // ChannelRow's avatar-size var is out of scope here (sysline rows render + // standalone), so the mobile compaction repeats the literals: 32 + 10 = 42, + // minus the 10px edge-pad difference (16 − 6) the message rows gained, + // = 32px — keeps the sysline body on the message-body column line. + paddingLeft: `calc(${toRem(40)} + ${ChannelAvatarGap})`, paddingRight: config.space.S200, paddingTop: config.space.S100, paddingBottom: config.space.S100, color: color.SurfaceVariant.OnContainer, + '@media': { + [MOBILE_MEDIA]: { + paddingLeft: toRem(32), + }, + }, }); export const ChannelSyslineIcon = style({ diff --git a/src/app/components/message/layout/Channel.tsx b/src/app/components/message/layout/Channel.tsx index e600cc53..141ab3c2 100644 --- a/src/app/components/message/layout/Channel.tsx +++ b/src/app/components/message/layout/Channel.tsx @@ -4,7 +4,7 @@ import { Avatar, Box, Icon, Icons, type IconSrc, as } from 'folds'; import { type Room } from 'matrix-js-sdk'; import * as css from './Channel.css'; -import { CHANNEL_AVATAR_PX } from './Channel.css'; +import { CHANNEL_AVATAR_SIZE_VAR } from './Channel.css'; import { UserAvatar } from '../../user-avatar'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; @@ -137,7 +137,16 @@ export function ChannelMessageAvatar({ ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; return ( - + // Size from the ChannelRow-scoped CSS var (40px desktop / 32px mobile) so + // the avatar tracks the slot's breakpoint while still out-ranking the + // folds size preset via inline style. + 0`. - // Without elevation that void bleeds up through the transparent - // strip-stack into the safe-top + tabsRow zone, turning the system- - // tray strip + tabs black. Bumping the static header into a positive - // z-index puts it ABOVE the strip's stacking context (positive z - // beats z:auto stacking contexts per CSS painting order), covering - // the void in its own y-band with SurfaceVariant bg + visible tabs. - // - // The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the - // sheet-open atoms, so elevation lands on the FIRST frame of drag — - // not 80 px later when the user crosses the commit threshold. The - // horseshoes' appBody flips to opaque in lockstep (same signal), - // containing the void to the bottom carve everywhere below the - // static header. + // Z-elevation is requested ONLY by the void-carve sheet design (the + // Channels workspace switcher): while geometrically active + // (`expandedPx > 0`, first frame of drag) its container paints + // `VOJO_HORSESHOE_VOID_COLOR` across the pane to drive the carve, + // and without elevation that void bleeds up through the transparent + // strip-stack into the safe-top + tabsRow zone. A positive z-index + // beats the strip's z:auto stacking context (CSS painting order), + // covering the void in this band with SurfaceVariant bg + visible + // tabs. The near-fullscreen Settings sheet keeps its appBody + // transparent and simply slides OVER this header like a real + // curtain, so it never publishes the elevate atom — see + // mobileHorseshoeElevateHeaderAtom's docs. // // Pinned-overrides-elevation: when the active pane's curtain is // pinned the curtain itself contains the void — it covers everything // from `y = safe-top` downward inside the strip's stacking context, - // and the opaque appBody (also flipped on `horseshoeActive`) covers - // the safe-top band above the curtain. Re-elevating the static - // header in that state would visibly «slice» the pinned curtain in - // the safe-top + tabsRow band, popping tabs back over what the user - // explicitly pulled up to cover. So we suppress elevation whenever - // the active tab's pin is set — preserves the «pinned hides tabs» - // invariant across sheet open/drag. + // and the opaque appBody (flipped on the same geometric signal) + // covers the safe-top band above the curtain. Re-elevating the + // static header in that state would visibly «slice» the pinned + // curtain in the safe-top + tabsRow band, popping tabs back over + // what the user explicitly pulled up to cover. So we suppress + // elevation whenever the active tab's pin is set — preserves the + // «pinned hides tabs» invariant across sheet open/drag. // // The curtain pin gesture is suppressed while either sheet is open // (see `StreamHeader.gestureDisabled`), so this elevation never // races with a pin-in-progress drag. - const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom); + const elevateHeader = useAtomValue(mobileHorseshoeElevateHeaderAtom); const pinnedByTab = useAtomValue(curtainPinnedByTabAtom); const activePinned = !!pinnedByTab[activeTab]; - const elevated = horseshoeActive && !activePinned; + const elevated = elevateHeader && !activePinned; return (
0`, which covers both -// the in-flight drag and the committed-open state — the wrapping +// The two horseshoe sheet designs interact differently with this +// header. The near-fullscreen Settings sheet keeps its appBody +// transparent and simply slides its opaque silhouette OVER the tabs +// like a real curtain — no elevation, the tabs stay put underneath. +// The Channels workspace switcher is the void-carve design: while +// geometrically active (`expandedPx > 0`, first frame of drag) its // container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark -// theme) across the entire pane so the carve at the sheet's top -// reads as a dark seam. With the transparent strip stack from (b), -// that void would bleed up through the safe-top + tabsRow zone, -// turning the system-tray strip + tabs solid black. +// theme) across the pane to drive the carve. With the transparent +// strip stack from (b), that void would bleed up through the +// safe-top + tabsRow zone, turning the system-tray strip + tabs +// solid black. // -// `MobileTabsPagerHeader.tsx` bumps this element to a positive -// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`) -// from the first frame of drag. Positive z beats the strip's -// `z: auto` stacking context, putting the static header back on -// top in the safe-top + tabsRow band — the void is contained to -// the carve area, tabs stay visible. The horseshoe's `appBody` -// flips back to opaque on the same signal so the void doesn't -// bleed into the mascot/form band between the static header and -// the curtain top either. The curtain pin gesture is gated off -// in the same state (see `StreamHeader.gestureDisabled`) so no -// pin can race the elevation flip. +// So only the void-carve sheet publishes +// `mobileHorseshoeElevateHeaderAtom`; `MobileTabsPagerHeader.tsx` +// bumps this element to a positive `zIndex` (inline style) on that +// signal. Positive z beats the strip's `z: auto` stacking context, +// putting the static header back on top in the safe-top + tabsRow +// band — the void is contained to the carve area, tabs stay +// visible. That horseshoe's `appBody` flips back to opaque on the +// same signal so the void doesn't bleed into the band between the +// static header and the curtain top either. The curtain pin gesture +// is gated off in the same state (see `StreamHeader.gestureDisabled`) +// so no pin can race the elevation flip. // // Pinned-override: when the active pane's curtain is pinned, the // curtain itself sits at the top of the stage (z:2 inside the diff --git a/src/app/components/swipe-back/externalSwipeFeed.ts b/src/app/components/swipe-back/externalSwipeFeed.ts new file mode 100644 index 00000000..8d6991ed --- /dev/null +++ b/src/app/components/swipe-back/externalSwipeFeed.ts @@ -0,0 +1,38 @@ +// Cross-boundary input feed for the swipe-back gesture. An iframe is a +// separate browsing context — touches inside it NEVER reach the host's +// listeners, so the swipe-back-from-widget gesture was dead over the +// bridge-bot widgets. The widget apps (apps/widget-*) forward their raw +// touch stream over the validated `io.vojo.bot-widget` side-channel +// (`swipe-touch` action); `BotWidgetMount` maps the coordinates into host +// viewport space and emits them here; `useSwipeBackGesture` subscribes and +// routes them through the SAME state machine as native touches (dead-zone +// axis resolve, edge guard, distance-commit). +// +// Module-level pub-sub (not context): the emitter (BotWidgetMount, inside +// the routed chat) and the consumer (SwipeBackOverlay, the router-level +// card) live in distant subtrees, and the feed is transient input — no +// render state, nothing to persist. + +export type ExternalSwipePhase = 'start' | 'move' | 'end' | 'cancel'; + +export type ExternalSwipeTouch = { + phase: ExternalSwipePhase; + // Host-viewport coordinates (the emitter translates iframe-local ones). + x: number; + y: number; +}; + +type Listener = (touch: ExternalSwipeTouch) => void; + +const listeners = new Set(); + +export const subscribeExternalSwipe = (listener: Listener): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; + +export const emitExternalSwipe = (touch: ExternalSwipeTouch): void => { + listeners.forEach((listener) => listener(touch)); +}; diff --git a/src/app/components/swipe-back/useSwipeBackGesture.ts b/src/app/components/swipe-back/useSwipeBackGesture.ts index c79812d0..0fea59b2 100644 --- a/src/app/components/swipe-back/useSwipeBackGesture.ts +++ b/src/app/components/swipe-back/useSwipeBackGesture.ts @@ -1,5 +1,6 @@ import { MutableRefObject, useEffect, useRef } from 'react'; import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX } from './geometry'; +import { subscribeExternalSwipe } from './externalSwipeFeed'; type Args = { // The sliding chat CARD element the listeners bind to. Touches outside it @@ -23,12 +24,21 @@ type Args = { }; // Rightward "swipe-to-go-back" (interactive pop) driver. Mirrors -// `useMobileTabsPagerGesture`: single listener on the card root, refs for -// live state, axis-resolve in the dead-zone, distance threshold-commit on -// release with snap-back below it. Differences: rightward-only (back, never -// forward — leftward clamps at 0). The card is `touch-action: pan-y`, so the -// browser reserves horizontal panning for us and our moves stay cancelable -// over the whole screen. +// `useMobileTabsPagerGesture`: refs for live state, axis-resolve in the +// dead-zone, distance threshold-commit on release with snap-back below it. +// Rightward-only (back, never forward — leftward clamps at 0). The card is +// `touch-action: pan-y`, so the browser reserves horizontal panning for us +// and our moves stay cancelable over the whole screen. +// +// TWO input sources feed ONE state machine (begin/move/end/cancel below): +// 1. Native touch listeners on the card root — every React-rendered +// surface (chats, AI chat, hero headers). +// 2. The external swipe feed (`externalSwipeFeed.ts`) — touches forwarded +// by the bridge-bot widget iframes over the validated side-channel, +// already mapped into host-viewport coordinates by `BotWidgetMount`. +// An iframe consumes its touches natively, so without this feed the +// gesture is dead over the widget body. External moves carry no +// cancelable browser event — the widget side owns preventDefault. export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void { const disabledRef = useRef(disabled); const setDragRef = useRef(setDrag); @@ -66,37 +76,42 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac reset(); }; - const onTouchStart = (e: TouchEvent) => { - if (disabledRef.current || e.touches.length !== 1) { - springHome(); - return; - } - const t = e.touches[0]; + // ── State machine (shared by both input sources) ────────────────── + + const begin = (x: number, y: number) => { + // Heal any orphaned offset FIRST: unlike native touches, the external + // feed has no guaranteed terminal event — an iframe dying mid-drag + // (widget crash → destroy() drops the message listener) never posts + // 'end', which would otherwise leave the card frozen half-slid with + // no recovery (a plain tap's end() sees clean state and no-ops). + // springHome is a no-op after a cleanly terminated gesture. + springHome(); + if (disabledRef.current) return; const vw = window.innerWidth; // The L/R edge strip belongs to the Android system back-gesture in // edge-to-edge mode. Ours shares its direction, so we MUST cede it — // a drag must start at least EDGE_GUARD_PX inside the left edge. - if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) { - springHome(); - return; - } - startX = t.clientX; - startY = t.clientY; + if (x < EDGE_GUARD_PX || x > vw - EDGE_GUARD_PX) return; + startX = x; + startY = y; engaged = false; bailed = false; lastDragPx = 0; }; - const onTouchMove = (e: TouchEvent) => { - if (e.touches.length !== 1 || disabledRef.current) { + // `preventDefault` is the source-specific cancelation hook: native + // touches pass the cancelable TouchEvent's preventDefault; the external + // feed passes undefined (the widget side already prevented its own + // default once it resolved the gesture as ours). + const move = (x: number, y: number, preventDefault?: () => void) => { + if (disabledRef.current) { springHome(); bailed = true; return; } if (startX === null || startY === null || bailed) return; - const t = e.touches[0]; - const dx = t.clientX - startX; - const dy = t.clientY - startY; + const dx = x - startX; + const dy = y - startY; if (!engaged) { if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return; @@ -119,7 +134,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac // screen (including over the vertical-scrolling timeline), so the // gesture engages everywhere, not just over non-scrollable spots. The // guard is defensive only. - if (e.cancelable) e.preventDefault(); + preventDefault?.(); const vw = window.innerWidth; // Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative: @@ -130,7 +145,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac setDragRef.current(drag, true); }; - const onTouchEnd = () => { + const end = () => { if (!engaged || disabledRef.current) { springHome(); return; @@ -149,20 +164,57 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac } }; - const onTouchCancel = () => { + const cancel = () => { // System cancel (incoming call, scroll take-over, …) never commits. springHome(); }; + // ── Source 1: native touches on the card root ───────────────────── + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) { + springHome(); + return; + } + const t = e.touches[0]; + begin(t.clientX, t.clientY); + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + springHome(); + bailed = true; + return; + } + const t = e.touches[0]; + move(t.clientX, t.clientY, () => { + if (e.cancelable) e.preventDefault(); + }); + }; + + const onTouchEnd = () => end(); + const onTouchCancel = () => cancel(); + root.addEventListener('touchstart', onTouchStart, { passive: true }); root.addEventListener('touchmove', onTouchMove, { passive: false }); root.addEventListener('touchend', onTouchEnd, { passive: true }); root.addEventListener('touchcancel', onTouchCancel, { passive: true }); + + // ── Source 2: the external (widget iframe) feed ─────────────────── + + const unsubscribeExternal = subscribeExternalSwipe((touch) => { + if (touch.phase === 'start') begin(touch.x, touch.y); + else if (touch.phase === 'move') move(touch.x, touch.y); + else if (touch.phase === 'end') end(); + else cancel(); + }); + return () => { root.removeEventListener('touchstart', onTouchStart); root.removeEventListener('touchmove', onTouchMove); root.removeEventListener('touchend', onTouchEnd); root.removeEventListener('touchcancel', onTouchCancel); + unsubscribeExternal(); }; // setDrag / onBack are mirrored via refs so the listener never re-binds // mid-gesture; `enabled` is a real dep so toggling it binds/unbinds. diff --git a/src/app/components/uia-stages/EmailStage.tsx b/src/app/components/uia-stages/EmailStage.tsx index fdc2b61a..ada84958 100644 --- a/src/app/components/uia-stages/EmailStage.tsx +++ b/src/app/components/uia-stages/EmailStage.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useCallback, FormEventHandler } from 'react'; import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds'; import { AuthType, MatrixError } from 'matrix-js-sdk'; +import { useTranslation } from 'react-i18next'; import { StageComponentProps } from './types'; import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback'; import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types'; @@ -18,13 +19,14 @@ function EmailErrorDialog({ onRetry: (email: string) => void; onCancel: () => void; }) { + const { t } = useTranslation(); + const handleFormSubmit: FormEventHandler = (evt) => { evt.preventDefault(); const { retryEmailInput } = evt.target as HTMLFormElement & { retryEmailInput: HTMLInputElement; }; - const t = retryEmailInput.value; - onRetry(t); + onRetry(retryEmailInput.value); }; return ( @@ -40,7 +42,7 @@ function EmailErrorDialog({ {title} {message} - Email + {t('Auth.uia_email_label')} @@ -80,6 +82,7 @@ export function EmailStageDialog({ emailTokenState: AsyncState; requestEmailToken: RequestEmailTokenCallback; }) { + const { t } = useTranslation(); const { errorCode, error, session } = stageData; const handleSubmit = useCallback( @@ -115,7 +118,7 @@ export function EmailStageDialog({ return ( - Sending verification email... + {t('Auth.uia_email_sending')} ); } @@ -123,11 +126,11 @@ export function EmailStageDialog({ if (emailTokenState.status === AsyncStatus.Error) { return ( - Verification Request Sent - {`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`} + {t('Auth.uia_email_sent_title')} + {t('Auth.uia_email_sent_message', { email: emailTokenState.data.email })} {errorCode && ( {`${errorCode}: ${error}`} @@ -149,7 +152,7 @@ export function EmailStageDialog({ @@ -160,8 +163,8 @@ export function EmailStageDialog({ if (!email) { return ( diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts deleted file mode 100644 index 80c1b264..00000000 --- a/src/app/components/upload-board/UploadBoard.css.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { style } from '@vanilla-extract/css'; -import { DefaultReset, color, config, toRem } from 'folds'; - -export const UploadBoardBase = style([ - DefaultReset, - { - position: 'relative', - pointerEvents: 'none', - }, -]); - -export const UploadBoardContainer = style([ - DefaultReset, - { - position: 'absolute', - bottom: config.space.S200, - left: 0, - right: 0, - zIndex: config.zIndex.Max, - }, -]); - -export const UploadBoard = style({ - maxWidth: toRem(400), - width: '100%', - maxHeight: toRem(450), - height: '100%', - backgroundColor: color.Surface.Container, - color: color.Surface.OnContainer, - borderRadius: config.radii.R400, - boxShadow: config.shadow.E200, - border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, - overflow: 'hidden', - pointerEvents: 'all', -}); - -export const UploadBoardHeaderContent = style({ - height: '100%', - padding: `0 ${config.space.S200}`, -}); - -export const UploadBoardContent = style({ - padding: config.space.S200, - paddingBottom: 0, - paddingRight: 0, -}); diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx deleted file mode 100644 index 42f3899f..00000000 --- a/src/app/components/upload-board/UploadBoard.tsx +++ /dev/null @@ -1,145 +0,0 @@ -import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react'; -import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds'; -import classNames from 'classnames'; -import { useAtomValue } from 'jotai'; - -import * as css from './UploadBoard.css'; -import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload'; - -type UploadBoardProps = { - header: ReactNode; -}; -export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => ( - - - - - {children} - - - {header} - - - - -)); - -export type UploadBoardImperativeHandlers = { handleSend: () => Promise }; - -type UploadBoardHeaderProps = { - open: boolean; - onToggle: () => void; - uploadFamilyObserverAtom: TUploadFamilyObserverAtom; - onCancel: (uploads: Upload[]) => void; - onSend: (uploads: UploadSuccess[]) => Promise; - imperativeHandlerRef: MutableRefObject; -}; - -export function UploadBoardHeader({ - open, - onToggle, - uploadFamilyObserverAtom, - onCancel, - onSend, - imperativeHandlerRef, -}: UploadBoardHeaderProps) { - const sendingRef = useRef(false); - const uploads = useAtomValue(uploadFamilyObserverAtom); - - const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success); - const isError = uploads.some((upload) => upload.status === UploadStatus.Error); - const progress = uploads.reduce( - (acc, upload) => { - acc.total += upload.file.size; - if (upload.status === UploadStatus.Loading) { - acc.loaded += upload.progress.loaded; - } - if (upload.status === UploadStatus.Success) { - acc.loaded += upload.file.size; - } - return acc; - }, - { loaded: 0, total: 0 } - ); - - const handleSend = async () => { - if (sendingRef.current) return; - sendingRef.current = true; - await onSend( - uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[] - ); - sendingRef.current = false; - }; - - useImperativeHandle(imperativeHandlerRef, () => ({ - handleSend, - })); - const handleCancel = () => onCancel(uploads); - - return ( -
- - - Files - - - {isSuccess && ( - } - > - Send - - )} - {isError && !open && ( - - Upload Failed - - )} - {!isSuccess && !isError && !open && ( - <> - - {Math.round(percent(0, progress.total, progress.loaded))}% - - - - )} - {!isSuccess && open && ( - } - > - {uploads.length === 1 ? 'Remove' : 'Remove All'} - - )} - -
- ); -} - -export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => ( - - {children} - -)); diff --git a/src/app/components/upload-board/index.ts b/src/app/components/upload-board/index.ts deleted file mode 100644 index 24ae780c..00000000 --- a/src/app/components/upload-board/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './UploadBoard'; diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx deleted file mode 100644 index f5bc68e0..00000000 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ /dev/null @@ -1,216 +0,0 @@ -import React, { ReactNode, useEffect } from 'react'; -import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds'; -import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard'; -import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { TUploadContent } from '../../utils/matrix'; -import { bytesToSize, getFileTypeIcon } from '../../utils/common'; -import { - roomUploadAtomFamily, - TUploadItem, - TUploadMetadata, -} from '../../state/room/roomInputDrafts'; -import { useObjectURL } from '../../hooks/useObjectURL'; -import { useMediaConfig } from '../../hooks/useMediaConfig'; - -type PreviewImageProps = { - fileItem: TUploadItem; -}; -function PreviewImage({ fileItem }: PreviewImageProps) { - const { originalFile, metadata } = fileItem; - const fileUrl = useObjectURL(originalFile); - - return ( - {originalFile.name} - ); -} - -type PreviewVideoProps = { - fileItem: TUploadItem; -}; -function PreviewVideo({ fileItem }: PreviewVideoProps) { - const { originalFile, metadata } = fileItem; - const fileUrl = useObjectURL(originalFile); - - return ( - // eslint-disable-next-line jsx-a11y/media-has-caption -
} > - - ) : ( - - ) - } - after={tagIconSrc ? : undefined} - onClick={open} - aria-pressed={!!cords} - > - - {tag.name} - - + {chip} ); } diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a6758300..c29e2edb 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -23,6 +23,7 @@ import { import React, { MouseEventHandler, useCallback, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { isKeyHotkey } from 'is-hotkey'; +import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { PowerColorBadge, PowerIcon } from '../power'; @@ -30,8 +31,6 @@ import { useGetMemberPowerLevel, usePowerLevels } from '../../hooks/usePowerLeve import { getPowers, usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { stopPropagation } from '../../utils/keyboard'; import { StateEvent } from '../../../types/matrix/room'; -import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; -import { RoomSettingsPage } from '../../state/roomSettings'; import { useRoom } from '../../hooks/useRoom'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { CutoutCard } from '../cutout-card'; @@ -145,11 +144,11 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) } export function PowerChip({ userId }: { userId: string }) { + const { t } = useTranslation(); const mx = useMatrixClient(); const room = useRoom(); const space = useSpaceOptionally(); const useAuthentication = useMediaAuthentication(); - const openRoomSettings = useOpenRoomSettings(); const openSpaceSettings = useOpenSpaceSettings(); const powerLevels = usePowerLevels(room); @@ -285,33 +284,33 @@ export function PowerChip({ userId }: { userId: string }) { ); })}
- -
- { - if (room.isSpaceRoom()) { - openSpaceSettings( - room.roomId, - space?.roomId, - SpaceSettingsPage.PermissionsPage - ); - } else { - openRoomSettings( - room.roomId, - space?.roomId, - RoomSettingsPage.PermissionsPage - ); - } - close(); - }} - > - Manage Powers - -
+ {/* «Manage Powers» only for spaces — the room-settings + Permissions page was removed with the room-settings + redesign; role assignment for rooms happens right here + via the tag list above. */} + {room.isSpaceRoom() && ( + <> + +
+ { + openSpaceSettings( + room.roomId, + space?.roomId, + SpaceSettingsPage.PermissionsPage + ); + close(); + }} + > + {t('User.manage_powers')} + +
+ + )} } diff --git a/src/app/components/user-profile/UserInfoRows.tsx b/src/app/components/user-profile/UserInfoRows.tsx index a69a1601..3005aa7f 100644 --- a/src/app/components/user-profile/UserInfoRows.tsx +++ b/src/app/components/user-profile/UserInfoRows.tsx @@ -46,7 +46,7 @@ import { copyToClipboard } from '../../utils/dom'; import { stopPropagation } from '../../utils/keyboard'; import { factoryRoomIdByAtoZ } from '../../utils/sort'; import { openExternalUrl } from '../../utils/capacitor'; -import { getMxIdServer } from '../../utils/matrix'; +import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; import { nameInitials } from '../../utils/common'; import { getExploreServerPath } from '../../pages/pathUtils'; @@ -80,7 +80,14 @@ export function InfoRow({ label, value, trailing, title }: InfoRowProps) { ); } -// ── id (handle + share menu) ───────────────────────────────────── +// ── id (nick + share menu) ─────────────────────────────────────── +// +// The value is the LOCALPART only — the server half lives inside the +// full mxid that «Copy user ID» puts on the clipboard. Showing +// `nick:server` here duplicated the server for every same-homeserver +// user and read as protocol noise (user request, redesign item 14). +// The trailing ⋯ menu carries exactly two actions: copy the full +// `@nick:server` and copy the matrix.to link. export function IdRow({ userId }: { userId: string }) { const { t } = useTranslation(); @@ -92,7 +99,7 @@ export function IdRow({ userId }: { userId: string }) { }; const close = () => setCords(undefined); - const handle = userId.replace(/^@/, ''); + const handle = getMxIdLocalPart(userId) ?? userId; return ( ); } - diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx index 160ecead..4dfbe93a 100644 --- a/src/app/components/user-profile/UserModeration.tsx +++ b/src/app/components/user-profile/UserModeration.tsx @@ -1,5 +1,6 @@ import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds'; import React, { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; import { useRoom } from '../../hooks/useRoom'; import { CutoutCard } from '../cutout-card'; import { SettingTile } from '../setting-tile'; @@ -14,6 +15,7 @@ type UserKickAlertProps = { ts?: number; }; export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { + const { t } = useTranslation(); const time = ts ? timeHourMinute(ts) : undefined; const date = ts ? timeDayMonYear(ts) : undefined; @@ -22,7 +24,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { - Kicked User + {t('User.moderation_kicked_user')} {time && date && ( {date} {time} @@ -32,16 +34,16 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) { {kickedBy && ( - Kicked by: {kickedBy} + {t('User.moderation_kicked_by')} {kickedBy} )} {reason ? ( <> - Reason: {reason} + {t('User.moderation_reason_label')} {reason} ) : ( - No Reason Provided. + {t('User.moderation_no_reason')} )} @@ -59,6 +61,7 @@ type UserBanAlertProps = { ts?: number; }; export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const room = useRoom(); const time = ts ? timeHourMinute(ts) : undefined; @@ -77,7 +80,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan - Banned User + {t('User.moderation_banned_user')} {time && date && ( {date} {time} @@ -87,16 +90,16 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan {bannedBy && ( - Banned by: {bannedBy} + {t('User.moderation_banned_by')} {bannedBy} )} {reason ? ( <> - Reason: {reason} + {t('User.moderation_reason_label')} {reason} ) : ( - No Reason Provided. + {t('User.moderation_no_reason')} )} @@ -114,7 +117,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan before={banning && } disabled={banning} > - Unban + {t('User.moderation_unban')} )} @@ -131,6 +134,7 @@ type UserInviteAlertProps = { ts?: number; }; export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const room = useRoom(); const time = ts ? timeHourMinute(ts) : undefined; @@ -149,7 +153,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User - Invited User + {t('User.moderation_invited_user')} {time && date && ( {date} {time} @@ -159,16 +163,16 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User {invitedBy && ( - Invited by: {invitedBy} + {t('User.moderation_invited_by')} {invitedBy} )} {reason ? ( <> - Reason: {reason} + {t('User.moderation_reason_label')} {reason} ) : ( - No Reason Provided. + {t('User.moderation_no_reason')} )} @@ -188,7 +192,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User before={kicking && } disabled={kicking} > - Cancel Invite + {t('User.moderation_cancel_invite')} )} @@ -204,6 +208,7 @@ type UserModerationProps = { canInvite: boolean; }; export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const room = useRoom(); const reasonInputRef = useRef(null); @@ -245,10 +250,10 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer - Moderation + {t('User.moderation_title')} - Invite + {t('User.moderation_invite')} )} {canKick && ( @@ -308,7 +313,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer onClick={kick} disabled={disabled} > - Kick + {t('User.moderation_kick')} )} {canBan && ( @@ -328,7 +333,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer onClick={ban} disabled={disabled} > - Ban + {t('User.moderation_ban')} )} diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx index ff301764..85970d10 100644 --- a/src/app/components/user-profile/UserRoomProfile.tsx +++ b/src/app/components/user-profile/UserRoomProfile.tsx @@ -4,7 +4,7 @@ // // • Hero — large gradient avatar, display name, monospaced // handle, presence + last-seen, optional e2ee badge. -// • Info rows — Fleet-style attribute table: id / server / role / +// • Info rows — Fleet-style attribute table: nick / server / role / // mutual chats. Each row has a trailing affordance // (popout menu) when there's something useful to do. // • Actions — single «Написать» chip in group rooms only @@ -16,7 +16,8 @@ // surface. import React from 'react'; -import { Box } from 'folds'; +import { Box, Icon, IconButton, Icons } from 'folds'; +import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { UserHero } from './UserHero'; import { IdRow, MutualRoomsRow, RoleRow, ServerRow } from './UserInfoRows'; @@ -51,9 +52,23 @@ type UserRoomProfileProps = { // and keeps using the full-rail avatar swap inside // `RoomViewProfilePanel`. avatarExpanded?: boolean; + // Top-left affordance (redesign item 15). `onBack` renders a back + // arrow — used when the profile replaced a parent surface the user + // can return to (the group room's members sheet). `onClose` renders + // a plain cross on root cards (the 1:1 profile sheet) where there's + // nothing to go back to. `onBack` wins when both are passed. + onBack?: () => void; + onClose?: () => void; }; -export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) { +export function UserRoomProfile({ + userId, + onAvatarClick, + avatarExpanded, + onBack, + onClose, +}: UserRoomProfileProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const navigate = useNavigate(); @@ -132,6 +147,22 @@ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserR return ( + {/* Top-left back / close — mirror of the actions anchor. Back + returns to the parent surface (members sheet); the cross + closes a root card. */} + {(onBack || onClose) && ( +
+ + + +
+ )} {/* Top-right action menu — absolute-positioned so it floats above the hero without claiming a layout row. Hosts every imperative action (Write / Block / Mod). On mobile the @@ -164,6 +195,9 @@ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserR />
+ {/* The nick row shows the LOCALPART only (the server half lives in + the separate server row below and in the full mxid the ⋯ menu + copies — no duplication, redesign item 14). */} {userId !== myUserId && } diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts index 2fbe79c1..c66f0363 100644 --- a/src/app/components/user-profile/styles.css.ts +++ b/src/app/components/user-profile/styles.css.ts @@ -20,6 +20,17 @@ export const CardActionsAnchor = style({ zIndex: 1, }); +// Back / close anchor — top-left mirror of `CardActionsAnchor`. Hosts +// the «back to the room card» arrow when the profile was reached from +// a group room's members sheet, or a plain close cross on root cards +// (1:1 profile sheet) — redesign item 15. +export const CardBackAnchor = style({ + position: 'absolute', + top: 0, + left: 0, + zIndex: 1, +}); + // ── Hero ───────────────────────────────────────────────────────── export const HeroRoot = style({ @@ -260,4 +271,3 @@ export const InfoRowMixed = style({ alignItems: 'center', gap: toRem(8), }); - diff --git a/src/app/features/bots/AiChatMenu.tsx b/src/app/features/bots/AiChatMenu.tsx index 907b6648..c9e34795 100644 --- a/src/app/features/bots/AiChatMenu.tsx +++ b/src/app/features/bots/AiChatMenu.tsx @@ -1,16 +1,16 @@ import React, { forwardRef, useState } from 'react'; -import { Icons, Menu, Spinner } from 'folds'; +import { Icons, Menu } from 'folds'; import type { Room } from 'matrix-js-sdk'; import { useTranslation } from 'react-i18next'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../hooks/useRoomsNotificationPreferences'; -import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; // Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the // AI surface's overflow menu is the same popup, styled identically — only the action set differs. -import { ActionRow, ActionSectionLine, RowChevron } from '../room/room-actions/RoomActions'; +import { + ActionRow, + ActionSectionLine, + NotificationsActionRow, + RowChevron, +} from '../room/room-actions/RoomActions'; import * as css from '../room/room-actions/RoomActions.css'; type AiChatMenuProps = { @@ -28,9 +28,6 @@ type AiChatMenuProps = { export const AiChatMenu = forwardRef( ({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => { const { t } = useTranslation(); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); - const [promptLeave, setPromptLeave] = useState(false); return ( @@ -62,18 +59,7 @@ export const AiChatMenu = forwardRef( ariaHasPopup trailing={} /> - - {(handleOpen, opened, changing) => ( - : } - /> - )} - + ( label={t('Room.leave_room')} onClick={() => setPromptLeave(true)} accent="critical" - ariaPressed={promptLeave} + ariaExpanded={promptLeave} />
diff --git a/src/app/features/bots/BotShell.tsx b/src/app/features/bots/BotShell.tsx index fabf9e51..bcc7c5eb 100644 --- a/src/app/features/bots/BotShell.tsx +++ b/src/app/features/bots/BotShell.tsx @@ -42,6 +42,9 @@ export function BotShell({ preset, room }: BotShellProps) { setShowChat(true); }, [setFailed, setShowChat]); + // Swipe-back over the widget body works via the forwarded touch stream + // (widget → `swipe-touch` side-channel → BotWidgetMount → + // externalSwipeFeed) — no host-side capture strip needed. return (
diff --git a/src/app/features/bots/BotShellMenu.tsx b/src/app/features/bots/BotShellMenu.tsx index 4ef36bf0..22d7074b 100644 --- a/src/app/features/bots/BotShellMenu.tsx +++ b/src/app/features/bots/BotShellMenu.tsx @@ -1,5 +1,5 @@ -import React, { forwardRef } from 'react'; -import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds'; +import React, { forwardRef, useState } from 'react'; +import { Icons, Menu } from 'folds'; import { useSetAtom } from 'jotai'; import type { Room } from 'matrix-js-sdk'; import { useTranslation } from 'react-i18next'; @@ -8,27 +8,30 @@ import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; -import { - getRoomNotificationMode, - getRoomNotificationModeIcon, - useRoomsNotificationPreferencesContext, -} from '../../hooks/useRoomsNotificationPreferences'; -import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; -import { UseStateProvider } from '../../components/UseStateProvider'; import { markAsRead } from '../../utils/notifications'; import { botShowChatAtomFamily } from './botExperienceState'; +// Same row vocabulary + popout chrome as the 1:1 chat's ⋮ menu +// (RoomActionsMenu) and the AI chat's AiChatMenu — one popup style across +// every surface, only the action set differs. +import { + ActionRow, + ActionSectionLine, + NotificationsActionRow, + RowChevron, +} from '../room/room-actions/RoomActions'; +import * as css from '../room/room-actions/RoomActions.css'; type BotShellMenuProps = { room: Room; requestClose: () => void; }; -// Slim dropdown for the bot hero's «Настроить» button. Only items that make -// sense in a 1:1 with a bridge bot: +// The bridge-bot widget hero's ⋮ menu. Only items that make sense in a 1:1 +// with a bridge bot: // - Show chat (chat-fallback toggle, switches BotExperienceHost branch) // - Mark as read -// - Notifications (shared switcher) +// - Notifications (inline mode picker) // - Leave room // Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are // dropped — none apply to a bridge bot's control DM. @@ -38,12 +41,12 @@ export const BotShellMenu = forwardRef( const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); // Setter-only — the menu's only job around showChat is to flip it. // useSetAtom skips the value-subscription that useAtom registers. const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId)); + const [promptLeave, setPromptLeave] = useState(false); + const handleShowChat = () => { setShowChat(true); requestClose(); @@ -54,80 +57,41 @@ export const BotShellMenu = forwardRef( }; return ( - - - + {promptLeave && ( + setPromptLeave(false)} + /> + )} + +
+ } - radii="300" - > - - {t('Bots.show_chat')} - - - } + /> + } - radii="300" disabled={!unread} - > - - {t('Room.mark_as_read')} - - - - {(handleOpen, opened, changing) => ( - - ) : ( - - ) - } - radii="300" - aria-pressed={opened} - onClick={handleOpen} - > - - {t('Room.notifications')} - - - )} - - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - {t('Room.leave_room')} - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - + /> + +
+ + +
+ setPromptLeave(true)} + accent="critical" + ariaExpanded={promptLeave} + /> +
); } diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 897f1204..4b1e56f0 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -49,6 +49,13 @@ export type BotWidgetEmbedOptions = { // the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like // `onOpenMatrixToRoom`) where `mx` + navigation are available. onAddToChat?: () => void; + // Forwarded raw touch stream from the widget (`swipe-touch` action) — + // coordinates are IFRAME-local; `BotWidgetMount` maps them into host + // viewport space and feeds the swipe-back gesture. An iframe consumes + // its touches natively, so this is the only way the + // swipe-back-from-widget gesture can see drags that start on the widget + // body. Carries no privileges — just pointer coordinates. + onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void; }; const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`; @@ -247,7 +254,14 @@ export class BotWidgetEmbed { // doesn't go through ClientWidgetApi at all — keeps the SDK ignorant // of our extension and avoids the «unknown action» reply path. // - // Three actions today: + // Four actions today: + // + // * `swipe-touch` — unidirectional forwarded touch stream driving the + // host's swipe-back gesture over the iframe (an iframe consumes its + // own touches, so the widget cooperates via `swipe-forward.ts`). + // Carries only a phase + viewport-local coordinates; shape-validated + // in the handler below, mapped to host coordinates by + // `BotWidgetMount`, consumed by `externalSwipeFeed`. // // * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat` // capability opt-in in config.json. Carries NO url and NO room/mxid: @@ -272,7 +286,7 @@ export class BotWidgetEmbed { // BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The // widget never sees a route — it only knows matrix.to URLs. // - // Security gates (defence in depth, apply to BOTH actions): + // Security gates (defence in depth, apply to ALL actions): // 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this // check, a compromised widget bundle could `window.location.href // = 'https://attacker.example/'` — the browser keeps the same @@ -307,6 +321,20 @@ export class BotWidgetEmbed { if (!msg || typeof msg !== 'object') return; if (msg.api !== 'io.vojo.bot-widget') return; + // Forwarded touch stream for the swipe-back gesture (see + // `onSwipeTouch` in the options). Validated shape only — finite + // numbers and a known phase; everything else is silently dropped. + if (msg.action === 'swipe-touch') { + const data = msg.data as { phase?: unknown; x?: unknown; y?: unknown } | undefined; + const phase = data?.phase; + const { x, y } = data ?? {}; + if (phase !== 'start' && phase !== 'move' && phase !== 'end' && phase !== 'cancel') return; + if (typeof x !== 'number' || !Number.isFinite(x)) return; + if (typeof y !== 'number' || !Number.isFinite(y)) return; + this.options.onSwipeTouch?.(phase, x, y); + return; + } + // Elevated verb — must be dispatched BEFORE any url extraction. Its `data` // is `{}` (no url), so a hoisted `typeof url === 'string'` gate would // early-return and the verb would never fire (F1). The host gate reads the diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx index 4ecd2221..fd3d3808 100644 --- a/src/app/features/bots/BotWidgetMount.tsx +++ b/src/app/features/bots/BotWidgetMount.tsx @@ -17,6 +17,7 @@ import { import type { MatrixToRoom } from '../../plugins/matrix-to'; import { Membership } from '../../../types/matrix/room'; import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room'; +import { emitExternalSwipe } from '../../components/swipe-back/externalSwipeFeed'; import { useBotWidgetEmbed } from './useBotWidgetEmbed'; import { BotAddToChatPicker } from './BotAddToChatPicker'; import * as css from './BotWidgetMount.css'; @@ -236,6 +237,24 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { const [pickerOpen, setPickerOpen] = useState(false); const handleAddToChat = useCallback(() => setPickerOpen(true), []); + // The widget's forwarded touch stream → the swipe-back gesture. The + // widget reports IFRAME-local clientX/Y; offset by the mount's viewport + // rect so the host-side state machine (edge guard, axis resolve in + // `useSwipeBackGesture`) sees true host-viewport coordinates. Always + // emitted — the feed only has a subscriber when the swipe overlay is + // mounted (mobile + native), everywhere else it's a no-op. + const handleSwipeTouch = useCallback( + (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => { + const rect = containerRef.current?.getBoundingClientRect(); + emitExternalSwipe({ + phase, + x: x + (rect?.left ?? 0), + y: y + (rect?.top ?? 0), + }); + }, + [] + ); + const { ready } = useBotWidgetEmbed({ containerRef, preset, @@ -243,6 +262,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) { onError, onOpenMatrixToRoom: handleOpenMatrixToRoom, onAddToChat: handleAddToChat, + onSwipeTouch: handleSwipeTouch, }); // Track Matrix sync state so the bot loading bar yields to the global diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts index c4ed5009..61c24a82 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -19,6 +19,10 @@ type UseBotWidgetEmbedOptions = { // `add-to-chat` verb (host opens its own room picker). Plumbed from // `BotWidgetMount` where `mx` is available. onAddToChat?: () => void; + // Forwarded into the embed — the widget's raw touch stream (iframe-local + // coordinates) for the swipe-back gesture. `BotWidgetMount` maps the + // coordinates and feeds `externalSwipeFeed`. + onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void; }; type UseBotWidgetEmbedResult = { @@ -40,6 +44,7 @@ export const useBotWidgetEmbed = ({ onError, onOpenMatrixToRoom, onAddToChat, + onSwipeTouch, }: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => { const { i18n } = useTranslation(); const mx = useMatrixClient(); @@ -63,6 +68,10 @@ export const useBotWidgetEmbed = ({ // render) so the embed lifecycle effect doesn't remount the iframe. const onAddToChatRef = useRef(onAddToChat); onAddToChatRef.current = onAddToChat; + // Same ref indirection for the swipe feed — per-render closure identity + // must not remount the iframe. + const onSwipeTouchRef = useRef(onSwipeTouch); + onSwipeTouchRef.current = onSwipeTouch; // Depend on primitive identity for the embed lifecycle — using `preset` // directly would remount the iframe (and re-handshake with the widget) @@ -96,6 +105,7 @@ export const useBotWidgetEmbed = ({ // navigate-callback closes over a new render's `mx`/`navigate`. onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target), onAddToChat: () => onAddToChatRef.current?.(), + onSwipeTouch: (phase, x, y) => onSwipeTouchRef.current?.(phase, x, y), }); embedRef.current = embed; } catch (error) { diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index f52b86f3..13ccf931 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -15,6 +15,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { SequenceCard } from '../../components/sequence-card'; import * as css from './styles.css'; import { @@ -33,6 +34,7 @@ type CallControlsProps = { callEmbed: CallEmbed; }; export function CallControls({ callEmbed }: CallControlsProps) { + const { t } = useTranslation(); const controlRef = useRef(null); const [compact, setCompact] = useState(document.body.clientWidth < 500); @@ -140,7 +142,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { onClick={handleSpotlightClick} > - {spotlight ? 'Grid View' : 'Spotlight View'} + {spotlight ? t('Call.view_grid') : t('Call.view_spotlight')} - Reactions + {t('Call.reactions')} @@ -162,7 +164,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { onClick={handleSettingsClick} > - Settings + {t('Call.settings')} @@ -198,7 +200,7 @@ export function CallControls({ callEmbed }: CallControlsProps) { } disabled={exiting} > - End + {t('Call.end_call')} diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx index 1c3680f5..4930bedf 100644 --- a/src/app/features/call/CallMemberCard.tsx +++ b/src/app/features/call/CallMemberCard.tsx @@ -1,6 +1,7 @@ import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import React, { useState } from 'react'; import { Avatar, Box, Icon, Icons, Text } from 'folds'; +import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; @@ -84,6 +85,7 @@ export function CallMemberRenderer({ members: CallMembership[]; max?: number; }) { + const { t } = useTranslation(); const [viewMore, setViewMore] = useState(false); const truncatedMembers = viewMore ? members : members.slice(0, 4); @@ -105,11 +107,11 @@ export function CallMemberRenderer({ {viewMore ? ( - Collapse + {t('Call.collapse')} ) : ( - {remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`} + {t('Call.others_count', { count: remaining })} )} diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index 7c7bec6c..34d269ad 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -1,5 +1,6 @@ import React, { RefObject, useRef } from 'react'; import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds'; +import { useTranslation } from 'react-i18next'; import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; @@ -16,9 +17,10 @@ import { CallControls } from './CallControls'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; function LivekitServerMissingMessage() { + const { t } = useTranslation(); return ( - Your homeserver does not support calling. But you can still join call started by others. + {t('Call.homeserver_no_calls')} ); } @@ -30,6 +32,7 @@ function JoinMessage({ hasParticipant?: boolean; livekitSupported?: boolean; }) { + const { t } = useTranslation(); if (hasParticipant) return null; if (livekitSupported === false) { @@ -38,28 +41,31 @@ function JoinMessage({ return ( - Voice chat’s empty — Be the first to hop in! + {t('Call.empty_be_first')} ); } function NoPermissionMessage() { + const { t } = useTranslation(); return ( - You don't have permission to join! + {t('Call.no_permission_to_join')} ); } function AlreadyInCallMessage() { + const { t } = useTranslation(); return ( - Already in another call — End the current call to join! + {t('Call.already_in_other_call')} ); } function CallPrescreen() { + const { t } = useTranslation(); const mx = useMatrixClient(); const room = useRoom(); const livekitSupported = useLivekitSupport(); @@ -86,11 +92,11 @@ function CallPrescreen() { {hasParticipant && (
- Participant + {t('Call.participant')} - {callMembers.length} Live + {t('Call.live_count', { count: callMembers.length })}
diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx index 200d0af0..74d90d93 100644 --- a/src/app/features/call/PrescreenControls.tsx +++ b/src/app/features/call/PrescreenControls.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Box, Button, Icon, Icons, Spinner, Text } from 'folds'; +import { useTranslation } from 'react-i18next'; import { SequenceCard } from '../../components/sequence-card'; import * as css from './styles.css'; import { ChatButton, ControlDivider, MicrophoneButton, VideoButton } from './Controls'; @@ -11,6 +12,7 @@ type PrescreenControlsProps = { canJoin?: boolean; }; export function PrescreenControls({ canJoin }: PrescreenControlsProps) { + const { t } = useTranslation(); const room = useRoom(); const callEmbed = useCallEmbed(); const callJoined = useCallJoined(callEmbed); @@ -57,7 +59,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) { ) } > - Join + {t('Call.join')} diff --git a/src/app/features/common-settings/SettingsNav.css.ts b/src/app/features/common-settings/SettingsNav.css.ts index 0bf939ce..7e0a0707 100644 --- a/src/app/features/common-settings/SettingsNav.css.ts +++ b/src/app/features/common-settings/SettingsNav.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { globalStyle, style } from '@vanilla-extract/css'; import { color, config, DefaultReset, FocusOutline, toRem } from 'folds'; // Dawn settings rail — uppercase tracked muted labels, raised active row with a @@ -34,6 +34,11 @@ export const NavSection = style([ }, ]); +// Menu row — same Fleet vocabulary as the user-settings menu: tinted +// rounded-square glyph chip (rendered by SettingsNav.tsx via the shared +// MenuRowIcon recipe), label, trailing chevron. Active row raises on the +// panel tone with a weight bump; mouse-only hover (Android WebView's +// synthesised sticky `:hover` — see src/index.tsx). export const NavItem = style([ DefaultReset, FocusOutline, @@ -43,40 +48,27 @@ export const NavItem = style([ alignItems: 'center', gap: config.space.S300, width: '100%', - padding: `${config.space.S300} ${config.space.S300}`, + minHeight: toRem(46), + padding: `${config.space.S200} ${config.space.S300}`, marginBottom: toRem(2), - borderRadius: config.radii.R400, + borderRadius: toRem(12), color: color.Surface.OnContainer, cursor: 'pointer', + transition: 'background-color 120ms ease-out', selectors: { - '&:hover': { backgroundColor: color.Background.ContainerHover }, + '&:active': { backgroundColor: color.Background.ContainerActive }, '&[aria-pressed=true]': { backgroundColor: color.Background.ContainerActive }, - '&[aria-pressed=true]::before': { - content: '""', - position: 'absolute', - left: toRem(5), - top: '50%', - transform: 'translateY(-50%)', - width: toRem(3), - height: '52%', - borderRadius: toRem(3), - backgroundColor: color.Primary.Main, - }, }, }, ]); -export const NavItemIcon = style({ - opacity: 0.6, - selectors: { - [`${NavItem}[aria-pressed=true] &`]: { - color: color.Primary.Main, - opacity: 1, - }, - }, +globalStyle(`:root[data-input="mouse"] ${NavItem}:hover`, { + backgroundColor: color.Background.ContainerHover, }); export const NavItemLabel = style({ + flexGrow: 1, + minWidth: 0, fontWeight: config.fontWeight.W500, selectors: { [`${NavItem}[aria-pressed=true] &`]: { @@ -84,3 +76,9 @@ export const NavItemLabel = style({ }, }, }); + +export const NavItemChevron = style({ + flexShrink: 0, + color: color.Surface.OnContainer, + opacity: 0.4, +}); diff --git a/src/app/features/common-settings/SettingsNav.tsx b/src/app/features/common-settings/SettingsNav.tsx index 998ad95f..c543e199 100644 --- a/src/app/features/common-settings/SettingsNav.tsx +++ b/src/app/features/common-settings/SettingsNav.tsx @@ -1,20 +1,29 @@ import React, { ReactNode } from 'react'; -import { Icon, IconSrc, Text } from 'folds'; +import { Icon, Icons, IconSrc, Text } from 'folds'; +import { MenuRowIcon } from '../settings/styles.css'; import * as css from './SettingsNav.css'; +// One muted Dawn accent per row — the same tinted rounded-square glyph +// vocabulary the user-settings menu uses (features/settings/styles.css). +export type SettingsNavTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral'; + type SettingsNavItemProps = { icon: IconSrc; label: string; active: boolean; + tint?: SettingsNavTint; onClick: () => void; }; -export function SettingsNavItem({ icon, label, active, onClick }: SettingsNavItemProps) { +export function SettingsNavItem({ icon, label, active, tint, onClick }: SettingsNavItemProps) { return ( ); } diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx index 1ea6f542..144cc0b0 100644 --- a/src/app/features/lobby/Lobby.tsx +++ b/src/app/features/lobby/Lobby.tsx @@ -3,6 +3,7 @@ import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config import { useVirtualizer } from '@tanstack/react-virtual'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk'; import type { AccountDataEvents, StateEvents } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; @@ -150,6 +151,7 @@ const useCanDropLobbyItem = ( }; export function Lobby() { + const { t } = useTranslation(); const navigate = useNavigate(); const mx = useMatrixClient(); const mDirects = useAtomValue(mDirectAtom); @@ -459,7 +461,7 @@ export function Lobby() { radii="Pill" outlined size="300" - aria-label="Scroll to Top" + aria-label={t('Room.lobby_scroll_to_top')} > @@ -535,7 +537,7 @@ export function Lobby() { radii="Pill" before={} > - Reordering + {t('Room.lobby_reordering')} )} diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index ed526c68..35dc0303 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -17,6 +17,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { PageHeader } from '../../components/page'; import { useSetSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; @@ -45,6 +46,7 @@ type LobbyMenuProps = { }; const LobbyMenu = forwardRef( ({ powerLevels, requestClose }, ref) => { + const { t } = useTranslation(); const mx = useMatrixClient(); const space = useSpace(); const creators = useRoomCreators(space); @@ -87,7 +89,7 @@ const LobbyMenu = forwardRef( disabled={!canInvite} > - Invite + {t('Room.invite')} ( radii="300" > - Space Settings + {t('Room.lobby_space_settings')} @@ -116,7 +118,7 @@ const LobbyMenu = forwardRef( aria-pressed={promptLeave} > - Leave Space + {t('Room.lobby_leave_space')} {promptLeave && ( @@ -140,6 +142,7 @@ type LobbyHeaderProps = { powerLevels: IPowerLevels; }; export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const space = useSpace(); @@ -213,7 +216,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { offset={4} tooltip={ - Members + {t('Room.members')} } > @@ -234,7 +237,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { offset={4} tooltip={ - More Options + {t('Room.more_options')} } > diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx index 7de59acd..1dd7aa4a 100644 --- a/src/app/features/lobby/RoomItem.tsx +++ b/src/app/features/lobby/RoomItem.tsx @@ -19,6 +19,7 @@ import { toRem, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { useTranslation } from 'react-i18next'; import { JoinRule, MatrixError, Room } from 'matrix-js-sdk'; import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; @@ -44,6 +45,7 @@ type RoomJoinButtonProps = { via?: string[]; }; function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const [joinState, join] = useAsyncCallback( @@ -91,7 +93,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) { onClick={join} disabled={!canJoin} > - Join + {t('Room.lobby_join')} ); @@ -127,6 +129,7 @@ type RoomProfileErrorProps = { via?: string[]; }; function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) { + const { t } = useTranslation(); return ( @@ -146,12 +149,12 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf - Unknown + {t('Room.lobby_unknown')} {suggested && ( - Suggested + {t('Room.lobby_suggested')} )} @@ -159,7 +162,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf {inaccessibleRoom ? ( - Inaccessible + {t('Room.lobby_inaccessible')} ) : ( @@ -195,6 +198,7 @@ function RoomProfile({ joinRule, options, }: RoomProfileProps) { + const { t } = useTranslation(); return ( @@ -213,7 +217,7 @@ function RoomProfile({ {suggested && ( - Suggested + {t('Room.lobby_suggested')} )} @@ -221,7 +225,12 @@ function RoomProfile({ {memberCount && ( - {`${millify(memberCount)} Members`} + + {t('Room.lobby_members_count', { + count: memberCount, + formattedCount: millify(memberCount), + })} + )} {memberCount && topic && ( @@ -311,6 +320,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( }, ref ) => { + const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { roomId, content } = item; @@ -359,7 +369,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>( fill="None" size="400" radii="Pill" - aria-label="Open Room" + aria-label={t('Room.lobby_open_room')} > diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx index a2114b78..35954c50 100644 --- a/src/app/features/lobby/SpaceItem.tsx +++ b/src/app/features/lobby/SpaceItem.tsx @@ -60,6 +60,7 @@ type InaccessibleSpaceProfileProps = { suggested?: boolean; }; function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) { + const { t } = useTranslation(); return ( ( - U + {nameInitials(t('Room.lobby_unknown'))} )} /> @@ -81,15 +82,15 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil > - Unknown + {t('Room.lobby_unknown')} - Inaccessible + {t('Room.lobby_inaccessible')} {suggested && ( - Suggested + {t('Room.lobby_suggested')} )} @@ -111,6 +112,7 @@ function UnjoinedSpaceProfile({ avatarUrl, suggested, }: UnjoinedSpaceProfileProps) { + const { t } = useTranslation(); const mx = useMatrixClient(); const [joinState, join] = useAsyncCallback( @@ -145,11 +147,11 @@ function UnjoinedSpaceProfile({ > - {name || 'Unknown'} + {name || t('Room.lobby_unknown')} {suggested && ( - Suggested + {t('Room.lobby_suggested')} )} {joinState.status === AsyncStatus.Error && ( @@ -182,6 +184,7 @@ function SpaceProfile({ categoryId, handleClose, }: SpaceProfileProps) { + const { t } = useTranslation(); return ( {suggested && ( - Suggested + {t('Room.lobby_suggested')} )} @@ -225,6 +228,7 @@ type RootSpaceProfileProps = { handleClose?: MouseEventHandler; }; function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) { + const { t } = useTranslation(); return ( - Rooms + {t('Room.lobby_rooms')} diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 0a3131ba..de49607a 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -198,7 +198,7 @@ export function MessageSearch({ radii="Pill" outlined size="300" - aria-label="Scroll to Top" + aria-label={t('Room.msg_search_scroll_to_top')} > diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 89a2e489..f084aca2 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -1,176 +1,68 @@ -import React, { useMemo, useState } from 'react'; -import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Text } from 'folds'; +import React from 'react'; +import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; import { useTranslation } from 'react-i18next'; -import { JoinRule } from 'matrix-js-sdk'; -import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page'; -import { - SettingsNavEyebrow, - SettingsNavItem, - SettingsNavSection, -} from '../common-settings/SettingsNav'; -import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; -import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { mxcUrlToHttp } from '../../utils/matrix'; -import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; -import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta'; -import { isOneOnOneRoom } from '../../utils/room'; -import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; -import { General } from './general'; -import { Members } from '../common-settings/members'; -import { EmojisStickers } from '../common-settings/emojis-stickers'; -import { Permissions } from './permissions'; -import { RoomSettingsPage } from '../../state/roomSettings'; +import { Page, PageContent, PageHeader } from '../../components/page'; +import { SettingsSection } from '../settings/SettingsSection'; +import { usePowerLevels } from '../../hooks/usePowerLevels'; import { useRoom } from '../../hooks/useRoom'; -import { DeveloperTools } from '../common-settings/developer-tools'; - -type RoomSettingsMenuItem = { - page: RoomSettingsPage; - name: string; - icon: IconSrc; -}; - -const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => { - const { t } = useTranslation(); - return useMemo( - () => [ - { - page: RoomSettingsPage.GeneralPage, - name: t('RoomSettings.general'), - icon: Icons.Setting, - }, - { - page: RoomSettingsPage.MembersPage, - name: t('RoomSettings.members'), - icon: Icons.User, - }, - { - page: RoomSettingsPage.PermissionsPage, - name: t('RoomSettings.permissions'), - icon: Icons.Lock, - }, - { - page: RoomSettingsPage.EmojisStickersPage, - name: t('RoomSettings.emojis_stickers'), - icon: Icons.Smile, - }, - { - page: RoomSettingsPage.DeveloperToolsPage, - name: t('RoomSettings.developer_tools'), - icon: Icons.Terminal, - }, - ], - [t] - ); -}; +import { + RoomProfile, + RoomEncryption, + RoomHistoryVisibility, + RoomJoinRules, + RoomVoiceMessages, +} from '../common-settings/general'; +import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { useRoomPermissions } from '../../hooks/useRoomPermissions'; type RoomSettingsProps = { - initialPage?: RoomSettingsPage; requestClose: () => void; }; -export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { + +// Single-page room settings, opened from the room's «⋯» menu. No nav +// column, no sub-pages: rooms keep only the profile + the four +// user-meaningful toggles. Addresses / directory publish / room upgrade / +// power levels / emoji packs / developer tools are Matrix-protocol +// surfaces end users never needed in a chat — dropped from rooms (spaces +// keep the full set in SpaceSettings). +export function RoomSettings({ requestClose }: RoomSettingsProps) { const { t } = useTranslation(); const room = useRoom(); - const mx = useMatrixClient(); - const useAuthentication = useMediaAuthentication(); - - // Peer-avatar fallback follows the member-count gate, mirroring the room - // header (post-P3c). Settings is a globally-mounted modal, so the - // IsOneOnOneProvider context isn't in scope here — call the helper directly. - const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room)); - const roomName = useRoomName(room); - const joinRuleContent = useRoomJoinRule(room); - - const avatarUrl = roomAvatar - ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined - : undefined; - - const screenSize = useScreenSizeContext(); - const [activePage, setActivePage] = useState(() => { - if (initialPage) return initialPage; - return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage; - }); - const menuItems = useRoomSettingsMenuItems(); - - const handlePageRequestClose = () => { - if (screenSize === ScreenSize.Mobile) { - setActivePage(undefined); - return; - } - requestClose(); - }; + const powerLevels = usePowerLevels(room); + const creators = useRoomCreators(room); + const permissions = useRoomPermissions(creators, powerLevels); return ( - - - - - ( - - )} - /> - - - {t('RoomSettings.settings')} - - {roomName} - - - - - {screenSize === ScreenSize.Mobile && ( - - - - )} - - - - -
- {t('RoomSettings.sections')} - {menuItems.map((item) => ( - setActivePage(item.page)} - /> - ))} -
-
+ + + + + + {t('Room.room_settings')} + + + + + + + + + + + + + + + + + + + + - - ) - } - > - {activePage === RoomSettingsPage.GeneralPage && ( - - )} - {activePage === RoomSettingsPage.MembersPage && ( - - )} - {activePage === RoomSettingsPage.PermissionsPage && ( - - )} - {activePage === RoomSettingsPage.EmojisStickersPage && ( - - )} - {activePage === RoomSettingsPage.DeveloperToolsPage && ( - - )} -
+ + +
+ ); } diff --git a/src/app/features/room-settings/RoomSettingsRenderer.tsx b/src/app/features/room-settings/RoomSettingsRenderer.tsx index bc967775..74837bb8 100644 --- a/src/app/features/room-settings/RoomSettingsRenderer.tsx +++ b/src/app/features/room-settings/RoomSettingsRenderer.tsx @@ -11,7 +11,7 @@ type RenderSettingsProps = { state: RoomSettingsState; }; function RenderSettings({ state }: RenderSettingsProps) { - const { roomId, spaceId, page } = state; + const { roomId, spaceId } = state; const closeSettings = useCloseRoomSettings(); const allJoinedRooms = useAllJoinedRoomsSet(); const getRoom = useGetRoom(allJoinedRooms); @@ -21,10 +21,10 @@ function RenderSettings({ state }: RenderSettingsProps) { if (!room) return null; return ( - + - + diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx deleted file mode 100644 index 52b6cb08..00000000 --- a/src/app/features/room-settings/general/General.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import React from 'react'; -import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; -import { useTranslation } from 'react-i18next'; -import { Page, PageContent, PageHeader } from '../../../components/page'; -import { SettingsSection } from '../../settings/SettingsSection'; -import { SectionLabel } from '../../settings/styles.css'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useRoom } from '../../../hooks/useRoom'; -import { - RoomProfile, - RoomEncryption, - RoomHistoryVisibility, - RoomJoinRules, - RoomLocalAddresses, - RoomPublishedAddresses, - RoomPublish, - RoomUpgrade, - RoomVoiceMessages, -} from '../../common-settings/general'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; - -type GeneralProps = { - requestClose: () => void; -}; -export function General({ requestClose }: GeneralProps) { - const { t } = useTranslation(); - const room = useRoom(); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - const permissions = useRoomPermissions(creators, powerLevels); - - return ( - - - - - - {t('RoomSettings.general')} - - - - - - - - - - - - - - - - - - - - - - - - {t('RoomSettings.addresses')} - - - - - - - {t('RoomSettings.advanced_options')} - - - - - - - - - ); -} diff --git a/src/app/features/room-settings/general/index.ts b/src/app/features/room-settings/general/index.ts deleted file mode 100644 index 0ab02c52..00000000 --- a/src/app/features/room-settings/general/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './General'; diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx deleted file mode 100644 index 6f23be85..00000000 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import React, { useState } from 'react'; -import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; -import { useTranslation } from 'react-i18next'; -import { Page, PageContent, PageHeader } from '../../../components/page'; -import { useRoom } from '../../../hooks/useRoom'; -import { usePowerLevels } from '../../../hooks/usePowerLevels'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { StateEvent } from '../../../../types/matrix/room'; -import { usePermissionGroups } from './usePermissionItems'; -import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; -import { useRoomCreators } from '../../../hooks/useRoomCreators'; -import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; - -type PermissionsProps = { - requestClose: () => void; -}; -export function Permissions({ requestClose }: PermissionsProps) { - const { t } = useTranslation(); - const mx = useMatrixClient(); - const room = useRoom(); - const powerLevels = usePowerLevels(room); - const creators = useRoomCreators(room); - - const permissions = useRoomPermissions(creators, powerLevels); - - const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId()); - const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId()); - const permissionGroups = usePermissionGroups(room.isCallRoom()); - - const [powerEditor, setPowerEditor] = useState(false); - - const handleEditPowers = () => { - setPowerEditor(true); - }; - - if (canEditPowers && powerEditor) { - return setPowerEditor(false)} />; - } - - return ( - - - - - - {t('RoomSettings.permissions')} - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/src/app/features/room-settings/permissions/index.ts b/src/app/features/room-settings/permissions/index.ts deleted file mode 100644 index 753f2b4b..00000000 --- a/src/app/features/room-settings/permissions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Permissions'; diff --git a/src/app/features/room-settings/permissions/usePermissionItems.ts b/src/app/features/room-settings/permissions/usePermissionItems.ts deleted file mode 100644 index eb46bc7c..00000000 --- a/src/app/features/room-settings/permissions/usePermissionItems.ts +++ /dev/null @@ -1,230 +0,0 @@ -import { useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { MessageEvent, StateEvent } from '../../../../types/matrix/room'; -import { PermissionGroup } from '../../common-settings/permissions'; - -export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => { - const { t } = useTranslation(); - const groups: PermissionGroup[] = useMemo(() => { - const messagesGroup: PermissionGroup = { - name: t('RoomSettings.perm_messages'), - items: [ - { - location: { - key: MessageEvent.RoomMessage, - }, - name: t('RoomSettings.perm_send_messages'), - }, - { - location: { - key: MessageEvent.Sticker, - }, - name: t('RoomSettings.perm_send_stickers'), - }, - { - location: { - key: MessageEvent.Reaction, - }, - name: t('RoomSettings.perm_send_reactions'), - }, - { - location: { - notification: true, - key: 'room', - }, - name: t('RoomSettings.perm_ping_room'), - }, - { - location: { - state: true, - key: StateEvent.RoomPinnedEvents, - }, - name: t('RoomSettings.perm_pin_messages'), - }, - { - location: {}, - name: t('RoomSettings.perm_other_message_events'), - }, - ], - }; - - const callSettingsGroup: PermissionGroup = { - name: t('RoomSettings.perm_calls'), - items: [ - { - location: { - state: true, - key: StateEvent.GroupCallMemberPrefix, - }, - name: t('RoomSettings.perm_join_call'), - }, - ], - }; - - const moderationGroup: PermissionGroup = { - name: t('RoomSettings.perm_moderation'), - items: [ - { - location: { - action: true, - key: 'invite', - }, - name: t('RoomSettings.perm_invite'), - }, - { - location: { - action: true, - key: 'kick', - }, - name: t('RoomSettings.perm_kick'), - }, - { - location: { - action: true, - key: 'ban', - }, - name: t('RoomSettings.perm_ban'), - }, - { - location: { - action: true, - key: 'redact', - }, - name: t('RoomSettings.perm_delete_others_messages'), - }, - { - location: { - key: MessageEvent.RoomRedaction, - }, - name: t('RoomSettings.perm_delete_self_messages'), - }, - ], - }; - - const roomOverviewGroup: PermissionGroup = { - name: t('RoomSettings.perm_room_overview'), - items: [ - { - location: { - state: true, - key: StateEvent.RoomAvatar, - }, - name: t('RoomSettings.perm_room_avatar'), - }, - { - location: { - state: true, - key: StateEvent.RoomName, - }, - name: t('RoomSettings.perm_room_name'), - }, - { - location: { - state: true, - key: StateEvent.RoomTopic, - }, - name: t('RoomSettings.perm_room_topic'), - }, - ], - }; - - const roomSettingsGroup: PermissionGroup = { - name: t('RoomSettings.perm_settings'), - items: [ - { - location: { - state: true, - key: StateEvent.RoomJoinRules, - }, - name: t('RoomSettings.perm_change_room_access'), - }, - { - location: { - state: true, - key: StateEvent.RoomCanonicalAlias, - }, - name: t('RoomSettings.perm_publish_address'), - }, - { - location: { - state: true, - key: StateEvent.RoomPowerLevels, - }, - name: t('RoomSettings.perm_change_all_permission'), - }, - { - location: { - state: true, - key: StateEvent.PowerLevelTags, - }, - name: t('RoomSettings.perm_edit_power_levels'), - }, - { - location: { - state: true, - key: StateEvent.RoomEncryption, - }, - name: t('RoomSettings.perm_enable_encryption'), - }, - { - location: { - state: true, - key: StateEvent.RoomHistoryVisibility, - }, - name: t('RoomSettings.perm_history_visibility'), - }, - { - location: { - state: true, - key: StateEvent.RoomTombstone, - }, - name: t('RoomSettings.perm_upgrade_room'), - }, - { - location: { - state: true, - }, - name: t('RoomSettings.perm_other_settings'), - }, - ], - }; - - const otherSettingsGroup: PermissionGroup = { - name: t('RoomSettings.perm_other'), - items: [ - { - location: { - state: true, - key: StateEvent.PoniesRoomEmotes, - }, - name: t('RoomSettings.perm_manage_emojis_stickers'), - }, - { - location: { - state: true, - key: StateEvent.RoomServerAcl, - }, - name: t('RoomSettings.perm_change_server_acls'), - }, - { - location: { - state: true, - key: 'im.vector.modular.widgets', - }, - name: t('RoomSettings.perm_modify_widgets'), - }, - ], - }; - - return [ - messagesGroup, - ...(isCallRoom ? [callSettingsGroup] : []), - moderationGroup, - roomOverviewGroup, - roomSettingsGroup, - otherSettingsGroup, - ]; - }, [isCallRoom, t]); - - return groups; -}; diff --git a/src/app/features/room-settings/styles.css.ts b/src/app/features/room-settings/styles.css.ts deleted file mode 100644 index ce89c16e..00000000 --- a/src/app/features/room-settings/styles.css.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { style } from '@vanilla-extract/css'; -import { config } from 'folds'; - -export const SequenceCardStyle = style({ - padding: config.space.S300, -}); diff --git a/src/app/features/room/ComposerAttachments.css.ts b/src/app/features/room/ComposerAttachments.css.ts new file mode 100644 index 00000000..439e883a --- /dev/null +++ b/src/app/features/room/ComposerAttachments.css.ts @@ -0,0 +1,187 @@ +import { globalStyle, style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +// In-composer attachment strip — replaces the old floating UploadBoard +// window. Attached media render as square thumbnails and other files as +// compact pills, all on ONE horizontally-scrollable row inside the +// composer card (the same `top` slot the reply/edit banners use) — +// Telegram-style: the attachment is a record in the input form, and the +// composer's own send button ships it. +export const Strip = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + overflowX: 'auto', + overflowY: 'hidden', + padding: `${config.space.S200} ${config.space.S300} 0`, + scrollbarWidth: 'none', +}); + +globalStyle(`${Strip}::-webkit-scrollbar`, { + display: 'none', +}); + +const TILE_PX = 76; + +// Square media thumbnail (image / video). +export const MediaTile = style({ + position: 'relative', + width: toRem(TILE_PX), + height: toRem(TILE_PX), + flexShrink: 0, + borderRadius: toRem(12), + overflow: 'hidden', + backgroundColor: color.SurfaceVariant.Container, +}); + +export const MediaTileContent = style({ + width: '100%', + height: '100%', + objectFit: 'cover', + display: 'block', +}); + +export const MediaTileSpoilered = style({ + filter: 'blur(20px)', +}); + +// Non-media file pill: type icon + name/size column. +export const FileTile = style({ + position: 'relative', + display: 'flex', + alignItems: 'center', + gap: config.space.S200, + height: toRem(TILE_PX), + maxWidth: toRem(220), + flexShrink: 0, + padding: `0 ${config.space.S300}`, + // Room for the floating remove badge so it doesn't sit on the text. + paddingRight: toRem(28), + borderRadius: toRem(12), + backgroundColor: color.SurfaceVariant.Container, +}); + +export const FileTileText = style({ + minWidth: 0, + display: 'flex', + flexDirection: 'column', + gap: toRem(2), +}); + +export const FileTileName = style({ + fontSize: toRem(12.5), + lineHeight: toRem(16), + fontWeight: 600, + color: color.SurfaceVariant.OnContainer, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); + +export const FileTileMeta = style({ + fontSize: toRem(11), + lineHeight: toRem(14), + color: color.SurfaceVariant.OnContainer, + opacity: 0.6, + whiteSpace: 'nowrap', + overflow: 'hidden', + textOverflow: 'ellipsis', + fontVariantNumeric: 'tabular-nums', +}); + +export const FileTileMetaCritical = style({ + color: color.Critical.Main, + opacity: 1, +}); + +// Floating × — top-right corner of every tile. The visible badge stays +// 20px; the transparent ::after inflates the hit area to ~36px for +// thumbs (clipped by the tile's overflow:hidden at the outer edges, so +// the growth is effectively inward). +export const RemoveBtn = style({ + position: 'absolute', + top: toRem(4), + right: toRem(4), + zIndex: 2, + width: toRem(20), + height: toRem(20), + borderRadius: '50%', + border: 'none', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + backgroundColor: 'rgba(0, 0, 0, 0.6)', + color: '#fff', + selectors: { + '&::after': { + content: '""', + position: 'absolute', + inset: toRem(-8), + }, + }, +}); + +// Spoiler toggle — bottom-left of media tiles; violet when armed. Same +// inflated hit area as RemoveBtn. +export const SpoilerBtn = style({ + position: 'absolute', + bottom: toRem(4), + left: toRem(4), + zIndex: 2, + width: toRem(22), + height: toRem(22), + borderRadius: '50%', + border: 'none', + padding: 0, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + cursor: 'pointer', + backgroundColor: 'rgba(0, 0, 0, 0.6)', + color: '#fff', + selectors: { + '&[aria-pressed="true"]': { + backgroundColor: color.Primary.Main, + color: color.Primary.OnMain, + }, + '&::after': { + content: '""', + position: 'absolute', + inset: toRem(-7), + }, + }, +}); + +// Upload progress / error overlay over a media tile. +export const TileOverlay = style({ + position: 'absolute', + inset: 0, + zIndex: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.45)', + color: '#fff', + fontSize: toRem(12), + fontWeight: 600, + fontVariantNumeric: 'tabular-nums', +}); + +// Failed / oversized tile — critical rim; the retry button lives in the +// overlay (media) or the meta line (files). +export const TileError = style({ + boxShadow: `inset 0 0 0 ${toRem(1.5)} ${color.Critical.Main}`, +}); + +export const RetryBtn = style({ + border: 'none', + padding: `${toRem(2)} ${toRem(8)}`, + borderRadius: toRem(99), + cursor: 'pointer', + backgroundColor: color.Critical.Main, + color: color.Critical.OnMain, + fontSize: toRem(11), + fontWeight: 600, +}); diff --git a/src/app/features/room/ComposerAttachments.tsx b/src/app/features/room/ComposerAttachments.tsx new file mode 100644 index 00000000..c6210bdd --- /dev/null +++ b/src/app/features/room/ComposerAttachments.tsx @@ -0,0 +1,213 @@ +import React, { useEffect } from 'react'; +import classNames from 'classnames'; +import { Icon, Icons, Text, percent } from 'folds'; +import { useTranslation } from 'react-i18next'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useObjectURL } from '../../hooks/useObjectURL'; +import { useMediaConfig } from '../../hooks/useMediaConfig'; +import { UploadStatus, useBindUploadAtom } from '../../state/upload'; +import { + roomUploadAtomFamily, + TUploadItem, + TUploadMetadata, +} from '../../state/room/roomInputDrafts'; +import { TUploadContent } from '../../utils/matrix'; +import { bytesToSize, getFileTypeIcon } from '../../utils/common'; +import * as css from './ComposerAttachments.css'; + +// In-composer attachment strip — the record of attached media INSIDE the +// input form (Telegram-style), replacing the old floating UploadBoard +// window. Uploads start immediately on attach (same as before); the +// composer's send button ships every finished upload together with the +// text. Per item: × to remove, spoiler toggle on media, progress overlay +// while uploading, critical rim + retry on failure. + +type AttachmentItemProps = { + fileItem: TUploadItem; + isEncrypted?: boolean; + setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void; + onRemove: (file: TUploadContent) => void; +}; + +function AttachmentItem({ fileItem, isEncrypted, setMetadata, onRemove }: AttachmentItemProps) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const mediaConfig = useMediaConfig(); + const allowSize = mediaConfig['m.upload.size'] || Infinity; + + const uploadAtom = roomUploadAtomFamily(fileItem.file); + const { metadata, originalFile } = fileItem; + const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted); + const { file } = upload; + const fileSizeExceeded = file.size >= allowSize; + + // Auto-start on attach — same behaviour the old UploadCardRenderer had. + useEffect(() => { + if (upload.status === UploadStatus.Idle && !fileSizeExceeded) { + startUpload(); + } + }, [upload.status, fileSizeExceeded, startUpload]); + + const previewUrl = useObjectURL( + originalFile.type.startsWith('image') || originalFile.type.startsWith('video') + ? originalFile + : undefined + ); + + const handleRemove = () => { + cancelUpload(); + onRemove(file); + }; + const handleSpoiler = () => { + setMetadata(fileItem, { ...metadata, markedAsSpoiler: !metadata.markedAsSpoiler }); + }; + + const loading = upload.status === UploadStatus.Loading; + const failed = upload.status === UploadStatus.Error; + const progressPercent = loading + ? Math.round(percent(0, file.size || 1, upload.progress.loaded)) + : 0; + + const removeBtn = ( + + ); + + if (previewUrl) { + const isVideo = originalFile.type.startsWith('video'); + return ( +
+ {isVideo ? ( + // eslint-disable-next-line jsx-a11y/media-has-caption +
+ ); + } + + let meta: React.ReactNode = bytesToSize(file.size); + if (loading) meta = `${progressPercent}%`; + else if (fileSizeExceeded) meta = t('Room.upload_too_large'); + else if (failed) { + meta = ( + + ); + } + + return ( +
+ +
+ {file.name} + + {meta} + +
+ {removeBtn} +
+ ); +} + +type ComposerAttachmentsProps = { + selectedFiles: TUploadItem[]; + setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void; + onRemove: (file: TUploadContent) => void; +}; + +export function ComposerAttachments({ + selectedFiles, + setMetadata, + onRemove, +}: ComposerAttachmentsProps) { + if (selectedFiles.length === 0) return null; + return ( +
+ {selectedFiles.map((fileItem, index) => ( + + ))} +
+ ); +} diff --git a/src/app/features/room/Room.tsx b/src/app/features/room/Room.tsx index 67fe586a..654589b0 100644 --- a/src/app/features/room/Room.tsx +++ b/src/app/features/room/Room.tsx @@ -132,7 +132,10 @@ export function Room({ renderRoomView }: RoomProps) { // PageRoot. The chat column applies an explicit Background bg so the // parent void can't bleed through any transparent slivers. const profileOpen = !!useAtomValue(userRoomProfileAtom); - const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom); + // Compare roomId, not truthiness — the atom is global, and a stale entry + // left by a surface that unmounted without cleanup must not auto-open the + // members pane in an unrelated room. + const membersSheetOpen = useAtomValue(roomMembersSheetAtom)?.roomId === room.roomId; const callView = room.isCallRoom(); const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer; // The desktop/tablet media viewer is no longer a right-side pane — it's @@ -152,9 +155,10 @@ export function Room({ renderRoomView }: RoomProps) { !callView && !isOneOnOne && !showThreadDrawer && !isMobile && membersSheetOpen; // Mobile mounts the members horseshoe wrapper for every non-1:1, // non-call room — group DMs, group rooms, AND channels. 1:1 rooms - // keep using `RoomViewProfilePanel`; the call surface routes member - // discovery through Room Settings (see the user-icon button kept in - // `RoomViewHeaderDm` for `callView`). + // keep using `RoomViewProfilePanel`; the call surface shows the + // people who matter there — the participants — via `CallView`'s own + // member cards (room-wide member management isn't reachable from a + // call room post-redesign). const useMembersWrapper = !isOneOnOne && !callView; // True whenever any right-side pane is mounted. Drives the parent flex // row's void background and the chat column's explicit Background paint diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index c58335a6..3d06df08 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -9,13 +9,24 @@ import React, { useRef, useState, } from 'react'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai'; import { isKeyHotkey } from 'is-hotkey'; +import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; -import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; +import { + EventStatus, + EventType, + IContent, + IMentions, + MatrixEvent, + MsgType, + RelationType, + Room, + RoomEvent, +} from 'matrix-js-sdk'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'; import { ReactEditor } from 'slate-react'; -import { Transforms, Editor } from 'slate'; +import { Transforms, Editor, Descendant } from 'slate'; import { Box, Dialog, @@ -26,7 +37,6 @@ import { OverlayBackdrop, OverlayCenter, PopOut, - Scroll, Text, color, config, @@ -56,6 +66,8 @@ import { getBeginCommand, trimCommand, getMentions, + htmlToEditorInput, + plainToEditorInput, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -68,6 +80,7 @@ import { mxcUrlToHttp, } from '../../utils/matrix'; import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater'; +import { useAlive } from '../../hooks/useAlive'; import { useFilePicker } from '../../hooks/useFilePicker'; import { useFilePasteHandler } from '../../hooks/useFilePasteHandler'; import { useFileDropZone } from '../../hooks/useFileDrop'; @@ -75,18 +88,13 @@ import { TUploadItem, TUploadMetadata, draftKey, + roomIdToEditDraftAtomFamily, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, } from '../../state/room/roomInputDrafts'; -import { UploadCardRenderer } from '../../components/upload-card'; -import { - UploadBoard, - UploadBoardContent, - UploadBoardHeader, - UploadBoardImperativeHandlers, -} from '../../components/upload-board'; +import { ComposerAttachments } from './ComposerAttachments'; import { Upload, UploadStatus, @@ -94,6 +102,7 @@ import { createUploadFamilyObserverAtom, } from '../../state/upload'; import { getImageUrlBlob, loadImageElement } from '../../utils/dom'; +import { stopPropagation } from '../../utils/keyboard'; import { safeFile } from '../../utils/mimeTypes'; import { fulfilledPromiseSettledResult } from '../../utils/common'; import { useSetting } from '../../state/hooks/settings'; @@ -109,7 +118,13 @@ import { VoiceRecording, VoiceRecordingResult } from '../../utils/voiceRecording import { VoiceRecorder } from './VoiceRecorderForm'; import { useStateEvent } from '../../hooks/useStateEvent'; import { StateEvent } from '../../../types/matrix/room'; -import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; +import { + getEditedEvent, + getMemberDisplayName, + getMentionContent, + trimReplyFromBody, + trimReplyFromFormattedBody, +} from '../../utils/room'; import { CommandAutocomplete } from './CommandAutocomplete'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { mobileOrTablet } from '../../utils/user-agent'; @@ -264,15 +279,19 @@ export const RoomInput = forwardRef( }, [voiceDisabledBy]); const emojiBtnRef = useRef(null); const screenSize = useScreenSizeContext(); - // On native / narrow screens the emoji-sticker board is docked inline at the - // top of the composer instead of floating as a pop-out. - const dockEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile; + // On native / narrow screens the emoji-sticker board opens as a CENTERED + // overlay window — the same presentation the message rail's reaction + // picker uses (an anchored PopOut drifts off the small viewport, and the + // old in-composer dock crowded the input). Desktop keeps the anchored + // pop-out by the emoji button. + const centerEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile; const [emojiBoardTab, setEmojiBoardTab] = useState(undefined); - // Crossing the dock/pop-out breakpoint remounts the trigger; drop any open - // dock state so the board doesn't silently re-open after a resize round-trip. + // Crossing the center/pop-out breakpoint remounts the trigger; drop any + // open state so the board doesn't silently re-open after a resize + // round-trip. useEffect(() => { setEmojiBoardTab(undefined); - }, [dockEmojiBoard]); + }, [centerEmojiBoard]); const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -283,6 +302,7 @@ export const RoomInput = forwardRef( const inputDraftKey = draftKey(roomId, threadId); const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey)); + const [editDraft, setEditDraft] = useAtom(roomIdToEditDraftAtomFamily(inputDraftKey)); const replyUserID = replyDraft?.userId; const powerLevelTags = usePowerLevelTags(room, powerLevels); @@ -301,13 +321,26 @@ export const RoomInput = forwardRef( : undefined; const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor; - const [uploadBoard, setUploadBoard] = useState(true); const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey)); - const uploadFamilyObserverAtom = createUploadFamilyObserverAtom( - roomUploadAtomFamily, - selectedFiles.map((f) => f.file) + // Memoised on the file list — a fresh derived atom every render would + // churn submit()'s identity (it sits in submit's deps) and with it every + // downstream useCallback. + const uploadFamilyObserverAtom = useMemo( + () => + createUploadFamilyObserverAtom( + roomUploadAtomFamily, + selectedFiles.map((f) => f.file) + ), + [selectedFiles] ); - const uploadBoardHandlers = useRef(); + // Imperative store handle — submit() reads the live upload states once + // at send time instead of subscribing the whole composer to every + // (throttled) progress tick. The attachment strip's per-item components + // are the only progress subscribers. + const jotaiStore = useStore(); + // Re-entrancy guard for the attachment send — mirrors the old + // UploadBoardHeader's sendingRef. + const sendingAttachmentsRef = useRef(false); const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents); @@ -319,13 +352,13 @@ export const RoomInput = forwardRef( // no thread_id, so a thread-composer typing event would surface // in the main channel chat as if the user were typing there). const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId); + const alive = useAlive(); const handleFiles = useCallback( async (files: File[]) => { // Text-only composer (AI new-chat landing) ignores every file vector — // picker, paste and drop all funnel through here. if (textOnly) return; - setUploadBoard(true); const safeFiles = files.map(safeFile); const fileItems: TUploadItem[] = []; @@ -375,15 +408,151 @@ export const RoomInput = forwardRef( Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); + // ── Edit-in-composer (Telegram-style) ───────────────────────────── + // «Edit» on an own message sets the per-scope edit draft; this block + // loads the message body into THIS composer, shows the banner (in the + // `top` slot below) and routes submit() through the m.replace path. + // The user's unsent draft is stashed on entry and restored when the + // edit ends (cancel or save) — same behaviour as Telegram. + + // Reads the target's CURRENT body (following any prior m.replace + // aggregation) — mirror of the old MessageEditor's + // getPrevBodyAndFormattedBody. + const getEditTargetContent = useCallback( + ( + eventId: string + ): { + body: string | undefined; + customHtml: string | undefined; + mentions: IMentions | undefined; + msgtype: string | undefined; + threadRootId: string | undefined; + } => { + const mEvent = room.findEventById(eventId); + if (!mEvent) { + return { + body: undefined, + customHtml: undefined, + mentions: undefined, + msgtype: undefined, + threadRootId: undefined, + }; + } + const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet()); + const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); + const { body, formatted_body: customHtml }: Record = content; + return { + body: typeof body === 'string' ? body : undefined, + customHtml: typeof customHtml === 'string' ? customHtml : undefined, + mentions: content['m.mentions'], + msgtype: mEvent.getContent().msgtype, + threadRootId: mEvent.threadRootId, + }; + }, + [room] + ); + + // Unsent text stashed while the editor hosts the edit body. + const preEditStashRef = useRef(null); + // A failed m.replace send re-enters edit mode with the USER'S edited + // text (set by submitEdit's .catch, consumed by the edit-load effect) + // instead of re-loading the target's old body — the edit banner + // re-opening with their text intact IS the failure feedback. + const failedEditRef = useRef<{ eventId: string; children: Descendant[] } | null>(null); + // Tracks which eventId the editor currently holds, so re-renders with + // the same draft don't re-load, while retargeting (edit A → edit B) + // and exit both do. + const loadedEditIdRef = useRef(); + + useEffect(() => { + const evtId = editDraft?.eventId; + if (evtId === loadedEditIdRef.current) return; + const wasEditing = loadedEditIdRef.current !== undefined; + loadedEditIdRef.current = evtId; + + if (!evtId) { + // Edit ended (cancel / save / external clear) — restore the stash. + resetEditor(editor); + resetEditorHistory(editor); + const stash = preEditStashRef.current; + preEditStashRef.current = null; + if (stash && stash.length > 0) { + Transforms.insertFragment(editor, stash); + } + if (!mobileOrTablet()) ReactEditor.focus(editor); + return; + } + + // Entering edit (stash once) or retargeting (keep the original stash). + if (!wasEditing && !isEmptyEditor(editor)) { + preEditStashRef.current = JSON.parse(JSON.stringify(editor.children)); + } + const failed = failedEditRef.current; + failedEditRef.current = null; + const initialValue = + failed && failed.eventId === evtId + ? failed.children + : (() => { + const { body, customHtml } = getEditTargetContent(evtId); + return typeof customHtml === 'string' + ? htmlToEditorInput(customHtml, isMarkdown) + : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); + })(); + resetEditor(editor); + resetEditorHistory(editor); + Transforms.insertFragment(editor, initialValue); + if (!mobileOrTablet()) ReactEditor.focus(editor); + // isMarkdown intentionally NOT a dep — toggling the setting mid-edit + // must not blow away the user's in-progress changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editDraft, editor, getEditTargetContent]); + + const editDraftRef = useRef(editDraft); + editDraftRef.current = editDraft; + + // Editing a still-sending message: the draft stores the local-echo + // '~txn' id, and the remote echo DELETES that id from the timeline set + // (EventTimelineSet.replaceEventId) — without retargeting, submit would + // build an m.replace against a dead id (and the SDK throws on a '~' + // relation under Chronological pending ordering). Follow the swap and + // keep the user's in-progress text (bump loadedEditIdRef FIRST so the + // edit-load effect doesn't reload the body over their changes). + useEffect(() => { + const onLocalEchoUpdated = (event: MatrixEvent, _room: Room, oldEventId?: string): void => { + const draft = editDraftRef.current; + if (!draft) return; + // The edit TARGET failed to send: an m.replace can never apply to + // an event that never reached the server, so Save would stay a + // silent no-op forever ('~' guard in submitEdit). Cancel the edit + // (restoring the stash) — the failed message keeps its own + // retry/delete affordances in the timeline. + if (event.status === EventStatus.NOT_SENT && event.getId() === draft.eventId) { + setEditDraft(undefined); + return; + } + if (!oldEventId || oldEventId !== draft.eventId) return; + const newId = event.getId(); + if (!newId || newId === oldEventId) return; + loadedEditIdRef.current = newId; + setEditDraft({ eventId: newId }); + }; + room.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); + return () => { + room.off(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated); + }; + }, [room, setEditDraft]); + + const editTarget = editDraft ? getEditTargetContent(editDraft.eventId) : undefined; + // Drain the global share-target hand-off into THIS chat. The system // share-sheet flow doesn't open a picker — it just lights up the // ShareTargetStrip banner and lets the user pick a chat by navigating // normally. The first RoomInput that mounts (or, if the user was // already inside a chat when the share arrived, the current RoomInput // re-running this effect with a non-null `pendingShare`) consumes the - // payload: files into the upload board, text into the composer. The - // user can still bail by tapping the [×] on each upload card before - // pressing Send. + // payload: files into the composer's attachment strip, text into the + // editor. The user can still bail by tapping the [×] on each + // attachment before pressing Send. // // Declared AFTER the msgDraft restore so the share text appends to // any saved draft instead of getting overwritten by it. @@ -391,8 +560,17 @@ export const RoomInput = forwardRef( // Thread composers (threadId set) deliberately skip — sharing into a // thread isn't a flow users ask for, and would silently consume the // share leaving the main composer empty. + // + // Edit mode also defers: the editor currently holds the EDIT body, and + // inserting the shared text there would pollute the m.replace (or lose + // the share on cancel). The payload stays in pendingShareAtom (the + // strip banner stays up); `editDraft` in the deps re-runs the drain + // when the edit ends — by then the edit-end effect above (declared + // earlier, runs first in the same commit) has restored the user's + // stashed draft, so the share appends to their real draft as designed. useEffect(() => { if (threadId) return; + if (editDraft) return; if (!pendingShare) return; // Clear first so a re-render mid-handleFiles can't queue another // run for the same payload. @@ -405,11 +583,21 @@ export const RoomInput = forwardRef( if (text) { Transforms.insertText(editor, text); } - }, [threadId, pendingShare, setPendingShare, handleFiles, editor]); + }, [threadId, editDraft, pendingShare, setPendingShare, handleFiles, editor]); useEffect( () => () => { - if (!isEmptyEditor(editor)) { + if (loadedEditIdRef.current !== undefined) { + // Unmounted mid-edit (navigation, thread drawer opening over the + // main composer): drop the edit — the editor holds the EDIT body, + // which must not be saved as the user's message draft. Persist + // the pre-edit stash instead so their unsent text survives. + setEditDraft(undefined); + loadedEditIdRef.current = undefined; + const stash = preEditStashRef.current; + preEditStashRef.current = null; + setMsgDraft(stash && stash.length > 0 ? stash : []); + } else if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); } else { @@ -423,7 +611,7 @@ export const RoomInput = forwardRef( // setter to the correct atom. Today the drawer is fully // remounted on rootId change via Room.tsx key, but the dep // pins the invariant explicitly. - [roomId, threadId, editor, setMsgDraft] + [roomId, threadId, editor, setMsgDraft, setEditDraft] ); const handleFileMetadata = useCallback( @@ -449,37 +637,49 @@ export const RoomInput = forwardRef( [setSelectedFiles, selectedFiles] ); - const handleCancelUpload = (uploads: Upload[]) => { - uploads.forEach((upload) => { - if (upload.status === UploadStatus.Loading) { - mx.cancelUpload(upload.promise); - } - }); - handleRemoveUpload(uploads.map((upload) => upload.file)); - }; + const handleCancelUpload = useCallback( + (uploads: Upload[]) => { + uploads.forEach((upload) => { + if (upload.status === UploadStatus.Loading) { + mx.cancelUpload(upload.promise); + } + }); + handleRemoveUpload(uploads.map((upload) => upload.file)); + }, + [mx, handleRemoveUpload] + ); - const handleSendUpload = async (uploads: UploadSuccess[]) => { - const contentsPromises = uploads.map(async (upload) => { - const fileItem = selectedFiles.find((f) => f.file === upload.file); - if (!fileItem) throw new Error('Broken upload'); + // useCallback (not a plain function) — submit() depends on it. + const handleSendUpload = useCallback( + async (uploads: UploadSuccess[]) => { + const contentsPromises = uploads.map(async (upload) => { + const fileItem = selectedFiles.find((f) => f.file === upload.file); + if (!fileItem) throw new Error('Broken upload'); - if (fileItem.file.type.startsWith('image')) { - return getImageMsgContent(mx, fileItem, upload.mxc); - } - if (fileItem.file.type.startsWith('video')) { - return getVideoMsgContent(mx, fileItem, upload.mxc); - } - if (fileItem.file.type.startsWith('audio')) { - return getAudioMsgContent(fileItem, upload.mxc); - } - return getFileMsgContent(fileItem, upload.mxc); - }); - handleCancelUpload(uploads); - const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => - mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent) - ); - }; + if (fileItem.file.type.startsWith('image')) { + return getImageMsgContent(mx, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('video')) { + return getVideoMsgContent(mx, fileItem, upload.mxc); + } + if (fileItem.file.type.startsWith('audio')) { + return getAudioMsgContent(fileItem, upload.mxc); + } + return getFileMsgContent(fileItem, upload.mxc); + }); + // Settle the content builds BEFORE clearing the strip: an item + // whose build failed (image decode, thumbnailing) stays visible + // for another Send instead of silently vanishing unsent. + const settled = await Promise.allSettled(contentsPromises); + const builtUploads = uploads.filter((_, i) => settled[i].status === 'fulfilled'); + handleCancelUpload(builtUploads); + const contents = fulfilledPromiseSettledResult(settled); + contents.forEach((content) => + mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent) + ); + }, + [mx, roomId, threadId, selectedFiles, handleCancelUpload] + ); const voiceBlockedName = useCallback( () => @@ -559,8 +759,163 @@ export const RoomInput = forwardRef( [voiceDisabledBy, voiceBlockedName, room, mx, roomId, threadId, t] ); + // Edit submit — builds the m.replace content the way the old + // MessageEditor did: `m.new_content` carries the real body, the + // top-level body gets the `* ` fallback prefix, mentions merge the + // previous revision's users. No-op edits (unchanged / emptied text) + // just exit edit mode. threadRootId comes from the TARGET event so a + // thread message edited from the drawer keeps its thread relation + // (SDK sets the local-echo `.thread` pointer; m.replace itself owns + // `m.relates_to`, spec-correct — see the old MessageEditor notes). + const submitEdit = useCallback(() => { + if (!editDraft) return; + const { eventId } = editDraft; + + // Still-sending local echo: a '~txn' relation makes the SDK throw + // synchronously (getPendingEvents under Chronological ordering) — + // stay in edit mode and wait for the LocalEchoUpdated retarget above + // to swap in the real id (usually a beat later). + if (eventId.startsWith('~')) return; + // Target vanished (redacted / not in the timeline set any more): + // there is nothing to replace — cancel the edit, restoring the stash. + if (!room.findEventById(eventId)) { + setEditDraft(undefined); + return; + } + + const target = getEditTargetContent(eventId); + + const plainText = toPlainText(editor.children, isMarkdown).trim(); + const customHtml = trimCustomHtml( + toMatrixCustomHTML(editor.children, { + allowTextFormatting: true, + allowBlockMarkdown: isMarkdown, + allowInlineMarkdown: isMarkdown, + }) + ); + + const exitEdit = () => { + setEditDraft(undefined); + sendTypingStatus(false); + }; + + if (plainText === '') { + // Emptied-out edit: do nothing (deleting is the Delete action's job). + exitEdit(); + return; + } + if (target.body) { + const unchangedHtml = + target.customHtml && trimReplyFromFormattedBody(target.customHtml) === customHtml; + const unchangedPlain = + !target.customHtml && + target.body === plainText && + customHtmlEqualsPlainText(customHtml, plainText); + if (unchangedHtml || unchangedPlain) { + exitEdit(); + return; + } + } + + const newContent: IContent = { + msgtype: target.msgtype, + body: plainText, + }; + + // Spec (event replacements / m.mentions): `m.new_content` carries the + // FULL resolved mention set of the revision, while the TOP-LEVEL + // m.mentions must list only the mentions ADDED by this edit — anyone + // already mentioned was pinged by the original and must not be + // re-notified on every revision. + const mentionData = getMentions(mx, roomId, editor); + const prevUserIds = new Set(target.mentions?.user_ids ?? []); + const prevRoom = target.mentions?.room === true; + const addedUsers = Array.from(mentionData.users).filter((u) => !prevUserIds.has(u)); + const fullUsers = new Set(mentionData.users); + prevUserIds.forEach((u) => fullUsers.add(u)); + newContent['m.mentions'] = getMentionContent( + Array.from(fullUsers), + mentionData.room || prevRoom + ); + + if (!customHtmlEqualsPlainText(customHtml, plainText)) { + newContent.format = 'org.matrix.custom.html'; + newContent.formatted_body = customHtml; + } + + const content: IContent = { + ...newContent, + 'm.mentions': getMentionContent(addedUsers, mentionData.room && !prevRoom), + body: `* ${plainText}`, + 'm.new_content': newContent, + 'm.relates_to': { + event_id: eventId, + rel_type: RelationType.Replace, + }, + }; + // The `* ` fallback prefix applies to the formatted fallback too + // (mirrors element-web's edit content shape). + if (typeof newContent.formatted_body === 'string') { + content.formatted_body = `* ${newContent.formatted_body}`; + } + + // Optimistic exit (Telegram-style: banner closes immediately). The + // failure leg re-enters edit mode with the user's text via + // failedEditRef — unless they have already started another edit by + // the time the send settles, or this composer is long gone. + const editedChildren: Descendant[] = JSON.parse(JSON.stringify(editor.children)); + mx.sendMessage(roomId, target.threadRootId ?? null, content as RoomMessageEventContent).catch( + () => { + if (!alive()) return; + if (editDraftRef.current) return; + failedEditRef.current = { eventId, children: editedChildren }; + setEditDraft({ eventId }); + } + ); + exitEdit(); + }, [ + mx, + room, + roomId, + editor, + editDraft, + getEditTargetContent, + isMarkdown, + setEditDraft, + sendTypingStatus, + alive, + ]); + const submit = useCallback(() => { - uploadBoardHandlers.current?.handleSend(); + // Edit mode owns the send path entirely — uploads/replies/commands + // don't apply to an m.replace. + if (editDraft) { + submitEdit(); + return; + } + + // Ship every FINISHED upload from the attachment strip. Still-running + // and failed ones stay in the strip (the user retries or sends again + // once they finish) — same semantics the old UploadBoard's Send had. + // Read the live states from the store so the composer doesn't + // subscribe to per-tick progress. + // + // The text send below CHAINS on the drain: handleSendUpload builds + // media contents asynchronously, and queueing the text in this tick + // would land the caption ABOVE the photos for everyone. + const uploads = jotaiStore.get(uploadFamilyObserverAtom); + const readyUploads = uploads.filter( + (upload): upload is UploadSuccess => upload.status === UploadStatus.Success + ); + let attachmentsDrain: Promise = Promise.resolve(); + if (readyUploads.length > 0 && !sendingAttachmentsRef.current) { + sendingAttachmentsRef.current = true; + attachmentsDrain = handleSendUpload(readyUploads) + .catch(() => undefined) + .finally(() => { + sendingAttachmentsRef.current = false; + }); + } const commandName = getBeginCommand(editor); let plainText = toPlainText(editor.children, isMarkdown).trim(); @@ -635,7 +990,12 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - const pending = mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent); + // Queue the text AFTER the attachment drain so a caption lands below + // its media. The composer resets immediately regardless — the user's + // send gesture already happened. + const pending = attachmentsDrain.then(() => + mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent) + ); if (onSend) { pending.then((res) => onSend(res.event_id)).catch(() => undefined); } @@ -650,10 +1010,15 @@ export const RoomInput = forwardRef( onSend, editor, replyDraft, + editDraft, + submitEdit, sendTypingStatus, setReplyDraft, isMarkdown, commands, + jotaiStore, + uploadFamilyObserverAtom, + handleSendUpload, ]); const handleKeyDown: KeyboardEventHandler = useCallback( @@ -671,10 +1036,24 @@ export const RoomInput = forwardRef( setAutocompleteQuery(undefined); return; } + // Edit first: Esc cancels the edit (restoring the stashed draft); + // a second Esc then clears the reply preview as before. + if (editDraft) { + setEditDraft(undefined); + return; + } setReplyDraft(undefined); } }, - [submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing] + [ + submit, + setReplyDraft, + editDraft, + setEditDraft, + enterForNewline, + autocompleteQuery, + isComposing, + ] ); const handleKeyUp: KeyboardEventHandler = useCallback( @@ -755,25 +1134,38 @@ export const RoomInput = forwardRef( ); - // The native dock renders the board in the composer's top slot, so its - // open/close state lives here (only read on native). Desktop keeps its - // state isolated inside the UseStateProvider below so opening the pop-out - // doesn't re-render the whole composer. - const dockedEmojiBoard = dockEmojiBoard && emojiBoardTab !== undefined && ( - setEmojiBoardTab(undefined)} - /> + // Mobile / native: the board opens as a centered overlay WINDOW — same + // presentation as the message rail's reaction picker — so its open + // state lives here. Desktop keeps its state isolated inside the + // UseStateProvider below so opening the pop-out doesn't re-render the + // whole composer. + const centeredEmojiBoard = centerEmojiBoard && emojiBoardTab !== undefined && ( + }> + + setEmojiBoardTab(undefined), + escapeDeactivates: stopPropagation, + }} + > + setEmojiBoardTab(undefined)} + /> + + + ); - const emojiButton = dockEmojiBoard ? ( + const emojiButton = centerEmojiBoard ? ( ( return (
- {selectedFiles.length > 0 && ( - setUploadBoard(!uploadBoard)} - uploadFamilyObserverAtom={uploadFamilyObserverAtom} - onSend={handleSendUpload} - imperativeHandlerRef={uploadBoardHandlers} - onCancel={handleCancelUpload} - /> - } - > - {uploadBoard && ( - - - {Array.from(selectedFiles) - .reverse() - .map((fileItem, index) => ( - - ))} - - - )} - - )} } @@ -975,7 +1334,16 @@ export const RoomInput = forwardRef( } top={ <> - {dockedEmojiBoard} + {centeredEmojiBoard} + {/* Attachment strip — the in-form record of attached media + (replaces the old floating UploadBoard window). Visible in + edit mode too: the m.replace send ignores it, and the + attachments are still there when the edit ends. */} + {voiceError && ( ( )} - {replyDraft && ( + {/* Edit banner — Telegram-style: pencil + «Editing» + the + original text, with [×] to cancel (Esc does the same). + Same chrome slot/structure as the reply preview below. */} + {editDraft && ( +
+ + setEditDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" + aria-label={t('Room.editing_cancel')} + > + + + + + + {t('Room.editing_message')} + + } + > + + {editTarget?.body ? trimReplyFromBody(editTarget.body) : ''} + + + + +
+ )} + {/* Reply preview hides while editing (the edit owns the send); + the reply draft itself is preserved and returns after. */} + {replyDraft && !editDraft && (
( after={ singleRow && !voiceMode ? ( <> - {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton} + {/* Mic hides while editing — a recording would ship a NEW + message under a pending edit banner. */} + {!textOnly && + !editDraft && + voiceSupported && + voiceDisabledBy === undefined && + micButton} {!textOnly && emojiButton} {sendButton} @@ -1059,7 +1476,13 @@ export const RoomInput = forwardRef( > {!textOnly && plusButton} - {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton} + {/* Mic hides while editing — a recording would ship a NEW + message under a pending edit banner. */} + {!textOnly && + !editDraft && + voiceSupported && + voiceDisabledBy === undefined && + micButton} {!textOnly && emojiButton} {sendButton} diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts index c428f470..a25a8553 100644 --- a/src/app/features/room/RoomTimeline.css.ts +++ b/src/app/features/room/RoomTimeline.css.ts @@ -1,5 +1,4 @@ import { globalStyle, keyframes, style } from '@vanilla-extract/css'; -import { RecipeVariants, recipe } from '@vanilla-extract/recipes'; import { DefaultReset, color, config, toRem } from 'folds'; import { VOJO_BUBBLE_BAND_PX, @@ -101,31 +100,6 @@ export const TimelineScroll = style({ scrollbarColor: `${color.SurfaceVariant.ContainerLine} transparent`, }); -export const TimelineFloat = recipe({ - base: [ - DefaultReset, - { - position: 'absolute', - left: '50%', - transform: 'translateX(-50%)', - zIndex: 1, - minWidth: 'max-content', - }, - ], - variants: { - position: { - Top: { - top: config.space.S400, - }, - }, - }, - defaultVariants: { - position: 'Top', - }, -}); - -export type TimelineFloatVariants = RecipeVariants; - // "Jump to latest" FAB. Bottom-right, circular, lavender brand accent. // `data-hidden` encodes visibility (state inline-styled would clobber the // `:active` press feedback). Inline `bottom` at the use site offsets for diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index bf50b01a..658a4938 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -22,12 +22,11 @@ import { RoomEventHandlerMap, } from 'matrix-js-sdk'; import { HTMLReactParserOptions } from 'html-react-parser'; -import classNames from 'classnames'; import { Editor } from 'slate'; import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc'; import to from 'await-to-js'; import { useAtomValue, useSetAtom } from 'jotai'; -import { Box, Chip, Icon, Icons, Text, as, config } from 'folds'; +import { Box, Icon, Icons, Text, config } from 'folds'; import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; @@ -96,9 +95,14 @@ import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResize import * as css from './RoomTimeline.css'; import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time'; import { isEmptyEditor } from '../../components/editor'; -import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; +import { + draftKey, + roomIdToEditDraftAtomFamily, + roomIdToReplyDraftAtomFamily, +} from '../../state/room/roomInputDrafts'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; +import { useStateEvent } from '../../hooks/useStateEvent'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange'; import { RenderMessageContent } from '../../components/RenderMessageContent'; @@ -127,19 +131,6 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { RoomTimelineTyping } from './RoomTimelineTyping'; import { MessageErrorBoundary } from './MessageErrorBoundary'; -const TimelineFloat = as<'div', css.TimelineFloatVariants>( - ({ position, className, ...props }, ref) => ( - - ) -); - export const getLiveTimeline = (room: Room): EventTimeline => room.getUnfilteredTimelineSet().getLiveTimeline(); @@ -593,6 +584,9 @@ export function RoomTimeline({ // / DM / legacy timeline). Drawer composer manages its own per-thread // reply draft via DraftKey([roomId, rootId]) inside RoomInput. const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId))); + // Edit-draft setter — same MAIN-composer scope. «Edit» loads the message + // into the composer (RoomInput's edit banner) instead of an in-timeline form. + const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId))); const powerLevels = usePowerLevelsContext(); const creators = useRoomCreators(room); @@ -613,6 +607,14 @@ export function RoomTimeline({ const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId()); const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId()); const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId()); + // Mirror of RoomView's composer mount condition (tombstone / canMessage / + // thread drawer). Editing happens IN the composer now, so the Edit + // affordance must hide whenever the main composer is unmounted — + // otherwise the tap silently writes an edit draft no one consumes, which + // then hijacks the composer if it mounts later (e.g. permission restored). + const canMessage = permissions.event(MessageEvent.RoomMessage, mx.getSafeUserId()); + const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone); + const mainComposerSuspended = threadDrawerOpen || !canMessage || !!tombstoneEvent; const roomToParents = useAtomValue(roomToParentsAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); @@ -735,21 +737,20 @@ export function RoomTimeline({ return () => scrollEl.removeEventListener('scroll', onScroll); }, [getScrollElement, setOpenMessageId]); - const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } = - useVirtualPaginator({ - count: eventsLength, - limit: PAGINATION_LIMIT, - range: timeline.range, - onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []), - getScrollElement, - getItemElement: useCallback( - (index: number) => - (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? - undefined, - [] - ), - onEnd: handleTimelinePagination, - }); + const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({ + count: eventsLength, + limit: PAGINATION_LIMIT, + range: timeline.range, + onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []), + getScrollElement, + getItemElement: useCallback( + (index: number) => + (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? + undefined, + [] + ), + onEnd: handleTimelinePagination, + }); const loadEventTimeline = useEventTimelineLoader( mx, @@ -947,7 +948,6 @@ export function RoomTimeline({ ); const { - editId, handleEdit, handleOpenReply, handleUserClick, @@ -957,8 +957,9 @@ export function RoomTimeline({ } = useMessageInteractionHandlers({ room, editor, - composerSuspended: threadDrawerOpen, + composerSuspended: mainComposerSuspended, setReplyDraft, + setEditDraft, onOpenEvent: handleOpenEvent, channelsMode, isBridged, @@ -1263,22 +1264,6 @@ export function RoomTimeline({ } }, [unread]); - // scroll out of view msg editor in view. - useEffect(() => { - if (editId) { - const editMsgElement = - (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ?? - undefined; - if (editMsgElement) { - scrollToElement(editMsgElement, { - align: 'center', - behavior: 'smooth', - stopInView: true, - }); - } - } - }, [scrollToElement, editId]); - const handleJumpToLatest = () => { if (eventId) { navigateRoom(room.roomId, undefined, { replace: true }); @@ -1288,17 +1273,6 @@ export function RoomTimeline({ scrollToBottomRef.current.smooth = false; }; - const handleJumpToUnread = () => { - if (unreadInfo?.readUptoEventId) { - setTimeline(getEmptyTimeline()); - loadEventTimeline(unreadInfo.readUptoEventId); - } - }; - - const handleMarkAsRead = () => { - markAsRead(mx, room.roomId, hideActivity); - }; - const { t } = useTranslation(); // Sticky day capsules (every room class — 1:1 bubble, group, channel). Each @@ -1697,7 +1671,6 @@ export function RoomTimeline({ collapse={collapse} railHidden={railHidden} highlight={highlighted} - edit={editId === mEventId} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} @@ -1707,7 +1680,7 @@ export function RoomTimeline({ onUsernameClick={handleUsernameClick} onReplyClick={handleReplyClick} onReactionToggle={handleReactionToggle} - onEditId={handleEdit} + onEditId={mainComposerSuspended ? undefined : handleEdit} reply={ replyEventId && ( {/* Day dates are the inline capsules themselves (every room class), made sticky via real CSS `position: sticky` (engaged by the effect above) — no separate floating pill. */} - {unreadFloatShown && ( - - } - onClick={handleJumpToUnread} - > - {t('Room.jump_to_unread')} - - - } - onClick={handleMarkAsRead} - > - {t('Room.mark_as_read')} - - - )}
{ }; export function RoomView({ eventId }: { eventId?: string }) { + const { t } = useTranslation(); const roomInputRef = useRef(null); const roomViewRef = useRef(null); const composerWrapRef = useRef(null); @@ -200,7 +202,7 @@ export function RoomView({ eventId }: { eventId?: string }) { alignItems="Center" justifyContent="Center" > - You do not have permission to post in this room + {t('Room.no_post_permission')} )} diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx index 6298140f..90a5d6f0 100644 --- a/src/app/features/room/RoomViewHeaderDm.tsx +++ b/src/app/features/room/RoomViewHeaderDm.tsx @@ -31,7 +31,6 @@ import { useRoomMemberCount } from '../../hooks/useRoomMemberCount'; import { Presence, useUserPresence } from '../../hooks/useUserPresence'; import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta'; import { useSpaceOptionally } from '../../hooks/useSpace'; -import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; @@ -42,7 +41,6 @@ import { useRoomMembersSheetState, } from '../../state/hooks/roomMembersSheet'; import { callEmbedAtom } from '../../state/callEmbed'; -import { RoomSettingsPage } from '../../state/roomSettings'; import { StateEvent } from '../../../types/matrix/room'; import { getMxIdLocalPart, @@ -179,7 +177,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) { const membersSheetState = useRoomMembersSheetState(); const membersSheetOpen = membersSheetState?.roomId === room.roomId; const parentSpace = useSpaceOptionally(); - const openSettings = useOpenRoomSettings(); // The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same // chrome on desktop and mobile. @@ -203,13 +200,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) { openRoomMembersSheet(room.roomId); } }; - // `callView` keeps the legacy «open Members in Settings» fallback — - // the members side-pane isn't mounted while we're inside the call - // surface, so a separate path is still needed to reach the list. - const handleCallViewMembers = () => { - openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage); - }; - const avatarNode = ( {isOneOnOne && peerUserId ? ( @@ -356,30 +346,9 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) { {callButtonVisible && } - {/* Member toggle — kept only for `callView` (1:1 or group): the - members side-pane / horseshoe isn't mounted under the call - surface, so we still need a way to reach the member list - via Room Settings → Members. Non-call group rooms now use - the identity button above (tap on avatar+title) — keeps - the gesture symmetrical with 1:1 peer profile. */} - {callView && screenSize === ScreenSize.Desktop && ( - - {t('Room.members')} - - } - > - {(triggerRef) => ( - - - - )} - - )} - + {/* No separate members button in callView — CallView renders its + own member list, and the Room-Settings→Members page this used + to open was removed with the room-settings redesign. */} setUserAvatarMode(true)} + // Back to the room card (members sheet) — `openSheet` + // clears the profile atom via the hooks' mutual + // exclusion, so the silhouette body swaps in place + // (both atoms keep `open` true → no close/reopen + // animation). + onBack={() => openSheet(room.roomId)} />
) : ( diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx index f650b919..fd26b282 100644 --- a/src/app/features/room/RoomViewProfilePanel.tsx +++ b/src/app/features/room/RoomViewProfilePanel.tsx @@ -553,6 +553,10 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps) setAvatarMode(true)} + // Root card (1:1 peer profile) — nothing to go + // back to, so the top-left affordance is a + // plain close cross (redesign item 15). + onClose={close} /> )}
diff --git a/src/app/features/room/RoomViewProfileSidePanel.tsx b/src/app/features/room/RoomViewProfileSidePanel.tsx index bfa5648c..0de612be 100644 --- a/src/app/features/room/RoomViewProfileSidePanel.tsx +++ b/src/app/features/room/RoomViewProfileSidePanel.tsx @@ -13,7 +13,8 @@ import FocusTrap from 'focus-trap-react'; import { useTranslation } from 'react-i18next'; import { userRoomProfileAtom } from '../../state/userRoomProfile'; import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile'; -import { useRoom } from '../../hooks/useRoom'; +import { useOpenRoomMembersSheet } from '../../state/hooks/roomMembersSheet'; +import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile'; import { stopPropagation } from '../../utils/keyboard'; import { PageHeader } from '../../components/page'; @@ -25,6 +26,8 @@ export function RoomViewProfileSidePanel() { const profileState = useAtomValue(userRoomProfileAtom); const close = useCloseUserRoomProfile(); const room = useRoom(); + const isOneOnOne = useIsOneOnOne(); + const openMembersSheet = useOpenRoomMembersSheet(); const open = !!profileState; // Inline avatar-expanded mode: the hero avatar stretches to fill @@ -78,6 +81,25 @@ export function RoomViewProfileSidePanel() { chat header uses, so the bottom rule lines up with the adjacent `RoomViewHeaderDm` row to the pixel. */} + {/* Group rooms: leading back arrow returns to the members sheet + (the room card) the profile replaced in this pane — the open + hook clears the profile atom, so the panes swap in place + (redesign item 15). 1:1 has no members sheet → no arrow. + Call rooms neither: Room.tsx mounts the members side pane only + when `!callView`, so opening the sheet atom from inside a call + would close the profile into nothing AND leak a stale atom + that auto-opens the pane in the next group room. */} + {!isOneOnOne && !room.isCallRoom() && ( + + openMembersSheet(room.roomId)} + aria-label={t('User.back')} + > + + + + )} {t('User.profile_title')} diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 676d49aa..bfebb72a 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -71,7 +71,11 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { roomToParentsAtom } from '../../state/room/roomToParents'; -import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts'; +import { + draftKey, + roomIdToEditDraftAtomFamily, + roomIdToReplyDraftAtomFamily, +} from '../../state/room/roomInputDrafts'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { @@ -383,6 +387,9 @@ export function ThreadDrawer({ // `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so // the chip surfaces inside the drawer composer. const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId))); + // Edit-draft scope: same tuple key — «Edit» on a thread row loads it into + // the DRAWER composer (its RoomInput subscribes to this slot). + const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId, rootId))); // Reply-chip click scrolls within the drawer to the matching // `data-message-id` row. If the target lives in the main timeline @@ -397,7 +404,6 @@ export function ThreadDrawer({ }, []); const { - editId, handleEdit, handleOpenReply, handleUserClick, @@ -417,6 +423,7 @@ export function ThreadDrawer({ // bail before touching the unmounted editor. composerSuspended: !canMessage, setReplyDraft, + setEditDraft, onOpenEvent: handleScrollToDrawerEvent, channelsMode, isBridged, @@ -1136,7 +1143,6 @@ export function ThreadDrawer({ mEvent={mEvent} collapse={false} highlight={false} - edit={editId === eventId} canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())} canSendReaction={canSendReaction} canPinEvent={canPinEvent} @@ -1146,7 +1152,7 @@ export function ThreadDrawer({ onUsernameClick={handleUsernameClick} onReplyClick={handleReplyClick} onReactionToggle={handleReactionToggle} - onEditId={handleEdit} + onEditId={canMessage ? handleEdit : undefined} reply={ showReplyChip && ( - }> - - - - - - - - + {/* EventReaders owns its Overlay + FocusTrap (self-contained Vojo card). */} + {open && } } @@ -809,7 +793,6 @@ export type MessageProps = { // by the channel layout. railHidden?: boolean; highlight: boolean; - edit?: boolean; canDelete?: boolean; canSendReaction?: boolean; canPinEvent?: boolean; @@ -930,7 +913,6 @@ const MessageInner = as<'div', MessageProps>( // but unused by the bubble/channel layouts. Pending the stream-rail cleanup. railHidden: _railHidden, highlight, - edit, canDelete, canSendReaction, canPinEvent, @@ -1010,7 +992,7 @@ const MessageInner = as<'div', MessageProps>( if (id) setOpenMessageId((prev) => (prev === id ? null : id)); }; const handleBubbleToggle: MouseEventHandler = (evt) => { - if (!isBubble || edit) return; + if (!isBubble) return; // Don't hijack a text selection, nor taps on interactive children (links, // buttons, media players, the waveform slider, the action rail, reaction // chips) — those run their own action and must not also toggle the rail. @@ -1030,7 +1012,7 @@ const MessageInner = as<'div', MessageProps>( // holds focus (not a child link/button), so Enter on a focused link still // follows it. Replaces the focus-within reveal the hover rail used to give. const handleBubbleKeyDown: KeyboardEventHandler = (evt) => { - if (!isBubble || edit) return; + if (!isBubble) return; if (evt.target !== evt.currentTarget) return; if (evt.key === 'Enter' || evt.key === ' ') { evt.preventDefault(); @@ -1048,20 +1030,19 @@ const MessageInner = as<'div', MessageProps>( // a local useState here sidesteps the commit↔effect race where // decryption fires between render and listener attach. const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video; - const mediaMode = isMediaMessage && !edit; + const mediaMode = isMediaMessage; // Voice notes are self-chromed cards — VoiceContent draws its own avatar + // bubble. Collapse the asymmetric Stream bubble (DM) and drop the channel // avatar (group) so a voice note renders identically for own/other, the only // difference being the bubble fill. See docs/plans/voice_messages.md §5. const isVoiceMessage = msgType === MsgType.Audio && isVoiceMessageContent(mEvent.getContent()); - const voiceMode = isVoiceMessage && !edit; + const voiceMode = isVoiceMessage; if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) { logMedia('Message', { eventId: mEvent.getId(), msgType, isMediaMessage, - edit, mediaMode, isMobile, screenSize, @@ -1082,29 +1063,12 @@ const MessageInner = as<'div', MessageProps>( const msgContentJSX = ( {reply} - {edit && onEditId ? ( - // Edit form styled as the main composer — a single rounded card - // (`ChatComposer` paints the inner Editor with Surface.Container + 32px - // radius). The bubble drops its own chrome while editing (see - // BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble. -
- onEditId()} - /> -
- ) : ( - children - )} + {children}
); const handleContextMenu: MouseEventHandler = (evt) => { - if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return; + if (evt.altKey || !window.getSelection()?.isCollapsed) return; const tag = (evt.target as Element).tagName; if (typeof tag === 'string' && tag.toLowerCase() === 'a') return; evt.preventDefault(); @@ -1153,8 +1117,7 @@ const MessageInner = as<'div', MessageProps>( // bar can render in two places: as a row-anchored overlay for channel/stream // rows, and — for the bubble layout — passed into BubbleLayout so it floats // centred just above the bubble itself. - const railVisible = - !edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor); + const railVisible = (isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor; // The reaction emoji board, shared by the desktop anchored PopOut and the // mobile centred Overlay (an anchored PopOut drifts off-screen on the small // native viewport — `съезжает` — so mobile centres it instead). @@ -1423,7 +1386,7 @@ const MessageInner = as<'div', MessageProps>( )} {/* Mobile: the reaction emoji board renders as a centred Overlay (an anchored PopOut drifts off the small native viewport). */} - {isMobile && canSendReaction && !edit && !!emojiBoardAnchor && ( + {isMobile && canSendReaction && !!emojiBoardAnchor && ( }> ( // `collapsed`. collapsed={collapse} selected={bubbleSelected} - // While editing, the body IS a composer card — drop the bubble chrome - // so it reads as one box, not a composer nested in a bubble. - editing={!!edit} // Grouped same-minute series → timestamp below the bubble; singles keep // it on the side (RoomTimeline computes timeBelow). Media is always below. timeBelow={timeBelow} @@ -1505,7 +1465,7 @@ const MessageInner = as<'div', MessageProps>( // Read/delivery status under the user's LAST own message only — at // the live end of the timeline (RoomTimeline computes isLatestOwn). readStatus={ - isOwnMessage && isLatestOwn && !edit ? ( + isOwnMessage && isLatestOwn ? ( ) : undefined } @@ -1602,7 +1562,6 @@ function areMessagePropsEqual( prev.collapse === next.collapse && prev.railHidden === next.railHidden && prev.highlight === next.highlight && - prev.edit === next.edit && prev.canDelete === next.canDelete && prev.canSendReaction === next.canSendReaction && prev.canPinEvent === next.canPinEvent && diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx deleted file mode 100644 index 98793c2d..00000000 --- a/src/app/features/room/message/MessageEditor.tsx +++ /dev/null @@ -1,379 +0,0 @@ -import React, { - KeyboardEventHandler, - MouseEventHandler, - useCallback, - useEffect, - useState, -} from 'react'; -import { - Box, - Chip, - Icon, - IconButton, - Icons, - Line, - PopOut, - RectCords, - Spinner, - Text, - as, - config, -} from 'folds'; -import { Editor, Transforms } from 'slate'; -import { ReactEditor } from 'slate-react'; -import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk'; -import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types'; -import { isKeyHotkey } from 'is-hotkey'; -import { - AUTOCOMPLETE_PREFIXES, - AutocompletePrefix, - AutocompleteQuery, - CustomEditor, - EmoticonAutocomplete, - RoomMentionAutocomplete, - Toolbar, - UserMentionAutocomplete, - createEmoticonElement, - customHtmlEqualsPlainText, - getAutocompleteQuery, - getPrevWorldRange, - htmlToEditorInput, - moveCursor, - plainToEditorInput, - toMatrixCustomHTML, - toPlainText, - trimCustomHtml, - useEditor, - getMentions, -} from '../../../components/editor'; -import { useSetting } from '../../../state/hooks/settings'; -import { settingsAtom } from '../../../state/settings'; -import { UseStateProvider } from '../../../components/UseStateProvider'; -import { EmojiBoard } from '../../../components/emoji-board'; -import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; -import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; -import { mobileOrTablet } from '../../../utils/user-agent'; -import { useComposingCheck } from '../../../hooks/useComposingCheck'; - -type MessageEditorProps = { - roomId: string; - room: Room; - mEvent: MatrixEvent; - imagePackRooms?: Room[]; - onCancel: () => void; -}; -export const MessageEditor = as<'div', MessageEditorProps>( - ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => { - const mx = useMatrixClient(); - const editor = useEditor(); - const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); - const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); - // The edit toolbar starts collapsed (the `editorToolbar` opt-in setting was - // removed — it only ever gated this one initial-open flag). - const [toolbar, setToolbar] = useState(false); - const isComposing = useComposingCheck(); - - const [autocompleteQuery, setAutocompleteQuery] = - useState>(); - - const getPrevBodyAndFormattedBody = useCallback((): [ - string | undefined, - string | undefined, - IMentions | undefined - ] => { - const evtId = mEvent.getId(); - if (!evtId) return [undefined, undefined, undefined]; - const evtTimeline = room.getTimelineForEvent(evtId); - const editedEvent = - evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet()); - - const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent(); - const { body, formatted_body: customHtml }: Record = content; - - const mMentions: IMentions | undefined = content['m.mentions']; - - return [ - typeof body === 'string' ? body : undefined, - typeof customHtml === 'string' ? customHtml : undefined, - mMentions, - ]; - }, [room, mEvent]); - - const [saveState, save] = useAsyncCallback( - useCallback(async () => { - const plainText = toPlainText(editor.children, isMarkdown).trim(); - const customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { - allowTextFormatting: true, - allowBlockMarkdown: isMarkdown, - allowInlineMarkdown: isMarkdown, - }) - ); - - const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); - - if (plainText === '') return undefined; - if (prevBody) { - if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) { - return undefined; - } - if ( - !prevCustomHtml && - prevBody === plainText && - customHtmlEqualsPlainText(customHtml, plainText) - ) { - return undefined; - } - } - - const newContent: IContent = { - msgtype: mEvent.getContent().msgtype, - body: plainText, - }; - - const mentionData = getMentions(mx, roomId, editor); - - prevMentions?.user_ids?.forEach((prevMentionId) => { - mentionData.users.add(prevMentionId); - }); - - const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room); - newContent['m.mentions'] = mMentions; - - if (!customHtmlEqualsPlainText(customHtml, plainText)) { - newContent.format = 'org.matrix.custom.html'; - newContent.formatted_body = customHtml; - } - - const content: IContent = { - ...newContent, - body: `* ${plainText}`, - 'm.new_content': newContent, - 'm.relates_to': { - event_id: mEvent.getId(), - rel_type: RelationType.Replace, - }, - }; - - // Pass threadRootId so the local-echo MatrixEvent gets its - // `.thread` pointer set via `localEvent.setThread(thread)` - // (client.js:1872-1875) and `Thread.onEcho` re-emits - // `ThreadEvent.Update` post-send, which the drawer subscribes - // to. SDK `addThreadRelationIfNeeded` (client.js:1810-1820) - // doesn't inject `m.thread` because m.replace already owns - // `m.relates_to.rel_type` — spec-correct, edit aggregation - // chains via the original event's relation. Element-web's - // EditMessageComposer.tsx:349-351 uses the same pattern. - // M4a-followup: own-send local-echo of m.replace doesn't - // aggregate into `thread.timelineSet.relations` (canContain - // rejects against unfilteredTimelineSet at - // event-timeline-set.js:786-803, and the thread-side path runs - // only on remote echo). User sees the new body only after - // /sync round-trip — same lag as M4 receipts. Optimistic - // local-replace via `mEvent.makeReplaced(localEvent)` is - // tracked for follow-up. - return mx.sendMessage( - roomId, - mEvent.threadRootId ?? null, - content as RoomMessageEventContent - ); - }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]) - ); - - const handleSave = useCallback(() => { - if (saveState.status !== AsyncStatus.Loading) { - save(); - } - }, [saveState, save]); - - const handleKeyDown: KeyboardEventHandler = useCallback( - (evt) => { - if ( - (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) && - !isComposing(evt) - ) { - evt.preventDefault(); - handleSave(); - } - if (isKeyHotkey('escape', evt)) { - evt.preventDefault(); - onCancel(); - } - }, - [onCancel, handleSave, enterForNewline, isComposing] - ); - - const handleKeyUp: KeyboardEventHandler = useCallback( - (evt) => { - if (isKeyHotkey('escape', evt)) { - evt.preventDefault(); - return; - } - - const prevWordRange = getPrevWorldRange(editor); - const query = prevWordRange - ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) - : undefined; - setAutocompleteQuery(query); - }, - [editor] - ); - - const handleCloseAutocomplete = useCallback(() => { - ReactEditor.focus(editor); - setAutocompleteQuery(undefined); - }, [editor]); - - const handleEmoticonSelect = (key: string, shortcode: string) => { - editor.insertNode(createEmoticonElement(key, shortcode)); - moveCursor(editor); - }; - - useEffect(() => { - const [body, customHtml] = getPrevBodyAndFormattedBody(); - - const initialValue = - typeof customHtml === 'string' - ? htmlToEditorInput(customHtml, isMarkdown) - : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown); - - Transforms.select(editor, { - anchor: Editor.start(editor, []), - focus: Editor.end(editor, []), - }); - - editor.insertFragment(initialValue); - if (!mobileOrTablet()) ReactEditor.focus(editor); - }, [editor, getPrevBodyAndFormattedBody, isMarkdown]); - - useEffect(() => { - if (saveState.status === AsyncStatus.Success) { - onCancel(); - } - }, [saveState, onCancel]); - - return ( -
- {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( - - )} - {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( - - )} - {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( - - )} - - - - - ) : undefined - } - > - Save - - - Cancel - - - - setToolbar(!toolbar)} - > - - - - {(anchor: RectCords | undefined, setAnchor) => ( - { - setAnchor((v) => { - if (v) { - if (!mobileOrTablet()) ReactEditor.focus(editor); - return undefined; - } - return v; - }); - }} - /> - } - > - - setAnchor( - evt.currentTarget.getBoundingClientRect() - )) as MouseEventHandler - } - variant="SurfaceVariant" - size="300" - radii="300" - > - - - - )} - - - - {toolbar && ( -
- - -
- )} - - } - /> -
- ); - } -); diff --git a/src/app/features/room/message/useMessageInteractionHandlers.ts b/src/app/features/room/message/useMessageInteractionHandlers.ts index b96d4e76..73e64e4c 100644 --- a/src/app/features/room/message/useMessageInteractionHandlers.ts +++ b/src/app/features/room/message/useMessageInteractionHandlers.ts @@ -1,4 +1,4 @@ -import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react'; +import { MouseEvent, MouseEventHandler, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import { Editor } from 'slate'; import { ReactEditor } from 'slate-react'; @@ -16,7 +16,7 @@ import { getReactionContent, } from '../../../utils/room'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix'; -import { IReplyDraft } from '../../../state/room/roomInputDrafts'; +import { IEditDraft, IReplyDraft } from '../../../state/room/roomInputDrafts'; import { getChannelsThreadPath } from '../../../pages/pathUtils'; export type UseMessageInteractionHandlersOptions = { @@ -26,16 +26,21 @@ export type UseMessageInteractionHandlersOptions = { // insertion + reply-draft focus target this instance. editor: Editor; // True when the composer associated with `editor` is currently - // unmounted/hidden. Main timeline passes `threadDrawerOpen` (drawer - // open hides main composer); drawer passes `false`. When suspended, - // the username-click mention insert no-ops to avoid writing into a - // hidden Slate instance the user can't see. + // unmounted/hidden. Main timeline passes `mainComposerSuspended` + // (drawer open / no post permission / tombstoned room); drawer passes + // `!canMessage`. When suspended, the username-click mention insert, + // edit and reply-draft writes all no-op to avoid writing into a hidden + // Slate instance / drafts the user can't see or clear. composerSuspended: boolean; // Reply-draft setter targeting the right composer scope. Caller // subscribes via `useSetAtom(roomIdToReplyDraftAtomFamily(...))` — // main timeline scopes to `[roomId, 'main']`, drawer scopes to // `[roomId, rootId]`. setReplyDraft: (draft: IReplyDraft | undefined) => void; + // Edit-draft setter for the same composer scope — «Edit» on a message + // loads it into the composer (Telegram-style) instead of opening an + // in-timeline edit form. See roomInputDrafts.ts::IEditDraft. + setEditDraft: (draft: IEditDraft | undefined) => void; // Caller-specific scroll-to-event for reply-chip clicks. Main timeline // scrolls via virtual paginator + setFocusItem; drawer scrolls via DOM // scrollIntoView on the matching `data-message-id` node. @@ -51,7 +56,6 @@ export type UseMessageInteractionHandlersOptions = { }; export type MessageInteractionHandlers = { - editId: string | undefined; handleEdit: (editEvtId?: string) => void; handleOpenReply: MouseEventHandler; handleUserClick: MouseEventHandler; @@ -61,15 +65,17 @@ export type MessageInteractionHandlers = { }; // Wiring layer for `` event-handler props, shared between -// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Each -// hook instance owns its own `editId` — editing in the drawer doesn't -// flip a row in the main timeline into edit mode and vice versa, which -// matches the user's mental model of two independent conversations. +// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Edit +// state lives in the per-(roomId, threadKey) edit-draft atom (the caller +// passes the scoped setter) — editing in the drawer doesn't put a main +// timeline row into edit mode and vice versa, which matches the user's +// mental model of two independent conversations. export function useMessageInteractionHandlers({ room, editor, composerSuspended, setReplyDraft, + setEditDraft, onOpenEvent, channelsMode, isBridged, @@ -81,8 +87,6 @@ export function useMessageInteractionHandlers({ const space = useSpaceOptionally(); const openUserRoomProfile = useOpenUserRoomProfile(); - const [editId, setEditId] = useState(); - const handleOpenReply: MouseEventHandler = useCallback( (evt) => { const targetId = evt.currentTarget.getAttribute('data-event-id'); @@ -171,6 +175,11 @@ export function useMessageInteractionHandlers({ navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId)); return; } + // Same orphan-draft guard as handleEdit below: while this scope's + // composer is hidden the reply banner could neither render nor be + // cleared. The thread-drawer navigation above is composer-independent + // and stays available. + if (composerSuspended) return; const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet()); const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent(); const { body, formatted_body: formattedBody } = content; @@ -189,7 +198,17 @@ export function useMessageInteractionHandlers({ setTimeout(() => ReactEditor.focus(editor), 100); } }, - [room, setReplyDraft, editor, channelsMode, isBridged, navigate, spaceIdOrAlias, roomIdOrAlias] + [ + room, + setReplyDraft, + editor, + channelsMode, + isBridged, + navigate, + spaceIdOrAlias, + roomIdOrAlias, + composerSuspended, + ] ); const handleReactionToggle = useCallback( @@ -219,19 +238,21 @@ export function useMessageInteractionHandlers({ ); const handleEdit = useCallback( + // Param stays optional because callers pass `mEvent.getId()` which is + // `string | undefined`; an id-less call is simply ignored (the old + // no-arg «cancel» semantics died with the in-timeline MessageEditor). (editEvtId?: string) => { - if (editEvtId) { - setEditId(editEvtId); - return; - } - setEditId(undefined); - ReactEditor.focus(editor); + if (!editEvtId) return; + // The composer for this scope loads the message + shows the edit + // banner (RoomInput's edit-draft effect). No-op while the composer + // is unmounted (callers also hide the Edit affordance then). + if (composerSuspended) return; + setEditDraft({ eventId: editEvtId }); }, - [editor] + [setEditDraft, composerSuspended] ); return { - editId, handleEdit, handleOpenReply, handleUserClick, diff --git a/src/app/features/room/room-actions/RoomActions.css.ts b/src/app/features/room/room-actions/RoomActions.css.ts index b79e30d0..69ef376b 100644 --- a/src/app/features/room/room-actions/RoomActions.css.ts +++ b/src/app/features/room/room-actions/RoomActions.css.ts @@ -73,16 +73,57 @@ export const ActionRowTrailing = style({ gap: config.space.S100, }); -export const ActionRowTrailingText = style({ - color: color.Surface.OnContainer, - opacity: config.opacity.P400, -}); - export const ActionRowChevron = style({ color: color.Surface.OnContainer, opacity: config.opacity.P300, }); +// Inline expansion group under a parent ActionRow (the Notifications mode +// picker). Indented past the parent's fixed icon slot so the sub-labels +// align with the parent label — reads as a nested tray INSIDE the popout +// instead of a second anchored PopOut (which rendered half-hidden over the +// menu on mobile and re-opened on the second tap — see NotificationsActionRow). +export const SubRowGroup = style({ + display: 'flex', + flexDirection: 'column', + gap: toRem(1), + paddingLeft: `calc(${toRem(20)} + ${config.space.S300})`, +}); + +// A mode option row — same flat vocabulary as ActionRow, just shorter. +export const SubRow = style({ + width: '100%', + minHeight: toRem(36), + display: 'flex', + alignItems: 'center', + gap: config.space.S300, + padding: `0 ${config.space.S300}`, + borderRadius: toRem(8), + background: 'transparent', + border: 'none', + textAlign: 'left', + font: 'inherit', + color: color.Surface.OnContainer, + cursor: 'pointer', + selectors: { + '&:hover:not(:disabled)': { backgroundColor: color.Surface.ContainerHover }, + '&:active:not(:disabled)': { backgroundColor: color.Surface.ContainerActive }, + '&:disabled': { + cursor: 'default', + opacity: config.opacity.P300, + }, + '&:focus-visible': { + outline: `${toRem(2)} solid ${color.Primary.Main}`, + outlineOffset: toRem(-2), + }, + }, +}); + +export const SubRowSelectedIcon = style({ + flexShrink: 0, + color: color.Primary.Main, +}); + // Popout container: width bump from the legacy 220px so the Notifications // mode word + Pinned badge breathe; softer mobile corner radius overriding // the folds Menu default. Background/border/shadow come from the folds Menu @@ -91,7 +132,14 @@ export const PopoutMenu = style({ width: toRem(280), maxWidth: '100vw', borderRadius: toRem(16), - overflow: 'hidden', + // Scroll, don't clip: the inline Notifications tray grows the menu AFTER + // the folds PopOut has fixed its position (PopOut never repositions on + // content growth), so on short viewports the tail rows would otherwise + // extend past the viewport bottom unreachable. The max-height keeps the + // frozen `top` valid; overflowY:auto still clips to the rounded corners. + maxHeight: 'calc(100dvh - 5rem)', + overflowY: 'auto', + overflowX: 'hidden', }); export const PopoutGroup = style({ diff --git a/src/app/features/room/room-actions/RoomActions.tsx b/src/app/features/room/room-actions/RoomActions.tsx index e55479c0..9065b8e2 100644 --- a/src/app/features/room/room-actions/RoomActions.tsx +++ b/src/app/features/room/room-actions/RoomActions.tsx @@ -1,9 +1,16 @@ -import React, { MouseEventHandler, ReactNode } from 'react'; -import { Icon, Icons, IconSrc, Text, color } from 'folds'; +import React, { MouseEventHandler, ReactNode, useState } from 'react'; +import { Icon, Icons, IconSrc, Spinner, Text, color } from 'folds'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; import { useAtom, useSetAtom } from 'jotai'; -import { RoomNotificationMode } from '../../../hooks/useRoomsNotificationPreferences'; +import { + getRoomNotificationMode, + getRoomNotificationModeIcon, + RoomNotificationMode, + useRoomsNotificationPreferencesContext, + useSetRoomNotificationPreference, +} from '../../../hooks/useRoomsNotificationPreferences'; +import { AsyncStatus } from '../../../hooks/useAsyncCallback'; import { botFailedAtomFamily, botShowChatAtomFamily } from '../../bots/botExperienceState'; import { getBotPath } from '../../../pages/pathUtils'; import * as css from './RoomActions.css'; @@ -22,7 +29,7 @@ type ActionRowProps = { trailing?: ReactNode; accent?: 'primary' | 'critical'; disabled?: boolean; - ariaPressed?: boolean; + ariaExpanded?: boolean; ariaHasPopup?: boolean; }; @@ -37,7 +44,7 @@ export function ActionRow({ trailing, accent, disabled, - ariaPressed, + ariaExpanded, ariaHasPopup, }: ActionRowProps) { let accentColor: string | undefined; @@ -50,7 +57,7 @@ export function ActionRow({ className={css.ActionRow} onClick={onClick} disabled={disabled} - aria-pressed={ariaPressed} + aria-expanded={ariaExpanded} aria-haspopup={ariaHasPopup} style={accentColor ? { color: accentColor } : undefined} > @@ -70,15 +77,6 @@ export function RowChevron() { return ; } -// Muted trailing word (e.g. the current notification mode beside the row). -export function RowTrailingText({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - // Subtle Fleet hairline between row groups. export function ActionSectionLine() { return
; @@ -126,3 +124,109 @@ export function useNotificationModeLabel(mode: RoomNotificationMode): string { }; return labels[mode]; } + +const NOTIFICATION_MODES: RoomNotificationMode[] = [ + RoomNotificationMode.Unset, + RoomNotificationMode.AllMessages, + RoomNotificationMode.SpecialMessages, + RoomNotificationMode.Mute, +]; + +function NotificationModeSubRow({ + mode, + selected, + disabled, + onSelect, +}: { + mode: RoomNotificationMode; + selected: boolean; + disabled: boolean; + onSelect: (mode: RoomNotificationMode) => void; +}) { + const label = useNotificationModeLabel(mode); + return ( + + ); +} + +type NotificationsActionRowProps = { + roomId: string; +}; + +// Notifications row for the ⋮ popout menus. The mode picker expands INLINE +// (an indented tray of the four modes under the row) instead of the old +// nested anchored PopOut, which rendered half-hidden on top of the menu on +// mobile. A plain boolean expansion has no anchor and no second FocusTrap, +// so the whole class of nested-trap positioning/toggle foot-guns is out. +export function NotificationsActionRow({ roomId }: NotificationsActionRowProps) { + const { t } = useTranslation(); + const preferences = useRoomsNotificationPreferencesContext(); + const mode = getRoomNotificationMode(preferences, roomId); + const [expanded, setExpanded] = useState(false); + + const { modeState, setMode } = useSetRoomNotificationPreference(roomId); + const changing = modeState.status === AsyncStatus.Loading; + + const handleSelect = (nextMode: RoomNotificationMode) => { + if (changing) return; + setMode(nextMode, mode); + setExpanded(false); + }; + + return ( + <> + {/* No current-mode word in the trailing slot — it crowded the row + label on narrow popouts; the expanded tray's check mark is the + mode indicator. */} + setExpanded(!expanded)} + ariaExpanded={expanded} + trailing={ + changing ? ( + + ) : ( + + ) + } + /> + {expanded && ( +
+ {NOTIFICATION_MODES.map((m) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/features/room/room-actions/RoomActionsMenu.tsx b/src/app/features/room/room-actions/RoomActionsMenu.tsx index 1f876e44..184ad397 100644 --- a/src/app/features/room/room-actions/RoomActionsMenu.tsx +++ b/src/app/features/room/room-actions/RoomActionsMenu.tsx @@ -1,5 +1,5 @@ import React, { MouseEventHandler, forwardRef, useState } from 'react'; -import { Badge, Icons, Menu, RectCords, Spinner, Text } from 'folds'; +import { Badge, Icons, Menu, RectCords, Text } from 'folds'; import { useTranslation } from 'react-i18next'; import { Room } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -14,11 +14,6 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useOpenRoomSettings } from '../../../state/hooks/roomSettings'; -import { - getRoomNotificationMode, - useRoomsNotificationPreferencesContext, -} from '../../../hooks/useRoomsNotificationPreferences'; -import { RoomNotificationModeSwitcher } from '../../../components/RoomNotificationSwitcher'; import { useBotPresets } from '../../bots/catalog'; import { findBotPresetForRoom } from '../../bots/room'; import { LeaveRoomPrompt } from '../../../components/leave-room-prompt'; @@ -29,7 +24,13 @@ import { getViaServers } from '../../../plugins/via-servers'; import { copyToClipboard } from '../../../utils/dom'; import { markAsRead } from '../../../utils/notifications'; import { JumpToTime } from '../jump-to-time'; -import { ActionRow, ActionSectionLine, BotWidgetActionRow, RowChevron } from './RoomActions'; +import { + ActionRow, + ActionSectionLine, + BotWidgetActionRow, + NotificationsActionRow, + RowChevron, +} from './RoomActions'; import * as css from './RoomActions.css'; type RoomActionsMenuProps = { @@ -43,9 +44,10 @@ type RoomActionsMenuProps = { // Content of the room overflow (⋮) popout, on both desktop and mobile. The // folds Menu `variant="Surface"` paints the deep Vojo input-field tone // (#0d0e11 = Surface.Container — the exact fill of the chat message composer, -// see RoomView.css.ts). Nested sub-flows are the same as upstream: -// Notifications → the anchored RoomNotificationModeSwitcher PopOut; Pinned → -// the RoomPinMenu PopOut via onPin(cords); Invite/Jump/Leave → centered overlays. +// see RoomView.css.ts). Nested sub-flows: Notifications expands INLINE +// (NotificationsActionRow — the old nested PopOut was half-hidden on mobile +// and re-opened on second tap); Pinned → the RoomPinMenu PopOut via +// onPin(cords); Invite/Jump/Leave → centered overlays. export const RoomActionsMenu = forwardRef( ({ room, callView, botControlRoom, onPin, requestClose }, ref) => { const { t } = useTranslation(); @@ -56,8 +58,6 @@ export const RoomActionsMenu = forwardRef( const creators = useRoomCreators(room); const permissions = useRoomPermissions(creators, powerLevels); const canInvite = permissions.action('invite', mx.getSafeUserId()); - const notificationPreferences = useRoomsNotificationPreferencesContext(); - const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId); const pinnedEvents = useRoomPinnedEvents(room); const { navigateRoom } = useRoomNavigate(); const openSettings = useOpenRoomSettings(); @@ -132,18 +132,7 @@ export const RoomActionsMenu = forwardRef( onClick={handleMarkAsRead} disabled={!unread} /> - - {(handleOpen, opened, changing) => ( - : } - /> - )} - + ( icon={Icons.RecentClock} label={t('Room.jump_to_time')} onClick={() => setPromptJump(true)} - ariaPressed={promptJump} + ariaExpanded={promptJump} ariaHasPopup trailing={} /> @@ -187,7 +176,7 @@ export const RoomActionsMenu = forwardRef( label={t('Room.invite')} onClick={() => setInvitePrompt(true)} accent="primary" - ariaPressed={invitePrompt} + ariaExpanded={invitePrompt} />
@@ -201,7 +190,7 @@ export const RoomActionsMenu = forwardRef( onClick={() => setPromptLeave(true)} accent="critical" disabled={callView} - ariaPressed={promptLeave} + ariaExpanded={promptLeave} />
diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts index 0580fe70..38345c48 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.css.ts +++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts @@ -1,19 +1,13 @@ import { style } from '@vanilla-extract/css'; import { color, toRem } from 'folds'; -import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; - -// Re-exported so the TSX can pick up the constants without crossing -// the vanilla-extract / runtime boundary twice. -export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX; -export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; +import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; // Outer container — `position: relative` anchor for the two absolutely- // positioned panes (`appBody` and `silhouette`). `overflow: hidden` -// clips anything that overflows the wrapper's bounds and crops the -// rounded carves on both panes against the container's bg (which is -// painted with `VOJO_HORSESHOE_VOID_COLOR` inline when the sheet is -// active, so the carved-out areas read as the same near-black seam -// used everywhere else in the app). +// clips the full-bleed children at the screen edges; the rounded top +// corners come from the silhouette's own static border-radius, whose +// corner triangles stay TRANSPARENT and reveal the live DM list behind +// (the media-viewer composition — no void colour, no carve). // // `flex: 1` so the container fills whatever flex slot it's mounted in // (PageNav's inner column for the Direct route). @@ -22,16 +16,10 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX; // status-bar safe-top zone reserved by `PageNav` via `padding-top`, // and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody` // keeps the wrapped DM list anchored at the same visual Y as before -// the shift. The combination has two load-bearing effects: -// -// (1) The settings-sheet clip-path mask on `appBody` carves rounded -// BL/BR into an opaque surface that already paints THROUGH the -// status-bar strip — without the upward extension the carve -// would visibly stop at the bottom of the system-tray strip. -// (2) `appBody`'s bg paints the safe-top strip itself in the same -// `SurfaceVariant.Container` tone as `PageNav-inner` / the -// pager's static header, giving the system-tray text a -// consistent backdrop across surfaces. +// the shift. The extension is what lets the near-fullscreen sheet's +// top edge (railHeight = containerHeight − safeTop) land exactly at +// the bottom of the status bar, while `appBody`'s bg paints the strip +// in the same `SurfaceVariant.Container` tone as the pager header. // // Note: the curtain never RESTS inside the safe-top zone — every snap // destination floors at `top: 0` of the stage (= `y = safe-top` in @@ -52,38 +40,25 @@ export const container = style({ }); // === App body === Holds the wrapped children (the DM list — header, -// scroll content, DirectSelfRow). Fills the container via `inset: 0`. -// Does NOT translate or shrink — the DM list stays exactly where it -// was in the closed state. Instead, the bottom of the pane is masked -// away by an animated `clip-path: -// inset(...)` with rounded BL/BR corners — the user sees the visible -// top portion of the DM list with a rounded carve at the new bottom -// edge, exactly like the profile horseshoe shows the chat with a -// rounded TOP carve as the panel masks it from above. +// scroll content, DirectSelfRow), full-bleed behind the silhouette via +// `inset: 0`. NOT clipped or translated: the DM list keeps its layout, +// scroll position and measured row heights; the opaque sheet simply +// grows over it, and the sheet's transparent rounded corners reveal +// this live surface — exactly the media-viewer composition. // -// Why clip-path rather than flex-shrink + margin-bottom: a flex- -// shrink approach changes the scroll-container's height every render, -// and the DM list's `@tanstack/react-virtual` re-measures items mid- -// gesture. Why not `transform: translateY` either: translating moves -// the whole pane up off the top of the viewport, including the -// StreamHeader the user wants to keep visible. Clip-path leaves -// layout unchanged — the DM list keeps its scroll position, its -// measured heights, and its top items in place; only the bottom edge -// of what's visible gets carved into the void below. +// `isolation: isolate` is load-bearing: it makes appBody a PERMANENT +// stacking context, which the pager refresh singleton's paint-order +// contract counts on (see mobile-tabs-pager/style.css.ts:: +// pagerRefreshSingleton). The always-on clip-path used to provide this +// as a side effect; with the carve gone, isolate keeps the guarantee. // -// `backgroundColor: SurfaceVariant.Container` is load-bearing on two -// counts: (1) it must be OPAQUE so the container's void colour -// (painted inline when the sheet is active) doesn't bleed through gaps -// between DM list rows; (2) the safe-top padding region of THIS -// element is what paints the system-tray strip when the wrapper -// container is extended up over it (see `container.marginTop` above). -// Picking `SurfaceVariant.Container` (not `Background.Container`) -// matches the Bots / ChannelsRoot status-bar tone exactly — Bots -// renders `PageNav-inner.bg = SurfaceVariant.Container` in the safe- -// top zone, and the StreamHeader curtain (`Background.Container`) -// overpaints that lighter strip with the darker tone as it's dragged -// up. Mirroring the same two tones here gives Direct the same visible -// «curtain darkens the strip» transition the user expects. +// `backgroundColor: SurfaceVariant.Container` is load-bearing: it must +// be OPAQUE so nothing behind the wrapper bleeds through gaps between +// DM-list rows, and the safe-top padding region of THIS element is +// what paints the system-tray strip when the wrapper container is +// extended up over it (see `container.marginTop` above). Picking +// `SurfaceVariant.Container` (not `Background.Container`) matches the +// Bots / ChannelsRoot status-bar tone exactly. // // `flex: column` so the children (which expect a flex column parent // — PageNav uses it) still stack naturally. `paddingTop: @@ -102,18 +77,17 @@ export const appBody = style({ minHeight: 0, backgroundColor: color.SurfaceVariant.Container, paddingTop: 'var(--vojo-safe-top, 0px)', - willChange: 'clip-path', + isolation: 'isolate', }); // === Silhouette === The Settings sheet's surface. Anchored at the // bottom of the container; its height animates 0 → railHeight as the -// user drags up. Rounded TL/TR carve the top edge against the void -// gap between it and the translated-up appBody. -// -// `overflow: hidden` clips `panelContent` (which is railHeight tall, -// top-anchored) so the visible portion of the panel is just the -// silhouette's current height — the user sees more of the panel -// content reveal from the top as silhouette grows. +// user drags up. STATIC rounded top corners (same radius as the tab +// curtains / media sheet); `overflow: hidden` clips the panel content +// to the curve, and the two corner triangles paint NOTHING — they stay +// transparent and reveal the live DM list behind (`appBody` is +// full-bleed, not clipped), so the rounding reads against real content +// with no backing square. Only the height animates (`willChange`). // // Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the // chat-pane tone, same as the Settings PageNav inside (set via @@ -122,9 +96,6 @@ export const appBody = style({ // matches the PageNav tone. With `Background.Container` (#0d0e11) // here, the user saw a dark stripe at that seam on Samsung S24 // edge-to-edge; matching silhouette to the PageNav tone closes it. -// Same idea as commit 77bb72d which dynamically retunes -// `--vojo-safe-area-bg` while a Room is mounted to keep the -// system-bar strips and the chat surface in lockstep. export const silhouette = style({ position: 'absolute', bottom: 0, @@ -134,7 +105,9 @@ export const silhouette = style({ flexDirection: 'column', overflow: 'hidden', backgroundColor: color.SurfaceVariant.Container, - willChange: 'height, border-top-left-radius, border-top-right-radius', + borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), + borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX), + willChange: 'height', }); // Anchored at the TOP of `silhouette` so as silhouette grows from 0 diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx index 93dec21a..f252308e 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.tsx +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -1,28 +1,18 @@ -// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list. -// Mirror of `MobileProfileHorseshoe` in features/room — the chat -// there is wrapped by a top-down horseshoe (panel above, chat below -// with a 12px void). Here we invert: the wrapped app body is above, -// the Settings sheet emerges from below, and a 12px void separates -// them in a )|( silhouette. +// Bottom-up near-fullscreen Settings sheet that wraps the mobile +// Direct DM list. Geometry mirrors the MEDIA-VIEWER sheet +// (`MobileMediaViewerHorseshoe`): the opaque sheet grows from the +// bottom over the full-bleed app body and stops just under the system +// status bar; its STATIC rounded top corners stay transparent and +// reveal the live DM list behind. No void gap, no clip-path carve, no +// emerge ramp — only the sheet's height animates. // // User-visible behaviour: // // • The wrapped app body (StreamHeader → DM list → DirectSelfRow) -// stays exactly where it was — no translate, -// no shrink. The bottom of the visible portion is "masked away" -// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx -// Rpx)` with rounded BL/BR carves at the new visible edge. The -// carved area exposes the container's void colour underneath, and -// the silhouette below covers the rest of the masked zone. Why -// not `transform: translateY`: translating moves the TOP of the -// pane out of the viewport (StreamHeader scrolls off- -// screen). Why not `flex-shrink + margin-bottom`: the virtualized -// DM list (`@tanstack/react-virtual`) re-measures items every -// time the scroll container resizes — items above the shrinking -// edge visibly smear. Clip-path leaves layout unchanged: the DM -// list keeps its scroll position, its measured heights, and its -// top items in place; only the bottom edge of what's visible is -// carved into the void. +// stays exactly where it was — no translate, no shrink, no clip. +// The virtualized DM list (`@tanstack/react-virtual`) keeps its +// scroll position and measured row heights; the sheet simply +// paints over it. // • Drag-up origin is `DirectSelfRow` itself, marked with the // `data-settings-drag-origin` attribute. A document-level // touchstart / pointerdown listener uses `target.closest()` to @@ -56,7 +46,6 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { HorseshoeEnabledContext } from '../../components/page'; import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext'; import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader'; -import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import { Settings } from './Settings'; import * as css from './MobileSettingsHorseshoe.css'; @@ -72,26 +61,6 @@ const ANIMATION_MS = 250; // or close from the panel handle). Mirrors the profile horseshoe's // 80px so the two gestures feel identical. const COMMIT_THRESHOLD_PX = 80; -// Fixed sheet height — 2/3 of viewport, what the user signed off on. -// Internal scrolling inside Settings sub-pages handles content -// overflow; no rail re-sizing on menu↔sub-page navigation. -const RAIL_FRACTION = 2 / 3; -// Drag distance over which the radii + void-gap ramp from 0 to their -// full value during finger-drag — same as the profile horseshoe's -// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the -// silhouette is fully formed exactly when the gesture qualifies to -// commit. -const HORSESHOE_EMERGE_PX = 80; - -// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror -// of the profile horseshoe's emerge curve (file rationale there). The -// linear ramp from round 1 came out too "snappy" because the rounding -// jumped in within the first ~10px of drag; the cubic keeps the corners -// barely visible until ~40% of the way through the gesture, then -// blossoms around the midpoint. Used only during finger-drag; release -// transitions use the asymmetric VAUL_EASING curve in CSS. -const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2); - type DragSource = 'directSelfRow' | 'handle'; // Axis dead-zone for horizontal-bail. The finger must travel this far @@ -129,17 +98,50 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) const inPagerMode = useMobilePagerPane() !== null; const [drag, setDrag] = useState(null); - const [viewportHeight, setViewportHeight] = useState(() => + + // Measure the wrapper container directly via `ResizeObserver` — the + // container extends up over the status bar (css `marginTop: + // -var(--vojo-safe-top)`), so its measured height already includes the + // strip; subtracting the live safe-top inset below puts the sheet's top + // edge exactly at the bottom of the status bar. Mirrors the media-viewer + // sheet's calc (MobileMediaViewerHorseshoe). + const containerRef = useRef(null); + const [containerHeight, setContainerHeight] = useState(() => typeof window === 'undefined' ? 800 : window.innerHeight ); - - useEffect(() => { - const onResize = () => setViewportHeight(window.innerHeight); - window.addEventListener('resize', onResize); - return () => window.removeEventListener('resize', onResize); + useLayoutEffect(() => { + const el = containerRef.current; + if (!el) return undefined; + setContainerHeight(el.clientHeight); + const ro = new ResizeObserver((entries) => { + const cr = entries[0]?.contentRect; + if (cr) setContainerHeight(cr.height); + }); + ro.observe(el); + return () => ro.disconnect(); }, []); - const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION); + // Live status-bar inset. `env(safe-area-inset-top)` can't be read + // reliably off a custom property in JS, so we measure a zero-width + // probe whose height is `var(--vojo-safe-top)` (= the env value). + const safeTopRef = useRef(null); + const [safeTopPx, setSafeTopPx] = useState(0); + useLayoutEffect(() => { + const el = safeTopRef.current; + if (!el) return undefined; + setSafeTopPx(el.offsetHeight); + const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight)); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + // Near-fullscreen by design (user request: Settings covers the app body + // and stops just under the system status bar / tray): the rail is the + // measured container height minus the live status-bar inset, so the + // sheet's rounded top edge lands at the bottom of the status bar, never + // over the tray icons. Shrunken wrappers (call rail / split-screen) + // shrink the sheet with them via the ResizeObserver above. + const railHeightPx = Math.max(0, containerHeight - safeTopPx); const open = !!sheet; // Entry-animation gate. On cold-start deep-links (push notification @@ -195,15 +197,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) const expandedPx = drag ? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY)) : baseExpanded; - const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0; const isDragging = drag !== null; const horseshoeActive = expandedPx > 0; // Bridge our local `horseshoeActive` (geometric) signal up to the - // pager-shared atom so `MobileTabsPagerHeader` can z-elevate the - // static header from the first frame of drag — see the atom's docs - // for the «no black flash» rationale. The cleanup writing `false` - // covers route unmount mid-drag. + // pager-shared atom — it only feeds the refresh singleton's hide. The + // static pager header is deliberately NOT z-elevated for this sheet + // (no `mobileHorseshoeElevateHeaderAtom` publication): the appBody + // stays transparent in pager mode, so the tabs remain visible exactly + // as at rest and the opaque sheet simply slides OVER them like a real + // curtain. The cleanup writing `false` covers route unmount mid-drag. const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom); useEffect(() => { setMobileHorseshoeActive(horseshoeActive); @@ -506,48 +509,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) }; }, []); - // Geometry — radii and void gap ramp through `easeInOutCubic` (slow - // start → fast middle → slow finish) during finger-drag, matching - // the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX` - // window as the profile horseshoe so the rounding finishes exactly - // when the gesture qualifies to commit. During release (not - // dragging), the values jump to their full target and the CSS - // transition (VAUL_EASING) carries them visually. - // - // The mask: appBody stays in place, its bottom edge is "carved - // away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)` - // where BOTTOM = expandedPx + voidGap. The carved zone exposes the - // container's bg (the void colour) above the silhouette; the void - // gap is just `voidGap` pixels of that exposed bg between the - // silhouette's top edge and the clip-path's lower edge. - let horseshoeRamp: number; - if (isDragging) { - horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX)); - } else { - horseshoeRamp = expandedFraction > 0 ? 1 : 0; - } - const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; - const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX; - const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX; - const appBodyMaskBottomPx = expandedPx + appBodyGapPx; - - // `inset()` shorthand: top right bottom left, then `round` followed - // by 4 corner radii in TL TR BR BL order. Only the bottom two carry - // the radius so the visible top portion of appBody has rounded BL/BR - // at the clip boundary. Always emitted (even when all values are 0 - // for the closed state) so CSS can transition smoothly between - // closed and open — interpolating between `inset(...)` and an - // `undefined` clip-path would snap rather than animate. - const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`; - - const silhouetteTransition = isDragging - ? 'none' - : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`; - const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`; - - const containerStyle: React.CSSProperties = { - backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined, - }; + // Geometry — media-viewer style (MobileMediaViewerHorseshoe): the opaque + // silhouette simply grows from the bottom over the full-bleed appBody. + // Its rounded TL/TR corners are STATIC (32px, in the css) and stay + // transparent, revealing the live DM list behind — no clip-path carve, + // no void gap, no emerge ramp. Only the silhouette's height animates. + const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`; const settingsState = sheet ?? lastSheetRef.current; const renderSettings = keepMounted || isDragging; @@ -568,7 +535,22 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps) : null; return ( -
+
+ {/* Zero-width probe — its height resolves `env(safe-area-inset-top)` + so the JS rail-height calc can keep the sheet below the tray. */} +