This commit is contained in:
heaven 2026-06-13 12:50:55 +03:00
parent 99391d9f28
commit c2f504ffb4
114 changed files with 4206 additions and 3674 deletions

View file

@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -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 }
);
}

View file

@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -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 }
);
}

View file

@ -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(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -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 }
);
}

View file

@ -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' ? <ChannelLayout/> : <StreamLayout/>`), 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' ? <ChannelLayout/> : <StreamLayout/>`), 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 `<CallView/>`. 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)

View file

@ -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.

View file

@ -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 chats 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"
}
}

View file

@ -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": "Вперёд"
}
}

View file

@ -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 (
<Box as="span" gap="100" alignItems="Center">
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
@ -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')}
</Text>
</Box>
);
@ -52,21 +54,23 @@ type BackupSyncingProps = {
count: number;
};
function BackupSyncing({ count }: BackupSyncingProps) {
const { t } = useTranslation();
return (
<Box as="span" gap="100" alignItems="Center">
<Spinner size="50" variant="Primary" fill="Soft" />
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
Syncing ({count})
{t('Settings.backup_syncing', { amount: count })}
</Text>
</Box>
);
}
function BackupProgressFetching() {
const { t } = useTranslation();
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: 0%</Text>
<Text size="L400">{t('Settings.backup_restoring_percent', { percent: 0 })}</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
@ -81,10 +85,15 @@ type BackupProgressProps = {
downloaded: number;
};
function BackupProgress({ total, downloaded }: BackupProgressProps) {
const { t } = useTranslation();
return (
<Box grow="Yes" gap="200" alignItems="Center">
<Badge variant="Secondary" fill="Solid" radii="300">
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
<Text size="L400">
{t('Settings.backup_restoring_percent', {
percent: Math.round(percent(0, total, downloaded)),
})}
</Text>
</Badge>
<Box grow="Yes" direction="Column">
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={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) {
<Box direction="Column">
{trust.matchesDecryptionKey ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted decryption key.</b>
<b>{t('Settings.backup_trusted_decryption_key')}</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted decryption key!</b>
<b>{t('Settings.backup_untrusted_decryption_key')}</b>
</Text>
)}
{trust.trusted ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Backup has trusted by signature.</b>
<b>{t('Settings.backup_trusted_signature')}</b>
</Text>
) : (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>Backup does not have trusted signature!</b>
<b>{t('Settings.backup_untrusted_signature')}</b>
</Text>
)}
</Box>
@ -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 (
<InfoCard
variant="Surface"
title="Encryption Backup"
title={t('Settings.backup_encryption_title')}
after={
<Box alignItems="Center" gap="200">
{remainingSession === 0 ? (
@ -212,12 +223,20 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
<Box direction="Column" gap="200">
<InfoCard
variant="SurfaceVariant"
title="Backup Details"
title={t('Settings.backup_details')}
description={
<>
<span>Version: {backupInfo?.version ?? 'NIL'}</span>
<span>
{t('Settings.backup_version', {
version: backupInfo?.version ?? t('Settings.backup_none_value'),
})}
</span>
<br />
<span>Keys: {backupInfo?.count ?? 'NIL'}</span>
<span>
{t('Settings.backup_keys_count', {
amount: backupInfo?.count ?? t('Settings.backup_none_value'),
})}
</span>
</>
}
/>
@ -234,7 +253,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
}
before={<Icon size="100" src={Icons.Download} />}
>
<Text size="B300">Restore Backup</Text>
<Text size="B300">{t('Settings.backup_restore')}</Text>
</Button>
</Box>
</Menu>
@ -251,7 +270,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
)}
{!backupEnabled && backupInfo === null && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>No backup present on server!</b>
<b>{t('Settings.backup_none_on_server')}</b>
</Text>
)}
{!syncFailure && !backupEnabled && backupInfo && (

View file

@ -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 (
<Box direction="Column" gap="400">
<Text>{message}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
<Text size="B400">{t('Settings.verification_close')}</Text>
</Button>
</Box>
);
}
function VerificationWaitAccept() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>Please accept the request from other device.</Text>
<WaitingMessage message="Waiting for request to be accepted..." />
<Text>{t('Settings.verification_accept_prompt')}</Text>
<WaitingMessage message={t('Settings.verification_waiting_accept')} />
</Box>
);
}
@ -73,12 +76,13 @@ type VerificationAcceptProps = {
onAccept: () => Promise<void>;
};
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const { t } = useTranslation();
const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="400">
<Text>Click accept to start the verification process.</Text>
<Text>{t('Settings.verification_click_accept')}</Text>
<Button
variant="Primary"
fill="Solid"
@ -86,17 +90,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
disabled={accepting}
>
<Text size="B400">Accept</Text>
<Text size="B400">{t('Settings.verification_accept')}</Text>
</Button>
</Box>
);
}
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>Verification request has been accepted.</Text>
<WaitingMessage message="Waiting for the response from other device..." />
<Text>{t('Settings.verification_request_accepted')}</Text>
<WaitingMessage message={t('Settings.verification_waiting_response')} />
</Box>
);
}
@ -105,18 +110,20 @@ type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
<WaitingMessage message={t('Settings.verification_starting_emoji')} />
</Box>
);
}
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 (
<Box direction="Column" gap="400">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
<Text>{t('Settings.verification_compare_emoji_prompt')}</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
@ -157,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
disabled={confirming}
before={confirming && <Spinner size="100" variant="Primary" />}
>
<Text size="B400">They Match</Text>
<Text size="B400">{t('Settings.verification_match')}</Text>
</Button>
<Button
variant="Primary"
@ -165,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
onClick={() => sasData.mismatch()}
disabled={confirming}
>
<Text size="B400">Do not Match</Text>
<Text size="B400">{t('Settings.verification_no_match')}</Text>
</Button>
</Box>
</Box>
@ -177,6 +184,7 @@ type SasVerificationProps = {
onCancel: () => void;
};
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const { t } = useTranslation();
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
<WaitingMessage message={t('Settings.verification_starting_emoji')} />
</Box>
);
}
@ -201,13 +209,14 @@ type VerificationDoneProps = {
onExit: () => void;
};
function VerificationDone({ onExit }: VerificationDoneProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<div>
<Text>Your device is verified.</Text>
<Text>{t('Settings.verification_device_verified')}</Text>
</div>
<Button variant="Primary" fill="Solid" onClick={onExit}>
<Text size="B400">Okay</Text>
<Text size="B400">{t('Settings.verification_okay')}</Text>
</Button>
</Box>
);
@ -217,11 +226,12 @@ type VerificationCanceledProps = {
onClose: () => void;
};
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>Verification has been canceled.</Text>
<Text>{t('Settings.verification_canceled')}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
<Text size="B400">{t('Settings.verification_close')}</Text>
</Button>
</Box>
);
@ -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)
<Dialog variant="Surface">
<Header style={DialogHeaderStyles} variant="Surface" size="500">
<Box grow="Yes">
<Text size="H4">Device Verification</Text>
<Text size="H4">{t('Settings.device_verification')}</Text>
</Box>
<IconButton size="300" radii="300" onClick={handleCancel}>
<Icon src={Icons.Cross} />
@ -283,7 +294,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
) : (
<VerificationUnexpected
message="Unexpected Error! Verification is started but verifier is missing."
message={t('Settings.verification_unexpected_error')}
onClose={handleCancel}
/>
))}

View file

@ -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 (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
<Text size="T300">
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
devices. Additionally, setup a passphrase as a memorable alternative.
</Text>
<Text size="T300">{t('Settings.verification_setup_desc')}</Text>
<Box direction="Column" gap="100">
<Text size="L400">Passphrase (Optional)</Text>
<Text size="L400">{t('Settings.verification_passphrase_optional')}</Text>
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
</Box>
<Button
@ -194,21 +193,19 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
disabled={loading}
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Continue</Text>
<Text size="B400">{t('Settings.verification_continue')}</Text>
</Button>
{setupState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
<b>
{setupState.error ? setupState.error.message : t('Settings.verification_error_generic')}
</b>
</Text>
)}
{nextAuthData !== null && uiaAction && (
<ActionUIAFlowsLoader
authData={nextAuthData ?? uiaAction.authData}
unsupported={() => (
<Text size="T200">
Authentication steps to perform this action are not supported by client.
</Text>
)}
unsupported={() => <Text size="T200">{t('Settings.verification_uia_unsupported')}</Text>}
>
{(ongoingFlow) => (
<ActionUIA
@ -228,6 +225,7 @@ type RecoveryKeyDisplayProps = {
recoveryKey: string;
};
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const handleCopy = () => {
@ -245,12 +243,9 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
return (
<Box direction="Column" gap="400">
<Text size="T300">
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.
</Text>
<Text size="T300">{t('Settings.verification_recovery_key_store_desc')}</Text>
<Box direction="Column" gap="100">
<Text size="L400">Recovery Key</Text>
<Text size="L400">{t('Settings.verification_recovery_key')}</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
@ -265,16 +260,18 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
{safeToDisplayKey}
</Text>
<Chip onClick={() => setShow(!show)} variant="Secondary" radii="Pill">
<Text size="B300">{show ? 'Hide' : 'Show'}</Text>
<Text size="B300">
{show ? t('Settings.verification_hide') : t('Settings.verification_show')}
</Text>
</Chip>
</Box>
</Box>
<Box direction="Column" gap="200">
<Button onClick={handleCopy}>
<Text size="B400">Copy</Text>
<Text size="B400">{t('Settings.verification_copy')}</Text>
</Button>
<Button onClick={handleDownload} fill="Soft">
<Text size="B400">Download</Text>
<Text size="B400">{t('Settings.verification_download')}</Text>
</Button>
</Box>
</Box>
@ -286,6 +283,7 @@ type DeviceVerificationSetupProps = {
};
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
({ onCancel }, ref) => {
const { t } = useTranslation();
const [recoveryKey, setRecoveryKey] = useState<string>();
return (
@ -299,7 +297,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text size="H4">Setup Device Verification</Text>
<Text size="H4">{t('Settings.verification_setup_title')}</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
@ -321,6 +319,7 @@ type DeviceVerificationResetProps = {
};
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
({ onCancel }, ref) => {
const { t } = useTranslation();
const [reset, setReset] = useState(false);
return (
@ -334,7 +333,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
size="500"
>
<Box grow="Yes">
<Text size="H4">Reset Device Verification</Text>
<Text size="H4">{t('Settings.verification_reset_title')}</Text>
</Box>
<IconButton size="300" radii="300" onClick={onCancel}>
<Icon src={Icons.Cross} />
@ -356,16 +355,11 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text size="H1">🧑🚒🤚</Text>
<Text size="T300">Resetting device verification is permanent.</Text>
<Text size="T300">
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{' '}
<b>Recovery Key</b> or <b>Recovery Passphrase</b> and every device you can verify
from.
</Text>
<Text size="T300">{t('Settings.verification_reset_permanent')}</Text>
<Text size="T300">{t('Settings.verification_reset_warning')}</Text>
</Box>
<Button variant="Critical" onClick={() => setReset(true)}>
<Text size="B400">Reset</Text>
<Text size="B400">{t('Settings.reset')}</Text>
</Button>
</Box>
)}

View file

@ -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<HTMLDivElement, LogoutDialogProps>(
({ 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<void, Error, []>(
useCallback(async () => {
await logoutClient(mx);
}, [mx])
);
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
return (
<Dialog variant="Surface" ref={ref}>
<Header
style={{
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Box grow="Yes">
<Text size="H4">{t('Settings.logout')}</Text>
</Box>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
{hasEncryptedRoom &&
(crossSigningActive ? (
verificationStatus === VerificationStatus.Unverified && (
<InfoCard
variant="Critical"
title={t('Settings.logout_unverified_title')}
description={t('Settings.logout_unverified_desc')}
/>
)
) : (
<InfoCard
variant="Critical"
title={t('Settings.logout_alert_title')}
description={t('Settings.logout_alert_desc')}
/>
))}
<Text priority="400">{t('Settings.logout_confirm')}</Text>
{logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{t('Settings.logout_failed', { message: logoutState.error.message })}
</Text>
)}
<Box direction="Column" gap="200">
<Button
variant="Critical"
onClick={logout}
disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
>
<Text size="B400">{t('Settings.logout')}</Text>
</Button>
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
<Text size="B400">{t('Settings.cancel')}</Text>
</Button>
</Box>
</Box>
</Dialog>
);
}
);

View file

@ -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<RectCords>();
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
@ -55,8 +57,10 @@ export function ManualVerificationMethodSwitcher({
onClick={handleMenu}
>
<Text as="span" size="B300">
{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')}
</Text>
</Chip>
<PopOut
@ -87,7 +91,7 @@ export function ManualVerificationMethodSwitcher({
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
>
<Box grow="Yes">
<Text size="T300">Recovery Passphrase</Text>
<Text size="T300">{t('Settings.verification_recovery_passphrase')}</Text>
</Box>
</MenuItem>
<MenuItem
@ -98,7 +102,7 @@ export function ManualVerificationMethodSwitcher({
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
>
<Box grow="Yes">
<Text size="T300">Recovery Key</Text>
<Text size="T300">{t('Settings.verification_recovery_key')}</Text>
</Box>
</MenuItem>
</Box>
@ -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 (
<Box direction="Column" gap="200">
<SettingTile
title="Verify Manually"
description={hasPassphrase ? 'Select a verification method.' : 'Provide recovery key.'}
title={t('Settings.verify_manually')}
description={
hasPassphrase
? t('Settings.verification_select_method')
: t('Settings.verification_provide_key')
}
after={
<Box alignItems="Center" gap="200">
{hasPassphrase && (
@ -167,7 +176,7 @@ export function ManualVerificationTile({
/>
{verifyState.status === AsyncStatus.Success ? (
<Text size="T200" style={{ color: color.Success.Main }}>
<b>Device verified!</b>
<b>{t('Settings.verification_verified_success')}</b>
</Text>
) : (
<Box direction="Column" gap="100">

View file

@ -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',
},
},
});

View file

@ -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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@ -20,26 +29,27 @@ export function Modal500({ requestClose, children }: Modal500Props) {
escapeDeactivates: stopPropagation,
}}
>
<Modal
size="500"
variant="Background"
<div
className={classNames(css.Card, narrow && css.CardNarrow)}
role="dialog"
aria-modal="true"
// Reset `--vojo-safe-top` for everything mounted inside the
// dialog. The Android status-bar inset is reserved by each
// page header's `padding-top: var(--vojo-safe-top)` for
// top-of-screen surfaces — but a centred 500px modal sits
// away from the screen edge, and the same padding inside it
// just adds dead space above its header.
// top-of-screen surfaces — on phones this window IS
// top-of-screen, but its own header chrome supplies the
// spacing; the inherited padding would double it.
style={{ ['--vojo-safe-top' as string]: '0px' }}
>
{/* 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. */}
<HorseshoeEnabledContext.Provider value={false}>
{children}
</HorseshoeEnabledContext.Provider>
</Modal>
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>

View file

@ -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<RectCords>();
// 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<HTMLElement | null>(null);
const open = !!menuCords;
const handleToggleMenu: MouseEventHandler<HTMLButtonElement> = (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',

View file

@ -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<RectCords>();
@ -158,7 +160,12 @@ export function HeadingBlockButton() {
<Menu style={{ padding: config.space.S100 }}>
<Box gap="100">
<TooltipProvider
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
tooltip={
<BtnTooltip
text={t('Room.format_heading', { level: 1 })}
shortCode={`${modKey} + 1`}
/>
}
delay={500}
>
{(triggerRef) => (
@ -173,7 +180,12 @@ export function HeadingBlockButton() {
)}
</TooltipProvider>
<TooltipProvider
tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + 2`} />}
tooltip={
<BtnTooltip
text={t('Room.format_heading', { level: 2 })}
shortCode={`${modKey} + 2`}
/>
}
delay={500}
>
{(triggerRef) => (
@ -188,7 +200,12 @@ export function HeadingBlockButton() {
)}
</TooltipProvider>
<TooltipProvider
tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + 3`} />}
tooltip={
<BtnTooltip
text={t('Room.format_heading', { level: 3 })}
shortCode={`${modKey} + 3`}
/>
}
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"
>
<Text size="B400">{`Exit ${KeySymbol.Hyper}`}</Text>
<Text size="B400">{`${t('Room.format_exit')} ${KeySymbol.Hyper}`}</Text>
</IconButton>
)}
</TooltipProvider>
@ -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() {
<MarkButton
format={MarkType.Bold}
icon={Icons.Bold}
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />}
tooltip={<BtnTooltip text={t('Room.format_bold')} shortCode={`${modKey} + B`} />}
/>
<MarkButton
format={MarkType.Italic}
icon={Icons.Italic}
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />}
tooltip={<BtnTooltip text={t('Room.format_italic')} shortCode={`${modKey} + I`} />}
/>
<MarkButton
format={MarkType.Underline}
icon={Icons.Underline}
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />}
tooltip={
<BtnTooltip text={t('Room.format_underline')} shortCode={`${modKey} + U`} />
}
/>
<MarkButton
format={MarkType.StrikeThrough}
icon={Icons.Strike}
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
tooltip={
<BtnTooltip text={t('Room.format_strikethrough')} shortCode={`${modKey} + S`} />
}
/>
<MarkButton
format={MarkType.Code}
icon={Icons.Code}
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />}
tooltip={
<BtnTooltip text={t('Room.format_inline_code')} shortCode={`${modKey} + [`} />
}
/>
<MarkButton
format={MarkType.Spoiler}
icon={Icons.EyeBlind}
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />}
tooltip={<BtnTooltip text={t('Room.format_spoiler')} shortCode={`${modKey} + H`} />}
/>
</Box>
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
@ -305,22 +330,30 @@ export function Toolbar() {
<BlockButton
format={BlockType.BlockQuote}
icon={Icons.BlockQuote}
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} />}
tooltip={
<BtnTooltip text={t('Room.format_block_quote')} shortCode={`${modKey} + '`} />
}
/>
<BlockButton
format={BlockType.CodeBlock}
icon={Icons.BlockCode}
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} />}
tooltip={
<BtnTooltip text={t('Room.format_block_code')} shortCode={`${modKey} + ;`} />
}
/>
<BlockButton
format={BlockType.OrderedList}
icon={Icons.OrderList}
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />}
tooltip={
<BtnTooltip text={t('Room.format_ordered_list')} shortCode={`${modKey} + 7`} />
}
/>
<BlockButton
format={BlockType.UnorderedList}
icon={Icons.UnorderList}
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />}
tooltip={
<BtnTooltip text={t('Room.format_unordered_list')} shortCode={`${modKey} + 8`} />
}
/>
<HeadingBlockButton />
</Box>
@ -330,7 +363,10 @@ export function Toolbar() {
<Box shrink="No" gap="100">
<ExitFormatting
tooltip={
<BtnTooltip text="Exit Formatting" shortCode={`Escape, ${modKey} + E`} />
<BtnTooltip
text={t('Room.format_exit_formatting')}
shortCode={`Escape, ${modKey} + E`}
/>
}
/>
</Box>
@ -339,7 +375,15 @@ export function Toolbar() {
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
<TooltipProvider
align="End"
tooltip={<BtnTooltip text={isMarkdown ? 'Disable Markdown' : 'Enable Markdown'} />}
tooltip={
<BtnTooltip
text={
isMarkdown
? t('Room.format_markdown_disable')
: t('Room.format_markdown_enable')
}
/>
}
delay={500}
>
{(triggerRef) => (

View file

@ -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({
}}
>
<EmojiBoardLayout
dock={dock}
header={
<Box direction="Column" gap="200">
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}

View file

@ -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) => (
<Box
display={dock ? 'Flex' : 'InlineFlex'}
className={classNames(css.Base, dock && css.BaseDock, className)}
display="InlineFlex"
className={classNames(css.Base, className)}
direction="Row"
{...props}
ref={ref}

View file

@ -25,9 +25,12 @@ export function SearchInput({
};
return (
// Deep input tone + pill shape — the composer-field look, so the
// search reads as part of the Vojo window rather than a stock field.
<Input
ref={inputRef}
variant="SurfaceVariant"
variant="Background"
radii="Pill"
size="400"
placeholder={allowTextCustomEmoji ? t('EmojiBoard.search_or_react') : t('EmojiBoard.search')}
maxLength={50}

View file

@ -1,11 +1,19 @@
import React, { CSSProperties } from 'react';
import { Badge, Box, Text } from 'folds';
import React from 'react';
import { Box } from 'folds';
import { useTranslation } from 'react-i18next';
import { EmojiBoardTab } from '../types';
import * as css from './styles.css';
const styles: CSSProperties = {
cursor: 'pointer',
};
// Window tabs (Эмодзи / Стикеры) — Vojo segment vocabulary (violet dot +
// weight bump on the active one), replacing the old folds Badge pills.
function Tab({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
return (
<button type="button" className={css.Tab} aria-pressed={active} onClick={onClick}>
<span aria-hidden className={css.TabDot} />
{label}
</button>
);
}
export function EmojiBoardTabs({
tab,
@ -17,30 +25,16 @@ export function EmojiBoardTabs({
const { t } = useTranslation();
return (
<Box gap="100">
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
size="500"
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
>
<Text as="span" size="L400">
{t('EmojiBoard.sticker')}
</Text>
</Badge>
<Badge
style={styles}
as="button"
variant="Secondary"
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
size="500"
<Tab
active={tab === EmojiBoardTab.Emoji}
label={t('EmojiBoard.emoji')}
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
>
<Text as="span" size="L400">
{t('EmojiBoard.emoji')}
</Text>
</Badge>
/>
<Tab
active={tab === EmojiBoardTab.Sticker}
label={t('EmojiBoard.sticker')}
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
/>
</Box>
);
}

View file

@ -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,
{

View file

@ -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',
});

View file

@ -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 (
<Box
className={classNames(css.EventReaders, className)}
direction="Column"
{...props}
ref={ref}
>
<Header className={css.Header} variant="Surface" size="600">
<Box grow="Yes">
<Text size="H3">Seen by</Text>
</Box>
<IconButton size="300" onClick={requestClose}>
<Icon src={Icons.Cross} />
</IconButton>
</Header>
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{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 (
<MenuItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
openProfile(
room.roomId,
space?.roomId,
readerId,
getMouseEventCords(event.nativeEvent),
'Bottom'
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T400" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Box>
);
}
);
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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<div
className={css.DialogCard}
role="dialog"
aria-modal="true"
aria-label={t('Room.seen_by')}
>
<header className={css.Header}>
<span className={css.HeaderBadge} aria-hidden="true">
<Icon src={Icons.CheckTwice} />
</span>
<div className={css.HeaderText}>
<span className={css.HeaderTitle}>{t('Room.seen_by')}</span>
<span className={css.HeaderSubtitle}>
{t('Room.seen_by_count', { count: latestEventReaders.length })}
</span>
</div>
<IconButton
size="300"
variant="Surface"
radii="300"
onClick={requestClose}
aria-label={t('Room.close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</header>
{latestEventReaders.length === 0 ? (
<div className={css.EmptyState}>{t('Room.seen_by_empty')}</div>
) : (
<Scroll
className={css.BodyScroll}
variant="Surface"
direction="Vertical"
size="300"
hideTrack
visibility="Hover"
>
<div className={css.Body}>
{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 (
<button
key={readerId}
type="button"
className={css.ReaderRow}
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
const cords = getMouseEventCords(event.nativeEvent);
requestClose();
openProfile(room.roomId, space?.roomId, readerId, cords, 'Bottom');
}}
>
<Avatar size="300">
<UserAvatar
userId={readerId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
/>
</Avatar>
<span className={css.ReaderText}>
<span className={css.ReaderName}>{name}</span>
{typeof receiptTs === 'number' && receiptTs > 0 && (
<span className={css.ReaderTime}>{formatReadTime(receiptTs)}</span>
)}
</span>
</button>
);
})}
</div>
</Scroll>
)}
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -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<undefined, MatrixError, []>(
@ -66,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
size="500"
>
<Box grow="Yes">
<Text size="H4">Leave Space</Text>
<Text size="H4">{t('Room.leave_space_title')}</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300">
<Icon src={Icons.Cross} />
@ -74,10 +76,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="200">
<Text priority="400">Are you sure you want to leave this space?</Text>
<Text priority="400">{t('Room.leave_space_confirm')}</Text>
{leaveState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
Failed to leave space! {leaveState.error.message}
{t('Room.leave_space_error', { error: leaveState.error.message })}
</Text>
)}
</Box>
@ -96,7 +98,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
}
>
<Text size="B400">
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
{leaveState.status === AsyncStatus.Loading ? t('Room.leaving') : t('Room.leave')}
</Text>
</Button>
</Box>

View file

@ -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,
});

View file

@ -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<void, Error, []>(
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 (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => {
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);
},
}}
>
<div
className={css.DialogCard}
role="dialog"
aria-modal="true"
aria-label={t('Settings.logout')}
>
<header className={css.Header}>
<span className={css.HeaderBadge} aria-hidden="true">
<Icon src={Icons.Power} />
</span>
<div className={css.HeaderText}>
<span className={css.HeaderTitle}>{t('Settings.logout')}</span>
<span className={css.HeaderSubtitle}>{t('Settings.logout_confirm')}</span>
</div>
<IconButton
size="300"
variant="Surface"
radii="300"
onClick={handleClose}
disabled={ongoingLogout}
aria-label={t('Settings.cancel')}
>
<Icon src={Icons.Cross} />
</IconButton>
</header>
<div className={css.Body}>
{hasEncryptedRoom &&
(crossSigningActive ? (
verificationStatus === VerificationStatus.Unverified && (
<InfoCard
variant="Critical"
title={t('Settings.logout_unverified_title')}
description={t('Settings.logout_unverified_desc')}
/>
)
) : (
<InfoCard
variant="Critical"
title={t('Settings.logout_alert_title')}
description={t('Settings.logout_alert_desc')}
/>
))}
{logoutState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T300">
{t('Settings.logout_failed', { message: logoutState.error.message })}
</Text>
)}
<Box className={css.ButtonRow}>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={handleClose}
disabled={ongoingLogout}
>
<Text size="B300">{t('Settings.cancel')}</Text>
</Button>
<Button
size="300"
variant="Critical"
radii="300"
onClick={logout}
disabled={ongoingLogout}
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="100" />}
>
<Text size="B300">{t('Settings.logout')}</Text>
</Button>
</Box>
</div>
</div>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}

View file

@ -0,0 +1 @@
export * from './LogoutDialog';

View file

@ -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.

View file

@ -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 = <div className={css.EditContent}>{children}</div>;
} 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 = (

View file

@ -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 `<Avatar>` 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({

View file

@ -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 (
<Avatar size="300" style={{ width: CHANNEL_AVATAR_PX, height: CHANNEL_AVATAR_PX }}>
// 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.
<Avatar
size="300"
style={{
width: `var(${CHANNEL_AVATAR_SIZE_VAR})`,
height: `var(${CHANNEL_AVATAR_SIZE_VAR})`,
}}
>
<UserAvatar
userId={senderId}
src={avatarUrl}

View file

@ -1,6 +1,7 @@
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX } from '../../../styles/horseshoe';
const SpacingVar = createVar();
const SpacingVariant = styleVariants({
@ -53,10 +54,36 @@ const highlightAnime = keyframes({
backgroundColor: color.Primary.Container,
},
});
// Jump-to-message highlight — a clean FULL-WIDTH band: flat rectangle
// (overrides the base's rounded-right radius, which read as «square left /
// rounded right» — wrong shape for a row spotlight) that bleeds through the
// timeline band's horizontal gutters out to the screen edge on native and to
// the centred band's edges on web. The bleed is transparent borders +
// cancelling negative margins: border and margin sum to zero per side, so
// the row's content layout doesn't shift when the highlight engages/clears,
// while the animated background paints under the transparent borders
// (default border-box clip). Gutter widths mirror BubbleTimelineBand
// (RoomTimeline.css.ts): VOJO_HORSESHOE_GAP_PX native, 40px ≥600px — keep
// in sync with that media query.
const HighlightGutterMobile = toRem(VOJO_HORSESHOE_GAP_PX);
const HighlightGutterDesktop = toRem(40);
const HighlightVariant = styleVariants({
true: {
animation: `${highlightAnime} 2000ms ease-in-out`,
animationIterationCount: 'infinite',
borderRadius: 0,
borderLeft: `${HighlightGutterMobile} solid transparent`,
borderRight: `${HighlightGutterMobile} solid transparent`,
marginLeft: `calc(-1 * ${HighlightGutterMobile})`,
marginRight: `calc(-1 * ${HighlightGutterMobile})`,
'@media': {
'screen and (min-width: 600px)': {
borderLeft: `${HighlightGutterDesktop} solid transparent`,
borderRight: `${HighlightGutterDesktop} solid transparent`,
marginLeft: `calc(-1 * ${HighlightGutterDesktop})`,
marginRight: `calc(-1 * ${HighlightGutterDesktop})`,
},
},
},
});

View file

@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai';
import { Box, Icon, IconButton, Icons } from 'folds';
import {
curtainPinnedByTabAtom,
mobileHorseshoeActiveAtom,
mobileHorseshoeElevateHeaderAtom,
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { Segment } from '../stream-header/Segment';
@ -89,42 +89,37 @@ export function MobileTabsPagerHeader({
// the «curtain-overlay invariants» comment in style.css.ts on
// pagerStaticHeader for the bg / z-order contract.
//
// Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the
// MobileSettings / ChannelsWorkspace container paints
// `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the
// entire pane to drive the carve cut-out the moment `expandedPx > 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 (
<div

View file

@ -86,28 +86,31 @@ export const pagerRoot = style({
// the tabs visually slide with the curtain. See git history for the
// user-feedback trail.
//
// Conditional z-elevation (horseshoe-active override, suppressed by pin):
// Conditional z-elevation (void-carve sheets only, suppressed by pin):
//
// When a horseshoe sheet (Settings or workspace switcher) is
// geometrically active — i.e. `expandedPx > 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

View file

@ -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<Listener>();
export const subscribeExternalSwipe = (listener: Listener): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
export const emitExternalSwipe = (touch: ExternalSwipeTouch): void => {
listeners.forEach((listener) => listener(touch));
};

View file

@ -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.

View file

@ -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<HTMLFormElement> = (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({
<Text size="H4">{title}</Text>
<Text>{message}</Text>
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
Email
{t('Auth.uia_email_label')}
</Text>
<Input
name="retryEmailInput"
@ -53,12 +55,12 @@ function EmailErrorDialog({
</Box>
<Button variant="Primary" type="submit">
<Text as="span" size="B400">
Send Verification Email
{t('Auth.uia_email_send_button')}
</Text>
</Button>
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
<Text as="span" size="B400">
Cancel
{t('Auth.uia_email_cancel')}
</Text>
</Button>
</Box>
@ -80,6 +82,7 @@ export function EmailStageDialog({
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
requestEmailToken: RequestEmailTokenCallback;
}) {
const { t } = useTranslation();
const { errorCode, error, session } = stageData;
const handleSubmit = useCallback(
@ -115,7 +118,7 @@ export function EmailStageDialog({
return (
<Box direction="Column" alignItems="Center" gap="400">
<Spinner variant="Secondary" size="600" />
<Text style={{ color: color.Secondary.Main }}>Sending verification email...</Text>
<Text style={{ color: color.Secondary.Main }}>{t('Auth.uia_email_sending')}</Text>
</Box>
);
}
@ -123,11 +126,11 @@ export function EmailStageDialog({
if (emailTokenState.status === AsyncStatus.Error) {
return (
<EmailErrorDialog
title={emailTokenState.error.errcode ?? 'Verify Email'}
title={emailTokenState.error.errcode ?? t('Auth.uia_email_verify_title')}
message={
emailTokenState.error?.data?.error ??
emailTokenState.error.message ??
'Failed to send verification Email request.'
t('Auth.uia_email_send_error')
}
onRetry={handleEmailSubmit}
onCancel={onCancel}
@ -140,8 +143,8 @@ export function EmailStageDialog({
<Dialog>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box direction="Column" gap="100">
<Text size="H4">Verification Request Sent</Text>
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
<Text size="H4">{t('Auth.uia_email_sent_title')}</Text>
<Text>{t('Auth.uia_email_sent_message', { email: emailTokenState.data.email })}</Text>
{errorCode && (
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
@ -149,7 +152,7 @@ export function EmailStageDialog({
</Box>
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
<Text as="span" size="B400">
Continue
{t('Auth.uia_email_continue')}
</Text>
</Button>
</Box>
@ -160,8 +163,8 @@ export function EmailStageDialog({
if (!email) {
return (
<EmailErrorDialog
title="Provide Email"
message="Please provide email to send verification request."
title={t('Auth.uia_email_provide_title')}
message={t('Auth.uia_email_provide_message')}
onRetry={handleEmailSubmit}
onCancel={onCancel}
/>

View file

@ -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,
});

View file

@ -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) => (
<Box className={css.UploadBoardBase} {...props} ref={ref}>
<Box className={css.UploadBoardContainer} justifyContent="End">
<Box className={classNames(css.UploadBoard)} direction="Column">
<Box grow="Yes" direction="Column">
{children}
</Box>
<Box direction="Column" shrink="No">
{header}
</Box>
</Box>
</Box>
</Box>
));
export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
type UploadBoardHeaderProps = {
open: boolean;
onToggle: () => void;
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
onCancel: (uploads: Upload[]) => void;
onSend: (uploads: UploadSuccess[]) => Promise<void>;
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
};
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 (
<Header size="400">
<Box
as="button"
style={{ cursor: 'pointer' }}
onClick={onToggle}
className={css.UploadBoardHeaderContent}
alignItems="Center"
grow="Yes"
gap="100"
>
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
<Text size="H6">Files</Text>
</Box>
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
{isSuccess && (
<Chip
as="button"
onClick={handleSend}
variant="Primary"
radii="Pill"
outlined
after={<Icon src={Icons.Send} size="50" filled />}
>
<Text size="B300">Send</Text>
</Chip>
)}
{isError && !open && (
<Badge variant="Critical" fill="Solid" radii="300">
<Text size="L400">Upload Failed</Text>
</Badge>
)}
{!isSuccess && !isError && !open && (
<>
<Badge variant="Secondary" fill="Solid" radii="Pill">
<Text size="L400">{Math.round(percent(0, progress.total, progress.loaded))}%</Text>
</Badge>
<Spinner variant="Secondary" size="200" />
</>
)}
{!isSuccess && open && (
<Chip
as="button"
onClick={handleCancel}
variant="SurfaceVariant"
radii="Pill"
after={<Icon src={Icons.Cross} size="50" />}
>
<Text size="B300">{uploads.length === 1 ? 'Remove' : 'Remove All'}</Text>
</Chip>
)}
</Box>
</Header>
);
}
export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
<Box
className={classNames(css.UploadBoardContent, className)}
direction="Column"
gap="200"
{...props}
ref={ref}
>
{children}
</Box>
));

View file

@ -1 +0,0 @@
export * from './UploadBoard';

View file

@ -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 (
<img
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
alt={originalFile.name}
src={fileUrl}
/>
);
}
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
<video
style={{
objectFit: 'contain',
width: '100%',
height: toRem(152),
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
}}
src={fileUrl}
/>
);
}
type MediaPreviewProps = {
fileItem: TUploadItem;
onSpoiler: (marked: boolean) => void;
children: ReactNode;
};
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
const { originalFile, metadata } = fileItem;
const fileUrl = useObjectURL(originalFile);
return fileUrl ? (
<Box
style={{
borderRadius: config.radii.R300,
overflow: 'hidden',
backgroundColor: 'black',
position: 'relative',
}}
>
{children}
<Box
justifyContent="End"
style={{
position: 'absolute',
bottom: config.space.S100,
left: config.space.S100,
right: config.space.S100,
}}
>
<Chip
variant={metadata.markedAsSpoiler ? 'Warning' : 'Secondary'}
fill="Soft"
radii="Pill"
aria-pressed={metadata.markedAsSpoiler}
before={<Icon src={Icons.EyeBlind} size="50" />}
onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
>
<Text size="B300">Spoiler</Text>
</Chip>
</Box>
</Box>
) : null;
}
type UploadCardRendererProps = {
isEncrypted?: boolean;
fileItem: TUploadItem;
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
onRemove: (file: TUploadContent) => void;
onComplete?: (upload: UploadSuccess) => void;
};
export function UploadCardRenderer({
isEncrypted,
fileItem,
setMetadata,
onRemove,
onComplete,
}: UploadCardRendererProps) {
const mx = useMatrixClient();
const mediaConfig = useMediaConfig();
const allowSize = mediaConfig['m.upload.size'] || Infinity;
const uploadAtom = roomUploadAtomFamily(fileItem.file);
const { metadata } = fileItem;
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
const { file } = upload;
const fileSizeExceeded = file.size >= allowSize;
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
startUpload();
}
const handleSpoiler = (marked: boolean) => {
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
};
const removeUpload = () => {
cancelUpload();
onRemove(file);
};
useEffect(() => {
if (upload.status === UploadStatus.Success) {
onComplete?.(upload);
}
}, [upload, onComplete]);
return (
<UploadCard
radii="300"
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
after={
<>
{upload.status === UploadStatus.Error && (
<Chip
as="button"
onClick={startUpload}
aria-label="Retry Upload"
variant="Critical"
radii="Pill"
outlined
>
<Text size="B300">Retry</Text>
</Chip>
)}
<IconButton
onClick={removeUpload}
aria-label="Cancel Upload"
variant="SurfaceVariant"
radii="Pill"
size="300"
>
<Icon src={Icons.Cross} size="200" />
</IconButton>
</>
}
bottom={
<>
{fileItem.originalFile.type.startsWith('image') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewImage fileItem={fileItem} />
</MediaPreview>
)}
{fileItem.originalFile.type.startsWith('video') && (
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
<PreviewVideo fileItem={fileItem} />
</MediaPreview>
)}
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Loading && (
<UploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
)}
{upload.status === UploadStatus.Error && (
<UploadCardError>
<Text size="T200">{upload.error.message}</Text>
</UploadCardError>
)}
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
<UploadCardError>
<Text size="T200">
The file size exceeds the limit. Maximum allowed size is{' '}
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
<b>{bytesToSize(file.size)}</b>.
</Text>
</UploadCardError>
)}
</>
}
>
<Text size="H6" truncate>
{file.name}
</Text>
{upload.status === UploadStatus.Success && (
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
)}
</UploadCard>
);
}

View file

@ -1,3 +1,2 @@
export * from './UploadCard';
export * from './UploadCardRenderer';
export * from './CompactUploadCardRenderer';

View file

@ -2,6 +2,7 @@ import { Chip, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } fr
import React, { MouseEventHandler, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { isKeyHotkey } from 'is-hotkey';
import { useTranslation } from 'react-i18next';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { PowerColorBadge, PowerIcon } from '../power';
import { getPowerTagIconSrc } from '../../hooks/useMemberPowerTag';
@ -10,17 +11,19 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { stopPropagation } from '../../utils/keyboard';
import { useRoom } from '../../hooks/useRoom';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
import { SpaceSettingsPage } from '../../state/spaceSettings';
import { RoomSettingsPage } from '../../state/roomSettings';
// «Manage Powers» only exists for SPACES — the room-settings Permissions
// page was removed with the room-settings redesign (power-level editing is
// Matrix-protocol surface end users never needed in a chat). For ordinary
// rooms the creator chip is a plain, non-interactive label.
export function CreatorChip() {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const room = useRoom();
const space = useSpaceOptionally();
const openRoomSettings = useOpenRoomSettings();
const openSpaceSettings = useOpenSpaceSettings();
const [cords, setCords] = useState<RectCords>();
@ -33,6 +36,31 @@ export function CreatorChip() {
const close = () => setCords(undefined);
const isSpace = room.isSpaceRoom();
const chip = (
<Chip
// Outside spaces the chip is a plain badge — render it as a <span>
// so keyboard users don't tab onto a button that does nothing.
as={isSpace ? 'button' : 'span'}
variant="Success"
outlined
radii="Pill"
before={
cords ? <Icon size="50" src={Icons.ChevronBottom} /> : <PowerColorBadge color={tag.color} />
}
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
onClick={isSpace ? open : undefined}
aria-pressed={isSpace ? !!cords : undefined}
>
<Text size="B300" truncate>
{tag.name}
</Text>
</Chip>
);
if (!isSpace) return chip;
return (
<PopOut
anchor={cords}
@ -58,44 +86,18 @@ export function CreatorChip() {
size="300"
radii="300"
onClick={() => {
if (room.isSpaceRoom()) {
openSpaceSettings(
room.roomId,
space?.roomId,
SpaceSettingsPage.PermissionsPage
);
} else {
openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
}
openSpaceSettings(room.roomId, space?.roomId, SpaceSettingsPage.PermissionsPage);
close();
}}
>
<Text size="B300">Manage Powers</Text>
<Text size="B300">{t('User.manage_powers')}</Text>
</MenuItem>
</div>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Success"
outlined
radii="Pill"
before={
cords ? (
<Icon size="50" src={Icons.ChevronBottom} />
) : (
<PowerColorBadge color={tag.color} />
)
}
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
onClick={open}
aria-pressed={!!cords}
>
<Text size="B300" truncate>
{tag.name}
</Text>
</Chip>
{chip}
</PopOut>
);
}

View file

@ -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 }) {
);
})}
</Box>
<Line size="300" />
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
if (room.isSpaceRoom()) {
openSpaceSettings(
room.roomId,
space?.roomId,
SpaceSettingsPage.PermissionsPage
);
} else {
openRoomSettings(
room.roomId,
space?.roomId,
RoomSettingsPage.PermissionsPage
);
}
close();
}}
>
<Text size="B300">Manage Powers</Text>
</MenuItem>
</div>
{/* «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() && (
<>
<Line size="300" />
<div style={{ padding: config.space.S100 }}>
<MenuItem
variant="Surface"
fill="None"
size="300"
radii="300"
onClick={() => {
openSpaceSettings(
room.roomId,
space?.roomId,
SpaceSettingsPage.PermissionsPage
);
close();
}}
>
<Text size="B300">{t('User.manage_powers')}</Text>
</MenuItem>
</div>
</>
)}
</Menu>
</FocusTrap>
}

View file

@ -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 (
<PopOut
@ -477,4 +484,3 @@ export function MutualRoomsRow({ userId }: { userId: string }) {
</PopOut>
);
}

View file

@ -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) {
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Kicked User</Text>
<Text size="L400">{t('User.moderation_kicked_user')}</Text>
{time && date && (
<Text size="T200">
{date} {time}
@ -32,16 +34,16 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
<Box direction="Column">
{kickedBy && (
<Text size="T200">
Kicked by: <b>{kickedBy}</b>
{t('User.moderation_kicked_by')} <b>{kickedBy}</b>
</Text>
)}
<Text size="T200">
{reason ? (
<>
Reason: <b>{reason}</b>
{t('User.moderation_reason_label')} <b>{reason}</b>
</>
) : (
<i>No Reason Provided.</i>
<i>{t('User.moderation_no_reason')}</i>
)}
</Text>
</Box>
@ -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
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Banned User</Text>
<Text size="L400">{t('User.moderation_banned_user')}</Text>
{time && date && (
<Text size="T200">
{date} {time}
@ -87,16 +90,16 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
<Box direction="Column">
{bannedBy && (
<Text size="T200">
Banned by: <b>{bannedBy}</b>
{t('User.moderation_banned_by')} <b>{bannedBy}</b>
</Text>
)}
<Text size="T200">
{reason ? (
<>
Reason: <b>{reason}</b>
{t('User.moderation_reason_label')} <b>{reason}</b>
</>
) : (
<i>No Reason Provided.</i>
<i>{t('User.moderation_no_reason')}</i>
)}
</Text>
</Box>
@ -114,7 +117,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
disabled={banning}
>
<Text size="B300">Unban</Text>
<Text size="B300">{t('User.moderation_unban')}</Text>
</Button>
)}
</Box>
@ -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
<SettingTile>
<Box direction="Column" gap="200">
<Box gap="200" justifyContent="SpaceBetween">
<Text size="L400">Invited User</Text>
<Text size="L400">{t('User.moderation_invited_user')}</Text>
{time && date && (
<Text size="T200">
{date} {time}
@ -159,16 +163,16 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
<Box direction="Column">
{invitedBy && (
<Text size="T200">
Invited by: <b>{invitedBy}</b>
{t('User.moderation_invited_by')} <b>{invitedBy}</b>
</Text>
)}
<Text size="T200">
{reason ? (
<>
Reason: <b>{reason}</b>
{t('User.moderation_reason_label')} <b>{reason}</b>
</>
) : (
<i>No Reason Provided.</i>
<i>{t('User.moderation_no_reason')}</i>
)}
</Text>
</Box>
@ -188,7 +192,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
disabled={kicking}
>
<Text size="B300">Cancel Invite</Text>
<Text size="B300">{t('User.moderation_cancel_invite')}</Text>
</Button>
)}
</Box>
@ -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<HTMLInputElement>(null);
@ -245,10 +250,10 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
<Box direction="Column" gap="400">
<Box direction="Column" gap="200">
<Box grow="Yes" direction="Column" gap="100">
<Text size="L400">Moderation</Text>
<Text size="L400">{t('User.moderation_title')}</Text>
<Input
ref={reasonInputRef}
placeholder="Reason"
placeholder={t('User.moderation_reason')}
size="300"
variant="Background"
radii="300"
@ -288,7 +293,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
onClick={invite}
disabled={disabled}
>
<Text size="B300">Invite</Text>
<Text size="B300">{t('User.moderation_invite')}</Text>
</Button>
)}
{canKick && (
@ -308,7 +313,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
onClick={kick}
disabled={disabled}
>
<Text size="B300">Kick</Text>
<Text size="B300">{t('User.moderation_kick')}</Text>
</Button>
)}
{canBan && (
@ -328,7 +333,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
onClick={ban}
disabled={disabled}
>
<Text size="B300">Ban</Text>
<Text size="B300">{t('User.moderation_ban')}</Text>
</Button>
)}
</Box>

View file

@ -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 (
<Box direction="Column" gap="500" className={css.CardRoot}>
{/* 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) && (
<div className={css.CardBackAnchor}>
<IconButton
size="300"
radii="Pill"
fill="None"
onClick={onBack ?? onClose}
aria-label={onBack ? t('User.back') : t('Room.close')}
>
<Icon size="200" src={onBack ? Icons.ArrowLeft : Icons.Cross} />
</IconButton>
</div>
)}
{/* 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
/>
<div className={css.InfoSection}>
{/* 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). */}
<IdRow userId={userId} />
<ServerRow userId={userId} />
{userId !== myUserId && <RoleRow roleLabel={roleLabel} emphasis={creator} />}

View file

@ -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),
});

View file

@ -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<HTMLDivElement, AiChatMenuProps>(
({ 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<HTMLDivElement, AiChatMenuProps>(
ariaHasPopup
trailing={<RowChevron />}
/>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<ActionRow
icon={Icons.Bell}
label={t('Room.notifications')}
onClick={handleOpen}
ariaPressed={opened}
ariaHasPopup
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
/>
)}
</RoomNotificationModeSwitcher>
<NotificationsActionRow roomId={room.roomId} />
<ActionRow
icon={Icons.ShieldLock}
label={t('Bots.privacy.menu')}
@ -93,7 +79,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
label={t('Room.leave_room')}
onClick={() => setPromptLeave(true)}
accent="critical"
ariaPressed={promptLeave}
ariaExpanded={promptLeave}
/>
</div>
</Menu>

View file

@ -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 (
<div className={css.Shell}>
<BotShellHero preset={preset} room={room} />

View file

@ -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<HTMLDivElement, BotShellMenuProps>(
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<HTMLDivElement, BotShellMenuProps>(
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
<Menu ref={ref} variant="Surface" className={css.PopoutMenu}>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
<div className={css.PopoutGroup}>
<ActionRow
icon={Icons.Message}
label={t('Bots.show_chat')}
onClick={handleShowChat}
size="300"
after={<Icon size="100" src={Icons.Message} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.show_chat')}
</Text>
</MenuItem>
<MenuItem
trailing={<RowChevron />}
/>
<ActionRow
icon={Icons.CheckTwice}
label={t('Room.mark_as_read')}
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
/>
<NotificationsActionRow roomId={room.roomId} />
</div>
<ActionSectionLine />
<div className={css.PopoutGroup}>
<ActionRow
icon={Icons.ArrowGoLeft}
label={t('Room.leave_room')}
onClick={() => setPromptLeave(true)}
accent="critical"
ariaExpanded={promptLeave}
/>
</div>
</Menu>
);
}

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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<HTMLDivElement>(null);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
@ -140,7 +142,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onClick={handleSpotlightClick}
>
<Text size="B300" truncate>
{spotlight ? 'Grid View' : 'Spotlight View'}
{spotlight ? t('Call.view_grid') : t('Call.view_spotlight')}
</Text>
</MenuItem>
<MenuItem
@ -150,7 +152,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onClick={handleReactionsClick}
>
<Text size="B300" truncate>
Reactions
{t('Call.reactions')}
</Text>
</MenuItem>
</>
@ -162,7 +164,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onClick={handleSettingsClick}
>
<Text size="B300" truncate>
Settings
{t('Call.settings')}
</Text>
</MenuItem>
</Box>
@ -198,7 +200,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}
disabled={exiting}
>
<Text size="B400">End</Text>
<Text size="B400">{t('Call.end_call')}</Text>
</Button>
</Box>
</Box>

View file

@ -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({
<Box grow="Yes" gap="300" alignItems="Center">
{viewMore ? (
<Text size="L400" truncate>
Collapse
{t('Call.collapse')}
</Text>
) : (
<Text size="L400" truncate>
{remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`}
{t('Call.others_count', { count: remaining })}
</Text>
)}
</Box>

View file

@ -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 (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Your homeserver does not support calling. But you can still join call started by others.
{t('Call.homeserver_no_calls')}
</Text>
);
}
@ -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 (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
Voice chats empty Be the first to hop in!
{t('Call.empty_be_first')}
</Text>
);
}
function NoPermissionMessage() {
const { t } = useTranslation();
return (
<Text style={{ margin: 'auto' }} size="L400" align="Center">
You don&#39;t have permission to join!
{t('Call.no_permission_to_join')}
</Text>
);
}
function AlreadyInCallMessage() {
const { t } = useTranslation();
return (
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
Already in another call End the current call to join!
{t('Call.already_in_other_call')}
</Text>
);
}
function CallPrescreen() {
const { t } = useTranslation();
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
@ -86,11 +92,11 @@ function CallPrescreen() {
{hasParticipant && (
<Header size="300">
<Box grow="Yes" alignItems="Center">
<Text size="L400">Participant</Text>
<Text size="L400">{t('Call.participant')}</Text>
</Box>
<Badge variant="Critical" fill="Solid" size="400">
<Text as="span" size="L400" truncate>
{callMembers.length} Live
{t('Call.live_count', { count: callMembers.length })}
</Text>
</Badge>
</Header>

View file

@ -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) {
)
}
>
<Text size="B400">Join</Text>
<Text size="B400">{t('Call.join')}</Text>
</Button>
</Box>
</SequenceCard>

View file

@ -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,
});

View file

@ -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 (
<button type="button" className={css.NavItem} aria-pressed={active} onClick={onClick}>
<Icon className={css.NavItemIcon} src={icon} size="100" filled={active} />
<span className={MenuRowIcon({ tint: tint ?? 'neutral' })}>
<Icon src={icon} size="100" filled={active} />
</span>
<Text className={css.NavItemLabel} as="span" size="T300" truncate>
{label}
</Text>
<Icon className={css.NavItemChevron} src={Icons.ChevronRight} size="100" />
</button>
);
}

View file

@ -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')}
>
<Icon src={Icons.ChevronTop} size="300" />
</IconButton>
@ -535,7 +537,7 @@ export function Lobby() {
radii="Pill"
before={<Spinner variant="Secondary" fill="Soft" size="100" />}
>
<Text size="L400">Reordering</Text>
<Text size="L400">{t('Room.lobby_reordering')}</Text>
</Chip>
</Box>
)}

View file

@ -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<HTMLDivElement, LobbyMenuProps>(
({ powerLevels, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const space = useSpace();
const creators = useRoomCreators(space);
@ -87,7 +89,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
{t('Room.invite')}
</Text>
</MenuItem>
<MenuItem
@ -97,7 +99,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Space Settings
{t('Room.lobby_space_settings')}
</Text>
</MenuItem>
</Box>
@ -116,7 +118,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Space
{t('Room.lobby_leave_space')}
</Text>
</MenuItem>
{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={
<Tooltip>
<Text>Members</Text>
<Text>{t('Room.members')}</Text>
</Tooltip>
}
>
@ -234,7 +237,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
<Text>{t('Room.more_options')}</Text>
</Tooltip>
}
>

View file

@ -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<Room, MatrixError, []>(
@ -91,7 +93,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
onClick={join}
disabled={!canJoin}
>
<Text size="B300">Join</Text>
<Text size="B300">{t('Room.lobby_join')}</Text>
</Chip>
</Box>
);
@ -127,6 +129,7 @@ type RoomProfileErrorProps = {
via?: string[];
};
function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
const { t } = useTranslation();
return (
<Box grow="Yes" gap="300">
<Avatar>
@ -146,12 +149,12 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
<Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
<Box gap="200" alignItems="Center">
<Text size="H5" truncate>
Unknown
{t('Room.lobby_unknown')}
</Text>
{suggested && (
<Box shrink="No" alignItems="Center">
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
<Text size="L400">{t('Room.lobby_suggested')}</Text>
</Badge>
</Box>
)}
@ -159,7 +162,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
<Box gap="200" alignItems="Center">
{inaccessibleRoom ? (
<Badge variant="Secondary" fill="Soft" radii="300" size="500">
<Text size="L400">Inaccessible</Text>
<Text size="L400">{t('Room.lobby_inaccessible')}</Text>
</Badge>
) : (
<Text size="T200" truncate>
@ -195,6 +198,7 @@ function RoomProfile({
joinRule,
options,
}: RoomProfileProps) {
const { t } = useTranslation();
return (
<Box grow="Yes" gap="300">
<Avatar>
@ -213,7 +217,7 @@ function RoomProfile({
{suggested && (
<Box shrink="No" alignItems="Center">
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
<Text size="L400">{t('Room.lobby_suggested')}</Text>
</Badge>
</Box>
)}
@ -221,7 +225,12 @@ function RoomProfile({
<Box gap="200" alignItems="Center">
{memberCount && (
<Box shrink="No" gap="200">
<Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
<Text size="T200" priority="300">
{t('Room.lobby_members_count', {
count: memberCount,
formattedCount: millify(memberCount),
})}
</Text>
</Box>
)}
{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')}
>
<Icon size="50" src={Icons.ArrowRight} />
</Chip>

View file

@ -60,6 +60,7 @@ type InaccessibleSpaceProfileProps = {
suggested?: boolean;
};
function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
const { t } = useTranslation();
return (
<Chip
as="span"
@ -72,7 +73,7 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
roomId={roomId}
renderFallback={() => (
<Text as="span" size="H6">
U
{nameInitials(t('Room.lobby_unknown'))}
</Text>
)}
/>
@ -81,15 +82,15 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
Unknown
{t('Room.lobby_unknown')}
</Text>
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
<Text size="L400">Inaccessible</Text>
<Text size="L400">{t('Room.lobby_inaccessible')}</Text>
</Badge>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
<Text size="L400">{t('Room.lobby_suggested')}</Text>
</Badge>
)}
</Box>
@ -111,6 +112,7 @@ function UnjoinedSpaceProfile({
avatarUrl,
suggested,
}: UnjoinedSpaceProfileProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
@ -145,11 +147,11 @@ function UnjoinedSpaceProfile({
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
{name || 'Unknown'}
{name || t('Room.lobby_unknown')}
</Text>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
<Text size="L400">{t('Room.lobby_suggested')}</Text>
</Badge>
)}
{joinState.status === AsyncStatus.Error && (
@ -182,6 +184,7 @@ function SpaceProfile({
categoryId,
handleClose,
}: SpaceProfileProps) {
const { t } = useTranslation();
return (
<Chip
data-category-id={categoryId}
@ -211,7 +214,7 @@ function SpaceProfile({
</Text>
{suggested && (
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
<Text size="L400">Suggested</Text>
<Text size="L400">{t('Room.lobby_suggested')}</Text>
</Badge>
)}
</Box>
@ -225,6 +228,7 @@ type RootSpaceProfileProps = {
handleClose?: MouseEventHandler<HTMLButtonElement>;
};
function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
const { t } = useTranslation();
return (
<Chip
data-category-id={categoryId}
@ -236,7 +240,7 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
>
<Box alignItems="Center" gap="200">
<Text size="H4" truncate>
Rooms
{t('Room.lobby_rooms')}
</Text>
</Box>
</Chip>

View file

@ -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')}
>
<Icon src={Icons.ChevronTop} size="300" />
</IconButton>

View file

@ -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<RoomSettingsPage | undefined>(() => {
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 (
<PageRoot
nav={
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
<PageNav size="300">
<PageNavHeader outlined={false}>
<Box grow="Yes" gap="300" alignItems="Center">
<Avatar size="300" radii="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={roomName}
renderFallback={() => (
<RoomIcon
size="50"
roomType={room.getType()}
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
filled
/>
)}
/>
</Avatar>
<Box direction="Column" grow="Yes">
<SettingsNavEyebrow>{t('RoomSettings.settings')}</SettingsNavEyebrow>
<Text size="H4" truncate>
{roomName}
</Text>
</Box>
</Box>
<Box shrink="No">
{screenSize === ScreenSize.Mobile && (
<IconButton onClick={requestClose} variant="Background">
<Icon src={Icons.Cross} />
</IconButton>
)}
</Box>
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
<SettingsNavSection>{t('RoomSettings.sections')}</SettingsNavSection>
{menuItems.map((item) => (
<SettingsNavItem
key={item.name}
icon={item.icon}
label={item.name}
active={activePage === item.page}
onClick={() => setActivePage(item.page)}
/>
))}
</div>
</PageNavContent>
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
{t('Room.room_settings')}
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<RoomProfile permissions={permissions} />
<SettingsSection label={t('RoomSettings.options')}>
<RoomJoinRules permissions={permissions} />
<RoomHistoryVisibility permissions={permissions} />
<RoomEncryption permissions={permissions} />
<RoomVoiceMessages permissions={permissions} />
</SettingsSection>
</Box>
</PageNav>
)
}
>
{activePage === RoomSettingsPage.GeneralPage && (
<General requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.MembersPage && (
<Members requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.PermissionsPage && (
<Permissions requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === RoomSettingsPage.DeveloperToolsPage && (
<DeveloperTools requestClose={handlePageRequestClose} />
)}
</PageRoot>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -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 (
<Modal500 requestClose={closeSettings}>
<Modal500 narrow requestClose={closeSettings}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<RoomSettings initialPage={page} requestClose={closeSettings} />
<RoomSettings requestClose={closeSettings} />
</RoomProvider>
</SpaceProvider>
</Modal500>

View file

@ -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 (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
{t('RoomSettings.general')}
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<RoomProfile permissions={permissions} />
<SettingsSection label={t('RoomSettings.options')}>
<RoomJoinRules permissions={permissions} />
<RoomHistoryVisibility permissions={permissions} />
<RoomEncryption permissions={permissions} />
<RoomVoiceMessages permissions={permissions} />
<RoomPublish permissions={permissions} />
</SettingsSection>
<Box direction="Column" gap="200">
<Text as="span" className={SectionLabel}>
{t('RoomSettings.addresses')}
</Text>
<RoomPublishedAddresses permissions={permissions} />
<RoomLocalAddresses permissions={permissions} />
</Box>
<Box direction="Column" gap="200">
<Text as="span" className={SectionLabel}>
{t('RoomSettings.advanced_options')}
</Text>
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
</Box>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -1 +0,0 @@
export * from './General';

View file

@ -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 <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
}
return (
<Page>
<PageHeader outlined={false}>
<Box grow="Yes" gap="200">
<Box grow="Yes" alignItems="Center" gap="200">
<Text size="H3" truncate>
{t('RoomSettings.permissions')}
</Text>
</Box>
<Box shrink="No">
<IconButton onClick={requestClose} variant="Surface">
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</PageHeader>
<Box grow="Yes">
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<Powers
powerLevels={powerLevels}
onEdit={canEditPowers ? handleEditPowers : undefined}
permissionGroups={permissionGroups}
/>
<PermissionGroups
canEdit={canEditPermissions}
powerLevels={powerLevels}
permissionGroups={permissionGroups}
/>
</Box>
</PageContent>
</Scroll>
</Box>
</Page>
);
}

View file

@ -1 +0,0 @@
export * from './Permissions';

View file

@ -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;
};

View file

@ -1,6 +0,0 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});

View file

@ -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,
});

View file

@ -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 = (
<button
type="button"
className={css.RemoveBtn}
onClick={handleRemove}
aria-label={t('Room.upload_cancel_label')}
>
<Icon src={Icons.Cross} size="50" />
</button>
);
if (previewUrl) {
const isVideo = originalFile.type.startsWith('video');
return (
<div
className={classNames(css.MediaTile, (failed || fileSizeExceeded) && css.TileError)}
title={file.name}
>
{isVideo ? (
// eslint-disable-next-line jsx-a11y/media-has-caption
<video
className={classNames(
css.MediaTileContent,
metadata.markedAsSpoiler && css.MediaTileSpoilered
)}
src={previewUrl}
/>
) : (
<img
className={classNames(
css.MediaTileContent,
metadata.markedAsSpoiler && css.MediaTileSpoilered
)}
src={previewUrl}
alt={file.name}
/>
)}
{loading && <div className={css.TileOverlay}>{progressPercent}%</div>}
{(failed || fileSizeExceeded) && (
<div className={css.TileOverlay}>
{failed ? (
<button
type="button"
className={css.RetryBtn}
onClick={startUpload}
aria-label={t('Room.upload_retry_label')}
>
{t('Room.upload_retry')}
</button>
) : (
<Text size="T200" align="Center">
{t('Room.upload_too_large')}
</Text>
)}
</div>
)}
{removeBtn}
{!fileSizeExceeded && (
<button
type="button"
className={css.SpoilerBtn}
onClick={handleSpoiler}
aria-pressed={metadata.markedAsSpoiler}
aria-label={t('Room.upload_spoiler')}
>
<Icon src={Icons.EyeBlind} size="50" />
</button>
)}
</div>
);
}
let meta: React.ReactNode = bytesToSize(file.size);
if (loading) meta = `${progressPercent}%`;
else if (fileSizeExceeded) meta = t('Room.upload_too_large');
else if (failed) {
meta = (
<button
type="button"
className={css.RetryBtn}
onClick={startUpload}
aria-label={t('Room.upload_retry_label')}
>
{t('Room.upload_retry')}
</button>
);
}
return (
<div
className={classNames(css.FileTile, (failed || fileSizeExceeded) && css.TileError)}
title={file.name}
>
<Icon src={getFileTypeIcon(Icons, file.type)} size="200" />
<div className={css.FileTileText}>
<span className={css.FileTileName}>{file.name}</span>
<span
className={classNames(
css.FileTileMeta,
(failed || fileSizeExceeded) && css.FileTileMetaCritical
)}
>
{meta}
</span>
</div>
{removeBtn}
</div>
);
}
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 (
<div className={css.Strip}>
{selectedFiles.map((fileItem, index) => (
<AttachmentItem
// Index keys are safe here: per-item upload state lives in the
// jotai family keyed by the FILE OBJECT, so a remount after a
// mid-list removal re-binds to the same atom. (Name+size would
// collide when the same file is attached twice.)
// eslint-disable-next-line react/no-array-index-key
key={index}
fileItem={fileItem}
// E2EE rooms upload the pre-encrypted blob — hide the filename
// from the upload endpoint, same as the old per-card renderer.
isEncrypted={!!fileItem.encInfo}
setMetadata={setMetadata}
onRemove={onRemove}
/>
))}
</div>
);
}

View file

@ -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

View file

@ -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<HTMLDivElement, RoomInputProps>(
}, [voiceDisabledBy]);
const emojiBtnRef = useRef<HTMLButtonElement>(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<EmojiBoardTab | undefined>(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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
: 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<UploadBoardImperativeHandlers>();
// 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<HTMLDivElement, RoomInputProps>(
// 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<HTMLDivElement, RoomInputProps>(
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<string, unknown> = 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<Descendant[] | null>(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<string>();
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<HTMLDivElement, RoomInputProps>(
// 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
// 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<HTMLDivElement, RoomInputProps>(
[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<HTMLDivElement, RoomInputProps>(
[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<unknown> = 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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
onSend,
editor,
replyDraft,
editDraft,
submitEdit,
sendTypingStatus,
setReplyDraft,
isMarkdown,
commands,
jotaiStore,
uploadFamilyObserverAtom,
handleSendUpload,
]);
const handleKeyDown: KeyboardEventHandler = useCallback(
@ -671,10 +1036,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
</IconButton>
);
// 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 && (
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
dock
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => 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 && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setEmojiBoardTab(undefined),
escapeDeactivates: stopPropagation,
}}
>
<EmojiBoard
tab={emojiBoardTab}
onTabChange={setEmojiBoardTab}
imagePackRooms={imagePackRooms}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
onStickerSelect={handleStickerSelect}
requestClose={() => setEmojiBoardTab(undefined)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
const emojiButton = dockEmojiBoard ? (
const emojiButton = centerEmojiBoard ? (
<IconButton
ref={emojiBtnRef}
aria-pressed={!!emojiBoardTab}
@ -873,39 +1265,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return (
<div ref={ref}>
{selectedFiles.length > 0 && (
<UploadBoard
header={
<UploadBoardHeader
open={uploadBoard}
onToggle={() => setUploadBoard(!uploadBoard)}
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
onSend={handleSendUpload}
imperativeHandlerRef={uploadBoardHandlers}
onCancel={handleCancelUpload}
/>
}
>
{uploadBoard && (
<Scroll size="300" hideTrack visibility="Hover">
<UploadBoardContent>
{Array.from(selectedFiles)
.reverse()
.map((fileItem, index) => (
<UploadCardRenderer
// eslint-disable-next-line react/no-array-index-key
key={index}
isEncrypted={!!fileItem.encInfo}
fileItem={fileItem}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
))}
</UploadBoardContent>
</Scroll>
)}
</UploadBoard>
)}
<Overlay
open={dropZoneVisible && !textOnly}
backdrop={<OverlayBackdrop />}
@ -975,7 +1334,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
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. */}
<ComposerAttachments
selectedFiles={selectedFiles}
setMetadata={handleFileMetadata}
onRemove={handleRemoveUpload}
/>
{voiceError && (
<Box
alignItems="Center"
@ -996,7 +1364,50 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Text>
</Box>
)}
{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 && (
<div>
<Box
alignItems="Center"
gap="300"
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
>
<IconButton
onClick={() => setEditDraft(undefined)}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label={t('Room.editing_cancel')}
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box direction="Row" gap="200" alignItems="Center">
<Icon
src={Icons.Pencil}
size="50"
style={{ color: color.Primary.Main, flexShrink: 0 }}
/>
<ReplyLayout
userColor={color.Primary.Main}
username={
<Text size="T300" truncate>
<b>{t('Room.editing_message')}</b>
</Text>
}
>
<Text size="T300" truncate>
{editTarget?.body ? trimReplyFromBody(editTarget.body) : ''}
</Text>
</ReplyLayout>
</Box>
</Box>
</div>
)}
{/* Reply preview hides while editing (the edit owns the send);
the reply draft itself is preserved and returns after. */}
{replyDraft && !editDraft && (
<div>
<Box
alignItems="Center"
@ -1044,7 +1455,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
>
{!textOnly && plusButton}
<Box grow="Yes" />
{!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}
</Box>

View file

@ -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<typeof TimelineFloat>;
// "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

View file

@ -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) => (
<Box
className={classNames(css.TimelineFloat({ position }), className)}
justifyContent="Center"
alignItems="Center"
gap="200"
{...props}
ref={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 && (
<Reply
@ -1830,7 +1803,6 @@ export function RoomTimeline({
collapse={collapse}
railHidden={railHidden}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
@ -1840,7 +1812,7 @@ export function RoomTimeline({
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
onEditId={mainComposerSuspended ? undefined : handleEdit}
reply={
replyEventId && (
<Reply
@ -2671,36 +2643,11 @@ export function RoomTimeline({
return eventJSX;
};
const unreadFloatShown = !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
return (
<Box grow="Yes" style={{ position: 'relative' }}>
{/* 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 && (
<TimelineFloat position="Top">
<Chip
variant="Primary"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.MessageUnread} />}
onClick={handleJumpToUnread}
>
<Text size="L400">{t('Room.jump_to_unread')}</Text>
</Chip>
<Chip
variant="SurfaceVariant"
radii="Pill"
outlined
before={<Icon size="50" src={Icons.CheckTwice} />}
onClick={handleMarkAsRead}
>
<Text size="L400">{t('Room.mark_as_read')}</Text>
</Chip>
</TimelineFloat>
)}
<div ref={scrollRef} className={css.TimelineScroll}>
<Box
direction="Column"

View file

@ -15,10 +15,11 @@ import {
// surface mirror the same curvature. The thread-drawer composer in
// `ThreadDrawer.tsx` also wraps `RoomInput` with this class
// (`${ThreadComposer} ${ChatComposer}`), so it inherits both the dark
// card chrome and the compact two-row geometry. The message-edit overlay
// and `Editor.preview.tsx` mount `CustomEditor` directly without the
// `ChatComposer` wrap, so they keep the folds-default pill R400 +
// SurfaceVariant fill. The touch-hover gate at the bottom of this file
// card chrome and the compact two-row geometry. `Editor.preview.tsx`
// mounts `CustomEditor` directly without the `ChatComposer` wrap, so it
// keeps the folds-default pill R400 + SurfaceVariant fill. (Message
// editing happens IN the composer now — the old in-timeline edit overlay
// is gone.) The touch-hover gate at the bottom of this file
// also covers `RoomTombstone` / `RoomInputPlaceholder` since they share
// the wrap, which is intentional: their action buttons benefit from the
// same Android-WebView stuck-:hover suppression.

View file

@ -4,6 +4,7 @@ import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
import { isKeyHotkey } from 'is-hotkey';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
@ -54,6 +55,7 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
};
export function RoomView({ eventId }: { eventId?: string }) {
const { t } = useTranslation();
const roomInputRef = useRef<HTMLDivElement>(null);
const roomViewRef = useRef<HTMLDivElement>(null);
const composerWrapRef = useRef<HTMLDivElement>(null);
@ -200,7 +202,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
alignItems="Center"
justifyContent="Center"
>
<Text align="Center">You do not have permission to post in this room</Text>
<Text align="Center">{t('Room.no_post_permission')}</Text>
</RoomInputPlaceholder>
)}
</>

View file

@ -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 = (
<Avatar size="300">
{isOneOnOne && peerUserId ? (
@ -356,30 +346,9 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
<Box shrink="No" alignItems="Center">
{callButtonVisible && <DmCallButton room={room} />}
{/* 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 && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Room.members')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleCallViewMembers}>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
{/* No separate members button in callView CallView renders its
own member list, and the Room-SettingsMembers page this used
to open was removed with the room-settings redesign. */}
<TooltipProvider
position="Bottom"
align="End"

View file

@ -519,6 +519,12 @@ function MobileMembersHorseshoe({ header, children }: RoomViewMembersPanelProps)
<UserRoomProfile
userId={profileState.userId}
onAvatarClick={() => 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)}
/>
</div>
) : (

View file

@ -553,6 +553,10 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
<UserRoomProfile
userId={renderUserId}
onAvatarClick={() => 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}
/>
)}
</div>

View file

@ -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. */}
<PageHeader className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}>
{/* 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() && (
<Box shrink="No" alignItems="Center">
<IconButton
fill="None"
onClick={() => openMembersSheet(room.roomId)}
aria-label={t('User.back')}
>
<Icon size="400" src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
<Box grow="Yes" alignItems="Center">
<Text size="H4" truncate>
{t('User.profile_title')}

View file

@ -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 && (
<Reply

View file

@ -58,7 +58,6 @@ import {
UsernameBold,
} from '../../../components/message';
import { StreamMediaContext } from '../../../components/RenderMessageContent';
import { ChatComposer } from '../RoomView.css';
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
@ -73,7 +72,6 @@ import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
@ -343,22 +341,8 @@ export const MessageReadReceiptItem = as<
return (
<>
<Overlay open={open} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<Modal variant="Surface" size="300">
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
</Modal>
</FocusTrap>
</OverlayCenter>
</Overlay>
{/* EventReaders owns its Overlay + FocusTrap (self-contained Vojo card). */}
{open && <EventReaders room={room} eventId={eventId} requestClose={handleClose} />}
<MenuItem
size="300"
after={<Icon size="100" src={RailIcons.Receipts} />}
@ -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<HTMLDivElement> = (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<HTMLDivElement> = (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 = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%', width: '100%' }}>
{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.
<div className={ChatComposer} style={{ width: '100%' }}>
<MessageEditor
style={{ maxWidth: '100%', width: '100%' }}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
</div>
) : (
children
)}
{children}
</Box>
);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (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 && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
@ -1493,9 +1456,6 @@ const MessageInner = as<'div', MessageProps>(
// `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 ? (
<DmReadStatusLine room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
) : 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 &&

View file

@ -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<AutocompleteQuery<AutocompletePrefix>>();
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<string, unknown> = 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<AutocompletePrefix>(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 (
<div {...props} ref={ref}>
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
<RoomMentionAutocomplete
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
<UserMentionAutocomplete
room={room}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
<EmoticonAutocomplete
imagePackRooms={imagePackRooms || []}
editor={editor}
query={autocompleteQuery}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editor={editor}
placeholder="Edit message..."
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
bottom={
<>
<Box
style={{ padding: config.space.S200, paddingTop: 0 }}
alignItems="End"
justifyContent="SpaceBetween"
gap="100"
>
<Box gap="Inherit">
<Chip
onClick={handleSave}
variant="Primary"
radii="Pill"
disabled={saveState.status === AsyncStatus.Loading}
outlined
before={
saveState.status === AsyncStatus.Loading ? (
<Spinner variant="Primary" fill="Soft" size="100" />
) : undefined
}
>
<Text size="B300">Save</Text>
</Chip>
<Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
<Text size="B300">Cancel</Text>
</Chip>
</Box>
<Box gap="Inherit">
<IconButton
variant="SurfaceVariant"
size="300"
radii="300"
onClick={() => setToolbar(!toolbar)}
>
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
<UseStateProvider initial={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
<PopOut
anchor={anchor}
alignOffset={-8}
position="Top"
align="End"
content={
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setAnchor((v) => {
if (v) {
if (!mobileOrTablet()) ReactEditor.focus(editor);
return undefined;
}
return v;
});
}}
/>
}
>
<IconButton
aria-pressed={anchor !== undefined}
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
</Box>
</Box>
{toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)}
</>
}
/>
</div>
);
}
);

View file

@ -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<HTMLButtonElement>;
@ -61,15 +65,17 @@ export type MessageInteractionHandlers = {
};
// Wiring layer for `<Message>` 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<string>();
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,

View file

@ -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({

View file

@ -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 <Icon className={css.ActionRowChevron} size="100" src={Icons.ChevronRight} />;
}
// Muted trailing word (e.g. the current notification mode beside the row).
export function RowTrailingText({ children }: { children: ReactNode }) {
return (
<Text as="span" size="T200" className={css.ActionRowTrailingText}>
{children}
</Text>
);
}
// Subtle Fleet hairline between row groups.
export function ActionSectionLine() {
return <div aria-hidden className={css.SectionLine} />;
@ -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 (
<button
type="button"
className={css.SubRow}
onClick={() => onSelect(mode)}
disabled={disabled}
aria-pressed={selected}
>
<span className={css.ActionRowIcon}>
<Icon size="100" src={getRoomNotificationModeIcon(mode)} filled={selected} />
</span>
<Text
as="span"
size="T300"
className={css.ActionRowLabel}
style={selected ? { fontWeight: 600 } : undefined}
>
{label}
</Text>
{selected && <Icon className={css.SubRowSelectedIcon} size="100" src={Icons.Check} />}
</button>
);
}
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. */}
<ActionRow
icon={Icons.Bell}
label={t('Room.notifications')}
onClick={() => setExpanded(!expanded)}
ariaExpanded={expanded}
trailing={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon
className={css.ActionRowChevron}
size="100"
src={expanded ? Icons.ChevronTop : Icons.ChevronBottom}
/>
)
}
/>
{expanded && (
<div className={css.SubRowGroup}>
{NOTIFICATION_MODES.map((m) => (
<NotificationModeSubRow
key={m}
mode={m}
selected={m === mode}
disabled={changing}
onSelect={handleSelect}
/>
))}
</div>
)}
</>
);
}

View file

@ -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<HTMLDivElement, RoomActionsMenuProps>(
({ room, callView, botControlRoom, onPin, requestClose }, ref) => {
const { t } = useTranslation();
@ -56,8 +58,6 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
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<HTMLDivElement, RoomActionsMenuProps>(
onClick={handleMarkAsRead}
disabled={!unread}
/>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<ActionRow
icon={Icons.Bell}
label={t('Room.notifications')}
onClick={handleOpen}
ariaPressed={opened}
ariaHasPopup
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
/>
)}
</RoomNotificationModeSwitcher>
<NotificationsActionRow roomId={room.roomId} />
<ActionRow
icon={Icons.Pin}
label={t('Room.pinned_messages')}
@ -167,7 +156,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
icon={Icons.RecentClock}
label={t('Room.jump_to_time')}
onClick={() => setPromptJump(true)}
ariaPressed={promptJump}
ariaExpanded={promptJump}
ariaHasPopup
trailing={<RowChevron />}
/>
@ -187,7 +176,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
label={t('Room.invite')}
onClick={() => setInvitePrompt(true)}
accent="primary"
ariaPressed={invitePrompt}
ariaExpanded={invitePrompt}
/>
</div>
</>
@ -201,7 +190,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
onClick={() => setPromptLeave(true)}
accent="critical"
disabled={callView}
ariaPressed={promptLeave}
ariaExpanded={promptLeave}
/>
</div>
</Menu>

View file

@ -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

View file

@ -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<DragState | null>(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<HTMLDivElement>(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<HTMLDivElement>(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 (
<div className={css.container} style={containerStyle}>
<div ref={containerRef} className={css.container}>
{/* Zero-width probe its height resolves `env(safe-area-inset-top)`
so the JS rail-height calc can keep the sheet below the tray. */}
<div
ref={safeTopRef}
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: 0,
height: 'var(--vojo-safe-top, 0px)',
pointerEvents: 'none',
visibility: 'hidden',
}}
/>
{open && portalTarget
? createPortal(
<div
@ -582,40 +564,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
<div
className={css.appBody}
style={{
// Always emitted, even at rest where it computes to
// `inset(0 …)` — see the `appBodyClipPath` comment above for
// why (interpolating from `undefined` would snap). Safe to
// keep always-on: the pull-to-refresh chrome is hosted in the
// strip-level singleton OUTSIDE this subtree, so the resting
// self-bounds clip slices nothing. (Bonus: a non-none
// clip-path makes appBody a permanent stacking context, which
// the singleton's paint-order contract counts on — see
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton.)
clipPath: appBodyClipPath,
transition: appBodyTransition,
overscrollBehaviorY: 'contain',
// In pager mode the appBody is transparent AT REST so the
// static pager header (sitting behind the swipe strip in DOM
// order) shows through the tabsRow zone — that's how the
// chats curtain visually rises ABOVE the header on pin.
//
// When the horseshoe is active (drag in flight OR sheet
// open) we flip the appBody back to opaque
// `SurfaceVariant.Container` (via the CSS class — unsetting
// the inline override). This contains the container's
// `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
// the bottom of the appBody. Otherwise the void would bleed
// up through every transparent pixel of the mascot/form band
// between the static header and the curtain top (refresh/form
// snaps), turning the strip black.
//
// The static-header z-elevation in `MobileTabsPagerHeader`
// tracks the same `mobileHorseshoeActiveAtom` so the static
// header z-pops above the now-opaque appBody in the safe-
// top + tabsRow band — tabs stay visible. Both gestures
// (pin + pager swipe) are gated off while a sheet is open,
// so the opaque flip can't race with curtain-show-through.
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
// In pager mode the appBody stays transparent THROUGHOUT —
// at rest so the static pager header (behind the strip in DOM
// order) shows through the tabsRow zone, and during drag/open
// so the tabs keep showing exactly as at rest while the opaque
// sheet slides OVER them like a real curtain. Nothing dark can
// bleed through: the pager root paints the same SurfaceVariant
// tone behind the strip, and this sheet has no void paint.
backgroundColor: inPagerMode ? 'transparent' : undefined,
}}
>
{children}
@ -625,10 +582,13 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
className={css.silhouette}
style={{
height: `${expandedPx}px`,
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
borderTopRightRadius: `${silhouetteRadiusPx}px`,
transition: silhouetteTransition,
visibility: expandedPx > 0 ? 'visible' : 'hidden',
// Gate on `renderSettings` (open / dragging / exiting), NOT on
// `expandedPx > 0` — on close `expandedPx` snaps to 0 while the
// height transition animates the retract; gating on it would
// hide the sheet on the first frame and the slide-down would
// never be seen. Same rationale as the media-viewer sheet.
visibility: renderSettings ? 'visible' : 'hidden',
// Reset `--vojo-safe-top` for everything mounted inside the
// sheet. The status-bar inset is reserved by PageNav's inner
// column via `padding-top: var(--vojo-safe-top)` for surfaces

View file

@ -1,33 +1,24 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Box,
Button,
color,
config,
Icon,
IconButton,
Icons,
IconSrc,
MenuItem,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { Box, color, config, Icon, IconButton, Icons, IconSrc, Text } from 'folds';
import { General } from './general';
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { Account } from './account';
import { Notifications } from './notifications';
import { Devices } from './devices';
import { EmojisStickers } from './emojis-stickers';
import { About } from './about';
import { Network } from './network';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { LogoutDialog } from '../../components/logout-dialog';
import { useAuthedUserId } from '../../hooks/useAuthedUserId';
import { useUserProfile } from '../../hooks/useUserProfile';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { nameInitials } from '../../utils/common';
import { UserAvatar } from '../../components/user-avatar';
import * as css from './styles.css';
export enum SettingsPages {
GeneralPage,
@ -35,13 +26,16 @@ export enum SettingsPages {
NotificationPage,
NetworkPage,
DevicesPage,
EmojisStickersPage,
AboutPage,
}
// Stable string keys for URL deep-links: `/settings?page=devices`.
// Append-only — the enum is local but the param values are part of
// the URL contract (push notifications, bookmarks, external links).
// Existing values never get RENAMED — the enum is local but the param
// values are part of the URL contract (push notifications, bookmarks,
// external links). A key may be dropped together with its page (the
// 'emojis' key died with the emojis-stickers page); stale inbound
// links then fall back to the settings landing, which is the correct
// degradation for a removed surface.
// `as const satisfies` so the value type is a literal union (not
// widened to `Record<string, SettingsPages>`): the lookup
// `SETTINGS_PAGE_PARAM[k]` with `k: keyof typeof SETTINGS_PAGE_PARAM`
@ -56,41 +50,88 @@ export const SETTINGS_PAGE_PARAM = {
notifications: SettingsPages.NotificationPage,
network: SettingsPages.NetworkPage,
devices: SettingsPages.DevicesPage,
emojis: SettingsPages.EmojisStickersPage,
about: SettingsPages.AboutPage,
} as const satisfies Record<string, SettingsPages>;
export const SETTINGS_PARAM_DEVICES = 'devices';
// DOM id of the visible "Settings" title in PageNavHeader — referenced
// from `aria-labelledby` on the mobile sheet's `role="dialog"` so
// screen readers announce the same string sighted users see (WAI-ARIA
// APG dialog pattern prefers `aria-labelledby` over `aria-label`).
export const SETTINGS_TITLE_ID = 'vojo-settings-title';
type MenuRowTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral';
type SettingsMenuItem = {
page: SettingsPages;
nameKey: string;
icon: IconSrc;
tint: MenuRowTint;
};
// The Account page is NOT in this list — the profile hero at the top of the
// menu is its entry (avatar + name + mxid, tap to open). One muted Dawn
// accent per row, per the design bundle's sidebar vocabulary.
const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [
{ page: SettingsPages.GeneralPage, nameKey: 'Settings.menu_general', icon: Icons.Setting },
{ page: SettingsPages.AccountPage, nameKey: 'Settings.menu_account', icon: Icons.User },
{
page: SettingsPages.GeneralPage,
nameKey: 'Settings.menu_general',
icon: Icons.Setting,
tint: 'violet',
},
{
page: SettingsPages.NotificationPage,
nameKey: 'Settings.menu_notifications',
icon: Icons.Bell,
tint: 'amber',
},
{ page: SettingsPages.NetworkPage, nameKey: 'Settings.network.menu', icon: Icons.Server },
{ page: SettingsPages.DevicesPage, nameKey: 'Settings.menu_devices', icon: Icons.Monitor },
{
page: SettingsPages.EmojisStickersPage,
nameKey: 'Settings.menu_emojis_stickers',
icon: Icons.Smile,
page: SettingsPages.NetworkPage,
nameKey: 'Settings.network.menu',
icon: Icons.Server,
tint: 'blue',
},
{
page: SettingsPages.DevicesPage,
nameKey: 'Settings.menu_devices',
icon: Icons.Monitor,
tint: 'green',
},
{
page: SettingsPages.AboutPage,
nameKey: 'Settings.menu_about',
icon: Icons.Info,
tint: 'neutral',
},
{ page: SettingsPages.AboutPage, nameKey: 'Settings.menu_about', icon: Icons.Info },
];
// Own-profile hero at the top of the settings menu — avatar, display name,
// mono mxid. Tapping opens the Account page (where avatar/name editing
// lives). Replaces the old «Account» menu row.
function SettingsProfileHero({ active, onClick }: { active: boolean; onClick: () => void }) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const userId = useAuthedUserId();
const profile = useUserProfile(userId);
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const avatarUrl = profile.avatarUrl
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
<button type="button" className={css.ProfileHero} aria-pressed={active} onClick={onClick}>
<span className={css.ProfileHeroAvatar}>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={displayName}
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
/>
</span>
<span className={css.ProfileHeroText}>
<span className={css.ProfileHeroName}>{displayName}</span>
<span className={css.ProfileHeroHandle}>{userId}</span>
</span>
<Icon className={css.ProfileHeroChevron} size="100" src={Icons.ChevronRight} />
</button>
);
}
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
@ -101,9 +142,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
// `initialPage !== undefined` (not truthy-check) — `GeneralPage`
// happens to be enum value `0`, which the old `if (initialPage)`
// form silently dropped, breaking `/settings?page=general`.
//
// Desktop lands on the ACCOUNT page (the user's own profile) — settings
// open on «me» first, mirroring the profile hero that tops the menu.
// Mobile keeps the bare menu (the hero IS the landing there).
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
if (initialPage !== undefined) return initialPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
});
// Re-sync when the parent passes a new `initialPage` (e.g. the
// UnverifiedTab shield-icon shortcut navigating from `/settings` to
@ -148,6 +193,17 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
) {
return;
}
// An overlay floating above the sub-page (folds dialogs/menus all
// portal into #portalContainer; the horseshoe sheets' display:none
// markers don't count) owns Esc / Android back. Window-capture runs
// before the overlay's document-level focus-trap listener, so
// without this bail we'd pop the page out from under an open
// dialog — e.g. mid-logout or mid-UIA on the Devices page.
const portal = document.getElementById('portalContainer');
const overlayOpen = portal
? Array.from(portal.children).some((c) => (c as HTMLElement).style.display !== 'none')
: false;
if (overlayOpen) return;
setActivePage(undefined);
e.stopImmediatePropagation();
};
@ -162,7 +218,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
<PageNav size="350" surface="surfaceVariant">
<PageNavHeader outlined={false}>
<Box grow="Yes" gap="200" alignItems="Center">
<Text id={SETTINGS_TITLE_ID} size="H4" truncate>
<Text size="H4" truncate>
{t('Settings.title')}
</Text>
</Box>
@ -188,89 +244,58 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
</PageNavHeader>
<Box grow="Yes" direction="Column">
<PageNavContent>
<div style={{ flexGrow: 1 }}>
{menuItems.map((item) => {
const isActive = activePage === item.page;
// The whole PageNav sits on SurfaceVariant.Container
// (the chat-pane tone). Active items step up to
// SurfaceVariant.ContainerActive (the raised
// hover/active tone) to read as a subtle raised
// row, and the active icon picks up
// `Primary.Main` (Fleet-violet) for a single
// splash of accent — the same accent the
// DM/Channels/Bots tab underline uses. Text stays
// neutral OnContainer; the weight bump conveys
// the active state to the eye.
return (
<MenuItem
key={item.nameKey}
variant="SurfaceVariant"
radii="400"
aria-pressed={isActive}
before={
<Icon
src={item.icon}
size="100"
filled={isActive}
style={{
color: isActive
? color.Primary.Main
: color.SurfaceVariant.OnContainer,
opacity: isActive ? 1 : 0.7,
}}
/>
}
onClick={() => setActivePage(item.page)}
style={{
backgroundColor: isActive
? color.SurfaceVariant.ContainerActive
: 'transparent',
}}
>
<Text
style={{
fontWeight: isActive ? config.fontWeight.W600 : undefined,
opacity: isActive ? 1 : 0.82,
}}
size="T300"
truncate
<Box direction="Column" gap="300" style={{ flexGrow: 1 }}>
<SettingsProfileHero
active={activePage === SettingsPages.AccountPage}
onClick={() => setActivePage(SettingsPages.AccountPage)}
/>
<div>
{menuItems.map((item) => {
const isActive = activePage === item.page;
return (
<button
key={item.nameKey}
type="button"
className={css.MenuRow}
aria-pressed={isActive}
onClick={() => setActivePage(item.page)}
>
{t(item.nameKey)}
</Text>
</MenuItem>
);
})}
</div>
<span className={css.MenuRowIcon({ tint: item.tint })}>
<Icon src={item.icon} size="100" filled={isActive} />
</span>
<span
className={css.MenuRowLabel}
style={isActive ? { fontWeight: 600 } : undefined}
>
{t(item.nameKey)}
</span>
<Icon
className={css.MenuRowChevron}
size="100"
src={Icons.ChevronRight}
/>
</button>
);
})}
</div>
</Box>
</PageNavContent>
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
<UseStateProvider initial={false}>
{(logout, setLogout) => (
<>
<Button
size="300"
variant="Critical"
fill="None"
radii="Pill"
before={<Icon src={Icons.Power} size="100" />}
<button
type="button"
className={`${css.MenuRow} ${css.MenuRowCritical}`}
onClick={() => setLogout(true)}
>
<Text size="B400">{t('Settings.logout')}</Text>
</Button>
{logout && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: () => setLogout(false),
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={() => setLogout(false)} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
<span className={css.MenuRowIcon({ tint: 'critical' })}>
<Icon src={Icons.Power} size="100" />
</span>
<span className={css.MenuRowLabel}>{t('Settings.logout')}</span>
</button>
{/* LogoutDialog owns its Overlay + FocusTrap. */}
{logout && <LogoutDialog handleClose={() => setLogout(false)} />}
</>
)}
</UseStateProvider>
@ -295,9 +320,6 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
{activePage === SettingsPages.DevicesPage && (
<Devices requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.EmojisStickersPage && (
<EmojisStickers requestClose={handlePageRequestClose} />
)}
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
</PageRoot>
);

View file

@ -11,23 +11,17 @@ import {
color,
Spinner,
toRem,
Overlay,
OverlayBackdrop,
OverlayCenter,
} from 'folds';
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
import FocusTrap from 'focus-trap-react';
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
import { BreakWord } from '../../../styles/Text.css';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
import { Mono, SettingRow } from '../styles.css';
import { LogoutDialog } from '../../../components/LogoutDialog';
import { stopPropagation } from '../../../utils/keyboard';
import { DeviceSummary, MenuRowIcon, Mono, SettingRow } from '../styles.css';
import { LogoutDialog } from '../../../components/logout-dialog';
export function DeviceTilePlaceholder() {
return (
@ -211,21 +205,8 @@ export function DeviceLogoutBtn() {
<Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
<Text size="B300">{t('Settings.logout')}</Text>
</Chip>
{prompt && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
onDeactivate: handleClose,
clickOutsideDeactivates: true,
escapeDeactivates: stopPropagation,
}}
>
<LogoutDialog handleClose={handleClose} />
</FocusTrap>
</OverlayCenter>
</Overlay>
)}
{/* LogoutDialog owns its Overlay + FocusTrap (self-contained Vojo card). */}
{prompt && <LogoutDialog handleClose={handleClose} />}
</>
);
}
@ -269,14 +250,22 @@ export function DeviceDeleteBtn({
type DeviceTileProps = {
device: IMyDevice;
deleted?: boolean;
// Tints the leading device chip green — the «this device» accent.
current?: boolean;
refreshDeviceList: () => Promise<void>;
disabled?: boolean;
options?: ReactNode;
children?: ReactNode;
};
// Vojo device row: tinted rounded-square device chip (green for the current
// session, critical when marked for sign-out), name + last-activity column
// that toggles the technical details (id / IP / key) on tap, and a trailing
// pencil for rename + the host-provided actions (logout / delete).
export function DeviceTile({
device,
deleted,
current,
refreshDeviceList,
disabled,
options,
@ -291,48 +280,52 @@ export function DeviceTile({
setEdit(false);
}, []);
let chipTint: 'green' | 'critical' | 'neutral' = 'neutral';
if (deleted) chipTint = 'critical';
else if (current) chipTint = 'green';
return (
<>
<SettingTile
before={
<IconButton
variant={deleted ? 'Critical' : 'Secondary'}
outlined={deleted}
radii="300"
onClick={() => setDetails(!details)}
>
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
</IconButton>
}
after={
!edit && (
<Box shrink="No" alignItems="Center" gap="200">
{options}
{!deleted && (
<Chip
variant="Secondary"
radii="Pill"
onClick={() => setEdit(true)}
disabled={disabled}
>
<Text size="B300">{t('Settings.edit')}</Text>
</Chip>
)}
</Box>
)
}
>
<Text size="T300">{device.display_name ?? device.device_id}</Text>
<Box direction="Column">
<Box alignItems="Center" gap="300">
<span className={MenuRowIcon({ tint: chipTint })}>
<Icon size="100" src={Icons.Monitor} filled={!!current} />
</span>
<button
type="button"
className={DeviceSummary}
onClick={() => setDetails(!details)}
aria-expanded={details}
>
<Text size="T300" truncate style={{ fontWeight: 600 }}>
{device.display_name ?? device.device_id}
</Text>
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
{details && (
<>
<DeviceDetails device={device} />
{children}
</>
)}
</button>
{!edit && (
<Box shrink="No" alignItems="Center" gap="200">
{!deleted && (
<IconButton
size="300"
radii="300"
variant="SurfaceVariant"
fill="None"
onClick={() => setEdit(true)}
disabled={disabled}
aria-label={t('Settings.edit')}
>
<Icon size="100" src={Icons.Pencil} />
</IconButton>
)}
{options}
</Box>
)}
</Box>
{details && (
<Box direction="Column" gap="100">
<DeviceDetails device={device} />
{children}
</Box>
</SettingTile>
)}
{edit && (
<DeviceRename
device={device}

View file

@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text } from 'folds';
import { SectionLabel, SettingFlatRow } from '../styles.css';
import { SectionLabel, SettingFlatGroup, SettingFlatRow } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { SettingsPage } from '../SettingsPage';
import { SettingsSection } from '../SettingsSection';
@ -93,27 +93,33 @@ export function Devices({ requestClose }: DevicesProps) {
{t('Settings.current')}
</Text>
{currentDevice ? (
<Box className={SettingFlatRow} direction="Column" gap="400">
<DeviceTile
device={currentDevice}
refreshDeviceList={refreshDeviceList}
options={<DeviceLogoutBtn />}
>
{crypto && <DeviceKeyDetails crypto={crypto} />}
</DeviceTile>
{crossSigningActive &&
verificationStatus === VerificationStatus.Unverified &&
defaultSecretStorageKeyId &&
defaultSecretStorageKeyContent && (
<VerifyCurrentDeviceTile
secretStorageKeyId={defaultSecretStorageKeyId}
secretStorageKeyContent={defaultSecretStorageKeyContent}
/>
// Inset grouped panel — same flat-group chrome the rest of the
// settings pages use; the row itself is the Vojo device row
// (green «this device» chip).
<div className={SettingFlatGroup}>
<Box className={SettingFlatRow} direction="Column" gap="400">
<DeviceTile
device={currentDevice}
current
refreshDeviceList={refreshDeviceList}
options={<DeviceLogoutBtn />}
>
{crypto && <DeviceKeyDetails crypto={crypto} />}
</DeviceTile>
{crossSigningActive &&
verificationStatus === VerificationStatus.Unverified &&
defaultSecretStorageKeyId &&
defaultSecretStorageKeyContent && (
<VerifyCurrentDeviceTile
secretStorageKeyId={defaultSecretStorageKeyId}
secretStorageKeyContent={defaultSecretStorageKeyContent}
/>
)}
{crypto && verificationStatus === VerificationStatus.Verified && (
<BackupRestoreTile crypto={crypto} />
)}
{crypto && verificationStatus === VerificationStatus.Verified && (
<BackupRestoreTile crypto={crypto} />
)}
</Box>
</Box>
</div>
) : (
<DeviceTilePlaceholder />
)}

Some files were not shown because too many files have changed in this diff Show more