+
+
+
+ );
+}
diff --git a/src/app/components/logout-dialog/index.ts b/src/app/components/logout-dialog/index.ts
new file mode 100644
index 00000000..364465fa
--- /dev/null
+++ b/src/app/components/logout-dialog/index.ts
@@ -0,0 +1 @@
+export * from './LogoutDialog';
diff --git a/src/app/components/message/layout/Bubble.css.ts b/src/app/components/message/layout/Bubble.css.ts
index c5c2d0a3..01b60b6d 100644
--- a/src/app/components/message/layout/Bubble.css.ts
+++ b/src/app/components/message/layout/Bubble.css.ts
@@ -85,13 +85,6 @@ export const MediaPlain = style({
maxWidth: '100%',
});
-// Editing: the body is the composer card itself (wrapped in ChatComposer by the
-// caller) — full width, no bubble background of our own.
-export const EditContent = style({
- width: '100%',
- minWidth: 0,
-});
-
// Tap-rail open (or context menu / emoji board open) → the message reads as
// «selected», the same affordance long-press gives. A soft brand ring rather
// than a fill so it works over both the bubble and bare peer text / media.
diff --git a/src/app/components/message/layout/Bubble.tsx b/src/app/components/message/layout/Bubble.tsx
index 43f3c681..2866c280 100644
--- a/src/app/components/message/layout/Bubble.tsx
+++ b/src/app/components/message/layout/Bubble.tsx
@@ -19,9 +19,6 @@ export type BubbleLayoutProps = {
// media AND for the tail of a same-minute series (grouped messages). A single
// text message keeps its timestamp on the side.
timeBelow?: boolean;
- // While editing, the body IS a composer card — drop the bubble chrome so it
- // reads as one box, and skip the timestamp.
- editing?: boolean;
// Reactions chip-row, floats under the message on the page background.
reactions?: ReactNode;
threadSummary?: ReactNode;
@@ -45,7 +42,6 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
selected,
mediaMode,
timeBelow,
- editing,
reactions,
threadSummary,
readStatus,
@@ -63,10 +59,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
);
let body: ReactNode;
- if (editing) {
- // The child is already a composer card; render it full width, no chrome.
- body =
{children}
;
- } else if (mediaMode || timeBelow) {
+ if (mediaMode || timeBelow) {
// Media, and the tail of a same-minute series: timestamp BELOW the content,
// aligned to the message side by the Row (own → right, peer → left).
body = (
diff --git a/src/app/components/message/layout/Channel.css.ts b/src/app/components/message/layout/Channel.css.ts
index 85776a32..0fafa9f9 100644
--- a/src/app/components/message/layout/Channel.css.ts
+++ b/src/app/components/message/layout/Channel.css.ts
@@ -1,24 +1,39 @@
-import { globalStyle, style } from '@vanilla-extract/css';
+import { createVar, globalStyle, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
-// 40px circular avatar — Discord's cozy-mode avatar size. Consumers override
-// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant
-// keeps the CSS slot width and the inline override in sync.
-export const CHANNEL_AVATAR_PX = 40;
-const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
+// Avatar diameter — Discord's cozy-mode 40px on desktop, compacted to 32px
+// on narrow/native viewports (the 40px slot + 16px gutters wasted ~a third
+// of a 360px screen on chrome — user request, redesign item 11). Exposed as
+// a CSS var scoped on ChannelRow so the inline `` size override in
+// Channel.tsx tracks the same breakpoint as the slot width without JS
+// media-query plumbing.
+export const CHANNEL_AVATAR_SIZE_VAR = createVar();
+const ChannelAvatarWidth = CHANNEL_AVATAR_SIZE_VAR;
-// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the
-// content, so the message column starts at 16 + 40 + 16 = 72px.
+// Mirrors BubbleTimelineBand's desktop breakpoint (RoomTimeline.css.ts) —
+// keep the two media queries in sync.
+const MOBILE_MEDIA = 'screen and (max-width: 599px)';
+
+// Discord cozy-mode geometry on desktop: avatar 16px from the list edge,
+// 16px gap to the content, so the message column starts at 16 + 40 + 16 =
+// 72px. On mobile everything tightens: 6px edge, 32px avatar, 10px gap →
+// body column at 48px from the band edge (60px from the screen edge with
+// the band's 12px native gutter), vs the old 84px.
const ChannelEdgePad = toRem(16);
const ChannelAvatarGap = toRem(16);
+const ChannelEdgePadMobile = toRem(6);
+const ChannelAvatarGapMobile = toRem(10);
export const ChannelRow = style({
display: 'flex',
alignItems: 'flex-start',
gap: ChannelAvatarGap,
+ vars: {
+ [CHANNEL_AVATAR_SIZE_VAR]: toRem(40),
+ },
// Span the full message-column width so the hover highlight runs edge-to-edge
// like Discord: cancel MessageBase's S400/S200 horizontal padding with negative
- // margins, then re-add the 16px avatar gutter as paddingLeft (so the avatar's
+ // margins, then re-add the avatar gutter as paddingLeft (so the avatar's
// left edge lands 16px from the column edge — Discord cozy). NB: the column is
// the centred BubbleTimelineBand, so that edge is the band's content edge, not
// the screen edge (the band adds 12px native / 40px desktop outside this).
@@ -31,9 +46,9 @@ export const ChannelRow = style({
paddingTop: toRem(2),
paddingBottom: toRem(2),
minWidth: 0,
- // Hover bg subtle so adjacent rows still read as distinct units. `@media
- // (hover: hover)` keeps this inert on touch where there's no pointer.
'@media': {
+ // Hover bg subtle so adjacent rows still read as distinct units. `@media
+ // (hover: hover)` keeps this inert on touch where there's no pointer.
'(hover: hover) and (pointer: fine)': {
selectors: {
'&:hover': {
@@ -41,12 +56,20 @@ export const ChannelRow = style({
},
},
},
+ [MOBILE_MEDIA]: {
+ vars: {
+ [CHANNEL_AVATAR_SIZE_VAR]: toRem(32),
+ },
+ gap: ChannelAvatarGapMobile,
+ paddingLeft: ChannelEdgePadMobile,
+ paddingRight: toRem(8),
+ },
},
});
// Fixed-width slot keeps the body column aligned across collapsed rows
-// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`,
-// so the body's left edge stays put).
+// (where `avatar` is `undefined` — the slot still occupies the avatar
+// diameter, so the body's left edge stays put).
export const ChannelAvatarSlot = style({
width: ChannelAvatarWidth,
flexShrink: 0,
@@ -90,14 +113,23 @@ export const ChannelThreadSummary = style({
// continuous.
export const ChannelSysline = style({
// Indent past the avatar gutter so the sysline body aligns with the message
- // body column (72px). The sysline sits inside MessageBase's S400 (16px) left
- // pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40)
- // + gap (16) = 56 lands the content at 16 + 56 = 72px.
- paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`,
+ // body column (72px desktop). The sysline sits inside MessageBase's S400
+ // (16px) left pad (it has no edge-to-edge negative margin), so paddingLeft
+ // = avatar (40) + gap (16) = 56 lands the content at 16 + 56 = 72px.
+ // ChannelRow's avatar-size var is out of scope here (sysline rows render
+ // standalone), so the mobile compaction repeats the literals: 32 + 10 = 42,
+ // minus the 10px edge-pad difference (16 − 6) the message rows gained,
+ // = 32px — keeps the sysline body on the message-body column line.
+ paddingLeft: `calc(${toRem(40)} + ${ChannelAvatarGap})`,
paddingRight: config.space.S200,
paddingTop: config.space.S100,
paddingBottom: config.space.S100,
color: color.SurfaceVariant.OnContainer,
+ '@media': {
+ [MOBILE_MEDIA]: {
+ paddingLeft: toRem(32),
+ },
+ },
});
export const ChannelSyslineIcon = style({
diff --git a/src/app/components/message/layout/Channel.tsx b/src/app/components/message/layout/Channel.tsx
index e600cc53..141ab3c2 100644
--- a/src/app/components/message/layout/Channel.tsx
+++ b/src/app/components/message/layout/Channel.tsx
@@ -4,7 +4,7 @@ import { Avatar, Box, Icon, Icons, type IconSrc, as } from 'folds';
import { type Room } from 'matrix-js-sdk';
import * as css from './Channel.css';
-import { CHANNEL_AVATAR_PX } from './Channel.css';
+import { CHANNEL_AVATAR_SIZE_VAR } from './Channel.css';
import { UserAvatar } from '../../user-avatar';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
@@ -137,7 +137,16 @@ export function ChannelMessageAvatar({
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
return (
-
+ // Size from the ChannelRow-scoped CSS var (40px desktop / 32px mobile) so
+ // the avatar tracks the slot's breakpoint while still out-ranking the
+ // folds size preset via inline style.
+ 0`.
- // Without elevation that void bleeds up through the transparent
- // strip-stack into the safe-top + tabsRow zone, turning the system-
- // tray strip + tabs black. Bumping the static header into a positive
- // z-index puts it ABOVE the strip's stacking context (positive z
- // beats z:auto stacking contexts per CSS painting order), covering
- // the void in its own y-band with SurfaceVariant bg + visible tabs.
- //
- // The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the
- // sheet-open atoms, so elevation lands on the FIRST frame of drag —
- // not 80 px later when the user crosses the commit threshold. The
- // horseshoes' appBody flips to opaque in lockstep (same signal),
- // containing the void to the bottom carve everywhere below the
- // static header.
+ // Z-elevation is requested ONLY by the void-carve sheet design (the
+ // Channels workspace switcher): while geometrically active
+ // (`expandedPx > 0`, first frame of drag) its container paints
+ // `VOJO_HORSESHOE_VOID_COLOR` across the pane to drive the carve,
+ // and without elevation that void bleeds up through the transparent
+ // strip-stack into the safe-top + tabsRow zone. A positive z-index
+ // beats the strip's z:auto stacking context (CSS painting order),
+ // covering the void in this band with SurfaceVariant bg + visible
+ // tabs. The near-fullscreen Settings sheet keeps its appBody
+ // transparent and simply slides OVER this header like a real
+ // curtain, so it never publishes the elevate atom — see
+ // mobileHorseshoeElevateHeaderAtom's docs.
//
// Pinned-overrides-elevation: when the active pane's curtain is
// pinned the curtain itself contains the void — it covers everything
// from `y = safe-top` downward inside the strip's stacking context,
- // and the opaque appBody (also flipped on `horseshoeActive`) covers
- // the safe-top band above the curtain. Re-elevating the static
- // header in that state would visibly «slice» the pinned curtain in
- // the safe-top + tabsRow band, popping tabs back over what the user
- // explicitly pulled up to cover. So we suppress elevation whenever
- // the active tab's pin is set — preserves the «pinned hides tabs»
- // invariant across sheet open/drag.
+ // and the opaque appBody (flipped on the same geometric signal)
+ // covers the safe-top band above the curtain. Re-elevating the
+ // static header in that state would visibly «slice» the pinned
+ // curtain in the safe-top + tabsRow band, popping tabs back over
+ // what the user explicitly pulled up to cover. So we suppress
+ // elevation whenever the active tab's pin is set — preserves the
+ // «pinned hides tabs» invariant across sheet open/drag.
//
// The curtain pin gesture is suppressed while either sheet is open
// (see `StreamHeader.gestureDisabled`), so this elevation never
// races with a pin-in-progress drag.
- const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
+ const elevateHeader = useAtomValue(mobileHorseshoeElevateHeaderAtom);
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
const activePinned = !!pinnedByTab[activeTab];
- const elevated = horseshoeActive && !activePinned;
+ const elevated = elevateHeader && !activePinned;
return (
0`, which covers both
-// the in-flight drag and the committed-open state — the wrapping
+// The two horseshoe sheet designs interact differently with this
+// header. The near-fullscreen Settings sheet keeps its appBody
+// transparent and simply slides its opaque silhouette OVER the tabs
+// like a real curtain — no elevation, the tabs stay put underneath.
+// The Channels workspace switcher is the void-carve design: while
+// geometrically active (`expandedPx > 0`, first frame of drag) its
// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark
-// theme) across the entire pane so the carve at the sheet's top
-// reads as a dark seam. With the transparent strip stack from (b),
-// that void would bleed up through the safe-top + tabsRow zone,
-// turning the system-tray strip + tabs solid black.
+// theme) across the pane to drive the carve. With the transparent
+// strip stack from (b), that void would bleed up through the
+// safe-top + tabsRow zone, turning the system-tray strip + tabs
+// solid black.
//
-// `MobileTabsPagerHeader.tsx` bumps this element to a positive
-// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`)
-// from the first frame of drag. Positive z beats the strip's
-// `z: auto` stacking context, putting the static header back on
-// top in the safe-top + tabsRow band — the void is contained to
-// the carve area, tabs stay visible. The horseshoe's `appBody`
-// flips back to opaque on the same signal so the void doesn't
-// bleed into the mascot/form band between the static header and
-// the curtain top either. The curtain pin gesture is gated off
-// in the same state (see `StreamHeader.gestureDisabled`) so no
-// pin can race the elevation flip.
+// So only the void-carve sheet publishes
+// `mobileHorseshoeElevateHeaderAtom`; `MobileTabsPagerHeader.tsx`
+// bumps this element to a positive `zIndex` (inline style) on that
+// signal. Positive z beats the strip's `z: auto` stacking context,
+// putting the static header back on top in the safe-top + tabsRow
+// band — the void is contained to the carve area, tabs stay
+// visible. That horseshoe's `appBody` flips back to opaque on the
+// same signal so the void doesn't bleed into the band between the
+// static header and the curtain top either. The curtain pin gesture
+// is gated off in the same state (see `StreamHeader.gestureDisabled`)
+// so no pin can race the elevation flip.
//
// Pinned-override: when the active pane's curtain is pinned, the
// curtain itself sits at the top of the stage (z:2 inside the
diff --git a/src/app/components/swipe-back/externalSwipeFeed.ts b/src/app/components/swipe-back/externalSwipeFeed.ts
new file mode 100644
index 00000000..8d6991ed
--- /dev/null
+++ b/src/app/components/swipe-back/externalSwipeFeed.ts
@@ -0,0 +1,38 @@
+// Cross-boundary input feed for the swipe-back gesture. An iframe is a
+// separate browsing context — touches inside it NEVER reach the host's
+// listeners, so the swipe-back-from-widget gesture was dead over the
+// bridge-bot widgets. The widget apps (apps/widget-*) forward their raw
+// touch stream over the validated `io.vojo.bot-widget` side-channel
+// (`swipe-touch` action); `BotWidgetMount` maps the coordinates into host
+// viewport space and emits them here; `useSwipeBackGesture` subscribes and
+// routes them through the SAME state machine as native touches (dead-zone
+// axis resolve, edge guard, distance-commit).
+//
+// Module-level pub-sub (not context): the emitter (BotWidgetMount, inside
+// the routed chat) and the consumer (SwipeBackOverlay, the router-level
+// card) live in distant subtrees, and the feed is transient input — no
+// render state, nothing to persist.
+
+export type ExternalSwipePhase = 'start' | 'move' | 'end' | 'cancel';
+
+export type ExternalSwipeTouch = {
+ phase: ExternalSwipePhase;
+ // Host-viewport coordinates (the emitter translates iframe-local ones).
+ x: number;
+ y: number;
+};
+
+type Listener = (touch: ExternalSwipeTouch) => void;
+
+const listeners = new Set();
+
+export const subscribeExternalSwipe = (listener: Listener): (() => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+};
+
+export const emitExternalSwipe = (touch: ExternalSwipeTouch): void => {
+ listeners.forEach((listener) => listener(touch));
+};
diff --git a/src/app/components/swipe-back/useSwipeBackGesture.ts b/src/app/components/swipe-back/useSwipeBackGesture.ts
index c79812d0..0fea59b2 100644
--- a/src/app/components/swipe-back/useSwipeBackGesture.ts
+++ b/src/app/components/swipe-back/useSwipeBackGesture.ts
@@ -1,5 +1,6 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX } from './geometry';
+import { subscribeExternalSwipe } from './externalSwipeFeed';
type Args = {
// The sliding chat CARD element the listeners bind to. Touches outside it
@@ -23,12 +24,21 @@ type Args = {
};
// Rightward "swipe-to-go-back" (interactive pop) driver. Mirrors
-// `useMobileTabsPagerGesture`: single listener on the card root, refs for
-// live state, axis-resolve in the dead-zone, distance threshold-commit on
-// release with snap-back below it. Differences: rightward-only (back, never
-// forward — leftward clamps at 0). The card is `touch-action: pan-y`, so the
-// browser reserves horizontal panning for us and our moves stay cancelable
-// over the whole screen.
+// `useMobileTabsPagerGesture`: refs for live state, axis-resolve in the
+// dead-zone, distance threshold-commit on release with snap-back below it.
+// Rightward-only (back, never forward — leftward clamps at 0). The card is
+// `touch-action: pan-y`, so the browser reserves horizontal panning for us
+// and our moves stay cancelable over the whole screen.
+//
+// TWO input sources feed ONE state machine (begin/move/end/cancel below):
+// 1. Native touch listeners on the card root — every React-rendered
+// surface (chats, AI chat, hero headers).
+// 2. The external swipe feed (`externalSwipeFeed.ts`) — touches forwarded
+// by the bridge-bot widget iframes over the validated side-channel,
+// already mapped into host-viewport coordinates by `BotWidgetMount`.
+// An iframe consumes its touches natively, so without this feed the
+// gesture is dead over the widget body. External moves carry no
+// cancelable browser event — the widget side owns preventDefault.
export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void {
const disabledRef = useRef(disabled);
const setDragRef = useRef(setDrag);
@@ -66,37 +76,42 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
reset();
};
- const onTouchStart = (e: TouchEvent) => {
- if (disabledRef.current || e.touches.length !== 1) {
- springHome();
- return;
- }
- const t = e.touches[0];
+ // ── State machine (shared by both input sources) ──────────────────
+
+ const begin = (x: number, y: number) => {
+ // Heal any orphaned offset FIRST: unlike native touches, the external
+ // feed has no guaranteed terminal event — an iframe dying mid-drag
+ // (widget crash → destroy() drops the message listener) never posts
+ // 'end', which would otherwise leave the card frozen half-slid with
+ // no recovery (a plain tap's end() sees clean state and no-ops).
+ // springHome is a no-op after a cleanly terminated gesture.
+ springHome();
+ if (disabledRef.current) return;
const vw = window.innerWidth;
// The L/R edge strip belongs to the Android system back-gesture in
// edge-to-edge mode. Ours shares its direction, so we MUST cede it —
// a drag must start at least EDGE_GUARD_PX inside the left edge.
- if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) {
- springHome();
- return;
- }
- startX = t.clientX;
- startY = t.clientY;
+ if (x < EDGE_GUARD_PX || x > vw - EDGE_GUARD_PX) return;
+ startX = x;
+ startY = y;
engaged = false;
bailed = false;
lastDragPx = 0;
};
- const onTouchMove = (e: TouchEvent) => {
- if (e.touches.length !== 1 || disabledRef.current) {
+ // `preventDefault` is the source-specific cancelation hook: native
+ // touches pass the cancelable TouchEvent's preventDefault; the external
+ // feed passes undefined (the widget side already prevented its own
+ // default once it resolved the gesture as ours).
+ const move = (x: number, y: number, preventDefault?: () => void) => {
+ if (disabledRef.current) {
springHome();
bailed = true;
return;
}
if (startX === null || startY === null || bailed) return;
- const t = e.touches[0];
- const dx = t.clientX - startX;
- const dy = t.clientY - startY;
+ const dx = x - startX;
+ const dy = y - startY;
if (!engaged) {
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
@@ -119,7 +134,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
// screen (including over the vertical-scrolling timeline), so the
// gesture engages everywhere, not just over non-scrollable spots. The
// guard is defensive only.
- if (e.cancelable) e.preventDefault();
+ preventDefault?.();
const vw = window.innerWidth;
// Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative:
@@ -130,7 +145,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
setDragRef.current(drag, true);
};
- const onTouchEnd = () => {
+ const end = () => {
if (!engaged || disabledRef.current) {
springHome();
return;
@@ -149,20 +164,57 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
}
};
- const onTouchCancel = () => {
+ const cancel = () => {
// System cancel (incoming call, scroll take-over, …) never commits.
springHome();
};
+ // ── Source 1: native touches on the card root ─────────────────────
+
+ const onTouchStart = (e: TouchEvent) => {
+ if (e.touches.length !== 1) {
+ springHome();
+ return;
+ }
+ const t = e.touches[0];
+ begin(t.clientX, t.clientY);
+ };
+
+ const onTouchMove = (e: TouchEvent) => {
+ if (e.touches.length !== 1) {
+ springHome();
+ bailed = true;
+ return;
+ }
+ const t = e.touches[0];
+ move(t.clientX, t.clientY, () => {
+ if (e.cancelable) e.preventDefault();
+ });
+ };
+
+ const onTouchEnd = () => end();
+ const onTouchCancel = () => cancel();
+
root.addEventListener('touchstart', onTouchStart, { passive: true });
root.addEventListener('touchmove', onTouchMove, { passive: false });
root.addEventListener('touchend', onTouchEnd, { passive: true });
root.addEventListener('touchcancel', onTouchCancel, { passive: true });
+
+ // ── Source 2: the external (widget iframe) feed ───────────────────
+
+ const unsubscribeExternal = subscribeExternalSwipe((touch) => {
+ if (touch.phase === 'start') begin(touch.x, touch.y);
+ else if (touch.phase === 'move') move(touch.x, touch.y);
+ else if (touch.phase === 'end') end();
+ else cancel();
+ });
+
return () => {
root.removeEventListener('touchstart', onTouchStart);
root.removeEventListener('touchmove', onTouchMove);
root.removeEventListener('touchend', onTouchEnd);
root.removeEventListener('touchcancel', onTouchCancel);
+ unsubscribeExternal();
};
// setDrag / onBack are mirrored via refs so the listener never re-binds
// mid-gesture; `enabled` is a real dep so toggling it binds/unbinds.
diff --git a/src/app/components/uia-stages/EmailStage.tsx b/src/app/components/uia-stages/EmailStage.tsx
index fdc2b61a..ada84958 100644
--- a/src/app/components/uia-stages/EmailStage.tsx
+++ b/src/app/components/uia-stages/EmailStage.tsx
@@ -1,6 +1,7 @@
import React, { useEffect, useCallback, FormEventHandler } from 'react';
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
import { AuthType, MatrixError } from 'matrix-js-sdk';
+import { useTranslation } from 'react-i18next';
import { StageComponentProps } from './types';
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
@@ -18,13 +19,14 @@ function EmailErrorDialog({
onRetry: (email: string) => void;
onCancel: () => void;
}) {
+ const { t } = useTranslation();
+
const handleFormSubmit: FormEventHandler = (evt) => {
evt.preventDefault();
const { retryEmailInput } = evt.target as HTMLFormElement & {
retryEmailInput: HTMLInputElement;
};
- const t = retryEmailInput.value;
- onRetry(t);
+ onRetry(retryEmailInput.value);
};
return (
@@ -40,7 +42,7 @@ function EmailErrorDialog({
{title}{message}
- Email
+ {t('Auth.uia_email_label')}
@@ -80,6 +82,7 @@ export function EmailStageDialog({
emailTokenState: AsyncState;
requestEmailToken: RequestEmailTokenCallback;
}) {
+ const { t } = useTranslation();
const { errorCode, error, session } = stageData;
const handleSubmit = useCallback(
@@ -115,7 +118,7 @@ export function EmailStageDialog({
return (
- Sending verification email...
+ {t('Auth.uia_email_sending')}
);
}
@@ -123,11 +126,11 @@ export function EmailStageDialog({
if (emailTokenState.status === AsyncStatus.Error) {
return (
- Verification Request Sent
- {`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}
+ {t('Auth.uia_email_sent_title')}
+ {t('Auth.uia_email_sent_message', { email: emailTokenState.data.email })}
{errorCode && (
{`${errorCode}: ${error}`}
@@ -149,7 +152,7 @@ export function EmailStageDialog({
@@ -160,8 +163,8 @@ export function EmailStageDialog({
if (!email) {
return (
diff --git a/src/app/components/upload-board/UploadBoard.css.ts b/src/app/components/upload-board/UploadBoard.css.ts
deleted file mode 100644
index 80c1b264..00000000
--- a/src/app/components/upload-board/UploadBoard.css.ts
+++ /dev/null
@@ -1,46 +0,0 @@
-import { style } from '@vanilla-extract/css';
-import { DefaultReset, color, config, toRem } from 'folds';
-
-export const UploadBoardBase = style([
- DefaultReset,
- {
- position: 'relative',
- pointerEvents: 'none',
- },
-]);
-
-export const UploadBoardContainer = style([
- DefaultReset,
- {
- position: 'absolute',
- bottom: config.space.S200,
- left: 0,
- right: 0,
- zIndex: config.zIndex.Max,
- },
-]);
-
-export const UploadBoard = style({
- maxWidth: toRem(400),
- width: '100%',
- maxHeight: toRem(450),
- height: '100%',
- backgroundColor: color.Surface.Container,
- color: color.Surface.OnContainer,
- borderRadius: config.radii.R400,
- boxShadow: config.shadow.E200,
- border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
- overflow: 'hidden',
- pointerEvents: 'all',
-});
-
-export const UploadBoardHeaderContent = style({
- height: '100%',
- padding: `0 ${config.space.S200}`,
-});
-
-export const UploadBoardContent = style({
- padding: config.space.S200,
- paddingBottom: 0,
- paddingRight: 0,
-});
diff --git a/src/app/components/upload-board/UploadBoard.tsx b/src/app/components/upload-board/UploadBoard.tsx
deleted file mode 100644
index 42f3899f..00000000
--- a/src/app/components/upload-board/UploadBoard.tsx
+++ /dev/null
@@ -1,145 +0,0 @@
-import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
-import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
-import classNames from 'classnames';
-import { useAtomValue } from 'jotai';
-
-import * as css from './UploadBoard.css';
-import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
-
-type UploadBoardProps = {
- header: ReactNode;
-};
-export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => (
-
-
-
-
- {children}
-
-
- {header}
-
-
-
-
-));
-
-export type UploadBoardImperativeHandlers = { handleSend: () => Promise };
-
-type UploadBoardHeaderProps = {
- open: boolean;
- onToggle: () => void;
- uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
- onCancel: (uploads: Upload[]) => void;
- onSend: (uploads: UploadSuccess[]) => Promise;
- imperativeHandlerRef: MutableRefObject;
-};
-
-export function UploadBoardHeader({
- open,
- onToggle,
- uploadFamilyObserverAtom,
- onCancel,
- onSend,
- imperativeHandlerRef,
-}: UploadBoardHeaderProps) {
- const sendingRef = useRef(false);
- const uploads = useAtomValue(uploadFamilyObserverAtom);
-
- const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
- const isError = uploads.some((upload) => upload.status === UploadStatus.Error);
- const progress = uploads.reduce(
- (acc, upload) => {
- acc.total += upload.file.size;
- if (upload.status === UploadStatus.Loading) {
- acc.loaded += upload.progress.loaded;
- }
- if (upload.status === UploadStatus.Success) {
- acc.loaded += upload.file.size;
- }
- return acc;
- },
- { loaded: 0, total: 0 }
- );
-
- const handleSend = async () => {
- if (sendingRef.current) return;
- sendingRef.current = true;
- await onSend(
- uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[]
- );
- sendingRef.current = false;
- };
-
- useImperativeHandle(imperativeHandlerRef, () => ({
- handleSend,
- }));
- const handleCancel = () => onCancel(uploads);
-
- return (
-
-
-
- Files
-
-
- {isSuccess && (
- }
- >
- Send
-
- )}
- {isError && !open && (
-
- Upload Failed
-
- )}
- {!isSuccess && !isError && !open && (
- <>
-
- {Math.round(percent(0, progress.total, progress.loaded))}%
-
-
- >
- )}
- {!isSuccess && open && (
- }
- >
- {uploads.length === 1 ? 'Remove' : 'Remove All'}
-
- )}
-
-
- );
-}
-
-export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
-
- {children}
-
-));
diff --git a/src/app/components/upload-board/index.ts b/src/app/components/upload-board/index.ts
deleted file mode 100644
index 24ae780c..00000000
--- a/src/app/components/upload-board/index.ts
+++ /dev/null
@@ -1 +0,0 @@
-export * from './UploadBoard';
diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx
deleted file mode 100644
index f5bc68e0..00000000
--- a/src/app/components/upload-card/UploadCardRenderer.tsx
+++ /dev/null
@@ -1,216 +0,0 @@
-import React, { ReactNode, useEffect } from 'react';
-import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
-import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
-import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { TUploadContent } from '../../utils/matrix';
-import { bytesToSize, getFileTypeIcon } from '../../utils/common';
-import {
- roomUploadAtomFamily,
- TUploadItem,
- TUploadMetadata,
-} from '../../state/room/roomInputDrafts';
-import { useObjectURL } from '../../hooks/useObjectURL';
-import { useMediaConfig } from '../../hooks/useMediaConfig';
-
-type PreviewImageProps = {
- fileItem: TUploadItem;
-};
-function PreviewImage({ fileItem }: PreviewImageProps) {
- const { originalFile, metadata } = fileItem;
- const fileUrl = useObjectURL(originalFile);
-
- return (
-
- );
-}
-
-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
-
- );
-}
-
-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 ? (
-
- {children}
-
- }
- onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
- >
- Spoiler
-
-
-
- ) : 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 (
- }
- after={
- <>
- {upload.status === UploadStatus.Error && (
-
- Retry
-
- )}
-
-
-
- >
- }
- bottom={
- <>
- {fileItem.originalFile.type.startsWith('image') && (
-
-
-
- )}
- {fileItem.originalFile.type.startsWith('video') && (
-
-
-
- )}
- {upload.status === UploadStatus.Idle && !fileSizeExceeded && (
-
- )}
- {upload.status === UploadStatus.Loading && (
-
- )}
- {upload.status === UploadStatus.Error && (
-
- {upload.error.message}
-
- )}
- {upload.status === UploadStatus.Idle && fileSizeExceeded && (
-
-
- The file size exceeds the limit. Maximum allowed size is{' '}
- {bytesToSize(allowSize)}, but the uploaded file is{' '}
- {bytesToSize(file.size)}.
-
-
- )}
- >
- }
- >
-
- {file.name}
-
- {upload.status === UploadStatus.Success && (
-
- )}
-
- );
-}
diff --git a/src/app/components/upload-card/index.ts b/src/app/components/upload-card/index.ts
index e014ab8a..94dc1498 100644
--- a/src/app/components/upload-card/index.ts
+++ b/src/app/components/upload-card/index.ts
@@ -1,3 +1,2 @@
export * from './UploadCard';
-export * from './UploadCardRenderer';
export * from './CompactUploadCardRenderer';
diff --git a/src/app/components/user-profile/CreatorChip.tsx b/src/app/components/user-profile/CreatorChip.tsx
index f59d2ae5..5acbf169 100644
--- a/src/app/components/user-profile/CreatorChip.tsx
+++ b/src/app/components/user-profile/CreatorChip.tsx
@@ -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();
@@ -33,6 +36,31 @@ export function CreatorChip() {
const close = () => setCords(undefined);
+ const isSpace = room.isSpaceRoom();
+
+ const chip = (
+
+ // so keyboard users don't tab onto a button that does nothing.
+ as={isSpace ? 'button' : 'span'}
+ variant="Success"
+ outlined
+ radii="Pill"
+ before={
+ cords ? :
+ }
+ after={tagIconSrc ? : undefined}
+ onClick={isSpace ? open : undefined}
+ aria-pressed={isSpace ? !!cords : undefined}
+ >
+
+ {tag.name}
+
+
+ );
+
+ if (!isSpace) return chip;
+
return (
{
- 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();
}}
>
- Manage Powers
+ {t('User.manage_powers')}
+ {/* «Manage Powers» only for spaces — the room-settings
+ Permissions page was removed with the room-settings
+ redesign; role assignment for rooms happens right here
+ via the tag list above. */}
+ {room.isSpaceRoom() && (
+ <>
+
+
+
+
+ >
+ )}
}
diff --git a/src/app/components/user-profile/UserInfoRows.tsx b/src/app/components/user-profile/UserInfoRows.tsx
index a69a1601..3005aa7f 100644
--- a/src/app/components/user-profile/UserInfoRows.tsx
+++ b/src/app/components/user-profile/UserInfoRows.tsx
@@ -46,7 +46,7 @@ import { copyToClipboard } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
import { factoryRoomIdByAtoZ } from '../../utils/sort';
import { openExternalUrl } from '../../utils/capacitor';
-import { getMxIdServer } from '../../utils/matrix';
+import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { nameInitials } from '../../utils/common';
import { getExploreServerPath } from '../../pages/pathUtils';
@@ -80,7 +80,14 @@ export function InfoRow({ label, value, trailing, title }: InfoRowProps) {
);
}
-// ── id (handle + share menu) ─────────────────────────────────────
+// ── id (nick + share menu) ───────────────────────────────────────
+//
+// The value is the LOCALPART only — the server half lives inside the
+// full mxid that «Copy user ID» puts on the clipboard. Showing
+// `nick:server` here duplicated the server for every same-homeserver
+// user and read as protocol noise (user request, redesign item 14).
+// The trailing ⋯ menu carries exactly two actions: copy the full
+// `@nick:server` and copy the matrix.to link.
export function IdRow({ userId }: { userId: string }) {
const { t } = useTranslation();
@@ -92,7 +99,7 @@ export function IdRow({ userId }: { userId: string }) {
};
const close = () => setCords(undefined);
- const handle = userId.replace(/^@/, '');
+ const handle = getMxIdLocalPart(userId) ?? userId;
return (
);
}
-
diff --git a/src/app/components/user-profile/UserModeration.tsx b/src/app/components/user-profile/UserModeration.tsx
index 160ecead..4dfbe93a 100644
--- a/src/app/components/user-profile/UserModeration.tsx
+++ b/src/app/components/user-profile/UserModeration.tsx
@@ -1,5 +1,6 @@
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
import React, { useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
import { useRoom } from '../../hooks/useRoom';
import { CutoutCard } from '../cutout-card';
import { SettingTile } from '../setting-tile';
@@ -14,6 +15,7 @@ type UserKickAlertProps = {
ts?: number;
};
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
+ const { t } = useTranslation();
const time = ts ? timeHourMinute(ts) : undefined;
const date = ts ? timeDayMonYear(ts) : undefined;
@@ -22,7 +24,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
- Kicked User
+ {t('User.moderation_kicked_user')}
{time && date && (
{date} {time}
@@ -32,16 +34,16 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
{kickedBy && (
- Kicked by: {kickedBy}
+ {t('User.moderation_kicked_by')} {kickedBy}
)}
{reason ? (
<>
- Reason: {reason}
+ {t('User.moderation_reason_label')} {reason}
>
) : (
- No Reason Provided.
+ {t('User.moderation_no_reason')}
)}
@@ -59,6 +61,7 @@ type UserBanAlertProps = {
ts?: number;
};
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const room = useRoom();
const time = ts ? timeHourMinute(ts) : undefined;
@@ -77,7 +80,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
- Banned User
+ {t('User.moderation_banned_user')}
{time && date && (
{date} {time}
@@ -87,16 +90,16 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
{bannedBy && (
- Banned by: {bannedBy}
+ {t('User.moderation_banned_by')} {bannedBy}
)}
{reason ? (
<>
- Reason: {reason}
+ {t('User.moderation_reason_label')} {reason}
>
) : (
- No Reason Provided.
+ {t('User.moderation_no_reason')}
)}
@@ -114,7 +117,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
before={banning && }
disabled={banning}
>
- Unban
+ {t('User.moderation_unban')}
)}
@@ -131,6 +134,7 @@ type UserInviteAlertProps = {
ts?: number;
};
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const room = useRoom();
const time = ts ? timeHourMinute(ts) : undefined;
@@ -149,7 +153,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
- Invited User
+ {t('User.moderation_invited_user')}
{time && date && (
{date} {time}
@@ -159,16 +163,16 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
{invitedBy && (
- Invited by: {invitedBy}
+ {t('User.moderation_invited_by')} {invitedBy}
)}
{reason ? (
<>
- Reason: {reason}
+ {t('User.moderation_reason_label')} {reason}
>
) : (
- No Reason Provided.
+ {t('User.moderation_no_reason')}
)}
@@ -188,7 +192,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
before={kicking && }
disabled={kicking}
>
- Cancel Invite
+ {t('User.moderation_cancel_invite')}
)}
@@ -204,6 +208,7 @@ type UserModerationProps = {
canInvite: boolean;
};
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const room = useRoom();
const reasonInputRef = useRef(null);
@@ -245,10 +250,10 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
- Moderation
+ {t('User.moderation_title')}
- Invite
+ {t('User.moderation_invite')}
)}
{canKick && (
@@ -308,7 +313,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
onClick={kick}
disabled={disabled}
>
- Kick
+ {t('User.moderation_kick')}
)}
{canBan && (
@@ -328,7 +333,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
onClick={ban}
disabled={disabled}
>
- Ban
+ {t('User.moderation_ban')}
)}
diff --git a/src/app/components/user-profile/UserRoomProfile.tsx b/src/app/components/user-profile/UserRoomProfile.tsx
index ff301764..85970d10 100644
--- a/src/app/components/user-profile/UserRoomProfile.tsx
+++ b/src/app/components/user-profile/UserRoomProfile.tsx
@@ -4,7 +4,7 @@
//
// • Hero — large gradient avatar, display name, monospaced
// handle, presence + last-seen, optional e2ee badge.
-// • Info rows — Fleet-style attribute table: id / server / role /
+// • Info rows — Fleet-style attribute table: nick / server / role /
// mutual chats. Each row has a trailing affordance
// (popout menu) when there's something useful to do.
// • Actions — single «Написать» chip in group rooms only
@@ -16,7 +16,8 @@
// surface.
import React from 'react';
-import { Box } from 'folds';
+import { Box, Icon, IconButton, Icons } from 'folds';
+import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { UserHero } from './UserHero';
import { IdRow, MutualRoomsRow, RoleRow, ServerRow } from './UserInfoRows';
@@ -51,9 +52,23 @@ type UserRoomProfileProps = {
// and keeps using the full-rail avatar swap inside
// `RoomViewProfilePanel`.
avatarExpanded?: boolean;
+ // Top-left affordance (redesign item 15). `onBack` renders a back
+ // arrow — used when the profile replaced a parent surface the user
+ // can return to (the group room's members sheet). `onClose` renders
+ // a plain cross on root cards (the 1:1 profile sheet) where there's
+ // nothing to go back to. `onBack` wins when both are passed.
+ onBack?: () => void;
+ onClose?: () => void;
};
-export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) {
+export function UserRoomProfile({
+ userId,
+ onAvatarClick,
+ avatarExpanded,
+ onBack,
+ onClose,
+}: UserRoomProfileProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const navigate = useNavigate();
@@ -132,6 +147,22 @@ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserR
return (
+ {/* Top-left back / close — mirror of the actions anchor. Back
+ returns to the parent surface (members sheet); the cross
+ closes a root card. */}
+ {(onBack || onClose) && (
+
+
+
+
+
+ )}
{/* Top-right action menu — absolute-positioned so it floats
above the hero without claiming a layout row. Hosts every
imperative action (Write / Block / Mod). On mobile the
@@ -164,6 +195,9 @@ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserR
/>
+ {/* The nick row shows the LOCALPART only (the server half lives in
+ the separate server row below and in the full mxid the ⋯ menu
+ copies — no duplication, redesign item 14). */}
{userId !== myUserId && }
diff --git a/src/app/components/user-profile/styles.css.ts b/src/app/components/user-profile/styles.css.ts
index 2fbe79c1..c66f0363 100644
--- a/src/app/components/user-profile/styles.css.ts
+++ b/src/app/components/user-profile/styles.css.ts
@@ -20,6 +20,17 @@ export const CardActionsAnchor = style({
zIndex: 1,
});
+// Back / close anchor — top-left mirror of `CardActionsAnchor`. Hosts
+// the «back to the room card» arrow when the profile was reached from
+// a group room's members sheet, or a plain close cross on root cards
+// (1:1 profile sheet) — redesign item 15.
+export const CardBackAnchor = style({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ zIndex: 1,
+});
+
// ── Hero ─────────────────────────────────────────────────────────
export const HeroRoot = style({
@@ -260,4 +271,3 @@ export const InfoRowMixed = style({
alignItems: 'center',
gap: toRem(8),
});
-
diff --git a/src/app/features/bots/AiChatMenu.tsx b/src/app/features/bots/AiChatMenu.tsx
index 907b6648..c9e34795 100644
--- a/src/app/features/bots/AiChatMenu.tsx
+++ b/src/app/features/bots/AiChatMenu.tsx
@@ -1,16 +1,16 @@
import React, { forwardRef, useState } from 'react';
-import { Icons, Menu, Spinner } from 'folds';
+import { Icons, Menu } from 'folds';
import type { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
-import {
- getRoomNotificationMode,
- useRoomsNotificationPreferencesContext,
-} from '../../hooks/useRoomsNotificationPreferences';
-import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
// Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the
// AI surface's overflow menu is the same popup, styled identically — only the action set differs.
-import { ActionRow, ActionSectionLine, RowChevron } from '../room/room-actions/RoomActions';
+import {
+ ActionRow,
+ ActionSectionLine,
+ NotificationsActionRow,
+ RowChevron,
+} from '../room/room-actions/RoomActions';
import * as css from '../room/room-actions/RoomActions.css';
type AiChatMenuProps = {
@@ -28,9 +28,6 @@ type AiChatMenuProps = {
export const AiChatMenu = forwardRef(
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
const { t } = useTranslation();
- const notificationPreferences = useRoomsNotificationPreferencesContext();
- const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
-
const [promptLeave, setPromptLeave] = useState(false);
return (
@@ -62,18 +59,7 @@ export const AiChatMenu = forwardRef(
ariaHasPopup
trailing={}
/>
-
- {(handleOpen, opened, changing) => (
- : }
- />
- )}
-
+ (
label={t('Room.leave_room')}
onClick={() => setPromptLeave(true)}
accent="critical"
- ariaPressed={promptLeave}
+ ariaExpanded={promptLeave}
/>
diff --git a/src/app/features/bots/BotShell.tsx b/src/app/features/bots/BotShell.tsx
index fabf9e51..bcc7c5eb 100644
--- a/src/app/features/bots/BotShell.tsx
+++ b/src/app/features/bots/BotShell.tsx
@@ -42,6 +42,9 @@ export function BotShell({ preset, room }: BotShellProps) {
setShowChat(true);
}, [setFailed, setShowChat]);
+ // Swipe-back over the widget body works via the forwarded touch stream
+ // (widget → `swipe-touch` side-channel → BotWidgetMount →
+ // externalSwipeFeed) — no host-side capture strip needed.
return (
diff --git a/src/app/features/bots/BotShellMenu.tsx b/src/app/features/bots/BotShellMenu.tsx
index 4ef36bf0..22d7074b 100644
--- a/src/app/features/bots/BotShellMenu.tsx
+++ b/src/app/features/bots/BotShellMenu.tsx
@@ -1,5 +1,5 @@
-import React, { forwardRef } from 'react';
-import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
+import React, { forwardRef, useState } from 'react';
+import { Icons, Menu } from 'folds';
import { useSetAtom } from 'jotai';
import type { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
@@ -8,27 +8,30 @@ import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
-import {
- getRoomNotificationMode,
- getRoomNotificationModeIcon,
- useRoomsNotificationPreferencesContext,
-} from '../../hooks/useRoomsNotificationPreferences';
-import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
-import { UseStateProvider } from '../../components/UseStateProvider';
import { markAsRead } from '../../utils/notifications';
import { botShowChatAtomFamily } from './botExperienceState';
+// Same row vocabulary + popout chrome as the 1:1 chat's ⋮ menu
+// (RoomActionsMenu) and the AI chat's AiChatMenu — one popup style across
+// every surface, only the action set differs.
+import {
+ ActionRow,
+ ActionSectionLine,
+ NotificationsActionRow,
+ RowChevron,
+} from '../room/room-actions/RoomActions';
+import * as css from '../room/room-actions/RoomActions.css';
type BotShellMenuProps = {
room: Room;
requestClose: () => void;
};
-// Slim dropdown for the bot hero's «Настроить» button. Only items that make
-// sense in a 1:1 with a bridge bot:
+// The bridge-bot widget hero's ⋮ menu. Only items that make sense in a 1:1
+// with a bridge bot:
// - Show chat (chat-fallback toggle, switches BotExperienceHost branch)
// - Mark as read
-// - Notifications (shared switcher)
+// - Notifications (inline mode picker)
// - Leave room
// Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are
// dropped — none apply to a bridge bot's control DM.
@@ -38,12 +41,12 @@ export const BotShellMenu = forwardRef(
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
- const notificationPreferences = useRoomsNotificationPreferencesContext();
- const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
// Setter-only — the menu's only job around showChat is to flip it.
// useSetAtom skips the value-subscription that useAtom registers.
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
+ const [promptLeave, setPromptLeave] = useState(false);
+
const handleShowChat = () => {
setShowChat(true);
requestClose();
@@ -54,80 +57,41 @@ export const BotShellMenu = forwardRef(
};
return (
-
);
}
diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts
index 897f1204..4b1e56f0 100644
--- a/src/app/features/bots/BotWidgetEmbed.ts
+++ b/src/app/features/bots/BotWidgetEmbed.ts
@@ -49,6 +49,13 @@ export type BotWidgetEmbedOptions = {
// the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like
// `onOpenMatrixToRoom`) where `mx` + navigation are available.
onAddToChat?: () => void;
+ // Forwarded raw touch stream from the widget (`swipe-touch` action) —
+ // coordinates are IFRAME-local; `BotWidgetMount` maps them into host
+ // viewport space and feeds the swipe-back gesture. An iframe consumes
+ // its touches natively, so this is the only way the
+ // swipe-back-from-widget gesture can see drags that start on the widget
+ // body. Carries no privileges — just pointer coordinates.
+ onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void;
};
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
@@ -247,7 +254,14 @@ export class BotWidgetEmbed {
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
// of our extension and avoids the «unknown action» reply path.
//
- // Three actions today:
+ // Four actions today:
+ //
+ // * `swipe-touch` — unidirectional forwarded touch stream driving the
+ // host's swipe-back gesture over the iframe (an iframe consumes its
+ // own touches, so the widget cooperates via `swipe-forward.ts`).
+ // Carries only a phase + viewport-local coordinates; shape-validated
+ // in the handler below, mapped to host coordinates by
+ // `BotWidgetMount`, consumed by `externalSwipeFeed`.
//
// * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat`
// capability opt-in in config.json. Carries NO url and NO room/mxid:
@@ -272,7 +286,7 @@ export class BotWidgetEmbed {
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
// widget never sees a route — it only knows matrix.to URLs.
//
- // Security gates (defence in depth, apply to BOTH actions):
+ // Security gates (defence in depth, apply to ALL actions):
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
// check, a compromised widget bundle could `window.location.href
// = 'https://attacker.example/'` — the browser keeps the same
@@ -307,6 +321,20 @@ export class BotWidgetEmbed {
if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'io.vojo.bot-widget') return;
+ // Forwarded touch stream for the swipe-back gesture (see
+ // `onSwipeTouch` in the options). Validated shape only — finite
+ // numbers and a known phase; everything else is silently dropped.
+ if (msg.action === 'swipe-touch') {
+ const data = msg.data as { phase?: unknown; x?: unknown; y?: unknown } | undefined;
+ const phase = data?.phase;
+ const { x, y } = data ?? {};
+ if (phase !== 'start' && phase !== 'move' && phase !== 'end' && phase !== 'cancel') return;
+ if (typeof x !== 'number' || !Number.isFinite(x)) return;
+ if (typeof y !== 'number' || !Number.isFinite(y)) return;
+ this.options.onSwipeTouch?.(phase, x, y);
+ return;
+ }
+
// Elevated verb — must be dispatched BEFORE any url extraction. Its `data`
// is `{}` (no url), so a hoisted `typeof url === 'string'` gate would
// early-return and the verb would never fire (F1). The host gate reads the
diff --git a/src/app/features/bots/BotWidgetMount.tsx b/src/app/features/bots/BotWidgetMount.tsx
index 4ecd2221..fd3d3808 100644
--- a/src/app/features/bots/BotWidgetMount.tsx
+++ b/src/app/features/bots/BotWidgetMount.tsx
@@ -17,6 +17,7 @@ import {
import type { MatrixToRoom } from '../../plugins/matrix-to';
import { Membership } from '../../../types/matrix/room';
import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room';
+import { emitExternalSwipe } from '../../components/swipe-back/externalSwipeFeed';
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
import { BotAddToChatPicker } from './BotAddToChatPicker';
import * as css from './BotWidgetMount.css';
@@ -236,6 +237,24 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const handleAddToChat = useCallback(() => setPickerOpen(true), []);
+ // The widget's forwarded touch stream → the swipe-back gesture. The
+ // widget reports IFRAME-local clientX/Y; offset by the mount's viewport
+ // rect so the host-side state machine (edge guard, axis resolve in
+ // `useSwipeBackGesture`) sees true host-viewport coordinates. Always
+ // emitted — the feed only has a subscriber when the swipe overlay is
+ // mounted (mobile + native), everywhere else it's a no-op.
+ const handleSwipeTouch = useCallback(
+ (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => {
+ const rect = containerRef.current?.getBoundingClientRect();
+ emitExternalSwipe({
+ phase,
+ x: x + (rect?.left ?? 0),
+ y: y + (rect?.top ?? 0),
+ });
+ },
+ []
+ );
+
const { ready } = useBotWidgetEmbed({
containerRef,
preset,
@@ -243,6 +262,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
onError,
onOpenMatrixToRoom: handleOpenMatrixToRoom,
onAddToChat: handleAddToChat,
+ onSwipeTouch: handleSwipeTouch,
});
// Track Matrix sync state so the bot loading bar yields to the global
diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts
index c4ed5009..61c24a82 100644
--- a/src/app/features/bots/useBotWidgetEmbed.ts
+++ b/src/app/features/bots/useBotWidgetEmbed.ts
@@ -19,6 +19,10 @@ type UseBotWidgetEmbedOptions = {
// `add-to-chat` verb (host opens its own room picker). Plumbed from
// `BotWidgetMount` where `mx` is available.
onAddToChat?: () => void;
+ // Forwarded into the embed — the widget's raw touch stream (iframe-local
+ // coordinates) for the swipe-back gesture. `BotWidgetMount` maps the
+ // coordinates and feeds `externalSwipeFeed`.
+ onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void;
};
type UseBotWidgetEmbedResult = {
@@ -40,6 +44,7 @@ export const useBotWidgetEmbed = ({
onError,
onOpenMatrixToRoom,
onAddToChat,
+ onSwipeTouch,
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
const { i18n } = useTranslation();
const mx = useMatrixClient();
@@ -63,6 +68,10 @@ export const useBotWidgetEmbed = ({
// render) so the embed lifecycle effect doesn't remount the iframe.
const onAddToChatRef = useRef(onAddToChat);
onAddToChatRef.current = onAddToChat;
+ // Same ref indirection for the swipe feed — per-render closure identity
+ // must not remount the iframe.
+ const onSwipeTouchRef = useRef(onSwipeTouch);
+ onSwipeTouchRef.current = onSwipeTouch;
// Depend on primitive identity for the embed lifecycle — using `preset`
// directly would remount the iframe (and re-handshake with the widget)
@@ -96,6 +105,7 @@ export const useBotWidgetEmbed = ({
// navigate-callback closes over a new render's `mx`/`navigate`.
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
onAddToChat: () => onAddToChatRef.current?.(),
+ onSwipeTouch: (phase, x, y) => onSwipeTouchRef.current?.(phase, x, y),
});
embedRef.current = embed;
} catch (error) {
diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx
index f52b86f3..13ccf931 100644
--- a/src/app/features/call/CallControls.tsx
+++ b/src/app/features/call/CallControls.tsx
@@ -15,6 +15,7 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
+import { useTranslation } from 'react-i18next';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import {
@@ -33,6 +34,7 @@ type CallControlsProps = {
callEmbed: CallEmbed;
};
export function CallControls({ callEmbed }: CallControlsProps) {
+ const { t } = useTranslation();
const controlRef = useRef(null);
const [compact, setCompact] = useState(document.body.clientWidth < 500);
@@ -140,7 +142,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onClick={handleSpotlightClick}
>
- {spotlight ? 'Grid View' : 'Spotlight View'}
+ {spotlight ? t('Call.view_grid') : t('Call.view_spotlight')}
>
@@ -162,7 +164,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
onClick={handleSettingsClick}
>
- Settings
+ {t('Call.settings')}
@@ -198,7 +200,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}
disabled={exiting}
>
- End
+ {t('Call.end_call')}
diff --git a/src/app/features/call/CallMemberCard.tsx b/src/app/features/call/CallMemberCard.tsx
index 1c3680f5..4930bedf 100644
--- a/src/app/features/call/CallMemberCard.tsx
+++ b/src/app/features/call/CallMemberCard.tsx
@@ -1,6 +1,7 @@
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import React, { useState } from 'react';
import { Avatar, Box, Icon, Icons, Text } from 'folds';
+import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
@@ -84,6 +85,7 @@ export function CallMemberRenderer({
members: CallMembership[];
max?: number;
}) {
+ const { t } = useTranslation();
const [viewMore, setViewMore] = useState(false);
const truncatedMembers = viewMore ? members : members.slice(0, 4);
@@ -105,11 +107,11 @@ export function CallMemberRenderer({
{viewMore ? (
- Collapse
+ {t('Call.collapse')}
) : (
- {remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`}
+ {t('Call.others_count', { count: remaining })}
)}
diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx
index 7c7bec6c..34d269ad 100644
--- a/src/app/features/call/CallView.tsx
+++ b/src/app/features/call/CallView.tsx
@@ -1,5 +1,6 @@
import React, { RefObject, useRef } from 'react';
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
+import { useTranslation } from 'react-i18next';
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { PrescreenControls } from './PrescreenControls';
@@ -16,9 +17,10 @@ import { CallControls } from './CallControls';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
function LivekitServerMissingMessage() {
+ const { t } = useTranslation();
return (
- Your homeserver does not support calling. But you can still join call started by others.
+ {t('Call.homeserver_no_calls')}
);
}
@@ -30,6 +32,7 @@ function JoinMessage({
hasParticipant?: boolean;
livekitSupported?: boolean;
}) {
+ const { t } = useTranslation();
if (hasParticipant) return null;
if (livekitSupported === false) {
@@ -38,28 +41,31 @@ function JoinMessage({
return (
- Voice chat’s empty — Be the first to hop in!
+ {t('Call.empty_be_first')}
);
}
function NoPermissionMessage() {
+ const { t } = useTranslation();
return (
- You don't have permission to join!
+ {t('Call.no_permission_to_join')}
);
}
function AlreadyInCallMessage() {
+ const { t } = useTranslation();
return (
- Already in another call — End the current call to join!
+ {t('Call.already_in_other_call')}
);
}
function CallPrescreen() {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const room = useRoom();
const livekitSupported = useLivekitSupport();
@@ -86,11 +92,11 @@ function CallPrescreen() {
{hasParticipant && (
- Participant
+ {t('Call.participant')}
- {callMembers.length} Live
+ {t('Call.live_count', { count: callMembers.length })}
diff --git a/src/app/features/call/PrescreenControls.tsx b/src/app/features/call/PrescreenControls.tsx
index 200d0af0..74d90d93 100644
--- a/src/app/features/call/PrescreenControls.tsx
+++ b/src/app/features/call/PrescreenControls.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
+import { useTranslation } from 'react-i18next';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, VideoButton } from './Controls';
@@ -11,6 +12,7 @@ type PrescreenControlsProps = {
canJoin?: boolean;
};
export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
+ const { t } = useTranslation();
const room = useRoom();
const callEmbed = useCallEmbed();
const callJoined = useCallJoined(callEmbed);
@@ -57,7 +59,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
)
}
>
- Join
+ {t('Call.join')}
diff --git a/src/app/features/common-settings/SettingsNav.css.ts b/src/app/features/common-settings/SettingsNav.css.ts
index 0bf939ce..7e0a0707 100644
--- a/src/app/features/common-settings/SettingsNav.css.ts
+++ b/src/app/features/common-settings/SettingsNav.css.ts
@@ -1,4 +1,4 @@
-import { style } from '@vanilla-extract/css';
+import { globalStyle, style } from '@vanilla-extract/css';
import { color, config, DefaultReset, FocusOutline, toRem } from 'folds';
// Dawn settings rail — uppercase tracked muted labels, raised active row with a
@@ -34,6 +34,11 @@ export const NavSection = style([
},
]);
+// Menu row — same Fleet vocabulary as the user-settings menu: tinted
+// rounded-square glyph chip (rendered by SettingsNav.tsx via the shared
+// MenuRowIcon recipe), label, trailing chevron. Active row raises on the
+// panel tone with a weight bump; mouse-only hover (Android WebView's
+// synthesised sticky `:hover` — see src/index.tsx).
export const NavItem = style([
DefaultReset,
FocusOutline,
@@ -43,40 +48,27 @@ export const NavItem = style([
alignItems: 'center',
gap: config.space.S300,
width: '100%',
- padding: `${config.space.S300} ${config.space.S300}`,
+ minHeight: toRem(46),
+ padding: `${config.space.S200} ${config.space.S300}`,
marginBottom: toRem(2),
- borderRadius: config.radii.R400,
+ borderRadius: toRem(12),
color: color.Surface.OnContainer,
cursor: 'pointer',
+ transition: 'background-color 120ms ease-out',
selectors: {
- '&:hover': { backgroundColor: color.Background.ContainerHover },
+ '&:active': { backgroundColor: color.Background.ContainerActive },
'&[aria-pressed=true]': { backgroundColor: color.Background.ContainerActive },
- '&[aria-pressed=true]::before': {
- content: '""',
- position: 'absolute',
- left: toRem(5),
- top: '50%',
- transform: 'translateY(-50%)',
- width: toRem(3),
- height: '52%',
- borderRadius: toRem(3),
- backgroundColor: color.Primary.Main,
- },
},
},
]);
-export const NavItemIcon = style({
- opacity: 0.6,
- selectors: {
- [`${NavItem}[aria-pressed=true] &`]: {
- color: color.Primary.Main,
- opacity: 1,
- },
- },
+globalStyle(`:root[data-input="mouse"] ${NavItem}:hover`, {
+ backgroundColor: color.Background.ContainerHover,
});
export const NavItemLabel = style({
+ flexGrow: 1,
+ minWidth: 0,
fontWeight: config.fontWeight.W500,
selectors: {
[`${NavItem}[aria-pressed=true] &`]: {
@@ -84,3 +76,9 @@ export const NavItemLabel = style({
},
},
});
+
+export const NavItemChevron = style({
+ flexShrink: 0,
+ color: color.Surface.OnContainer,
+ opacity: 0.4,
+});
diff --git a/src/app/features/common-settings/SettingsNav.tsx b/src/app/features/common-settings/SettingsNav.tsx
index 998ad95f..c543e199 100644
--- a/src/app/features/common-settings/SettingsNav.tsx
+++ b/src/app/features/common-settings/SettingsNav.tsx
@@ -1,20 +1,29 @@
import React, { ReactNode } from 'react';
-import { Icon, IconSrc, Text } from 'folds';
+import { Icon, Icons, IconSrc, Text } from 'folds';
+import { MenuRowIcon } from '../settings/styles.css';
import * as css from './SettingsNav.css';
+// One muted Dawn accent per row — the same tinted rounded-square glyph
+// vocabulary the user-settings menu uses (features/settings/styles.css).
+export type SettingsNavTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral';
+
type SettingsNavItemProps = {
icon: IconSrc;
label: string;
active: boolean;
+ tint?: SettingsNavTint;
onClick: () => void;
};
-export function SettingsNavItem({ icon, label, active, onClick }: SettingsNavItemProps) {
+export function SettingsNavItem({ icon, label, active, tint, onClick }: SettingsNavItemProps) {
return (
);
}
diff --git a/src/app/features/lobby/Lobby.tsx b/src/app/features/lobby/Lobby.tsx
index 1ea6f542..144cc0b0 100644
--- a/src/app/features/lobby/Lobby.tsx
+++ b/src/app/features/lobby/Lobby.tsx
@@ -3,6 +3,7 @@ import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config
import { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
import type { AccountDataEvents, StateEvents } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
@@ -150,6 +151,7 @@ const useCanDropLobbyItem = (
};
export function Lobby() {
+ const { t } = useTranslation();
const navigate = useNavigate();
const mx = useMatrixClient();
const mDirects = useAtomValue(mDirectAtom);
@@ -459,7 +461,7 @@ export function Lobby() {
radii="Pill"
outlined
size="300"
- aria-label="Scroll to Top"
+ aria-label={t('Room.lobby_scroll_to_top')}
>
@@ -535,7 +537,7 @@ export function Lobby() {
radii="Pill"
before={}
>
- Reordering
+ {t('Room.lobby_reordering')}
)}
diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx
index ed526c68..35dc0303 100644
--- a/src/app/features/lobby/LobbyHeader.tsx
+++ b/src/app/features/lobby/LobbyHeader.tsx
@@ -17,6 +17,7 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
+import { useTranslation } from 'react-i18next';
import { PageHeader } from '../../components/page';
import { useSetSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -45,6 +46,7 @@ type LobbyMenuProps = {
};
const LobbyMenu = forwardRef(
({ powerLevels, requestClose }, ref) => {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const space = useSpace();
const creators = useRoomCreators(space);
@@ -87,7 +89,7 @@ const LobbyMenu = forwardRef(
disabled={!canInvite}
>
- Invite
+ {t('Room.invite')}
@@ -116,7 +118,7 @@ const LobbyMenu = forwardRef(
aria-pressed={promptLeave}
>
- Leave Space
+ {t('Room.lobby_leave_space')}
{promptLeave && (
@@ -140,6 +142,7 @@ type LobbyHeaderProps = {
powerLevels: IPowerLevels;
};
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const space = useSpace();
@@ -213,7 +216,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
offset={4}
tooltip={
- Members
+ {t('Room.members')}
}
>
@@ -234,7 +237,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
offset={4}
tooltip={
- More Options
+ {t('Room.more_options')}
}
>
diff --git a/src/app/features/lobby/RoomItem.tsx b/src/app/features/lobby/RoomItem.tsx
index 7de59acd..1dd7aa4a 100644
--- a/src/app/features/lobby/RoomItem.tsx
+++ b/src/app/features/lobby/RoomItem.tsx
@@ -19,6 +19,7 @@ import {
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
+import { useTranslation } from 'react-i18next';
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
@@ -44,6 +45,7 @@ type RoomJoinButtonProps = {
via?: string[];
};
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback(
@@ -91,7 +93,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
onClick={join}
disabled={!canJoin}
>
- Join
+ {t('Room.lobby_join')}
);
@@ -127,6 +129,7 @@ type RoomProfileErrorProps = {
via?: string[];
};
function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
+ const { t } = useTranslation();
return (
@@ -146,12 +149,12 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
- Unknown
+ {t('Room.lobby_unknown')}
{suggested && (
- Suggested
+ {t('Room.lobby_suggested')}
)}
@@ -159,7 +162,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
{inaccessibleRoom ? (
- Inaccessible
+ {t('Room.lobby_inaccessible')}
) : (
@@ -195,6 +198,7 @@ function RoomProfile({
joinRule,
options,
}: RoomProfileProps) {
+ const { t } = useTranslation();
return (
@@ -213,7 +217,7 @@ function RoomProfile({
{suggested && (
- Suggested
+ {t('Room.lobby_suggested')}
)}
@@ -221,7 +225,12 @@ function RoomProfile({
{memberCount && (
- {`${millify(memberCount)} Members`}
+
+ {t('Room.lobby_members_count', {
+ count: memberCount,
+ formattedCount: millify(memberCount),
+ })}
+
)}
{memberCount && topic && (
@@ -311,6 +320,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
},
ref
) => {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { roomId, content } = item;
@@ -359,7 +369,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
fill="None"
size="400"
radii="Pill"
- aria-label="Open Room"
+ aria-label={t('Room.lobby_open_room')}
>
diff --git a/src/app/features/lobby/SpaceItem.tsx b/src/app/features/lobby/SpaceItem.tsx
index a2114b78..35954c50 100644
--- a/src/app/features/lobby/SpaceItem.tsx
+++ b/src/app/features/lobby/SpaceItem.tsx
@@ -60,6 +60,7 @@ type InaccessibleSpaceProfileProps = {
suggested?: boolean;
};
function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
+ const { t } = useTranslation();
return (
(
- U
+ {nameInitials(t('Room.lobby_unknown'))}
)}
/>
@@ -81,15 +82,15 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
>
- Unknown
+ {t('Room.lobby_unknown')}
- Inaccessible
+ {t('Room.lobby_inaccessible')}
{suggested && (
- Suggested
+ {t('Room.lobby_suggested')}
)}
@@ -111,6 +112,7 @@ function UnjoinedSpaceProfile({
avatarUrl,
suggested,
}: UnjoinedSpaceProfileProps) {
+ const { t } = useTranslation();
const mx = useMatrixClient();
const [joinState, join] = useAsyncCallback(
@@ -145,11 +147,11 @@ function UnjoinedSpaceProfile({
>
- {name || 'Unknown'}
+ {name || t('Room.lobby_unknown')}
{suggested && (
- Suggested
+ {t('Room.lobby_suggested')}
)}
{joinState.status === AsyncStatus.Error && (
@@ -182,6 +184,7 @@ function SpaceProfile({
categoryId,
handleClose,
}: SpaceProfileProps) {
+ const { t } = useTranslation();
return (
{suggested && (
- Suggested
+ {t('Room.lobby_suggested')}
)}
@@ -225,6 +228,7 @@ type RootSpaceProfileProps = {
handleClose?: MouseEventHandler;
};
function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
+ const { t } = useTranslation();
return (
- Rooms
+ {t('Room.lobby_rooms')}
diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx
index 0a3131ba..de49607a 100644
--- a/src/app/features/message-search/MessageSearch.tsx
+++ b/src/app/features/message-search/MessageSearch.tsx
@@ -198,7 +198,7 @@ export function MessageSearch({
radii="Pill"
outlined
size="300"
- aria-label="Scroll to Top"
+ aria-label={t('Room.msg_search_scroll_to_top')}
>
diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx
index 89a2e489..f084aca2 100644
--- a/src/app/features/room-settings/RoomSettings.tsx
+++ b/src/app/features/room-settings/RoomSettings.tsx
@@ -1,176 +1,68 @@
-import React, { useMemo, useState } from 'react';
-import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Text } from 'folds';
+import React from 'react';
+import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
import { useTranslation } from 'react-i18next';
-import { JoinRule } from 'matrix-js-sdk';
-import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
-import {
- SettingsNavEyebrow,
- SettingsNavItem,
- SettingsNavSection,
-} from '../common-settings/SettingsNav';
-import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
-import { useMatrixClient } from '../../hooks/useMatrixClient';
-import { mxcUrlToHttp } from '../../utils/matrix';
-import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
-import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
-import { isOneOnOneRoom } from '../../utils/room';
-import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
-import { General } from './general';
-import { Members } from '../common-settings/members';
-import { EmojisStickers } from '../common-settings/emojis-stickers';
-import { Permissions } from './permissions';
-import { RoomSettingsPage } from '../../state/roomSettings';
+import { Page, PageContent, PageHeader } from '../../components/page';
+import { SettingsSection } from '../settings/SettingsSection';
+import { usePowerLevels } from '../../hooks/usePowerLevels';
import { useRoom } from '../../hooks/useRoom';
-import { DeveloperTools } from '../common-settings/developer-tools';
-
-type RoomSettingsMenuItem = {
- page: RoomSettingsPage;
- name: string;
- icon: IconSrc;
-};
-
-const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => {
- const { t } = useTranslation();
- return useMemo(
- () => [
- {
- page: RoomSettingsPage.GeneralPage,
- name: t('RoomSettings.general'),
- icon: Icons.Setting,
- },
- {
- page: RoomSettingsPage.MembersPage,
- name: t('RoomSettings.members'),
- icon: Icons.User,
- },
- {
- page: RoomSettingsPage.PermissionsPage,
- name: t('RoomSettings.permissions'),
- icon: Icons.Lock,
- },
- {
- page: RoomSettingsPage.EmojisStickersPage,
- name: t('RoomSettings.emojis_stickers'),
- icon: Icons.Smile,
- },
- {
- page: RoomSettingsPage.DeveloperToolsPage,
- name: t('RoomSettings.developer_tools'),
- icon: Icons.Terminal,
- },
- ],
- [t]
- );
-};
+import {
+ RoomProfile,
+ RoomEncryption,
+ RoomHistoryVisibility,
+ RoomJoinRules,
+ RoomVoiceMessages,
+} from '../common-settings/general';
+import { useRoomCreators } from '../../hooks/useRoomCreators';
+import { useRoomPermissions } from '../../hooks/useRoomPermissions';
type RoomSettingsProps = {
- initialPage?: RoomSettingsPage;
requestClose: () => void;
};
-export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
+
+// Single-page room settings, opened from the room's «⋯» menu. No nav
+// column, no sub-pages: rooms keep only the profile + the four
+// user-meaningful toggles. Addresses / directory publish / room upgrade /
+// power levels / emoji packs / developer tools are Matrix-protocol
+// surfaces end users never needed in a chat — dropped from rooms (spaces
+// keep the full set in SpaceSettings).
+export function RoomSettings({ requestClose }: RoomSettingsProps) {
const { t } = useTranslation();
const room = useRoom();
- const mx = useMatrixClient();
- const useAuthentication = useMediaAuthentication();
-
- // Peer-avatar fallback follows the member-count gate, mirroring the room
- // header (post-P3c). Settings is a globally-mounted modal, so the
- // IsOneOnOneProvider context isn't in scope here — call the helper directly.
- const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room));
- const roomName = useRoomName(room);
- const joinRuleContent = useRoomJoinRule(room);
-
- const avatarUrl = roomAvatar
- ? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
- : undefined;
-
- const screenSize = useScreenSizeContext();
- const [activePage, setActivePage] = useState(() => {
- if (initialPage) return initialPage;
- return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
- });
- const menuItems = useRoomSettingsMenuItems();
-
- const handlePageRequestClose = () => {
- if (screenSize === ScreenSize.Mobile) {
- setActivePage(undefined);
- return;
- }
- requestClose();
- };
+ const powerLevels = usePowerLevels(room);
+ const creators = useRoomCreators(room);
+ const permissions = useRoomPermissions(creators, powerLevels);
return (
-
-
-
-
- (
-
- )}
- />
-
-
- {t('RoomSettings.settings')}
-
- {roomName}
-
-
-
-
- {screenSize === ScreenSize.Mobile && (
-
-
-
- )}
-
-
-
-
-
+ )}
+ {/* Reply preview hides while editing (the edit owns the send);
+ the reply draft itself is preserved and returns after. */}
+ {replyDraft && !editDraft && (
(
after={
singleRow && !voiceMode ? (
<>
- {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
+ {/* Mic hides while editing — a recording would ship a NEW
+ message under a pending edit banner. */}
+ {!textOnly &&
+ !editDraft &&
+ voiceSupported &&
+ voiceDisabledBy === undefined &&
+ micButton}
{!textOnly && emojiButton}
{sendButton}
>
@@ -1059,7 +1476,13 @@ export const RoomInput = forwardRef(
>
{!textOnly && plusButton}
- {!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
+ {/* Mic hides while editing — a recording would ship a NEW
+ message under a pending edit banner. */}
+ {!textOnly &&
+ !editDraft &&
+ voiceSupported &&
+ voiceDisabledBy === undefined &&
+ micButton}
{!textOnly && emojiButton}
{sendButton}
diff --git a/src/app/features/room/RoomTimeline.css.ts b/src/app/features/room/RoomTimeline.css.ts
index c428f470..a25a8553 100644
--- a/src/app/features/room/RoomTimeline.css.ts
+++ b/src/app/features/room/RoomTimeline.css.ts
@@ -1,5 +1,4 @@
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
-import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
import { DefaultReset, color, config, toRem } from 'folds';
import {
VOJO_BUBBLE_BAND_PX,
@@ -101,31 +100,6 @@ export const TimelineScroll = style({
scrollbarColor: `${color.SurfaceVariant.ContainerLine} transparent`,
});
-export const TimelineFloat = recipe({
- base: [
- DefaultReset,
- {
- position: 'absolute',
- left: '50%',
- transform: 'translateX(-50%)',
- zIndex: 1,
- minWidth: 'max-content',
- },
- ],
- variants: {
- position: {
- Top: {
- top: config.space.S400,
- },
- },
- },
- defaultVariants: {
- position: 'Top',
- },
-});
-
-export type TimelineFloatVariants = RecipeVariants;
-
// "Jump to latest" FAB. Bottom-right, circular, lavender brand accent.
// `data-hidden` encodes visibility (state inline-styled would clobber the
// `:active` press feedback). Inline `bottom` at the use site offsets for
diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx
index bf50b01a..658a4938 100644
--- a/src/app/features/room/RoomTimeline.tsx
+++ b/src/app/features/room/RoomTimeline.tsx
@@ -22,12 +22,11 @@ import {
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { HTMLReactParserOptions } from 'html-react-parser';
-import classNames from 'classnames';
import { Editor } from 'slate';
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
import to from 'await-to-js';
import { useAtomValue, useSetAtom } from 'jotai';
-import { Box, Chip, Icon, Icons, Text, as, config } from 'folds';
+import { Box, Icon, Icons, Text, config } from 'folds';
import { isKeyHotkey } from 'is-hotkey';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { useTranslation } from 'react-i18next';
@@ -96,9 +95,14 @@ import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResize
import * as css from './RoomTimeline.css';
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
import { isEmptyEditor } from '../../components/editor';
-import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
+import {
+ draftKey,
+ roomIdToEditDraftAtomFamily,
+ roomIdToReplyDraftAtomFamily,
+} from '../../state/room/roomInputDrafts';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
+import { useStateEvent } from '../../hooks/useStateEvent';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
import { RenderMessageContent } from '../../components/RenderMessageContent';
@@ -127,19 +131,6 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { RoomTimelineTyping } from './RoomTimelineTyping';
import { MessageErrorBoundary } from './MessageErrorBoundary';
-const TimelineFloat = as<'div', css.TimelineFloatVariants>(
- ({ position, className, ...props }, ref) => (
-
- )
-);
-
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
@@ -593,6 +584,9 @@ export function RoomTimeline({
// / DM / legacy timeline). Drawer composer manages its own per-thread
// reply draft via DraftKey([roomId, rootId]) inside RoomInput.
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId)));
+ // Edit-draft setter — same MAIN-composer scope. «Edit» loads the message
+ // into the composer (RoomInput's edit banner) instead of an in-timeline form.
+ const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId)));
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
@@ -613,6 +607,14 @@ export function RoomTimeline({
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
+ // Mirror of RoomView's composer mount condition (tombstone / canMessage /
+ // thread drawer). Editing happens IN the composer now, so the Edit
+ // affordance must hide whenever the main composer is unmounted —
+ // otherwise the tap silently writes an edit draft no one consumes, which
+ // then hijacks the composer if it mounts later (e.g. permission restored).
+ const canMessage = permissions.event(MessageEvent.RoomMessage, mx.getSafeUserId());
+ const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
+ const mainComposerSuspended = threadDrawerOpen || !canMessage || !!tombstoneEvent;
const roomToParents = useAtomValue(roomToParentsAtom);
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
@@ -735,21 +737,20 @@ export function RoomTimeline({
return () => scrollEl.removeEventListener('scroll', onScroll);
}, [getScrollElement, setOpenMessageId]);
- const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
- useVirtualPaginator({
- count: eventsLength,
- limit: PAGINATION_LIMIT,
- range: timeline.range,
- onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
- getScrollElement,
- getItemElement: useCallback(
- (index: number) =>
- (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
- undefined,
- []
- ),
- onEnd: handleTimelinePagination,
- });
+ const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
+ count: eventsLength,
+ limit: PAGINATION_LIMIT,
+ range: timeline.range,
+ onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+ getScrollElement,
+ getItemElement: useCallback(
+ (index: number) =>
+ (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+ undefined,
+ []
+ ),
+ onEnd: handleTimelinePagination,
+ });
const loadEventTimeline = useEventTimelineLoader(
mx,
@@ -947,7 +948,6 @@ export function RoomTimeline({
);
const {
- editId,
handleEdit,
handleOpenReply,
handleUserClick,
@@ -957,8 +957,9 @@ export function RoomTimeline({
} = useMessageInteractionHandlers({
room,
editor,
- composerSuspended: threadDrawerOpen,
+ composerSuspended: mainComposerSuspended,
setReplyDraft,
+ setEditDraft,
onOpenEvent: handleOpenEvent,
channelsMode,
isBridged,
@@ -1263,22 +1264,6 @@ export function RoomTimeline({
}
}, [unread]);
- // scroll out of view msg editor in view.
- useEffect(() => {
- if (editId) {
- const editMsgElement =
- (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
- undefined;
- if (editMsgElement) {
- scrollToElement(editMsgElement, {
- align: 'center',
- behavior: 'smooth',
- stopInView: true,
- });
- }
- }
- }, [scrollToElement, editId]);
-
const handleJumpToLatest = () => {
if (eventId) {
navigateRoom(room.roomId, undefined, { replace: true });
@@ -1288,17 +1273,6 @@ export function RoomTimeline({
scrollToBottomRef.current.smooth = false;
};
- const handleJumpToUnread = () => {
- if (unreadInfo?.readUptoEventId) {
- setTimeline(getEmptyTimeline());
- loadEventTimeline(unreadInfo.readUptoEventId);
- }
- };
-
- const handleMarkAsRead = () => {
- markAsRead(mx, room.roomId, hideActivity);
- };
-
const { t } = useTranslation();
// Sticky day capsules (every room class — 1:1 bubble, group, channel). Each
@@ -1697,7 +1671,6 @@ export function RoomTimeline({
collapse={collapse}
railHidden={railHidden}
highlight={highlighted}
- edit={editId === mEventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
@@ -1707,7 +1680,7 @@ export function RoomTimeline({
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
- onEditId={handleEdit}
+ onEditId={mainComposerSuspended ? undefined : handleEdit}
reply={
replyEventId && (
{/* Day dates are the inline capsules themselves (every room class), made
sticky via real CSS `position: sticky` (engaged by the effect above) —
no separate floating pill. */}
- {unreadFloatShown && (
-
- }
- onClick={handleJumpToUnread}
- >
- {t('Room.jump_to_unread')}
-
-
- }
- onClick={handleMarkAsRead}
- >
- {t('Room.mark_as_read')}
-
-
- )}
{
};
export function RoomView({ eventId }: { eventId?: string }) {
+ const { t } = useTranslation();
const roomInputRef = useRef(null);
const roomViewRef = useRef(null);
const composerWrapRef = useRef(null);
@@ -200,7 +202,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
alignItems="Center"
justifyContent="Center"
>
- You do not have permission to post in this room
+ {t('Room.no_post_permission')}
)}
>
diff --git a/src/app/features/room/RoomViewHeaderDm.tsx b/src/app/features/room/RoomViewHeaderDm.tsx
index 6298140f..90a5d6f0 100644
--- a/src/app/features/room/RoomViewHeaderDm.tsx
+++ b/src/app/features/room/RoomViewHeaderDm.tsx
@@ -31,7 +31,6 @@ import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
import { useSpaceOptionally } from '../../hooks/useSpace';
-import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
@@ -42,7 +41,6 @@ import {
useRoomMembersSheetState,
} from '../../state/hooks/roomMembersSheet';
import { callEmbedAtom } from '../../state/callEmbed';
-import { RoomSettingsPage } from '../../state/roomSettings';
import { StateEvent } from '../../../types/matrix/room';
import {
getMxIdLocalPart,
@@ -179,7 +177,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const membersSheetState = useRoomMembersSheetState();
const membersSheetOpen = membersSheetState?.roomId === room.roomId;
const parentSpace = useSpaceOptionally();
- const openSettings = useOpenRoomSettings();
// The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same
// chrome on desktop and mobile.
@@ -203,13 +200,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
openRoomMembersSheet(room.roomId);
}
};
- // `callView` keeps the legacy «open Members in Settings» fallback —
- // the members side-pane isn't mounted while we're inside the call
- // surface, so a separate path is still needed to reach the list.
- const handleCallViewMembers = () => {
- openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
- };
-
const avatarNode = (
{isOneOnOne && peerUserId ? (
@@ -356,30 +346,9 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
{callButtonVisible && }
- {/* Member toggle — kept only for `callView` (1:1 or group): the
- members side-pane / horseshoe isn't mounted under the call
- surface, so we still need a way to reach the member list
- via Room Settings → Members. Non-call group rooms now use
- the identity button above (tap on avatar+title) — keeps
- the gesture symmetrical with 1:1 peer profile. */}
- {callView && screenSize === ScreenSize.Desktop && (
-
- {t('Room.members')}
-
- }
- >
- {(triggerRef) => (
-
-
-
- )}
-
- )}
-
+ {/* No separate members button in callView — CallView renders its
+ own member list, and the Room-Settings→Members page this used
+ to open was removed with the room-settings redesign. */}
setUserAvatarMode(true)}
+ // Back to the room card (members sheet) — `openSheet`
+ // clears the profile atom via the hooks' mutual
+ // exclusion, so the silhouette body swaps in place
+ // (both atoms keep `open` true → no close/reopen
+ // animation).
+ onBack={() => openSheet(room.roomId)}
/>
) : (
diff --git a/src/app/features/room/RoomViewProfilePanel.tsx b/src/app/features/room/RoomViewProfilePanel.tsx
index f650b919..fd26b282 100644
--- a/src/app/features/room/RoomViewProfilePanel.tsx
+++ b/src/app/features/room/RoomViewProfilePanel.tsx
@@ -553,6 +553,10 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
setAvatarMode(true)}
+ // Root card (1:1 peer profile) — nothing to go
+ // back to, so the top-left affordance is a
+ // plain close cross (redesign item 15).
+ onClose={close}
/>
)}
diff --git a/src/app/features/room/RoomViewProfileSidePanel.tsx b/src/app/features/room/RoomViewProfileSidePanel.tsx
index bfa5648c..0de612be 100644
--- a/src/app/features/room/RoomViewProfileSidePanel.tsx
+++ b/src/app/features/room/RoomViewProfileSidePanel.tsx
@@ -13,7 +13,8 @@ import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import { userRoomProfileAtom } from '../../state/userRoomProfile';
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
-import { useRoom } from '../../hooks/useRoom';
+import { useOpenRoomMembersSheet } from '../../state/hooks/roomMembersSheet';
+import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
import { stopPropagation } from '../../utils/keyboard';
import { PageHeader } from '../../components/page';
@@ -25,6 +26,8 @@ export function RoomViewProfileSidePanel() {
const profileState = useAtomValue(userRoomProfileAtom);
const close = useCloseUserRoomProfile();
const room = useRoom();
+ const isOneOnOne = useIsOneOnOne();
+ const openMembersSheet = useOpenRoomMembersSheet();
const open = !!profileState;
// Inline avatar-expanded mode: the hero avatar stretches to fill
@@ -78,6 +81,25 @@ export function RoomViewProfileSidePanel() {
chat header uses, so the bottom rule lines up with the
adjacent `RoomViewHeaderDm` row to the pixel. */}
+ {/* Group rooms: leading back arrow returns to the members sheet
+ (the room card) the profile replaced in this pane — the open
+ hook clears the profile atom, so the panes swap in place
+ (redesign item 15). 1:1 has no members sheet → no arrow.
+ Call rooms neither: Room.tsx mounts the members side pane only
+ when `!callView`, so opening the sheet atom from inside a call
+ would close the profile into nothing AND leak a stale atom
+ that auto-opens the pane in the next group room. */}
+ {!isOneOnOne && !room.isCallRoom() && (
+
+ openMembersSheet(room.roomId)}
+ aria-label={t('User.back')}
+ >
+
+
+
+ )}
{t('User.profile_title')}
diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx
index 676d49aa..bfebb72a 100644
--- a/src/app/features/room/ThreadDrawer.tsx
+++ b/src/app/features/room/ThreadDrawer.tsx
@@ -71,7 +71,11 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
import { roomToParentsAtom } from '../../state/room/roomToParents';
-import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
+import {
+ draftKey,
+ roomIdToEditDraftAtomFamily,
+ roomIdToReplyDraftAtomFamily,
+} from '../../state/room/roomInputDrafts';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import {
@@ -383,6 +387,9 @@ export function ThreadDrawer({
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
// the chip surfaces inside the drawer composer.
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));
+ // Edit-draft scope: same tuple key — «Edit» on a thread row loads it into
+ // the DRAWER composer (its RoomInput subscribes to this slot).
+ const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId, rootId)));
// Reply-chip click scrolls within the drawer to the matching
// `data-message-id` row. If the target lives in the main timeline
@@ -397,7 +404,6 @@ export function ThreadDrawer({
}, []);
const {
- editId,
handleEdit,
handleOpenReply,
handleUserClick,
@@ -417,6 +423,7 @@ export function ThreadDrawer({
// bail before touching the unmounted editor.
composerSuspended: !canMessage,
setReplyDraft,
+ setEditDraft,
onOpenEvent: handleScrollToDrawerEvent,
channelsMode,
isBridged,
@@ -1136,7 +1143,6 @@ export function ThreadDrawer({
mEvent={mEvent}
collapse={false}
highlight={false}
- edit={editId === eventId}
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
canSendReaction={canSendReaction}
canPinEvent={canPinEvent}
@@ -1146,7 +1152,7 @@ export function ThreadDrawer({
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
- onEditId={handleEdit}
+ onEditId={canMessage ? handleEdit : undefined}
reply={
showReplyChip && (
- }>
-
-
-
-
-
-
-
-
+ {/* EventReaders owns its Overlay + FocusTrap (self-contained Vojo card). */}
+ {open && }
}
@@ -809,7 +793,6 @@ export type MessageProps = {
// by the channel layout.
railHidden?: boolean;
highlight: boolean;
- edit?: boolean;
canDelete?: boolean;
canSendReaction?: boolean;
canPinEvent?: boolean;
@@ -930,7 +913,6 @@ const MessageInner = as<'div', MessageProps>(
// but unused by the bubble/channel layouts. Pending the stream-rail cleanup.
railHidden: _railHidden,
highlight,
- edit,
canDelete,
canSendReaction,
canPinEvent,
@@ -1010,7 +992,7 @@ const MessageInner = as<'div', MessageProps>(
if (id) setOpenMessageId((prev) => (prev === id ? null : id));
};
const handleBubbleToggle: MouseEventHandler = (evt) => {
- if (!isBubble || edit) return;
+ if (!isBubble) return;
// Don't hijack a text selection, nor taps on interactive children (links,
// buttons, media players, the waveform slider, the action rail, reaction
// chips) — those run their own action and must not also toggle the rail.
@@ -1030,7 +1012,7 @@ const MessageInner = as<'div', MessageProps>(
// holds focus (not a child link/button), so Enter on a focused link still
// follows it. Replaces the focus-within reveal the hover rail used to give.
const handleBubbleKeyDown: KeyboardEventHandler = (evt) => {
- if (!isBubble || edit) return;
+ if (!isBubble) return;
if (evt.target !== evt.currentTarget) return;
if (evt.key === 'Enter' || evt.key === ' ') {
evt.preventDefault();
@@ -1048,20 +1030,19 @@ const MessageInner = as<'div', MessageProps>(
// a local useState here sidesteps the commit↔effect race where
// decryption fires between render and listener attach.
const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
- const mediaMode = isMediaMessage && !edit;
+ const mediaMode = isMediaMessage;
// Voice notes are self-chromed cards — VoiceContent draws its own avatar +
// bubble. Collapse the asymmetric Stream bubble (DM) and drop the channel
// avatar (group) so a voice note renders identically for own/other, the only
// difference being the bubble fill. See docs/plans/voice_messages.md §5.
const isVoiceMessage = msgType === MsgType.Audio && isVoiceMessageContent(mEvent.getContent());
- const voiceMode = isVoiceMessage && !edit;
+ const voiceMode = isVoiceMessage;
if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
logMedia('Message', {
eventId: mEvent.getId(),
msgType,
isMediaMessage,
- edit,
mediaMode,
isMobile,
screenSize,
@@ -1082,29 +1063,12 @@ const MessageInner = as<'div', MessageProps>(
const msgContentJSX = (
{reply}
- {edit && onEditId ? (
- // Edit form styled as the main composer — a single rounded card
- // (`ChatComposer` paints the inner Editor with Surface.Container + 32px
- // radius). The bubble drops its own chrome while editing (see
- // BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble.
-
- onEditId()}
- />
-
- ) : (
- children
- )}
+ {children}
);
const handleContextMenu: MouseEventHandler = (evt) => {
- if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
+ if (evt.altKey || !window.getSelection()?.isCollapsed) return;
const tag = (evt.target as Element).tagName;
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
evt.preventDefault();
@@ -1153,8 +1117,7 @@ const MessageInner = as<'div', MessageProps>(
// bar can render in two places: as a row-anchored overlay for channel/stream
// rows, and — for the bubble layout — passed into BubbleLayout so it floats
// centred just above the bubble itself.
- const railVisible =
- !edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor);
+ const railVisible = (isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor;
// The reaction emoji board, shared by the desktop anchored PopOut and the
// mobile centred Overlay (an anchored PopOut drifts off-screen on the small
// native viewport — `съезжает` — so mobile centres it instead).
@@ -1423,7 +1386,7 @@ const MessageInner = as<'div', MessageProps>(
)}
{/* Mobile: the reaction emoji board renders as a centred Overlay (an
anchored PopOut drifts off the small native viewport). */}
- {isMobile && canSendReaction && !edit && !!emojiBoardAnchor && (
+ {isMobile && canSendReaction && !!emojiBoardAnchor && (
}>
(
// `collapsed`.
collapsed={collapse}
selected={bubbleSelected}
- // While editing, the body IS a composer card — drop the bubble chrome
- // so it reads as one box, not a composer nested in a bubble.
- editing={!!edit}
// Grouped same-minute series → timestamp below the bubble; singles keep
// it on the side (RoomTimeline computes timeBelow). Media is always below.
timeBelow={timeBelow}
@@ -1505,7 +1465,7 @@ const MessageInner = as<'div', MessageProps>(
// Read/delivery status under the user's LAST own message only — at
// the live end of the timeline (RoomTimeline computes isLatestOwn).
readStatus={
- isOwnMessage && isLatestOwn && !edit ? (
+ isOwnMessage && isLatestOwn ? (
) : undefined
}
@@ -1602,7 +1562,6 @@ function areMessagePropsEqual(
prev.collapse === next.collapse &&
prev.railHidden === next.railHidden &&
prev.highlight === next.highlight &&
- prev.edit === next.edit &&
prev.canDelete === next.canDelete &&
prev.canSendReaction === next.canSendReaction &&
prev.canPinEvent === next.canPinEvent &&
diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx
deleted file mode 100644
index 98793c2d..00000000
--- a/src/app/features/room/message/MessageEditor.tsx
+++ /dev/null
@@ -1,379 +0,0 @@
-import React, {
- KeyboardEventHandler,
- MouseEventHandler,
- useCallback,
- useEffect,
- useState,
-} from 'react';
-import {
- Box,
- Chip,
- Icon,
- IconButton,
- Icons,
- Line,
- PopOut,
- RectCords,
- Spinner,
- Text,
- as,
- config,
-} from 'folds';
-import { Editor, Transforms } from 'slate';
-import { ReactEditor } from 'slate-react';
-import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
-import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
-import { isKeyHotkey } from 'is-hotkey';
-import {
- AUTOCOMPLETE_PREFIXES,
- AutocompletePrefix,
- AutocompleteQuery,
- CustomEditor,
- EmoticonAutocomplete,
- RoomMentionAutocomplete,
- Toolbar,
- UserMentionAutocomplete,
- createEmoticonElement,
- customHtmlEqualsPlainText,
- getAutocompleteQuery,
- getPrevWorldRange,
- htmlToEditorInput,
- moveCursor,
- plainToEditorInput,
- toMatrixCustomHTML,
- toPlainText,
- trimCustomHtml,
- useEditor,
- getMentions,
-} from '../../../components/editor';
-import { useSetting } from '../../../state/hooks/settings';
-import { settingsAtom } from '../../../state/settings';
-import { UseStateProvider } from '../../../components/UseStateProvider';
-import { EmojiBoard } from '../../../components/emoji-board';
-import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
-import { useMatrixClient } from '../../../hooks/useMatrixClient';
-import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
-import { mobileOrTablet } from '../../../utils/user-agent';
-import { useComposingCheck } from '../../../hooks/useComposingCheck';
-
-type MessageEditorProps = {
- roomId: string;
- room: Room;
- mEvent: MatrixEvent;
- imagePackRooms?: Room[];
- onCancel: () => void;
-};
-export const MessageEditor = as<'div', MessageEditorProps>(
- ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
- const mx = useMatrixClient();
- const editor = useEditor();
- const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
- const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
- // The edit toolbar starts collapsed (the `editorToolbar` opt-in setting was
- // removed — it only ever gated this one initial-open flag).
- const [toolbar, setToolbar] = useState(false);
- const isComposing = useComposingCheck();
-
- const [autocompleteQuery, setAutocompleteQuery] =
- useState>();
-
- const getPrevBodyAndFormattedBody = useCallback((): [
- string | undefined,
- string | undefined,
- IMentions | undefined
- ] => {
- const evtId = mEvent.getId();
- if (!evtId) return [undefined, undefined, undefined];
- const evtTimeline = room.getTimelineForEvent(evtId);
- const editedEvent =
- evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
-
- const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
- const { body, formatted_body: customHtml }: Record = content;
-
- const mMentions: IMentions | undefined = content['m.mentions'];
-
- return [
- typeof body === 'string' ? body : undefined,
- typeof customHtml === 'string' ? customHtml : undefined,
- mMentions,
- ];
- }, [room, mEvent]);
-
- const [saveState, save] = useAsyncCallback(
- useCallback(async () => {
- const plainText = toPlainText(editor.children, isMarkdown).trim();
- const customHtml = trimCustomHtml(
- toMatrixCustomHTML(editor.children, {
- allowTextFormatting: true,
- allowBlockMarkdown: isMarkdown,
- allowInlineMarkdown: isMarkdown,
- })
- );
-
- const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
-
- if (plainText === '') return undefined;
- if (prevBody) {
- if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
- return undefined;
- }
- if (
- !prevCustomHtml &&
- prevBody === plainText &&
- customHtmlEqualsPlainText(customHtml, plainText)
- ) {
- return undefined;
- }
- }
-
- const newContent: IContent = {
- msgtype: mEvent.getContent().msgtype,
- body: plainText,
- };
-
- const mentionData = getMentions(mx, roomId, editor);
-
- prevMentions?.user_ids?.forEach((prevMentionId) => {
- mentionData.users.add(prevMentionId);
- });
-
- const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
- newContent['m.mentions'] = mMentions;
-
- if (!customHtmlEqualsPlainText(customHtml, plainText)) {
- newContent.format = 'org.matrix.custom.html';
- newContent.formatted_body = customHtml;
- }
-
- const content: IContent = {
- ...newContent,
- body: `* ${plainText}`,
- 'm.new_content': newContent,
- 'm.relates_to': {
- event_id: mEvent.getId(),
- rel_type: RelationType.Replace,
- },
- };
-
- // Pass threadRootId so the local-echo MatrixEvent gets its
- // `.thread` pointer set via `localEvent.setThread(thread)`
- // (client.js:1872-1875) and `Thread.onEcho` re-emits
- // `ThreadEvent.Update` post-send, which the drawer subscribes
- // to. SDK `addThreadRelationIfNeeded` (client.js:1810-1820)
- // doesn't inject `m.thread` because m.replace already owns
- // `m.relates_to.rel_type` — spec-correct, edit aggregation
- // chains via the original event's relation. Element-web's
- // EditMessageComposer.tsx:349-351 uses the same pattern.
- // M4a-followup: own-send local-echo of m.replace doesn't
- // aggregate into `thread.timelineSet.relations` (canContain
- // rejects against unfilteredTimelineSet at
- // event-timeline-set.js:786-803, and the thread-side path runs
- // only on remote echo). User sees the new body only after
- // /sync round-trip — same lag as M4 receipts. Optimistic
- // local-replace via `mEvent.makeReplaced(localEvent)` is
- // tracked for follow-up.
- return mx.sendMessage(
- roomId,
- mEvent.threadRootId ?? null,
- content as RoomMessageEventContent
- );
- }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
- );
-
- const handleSave = useCallback(() => {
- if (saveState.status !== AsyncStatus.Loading) {
- save();
- }
- }, [saveState, save]);
-
- const handleKeyDown: KeyboardEventHandler = useCallback(
- (evt) => {
- if (
- (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
- !isComposing(evt)
- ) {
- evt.preventDefault();
- handleSave();
- }
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- onCancel();
- }
- },
- [onCancel, handleSave, enterForNewline, isComposing]
- );
-
- const handleKeyUp: KeyboardEventHandler = useCallback(
- (evt) => {
- if (isKeyHotkey('escape', evt)) {
- evt.preventDefault();
- return;
- }
-
- const prevWordRange = getPrevWorldRange(editor);
- const query = prevWordRange
- ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
- : undefined;
- setAutocompleteQuery(query);
- },
- [editor]
- );
-
- const handleCloseAutocomplete = useCallback(() => {
- ReactEditor.focus(editor);
- setAutocompleteQuery(undefined);
- }, [editor]);
-
- const handleEmoticonSelect = (key: string, shortcode: string) => {
- editor.insertNode(createEmoticonElement(key, shortcode));
- moveCursor(editor);
- };
-
- useEffect(() => {
- const [body, customHtml] = getPrevBodyAndFormattedBody();
-
- const initialValue =
- typeof customHtml === 'string'
- ? htmlToEditorInput(customHtml, isMarkdown)
- : plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
-
- Transforms.select(editor, {
- anchor: Editor.start(editor, []),
- focus: Editor.end(editor, []),
- });
-
- editor.insertFragment(initialValue);
- if (!mobileOrTablet()) ReactEditor.focus(editor);
- }, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
-
- useEffect(() => {
- if (saveState.status === AsyncStatus.Success) {
- onCancel();
- }
- }, [saveState, onCancel]);
-
- return (
-
diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts
index 0580fe70..38345c48 100644
--- a/src/app/features/settings/MobileSettingsHorseshoe.css.ts
+++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts
@@ -1,19 +1,13 @@
import { style } from '@vanilla-extract/css';
import { color, toRem } from 'folds';
-import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
-
-// Re-exported so the TSX can pick up the constants without crossing
-// the vanilla-extract / runtime boundary twice.
-export const HORSESHOE_RADIUS_PX = VOJO_HORSESHOE_RADIUS_PX;
-export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
+import { VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
// Outer container — `position: relative` anchor for the two absolutely-
// positioned panes (`appBody` and `silhouette`). `overflow: hidden`
-// clips anything that overflows the wrapper's bounds and crops the
-// rounded carves on both panes against the container's bg (which is
-// painted with `VOJO_HORSESHOE_VOID_COLOR` inline when the sheet is
-// active, so the carved-out areas read as the same near-black seam
-// used everywhere else in the app).
+// clips the full-bleed children at the screen edges; the rounded top
+// corners come from the silhouette's own static border-radius, whose
+// corner triangles stay TRANSPARENT and reveal the live DM list behind
+// (the media-viewer composition — no void colour, no carve).
//
// `flex: 1` so the container fills whatever flex slot it's mounted in
// (PageNav's inner column for the Direct route).
@@ -22,16 +16,10 @@ export const HORSESHOE_GAP_PX = VOJO_HORSESHOE_GAP_PX;
// status-bar safe-top zone reserved by `PageNav` via `padding-top`,
// and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
// keeps the wrapped DM list anchored at the same visual Y as before
-// the shift. The combination has two load-bearing effects:
-//
-// (1) The settings-sheet clip-path mask on `appBody` carves rounded
-// BL/BR into an opaque surface that already paints THROUGH the
-// status-bar strip — without the upward extension the carve
-// would visibly stop at the bottom of the system-tray strip.
-// (2) `appBody`'s bg paints the safe-top strip itself in the same
-// `SurfaceVariant.Container` tone as `PageNav-inner` / the
-// pager's static header, giving the system-tray text a
-// consistent backdrop across surfaces.
+// the shift. The extension is what lets the near-fullscreen sheet's
+// top edge (railHeight = containerHeight − safeTop) land exactly at
+// the bottom of the status bar, while `appBody`'s bg paints the strip
+// in the same `SurfaceVariant.Container` tone as the pager header.
//
// Note: the curtain never RESTS inside the safe-top zone — every snap
// destination floors at `top: 0` of the stage (= `y = safe-top` in
@@ -52,38 +40,25 @@ export const container = style({
});
// === App body === Holds the wrapped children (the DM list — header,
-// scroll content, DirectSelfRow). Fills the container via `inset: 0`.
-// Does NOT translate or shrink — the DM list stays exactly where it
-// was in the closed state. Instead, the bottom of the pane is masked
-// away by an animated `clip-path:
-// inset(...)` with rounded BL/BR corners — the user sees the visible
-// top portion of the DM list with a rounded carve at the new bottom
-// edge, exactly like the profile horseshoe shows the chat with a
-// rounded TOP carve as the panel masks it from above.
+// scroll content, DirectSelfRow), full-bleed behind the silhouette via
+// `inset: 0`. NOT clipped or translated: the DM list keeps its layout,
+// scroll position and measured row heights; the opaque sheet simply
+// grows over it, and the sheet's transparent rounded corners reveal
+// this live surface — exactly the media-viewer composition.
//
-// Why clip-path rather than flex-shrink + margin-bottom: a flex-
-// shrink approach changes the scroll-container's height every render,
-// and the DM list's `@tanstack/react-virtual` re-measures items mid-
-// gesture. Why not `transform: translateY` either: translating moves
-// the whole pane up off the top of the viewport, including the
-// StreamHeader the user wants to keep visible. Clip-path leaves
-// layout unchanged — the DM list keeps its scroll position, its
-// measured heights, and its top items in place; only the bottom edge
-// of what's visible gets carved into the void below.
+// `isolation: isolate` is load-bearing: it makes appBody a PERMANENT
+// stacking context, which the pager refresh singleton's paint-order
+// contract counts on (see mobile-tabs-pager/style.css.ts::
+// pagerRefreshSingleton). The always-on clip-path used to provide this
+// as a side effect; with the carve gone, isolate keeps the guarantee.
//
-// `backgroundColor: SurfaceVariant.Container` is load-bearing on two
-// counts: (1) it must be OPAQUE so the container's void colour
-// (painted inline when the sheet is active) doesn't bleed through gaps
-// between DM list rows; (2) the safe-top padding region of THIS
-// element is what paints the system-tray strip when the wrapper
-// container is extended up over it (see `container.marginTop` above).
-// Picking `SurfaceVariant.Container` (not `Background.Container`)
-// matches the Bots / ChannelsRoot status-bar tone exactly — Bots
-// renders `PageNav-inner.bg = SurfaceVariant.Container` in the safe-
-// top zone, and the StreamHeader curtain (`Background.Container`)
-// overpaints that lighter strip with the darker tone as it's dragged
-// up. Mirroring the same two tones here gives Direct the same visible
-// «curtain darkens the strip» transition the user expects.
+// `backgroundColor: SurfaceVariant.Container` is load-bearing: it must
+// be OPAQUE so nothing behind the wrapper bleeds through gaps between
+// DM-list rows, and the safe-top padding region of THIS element is
+// what paints the system-tray strip when the wrapper container is
+// extended up over it (see `container.marginTop` above). Picking
+// `SurfaceVariant.Container` (not `Background.Container`) matches the
+// Bots / ChannelsRoot status-bar tone exactly.
//
// `flex: column` so the children (which expect a flex column parent
// — PageNav uses it) still stack naturally. `paddingTop:
@@ -102,18 +77,17 @@ export const appBody = style({
minHeight: 0,
backgroundColor: color.SurfaceVariant.Container,
paddingTop: 'var(--vojo-safe-top, 0px)',
- willChange: 'clip-path',
+ isolation: 'isolate',
});
// === Silhouette === The Settings sheet's surface. Anchored at the
// bottom of the container; its height animates 0 → railHeight as the
-// user drags up. Rounded TL/TR carve the top edge against the void
-// gap between it and the translated-up appBody.
-//
-// `overflow: hidden` clips `panelContent` (which is railHeight tall,
-// top-anchored) so the visible portion of the panel is just the
-// silhouette's current height — the user sees more of the panel
-// content reveal from the top as silhouette grows.
+// user drags up. STATIC rounded top corners (same radius as the tab
+// curtains / media sheet); `overflow: hidden` clips the panel content
+// to the curve, and the two corner triangles paint NOTHING — they stay
+// transparent and reveal the live DM list behind (`appBody` is
+// full-bleed, not clipped), so the rounding reads against real content
+// with no backing square. Only the height animates (`willChange`).
//
// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
// chat-pane tone, same as the Settings PageNav inside (set via
@@ -122,9 +96,6 @@ export const appBody = style({
// matches the PageNav tone. With `Background.Container` (#0d0e11)
// here, the user saw a dark stripe at that seam on Samsung S24
// edge-to-edge; matching silhouette to the PageNav tone closes it.
-// Same idea as commit 77bb72d which dynamically retunes
-// `--vojo-safe-area-bg` while a Room is mounted to keep the
-// system-bar strips and the chat surface in lockstep.
export const silhouette = style({
position: 'absolute',
bottom: 0,
@@ -134,7 +105,9 @@ export const silhouette = style({
flexDirection: 'column',
overflow: 'hidden',
backgroundColor: color.SurfaceVariant.Container,
- willChange: 'height, border-top-left-radius, border-top-right-radius',
+ borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
+ borderTopRightRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
+ willChange: 'height',
});
// Anchored at the TOP of `silhouette` so as silhouette grows from 0
diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx
index 93dec21a..f252308e 100644
--- a/src/app/features/settings/MobileSettingsHorseshoe.tsx
+++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx
@@ -1,28 +1,18 @@
-// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list.
-// Mirror of `MobileProfileHorseshoe` in features/room — the chat
-// there is wrapped by a top-down horseshoe (panel above, chat below
-// with a 12px void). Here we invert: the wrapped app body is above,
-// the Settings sheet emerges from below, and a 12px void separates
-// them in a )|( silhouette.
+// Bottom-up near-fullscreen Settings sheet that wraps the mobile
+// Direct DM list. Geometry mirrors the MEDIA-VIEWER sheet
+// (`MobileMediaViewerHorseshoe`): the opaque sheet grows from the
+// bottom over the full-bleed app body and stops just under the system
+// status bar; its STATIC rounded top corners stay transparent and
+// reveal the live DM list behind. No void gap, no clip-path carve, no
+// emerge ramp — only the sheet's height animates.
//
// User-visible behaviour:
//
// • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
-// stays exactly where it was — no translate,
-// no shrink. The bottom of the visible portion is "masked away"
-// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx
-// Rpx)` with rounded BL/BR carves at the new visible edge. The
-// carved area exposes the container's void colour underneath, and
-// the silhouette below covers the rest of the masked zone. Why
-// not `transform: translateY`: translating moves the TOP of the
-// pane out of the viewport (StreamHeader scrolls off-
-// screen). Why not `flex-shrink + margin-bottom`: the virtualized
-// DM list (`@tanstack/react-virtual`) re-measures items every
-// time the scroll container resizes — items above the shrinking
-// edge visibly smear. Clip-path leaves layout unchanged: the DM
-// list keeps its scroll position, its measured heights, and its
-// top items in place; only the bottom edge of what's visible is
-// carved into the void.
+// stays exactly where it was — no translate, no shrink, no clip.
+// The virtualized DM list (`@tanstack/react-virtual`) keeps its
+// scroll position and measured row heights; the sheet simply
+// paints over it.
// • Drag-up origin is `DirectSelfRow` itself, marked with the
// `data-settings-drag-origin` attribute. A document-level
// touchstart / pointerdown listener uses `target.closest()` to
@@ -56,7 +46,6 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { HorseshoeEnabledContext } from '../../components/page';
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
-import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
import { Settings } from './Settings';
import * as css from './MobileSettingsHorseshoe.css';
@@ -72,26 +61,6 @@ const ANIMATION_MS = 250;
// or close from the panel handle). Mirrors the profile horseshoe's
// 80px so the two gestures feel identical.
const COMMIT_THRESHOLD_PX = 80;
-// Fixed sheet height — 2/3 of viewport, what the user signed off on.
-// Internal scrolling inside Settings sub-pages handles content
-// overflow; no rail re-sizing on menu↔sub-page navigation.
-const RAIL_FRACTION = 2 / 3;
-// Drag distance over which the radii + void-gap ramp from 0 to their
-// full value during finger-drag — same as the profile horseshoe's
-// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the
-// silhouette is fully formed exactly when the gesture qualifies to
-// commit.
-const HORSESHOE_EMERGE_PX = 80;
-
-// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror
-// of the profile horseshoe's emerge curve (file rationale there). The
-// linear ramp from round 1 came out too "snappy" because the rounding
-// jumped in within the first ~10px of drag; the cubic keeps the corners
-// barely visible until ~40% of the way through the gesture, then
-// blossoms around the midpoint. Used only during finger-drag; release
-// transitions use the asymmetric VAUL_EASING curve in CSS.
-const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
-
type DragSource = 'directSelfRow' | 'handle';
// Axis dead-zone for horizontal-bail. The finger must travel this far
@@ -129,17 +98,50 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const inPagerMode = useMobilePagerPane() !== null;
const [drag, setDrag] = useState(null);
- const [viewportHeight, setViewportHeight] = useState(() =>
+
+ // Measure the wrapper container directly via `ResizeObserver` — the
+ // container extends up over the status bar (css `marginTop:
+ // -var(--vojo-safe-top)`), so its measured height already includes the
+ // strip; subtracting the live safe-top inset below puts the sheet's top
+ // edge exactly at the bottom of the status bar. Mirrors the media-viewer
+ // sheet's calc (MobileMediaViewerHorseshoe).
+ const containerRef = useRef(null);
+ const [containerHeight, setContainerHeight] = useState(() =>
typeof window === 'undefined' ? 800 : window.innerHeight
);
-
- useEffect(() => {
- const onResize = () => setViewportHeight(window.innerHeight);
- window.addEventListener('resize', onResize);
- return () => window.removeEventListener('resize', onResize);
+ useLayoutEffect(() => {
+ const el = containerRef.current;
+ if (!el) return undefined;
+ setContainerHeight(el.clientHeight);
+ const ro = new ResizeObserver((entries) => {
+ const cr = entries[0]?.contentRect;
+ if (cr) setContainerHeight(cr.height);
+ });
+ ro.observe(el);
+ return () => ro.disconnect();
}, []);
- const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION);
+ // Live status-bar inset. `env(safe-area-inset-top)` can't be read
+ // reliably off a custom property in JS, so we measure a zero-width
+ // probe whose height is `var(--vojo-safe-top)` (= the env value).
+ const safeTopRef = useRef(null);
+ const [safeTopPx, setSafeTopPx] = useState(0);
+ useLayoutEffect(() => {
+ const el = safeTopRef.current;
+ if (!el) return undefined;
+ setSafeTopPx(el.offsetHeight);
+ const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight));
+ ro.observe(el);
+ return () => ro.disconnect();
+ }, []);
+
+ // Near-fullscreen by design (user request: Settings covers the app body
+ // and stops just under the system status bar / tray): the rail is the
+ // measured container height minus the live status-bar inset, so the
+ // sheet's rounded top edge lands at the bottom of the status bar, never
+ // over the tray icons. Shrunken wrappers (call rail / split-screen)
+ // shrink the sheet with them via the ResizeObserver above.
+ const railHeightPx = Math.max(0, containerHeight - safeTopPx);
const open = !!sheet;
// Entry-animation gate. On cold-start deep-links (push notification
@@ -195,15 +197,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
const expandedPx = drag
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
: baseExpanded;
- const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
const isDragging = drag !== null;
const horseshoeActive = expandedPx > 0;
// Bridge our local `horseshoeActive` (geometric) signal up to the
- // pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
- // static header from the first frame of drag — see the atom's docs
- // for the «no black flash» rationale. The cleanup writing `false`
- // covers route unmount mid-drag.
+ // pager-shared atom — it only feeds the refresh singleton's hide. The
+ // static pager header is deliberately NOT z-elevated for this sheet
+ // (no `mobileHorseshoeElevateHeaderAtom` publication): the appBody
+ // stays transparent in pager mode, so the tabs remain visible exactly
+ // as at rest and the opaque sheet simply slides OVER them like a real
+ // curtain. The cleanup writing `false` covers route unmount mid-drag.
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
useEffect(() => {
setMobileHorseshoeActive(horseshoeActive);
@@ -506,48 +509,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
};
}, []);
- // Geometry — radii and void gap ramp through `easeInOutCubic` (slow
- // start → fast middle → slow finish) during finger-drag, matching
- // the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX`
- // window as the profile horseshoe so the rounding finishes exactly
- // when the gesture qualifies to commit. During release (not
- // dragging), the values jump to their full target and the CSS
- // transition (VAUL_EASING) carries them visually.
- //
- // The mask: appBody stays in place, its bottom edge is "carved
- // away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)`
- // where BOTTOM = expandedPx + voidGap. The carved zone exposes the
- // container's bg (the void colour) above the silhouette; the void
- // gap is just `voidGap` pixels of that exposed bg between the
- // silhouette's top edge and the clip-path's lower edge.
- let horseshoeRamp: number;
- if (isDragging) {
- horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
- } else {
- horseshoeRamp = expandedFraction > 0 ? 1 : 0;
- }
- const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
- const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
- const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
- const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
-
- // `inset()` shorthand: top right bottom left, then `round` followed
- // by 4 corner radii in TL TR BR BL order. Only the bottom two carry
- // the radius so the visible top portion of appBody has rounded BL/BR
- // at the clip boundary. Always emitted (even when all values are 0
- // for the closed state) so CSS can transition smoothly between
- // closed and open — interpolating between `inset(...)` and an
- // `undefined` clip-path would snap rather than animate.
- const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
-
- const silhouetteTransition = isDragging
- ? 'none'
- : `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
- const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
-
- const containerStyle: React.CSSProperties = {
- backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
- };
+ // Geometry — media-viewer style (MobileMediaViewerHorseshoe): the opaque
+ // silhouette simply grows from the bottom over the full-bleed appBody.
+ // Its rounded TL/TR corners are STATIC (32px, in the css) and stay
+ // transparent, revealing the live DM list behind — no clip-path carve,
+ // no void gap, no emerge ramp. Only the silhouette's height animates.
+ const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
const settingsState = sheet ?? lastSheetRef.current;
const renderSettings = keepMounted || isDragging;
@@ -568,7 +535,22 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
: null;
return (
-
+
+ {/* Zero-width probe — its height resolves `env(safe-area-inset-top)`
+ so the JS rail-height calc can keep the sheet below the tray. */}
+
{open && portalTarget
? createPortal(
{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
diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx
index 62c7328b..a371034e 100644
--- a/src/app/features/settings/Settings.tsx
+++ b/src/app/features/settings/Settings.tsx
@@ -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`): 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;
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 (
+
+ );
+}
+
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(() => {
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) {
-
+
{t('Settings.title')}
@@ -188,89 +244,58 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
-
- {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 (
-
- }
- onClick={() => setActivePage(item.page)}
- style={{
- backgroundColor: isActive
- ? color.SurfaceVariant.ContainerActive
- : 'transparent',
- }}
- >
-
+ setActivePage(SettingsPages.AccountPage)}
+ />
+