refactor(settings): restructure user settings into a profile hero with tinted menu rows and a near-fullscreen mobile sheet
This commit is contained in:
parent
df9cac8f5c
commit
0956e58cfd
18 changed files with 673 additions and 1137 deletions
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai';
|
|||
import { Box, Icon, IconButton, Icons } from 'folds';
|
||||
import {
|
||||
curtainPinnedByTabAtom,
|
||||
mobileHorseshoeActiveAtom,
|
||||
mobileHorseshoeElevateHeaderAtom,
|
||||
mobilePagerCurtainAtom,
|
||||
} from '../../state/mobilePagerHeader';
|
||||
import { Segment } from '../stream-header/Segment';
|
||||
|
|
@ -89,42 +89,37 @@ export function MobileTabsPagerHeader({
|
|||
// the «curtain-overlay invariants» comment in style.css.ts on
|
||||
// pagerStaticHeader for the bg / z-order contract.
|
||||
//
|
||||
// Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the
|
||||
// MobileSettings / ChannelsWorkspace container paints
|
||||
// `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the
|
||||
// entire pane to drive the carve cut-out the moment `expandedPx > 0`.
|
||||
// Without elevation that void bleeds up through the transparent
|
||||
// strip-stack into the safe-top + tabsRow zone, turning the system-
|
||||
// tray strip + tabs black. Bumping the static header into a positive
|
||||
// z-index puts it ABOVE the strip's stacking context (positive z
|
||||
// beats z:auto stacking contexts per CSS painting order), covering
|
||||
// the void in its own y-band with SurfaceVariant bg + visible tabs.
|
||||
//
|
||||
// The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the
|
||||
// sheet-open atoms, so elevation lands on the FIRST frame of drag —
|
||||
// not 80 px later when the user crosses the commit threshold. The
|
||||
// horseshoes' appBody flips to opaque in lockstep (same signal),
|
||||
// containing the void to the bottom carve everywhere below the
|
||||
// static header.
|
||||
// Z-elevation is requested ONLY by the void-carve sheet design (the
|
||||
// Channels workspace switcher): while geometrically active
|
||||
// (`expandedPx > 0`, first frame of drag) its container paints
|
||||
// `VOJO_HORSESHOE_VOID_COLOR` across the pane to drive the carve,
|
||||
// and without elevation that void bleeds up through the transparent
|
||||
// strip-stack into the safe-top + tabsRow zone. A positive z-index
|
||||
// beats the strip's z:auto stacking context (CSS painting order),
|
||||
// covering the void in this band with SurfaceVariant bg + visible
|
||||
// tabs. The near-fullscreen Settings sheet keeps its appBody
|
||||
// transparent and simply slides OVER this header like a real
|
||||
// curtain, so it never publishes the elevate atom — see
|
||||
// mobileHorseshoeElevateHeaderAtom's docs.
|
||||
//
|
||||
// Pinned-overrides-elevation: when the active pane's curtain is
|
||||
// pinned the curtain itself contains the void — it covers everything
|
||||
// from `y = safe-top` downward inside the strip's stacking context,
|
||||
// and the opaque appBody (also flipped on `horseshoeActive`) covers
|
||||
// the safe-top band above the curtain. Re-elevating the static
|
||||
// header in that state would visibly «slice» the pinned curtain in
|
||||
// the safe-top + tabsRow band, popping tabs back over what the user
|
||||
// explicitly pulled up to cover. So we suppress elevation whenever
|
||||
// the active tab's pin is set — preserves the «pinned hides tabs»
|
||||
// invariant across sheet open/drag.
|
||||
// and the opaque appBody (flipped on the same geometric signal)
|
||||
// covers the safe-top band above the curtain. Re-elevating the
|
||||
// static header in that state would visibly «slice» the pinned
|
||||
// curtain in the safe-top + tabsRow band, popping tabs back over
|
||||
// what the user explicitly pulled up to cover. So we suppress
|
||||
// elevation whenever the active tab's pin is set — preserves the
|
||||
// «pinned hides tabs» invariant across sheet open/drag.
|
||||
//
|
||||
// The curtain pin gesture is suppressed while either sheet is open
|
||||
// (see `StreamHeader.gestureDisabled`), so this elevation never
|
||||
// races with a pin-in-progress drag.
|
||||
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
|
||||
const elevateHeader = useAtomValue(mobileHorseshoeElevateHeaderAtom);
|
||||
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
|
||||
const activePinned = !!pinnedByTab[activeTab];
|
||||
const elevated = horseshoeActive && !activePinned;
|
||||
const elevated = elevateHeader && !activePinned;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -86,28 +86,31 @@ export const pagerRoot = style({
|
|||
// the tabs visually slide with the curtain. See git history for the
|
||||
// user-feedback trail.
|
||||
//
|
||||
// Conditional z-elevation (horseshoe-active override, suppressed by pin):
|
||||
// Conditional z-elevation (void-carve sheets only, suppressed by pin):
|
||||
//
|
||||
// When a horseshoe sheet (Settings or workspace switcher) is
|
||||
// geometrically active — i.e. `expandedPx > 0`, which covers both
|
||||
// the in-flight drag and the committed-open state — the wrapping
|
||||
// The two horseshoe sheet designs interact differently with this
|
||||
// header. The near-fullscreen Settings sheet keeps its appBody
|
||||
// transparent and simply slides its opaque silhouette OVER the tabs
|
||||
// like a real curtain — no elevation, the tabs stay put underneath.
|
||||
// The Channels workspace switcher is the void-carve design: while
|
||||
// geometrically active (`expandedPx > 0`, first frame of drag) its
|
||||
// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark
|
||||
// theme) across the entire pane so the carve at the sheet's top
|
||||
// reads as a dark seam. With the transparent strip stack from (b),
|
||||
// that void would bleed up through the safe-top + tabsRow zone,
|
||||
// turning the system-tray strip + tabs solid black.
|
||||
// theme) across the pane to drive the carve. With the transparent
|
||||
// strip stack from (b), that void would bleed up through the
|
||||
// safe-top + tabsRow zone, turning the system-tray strip + tabs
|
||||
// solid black.
|
||||
//
|
||||
// `MobileTabsPagerHeader.tsx` bumps this element to a positive
|
||||
// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`)
|
||||
// from the first frame of drag. Positive z beats the strip's
|
||||
// `z: auto` stacking context, putting the static header back on
|
||||
// top in the safe-top + tabsRow band — the void is contained to
|
||||
// the carve area, tabs stay visible. The horseshoe's `appBody`
|
||||
// flips back to opaque on the same signal so the void doesn't
|
||||
// bleed into the mascot/form band between the static header and
|
||||
// the curtain top either. The curtain pin gesture is gated off
|
||||
// in the same state (see `StreamHeader.gestureDisabled`) so no
|
||||
// pin can race the elevation flip.
|
||||
// So only the void-carve sheet publishes
|
||||
// `mobileHorseshoeElevateHeaderAtom`; `MobileTabsPagerHeader.tsx`
|
||||
// bumps this element to a positive `zIndex` (inline style) on that
|
||||
// signal. Positive z beats the strip's `z: auto` stacking context,
|
||||
// putting the static header back on top in the safe-top + tabsRow
|
||||
// band — the void is contained to the carve area, tabs stay
|
||||
// visible. That horseshoe's `appBody` flips back to opaque on the
|
||||
// same signal so the void doesn't bleed into the band between the
|
||||
// static header and the curtain top either. The curtain pin gesture
|
||||
// is gated off in the same state (see `StreamHeader.gestureDisabled`)
|
||||
// so no pin can race the elevation flip.
|
||||
//
|
||||
// Pinned-override: when the active pane's curtain is pinned, the
|
||||
// curtain itself sits at the top of the stage (z:2 inside the
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,20 +1,29 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import { Icon, IconSrc, Text } from 'folds';
|
||||
import { Icon, Icons, IconSrc, Text } from 'folds';
|
||||
import { MenuRowIcon } from '../settings/styles.css';
|
||||
import * as css from './SettingsNav.css';
|
||||
|
||||
// One muted Dawn accent per row — the same tinted rounded-square glyph
|
||||
// vocabulary the user-settings menu uses (features/settings/styles.css).
|
||||
export type SettingsNavTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral';
|
||||
|
||||
type SettingsNavItemProps = {
|
||||
icon: IconSrc;
|
||||
label: string;
|
||||
active: boolean;
|
||||
tint?: SettingsNavTint;
|
||||
onClick: () => void;
|
||||
};
|
||||
export function SettingsNavItem({ icon, label, active, onClick }: SettingsNavItemProps) {
|
||||
export function SettingsNavItem({ icon, label, active, tint, onClick }: SettingsNavItemProps) {
|
||||
return (
|
||||
<button type="button" className={css.NavItem} aria-pressed={active} onClick={onClick}>
|
||||
<Icon className={css.NavItemIcon} src={icon} size="100" filled={active} />
|
||||
<span className={MenuRowIcon({ tint: tint ?? 'neutral' })}>
|
||||
<Icon src={icon} size="100" filled={active} />
|
||||
</span>
|
||||
<Text className={css.NavItemLabel} as="span" size="T300" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
<Icon className={css.NavItemChevron} src={Icons.ChevronRight} size="100" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,28 +1,18 @@
|
|||
// Bottom-up «horseshoe» sheet that wraps the mobile Direct DM list.
|
||||
// Mirror of `MobileProfileHorseshoe` in features/room — the chat
|
||||
// there is wrapped by a top-down horseshoe (panel above, chat below
|
||||
// with a 12px void). Here we invert: the wrapped app body is above,
|
||||
// the Settings sheet emerges from below, and a 12px void separates
|
||||
// them in a )|( silhouette.
|
||||
// Bottom-up near-fullscreen Settings sheet that wraps the mobile
|
||||
// Direct DM list. Geometry mirrors the MEDIA-VIEWER sheet
|
||||
// (`MobileMediaViewerHorseshoe`): the opaque sheet grows from the
|
||||
// bottom over the full-bleed app body and stops just under the system
|
||||
// status bar; its STATIC rounded top corners stay transparent and
|
||||
// reveal the live DM list behind. No void gap, no clip-path carve, no
|
||||
// emerge ramp — only the sheet's height animates.
|
||||
//
|
||||
// User-visible behaviour:
|
||||
//
|
||||
// • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
|
||||
// stays exactly where it was — no translate,
|
||||
// no shrink. The bottom of the visible portion is "masked away"
|
||||
// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx
|
||||
// Rpx)` with rounded BL/BR carves at the new visible edge. The
|
||||
// carved area exposes the container's void colour underneath, and
|
||||
// the silhouette below covers the rest of the masked zone. Why
|
||||
// not `transform: translateY`: translating moves the TOP of the
|
||||
// pane out of the viewport (StreamHeader scrolls off-
|
||||
// screen). Why not `flex-shrink + margin-bottom`: the virtualized
|
||||
// DM list (`@tanstack/react-virtual`) re-measures items every
|
||||
// time the scroll container resizes — items above the shrinking
|
||||
// edge visibly smear. Clip-path leaves layout unchanged: the DM
|
||||
// list keeps its scroll position, its measured heights, and its
|
||||
// top items in place; only the bottom edge of what's visible is
|
||||
// carved into the void.
|
||||
// stays exactly where it was — no translate, no shrink, no clip.
|
||||
// The virtualized DM list (`@tanstack/react-virtual`) keeps its
|
||||
// scroll position and measured row heights; the sheet simply
|
||||
// paints over it.
|
||||
// • Drag-up origin is `DirectSelfRow` itself, marked with the
|
||||
// `data-settings-drag-origin` attribute. A document-level
|
||||
// touchstart / pointerdown listener uses `target.closest()` to
|
||||
|
|
@ -56,7 +46,6 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|||
import { HorseshoeEnabledContext } from '../../components/page';
|
||||
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
|
||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
||||
import { Settings } from './Settings';
|
||||
import * as css from './MobileSettingsHorseshoe.css';
|
||||
|
||||
|
|
@ -72,26 +61,6 @@ const ANIMATION_MS = 250;
|
|||
// or close from the panel handle). Mirrors the profile horseshoe's
|
||||
// 80px so the two gestures feel identical.
|
||||
const COMMIT_THRESHOLD_PX = 80;
|
||||
// Fixed sheet height — 2/3 of viewport, what the user signed off on.
|
||||
// Internal scrolling inside Settings sub-pages handles content
|
||||
// overflow; no rail re-sizing on menu↔sub-page navigation.
|
||||
const RAIL_FRACTION = 2 / 3;
|
||||
// Drag distance over which the radii + void-gap ramp from 0 to their
|
||||
// full value during finger-drag — same as the profile horseshoe's
|
||||
// `HORSESHOE_EMERGE_PX`. Matched to `COMMIT_THRESHOLD_PX` so the
|
||||
// silhouette is fully formed exactly when the gesture qualifies to
|
||||
// commit.
|
||||
const HORSESHOE_EMERGE_PX = 80;
|
||||
|
||||
// Symmetric cubic in-out — slow start, fast middle, slow finish. Mirror
|
||||
// of the profile horseshoe's emerge curve (file rationale there). The
|
||||
// linear ramp from round 1 came out too "snappy" because the rounding
|
||||
// jumped in within the first ~10px of drag; the cubic keeps the corners
|
||||
// barely visible until ~40% of the way through the gesture, then
|
||||
// blossoms around the midpoint. Used only during finger-drag; release
|
||||
// transitions use the asymmetric VAUL_EASING curve in CSS.
|
||||
const easeInOutCubic = (t: number): number => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
||||
|
||||
type DragSource = 'directSelfRow' | 'handle';
|
||||
|
||||
// Axis dead-zone for horizontal-bail. The finger must travel this far
|
||||
|
|
@ -129,17 +98,50 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
const inPagerMode = useMobilePagerPane() !== null;
|
||||
|
||||
const [drag, setDrag] = useState<DragState | null>(null);
|
||||
const [viewportHeight, setViewportHeight] = useState(() =>
|
||||
|
||||
// Measure the wrapper container directly via `ResizeObserver` — the
|
||||
// container extends up over the status bar (css `marginTop:
|
||||
// -var(--vojo-safe-top)`), so its measured height already includes the
|
||||
// strip; subtracting the live safe-top inset below puts the sheet's top
|
||||
// edge exactly at the bottom of the status bar. Mirrors the media-viewer
|
||||
// sheet's calc (MobileMediaViewerHorseshoe).
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState(() =>
|
||||
typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setViewportHeight(window.innerHeight);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
useLayoutEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return undefined;
|
||||
setContainerHeight(el.clientHeight);
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const cr = entries[0]?.contentRect;
|
||||
if (cr) setContainerHeight(cr.height);
|
||||
});
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const railHeightPx = Math.round(viewportHeight * RAIL_FRACTION);
|
||||
// Live status-bar inset. `env(safe-area-inset-top)` can't be read
|
||||
// reliably off a custom property in JS, so we measure a zero-width
|
||||
// probe whose height is `var(--vojo-safe-top)` (= the env value).
|
||||
const safeTopRef = useRef<HTMLDivElement>(null);
|
||||
const [safeTopPx, setSafeTopPx] = useState(0);
|
||||
useLayoutEffect(() => {
|
||||
const el = safeTopRef.current;
|
||||
if (!el) return undefined;
|
||||
setSafeTopPx(el.offsetHeight);
|
||||
const ro = new ResizeObserver(() => setSafeTopPx(el.offsetHeight));
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// Near-fullscreen by design (user request: Settings covers the app body
|
||||
// and stops just under the system status bar / tray): the rail is the
|
||||
// measured container height minus the live status-bar inset, so the
|
||||
// sheet's rounded top edge lands at the bottom of the status bar, never
|
||||
// over the tray icons. Shrunken wrappers (call rail / split-screen)
|
||||
// shrink the sheet with them via the ResizeObserver above.
|
||||
const railHeightPx = Math.max(0, containerHeight - safeTopPx);
|
||||
const open = !!sheet;
|
||||
|
||||
// Entry-animation gate. On cold-start deep-links (push notification
|
||||
|
|
@ -195,15 +197,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
const expandedPx = drag
|
||||
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
||||
: baseExpanded;
|
||||
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
||||
const isDragging = drag !== null;
|
||||
const horseshoeActive = expandedPx > 0;
|
||||
|
||||
// Bridge our local `horseshoeActive` (geometric) signal up to the
|
||||
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
|
||||
// static header from the first frame of drag — see the atom's docs
|
||||
// for the «no black flash» rationale. The cleanup writing `false`
|
||||
// covers route unmount mid-drag.
|
||||
// pager-shared atom — it only feeds the refresh singleton's hide. The
|
||||
// static pager header is deliberately NOT z-elevated for this sheet
|
||||
// (no `mobileHorseshoeElevateHeaderAtom` publication): the appBody
|
||||
// stays transparent in pager mode, so the tabs remain visible exactly
|
||||
// as at rest and the opaque sheet simply slides OVER them like a real
|
||||
// curtain. The cleanup writing `false` covers route unmount mid-drag.
|
||||
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
|
||||
useEffect(() => {
|
||||
setMobileHorseshoeActive(horseshoeActive);
|
||||
|
|
@ -506,48 +509,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Geometry — radii and void gap ramp through `easeInOutCubic` (slow
|
||||
// start → fast middle → slow finish) during finger-drag, matching
|
||||
// the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX`
|
||||
// window as the profile horseshoe so the rounding finishes exactly
|
||||
// when the gesture qualifies to commit. During release (not
|
||||
// dragging), the values jump to their full target and the CSS
|
||||
// transition (VAUL_EASING) carries them visually.
|
||||
//
|
||||
// The mask: appBody stays in place, its bottom edge is "carved
|
||||
// away" by `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx Rpx)`
|
||||
// where BOTTOM = expandedPx + voidGap. The carved zone exposes the
|
||||
// container's bg (the void colour) above the silhouette; the void
|
||||
// gap is just `voidGap` pixels of that exposed bg between the
|
||||
// silhouette's top edge and the clip-path's lower edge.
|
||||
let horseshoeRamp: number;
|
||||
if (isDragging) {
|
||||
horseshoeRamp = easeInOutCubic(Math.min(1, expandedPx / HORSESHOE_EMERGE_PX));
|
||||
} else {
|
||||
horseshoeRamp = expandedFraction > 0 ? 1 : 0;
|
||||
}
|
||||
const silhouetteRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const appBodyRadiusPx = horseshoeRamp * css.HORSESHOE_RADIUS_PX;
|
||||
const appBodyGapPx = horseshoeRamp * css.HORSESHOE_GAP_PX;
|
||||
const appBodyMaskBottomPx = expandedPx + appBodyGapPx;
|
||||
|
||||
// `inset()` shorthand: top right bottom left, then `round` followed
|
||||
// by 4 corner radii in TL TR BR BL order. Only the bottom two carry
|
||||
// the radius so the visible top portion of appBody has rounded BL/BR
|
||||
// at the clip boundary. Always emitted (even when all values are 0
|
||||
// for the closed state) so CSS can transition smoothly between
|
||||
// closed and open — interpolating between `inset(...)` and an
|
||||
// `undefined` clip-path would snap rather than animate.
|
||||
const appBodyClipPath = `inset(0px 0px ${appBodyMaskBottomPx}px 0px round 0px 0px ${appBodyRadiusPx}px ${appBodyRadiusPx}px)`;
|
||||
|
||||
const silhouetteTransition = isDragging
|
||||
? 'none'
|
||||
: `height ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-left-radius ${ANIMATION_MS}ms ${VAUL_EASING}, border-top-right-radius ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
const appBodyTransition = isDragging ? 'none' : `clip-path ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
|
||||
const containerStyle: React.CSSProperties = {
|
||||
backgroundColor: horseshoeActive ? VOJO_HORSESHOE_VOID_COLOR : undefined,
|
||||
};
|
||||
// Geometry — media-viewer style (MobileMediaViewerHorseshoe): the opaque
|
||||
// silhouette simply grows from the bottom over the full-bleed appBody.
|
||||
// Its rounded TL/TR corners are STATIC (32px, in the css) and stay
|
||||
// transparent, revealing the live DM list behind — no clip-path carve,
|
||||
// no void gap, no emerge ramp. Only the silhouette's height animates.
|
||||
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||
|
||||
const settingsState = sheet ?? lastSheetRef.current;
|
||||
const renderSettings = keepMounted || isDragging;
|
||||
|
|
@ -568,7 +535,22 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
: null;
|
||||
|
||||
return (
|
||||
<div className={css.container} style={containerStyle}>
|
||||
<div ref={containerRef} className={css.container}>
|
||||
{/* Zero-width probe — its height resolves `env(safe-area-inset-top)`
|
||||
so the JS rail-height calc can keep the sheet below the tray. */}
|
||||
<div
|
||||
ref={safeTopRef}
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 'var(--vojo-safe-top, 0px)',
|
||||
pointerEvents: 'none',
|
||||
visibility: 'hidden',
|
||||
}}
|
||||
/>
|
||||
{open && portalTarget
|
||||
? createPortal(
|
||||
<div
|
||||
|
|
@ -582,40 +564,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
<div
|
||||
className={css.appBody}
|
||||
style={{
|
||||
// Always emitted, even at rest where it computes to
|
||||
// `inset(0 …)` — see the `appBodyClipPath` comment above for
|
||||
// why (interpolating from `undefined` would snap). Safe to
|
||||
// keep always-on: the pull-to-refresh chrome is hosted in the
|
||||
// strip-level singleton OUTSIDE this subtree, so the resting
|
||||
// self-bounds clip slices nothing. (Bonus: a non-none
|
||||
// clip-path makes appBody a permanent stacking context, which
|
||||
// the singleton's paint-order contract counts on — see
|
||||
// mobile-tabs-pager/style.css.ts::pagerRefreshSingleton.)
|
||||
clipPath: appBodyClipPath,
|
||||
transition: appBodyTransition,
|
||||
overscrollBehaviorY: 'contain',
|
||||
// In pager mode the appBody is transparent AT REST so the
|
||||
// static pager header (sitting behind the swipe strip in DOM
|
||||
// order) shows through the tabsRow zone — that's how the
|
||||
// chats curtain visually rises ABOVE the header on pin.
|
||||
//
|
||||
// When the horseshoe is active (drag in flight OR sheet
|
||||
// open) we flip the appBody back to opaque
|
||||
// `SurfaceVariant.Container` (via the CSS class — unsetting
|
||||
// the inline override). This contains the container's
|
||||
// `VOJO_HORSESHOE_VOID_COLOR` paint to the carve cut-out at
|
||||
// the bottom of the appBody. Otherwise the void would bleed
|
||||
// up through every transparent pixel of the mascot/form band
|
||||
// between the static header and the curtain top (refresh/form
|
||||
// snaps), turning the strip black.
|
||||
//
|
||||
// The static-header z-elevation in `MobileTabsPagerHeader`
|
||||
// tracks the same `mobileHorseshoeActiveAtom` so the static
|
||||
// header z-pops above the now-opaque appBody in the safe-
|
||||
// top + tabsRow band — tabs stay visible. Both gestures
|
||||
// (pin + pager swipe) are gated off while a sheet is open,
|
||||
// so the opaque flip can't race with curtain-show-through.
|
||||
backgroundColor: inPagerMode && !horseshoeActive ? 'transparent' : undefined,
|
||||
// In pager mode the appBody stays transparent THROUGHOUT —
|
||||
// at rest so the static pager header (behind the strip in DOM
|
||||
// order) shows through the tabsRow zone, and during drag/open
|
||||
// so the tabs keep showing exactly as at rest while the opaque
|
||||
// sheet slides OVER them like a real curtain. Nothing dark can
|
||||
// bleed through: the pager root paints the same SurfaceVariant
|
||||
// tone behind the strip, and this sheet has no void paint.
|
||||
backgroundColor: inPagerMode ? 'transparent' : undefined,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
@ -625,10 +582,13 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
|||
className={css.silhouette}
|
||||
style={{
|
||||
height: `${expandedPx}px`,
|
||||
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
||||
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
||||
transition: silhouetteTransition,
|
||||
visibility: expandedPx > 0 ? 'visible' : 'hidden',
|
||||
// Gate on `renderSettings` (open / dragging / exiting), NOT on
|
||||
// `expandedPx > 0` — on close `expandedPx` snaps to 0 while the
|
||||
// height transition animates the retract; gating on it would
|
||||
// hide the sheet on the first frame and the slide-down would
|
||||
// never be seen. Same rationale as the media-viewer sheet.
|
||||
visibility: renderSettings ? 'visible' : 'hidden',
|
||||
// Reset `--vojo-safe-top` for everything mounted inside the
|
||||
// sheet. The status-bar inset is reserved by PageNav's inner
|
||||
// column via `padding-top: var(--vojo-safe-top)` for surfaces
|
||||
|
|
|
|||
|
|
@ -1,33 +1,24 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
IconSrc,
|
||||
MenuItem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Box, color, config, Icon, IconButton, Icons, IconSrc, Text } from 'folds';
|
||||
import { General } from './general';
|
||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { Account } from './account';
|
||||
import { Notifications } from './notifications';
|
||||
import { Devices } from './devices';
|
||||
import { EmojisStickers } from './emojis-stickers';
|
||||
import { About } from './about';
|
||||
import { Network } from './network';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { LogoutDialog } from '../../components/logout-dialog';
|
||||
import { useAuthedUserId } from '../../hooks/useAuthedUserId';
|
||||
import { useUserProfile } from '../../hooks/useUserProfile';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import * as css from './styles.css';
|
||||
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
|
|
@ -35,13 +26,16 @@ export enum SettingsPages {
|
|||
NotificationPage,
|
||||
NetworkPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
AboutPage,
|
||||
}
|
||||
|
||||
// Stable string keys for URL deep-links: `/settings?page=devices`.
|
||||
// Append-only — the enum is local but the param values are part of
|
||||
// the URL contract (push notifications, bookmarks, external links).
|
||||
// Existing values never get RENAMED — the enum is local but the param
|
||||
// values are part of the URL contract (push notifications, bookmarks,
|
||||
// external links). A key may be dropped together with its page (the
|
||||
// 'emojis' key died with the emojis-stickers page); stale inbound
|
||||
// links then fall back to the settings landing, which is the correct
|
||||
// degradation for a removed surface.
|
||||
// `as const satisfies` so the value type is a literal union (not
|
||||
// widened to `Record<string, SettingsPages>`): the lookup
|
||||
// `SETTINGS_PAGE_PARAM[k]` with `k: keyof typeof SETTINGS_PAGE_PARAM`
|
||||
|
|
@ -56,41 +50,88 @@ export const SETTINGS_PAGE_PARAM = {
|
|||
notifications: SettingsPages.NotificationPage,
|
||||
network: SettingsPages.NetworkPage,
|
||||
devices: SettingsPages.DevicesPage,
|
||||
emojis: SettingsPages.EmojisStickersPage,
|
||||
about: SettingsPages.AboutPage,
|
||||
} as const satisfies Record<string, SettingsPages>;
|
||||
export const SETTINGS_PARAM_DEVICES = 'devices';
|
||||
|
||||
// DOM id of the visible "Settings" title in PageNavHeader — referenced
|
||||
// from `aria-labelledby` on the mobile sheet's `role="dialog"` so
|
||||
// screen readers announce the same string sighted users see (WAI-ARIA
|
||||
// APG dialog pattern prefers `aria-labelledby` over `aria-label`).
|
||||
export const SETTINGS_TITLE_ID = 'vojo-settings-title';
|
||||
type MenuRowTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral';
|
||||
|
||||
type SettingsMenuItem = {
|
||||
page: SettingsPages;
|
||||
nameKey: string;
|
||||
icon: IconSrc;
|
||||
tint: MenuRowTint;
|
||||
};
|
||||
|
||||
// The Account page is NOT in this list — the profile hero at the top of the
|
||||
// menu is its entry (avatar + name + mxid, tap to open). One muted Dawn
|
||||
// accent per row, per the design bundle's sidebar vocabulary.
|
||||
const SETTINGS_MENU_ITEMS: SettingsMenuItem[] = [
|
||||
{ page: SettingsPages.GeneralPage, nameKey: 'Settings.menu_general', icon: Icons.Setting },
|
||||
{ page: SettingsPages.AccountPage, nameKey: 'Settings.menu_account', icon: Icons.User },
|
||||
{
|
||||
page: SettingsPages.GeneralPage,
|
||||
nameKey: 'Settings.menu_general',
|
||||
icon: Icons.Setting,
|
||||
tint: 'violet',
|
||||
},
|
||||
{
|
||||
page: SettingsPages.NotificationPage,
|
||||
nameKey: 'Settings.menu_notifications',
|
||||
icon: Icons.Bell,
|
||||
tint: 'amber',
|
||||
},
|
||||
{ page: SettingsPages.NetworkPage, nameKey: 'Settings.network.menu', icon: Icons.Server },
|
||||
{ page: SettingsPages.DevicesPage, nameKey: 'Settings.menu_devices', icon: Icons.Monitor },
|
||||
{
|
||||
page: SettingsPages.EmojisStickersPage,
|
||||
nameKey: 'Settings.menu_emojis_stickers',
|
||||
icon: Icons.Smile,
|
||||
page: SettingsPages.NetworkPage,
|
||||
nameKey: 'Settings.network.menu',
|
||||
icon: Icons.Server,
|
||||
tint: 'blue',
|
||||
},
|
||||
{
|
||||
page: SettingsPages.DevicesPage,
|
||||
nameKey: 'Settings.menu_devices',
|
||||
icon: Icons.Monitor,
|
||||
tint: 'green',
|
||||
},
|
||||
{
|
||||
page: SettingsPages.AboutPage,
|
||||
nameKey: 'Settings.menu_about',
|
||||
icon: Icons.Info,
|
||||
tint: 'neutral',
|
||||
},
|
||||
{ page: SettingsPages.AboutPage, nameKey: 'Settings.menu_about', icon: Icons.Info },
|
||||
];
|
||||
|
||||
// Own-profile hero at the top of the settings menu — avatar, display name,
|
||||
// mono mxid. Tapping opens the Account page (where avatar/name editing
|
||||
// lives). Replaces the old «Account» menu row.
|
||||
function SettingsProfileHero({ active, onClick }: { active: boolean; onClick: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const userId = useAuthedUserId();
|
||||
const profile = useUserProfile(userId);
|
||||
|
||||
const displayName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarUrl = profile.avatarUrl
|
||||
? mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<button type="button" className={css.ProfileHero} aria-pressed={active} onClick={onClick}>
|
||||
<span className={css.ProfileHeroAvatar}>
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Text size="H4">{nameInitials(displayName)}</Text>}
|
||||
/>
|
||||
</span>
|
||||
<span className={css.ProfileHeroText}>
|
||||
<span className={css.ProfileHeroName}>{displayName}</span>
|
||||
<span className={css.ProfileHeroHandle}>{userId}</span>
|
||||
</span>
|
||||
<Icon className={css.ProfileHeroChevron} size="100" src={Icons.ChevronRight} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type SettingsProps = {
|
||||
initialPage?: SettingsPages;
|
||||
requestClose: () => void;
|
||||
|
|
@ -101,9 +142,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
// `initialPage !== undefined` (not truthy-check) — `GeneralPage`
|
||||
// happens to be enum value `0`, which the old `if (initialPage)`
|
||||
// form silently dropped, breaking `/settings?page=general`.
|
||||
//
|
||||
// Desktop lands on the ACCOUNT page (the user's own profile) — settings
|
||||
// open on «me» first, mirroring the profile hero that tops the menu.
|
||||
// Mobile keeps the bare menu (the hero IS the landing there).
|
||||
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
||||
if (initialPage !== undefined) return initialPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.GeneralPage;
|
||||
return screenSize === ScreenSize.Mobile ? undefined : SettingsPages.AccountPage;
|
||||
});
|
||||
// Re-sync when the parent passes a new `initialPage` (e.g. the
|
||||
// UnverifiedTab shield-icon shortcut navigating from `/settings` to
|
||||
|
|
@ -148,6 +193,17 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
) {
|
||||
return;
|
||||
}
|
||||
// An overlay floating above the sub-page (folds dialogs/menus all
|
||||
// portal into #portalContainer; the horseshoe sheets' display:none
|
||||
// markers don't count) owns Esc / Android back. Window-capture runs
|
||||
// before the overlay's document-level focus-trap listener, so
|
||||
// without this bail we'd pop the page out from under an open
|
||||
// dialog — e.g. mid-logout or mid-UIA on the Devices page.
|
||||
const portal = document.getElementById('portalContainer');
|
||||
const overlayOpen = portal
|
||||
? Array.from(portal.children).some((c) => (c as HTMLElement).style.display !== 'none')
|
||||
: false;
|
||||
if (overlayOpen) return;
|
||||
setActivePage(undefined);
|
||||
e.stopImmediatePropagation();
|
||||
};
|
||||
|
|
@ -162,7 +218,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
<PageNav size="350" surface="surfaceVariant">
|
||||
<PageNavHeader outlined={false}>
|
||||
<Box grow="Yes" gap="200" alignItems="Center">
|
||||
<Text id={SETTINGS_TITLE_ID} size="H4" truncate>
|
||||
<Text size="H4" truncate>
|
||||
{t('Settings.title')}
|
||||
</Text>
|
||||
</Box>
|
||||
|
|
@ -188,89 +244,58 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
</PageNavHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<PageNavContent>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
{menuItems.map((item) => {
|
||||
const isActive = activePage === item.page;
|
||||
// The whole PageNav sits on SurfaceVariant.Container
|
||||
// (the chat-pane tone). Active items step up to
|
||||
// SurfaceVariant.ContainerActive (the raised
|
||||
// hover/active tone) to read as a subtle raised
|
||||
// row, and the active icon picks up
|
||||
// `Primary.Main` (Fleet-violet) for a single
|
||||
// splash of accent — the same accent the
|
||||
// DM/Channels/Bots tab underline uses. Text stays
|
||||
// neutral OnContainer; the weight bump conveys
|
||||
// the active state to the eye.
|
||||
return (
|
||||
<MenuItem
|
||||
key={item.nameKey}
|
||||
variant="SurfaceVariant"
|
||||
radii="400"
|
||||
aria-pressed={isActive}
|
||||
before={
|
||||
<Icon
|
||||
src={item.icon}
|
||||
size="100"
|
||||
filled={isActive}
|
||||
style={{
|
||||
color: isActive
|
||||
? color.Primary.Main
|
||||
: color.SurfaceVariant.OnContainer,
|
||||
opacity: isActive ? 1 : 0.7,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={() => setActivePage(item.page)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
? color.SurfaceVariant.ContainerActive
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
fontWeight: isActive ? config.fontWeight.W600 : undefined,
|
||||
opacity: isActive ? 1 : 0.82,
|
||||
}}
|
||||
size="T300"
|
||||
truncate
|
||||
<Box direction="Column" gap="300" style={{ flexGrow: 1 }}>
|
||||
<SettingsProfileHero
|
||||
active={activePage === SettingsPages.AccountPage}
|
||||
onClick={() => setActivePage(SettingsPages.AccountPage)}
|
||||
/>
|
||||
<div>
|
||||
{menuItems.map((item) => {
|
||||
const isActive = activePage === item.page;
|
||||
return (
|
||||
<button
|
||||
key={item.nameKey}
|
||||
type="button"
|
||||
className={css.MenuRow}
|
||||
aria-pressed={isActive}
|
||||
onClick={() => setActivePage(item.page)}
|
||||
>
|
||||
{t(item.nameKey)}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={css.MenuRowIcon({ tint: item.tint })}>
|
||||
<Icon src={item.icon} size="100" filled={isActive} />
|
||||
</span>
|
||||
<span
|
||||
className={css.MenuRowLabel}
|
||||
style={isActive ? { fontWeight: 600 } : undefined}
|
||||
>
|
||||
{t(item.nameKey)}
|
||||
</span>
|
||||
<Icon
|
||||
className={css.MenuRowChevron}
|
||||
size="100"
|
||||
src={Icons.ChevronRight}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
</PageNavContent>
|
||||
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
||||
<UseStateProvider initial={false}>
|
||||
{(logout, setLogout) => (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="Pill"
|
||||
before={<Icon src={Icons.Power} size="100" />}
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.MenuRow} ${css.MenuRowCritical}`}
|
||||
onClick={() => setLogout(true)}
|
||||
>
|
||||
<Text size="B400">{t('Settings.logout')}</Text>
|
||||
</Button>
|
||||
{logout && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
onDeactivate: () => setLogout(false),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<LogoutDialog handleClose={() => setLogout(false)} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
<span className={css.MenuRowIcon({ tint: 'critical' })}>
|
||||
<Icon src={Icons.Power} size="100" />
|
||||
</span>
|
||||
<span className={css.MenuRowLabel}>{t('Settings.logout')}</span>
|
||||
</button>
|
||||
{/* LogoutDialog owns its Overlay + FocusTrap. */}
|
||||
{logout && <LogoutDialog handleClose={() => setLogout(false)} />}
|
||||
</>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
|
|
@ -295,9 +320,6 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
|||
{activePage === SettingsPages.DevicesPage && (
|
||||
<Devices requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.EmojisStickersPage && (
|
||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
||||
)}
|
||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||
</PageRoot>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,23 +11,17 @@ import {
|
|||
color,
|
||||
Spinner,
|
||||
toRem,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
} from 'folds';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { Mono, SettingRow } from '../styles.css';
|
||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { DeviceSummary, MenuRowIcon, Mono, SettingRow } from '../styles.css';
|
||||
import { LogoutDialog } from '../../../components/logout-dialog';
|
||||
|
||||
export function DeviceTilePlaceholder() {
|
||||
return (
|
||||
|
|
@ -211,21 +205,8 @@ export function DeviceLogoutBtn() {
|
|||
<Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
|
||||
<Text size="B300">{t('Settings.logout')}</Text>
|
||||
</Chip>
|
||||
{prompt && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
onDeactivate: handleClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<LogoutDialog handleClose={handleClose} />
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
)}
|
||||
{/* LogoutDialog owns its Overlay + FocusTrap (self-contained Vojo card). */}
|
||||
{prompt && <LogoutDialog handleClose={handleClose} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -269,14 +250,22 @@ export function DeviceDeleteBtn({
|
|||
type DeviceTileProps = {
|
||||
device: IMyDevice;
|
||||
deleted?: boolean;
|
||||
// Tints the leading device chip green — the «this device» accent.
|
||||
current?: boolean;
|
||||
refreshDeviceList: () => Promise<void>;
|
||||
disabled?: boolean;
|
||||
options?: ReactNode;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
// Vojo device row: tinted rounded-square device chip (green for the current
|
||||
// session, critical when marked for sign-out), name + last-activity column
|
||||
// that toggles the technical details (id / IP / key) on tap, and a trailing
|
||||
// pencil for rename + the host-provided actions (logout / delete).
|
||||
export function DeviceTile({
|
||||
device,
|
||||
deleted,
|
||||
current,
|
||||
refreshDeviceList,
|
||||
disabled,
|
||||
options,
|
||||
|
|
@ -291,48 +280,52 @@ export function DeviceTile({
|
|||
setEdit(false);
|
||||
}, []);
|
||||
|
||||
let chipTint: 'green' | 'critical' | 'neutral' = 'neutral';
|
||||
if (deleted) chipTint = 'critical';
|
||||
else if (current) chipTint = 'green';
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingTile
|
||||
before={
|
||||
<IconButton
|
||||
variant={deleted ? 'Critical' : 'Secondary'}
|
||||
outlined={deleted}
|
||||
radii="300"
|
||||
onClick={() => setDetails(!details)}
|
||||
>
|
||||
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
|
||||
</IconButton>
|
||||
}
|
||||
after={
|
||||
!edit && (
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
{options}
|
||||
{!deleted && (
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
onClick={() => setEdit(true)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Text size="B300">{t('Settings.edit')}</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text size="T300">{device.display_name ?? device.device_id}</Text>
|
||||
<Box direction="Column">
|
||||
<Box alignItems="Center" gap="300">
|
||||
<span className={MenuRowIcon({ tint: chipTint })}>
|
||||
<Icon size="100" src={Icons.Monitor} filled={!!current} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className={DeviceSummary}
|
||||
onClick={() => setDetails(!details)}
|
||||
aria-expanded={details}
|
||||
>
|
||||
<Text size="T300" truncate style={{ fontWeight: 600 }}>
|
||||
{device.display_name ?? device.device_id}
|
||||
</Text>
|
||||
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
|
||||
{details && (
|
||||
<>
|
||||
<DeviceDetails device={device} />
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!edit && (
|
||||
<Box shrink="No" alignItems="Center" gap="200">
|
||||
{!deleted && (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={() => setEdit(true)}
|
||||
disabled={disabled}
|
||||
aria-label={t('Settings.edit')}
|
||||
>
|
||||
<Icon size="100" src={Icons.Pencil} />
|
||||
</IconButton>
|
||||
)}
|
||||
{options}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{details && (
|
||||
<Box direction="Column" gap="100">
|
||||
<DeviceDetails device={device} />
|
||||
{children}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
)}
|
||||
{edit && (
|
||||
<DeviceRename
|
||||
device={device}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Text } from 'folds';
|
||||
import { SectionLabel, SettingFlatRow } from '../styles.css';
|
||||
import { SectionLabel, SettingFlatGroup, SettingFlatRow } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SettingsPage } from '../SettingsPage';
|
||||
import { SettingsSection } from '../SettingsSection';
|
||||
|
|
@ -93,27 +93,33 @@ export function Devices({ requestClose }: DevicesProps) {
|
|||
{t('Settings.current')}
|
||||
</Text>
|
||||
{currentDevice ? (
|
||||
<Box className={SettingFlatRow} direction="Column" gap="400">
|
||||
<DeviceTile
|
||||
device={currentDevice}
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
options={<DeviceLogoutBtn />}
|
||||
>
|
||||
{crypto && <DeviceKeyDetails crypto={crypto} />}
|
||||
</DeviceTile>
|
||||
{crossSigningActive &&
|
||||
verificationStatus === VerificationStatus.Unverified &&
|
||||
defaultSecretStorageKeyId &&
|
||||
defaultSecretStorageKeyContent && (
|
||||
<VerifyCurrentDeviceTile
|
||||
secretStorageKeyId={defaultSecretStorageKeyId}
|
||||
secretStorageKeyContent={defaultSecretStorageKeyContent}
|
||||
/>
|
||||
// Inset grouped panel — same flat-group chrome the rest of the
|
||||
// settings pages use; the row itself is the Vojo device row
|
||||
// (green «this device» chip).
|
||||
<div className={SettingFlatGroup}>
|
||||
<Box className={SettingFlatRow} direction="Column" gap="400">
|
||||
<DeviceTile
|
||||
device={currentDevice}
|
||||
current
|
||||
refreshDeviceList={refreshDeviceList}
|
||||
options={<DeviceLogoutBtn />}
|
||||
>
|
||||
{crypto && <DeviceKeyDetails crypto={crypto} />}
|
||||
</DeviceTile>
|
||||
{crossSigningActive &&
|
||||
verificationStatus === VerificationStatus.Unverified &&
|
||||
defaultSecretStorageKeyId &&
|
||||
defaultSecretStorageKeyContent && (
|
||||
<VerifyCurrentDeviceTile
|
||||
secretStorageKeyId={defaultSecretStorageKeyId}
|
||||
secretStorageKeyContent={defaultSecretStorageKeyContent}
|
||||
/>
|
||||
)}
|
||||
{crypto && verificationStatus === VerificationStatus.Verified && (
|
||||
<BackupRestoreTile crypto={crypto} />
|
||||
)}
|
||||
{crypto && verificationStatus === VerificationStatus.Verified && (
|
||||
<BackupRestoreTile crypto={crypto} />
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</div>
|
||||
) : (
|
||||
<DeviceTilePlaceholder />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import { useTranslation } from 'react-i18next';
|
|||
import { Box, Button, config, Menu, Spinner, Text } from 'folds';
|
||||
import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import { SectionLabel, SettingFlatRow, SettingFlatRowCritical } from '../styles.css';
|
||||
import {
|
||||
SectionLabel,
|
||||
SettingFlatGroup,
|
||||
SettingFlatRow,
|
||||
SettingFlatRowCritical,
|
||||
} from '../styles.css';
|
||||
import { ActionUIA, ActionUIAFlowsLoader } from '../../../components/ActionUIA';
|
||||
import { DeviceDeleteBtn, DeviceTile } from './DeviceTile';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../../hooks/useAsyncCallback';
|
||||
|
|
@ -108,7 +113,8 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
|||
<Text as="span" className={SectionLabel}>
|
||||
{t('Settings.others')}
|
||||
</Text>
|
||||
<Box direction="Column">
|
||||
{/* Inset grouped panel — hairline-parted Vojo device rows. */}
|
||||
<Box className={SettingFlatGroup} direction="Column">
|
||||
{authMetadata && (
|
||||
<Box className={SettingFlatRow} direction="Column" gap="400">
|
||||
<SettingTile
|
||||
|
|
|
|||
|
|
@ -1,30 +0,0 @@
|
|||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { GlobalPacks } from './GlobalPacks';
|
||||
import { UserPack } from './UserPack';
|
||||
import { ImagePack } from '../../../plugins/custom-emoji';
|
||||
import { ImagePackView } from '../../../components/image-pack-view';
|
||||
import { SettingsPage } from '../SettingsPage';
|
||||
|
||||
type EmojisStickersProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
export function EmojisStickers({ requestClose }: EmojisStickersProps) {
|
||||
const { t } = useTranslation();
|
||||
const [imagePack, setImagePack] = useState<ImagePack>();
|
||||
|
||||
const handleImagePackViewClose = () => {
|
||||
setImagePack(undefined);
|
||||
};
|
||||
|
||||
if (imagePack) {
|
||||
return <ImagePackView address={imagePack.address} requestClose={handleImagePackViewClose} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsPage title={t('Settings.emojis_stickers_title')} requestClose={requestClose}>
|
||||
<UserPack onViewPack={setImagePack} />
|
||||
<GlobalPacks onViewPack={setImagePack} />
|
||||
</SettingsPage>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,553 +0,0 @@
|
|||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Box,
|
||||
Text,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
IconButton,
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
config,
|
||||
Spinner,
|
||||
Menu,
|
||||
RectCords,
|
||||
PopOut,
|
||||
Checkbox,
|
||||
toRem,
|
||||
Scroll,
|
||||
Header,
|
||||
Line,
|
||||
Chip,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useGlobalImagePacks, useRoomsImagePacks } from '../../../hooks/useImagePacks';
|
||||
import { SectionLabel, SettingRow } from '../styles.css';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
EmoteRoomsContent,
|
||||
ImagePack,
|
||||
ImageUsage,
|
||||
PackAddress,
|
||||
packAddressEqual,
|
||||
} from '../../../plugins/custom-emoji';
|
||||
import { LineClamp2 } from '../../../styles/Text.css';
|
||||
import { allRoomsAtom } from '../../../state/room-list/roomList';
|
||||
import { AccountDataEvent } from '../../../../types/matrix/accountData';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
function GlobalPackSelector({
|
||||
packs,
|
||||
useAuthentication,
|
||||
onSelect,
|
||||
}: {
|
||||
packs: ImagePack[];
|
||||
useAuthentication: boolean;
|
||||
onSelect: (addresses: PackAddress[]) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const roomToPacks = useMemo(() => {
|
||||
const rToP = new Map<string, ImagePack[]>();
|
||||
packs
|
||||
.filter((pack) => !pack.deleted)
|
||||
.forEach((pack) => {
|
||||
if (!pack.address) return;
|
||||
const pks = rToP.get(pack.address.roomId) ?? [];
|
||||
pks.push(pack);
|
||||
rToP.set(pack.address.roomId, pks);
|
||||
});
|
||||
return rToP;
|
||||
}, [packs]);
|
||||
|
||||
const [selected, setSelected] = useState<PackAddress[]>([]);
|
||||
const toggleSelect = (address: PackAddress) => {
|
||||
setSelected((addresses) => {
|
||||
const newAddresses = addresses.filter((addr) => !packAddressEqual(addr, address));
|
||||
if (newAddresses.length !== addresses.length) {
|
||||
return newAddresses;
|
||||
}
|
||||
newAddresses.push(address);
|
||||
return newAddresses;
|
||||
});
|
||||
};
|
||||
|
||||
const addSelected = (adds: PackAddress[]) => {
|
||||
setSelected((addresses) => {
|
||||
const newAddresses = Array.from(addresses);
|
||||
adds.forEach((address) => {
|
||||
if (newAddresses.find((addr) => packAddressEqual(addr, address))) {
|
||||
return;
|
||||
}
|
||||
newAddresses.push(address);
|
||||
});
|
||||
return newAddresses;
|
||||
});
|
||||
};
|
||||
|
||||
const removeSelected = (adds: PackAddress[]) => {
|
||||
setSelected((addresses) => {
|
||||
const newAddresses = addresses.filter(
|
||||
(addr) => !adds.find((address) => packAddressEqual(addr, address))
|
||||
);
|
||||
return newAddresses;
|
||||
});
|
||||
};
|
||||
|
||||
const hasSelected = selected.length > 0;
|
||||
return (
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Header size="400" variant="Surface" style={{ padding: `0 ${config.space.S300}` }}>
|
||||
<Box grow="Yes">
|
||||
<Text size="L400" truncate>
|
||||
{t('Settings.room_packs')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Chip
|
||||
radii="Pill"
|
||||
variant={hasSelected ? 'Success' : 'SurfaceVariant'}
|
||||
outlined={hasSelected}
|
||||
onClick={() => onSelect(selected)}
|
||||
>
|
||||
<Text size="B300">{hasSelected ? t('Settings.save') : t('Settings.close')}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Header>
|
||||
<Line variant="Surface" size="300" />
|
||||
<Box grow="Yes">
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="400"
|
||||
style={{
|
||||
paddingLeft: config.space.S300,
|
||||
paddingTop: config.space.S300,
|
||||
paddingBottom: config.space.S300,
|
||||
paddingRight: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{Array.from(roomToPacks.entries()).map(([roomId, roomPacks]) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
const roomPackAddresses = roomPacks
|
||||
.map((pack) => pack.address)
|
||||
.filter((addr): addr is PackAddress => addr !== undefined);
|
||||
const allSelected = roomPackAddresses.every((addr) =>
|
||||
selected.find((address) => packAddressEqual(addr, address))
|
||||
);
|
||||
|
||||
return (
|
||||
<Box key={roomId} direction="Column" gap="100">
|
||||
<Box alignItems="Center">
|
||||
<Box grow="Yes">
|
||||
<Text size="L400">{room.name}</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Chip
|
||||
variant={allSelected ? 'Critical' : 'Surface'}
|
||||
radii="Pill"
|
||||
onClick={() => {
|
||||
if (allSelected) {
|
||||
removeSelected(roomPackAddresses);
|
||||
return;
|
||||
}
|
||||
addSelected(roomPackAddresses);
|
||||
}}
|
||||
>
|
||||
<Text size="B300">
|
||||
{allSelected ? t('Settings.unselect_all') : t('Settings.select_all')}
|
||||
</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
{roomPacks.map((pack) => {
|
||||
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
|
||||
const avatarUrl = avatarMxc
|
||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication)
|
||||
: undefined;
|
||||
const { address } = pack;
|
||||
if (!address) return null;
|
||||
|
||||
const added = !!selected.find((addr) => packAddressEqual(addr, address));
|
||||
return (
|
||||
<SequenceCard
|
||||
key={pack.id}
|
||||
className={SettingRow}
|
||||
variant={added ? 'Success' : 'SurfaceVariant'}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={pack.meta.name ?? t('Settings.unknown')}
|
||||
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
|
||||
before={
|
||||
<Box alignItems="Center" gap="300">
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
}
|
||||
after={
|
||||
<Checkbox
|
||||
checked={added}
|
||||
variant="Success"
|
||||
onClick={() => toggleSelect(address)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{roomToPacks.size === 0 && (
|
||||
<SequenceCard
|
||||
className={SettingRow}
|
||||
variant="Background"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
style={{
|
||||
padding: `${config.space.S700} ${config.space.S400}`,
|
||||
maxWidth: toRem(300),
|
||||
margin: 'auto',
|
||||
}}
|
||||
>
|
||||
<Text size="H5" align="Center">
|
||||
{t('Settings.no_packs')}
|
||||
</Text>
|
||||
<Text size="T200" align="Center">
|
||||
{t('Settings.no_packs_desc')}
|
||||
</Text>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type GlobalPacksProps = {
|
||||
onViewPack: (imagePack: ImagePack) => void;
|
||||
};
|
||||
export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const globalPacks = useGlobalImagePacks();
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
|
||||
const roomIds = useAtomValue(allRoomsAtom);
|
||||
const rooms = useMemo(() => {
|
||||
const rs: Room[] = [];
|
||||
roomIds.forEach((rId) => {
|
||||
const r = mx.getRoom(rId);
|
||||
if (r) rs.push(r);
|
||||
});
|
||||
return rs;
|
||||
}, [mx, roomIds]);
|
||||
const roomsImagePack = useRoomsImagePacks(rooms);
|
||||
const nonGlobalPacks = useMemo(
|
||||
() =>
|
||||
roomsImagePack.filter(
|
||||
(pack) => !globalPacks.find((p) => packAddressEqual(pack.address, p.address))
|
||||
),
|
||||
[roomsImagePack, globalPacks]
|
||||
);
|
||||
|
||||
const [selectedPacks, setSelectedPacks] = useState<PackAddress[]>([]);
|
||||
const [removedPacks, setRemovedPacks] = useState<PackAddress[]>([]);
|
||||
|
||||
const unselectedGlobalPacks = useMemo(
|
||||
() =>
|
||||
nonGlobalPacks.filter(
|
||||
(pack) => !selectedPacks.find((addr) => packAddressEqual(pack.address, addr))
|
||||
),
|
||||
[selectedPacks, nonGlobalPacks]
|
||||
);
|
||||
|
||||
const handleRemove = (address: PackAddress) => {
|
||||
setRemovedPacks((addresses) => [...addresses, address]);
|
||||
};
|
||||
|
||||
const handleUndoRemove = (address: PackAddress) => {
|
||||
setRemovedPacks((addresses) => addresses.filter((addr) => !packAddressEqual(addr, address)));
|
||||
};
|
||||
|
||||
const handleSelected = (addresses: PackAddress[]) => {
|
||||
setMenuCords(undefined);
|
||||
if (addresses.length > 0) {
|
||||
setSelectedPacks((a) => [...addresses, ...a]);
|
||||
}
|
||||
};
|
||||
|
||||
const [applyState, applyChanges] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const content =
|
||||
mx.getAccountData(AccountDataEvent.PoniesEmoteRooms)?.getContent<EmoteRoomsContent>() ?? {};
|
||||
const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
|
||||
|
||||
selectedPacks.forEach((addr) => {
|
||||
const roomsToState = updatedContent.rooms ?? {};
|
||||
const stateKeyToObj = roomsToState[addr.roomId] ?? {};
|
||||
stateKeyToObj[addr.stateKey] = {};
|
||||
roomsToState[addr.roomId] = stateKeyToObj;
|
||||
updatedContent.rooms = roomsToState;
|
||||
});
|
||||
|
||||
removedPacks.forEach((addr) => {
|
||||
if (updatedContent.rooms?.[addr.roomId]?.[addr.stateKey]) {
|
||||
delete updatedContent.rooms?.[addr.roomId][addr.stateKey];
|
||||
}
|
||||
});
|
||||
|
||||
await mx.setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent);
|
||||
}, [mx, selectedPacks, removedPacks])
|
||||
);
|
||||
|
||||
const resetChanges = useCallback(() => {
|
||||
setSelectedPacks([]);
|
||||
setRemovedPacks([]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (applyState.status === AsyncStatus.Success) {
|
||||
resetChanges();
|
||||
}
|
||||
}, [applyState, resetChanges]);
|
||||
|
||||
const handleSelectMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const applyingChanges = applyState.status === AsyncStatus.Loading;
|
||||
const hasChanges = removedPacks.length > 0 || selectedPacks.length > 0;
|
||||
|
||||
const renderPack = (pack: ImagePack) => {
|
||||
const avatarMxc = pack.getAvatarUrl(ImageUsage.Emoticon);
|
||||
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||
const { address } = pack;
|
||||
if (!address) return null;
|
||||
const removed = !!removedPacks.find((addr) => packAddressEqual(addr, address));
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
key={pack.id}
|
||||
className={SettingRow}
|
||||
variant={removed ? 'Critical' : 'Background'}
|
||||
outlined
|
||||
mergeBorder={!removed}
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={
|
||||
<span style={{ textDecoration: removed ? 'line-through' : undefined }}>
|
||||
{pack.meta.name ?? t('Settings.unknown')}
|
||||
</span>
|
||||
}
|
||||
description={<span className={LineClamp2}>{pack.meta.attribution}</span>}
|
||||
before={
|
||||
<Box alignItems="Center" gap="300">
|
||||
{removed ? (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Critical"
|
||||
onClick={() => handleUndoRemove(address)}
|
||||
disabled={applyingChanges}
|
||||
>
|
||||
<Icon src={Icons.Plus} size="100" />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Secondary"
|
||||
onClick={() => handleRemove(address)}
|
||||
disabled={applyingChanges}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
</Box>
|
||||
}
|
||||
after={
|
||||
!removed && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => onViewPack(pack)}
|
||||
>
|
||||
<Text size="B300">{t('Settings.view')}</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text as="span" className={SectionLabel}>
|
||||
{t('Settings.favorite_packs')}
|
||||
</Text>
|
||||
<Box direction="Column">
|
||||
<SequenceCard
|
||||
className={SettingRow}
|
||||
variant="Background"
|
||||
outlined
|
||||
mergeBorder
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title={t('Settings.select_pack')}
|
||||
description={t('Settings.select_pack_desc')}
|
||||
after={
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSelectMenu}
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
>
|
||||
<Text size="B300">{t('Settings.select')}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
style={{
|
||||
display: 'flex',
|
||||
maxWidth: toRem(400),
|
||||
width: '100vw',
|
||||
maxHeight: toRem(500),
|
||||
}}
|
||||
>
|
||||
<GlobalPackSelector
|
||||
packs={unselectedGlobalPacks}
|
||||
useAuthentication={useAuthentication}
|
||||
onSelect={handleSelected}
|
||||
/>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
{globalPacks.map(renderPack)}
|
||||
{nonGlobalPacks
|
||||
.filter((pack) => !!selectedPacks.find((addr) => packAddressEqual(pack.address, addr)))
|
||||
.map(renderPack)}
|
||||
</Box>
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
<Menu
|
||||
style={{
|
||||
position: 'sticky',
|
||||
padding: config.space.S200,
|
||||
paddingLeft: config.space.S400,
|
||||
bottom: config.space.S400,
|
||||
left: config.space.S400,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
}}
|
||||
variant="Success"
|
||||
>
|
||||
<Box alignItems="Center" gap="400">
|
||||
<Box grow="Yes" direction="Column">
|
||||
{applyState.status === AsyncStatus.Error ? (
|
||||
<Text size="T200">
|
||||
<b>{t('Settings.apply_error')}</b>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T200">
|
||||
<b>{t('Settings.apply_ready')}</b>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={applyingChanges}
|
||||
onClick={resetChanges}
|
||||
>
|
||||
<Text size="B300">{t('Settings.reset')}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Success"
|
||||
radii="300"
|
||||
disabled={applyingChanges}
|
||||
before={applyingChanges && <Spinner variant="Success" fill="Solid" size="100" />}
|
||||
onClick={applyChanges}
|
||||
>
|
||||
<Text size="B300">{t('Settings.apply_changes')}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,64 +0,0 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button, Icon, Icons, Text } from 'folds';
|
||||
import { useUserImagePack } from '../../../hooks/useImagePacks';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SettingsSection } from '../SettingsSection';
|
||||
import { ImagePack, ImageUsage } from '../../../plugins/custom-emoji';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
|
||||
type UserPackProps = {
|
||||
onViewPack: (imagePack: ImagePack) => void;
|
||||
};
|
||||
export function UserPack({ onViewPack }: UserPackProps) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const userPack = useUserImagePack();
|
||||
const avatarMxc = userPack?.getAvatarUrl(ImageUsage.Emoticon);
|
||||
const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication) : undefined;
|
||||
|
||||
const handleView = () => {
|
||||
if (userPack) {
|
||||
onViewPack(userPack);
|
||||
} else {
|
||||
const defaultPack = new ImagePack(mx.getSafeUserId(), {}, undefined);
|
||||
onViewPack(defaultPack);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection label={t('Settings.default_pack')}>
|
||||
<SettingTile
|
||||
title={userPack?.meta.name ?? t('Settings.unknown')}
|
||||
description={userPack?.meta.attribution}
|
||||
before={
|
||||
<Avatar size="300" radii="300">
|
||||
{avatarUrl ? (
|
||||
<AvatarImage style={{ objectFit: 'contain' }} src={avatarUrl} />
|
||||
) : (
|
||||
<AvatarFallback>
|
||||
<Icon size="400" src={Icons.Sticker} filled />
|
||||
</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={handleView}
|
||||
>
|
||||
<Text size="B300">{t('Settings.view')}</Text>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export * from './EmojisStickers';
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { recipe } from '@vanilla-extract/recipes';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
// Section label above a grouped panel — uppercase, tracked, muted. Matches the
|
||||
|
|
@ -92,3 +93,196 @@ export const SettingsContentCenter = style({
|
|||
width: '100%',
|
||||
margin: 'auto',
|
||||
});
|
||||
|
||||
// ── Settings landing (profile hero + Fleet menu rows) ────────────────────
|
||||
//
|
||||
// The settings root opens on the user's OWN profile: a hero card with the
|
||||
// avatar, display name and mono mxid at the top of the menu. Tapping it
|
||||
// opens the Account page (where avatar/name editing lives) — the hero
|
||||
// replaces the old «Account» menu row. Below it, the page list uses the
|
||||
// Dawn sidebar vocabulary from the design bundle (stream-v2-dawn.jsx
|
||||
// BOT_LIST / ChannelsList SectionIcon): tinted rounded-square glyph chips +
|
||||
// app-type label + chevron, one accent per row.
|
||||
|
||||
export const ProfileHero = style({
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S300,
|
||||
padding: config.space.S300,
|
||||
borderRadius: toRem(16),
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
backgroundColor: color.Surface.Container,
|
||||
transition: 'background-color 120ms ease-out',
|
||||
selectors: {
|
||||
'&:active': { backgroundColor: color.Surface.ContainerActive },
|
||||
'&[aria-pressed="true"]': { backgroundColor: color.Surface.ContainerActive },
|
||||
},
|
||||
});
|
||||
|
||||
// Mouse-only hover — Android WebView's synthesised sticky `:hover` would
|
||||
// otherwise leave the hero tinted after every tap (same gate as
|
||||
// ChannelsList.SectionHeader; see src/index.tsx input-mode detector).
|
||||
globalStyle(`:root[data-input="mouse"] ${ProfileHero}:hover`, {
|
||||
backgroundColor: color.Surface.ContainerHover,
|
||||
});
|
||||
|
||||
export const ProfileHeroAvatar = style({
|
||||
// `position: relative` is load-bearing: UserAvatar renders the folds
|
||||
// AvatarImage/AvatarFallback, which are `position: absolute; width/height
|
||||
// 100%` and size against the nearest POSITIONED ancestor — without this
|
||||
// the image resolves against the settings panel and paints panel-sized
|
||||
// over the menu (same contract as user-profile HeroAvatar).
|
||||
position: 'relative',
|
||||
flexShrink: 0,
|
||||
width: toRem(56),
|
||||
height: toRem(56),
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const ProfileHeroText = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: toRem(2),
|
||||
});
|
||||
|
||||
export const ProfileHeroName = style({
|
||||
fontSize: toRem(16),
|
||||
lineHeight: toRem(21),
|
||||
fontWeight: 700,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
// Mono mxid line — same technical-metadata treatment as the chat header
|
||||
// handle.
|
||||
export const ProfileHeroHandle = style({
|
||||
fontSize: toRem(12),
|
||||
lineHeight: toRem(16),
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.6,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
export const ProfileHeroChevron = style({
|
||||
flexShrink: 0,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.45,
|
||||
});
|
||||
|
||||
// Menu row — flat, tall enough to tap (44px+), active row raises on the
|
||||
// panel tone with a weight bump (mirrors the chat-pane nav rows).
|
||||
export const MenuRow = style({
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
width: '100%',
|
||||
minHeight: toRem(46),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S300,
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: toRem(12),
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
transition: 'background-color 120ms ease-out',
|
||||
selectors: {
|
||||
'&:active': { backgroundColor: color.SurfaceVariant.ContainerActive },
|
||||
'&[aria-pressed="true"]': { backgroundColor: color.SurfaceVariant.ContainerActive },
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`:root[data-input="mouse"] ${MenuRow}:hover`, {
|
||||
backgroundColor: color.SurfaceVariant.ContainerHover,
|
||||
});
|
||||
|
||||
// Tinted rounded-square glyph chip — one muted Dawn accent per row (the
|
||||
// design bundle's letter-avatar idiom; same vocabulary as
|
||||
// ChannelsList.SectionIcon).
|
||||
export const MenuRowIcon = recipe({
|
||||
base: {
|
||||
flexShrink: 0,
|
||||
width: toRem(30),
|
||||
height: toRem(30),
|
||||
borderRadius: toRem(8),
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
variants: {
|
||||
tint: {
|
||||
violet: { background: 'rgba(149, 128, 255, 0.16)', color: '#9580ff' },
|
||||
amber: { background: 'rgba(212, 184, 138, 0.18)', color: '#d4b88a' },
|
||||
blue: { background: 'rgba(122, 182, 217, 0.16)', color: '#7ab6d9' },
|
||||
green: { background: 'rgba(125, 211, 168, 0.16)', color: '#7dd3a8' },
|
||||
rose: { background: 'rgba(192, 142, 123, 0.18)', color: '#c08e7b' },
|
||||
neutral: {
|
||||
background: color.Surface.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
},
|
||||
critical: { background: color.Critical.Container, color: color.Critical.Main },
|
||||
},
|
||||
},
|
||||
defaultVariants: { tint: 'neutral' },
|
||||
});
|
||||
|
||||
export const MenuRowLabel = style({
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
fontSize: toRem(14),
|
||||
lineHeight: toRem(19),
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
export const MenuRowChevron = style({
|
||||
flexShrink: 0,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
opacity: 0.4,
|
||||
});
|
||||
|
||||
// Logout — same row shape in the critical accent, no chevron (it's an
|
||||
// action, not a sub-page).
|
||||
export const MenuRowCritical = style({
|
||||
color: color.Critical.Main,
|
||||
});
|
||||
|
||||
// Device row summary (Devices page) — the tappable name + last-activity
|
||||
// column that toggles the technical details. Reset button so the whole
|
||||
// text block is the toggle target.
|
||||
export const DeviceSummary = style({
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
padding: 0,
|
||||
font: 'inherit',
|
||||
textAlign: 'left',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: toRem(2),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
SettingsNavEyebrow,
|
||||
SettingsNavItem,
|
||||
SettingsNavSection,
|
||||
SettingsNavTint,
|
||||
} from '../common-settings/SettingsNav';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
|
|
@ -27,6 +28,7 @@ type SpaceSettingsMenuItem = {
|
|||
page: SpaceSettingsPage;
|
||||
name: string;
|
||||
icon: IconSrc;
|
||||
tint: SettingsNavTint;
|
||||
};
|
||||
|
||||
const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] => {
|
||||
|
|
@ -37,26 +39,31 @@ const useSpaceSettingsMenuItems = (): SpaceSettingsMenuItem[] => {
|
|||
page: SpaceSettingsPage.GeneralPage,
|
||||
name: t('RoomSettings.general'),
|
||||
icon: Icons.Setting,
|
||||
tint: 'violet',
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.MembersPage,
|
||||
name: t('RoomSettings.members'),
|
||||
icon: Icons.User,
|
||||
tint: 'blue',
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.PermissionsPage,
|
||||
name: t('RoomSettings.permissions'),
|
||||
icon: Icons.Lock,
|
||||
tint: 'amber',
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.EmojisStickersPage,
|
||||
name: t('RoomSettings.emojis_stickers'),
|
||||
icon: Icons.Smile,
|
||||
tint: 'rose',
|
||||
},
|
||||
{
|
||||
page: SpaceSettingsPage.DeveloperToolsPage,
|
||||
name: t('RoomSettings.developer_tools'),
|
||||
icon: Icons.Terminal,
|
||||
tint: 'neutral',
|
||||
},
|
||||
],
|
||||
[t]
|
||||
|
|
@ -144,6 +151,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps)
|
|||
key={item.name}
|
||||
icon={item.icon}
|
||||
label={item.name}
|
||||
tint={item.tint}
|
||||
active={activePage === item.page}
|
||||
onClick={() => setActivePage(item.page)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ import {
|
|||
useOpenChannelsWorkspaceSheet,
|
||||
} from '../../../state/hooks/channelsWorkspaceSheet';
|
||||
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||
import { mobileHorseshoeActiveAtom } from '../../../state/mobilePagerHeader';
|
||||
import {
|
||||
mobileHorseshoeActiveAtom,
|
||||
mobileHorseshoeElevateHeaderAtom,
|
||||
} from '../../../state/mobilePagerHeader';
|
||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../../styles/horseshoe';
|
||||
import { WorkspaceSwitcherSheet } from './WorkspaceSwitcherSheet';
|
||||
import * as css from './ChannelsWorkspaceHorseshoe.css';
|
||||
|
|
@ -168,15 +171,23 @@ export function ChannelsWorkspaceHorseshoe({ space, children }: ChannelsWorkspac
|
|||
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. Mirror of the same
|
||||
// bridge in `MobileSettingsHorseshoe.tsx`. See the atom's docs in
|
||||
// `state/mobilePagerHeader.ts` for the «no black flash» rationale.
|
||||
// pager-shared atoms. `active` feeds the refresh singleton's hide;
|
||||
// `elevateHeader` makes `MobileTabsPagerHeader` z-elevate the static
|
||||
// header from the first frame of drag — THIS sheet keeps the void-carve
|
||||
// design with an opaque appBody, which would otherwise paint over the
|
||||
// tabs band («no black flash» — see the atoms' docs in
|
||||
// `state/mobilePagerHeader.ts`). The Settings sheet publishes only
|
||||
// `active`: it overlays the header like a real curtain instead.
|
||||
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
|
||||
const setMobileHorseshoeElevateHeader = useSetAtom(mobileHorseshoeElevateHeaderAtom);
|
||||
useEffect(() => {
|
||||
setMobileHorseshoeActive(horseshoeActive);
|
||||
return () => setMobileHorseshoeActive(false);
|
||||
}, [horseshoeActive, setMobileHorseshoeActive]);
|
||||
setMobileHorseshoeElevateHeader(horseshoeActive);
|
||||
return () => {
|
||||
setMobileHorseshoeActive(false);
|
||||
setMobileHorseshoeElevateHeader(false);
|
||||
};
|
||||
}, [horseshoeActive, setMobileHorseshoeActive, setMobileHorseshoeElevateHeader]);
|
||||
|
||||
const handleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
|
|||
|
|
@ -133,20 +133,14 @@ export const curtainPinnedByTabAtom = atom<Partial<Record<MobilePagerTab, boolea
|
|||
// switcher on Channels) is geometrically active — i.e. `expandedPx > 0`,
|
||||
// which covers both the in-flight drag and the committed-open state.
|
||||
//
|
||||
// Source of truth for `MobileTabsPagerHeader`'s z-elevation: when this
|
||||
// is true, the static pager header bumps to a positive z-index so it
|
||||
// paints above the strip's stacking context and covers the safe-top +
|
||||
// tabsRow band with `SurfaceVariant.Container`. Without this, the
|
||||
// container's `VOJO_HORSESHOE_VOID_COLOR` paint that drives the carve
|
||||
// would bleed up through the transparent strip stack into the system-
|
||||
// tray strip.
|
||||
// Consumed by `PagerRefreshSingleton`, which hides the strip-hosted
|
||||
// mascot/refresh chrome while any sheet is up. Header z-elevation is NOT
|
||||
// driven from here — that's `mobileHorseshoeElevateHeaderAtom` below,
|
||||
// published only by the void-carve sheet design.
|
||||
//
|
||||
// Tracking the GEOMETRIC signal (not the sheet-open atoms) is load-
|
||||
// bearing: container bg flips to the void colour the moment a drag-
|
||||
// up on `DirectSelfRow` crosses 0 px, but the sheet atom only commits
|
||||
// after 80 px past the threshold. Driving elevation off the geometry
|
||||
// keeps the static header z-above the void from the first frame of
|
||||
// drag — no «pane goes black then header re-paints light blue» flash.
|
||||
// Tracking the GEOMETRIC signal (not the sheet-open atoms) keeps the
|
||||
// singleton's hide in lockstep with the first frame of drag, not 80 px
|
||||
// later when the user crosses the commit threshold.
|
||||
//
|
||||
// Mount exclusivity: in pager mode all three listing surfaces stay
|
||||
// mounted, but only one's horseshoe can have `horseshoeActive=true`
|
||||
|
|
@ -156,3 +150,15 @@ export const curtainPinnedByTabAtom = atom<Partial<Record<MobilePagerTab, boolea
|
|||
// `true` at the same time is therefore unreachable through any user
|
||||
// flow.
|
||||
export const mobileHorseshoeActiveAtom = atom<boolean>(false);
|
||||
|
||||
// True while a horseshoe sheet whose design paints an OPAQUE appBody +
|
||||
// void carve (the Channels workspace switcher) is active — only then must
|
||||
// `MobileTabsPagerHeader` z-elevate the static header, or the void paint
|
||||
// would swallow the tabs band («no black flash», see the atom above).
|
||||
//
|
||||
// The near-fullscreen Settings sheet deliberately does NOT publish this:
|
||||
// its 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 — elevating the header there would paint the tabs on top
|
||||
// of the sheet's drag handle and steal its taps.
|
||||
export const mobileHorseshoeElevateHeaderAtom = atom<boolean>(false);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue