temp
This commit is contained in:
parent
99391d9f28
commit
c2f504ffb4
114 changed files with 4206 additions and 3674 deletions
|
|
@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createT } from './i18n';
|
import { createT } from './i18n';
|
||||||
import { WidgetApi, buildCapabilities } from './widget-api';
|
import { WidgetApi, buildCapabilities } from './widget-api';
|
||||||
|
import { installSwipeForwarder } from './swipe-forward';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
|
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
|
||||||
|
|
@ -58,5 +59,8 @@ if (!result.ok) {
|
||||||
// with the cached-bundle remount path. See widget-telegram for full
|
// with the cached-bundle remount path. See widget-telegram for full
|
||||||
// rationale.
|
// rationale.
|
||||||
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
||||||
|
// Forward the raw touch stream to the host so its swipe-back
|
||||||
|
// gesture works over this iframe — see swipe-forward.ts.
|
||||||
|
installSwipeForwarder(result.bootstrap.parentOrigin);
|
||||||
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
apps/widget-discord/src/swipe-forward.ts
Normal file
121
apps/widget-discord/src/swipe-forward.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Forwards the widget's raw touch stream to the Vojo host so the
|
||||||
|
// swipe-back-from-widget gesture works across the iframe boundary. An
|
||||||
|
// iframe is a separate browsing context — touches inside it NEVER bubble
|
||||||
|
// to the host document, so without this the host's interactive-pop
|
||||||
|
// gesture (src/app/components/swipe-back) is dead over the widget body.
|
||||||
|
//
|
||||||
|
// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch',
|
||||||
|
// data: { phase, x, y } }` posted to the parent with the pinned
|
||||||
|
// `parentOrigin` (same side-channel + origin discipline as
|
||||||
|
// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local
|
||||||
|
// clientX/Y; the host offsets them by the iframe's viewport rect.
|
||||||
|
//
|
||||||
|
// The host owns the real gesture state machine (dead-zone axis resolve,
|
||||||
|
// edge guard, distance commit). The ONLY logic duplicated here is the
|
||||||
|
// axis resolution needed to call preventDefault locally — the host
|
||||||
|
// cannot cancel this document's scroll, so once a single-finger drag
|
||||||
|
// resolves as horizontal-rightward we must suppress our own default
|
||||||
|
// handling or the widget's vertical scroll would fight the card slide.
|
||||||
|
// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync.
|
||||||
|
const DEAD_ZONE_PX = 12;
|
||||||
|
|
||||||
|
type Phase = 'start' | 'move' | 'end' | 'cancel';
|
||||||
|
|
||||||
|
export function installSwipeForwarder(parentOrigin: string): void {
|
||||||
|
const post = (phase: Phase, x: number, y: number): void => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } },
|
||||||
|
parentOrigin
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let tracking = false;
|
||||||
|
let bailed = false;
|
||||||
|
let engaged = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
|
||||||
|
const cancel = (x: number, y: number): void => {
|
||||||
|
if (tracking && !bailed) post('cancel', x, y);
|
||||||
|
tracking = false;
|
||||||
|
bailed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
(e) => {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
cancel(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
tracking = true;
|
||||||
|
bailed = false;
|
||||||
|
engaged = false;
|
||||||
|
startX = t.clientX;
|
||||||
|
startY = t.clientY;
|
||||||
|
post('start', t.clientX, t.clientY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchmove',
|
||||||
|
(e) => {
|
||||||
|
if (!tracking || bailed) return;
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
cancel(t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!engaged) {
|
||||||
|
const dx = t.clientX - startX;
|
||||||
|
const dy = t.clientY - startY;
|
||||||
|
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) {
|
||||||
|
// Still inside the dead-zone — keep feeding the host (its own
|
||||||
|
// machine waits the same way) but make no local decision yet.
|
||||||
|
post('move', t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vertical-dominant or leftward: the gesture is the widget's own
|
||||||
|
// (scroll / horizontal UI). Stop forwarding — the host's machine
|
||||||
|
// bails identically from the same data; the cancel is belt and
|
||||||
|
// braces against threshold drift.
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) {
|
||||||
|
cancel(t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engaged = true;
|
||||||
|
}
|
||||||
|
// Horizontal-rightward drag — the host owns it now. Suppress the
|
||||||
|
// widget's own scroll for the rest of the touch.
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
post('move', t.clientX, t.clientY);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
(e) => {
|
||||||
|
if (!tracking || bailed) {
|
||||||
|
tracking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracking = false;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
post('end', t?.clientX ?? startX, t?.clientY ?? startY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchcancel',
|
||||||
|
(e) => {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
cancel(t?.clientX ?? startX, t?.clientY ?? startY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createT } from './i18n';
|
import { createT } from './i18n';
|
||||||
import { WidgetApi } from './widget-api';
|
import { WidgetApi } from './widget-api';
|
||||||
|
import { installSwipeForwarder } from './swipe-forward';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
// Input-mode detector for hover styling. CSS gates `:hover` and
|
// Input-mode detector for hover styling. CSS gates `:hover` and
|
||||||
|
|
@ -73,5 +74,8 @@ if (!result.ok) {
|
||||||
// cached-bundle remount the request can race ahead of any useEffect —
|
// cached-bundle remount the request can race ahead of any useEffect —
|
||||||
// construction at module-load closes that window.
|
// construction at module-load closes that window.
|
||||||
const api = new WidgetApi(result.bootstrap);
|
const api = new WidgetApi(result.bootstrap);
|
||||||
|
// Forward the raw touch stream to the host so its swipe-back
|
||||||
|
// gesture works over this iframe — see swipe-forward.ts.
|
||||||
|
installSwipeForwarder(result.bootstrap.parentOrigin);
|
||||||
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
apps/widget-telegram/src/swipe-forward.ts
Normal file
121
apps/widget-telegram/src/swipe-forward.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Forwards the widget's raw touch stream to the Vojo host so the
|
||||||
|
// swipe-back-from-widget gesture works across the iframe boundary. An
|
||||||
|
// iframe is a separate browsing context — touches inside it NEVER bubble
|
||||||
|
// to the host document, so without this the host's interactive-pop
|
||||||
|
// gesture (src/app/components/swipe-back) is dead over the widget body.
|
||||||
|
//
|
||||||
|
// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch',
|
||||||
|
// data: { phase, x, y } }` posted to the parent with the pinned
|
||||||
|
// `parentOrigin` (same side-channel + origin discipline as
|
||||||
|
// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local
|
||||||
|
// clientX/Y; the host offsets them by the iframe's viewport rect.
|
||||||
|
//
|
||||||
|
// The host owns the real gesture state machine (dead-zone axis resolve,
|
||||||
|
// edge guard, distance commit). The ONLY logic duplicated here is the
|
||||||
|
// axis resolution needed to call preventDefault locally — the host
|
||||||
|
// cannot cancel this document's scroll, so once a single-finger drag
|
||||||
|
// resolves as horizontal-rightward we must suppress our own default
|
||||||
|
// handling or the widget's vertical scroll would fight the card slide.
|
||||||
|
// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync.
|
||||||
|
const DEAD_ZONE_PX = 12;
|
||||||
|
|
||||||
|
type Phase = 'start' | 'move' | 'end' | 'cancel';
|
||||||
|
|
||||||
|
export function installSwipeForwarder(parentOrigin: string): void {
|
||||||
|
const post = (phase: Phase, x: number, y: number): void => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } },
|
||||||
|
parentOrigin
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let tracking = false;
|
||||||
|
let bailed = false;
|
||||||
|
let engaged = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
|
||||||
|
const cancel = (x: number, y: number): void => {
|
||||||
|
if (tracking && !bailed) post('cancel', x, y);
|
||||||
|
tracking = false;
|
||||||
|
bailed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
(e) => {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
cancel(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
tracking = true;
|
||||||
|
bailed = false;
|
||||||
|
engaged = false;
|
||||||
|
startX = t.clientX;
|
||||||
|
startY = t.clientY;
|
||||||
|
post('start', t.clientX, t.clientY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchmove',
|
||||||
|
(e) => {
|
||||||
|
if (!tracking || bailed) return;
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
cancel(t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!engaged) {
|
||||||
|
const dx = t.clientX - startX;
|
||||||
|
const dy = t.clientY - startY;
|
||||||
|
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) {
|
||||||
|
// Still inside the dead-zone — keep feeding the host (its own
|
||||||
|
// machine waits the same way) but make no local decision yet.
|
||||||
|
post('move', t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vertical-dominant or leftward: the gesture is the widget's own
|
||||||
|
// (scroll / horizontal UI). Stop forwarding — the host's machine
|
||||||
|
// bails identically from the same data; the cancel is belt and
|
||||||
|
// braces against threshold drift.
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) {
|
||||||
|
cancel(t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engaged = true;
|
||||||
|
}
|
||||||
|
// Horizontal-rightward drag — the host owns it now. Suppress the
|
||||||
|
// widget's own scroll for the rest of the touch.
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
post('move', t.clientX, t.clientY);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
(e) => {
|
||||||
|
if (!tracking || bailed) {
|
||||||
|
tracking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracking = false;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
post('end', t?.clientX ?? startX, t?.clientY ?? startY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchcancel',
|
||||||
|
(e) => {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
cancel(t?.clientX ?? startX, t?.clientY ?? startY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { readBootstrap } from './bootstrap';
|
||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createT } from './i18n';
|
import { createT } from './i18n';
|
||||||
import { WidgetApi } from './widget-api';
|
import { WidgetApi } from './widget-api';
|
||||||
|
import { installSwipeForwarder } from './swipe-forward';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
// Input-mode detector for hover styling. CSS gates `:hover` and
|
// Input-mode detector for hover styling. CSS gates `:hover` and
|
||||||
|
|
@ -73,5 +74,8 @@ if (!result.ok) {
|
||||||
// cached-bundle remount the request can race ahead of any useEffect —
|
// cached-bundle remount the request can race ahead of any useEffect —
|
||||||
// construction at module-load closes that window.
|
// construction at module-load closes that window.
|
||||||
const api = new WidgetApi(result.bootstrap);
|
const api = new WidgetApi(result.bootstrap);
|
||||||
|
// Forward the raw touch stream to the host so its swipe-back
|
||||||
|
// gesture works over this iframe — see swipe-forward.ts.
|
||||||
|
installSwipeForwarder(result.bootstrap.parentOrigin);
|
||||||
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
apps/widget-whatsapp/src/swipe-forward.ts
Normal file
121
apps/widget-whatsapp/src/swipe-forward.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
// Forwards the widget's raw touch stream to the Vojo host so the
|
||||||
|
// swipe-back-from-widget gesture works across the iframe boundary. An
|
||||||
|
// iframe is a separate browsing context — touches inside it NEVER bubble
|
||||||
|
// to the host document, so without this the host's interactive-pop
|
||||||
|
// gesture (src/app/components/swipe-back) is dead over the widget body.
|
||||||
|
//
|
||||||
|
// Protocol: `{ api: 'io.vojo.bot-widget', action: 'swipe-touch',
|
||||||
|
// data: { phase, x, y } }` posted to the parent with the pinned
|
||||||
|
// `parentOrigin` (same side-channel + origin discipline as
|
||||||
|
// `open-external-url` in widget-api.ts). Coordinates are IFRAME-local
|
||||||
|
// clientX/Y; the host offsets them by the iframe's viewport rect.
|
||||||
|
//
|
||||||
|
// The host owns the real gesture state machine (dead-zone axis resolve,
|
||||||
|
// edge guard, distance commit). The ONLY logic duplicated here is the
|
||||||
|
// axis resolution needed to call preventDefault locally — the host
|
||||||
|
// cannot cancel this document's scroll, so once a single-finger drag
|
||||||
|
// resolves as horizontal-rightward we must suppress our own default
|
||||||
|
// handling or the widget's vertical scroll would fight the card slide.
|
||||||
|
// Thresholds mirror the host's swipe-back/geometry.ts: keep in sync.
|
||||||
|
const DEAD_ZONE_PX = 12;
|
||||||
|
|
||||||
|
type Phase = 'start' | 'move' | 'end' | 'cancel';
|
||||||
|
|
||||||
|
export function installSwipeForwarder(parentOrigin: string): void {
|
||||||
|
const post = (phase: Phase, x: number, y: number): void => {
|
||||||
|
window.parent.postMessage(
|
||||||
|
{ api: 'io.vojo.bot-widget', action: 'swipe-touch', data: { phase, x, y } },
|
||||||
|
parentOrigin
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let tracking = false;
|
||||||
|
let bailed = false;
|
||||||
|
let engaged = false;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
|
||||||
|
const cancel = (x: number, y: number): void => {
|
||||||
|
if (tracking && !bailed) post('cancel', x, y);
|
||||||
|
tracking = false;
|
||||||
|
bailed = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchstart',
|
||||||
|
(e) => {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
cancel(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
tracking = true;
|
||||||
|
bailed = false;
|
||||||
|
engaged = false;
|
||||||
|
startX = t.clientX;
|
||||||
|
startY = t.clientY;
|
||||||
|
post('start', t.clientX, t.clientY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchmove',
|
||||||
|
(e) => {
|
||||||
|
if (!tracking || bailed) return;
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
const t = e.touches[0];
|
||||||
|
cancel(t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
if (!engaged) {
|
||||||
|
const dx = t.clientX - startX;
|
||||||
|
const dy = t.clientY - startY;
|
||||||
|
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) {
|
||||||
|
// Still inside the dead-zone — keep feeding the host (its own
|
||||||
|
// machine waits the same way) but make no local decision yet.
|
||||||
|
post('move', t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Vertical-dominant or leftward: the gesture is the widget's own
|
||||||
|
// (scroll / horizontal UI). Stop forwarding — the host's machine
|
||||||
|
// bails identically from the same data; the cancel is belt and
|
||||||
|
// braces against threshold drift.
|
||||||
|
if (Math.abs(dy) >= Math.abs(dx) || dx <= 0) {
|
||||||
|
cancel(t.clientX, t.clientY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
engaged = true;
|
||||||
|
}
|
||||||
|
// Horizontal-rightward drag — the host owns it now. Suppress the
|
||||||
|
// widget's own scroll for the rest of the touch.
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
post('move', t.clientX, t.clientY);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchend',
|
||||||
|
(e) => {
|
||||||
|
if (!tracking || bailed) {
|
||||||
|
tracking = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tracking = false;
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
post('end', t?.clientX ?? startX, t?.clientY ?? startY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
document.addEventListener(
|
||||||
|
'touchcancel',
|
||||||
|
(e) => {
|
||||||
|
const t = e.changedTouches[0];
|
||||||
|
cancel(t?.clientX ?? startX, t?.clientY ?? startY);
|
||||||
|
},
|
||||||
|
{ passive: true }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -152,15 +152,15 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g
|
||||||
| Dir | Purpose |
|
| Dir | Purpose |
|
||||||
|-----|---------|
|
|-----|---------|
|
||||||
| `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; the `…` overflow opens `room-actions/RoomActionsMenu` in an anchored folds PopOut — same chrome on desktop and mobile — restyled to a flat `ActionRow` vocabulary (`RoomActions.tsx`) on the dark-blue Vojo composer tone (folds Menu `variant="SurfaceVariant"` = #181a20); hosts mark-read/notifications/search/pinned/copy-link/settings/jump-to-time/invite/leave with the same nested popouts/overlays as upstream). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. |
|
| `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; the `…` overflow opens `room-actions/RoomActionsMenu` in an anchored folds PopOut — same chrome on desktop and mobile — restyled to a flat `ActionRow` vocabulary (`RoomActions.tsx`) on the dark-blue Vojo composer tone (folds Menu `variant="SurfaceVariant"` = #181a20); hosts mark-read/notifications/search/pinned/copy-link/settings/jump-to-time/invite/leave with the same nested popouts/overlays as upstream). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. |
|
||||||
| `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? <ChannelLayout/> : <StreamLayout/>`), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `MessageEditor`, `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. |
|
| `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? <ChannelLayout/> : <StreamLayout/>`), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. Editing happens IN the composer (Telegram-style edit banner in `RoomInput.tsx`, `roomIdToEditDraftAtomFamily`); the old in-timeline `MessageEditor` is gone. |
|
||||||
| `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). |
|
| `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). |
|
||||||
| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Pairs with `pages/client/bots/`. |
|
| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Widget iframes also forward touch phases over the `io.vojo.bot-widget` side-channel (`swipe-touch` action; widget-side `apps/widget-*/src/swipe-forward.ts`, host-side `BotWidgetEmbed` → `components/swipe-back/externalSwipeFeed.ts`) so the mobile swipe-back gesture works over the widget body — contract notes in `BotWidgetEmbed.ts`. Pairs with `pages/client/bots/`. |
|
||||||
| `share-target/` | **NEW.** Android/web system share-sheet hand-off. `ShareTargetStrip.tsx` is a top banner (mounted in `HorseshoeContainer`) shown while `pendingShareAtom` holds a payload; the next `RoomInput` mount consumes it (injects files + text, then nulls the atom). Native slot drained by `hooks/useShareTargetReceiver.ts`. |
|
| `share-target/` | **NEW.** Android/web system share-sheet hand-off. `ShareTargetStrip.tsx` is a top banner (mounted in `HorseshoeContainer`) shown while `pendingShareAtom` holds a payload; the next `RoomInput` mount consumes it (injects files + text, then nulls the atom). Native slot drained by `hooks/useShareTargetReceiver.ts`. |
|
||||||
| `call/` | **In-room call pane** — `CallView` (prescreen/join screen + member list + livekit checks), `CallControls`/`Controls`/`PrescreenControls`/`CallMemberCard`. Mounted in `Room.tsx` via `<CallView/>`. Consumes `plugins/call` CallEmbed + `state/callEmbed`. Don't unmount/remount the widget root carelessly — Android FGS is keyed on `joined`. |
|
| `call/` | **In-room call pane** — `CallView` (prescreen/join screen + member list + livekit checks), `CallControls`/`Controls`/`PrescreenControls`/`CallMemberCard`. Mounted in `Room.tsx` via `<CallView/>`. Consumes `plugins/call` CallEmbed + `state/callEmbed`. Don't unmount/remount the widget root carelessly — Android FGS is keyed on `joined`. |
|
||||||
| `call-status/` | **Global bottom call rail** — `IncomingCallStrip` (incoming-ring row) + `CallStatus` (active-call pill) + `CallControl`. Mounted via `pages/CallStatusRenderer.tsx` + `pages/IncomingCallStripRenderer.tsx` inside `HorseshoeContainer` (NOT directly in Router). Call **lifecycle** hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`, `usePendingCallActionConsumer`) run in Router's `IncomingCallsFeature()`. |
|
| `call-status/` | **Global bottom call rail** — `IncomingCallStrip` (incoming-ring row) + `CallStatus` (active-call pill) + `CallControl`. Mounted via `pages/CallStatusRenderer.tsx` + `pages/IncomingCallStripRenderer.tsx` inside `HorseshoeContainer` (NOT directly in Router). Call **lifecycle** hooks (`useIncomingRtcNotifications`, `useCallerAutoHangup`, `usePendingCallActionConsumer`) run in Router's `IncomingCallsFeature()`. |
|
||||||
| `settings/` | User settings as 7 pages (`GeneralPage, AccountPage, NotificationPage, DevicesPage, EmojisStickersPage, DeveloperToolsPage, AboutPage`; `SETTINGS_PAGE_PARAM` deep-links). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` / `hour24Clock` / `dateFormatString` were removed — layout is no longer user-configurable and time/date derive from the runtime locale (`utils/time.ts`). `hideMembershipEvents` / `hideNickAvatarEvents` survive (gate group-room syslines). **Logout lives here only** (`LogoutDialog`). `MobileSettingsHorseshoe` + `SettingsScreen` are the mobile sheet / route entry. |
|
| `settings/` | User settings as 6 pages (`GeneralPage, AccountPage, NotificationPage, NetworkPage, DevicesPage, AboutPage`; `SETTINGS_PAGE_PARAM` deep-links; emojis-stickers and developer-tools pages were dropped from user settings). `MessageLayout` / `messageSpacing` / `legacyUsernameColor` / `hour24Clock` / `dateFormatString` were removed — layout is no longer user-configurable and time/date derive from the runtime locale (`utils/time.ts`). `hideMembershipEvents` / `hideNickAvatarEvents` survive (gate group-room syslines). Logout confirm is `components/logout-dialog/` (self-contained Vojo card; opened from the settings menu row and `DeviceTile`'s current-device sign-out). `MobileSettingsHorseshoe` + `SettingsScreen` are the mobile sheet / route entry. |
|
||||||
| `common-settings/` | **Shared** settings modules reused by both room and space settings: `general/` (RoomProfile, Address, Encryption, HistoryVisibility, JoinRules, Publish, Upgrade), `members/`, `permissions/` (Powers, PowersEditor, PermissionGroups), `emojis-stickers/` (RoomPacks), `developer-tools/` (SendRoomEvent, StateEventEditor). |
|
| `common-settings/` | **Shared** settings modules reused by both room and space settings: `general/` (RoomProfile, Address, Encryption, HistoryVisibility, JoinRules, Publish, Upgrade), `members/`, `permissions/` (Powers, PowersEditor, PermissionGroups), `emojis-stickers/` (RoomPacks), `developer-tools/` (SendRoomEvent, StateEventEditor). |
|
||||||
| `room-settings/` / `space-settings/` | Each defines only its own General + Permissions and **imports** Members/EmojisStickers/DeveloperTools from `common-settings`. Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. |
|
| `room-settings/` / `space-settings/` | Room settings is a SINGLE page (profile + join rules/history/encryption/voice) in a narrow `Modal500`; no nav, no permissions/dev-tools (Matrix-protocol surfaces dropped from rooms). Space settings keeps the full nav set (General/Members/Permissions/EmojisStickers/DeveloperTools via `common-settings`). Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. |
|
||||||
| `lobby/` | Space lobby (`Lobby` + Hierarchy/Item + pragmatic-drag-and-drop reordering). Still routed under `SPACE_PATH/_LOBBY_PATH` and reached from the **legacy** Space tab — the new Channels surface does **not** use it. |
|
| `lobby/` | Space lobby (`Lobby` + Hierarchy/Item + pragmatic-drag-and-drop reordering). Still routed under `SPACE_PATH/_LOBBY_PATH` and reached from the **legacy** Space tab — the new Channels surface does **not** use it. |
|
||||||
| `search/` | Unified switcher (Cmd+K modal `Search.tsx` via `SearchModalRenderer` + the StreamHeader's `InlineRoomSearch`, both on `useRoomSearch.ts`). Searches local rooms/DMs/spaces (`useAsyncSearch`) **and** a **"People" section** from the homeserver user directory (`mx.searchUserDirectory`, debounced 300ms / ≥2 chars; exact `@user:server` falls back to `mx.getProfileInfo`, then a soft-added raw id). People are deduped against existing DMs (`getDMRoomFor`); clicking one opens-or-creates a DM via `create-chat/useCreateDirect.ts`. This is how you reach someone you haven't chatted with — the old "+ new chat" form is gone from the listing headers (see `stream-header/`). Findability of who appears is the server's lever: Synapse `user_directory.search_all_users` (federation only surfaces remote users the server already knows). |
|
| `search/` | Unified switcher (Cmd+K modal `Search.tsx` via `SearchModalRenderer` + the StreamHeader's `InlineRoomSearch`, both on `useRoomSearch.ts`). Searches local rooms/DMs/spaces (`useAsyncSearch`) **and** a **"People" section** from the homeserver user directory (`mx.searchUserDirectory`, debounced 300ms / ≥2 chars; exact `@user:server` falls back to `mx.getProfileInfo`, then a soft-added raw id). People are deduped against existing DMs (`getDMRoomFor`); clicking one opens-or-creates a DM via `create-chat/useCreateDirect.ts`. This is how you reach someone you haven't chatted with — the old "+ new chat" form is gone from the listing headers (see `stream-header/`). Findability of who appears is the server's lever: Synapse `user_directory.search_all_users` (federation only surfaces remote users the server already knows). |
|
||||||
| `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). |
|
| `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). |
|
||||||
|
|
@ -213,11 +213,11 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem
|
||||||
|
|
||||||
### Badges / indicators / upload / preview
|
### Badges / indicators / upload / preview
|
||||||
|
|
||||||
`unread-badge/`, `server-badge/` (**NEW**), `typing-indicator/` (**NEW**), `time-date/` (**NEW** — DatePicker/TimePicker/PickerColumn), `upload-card/`, `upload-board/` (**NEW**), `url-preview/`.
|
`unread-badge/`, `server-badge/` (**NEW**), `typing-indicator/` (**NEW**), `time-date/` (**NEW** — DatePicker/TimePicker/PickerColumn), `upload-card/` (compact renderer only — the floating `upload-board/` window is gone; attachments render inside the composer via `features/room/ComposerAttachments.tsx`), `url-preview/`.
|
||||||
|
|
||||||
### Prompts / dialogs
|
### Prompts / dialogs
|
||||||
|
|
||||||
`invite-user-prompt/`, `leave-room-prompt/`, `leave-space-prompt/` (**NEW**), `full-screen-intent-prompt/` (**NEW** — Android FSI permission, 7-day cooldown), `push-permission-prompt/` (**NEW** — push permission, 7-day cooldown), `uia-stages/` (Dummy/Email/Password/ReCaptcha/RegistrationToken/SSO/Terms), `LogoutDialog`. (`join-address-prompt/` from the old doc **does not exist**.)
|
`invite-user-prompt/`, `leave-room-prompt/`, `leave-space-prompt/` (**NEW**), `full-screen-intent-prompt/` (**NEW** — Android FSI permission, 7-day cooldown), `push-permission-prompt/` (**NEW** — push permission, 7-day cooldown), `uia-stages/` (Dummy/Email/Password/ReCaptcha/RegistrationToken/SSO/Terms), `logout-dialog/`. (`join-address-prompt/` from the old doc **does not exist**.)
|
||||||
|
|
||||||
### Boot/runtime Loaders & Providers (top-level render-prop components)
|
### Boot/runtime Loaders & Providers (top-level render-prop components)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,16 +59,30 @@ The developer reviews Russian translations as a native speaker would see them in
|
||||||
- `Organisms` — Complex UI pieces
|
- `Organisms` — Complex UI pieces
|
||||||
- `Boot` — Boot/splash error screens (`ConfigConfigError`, `SpecVersions` error, `ClientRoot` init/start error)
|
- `Boot` — Boot/splash error screens (`ConfigConfigError`, `SpecVersions` error, `ClientRoot` init/start error)
|
||||||
|
|
||||||
**Still to localise** (snapshot taken 2026-04-14 — verify before acting):
|
**Still to localise** (snapshot taken 2026-06-12 — verify before acting):
|
||||||
|
|
||||||
- Room features: `MessageEditor`, `MembersDrawer`, `RoomTombstone`
|
- Room features: `MembersDrawer`, `RoomTombstone`
|
||||||
- Lobby: `Lobby.tsx`, `RoomItem.tsx`
|
|
||||||
- `AddExisting` feature
|
- `AddExisting` feature
|
||||||
- System pages: `FeatureCheck`, `WelcomePage`
|
- System pages: `FeatureCheck`, `WelcomePage`
|
||||||
- Auth: `OrDivider`
|
- Auth: `OrDivider`
|
||||||
- Dialogs: `LogoutDialog`, `ManualVerification`, `BackupRestore`
|
- UIA stages: `ReCaptchaStage`, `RegistrationTokenStage`
|
||||||
- UIA stages: `ReCaptchaStage`, `EmailStage`, `RegistrationTokenStage`
|
- Components: `TimePicker`, `DatePicker`, `ImageViewer`, `PdfViewer`
|
||||||
- Components: `TimePicker`, `DatePicker`, `ImageViewer`, `PdfViewer`, `UploadCard`, `UserModeration`
|
|
||||||
- Various `aria-label` attributes throughout
|
- Various `aria-label` attributes throughout
|
||||||
|
|
||||||
|
Localised 2026-06-12 (the hardcoded-English sweep): `UserModeration`,
|
||||||
|
call screens (`CallControls`/`PrescreenControls`/`CallView`/`CallMemberCard`),
|
||||||
|
lobby (`Lobby`/`RoomItem`/`SpaceItem`/`LobbyHeader`), `MessageSearch`
|
||||||
|
(aria-label), `LeaveSpacePrompt`, the device-verification cluster
|
||||||
|
(`DeviceVerification`/`DeviceVerificationSetup`/`ManualVerification`/
|
||||||
|
`BackupRestore`/settings `Verification`), `ComposerAttachments`
|
||||||
|
(spoiler/retry/cancel/size-error — the attachment strip that replaced the
|
||||||
|
UploadBoard window), editor `Toolbar` tooltips, `EmailStage`,
|
||||||
|
`MediaViewerBody` aria-labels (`MediaViewer.*`/`Common.*`). `MessageEditor`
|
||||||
|
was deleted (editing moved into the composer); `LogoutDialog` was rewritten
|
||||||
|
as a localized self-contained card. Dead `Settings.email_*notification*`
|
||||||
|
strings removed from both locale files (the section is never rendered), as
|
||||||
|
were the keys orphaned by the dropped room-permissions page
|
||||||
|
(`RoomSettings.perm_*` room-only set), the user emoji-pack settings page,
|
||||||
|
and the old upload-size-limit concatenation fragments.
|
||||||
|
|
||||||
When the developer asks to localise a new area, check this list first and update it after finishing.
|
When the developer asks to localise a new area, check this list first and update it after finishing.
|
||||||
|
|
|
||||||
|
|
@ -103,15 +103,24 @@
|
||||||
"reset_button": "Reset Password",
|
"reset_button": "Reset Password",
|
||||||
"reset_error_fallback": "Failed to reset password.",
|
"reset_error_fallback": "Failed to reset password.",
|
||||||
"reset_success_message": "Password has been reset successfully. Please login with your new password.",
|
"reset_success_message": "Password has been reset successfully. Please login with your new password.",
|
||||||
"reset_success_login": "Login"
|
"reset_success_login": "Login",
|
||||||
|
"uia_email_label": "Email",
|
||||||
|
"uia_email_send_button": "Send Verification Email",
|
||||||
|
"uia_email_cancel": "Cancel",
|
||||||
|
"uia_email_sending": "Sending verification email...",
|
||||||
|
"uia_email_verify_title": "Verify Email",
|
||||||
|
"uia_email_send_error": "Failed to send verification email request.",
|
||||||
|
"uia_email_sent_title": "Verification Request Sent",
|
||||||
|
"uia_email_sent_message": "Please check your email \"{{email}}\" and validate before continuing further.",
|
||||||
|
"uia_email_continue": "Continue",
|
||||||
|
"uia_email_provide_title": "Provide Email",
|
||||||
|
"uia_email_provide_message": "Please provide email to send verification request."
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
"menu_general": "General",
|
"menu_general": "General",
|
||||||
"menu_account": "Account",
|
|
||||||
"menu_notifications": "Notifications",
|
"menu_notifications": "Notifications",
|
||||||
"menu_devices": "Devices",
|
"menu_devices": "Devices",
|
||||||
"menu_emojis_stickers": "Emojis & Stickers",
|
|
||||||
"menu_about": "About",
|
"menu_about": "About",
|
||||||
"network": {
|
"network": {
|
||||||
"menu": "Connection",
|
"menu": "Connection",
|
||||||
|
|
@ -224,10 +233,6 @@
|
||||||
"fsi_prompt_body": "Let Vojo show full-screen notifications so incoming calls wake the screen and appear over the lockscreen, like WhatsApp or Telegram. Open \"Full-screen notifications\" and turn Vojo on.",
|
"fsi_prompt_body": "Let Vojo show full-screen notifications so incoming calls wake the screen and appear over the lockscreen, like WhatsApp or Telegram. Open \"Full-screen notifications\" and turn Vojo on.",
|
||||||
"fsi_prompt_later": "Not now",
|
"fsi_prompt_later": "Not now",
|
||||||
"fsi_prompt_open": "Open settings",
|
"fsi_prompt_open": "Open settings",
|
||||||
"email_notification": "Email Notification",
|
|
||||||
"email_no_email": "Your account does not have any email attached.",
|
|
||||||
"email_send_notif": "Send notification to your email.",
|
|
||||||
"email_send_notif_to": "Send notification to your email. (\"{{email}}\")",
|
|
||||||
"unexpected_error": "Unexpected Error!",
|
"unexpected_error": "Unexpected Error!",
|
||||||
"all_messages": "All Messages",
|
"all_messages": "All Messages",
|
||||||
"one_to_one": "1-to-1 Chats",
|
"one_to_one": "1-to-1 Chats",
|
||||||
|
|
@ -302,21 +307,11 @@
|
||||||
"import_desc": "Load password protected copy of encryption data from device to decrypt your messages.",
|
"import_desc": "Load password protected copy of encryption data from device to decrypt your messages.",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
"decrypt": "Decrypt",
|
"decrypt": "Decrypt",
|
||||||
"emojis_stickers_title": "Emojis & Stickers",
|
|
||||||
"default_pack": "Default Pack",
|
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"favorite_packs": "Favorite Packs",
|
|
||||||
"select_pack": "Select Pack",
|
|
||||||
"select_pack_desc": "Pick emoji and sticker packs from rooms to use globally.",
|
|
||||||
"select": "Select",
|
"select": "Select",
|
||||||
"room_packs": "Room Packs",
|
|
||||||
"select_all": "Select All",
|
|
||||||
"unselect_all": "Unselect All",
|
|
||||||
"no_packs": "No Packs",
|
"no_packs": "No Packs",
|
||||||
"no_packs_desc": "Packs from rooms will appear here. You do not have any rooms with packs yet.",
|
"no_packs_desc": "Packs from rooms will appear here. You do not have any rooms with packs yet.",
|
||||||
"apply_error": "Failed to apply changes! Please try again.",
|
|
||||||
"apply_ready": "Changes saved! Apply when ready.",
|
|
||||||
"apply_changes": "Apply Changes",
|
"apply_changes": "Apply Changes",
|
||||||
"about_title": "About",
|
"about_title": "About",
|
||||||
"about_tagline": "A messenger for everyone.",
|
"about_tagline": "A messenger for everyone.",
|
||||||
|
|
@ -333,7 +328,58 @@
|
||||||
"notify_on_mention": "Mentions",
|
"notify_on_mention": "Mentions",
|
||||||
"notify_on_mention_desc": "Notify me when someone mentions my name or username.",
|
"notify_on_mention_desc": "Notify me when someone mentions my name or username.",
|
||||||
"room_announcements": "@room announcements",
|
"room_announcements": "@room announcements",
|
||||||
"room_announcements_desc": "Notify me about @room messages."
|
"room_announcements_desc": "Notify me about @room messages.",
|
||||||
|
"verification_close": "Close",
|
||||||
|
"verification_accept_prompt": "Please accept the request from other device.",
|
||||||
|
"verification_waiting_accept": "Waiting for request to be accepted...",
|
||||||
|
"verification_click_accept": "Click accept to start the verification process.",
|
||||||
|
"verification_accept": "Accept",
|
||||||
|
"verification_request_accepted": "Verification request has been accepted.",
|
||||||
|
"verification_waiting_response": "Waiting for the response from other device...",
|
||||||
|
"verification_starting_emoji": "Starting verification using emoji comparison...",
|
||||||
|
"verification_compare_emoji_prompt": "Confirm the emoji below are displayed on both devices, in the same order:",
|
||||||
|
"verification_match": "They Match",
|
||||||
|
"verification_no_match": "Do not Match",
|
||||||
|
"verification_device_verified": "Your device is verified.",
|
||||||
|
"verification_okay": "Okay",
|
||||||
|
"verification_canceled": "Verification has been canceled.",
|
||||||
|
"verification_unexpected_error": "Unexpected Error! Verification is started but verifier is missing.",
|
||||||
|
"verification_setup_desc": "Generate a Recovery Key for verifying identity if you do not have access to other devices. Additionally, setup a passphrase as a memorable alternative.",
|
||||||
|
"verification_passphrase_optional": "Passphrase (Optional)",
|
||||||
|
"verification_continue": "Continue",
|
||||||
|
"verification_error_generic": "Unexpected Error!",
|
||||||
|
"verification_uia_unsupported": "Authentication steps to perform this action are not supported by client.",
|
||||||
|
"verification_recovery_key_store_desc": "Store the Recovery Key in a safe place for future use, as you will need it to verify your identity if you do not have access to other devices.",
|
||||||
|
"verification_recovery_key": "Recovery Key",
|
||||||
|
"verification_show": "Show",
|
||||||
|
"verification_hide": "Hide",
|
||||||
|
"verification_copy": "Copy",
|
||||||
|
"verification_download": "Download",
|
||||||
|
"verification_setup_title": "Setup Device Verification",
|
||||||
|
"verification_reset_title": "Reset Device Verification",
|
||||||
|
"verification_reset_permanent": "Resetting device verification is permanent.",
|
||||||
|
"verification_reset_warning": "Anyone you have verified with will see security alerts and your encryption backup will be lost. You almost certainly do not want to do this, unless you have lost Recovery Key or Recovery Passphrase and every device you can verify from.",
|
||||||
|
"verification_recovery_passphrase": "Recovery Passphrase",
|
||||||
|
"verification_select_method": "Select a verification method.",
|
||||||
|
"verification_provide_key": "Provide recovery key.",
|
||||||
|
"verification_verified_success": "Device verified!",
|
||||||
|
"verification_step_open": "Open {{settings}}.",
|
||||||
|
"verification_step_find": "Find this device in {{section}} section.",
|
||||||
|
"backup_connected": "Connected",
|
||||||
|
"backup_disconnected": "Disconnected",
|
||||||
|
"backup_syncing": "Syncing ({{amount}})",
|
||||||
|
"backup_restoring_percent": "Restoring: {{percent}}%",
|
||||||
|
"backup_trusted_decryption_key": "Backup has trusted decryption key.",
|
||||||
|
"backup_untrusted_decryption_key": "Backup does not have trusted decryption key!",
|
||||||
|
"backup_trusted_signature": "Backup is trusted by signature.",
|
||||||
|
"backup_untrusted_signature": "Backup does not have trusted signature!",
|
||||||
|
"backup_encryption_title": "Encryption Backup",
|
||||||
|
"backup_details": "Backup Details",
|
||||||
|
"backup_version": "Version: {{version}}",
|
||||||
|
"backup_keys_count": "Keys: {{amount}}",
|
||||||
|
"backup_none_value": "NIL",
|
||||||
|
"backup_restore": "Restore Backup",
|
||||||
|
"backup_none_on_server": "No backup present on server!"
|
||||||
},
|
},
|
||||||
"Search": {
|
"Search": {
|
||||||
"search": "Search",
|
"search": "Search",
|
||||||
|
|
@ -521,7 +567,20 @@
|
||||||
"bubble_cancelled_count_one": "{{count}} cancelled call",
|
"bubble_cancelled_count_one": "{{count}} cancelled call",
|
||||||
"bubble_cancelled_count_other": "{{count}} cancelled calls",
|
"bubble_cancelled_count_other": "{{count}} cancelled calls",
|
||||||
"duration_minutes_seconds": "{{minutes}} min {{seconds}} sec",
|
"duration_minutes_seconds": "{{minutes}} min {{seconds}} sec",
|
||||||
"duration_seconds": "{{seconds}} sec"
|
"duration_seconds": "{{seconds}} sec",
|
||||||
|
"view_grid": "Grid View",
|
||||||
|
"view_spotlight": "Spotlight View",
|
||||||
|
"reactions": "Reactions",
|
||||||
|
"settings": "Settings",
|
||||||
|
"homeserver_no_calls": "Your homeserver does not support calling. But you can still join call started by others.",
|
||||||
|
"empty_be_first": "Voice chat’s empty — be the first to hop in!",
|
||||||
|
"no_permission_to_join": "You don't have permission to join!",
|
||||||
|
"already_in_other_call": "Already in another call — end the current call to join!",
|
||||||
|
"participant": "Participants",
|
||||||
|
"live_count": "{{count}} Live",
|
||||||
|
"collapse": "Collapse",
|
||||||
|
"others_count_one": "+{{count}} Other",
|
||||||
|
"others_count_other": "+{{count}} Others"
|
||||||
},
|
},
|
||||||
"Room": {
|
"Room": {
|
||||||
"delivery": {
|
"delivery": {
|
||||||
|
|
@ -534,7 +593,6 @@
|
||||||
"collapse_avatar": "Collapse avatar",
|
"collapse_avatar": "Collapse avatar",
|
||||||
"expand_avatar": "Open avatar",
|
"expand_avatar": "Open avatar",
|
||||||
"new_messages": "New Messages",
|
"new_messages": "New Messages",
|
||||||
"jump_to_unread": "Jump to Unread",
|
|
||||||
"mark_as_read": "Mark as Read",
|
"mark_as_read": "Mark as Read",
|
||||||
"jump_to_latest": "Jump to Latest",
|
"jump_to_latest": "Jump to Latest",
|
||||||
"message_render_error": "This message could not be displayed.",
|
"message_render_error": "This message could not be displayed.",
|
||||||
|
|
@ -542,6 +600,13 @@
|
||||||
"yesterday": "Yesterday",
|
"yesterday": "Yesterday",
|
||||||
"view_reactions": "View Reactions",
|
"view_reactions": "View Reactions",
|
||||||
"read_receipts": "Read Receipts",
|
"read_receipts": "Read Receipts",
|
||||||
|
"seen_by": "Seen by",
|
||||||
|
"seen_by_count_one": "{{count}} person",
|
||||||
|
"seen_by_count_other": "{{count}} people",
|
||||||
|
"seen_by_empty": "No one has seen this message yet.",
|
||||||
|
"editing_message": "Editing message",
|
||||||
|
"editing_cancel": "Cancel editing",
|
||||||
|
"no_post_permission": "You do not have permission to post in this room",
|
||||||
"view_source": "View Source",
|
"view_source": "View Source",
|
||||||
"source_code": "Source Code",
|
"source_code": "Source Code",
|
||||||
"copy_link": "Copy Link",
|
"copy_link": "Copy Link",
|
||||||
|
|
@ -652,7 +717,6 @@
|
||||||
"thread_summary_unread_other": "{{count}} unread",
|
"thread_summary_unread_other": "{{count}} unread",
|
||||||
"thread_summary_highlight_one": "{{count}} mention",
|
"thread_summary_highlight_one": "{{count}} mention",
|
||||||
"thread_summary_highlight_other": "{{count}} mentions",
|
"thread_summary_highlight_other": "{{count}} mentions",
|
||||||
"no_post_permission": "You do not have permission to post in this room",
|
|
||||||
"empty_dm": "The hardest part is the first message.",
|
"empty_dm": "The hardest part is the first message.",
|
||||||
"empty_dm_alt_1": "You have to start somewhere.",
|
"empty_dm_alt_1": "You have to start somewhere.",
|
||||||
"empty_dm_alt_2": "Someone has to go first.",
|
"empty_dm_alt_2": "Someone has to go first.",
|
||||||
|
|
@ -693,7 +757,43 @@
|
||||||
"autocomplete_rooms": "Rooms",
|
"autocomplete_rooms": "Rooms",
|
||||||
"autocomplete_emojis": "Emojis",
|
"autocomplete_emojis": "Emojis",
|
||||||
"autocomplete_commands": "Commands",
|
"autocomplete_commands": "Commands",
|
||||||
"autocomplete_unknown_room": "Unknown Room"
|
"autocomplete_unknown_room": "Unknown Room",
|
||||||
|
"lobby_join": "Join",
|
||||||
|
"lobby_unknown": "Unknown",
|
||||||
|
"lobby_suggested": "Suggested",
|
||||||
|
"lobby_inaccessible": "Inaccessible",
|
||||||
|
"lobby_open_room": "Open Room",
|
||||||
|
"lobby_rooms": "Rooms",
|
||||||
|
"lobby_scroll_to_top": "Scroll to Top",
|
||||||
|
"lobby_reordering": "Reordering",
|
||||||
|
"lobby_space_settings": "Space Settings",
|
||||||
|
"lobby_leave_space": "Leave Space",
|
||||||
|
"lobby_members_count_one": "{{formattedCount}} Member",
|
||||||
|
"lobby_members_count_other": "{{formattedCount}} Members",
|
||||||
|
"msg_search_scroll_to_top": "Scroll to Top",
|
||||||
|
"leave_space_title": "Leave Space",
|
||||||
|
"leave_space_confirm": "Are you sure you want to leave this space?",
|
||||||
|
"leave_space_error": "Failed to leave space! {{error}}",
|
||||||
|
"upload_spoiler": "Spoiler",
|
||||||
|
"upload_retry": "Retry",
|
||||||
|
"upload_retry_label": "Retry Upload",
|
||||||
|
"upload_cancel_label": "Cancel Upload",
|
||||||
|
"upload_too_large": "File too large",
|
||||||
|
"format_bold": "Bold",
|
||||||
|
"format_italic": "Italic",
|
||||||
|
"format_underline": "Underline",
|
||||||
|
"format_strikethrough": "Strike Through",
|
||||||
|
"format_inline_code": "Inline Code",
|
||||||
|
"format_spoiler": "Spoiler",
|
||||||
|
"format_block_quote": "Block Quote",
|
||||||
|
"format_block_code": "Block Code",
|
||||||
|
"format_ordered_list": "Ordered List",
|
||||||
|
"format_unordered_list": "Unordered List",
|
||||||
|
"format_heading": "Heading {{level}}",
|
||||||
|
"format_exit_formatting": "Exit Formatting",
|
||||||
|
"format_exit": "Exit",
|
||||||
|
"format_markdown_disable": "Disable Markdown",
|
||||||
|
"format_markdown_enable": "Enable Markdown"
|
||||||
},
|
},
|
||||||
"Inbox": {
|
"Inbox": {
|
||||||
"invite_title": "Invite",
|
"invite_title": "Invite",
|
||||||
|
|
@ -890,38 +990,19 @@
|
||||||
"sort_z_to_a": "Z to A",
|
"sort_z_to_a": "Z to A",
|
||||||
"sort_newest": "Newest",
|
"sort_newest": "Newest",
|
||||||
"sort_oldest": "Oldest",
|
"sort_oldest": "Oldest",
|
||||||
"perm_messages": "Messages",
|
|
||||||
"perm_send_messages": "Send Messages",
|
|
||||||
"perm_send_stickers": "Send Stickers",
|
|
||||||
"perm_send_reactions": "Send Reactions",
|
|
||||||
"perm_ping_room": "Ping @room",
|
|
||||||
"perm_pin_messages": "Pin Messages",
|
|
||||||
"perm_other_message_events": "Other Message Events",
|
"perm_other_message_events": "Other Message Events",
|
||||||
"perm_calls": "Calls",
|
|
||||||
"perm_join_call": "Join Call",
|
|
||||||
"perm_moderation": "Moderation",
|
"perm_moderation": "Moderation",
|
||||||
"perm_invite": "Invite",
|
"perm_invite": "Invite",
|
||||||
"perm_kick": "Kick",
|
"perm_kick": "Kick",
|
||||||
"perm_ban": "Ban",
|
"perm_ban": "Ban",
|
||||||
"perm_delete_others_messages": "Delete Others' Messages",
|
|
||||||
"perm_delete_self_messages": "Delete Self Messages",
|
|
||||||
"perm_room_overview": "Room Overview",
|
|
||||||
"perm_room_avatar": "Room Avatar",
|
|
||||||
"perm_room_name": "Room Name",
|
|
||||||
"perm_room_topic": "Room Topic",
|
|
||||||
"perm_settings": "Settings",
|
"perm_settings": "Settings",
|
||||||
"perm_change_room_access": "Change Room Access",
|
|
||||||
"perm_publish_address": "Publish Address",
|
"perm_publish_address": "Publish Address",
|
||||||
"perm_change_all_permission": "Change All Permissions",
|
"perm_change_all_permission": "Change All Permissions",
|
||||||
"perm_edit_power_levels": "Edit Power Levels",
|
"perm_edit_power_levels": "Edit Power Levels",
|
||||||
"perm_enable_encryption": "Enable Encryption",
|
|
||||||
"perm_history_visibility": "History Visibility",
|
|
||||||
"perm_upgrade_room": "Upgrade Room",
|
|
||||||
"perm_other_settings": "Other Settings",
|
"perm_other_settings": "Other Settings",
|
||||||
"perm_other": "Other",
|
"perm_other": "Other",
|
||||||
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
|
"perm_manage_emojis_stickers": "Manage Emojis & Stickers",
|
||||||
"perm_change_server_acls": "Change Server ACLs",
|
"perm_change_server_acls": "Change Server ACLs",
|
||||||
"perm_modify_widgets": "Modify Widgets",
|
|
||||||
"founders": "Founders",
|
"founders": "Founders",
|
||||||
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
|
"founders_desc": "Founding members have all permissions and can only be changed during a room upgrade.",
|
||||||
"power_levels": "Power Levels",
|
"power_levels": "Power Levels",
|
||||||
|
|
@ -1113,6 +1194,8 @@
|
||||||
"blocked_title": "Blocked User",
|
"blocked_title": "Blocked User",
|
||||||
"blocked_description": "You do not receive any messages or invites from this user.",
|
"blocked_description": "You do not receive any messages or invites from this user.",
|
||||||
"profile_title": "Profile",
|
"profile_title": "Profile",
|
||||||
|
"back": "Back",
|
||||||
|
"manage_powers": "Manage roles",
|
||||||
"presence_online": "Online",
|
"presence_online": "Online",
|
||||||
"presence_unavailable": "Idle",
|
"presence_unavailable": "Idle",
|
||||||
"presence_offline": "Offline",
|
"presence_offline": "Offline",
|
||||||
|
|
@ -1123,7 +1206,7 @@
|
||||||
"last_seen_hours_other": "Last seen {{count}} hours ago",
|
"last_seen_hours_other": "Last seen {{count}} hours ago",
|
||||||
"last_seen_yesterday": "Last seen yesterday at {{time}}",
|
"last_seen_yesterday": "Last seen yesterday at {{time}}",
|
||||||
"last_seen_date": "Last seen {{date}}",
|
"last_seen_date": "Last seen {{date}}",
|
||||||
"row_id": "id",
|
"row_id": "nick",
|
||||||
"row_server": "server",
|
"row_server": "server",
|
||||||
"row_role": "role",
|
"row_role": "role",
|
||||||
"row_mutual": "shared",
|
"row_mutual": "shared",
|
||||||
|
|
@ -1137,7 +1220,22 @@
|
||||||
"copy_user_link": "Copy user link",
|
"copy_user_link": "Copy user link",
|
||||||
"copy_server": "Copy server",
|
"copy_server": "Copy server",
|
||||||
"explore_community": "Explore community",
|
"explore_community": "Explore community",
|
||||||
"open_in_browser": "Open in browser"
|
"open_in_browser": "Open in browser",
|
||||||
|
"moderation_title": "Moderation",
|
||||||
|
"moderation_reason": "Reason",
|
||||||
|
"moderation_reason_label": "Reason:",
|
||||||
|
"moderation_no_reason": "No reason provided.",
|
||||||
|
"moderation_kicked_user": "Kicked User",
|
||||||
|
"moderation_banned_user": "Banned User",
|
||||||
|
"moderation_invited_user": "Invited User",
|
||||||
|
"moderation_kicked_by": "Kicked by:",
|
||||||
|
"moderation_banned_by": "Banned by:",
|
||||||
|
"moderation_invited_by": "Invited by:",
|
||||||
|
"moderation_unban": "Unban",
|
||||||
|
"moderation_cancel_invite": "Cancel Invite",
|
||||||
|
"moderation_invite": "Invite",
|
||||||
|
"moderation_kick": "Kick",
|
||||||
|
"moderation_ban": "Ban"
|
||||||
},
|
},
|
||||||
"Share": {
|
"Share": {
|
||||||
"share_text": "Ready to share text",
|
"share_text": "Ready to share text",
|
||||||
|
|
@ -1148,5 +1246,16 @@
|
||||||
"share_files": "Ready to share {{count}} files",
|
"share_files": "Ready to share {{count}} files",
|
||||||
"tap_chat_to_send": "Open a chat to drop it in",
|
"tap_chat_to_send": "Open a chat to drop it in",
|
||||||
"cancel": "Cancel share"
|
"cancel": "Cancel share"
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"close": "Close",
|
||||||
|
"retry": "Retry"
|
||||||
|
},
|
||||||
|
"MediaViewer": {
|
||||||
|
"zoom_out": "Zoom out",
|
||||||
|
"zoom_in": "Zoom in",
|
||||||
|
"download": "Download",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -103,15 +103,24 @@
|
||||||
"reset_button": "Сбросить пароль",
|
"reset_button": "Сбросить пароль",
|
||||||
"reset_error_fallback": "Не удалось сбросить пароль.",
|
"reset_error_fallback": "Не удалось сбросить пароль.",
|
||||||
"reset_success_message": "Пароль успешно сброшен. Войдите с новым паролем.",
|
"reset_success_message": "Пароль успешно сброшен. Войдите с новым паролем.",
|
||||||
"reset_success_login": "Войти"
|
"reset_success_login": "Войти",
|
||||||
|
"uia_email_label": "Эл. почта",
|
||||||
|
"uia_email_send_button": "Отправить письмо для подтверждения",
|
||||||
|
"uia_email_cancel": "Отмена",
|
||||||
|
"uia_email_sending": "Отправка письма для подтверждения...",
|
||||||
|
"uia_email_verify_title": "Подтверждение почты",
|
||||||
|
"uia_email_send_error": "Не удалось отправить письмо для подтверждения.",
|
||||||
|
"uia_email_sent_title": "Письмо для подтверждения отправлено",
|
||||||
|
"uia_email_sent_message": "Проверьте почту \"{{email}}\" и подтвердите адрес, прежде чем продолжить.",
|
||||||
|
"uia_email_continue": "Продолжить",
|
||||||
|
"uia_email_provide_title": "Укажите эл. почту",
|
||||||
|
"uia_email_provide_message": "Укажите адрес эл. почты, чтобы отправить письмо для подтверждения."
|
||||||
},
|
},
|
||||||
"Settings": {
|
"Settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
"menu_general": "Общие",
|
"menu_general": "Общие",
|
||||||
"menu_account": "Аккаунт",
|
|
||||||
"menu_notifications": "Уведомления",
|
"menu_notifications": "Уведомления",
|
||||||
"menu_devices": "Устройства",
|
"menu_devices": "Устройства",
|
||||||
"menu_emojis_stickers": "Эмодзи и стикеры",
|
|
||||||
"menu_about": "О приложении",
|
"menu_about": "О приложении",
|
||||||
"network": {
|
"network": {
|
||||||
"menu": "Соединение",
|
"menu": "Соединение",
|
||||||
|
|
@ -224,10 +233,6 @@
|
||||||
"fsi_prompt_body": "Разрешите Vojo показывать полноэкранные уведомления — тогда входящие звонки будут будить экран и появляться поверх блокировки, как в WhatsApp или Telegram. Откройте «Уведомления поверх экрана блокировки» и включите переключатель для Vojo.",
|
"fsi_prompt_body": "Разрешите Vojo показывать полноэкранные уведомления — тогда входящие звонки будут будить экран и появляться поверх блокировки, как в WhatsApp или Telegram. Откройте «Уведомления поверх экрана блокировки» и включите переключатель для Vojo.",
|
||||||
"fsi_prompt_later": "Позже",
|
"fsi_prompt_later": "Позже",
|
||||||
"fsi_prompt_open": "Открыть настройки",
|
"fsi_prompt_open": "Открыть настройки",
|
||||||
"email_notification": "Уведомления по почте",
|
|
||||||
"email_no_email": "К вашему аккаунту не привязана электронная почта.",
|
|
||||||
"email_send_notif": "Отправлять уведомления на вашу почту.",
|
|
||||||
"email_send_notif_to": "Отправлять уведомления на вашу почту. (\"{{email}}\")",
|
|
||||||
"unexpected_error": "Непредвиденная ошибка!",
|
"unexpected_error": "Непредвиденная ошибка!",
|
||||||
"all_messages": "Все сообщения",
|
"all_messages": "Все сообщения",
|
||||||
"one_to_one": "Личные чаты",
|
"one_to_one": "Личные чаты",
|
||||||
|
|
@ -302,21 +307,11 @@
|
||||||
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
|
"import_desc": "Загрузите защищённую паролем копию ключей шифрования с устройства для расшифровки сообщений.",
|
||||||
"import": "Импорт",
|
"import": "Импорт",
|
||||||
"decrypt": "Расшифровать",
|
"decrypt": "Расшифровать",
|
||||||
"emojis_stickers_title": "Эмодзи и стикеры",
|
|
||||||
"default_pack": "Пакет по умолчанию",
|
|
||||||
"unknown": "Неизвестно",
|
"unknown": "Неизвестно",
|
||||||
"view": "Открыть",
|
"view": "Открыть",
|
||||||
"favorite_packs": "Избранные пакеты",
|
|
||||||
"select_pack": "Выбрать пакет",
|
|
||||||
"select_pack_desc": "Выберите пакеты эмодзи и стикеров из комнат для использования во всех комнатах.",
|
|
||||||
"select": "Выбрать",
|
"select": "Выбрать",
|
||||||
"room_packs": "Пакеты комнат",
|
|
||||||
"select_all": "Выбрать все",
|
|
||||||
"unselect_all": "Снять выделение",
|
|
||||||
"no_packs": "Нет пакетов",
|
"no_packs": "Нет пакетов",
|
||||||
"no_packs_desc": "Здесь появятся пакеты из комнат. У вас пока нет комнат с пакетами.",
|
"no_packs_desc": "Здесь появятся пакеты из комнат. У вас пока нет комнат с пакетами.",
|
||||||
"apply_error": "Не удалось применить изменения! Попробуйте снова.",
|
|
||||||
"apply_ready": "Изменения сохранены! Примените, когда будете готовы.",
|
|
||||||
"apply_changes": "Применить изменения",
|
"apply_changes": "Применить изменения",
|
||||||
"about_title": "О приложении",
|
"about_title": "О приложении",
|
||||||
"about_tagline": "Вседоступный мессенджер.",
|
"about_tagline": "Вседоступный мессенджер.",
|
||||||
|
|
@ -333,7 +328,58 @@
|
||||||
"notify_on_mention": "Упоминания",
|
"notify_on_mention": "Упоминания",
|
||||||
"notify_on_mention_desc": "Уведомлять, когда упоминают моё имя или ник.",
|
"notify_on_mention_desc": "Уведомлять, когда упоминают моё имя или ник.",
|
||||||
"room_announcements": "Объявления @room",
|
"room_announcements": "Объявления @room",
|
||||||
"room_announcements_desc": "Уведомлять о сообщениях с @room."
|
"room_announcements_desc": "Уведомлять о сообщениях с @room.",
|
||||||
|
"verification_close": "Закрыть",
|
||||||
|
"verification_accept_prompt": "Примите запрос на другом устройстве.",
|
||||||
|
"verification_waiting_accept": "Ожидание подтверждения запроса...",
|
||||||
|
"verification_click_accept": "Нажмите «Принять», чтобы начать верификацию.",
|
||||||
|
"verification_accept": "Принять",
|
||||||
|
"verification_request_accepted": "Запрос на верификацию принят.",
|
||||||
|
"verification_waiting_response": "Ожидание ответа от другого устройства...",
|
||||||
|
"verification_starting_emoji": "Начинаем верификацию сравнением эмодзи...",
|
||||||
|
"verification_compare_emoji_prompt": "Убедитесь, что на обоих устройствах показаны одни и те же эмодзи в одинаковом порядке:",
|
||||||
|
"verification_match": "Совпадают",
|
||||||
|
"verification_no_match": "Не совпадают",
|
||||||
|
"verification_device_verified": "Ваше устройство верифицировано.",
|
||||||
|
"verification_okay": "Понятно",
|
||||||
|
"verification_canceled": "Верификация отменена.",
|
||||||
|
"verification_unexpected_error": "Непредвиденная ошибка! Верификация началась, но обработчик проверки не найден.",
|
||||||
|
"verification_setup_desc": "Создайте ключ восстановления — он подтвердит вашу личность, если другие устройства недоступны. Дополнительно можно задать парольную фразу, которую проще запомнить.",
|
||||||
|
"verification_passphrase_optional": "Парольная фраза (необязательно)",
|
||||||
|
"verification_continue": "Продолжить",
|
||||||
|
"verification_error_generic": "Непредвиденная ошибка!",
|
||||||
|
"verification_uia_unsupported": "Клиент не поддерживает шаги аутентификации, необходимые для этого действия.",
|
||||||
|
"verification_recovery_key_store_desc": "Сохраните ключ восстановления в надёжном месте: он понадобится для подтверждения личности, если другие устройства будут недоступны.",
|
||||||
|
"verification_recovery_key": "Ключ восстановления",
|
||||||
|
"verification_show": "Показать",
|
||||||
|
"verification_hide": "Скрыть",
|
||||||
|
"verification_copy": "Скопировать",
|
||||||
|
"verification_download": "Скачать",
|
||||||
|
"verification_setup_title": "Настройка верификации устройства",
|
||||||
|
"verification_reset_title": "Сброс верификации устройства",
|
||||||
|
"verification_reset_permanent": "Сброс верификации устройства необратим.",
|
||||||
|
"verification_reset_warning": "Все, с кем вы проходили верификацию, увидят предупреждения безопасности, а резервная копия шифрования будет потеряна. Делайте это, только если вы потеряли ключ восстановления или парольную фразу и все устройства, с которых можно пройти верификацию.",
|
||||||
|
"verification_recovery_passphrase": "Парольная фраза восстановления",
|
||||||
|
"verification_select_method": "Выберите способ верификации.",
|
||||||
|
"verification_provide_key": "Введите ключ восстановления.",
|
||||||
|
"verification_verified_success": "Устройство верифицировано!",
|
||||||
|
"verification_step_open": "Откройте «{{settings}}».",
|
||||||
|
"verification_step_find": "Найдите это устройство в разделе «{{section}}».",
|
||||||
|
"backup_connected": "Подключено",
|
||||||
|
"backup_disconnected": "Отключено",
|
||||||
|
"backup_syncing": "Синхронизация ({{amount}})",
|
||||||
|
"backup_restoring_percent": "Восстановление: {{percent}}%",
|
||||||
|
"backup_trusted_decryption_key": "У резервной копии есть доверенный ключ расшифровки.",
|
||||||
|
"backup_untrusted_decryption_key": "У резервной копии нет доверенного ключа расшифровки!",
|
||||||
|
"backup_trusted_signature": "Резервная копия заверена доверенной подписью.",
|
||||||
|
"backup_untrusted_signature": "У резервной копии нет доверенной подписи!",
|
||||||
|
"backup_encryption_title": "Резервная копия шифрования",
|
||||||
|
"backup_details": "Сведения о резервной копии",
|
||||||
|
"backup_version": "Версия: {{version}}",
|
||||||
|
"backup_keys_count": "Ключей: {{amount}}",
|
||||||
|
"backup_none_value": "нет",
|
||||||
|
"backup_restore": "Восстановить резервную копию",
|
||||||
|
"backup_none_on_server": "На сервере нет резервной копии!"
|
||||||
},
|
},
|
||||||
"Search": {
|
"Search": {
|
||||||
"search": "Поиск",
|
"search": "Поиск",
|
||||||
|
|
@ -523,11 +569,28 @@
|
||||||
"bubble_missed_count_one": "{{count}} пропущенный звонок",
|
"bubble_missed_count_one": "{{count}} пропущенный звонок",
|
||||||
"bubble_missed_count_few": "{{count}} пропущенных звонка",
|
"bubble_missed_count_few": "{{count}} пропущенных звонка",
|
||||||
"bubble_missed_count_many": "{{count}} пропущенных звонков",
|
"bubble_missed_count_many": "{{count}} пропущенных звонков",
|
||||||
|
"bubble_missed_count_other": "{{count}} пропущенных звонков",
|
||||||
"bubble_cancelled_count_one": "{{count}} отменённый звонок",
|
"bubble_cancelled_count_one": "{{count}} отменённый звонок",
|
||||||
"bubble_cancelled_count_few": "{{count}} отменённых звонка",
|
"bubble_cancelled_count_few": "{{count}} отменённых звонка",
|
||||||
"bubble_cancelled_count_many": "{{count}} отменённых звонков",
|
"bubble_cancelled_count_many": "{{count}} отменённых звонков",
|
||||||
|
"bubble_cancelled_count_other": "{{count}} отменённых звонков",
|
||||||
"duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
|
"duration_minutes_seconds": "{{minutes}} мин {{seconds}} сек",
|
||||||
"duration_seconds": "{{seconds}} сек"
|
"duration_seconds": "{{seconds}} сек",
|
||||||
|
"view_grid": "Вид галереи",
|
||||||
|
"view_spotlight": "Вид докладчика",
|
||||||
|
"reactions": "Реакции",
|
||||||
|
"settings": "Настройки",
|
||||||
|
"homeserver_no_calls": "Ваш сервер не поддерживает звонки, но вы можете присоединиться к звонку, который начал кто-то другой.",
|
||||||
|
"empty_be_first": "В голосовом чате пусто — присоединяйтесь первым!",
|
||||||
|
"no_permission_to_join": "У вас нет прав, чтобы присоединиться!",
|
||||||
|
"already_in_other_call": "Вы уже в другом звонке — завершите его, чтобы присоединиться!",
|
||||||
|
"participant": "Участники",
|
||||||
|
"live_count": "{{count}} в эфире",
|
||||||
|
"collapse": "Свернуть",
|
||||||
|
"others_count_one": "Ещё {{count}} участник",
|
||||||
|
"others_count_few": "Ещё {{count}} участника",
|
||||||
|
"others_count_many": "Ещё {{count}} участников",
|
||||||
|
"others_count_other": "Ещё {{count}} участников"
|
||||||
},
|
},
|
||||||
"Room": {
|
"Room": {
|
||||||
"delivery": {
|
"delivery": {
|
||||||
|
|
@ -540,7 +603,6 @@
|
||||||
"collapse_avatar": "Свернуть аватар",
|
"collapse_avatar": "Свернуть аватар",
|
||||||
"expand_avatar": "Развернуть аватар",
|
"expand_avatar": "Развернуть аватар",
|
||||||
"new_messages": "Новые сообщения",
|
"new_messages": "Новые сообщения",
|
||||||
"jump_to_unread": "К непрочитанным",
|
|
||||||
"mark_as_read": "Отметить прочитанным",
|
"mark_as_read": "Отметить прочитанным",
|
||||||
"jump_to_latest": "К последним",
|
"jump_to_latest": "К последним",
|
||||||
"message_render_error": "Не удалось показать это сообщение.",
|
"message_render_error": "Не удалось показать это сообщение.",
|
||||||
|
|
@ -548,6 +610,15 @@
|
||||||
"yesterday": "Вчера",
|
"yesterday": "Вчера",
|
||||||
"view_reactions": "Реакции",
|
"view_reactions": "Реакции",
|
||||||
"read_receipts": "Подтверждения прочтения",
|
"read_receipts": "Подтверждения прочтения",
|
||||||
|
"seen_by": "Прочитали",
|
||||||
|
"seen_by_count_one": "{{count}} человек",
|
||||||
|
"seen_by_count_few": "{{count}} человека",
|
||||||
|
"seen_by_count_many": "{{count}} человек",
|
||||||
|
"seen_by_count_other": "{{count}} человек",
|
||||||
|
"seen_by_empty": "Это сообщение пока никто не прочитал.",
|
||||||
|
"editing_message": "Редактирование",
|
||||||
|
"editing_cancel": "Отменить редактирование",
|
||||||
|
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
||||||
"view_source": "Исходный код",
|
"view_source": "Исходный код",
|
||||||
"source_code": "Исходный код",
|
"source_code": "Исходный код",
|
||||||
"copy_link": "Копировать ссылку",
|
"copy_link": "Копировать ссылку",
|
||||||
|
|
@ -668,7 +739,6 @@
|
||||||
"thread_summary_highlight_few": "{{count}} упоминания",
|
"thread_summary_highlight_few": "{{count}} упоминания",
|
||||||
"thread_summary_highlight_many": "{{count}} упоминаний",
|
"thread_summary_highlight_many": "{{count}} упоминаний",
|
||||||
"thread_summary_highlight_other": "{{count}} упоминания",
|
"thread_summary_highlight_other": "{{count}} упоминания",
|
||||||
"no_post_permission": "У вас нет разрешения на отправку сообщений в этой комнате",
|
|
||||||
"empty_dm": "Самое сложное — первое сообщение.",
|
"empty_dm": "Самое сложное — первое сообщение.",
|
||||||
"empty_dm_alt_1": "С чего-то надо начать.",
|
"empty_dm_alt_1": "С чего-то надо начать.",
|
||||||
"empty_dm_alt_2": "Кто-то должен написать первым.",
|
"empty_dm_alt_2": "Кто-то должен написать первым.",
|
||||||
|
|
@ -709,7 +779,45 @@
|
||||||
"autocomplete_rooms": "Комнаты",
|
"autocomplete_rooms": "Комнаты",
|
||||||
"autocomplete_emojis": "Эмодзи",
|
"autocomplete_emojis": "Эмодзи",
|
||||||
"autocomplete_commands": "Команды",
|
"autocomplete_commands": "Команды",
|
||||||
"autocomplete_unknown_room": "Неизвестная комната"
|
"autocomplete_unknown_room": "Неизвестная комната",
|
||||||
|
"lobby_join": "Присоединиться",
|
||||||
|
"lobby_unknown": "Неизвестно",
|
||||||
|
"lobby_suggested": "Рекомендуется",
|
||||||
|
"lobby_inaccessible": "Недоступно",
|
||||||
|
"lobby_open_room": "Открыть комнату",
|
||||||
|
"lobby_rooms": "Комнаты",
|
||||||
|
"lobby_scroll_to_top": "Наверх",
|
||||||
|
"lobby_reordering": "Перемещение",
|
||||||
|
"lobby_space_settings": "Настройки сообщества",
|
||||||
|
"lobby_leave_space": "Покинуть сообщество",
|
||||||
|
"lobby_members_count_one": "{{formattedCount}} участник",
|
||||||
|
"lobby_members_count_few": "{{formattedCount}} участника",
|
||||||
|
"lobby_members_count_many": "{{formattedCount}} участников",
|
||||||
|
"lobby_members_count_other": "{{formattedCount}} участников",
|
||||||
|
"msg_search_scroll_to_top": "Наверх",
|
||||||
|
"leave_space_title": "Покинуть сообщество",
|
||||||
|
"leave_space_confirm": "Покинуть это сообщество?",
|
||||||
|
"leave_space_error": "Не удалось покинуть сообщество! {{error}}",
|
||||||
|
"upload_spoiler": "Спойлер",
|
||||||
|
"upload_retry": "Повторить",
|
||||||
|
"upload_retry_label": "Повторить загрузку",
|
||||||
|
"upload_cancel_label": "Отменить загрузку",
|
||||||
|
"upload_too_large": "Файл слишком большой",
|
||||||
|
"format_bold": "Жирный",
|
||||||
|
"format_italic": "Курсив",
|
||||||
|
"format_underline": "Подчёркнутый",
|
||||||
|
"format_strikethrough": "Зачёркнутый",
|
||||||
|
"format_inline_code": "Код",
|
||||||
|
"format_spoiler": "Спойлер",
|
||||||
|
"format_block_quote": "Цитата",
|
||||||
|
"format_block_code": "Блок кода",
|
||||||
|
"format_ordered_list": "Нумерованный список",
|
||||||
|
"format_unordered_list": "Маркированный список",
|
||||||
|
"format_heading": "Заголовок {{level}}",
|
||||||
|
"format_exit_formatting": "Выйти из форматирования",
|
||||||
|
"format_exit": "Выйти",
|
||||||
|
"format_markdown_disable": "Отключить Markdown",
|
||||||
|
"format_markdown_enable": "Включить Markdown"
|
||||||
},
|
},
|
||||||
"Inbox": {
|
"Inbox": {
|
||||||
"invite_title": "Пригласить",
|
"invite_title": "Пригласить",
|
||||||
|
|
@ -908,38 +1016,19 @@
|
||||||
"sort_z_to_a": "Я — А",
|
"sort_z_to_a": "Я — А",
|
||||||
"sort_newest": "Новые",
|
"sort_newest": "Новые",
|
||||||
"sort_oldest": "Старые",
|
"sort_oldest": "Старые",
|
||||||
"perm_messages": "Сообщения",
|
|
||||||
"perm_send_messages": "Отправка сообщений",
|
|
||||||
"perm_send_stickers": "Отправка стикеров",
|
|
||||||
"perm_send_reactions": "Отправка реакций",
|
|
||||||
"perm_ping_room": "Упоминание @room",
|
|
||||||
"perm_pin_messages": "Закрепление сообщений",
|
|
||||||
"perm_other_message_events": "Прочие события сообщений",
|
"perm_other_message_events": "Прочие события сообщений",
|
||||||
"perm_calls": "Звонки",
|
|
||||||
"perm_join_call": "Присоединиться к звонку",
|
|
||||||
"perm_moderation": "Модерация",
|
"perm_moderation": "Модерация",
|
||||||
"perm_invite": "Приглашение",
|
"perm_invite": "Приглашение",
|
||||||
"perm_kick": "Исключение",
|
"perm_kick": "Исключение",
|
||||||
"perm_ban": "Бан",
|
"perm_ban": "Бан",
|
||||||
"perm_delete_others_messages": "Удаление чужих сообщений",
|
|
||||||
"perm_delete_self_messages": "Удаление своих сообщений",
|
|
||||||
"perm_room_overview": "Обзор комнаты",
|
|
||||||
"perm_room_avatar": "Аватар комнаты",
|
|
||||||
"perm_room_name": "Название комнаты",
|
|
||||||
"perm_room_topic": "Тема комнаты",
|
|
||||||
"perm_settings": "Настройки",
|
"perm_settings": "Настройки",
|
||||||
"perm_change_room_access": "Изменение доступа к комнате",
|
|
||||||
"perm_publish_address": "Публикация адреса",
|
"perm_publish_address": "Публикация адреса",
|
||||||
"perm_change_all_permission": "Изменение всех прав",
|
"perm_change_all_permission": "Изменение всех прав",
|
||||||
"perm_edit_power_levels": "Редактирование уровней власти",
|
"perm_edit_power_levels": "Редактирование уровней власти",
|
||||||
"perm_enable_encryption": "Включение шифрования",
|
|
||||||
"perm_history_visibility": "Видимость истории",
|
|
||||||
"perm_upgrade_room": "Обновление комнаты",
|
|
||||||
"perm_other_settings": "Прочие настройки",
|
"perm_other_settings": "Прочие настройки",
|
||||||
"perm_other": "Прочее",
|
"perm_other": "Прочее",
|
||||||
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
|
"perm_manage_emojis_stickers": "Управление эмодзи и стикерами",
|
||||||
"perm_change_server_acls": "Изменение ACL серверов",
|
"perm_change_server_acls": "Изменение ACL серверов",
|
||||||
"perm_modify_widgets": "Изменение виджетов",
|
|
||||||
"founders": "Основатели",
|
"founders": "Основатели",
|
||||||
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
|
"founders_desc": "Основатели имеют все права. Изменить их состав можно только при обновлении комнаты.",
|
||||||
"power_levels": "Уровни власти",
|
"power_levels": "Уровни власти",
|
||||||
|
|
@ -1131,6 +1220,8 @@
|
||||||
"blocked_title": "Заблокирован",
|
"blocked_title": "Заблокирован",
|
||||||
"blocked_description": "Сообщения и приглашения от этого пользователя не приходят.",
|
"blocked_description": "Сообщения и приглашения от этого пользователя не приходят.",
|
||||||
"profile_title": "Профиль",
|
"profile_title": "Профиль",
|
||||||
|
"back": "Назад",
|
||||||
|
"manage_powers": "Управление ролями",
|
||||||
"presence_online": "В сети",
|
"presence_online": "В сети",
|
||||||
"presence_unavailable": "Не активен",
|
"presence_unavailable": "Не активен",
|
||||||
"presence_offline": "Не в сети",
|
"presence_offline": "Не в сети",
|
||||||
|
|
@ -1138,12 +1229,14 @@
|
||||||
"last_seen_minutes_one": "Был в сети {{count}} минуту назад",
|
"last_seen_minutes_one": "Был в сети {{count}} минуту назад",
|
||||||
"last_seen_minutes_few": "Был в сети {{count}} минуты назад",
|
"last_seen_minutes_few": "Был в сети {{count}} минуты назад",
|
||||||
"last_seen_minutes_many": "Был в сети {{count}} минут назад",
|
"last_seen_minutes_many": "Был в сети {{count}} минут назад",
|
||||||
|
"last_seen_minutes_other": "Был в сети {{count}} минут назад",
|
||||||
"last_seen_hours_one": "Был в сети {{count}} час назад",
|
"last_seen_hours_one": "Был в сети {{count}} час назад",
|
||||||
"last_seen_hours_few": "Был в сети {{count}} часа назад",
|
"last_seen_hours_few": "Был в сети {{count}} часа назад",
|
||||||
"last_seen_hours_many": "Был в сети {{count}} часов назад",
|
"last_seen_hours_many": "Был в сети {{count}} часов назад",
|
||||||
|
"last_seen_hours_other": "Был в сети {{count}} часов назад",
|
||||||
"last_seen_yesterday": "Был в сети вчера в {{time}}",
|
"last_seen_yesterday": "Был в сети вчера в {{time}}",
|
||||||
"last_seen_date": "Был в сети {{date}}",
|
"last_seen_date": "Был в сети {{date}}",
|
||||||
"row_id": "id",
|
"row_id": "ник",
|
||||||
"row_server": "сервер",
|
"row_server": "сервер",
|
||||||
"row_role": "роль",
|
"row_role": "роль",
|
||||||
"row_mutual": "общие",
|
"row_mutual": "общие",
|
||||||
|
|
@ -1154,11 +1247,27 @@
|
||||||
"row_mutual_count_one": "{{count}} чат",
|
"row_mutual_count_one": "{{count}} чат",
|
||||||
"row_mutual_count_few": "{{count}} чата",
|
"row_mutual_count_few": "{{count}} чата",
|
||||||
"row_mutual_count_many": "{{count}} чатов",
|
"row_mutual_count_many": "{{count}} чатов",
|
||||||
|
"row_mutual_count_other": "{{count}} чатов",
|
||||||
"copy_user_id": "Скопировать ID",
|
"copy_user_id": "Скопировать ID",
|
||||||
"copy_user_link": "Скопировать ссылку",
|
"copy_user_link": "Скопировать ссылку",
|
||||||
"copy_server": "Скопировать сервер",
|
"copy_server": "Скопировать сервер",
|
||||||
"explore_community": "Открыть сервер",
|
"explore_community": "Открыть сервер",
|
||||||
"open_in_browser": "Открыть в браузере"
|
"open_in_browser": "Открыть в браузере",
|
||||||
|
"moderation_title": "Модерация",
|
||||||
|
"moderation_reason": "Причина",
|
||||||
|
"moderation_reason_label": "Причина:",
|
||||||
|
"moderation_no_reason": "Причина не указана.",
|
||||||
|
"moderation_kicked_user": "Пользователь исключён",
|
||||||
|
"moderation_banned_user": "Пользователь забанен",
|
||||||
|
"moderation_invited_user": "Пользователь приглашён",
|
||||||
|
"moderation_kicked_by": "Исключил(а):",
|
||||||
|
"moderation_banned_by": "Забанил(а):",
|
||||||
|
"moderation_invited_by": "Пригласил(а):",
|
||||||
|
"moderation_unban": "Разбанить",
|
||||||
|
"moderation_cancel_invite": "Отменить приглашение",
|
||||||
|
"moderation_invite": "Пригласить",
|
||||||
|
"moderation_kick": "Исключить",
|
||||||
|
"moderation_ban": "Забанить"
|
||||||
},
|
},
|
||||||
"Share": {
|
"Share": {
|
||||||
"share_text": "Готово к пересылке: текст",
|
"share_text": "Готово к пересылке: текст",
|
||||||
|
|
@ -1169,5 +1278,16 @@
|
||||||
"share_files": "Готово к пересылке: {{count}} файлов",
|
"share_files": "Готово к пересылке: {{count}} файлов",
|
||||||
"tap_chat_to_send": "Откройте чат, чтобы отправить",
|
"tap_chat_to_send": "Откройте чат, чтобы отправить",
|
||||||
"cancel": "Отменить"
|
"cancel": "Отменить"
|
||||||
|
},
|
||||||
|
"Common": {
|
||||||
|
"close": "Закрыть",
|
||||||
|
"retry": "Повторить"
|
||||||
|
},
|
||||||
|
"MediaViewer": {
|
||||||
|
"zoom_out": "Уменьшить",
|
||||||
|
"zoom_in": "Увеличить",
|
||||||
|
"download": "Скачать",
|
||||||
|
"previous": "Назад",
|
||||||
|
"next": "Вперёд"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
|
import { BackupProgressStatus, backupRestoreProgressAtom } from '../state/backupRestore';
|
||||||
import { InfoCard } from './info-card';
|
import { InfoCard } from './info-card';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
|
|
@ -35,6 +36,7 @@ type BackupStatusProps = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
function BackupStatus({ enabled }: BackupStatusProps) {
|
function BackupStatus({ enabled }: BackupStatusProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box as="span" gap="100" alignItems="Center">
|
<Box as="span" gap="100" alignItems="Center">
|
||||||
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
|
<Badge variant={enabled ? 'Success' : 'Critical'} fill="Solid" size="200" radii="Pill" />
|
||||||
|
|
@ -43,7 +45,7 @@ function BackupStatus({ enabled }: BackupStatusProps) {
|
||||||
size="L400"
|
size="L400"
|
||||||
style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
|
style={{ color: enabled ? color.Success.Main : color.Critical.Main }}
|
||||||
>
|
>
|
||||||
{enabled ? 'Connected' : 'Disconnected'}
|
{enabled ? t('Settings.backup_connected') : t('Settings.backup_disconnected')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -52,21 +54,23 @@ type BackupSyncingProps = {
|
||||||
count: number;
|
count: number;
|
||||||
};
|
};
|
||||||
function BackupSyncing({ count }: BackupSyncingProps) {
|
function BackupSyncing({ count }: BackupSyncingProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box as="span" gap="100" alignItems="Center">
|
<Box as="span" gap="100" alignItems="Center">
|
||||||
<Spinner size="50" variant="Primary" fill="Soft" />
|
<Spinner size="50" variant="Primary" fill="Soft" />
|
||||||
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
|
<Text as="span" size="L400" style={{ color: color.Primary.Main }}>
|
||||||
Syncing ({count})
|
{t('Settings.backup_syncing', { amount: count })}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BackupProgressFetching() {
|
function BackupProgressFetching() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" gap="200" alignItems="Center">
|
<Box grow="Yes" gap="200" alignItems="Center">
|
||||||
<Badge variant="Secondary" fill="Solid" radii="300">
|
<Badge variant="Secondary" fill="Solid" radii="300">
|
||||||
<Text size="L400">Restoring: 0%</Text>
|
<Text size="L400">{t('Settings.backup_restoring_percent', { percent: 0 })}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
|
<ProgressBar variant="Secondary" size="300" min={0} max={1} value={0} />
|
||||||
|
|
@ -81,10 +85,15 @@ type BackupProgressProps = {
|
||||||
downloaded: number;
|
downloaded: number;
|
||||||
};
|
};
|
||||||
function BackupProgress({ total, downloaded }: BackupProgressProps) {
|
function BackupProgress({ total, downloaded }: BackupProgressProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" gap="200" alignItems="Center">
|
<Box grow="Yes" gap="200" alignItems="Center">
|
||||||
<Badge variant="Secondary" fill="Solid" radii="300">
|
<Badge variant="Secondary" fill="Solid" radii="300">
|
||||||
<Text size="L400">Restoring: {`${Math.round(percent(0, total, downloaded))}%`}</Text>
|
<Text size="L400">
|
||||||
|
{t('Settings.backup_restoring_percent', {
|
||||||
|
percent: Math.round(percent(0, total, downloaded)),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
|
<ProgressBar variant="Secondary" size="300" min={0} max={total} value={downloaded} />
|
||||||
|
|
@ -103,6 +112,7 @@ type BackupTrustInfoProps = {
|
||||||
backupInfo: KeyBackupInfo;
|
backupInfo: KeyBackupInfo;
|
||||||
};
|
};
|
||||||
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
|
function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const trust = useKeyBackupTrust(crypto, backupInfo);
|
const trust = useKeyBackupTrust(crypto, backupInfo);
|
||||||
|
|
||||||
if (!trust) return null;
|
if (!trust) return null;
|
||||||
|
|
@ -111,20 +121,20 @@ function BackupTrustInfo({ crypto, backupInfo }: BackupTrustInfoProps) {
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
{trust.matchesDecryptionKey ? (
|
{trust.matchesDecryptionKey ? (
|
||||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||||
<b>Backup has trusted decryption key.</b>
|
<b>{t('Settings.backup_trusted_decryption_key')}</b>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
<b>Backup does not have trusted decryption key!</b>
|
<b>{t('Settings.backup_untrusted_decryption_key')}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{trust.trusted ? (
|
{trust.trusted ? (
|
||||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||||
<b>Backup has trusted by signature.</b>
|
<b>{t('Settings.backup_trusted_signature')}</b>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
<b>Backup does not have trusted signature!</b>
|
<b>{t('Settings.backup_untrusted_signature')}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -135,6 +145,7 @@ type BackupRestoreTileProps = {
|
||||||
crypto: CryptoApi;
|
crypto: CryptoApi;
|
||||||
};
|
};
|
||||||
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
|
const [restoreProgress, setRestoreProgress] = useAtom(backupRestoreProgressAtom);
|
||||||
const restoring =
|
const restoring =
|
||||||
restoreProgress.status === BackupProgressStatus.Fetching ||
|
restoreProgress.status === BackupProgressStatus.Fetching ||
|
||||||
|
|
@ -168,7 +179,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
return (
|
return (
|
||||||
<InfoCard
|
<InfoCard
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
title="Encryption Backup"
|
title={t('Settings.backup_encryption_title')}
|
||||||
after={
|
after={
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
{remainingSession === 0 ? (
|
{remainingSession === 0 ? (
|
||||||
|
|
@ -212,12 +223,20 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<InfoCard
|
<InfoCard
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
title="Backup Details"
|
title={t('Settings.backup_details')}
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
<span>Version: {backupInfo?.version ?? 'NIL'}</span>
|
<span>
|
||||||
|
{t('Settings.backup_version', {
|
||||||
|
version: backupInfo?.version ?? t('Settings.backup_none_value'),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
<br />
|
<br />
|
||||||
<span>Keys: {backupInfo?.count ?? 'NIL'}</span>
|
<span>
|
||||||
|
{t('Settings.backup_keys_count', {
|
||||||
|
amount: backupInfo?.count ?? t('Settings.backup_none_value'),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -234,7 +253,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
}
|
}
|
||||||
before={<Icon size="100" src={Icons.Download} />}
|
before={<Icon size="100" src={Icons.Download} />}
|
||||||
>
|
>
|
||||||
<Text size="B300">Restore Backup</Text>
|
<Text size="B300">{t('Settings.backup_restore')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
@ -251,7 +270,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) {
|
||||||
)}
|
)}
|
||||||
{!backupEnabled && backupInfo === null && (
|
{!backupEnabled && backupInfo === null && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
<b>No backup present on server!</b>
|
<b>{t('Settings.backup_none_on_server')}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{!syncFailure && !backupEnabled && backupInfo && (
|
{!syncFailure && !backupEnabled && backupInfo && (
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
Text,
|
Text,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
useVerificationRequestPhase,
|
useVerificationRequestPhase,
|
||||||
useVerificationRequestReceived,
|
useVerificationRequestReceived,
|
||||||
|
|
@ -50,21 +51,23 @@ function WaitingMessage({ message }: WaitingMessageProps) {
|
||||||
|
|
||||||
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
type VerificationUnexpectedProps = { message: string; onClose: () => void };
|
||||||
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>{message}</Text>
|
<Text>{message}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">Close</Text>
|
<Text size="B400">{t('Settings.verification_close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitAccept() {
|
function VerificationWaitAccept() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Please accept the request from other device.</Text>
|
<Text>{t('Settings.verification_accept_prompt')}</Text>
|
||||||
<WaitingMessage message="Waiting for request to be accepted..." />
|
<WaitingMessage message={t('Settings.verification_waiting_accept')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -73,12 +76,13 @@ type VerificationAcceptProps = {
|
||||||
onAccept: () => Promise<void>;
|
onAccept: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [acceptState, accept] = useAsyncCallback(onAccept);
|
const [acceptState, accept] = useAsyncCallback(onAccept);
|
||||||
|
|
||||||
const accepting = acceptState.status === AsyncStatus.Loading;
|
const accepting = acceptState.status === AsyncStatus.Loading;
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Click accept to start the verification process.</Text>
|
<Text>{t('Settings.verification_click_accept')}</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
|
|
@ -86,17 +90,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
|
||||||
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
|
||||||
disabled={accepting}
|
disabled={accepting}
|
||||||
>
|
>
|
||||||
<Text size="B400">Accept</Text>
|
<Text size="B400">{t('Settings.verification_accept')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function VerificationWaitStart() {
|
function VerificationWaitStart() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification request has been accepted.</Text>
|
<Text>{t('Settings.verification_request_accepted')}</Text>
|
||||||
<WaitingMessage message="Waiting for the response from other device..." />
|
<WaitingMessage message={t('Settings.verification_waiting_response')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -105,18 +110,20 @@ type VerificationStartProps = {
|
||||||
onStart: () => Promise<void>;
|
onStart: () => Promise<void>;
|
||||||
};
|
};
|
||||||
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
function AutoVerificationStart({ onStart }: VerificationStartProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onStart();
|
onStart();
|
||||||
}, [onStart]);
|
}, [onStart]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Settings.verification_starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
|
||||||
|
|
||||||
const confirming =
|
const confirming =
|
||||||
|
|
@ -124,7 +131,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
|
<Text>{t('Settings.verification_compare_emoji_prompt')}</Text>
|
||||||
<Box
|
<Box
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -157,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
before={confirming && <Spinner size="100" variant="Primary" />}
|
before={confirming && <Spinner size="100" variant="Primary" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">They Match</Text>
|
<Text size="B400">{t('Settings.verification_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
|
|
@ -165,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
|
||||||
onClick={() => sasData.mismatch()}
|
onClick={() => sasData.mismatch()}
|
||||||
disabled={confirming}
|
disabled={confirming}
|
||||||
>
|
>
|
||||||
<Text size="B400">Do not Match</Text>
|
<Text size="B400">{t('Settings.verification_no_match')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -177,6 +184,7 @@ type SasVerificationProps = {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
const [sasData, setSasData] = useState<ShowSasCallbacks>();
|
||||||
|
|
||||||
useVerifierShowSas(verifier, setSasData);
|
useVerifierShowSas(verifier, setSasData);
|
||||||
|
|
@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<WaitingMessage message="Starting verification using emoji comparison..." />
|
<WaitingMessage message={t('Settings.verification_starting_emoji')} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -201,13 +209,14 @@ type VerificationDoneProps = {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
};
|
};
|
||||||
function VerificationDone({ onExit }: VerificationDoneProps) {
|
function VerificationDone({ onExit }: VerificationDoneProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<div>
|
<div>
|
||||||
<Text>Your device is verified.</Text>
|
<Text>{t('Settings.verification_device_verified')}</Text>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
<Button variant="Primary" fill="Solid" onClick={onExit}>
|
||||||
<Text size="B400">Okay</Text>
|
<Text size="B400">{t('Settings.verification_okay')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -217,11 +226,12 @@ type VerificationCanceledProps = {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
};
|
};
|
||||||
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text>Verification has been canceled.</Text>
|
<Text>{t('Settings.verification_canceled')}</Text>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
<Button variant="Secondary" fill="Soft" onClick={onClose}>
|
||||||
<Text size="B400">Close</Text>
|
<Text size="B400">{t('Settings.verification_close')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -232,6 +242,7 @@ type DeviceVerificationProps = {
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
};
|
};
|
||||||
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
|
export function DeviceVerification({ request, onExit }: DeviceVerificationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const phase = useVerificationRequestPhase(request);
|
const phase = useVerificationRequestPhase(request);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
|
|
@ -259,7 +270,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
||||||
<Dialog variant="Surface">
|
<Dialog variant="Surface">
|
||||||
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
<Header style={DialogHeaderStyles} variant="Surface" size="500">
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Device Verification</Text>
|
<Text size="H4">{t('Settings.device_verification')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={handleCancel}>
|
<IconButton size="300" radii="300" onClick={handleCancel}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
|
|
@ -283,7 +294,7 @@ export function DeviceVerification({ request, onExit }: DeviceVerificationProps)
|
||||||
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
|
<SasVerification verifier={request.verifier} onCancel={handleCancel} />
|
||||||
) : (
|
) : (
|
||||||
<VerificationUnexpected
|
<VerificationUnexpected
|
||||||
message="Unexpected Error! Verification is started but verifier is missing."
|
message={t('Settings.verification_unexpected_error')}
|
||||||
onClose={handleCancel}
|
onClose={handleCancel}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import {
|
||||||
import FileSaver from 'file-saver';
|
import FileSaver from 'file-saver';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import { ContainerColor } from '../styles/ContainerColor.css';
|
import { ContainerColor } from '../styles/ContainerColor.css';
|
||||||
import { copyToClipboard } from '../utils/dom';
|
import { copyToClipboard } from '../utils/dom';
|
||||||
|
|
@ -71,6 +72,7 @@ type SetupVerificationProps = {
|
||||||
onComplete: (recoveryKey: string) => void;
|
onComplete: (recoveryKey: string) => void;
|
||||||
};
|
};
|
||||||
function SetupVerification({ onComplete }: SetupVerificationProps) {
|
function SetupVerification({ onComplete }: SetupVerificationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
|
||||||
|
|
@ -181,12 +183,9 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
|
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="400">
|
||||||
<Text size="T300">
|
<Text size="T300">{t('Settings.verification_setup_desc')}</Text>
|
||||||
Generate a <b>Recovery Key</b> for verifying identity if you do not have access to other
|
|
||||||
devices. Additionally, setup a passphrase as a memorable alternative.
|
|
||||||
</Text>
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Passphrase (Optional)</Text>
|
<Text size="L400">{t('Settings.verification_passphrase_optional')}</Text>
|
||||||
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
|
<PasswordInput name="passphraseInput" size="400" readOnly={loading} />
|
||||||
</Box>
|
</Box>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -194,21 +193,19 @@ function SetupVerification({ onComplete }: SetupVerificationProps) {
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
|
before={loading && <Spinner size="200" variant="Primary" fill="Solid" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">Continue</Text>
|
<Text size="B400">{t('Settings.verification_continue')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
{setupState.status === AsyncStatus.Error && (
|
{setupState.status === AsyncStatus.Error && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
<b>{setupState.error ? setupState.error.message : 'Unexpected Error!'}</b>
|
<b>
|
||||||
|
{setupState.error ? setupState.error.message : t('Settings.verification_error_generic')}
|
||||||
|
</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{nextAuthData !== null && uiaAction && (
|
{nextAuthData !== null && uiaAction && (
|
||||||
<ActionUIAFlowsLoader
|
<ActionUIAFlowsLoader
|
||||||
authData={nextAuthData ?? uiaAction.authData}
|
authData={nextAuthData ?? uiaAction.authData}
|
||||||
unsupported={() => (
|
unsupported={() => <Text size="T200">{t('Settings.verification_uia_unsupported')}</Text>}
|
||||||
<Text size="T200">
|
|
||||||
Authentication steps to perform this action are not supported by client.
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{(ongoingFlow) => (
|
{(ongoingFlow) => (
|
||||||
<ActionUIA
|
<ActionUIA
|
||||||
|
|
@ -228,6 +225,7 @@ type RecoveryKeyDisplayProps = {
|
||||||
recoveryKey: string;
|
recoveryKey: string;
|
||||||
};
|
};
|
||||||
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
|
|
@ -245,12 +243,9 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Text size="T300">
|
<Text size="T300">{t('Settings.verification_recovery_key_store_desc')}</Text>
|
||||||
Store the Recovery Key in a safe place for future use, as you will need it to verify your
|
|
||||||
identity if you do not have access to other devices.
|
|
||||||
</Text>
|
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Recovery Key</Text>
|
<Text size="L400">{t('Settings.verification_recovery_key')}</Text>
|
||||||
<Box
|
<Box
|
||||||
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
className={ContainerColor({ variant: 'SurfaceVariant' })}
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -265,16 +260,18 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
|
||||||
{safeToDisplayKey}
|
{safeToDisplayKey}
|
||||||
</Text>
|
</Text>
|
||||||
<Chip onClick={() => setShow(!show)} variant="Secondary" radii="Pill">
|
<Chip onClick={() => setShow(!show)} variant="Secondary" radii="Pill">
|
||||||
<Text size="B300">{show ? 'Hide' : 'Show'}</Text>
|
<Text size="B300">
|
||||||
|
{show ? t('Settings.verification_hide') : t('Settings.verification_show')}
|
||||||
|
</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button onClick={handleCopy}>
|
<Button onClick={handleCopy}>
|
||||||
<Text size="B400">Copy</Text>
|
<Text size="B400">{t('Settings.verification_copy')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDownload} fill="Soft">
|
<Button onClick={handleDownload} fill="Soft">
|
||||||
<Text size="B400">Download</Text>
|
<Text size="B400">{t('Settings.verification_download')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -286,6 +283,7 @@ type DeviceVerificationSetupProps = {
|
||||||
};
|
};
|
||||||
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
|
export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerificationSetupProps>(
|
||||||
({ onCancel }, ref) => {
|
({ onCancel }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [recoveryKey, setRecoveryKey] = useState<string>();
|
const [recoveryKey, setRecoveryKey] = useState<string>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -299,7 +297,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||||
size="500"
|
size="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Setup Device Verification</Text>
|
<Text size="H4">{t('Settings.verification_setup_title')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel}>
|
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
|
|
@ -321,6 +319,7 @@ type DeviceVerificationResetProps = {
|
||||||
};
|
};
|
||||||
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
|
export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerificationResetProps>(
|
||||||
({ onCancel }, ref) => {
|
({ onCancel }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [reset, setReset] = useState(false);
|
const [reset, setReset] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -334,7 +333,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||||
size="500"
|
size="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Reset Device Verification</Text>
|
<Text size="H4">{t('Settings.verification_reset_title')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel}>
|
<IconButton size="300" radii="300" onClick={onCancel}>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
|
|
@ -356,16 +355,11 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="H1">✋🧑🚒🤚</Text>
|
<Text size="H1">✋🧑🚒🤚</Text>
|
||||||
<Text size="T300">Resetting device verification is permanent.</Text>
|
<Text size="T300">{t('Settings.verification_reset_permanent')}</Text>
|
||||||
<Text size="T300">
|
<Text size="T300">{t('Settings.verification_reset_warning')}</Text>
|
||||||
Anyone you have verified with will see security alerts and your encryption backup
|
|
||||||
will be lost. You almost certainly do not want to do this, unless you have lost{' '}
|
|
||||||
<b>Recovery Key</b> or <b>Recovery Passphrase</b> and every device you can verify
|
|
||||||
from.
|
|
||||||
</Text>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="Critical" onClick={() => setReset(true)}>
|
<Button variant="Critical" onClick={() => setReset(true)}>
|
||||||
<Text size="B400">Reset</Text>
|
<Text size="B400">{t('Settings.reset')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import React, { forwardRef, useCallback } from 'react';
|
|
||||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
|
||||||
import { logoutClient } from '../../client/initMatrix';
|
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
||||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
|
||||||
import { InfoCard } from './info-card';
|
|
||||||
import {
|
|
||||||
useDeviceVerificationStatus,
|
|
||||||
VerificationStatus,
|
|
||||||
} from '../hooks/useDeviceVerificationStatus';
|
|
||||||
|
|
||||||
type LogoutDialogProps = {
|
|
||||||
handleClose: () => void;
|
|
||||||
};
|
|
||||||
export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|
||||||
({ handleClose }, ref) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
|
||||||
const crossSigningActive = useCrossSigningActive();
|
|
||||||
const verificationStatus = useDeviceVerificationStatus(
|
|
||||||
mx.getCrypto(),
|
|
||||||
mx.getSafeUserId(),
|
|
||||||
mx.getDeviceId() ?? undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
|
||||||
useCallback(async () => {
|
|
||||||
await logoutClient(mx);
|
|
||||||
}, [mx])
|
|
||||||
);
|
|
||||||
|
|
||||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog variant="Surface" ref={ref}>
|
|
||||||
<Header
|
|
||||||
style={{
|
|
||||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
|
||||||
borderBottomWidth: config.borderWidth.B300,
|
|
||||||
}}
|
|
||||||
variant="Surface"
|
|
||||||
size="500"
|
|
||||||
>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="H4">{t('Settings.logout')}</Text>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
|
||||||
{hasEncryptedRoom &&
|
|
||||||
(crossSigningActive ? (
|
|
||||||
verificationStatus === VerificationStatus.Unverified && (
|
|
||||||
<InfoCard
|
|
||||||
variant="Critical"
|
|
||||||
title={t('Settings.logout_unverified_title')}
|
|
||||||
description={t('Settings.logout_unverified_desc')}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<InfoCard
|
|
||||||
variant="Critical"
|
|
||||||
title={t('Settings.logout_alert_title')}
|
|
||||||
description={t('Settings.logout_alert_desc')}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
<Text priority="400">{t('Settings.logout_confirm')}</Text>
|
|
||||||
{logoutState.status === AsyncStatus.Error && (
|
|
||||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
|
||||||
{t('Settings.logout_failed', { message: logoutState.error.message })}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<Button
|
|
||||||
variant="Critical"
|
|
||||||
onClick={logout}
|
|
||||||
disabled={ongoingLogout}
|
|
||||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
|
||||||
>
|
|
||||||
<Text size="B400">{t('Settings.logout')}</Text>
|
|
||||||
</Button>
|
|
||||||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
|
||||||
<Text size="B400">{t('Settings.cancel')}</Text>
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
color,
|
color,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
import { SettingTile } from './setting-tile';
|
import { SettingTile } from './setting-tile';
|
||||||
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
|
import { SecretStorageKeyContent } from '../../types/matrix/accountData';
|
||||||
|
|
@ -33,6 +34,7 @@ export function ManualVerificationMethodSwitcher({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
}: ManualVerificationMethodSwitcherProps) {
|
}: ManualVerificationMethodSwitcherProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
|
@ -55,8 +57,10 @@ export function ManualVerificationMethodSwitcher({
|
||||||
onClick={handleMenu}
|
onClick={handleMenu}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
{value === ManualVerificationMethod.RecoveryPassphrase && 'Recovery Passphrase'}
|
{value === ManualVerificationMethod.RecoveryPassphrase &&
|
||||||
{value === ManualVerificationMethod.RecoveryKey && 'Recovery Key'}
|
t('Settings.verification_recovery_passphrase')}
|
||||||
|
{value === ManualVerificationMethod.RecoveryKey &&
|
||||||
|
t('Settings.verification_recovery_key')}
|
||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
<PopOut
|
<PopOut
|
||||||
|
|
@ -87,7 +91,7 @@ export function ManualVerificationMethodSwitcher({
|
||||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="T300">Recovery Passphrase</Text>
|
<Text size="T300">{t('Settings.verification_recovery_passphrase')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -98,7 +102,7 @@ export function ManualVerificationMethodSwitcher({
|
||||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="T300">Recovery Key</Text>
|
<Text size="T300">{t('Settings.verification_recovery_key')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -120,6 +124,7 @@ export function ManualVerificationTile({
|
||||||
secretStorageKeyContent,
|
secretStorageKeyContent,
|
||||||
options,
|
options,
|
||||||
}: ManualVerificationTileProps) {
|
}: ManualVerificationTileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const hasPassphrase = !!secretStorageKeyContent.passphrase;
|
const hasPassphrase = !!secretStorageKeyContent.passphrase;
|
||||||
|
|
@ -154,8 +159,12 @@ export function ManualVerificationTile({
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Verify Manually"
|
title={t('Settings.verify_manually')}
|
||||||
description={hasPassphrase ? 'Select a verification method.' : 'Provide recovery key.'}
|
description={
|
||||||
|
hasPassphrase
|
||||||
|
? t('Settings.verification_select_method')
|
||||||
|
: t('Settings.verification_provide_key')
|
||||||
|
}
|
||||||
after={
|
after={
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
{hasPassphrase && (
|
{hasPassphrase && (
|
||||||
|
|
@ -167,7 +176,7 @@ export function ManualVerificationTile({
|
||||||
/>
|
/>
|
||||||
{verifyState.status === AsyncStatus.Success ? (
|
{verifyState.status === AsyncStatus.Success ? (
|
||||||
<Text size="T200" style={{ color: color.Success.Main }}>
|
<Text size="T200" style={{ color: color.Success.Main }}>
|
||||||
<b>Device verified!</b>
|
<b>{t('Settings.verification_verified_success')}</b>
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
|
|
|
||||||
54
src/app/components/Modal500.css.ts
Normal file
54
src/app/components/Modal500.css.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
// Settings-window shell (room / space settings) — the self-styled Vojo card
|
||||||
|
// vocabulary (AiChatPrivacy / LogoutDialog): own surface, 16px radius,
|
||||||
|
// hairline border, deep shadow. Replaces the stock folds `Modal` whose flat
|
||||||
|
// square-ish chrome read as the old cinny dialog. The card only provides
|
||||||
|
// the shell; the content inside (PageRoot nav + page) paints its own panel
|
||||||
|
// tones.
|
||||||
|
export const Card = style({
|
||||||
|
width: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
|
maxWidth: toRem(860),
|
||||||
|
height: '85vh',
|
||||||
|
maxHeight: toRem(700),
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Background.Container,
|
||||||
|
borderRadius: toRem(16),
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||||
|
'@media': {
|
||||||
|
// Narrow viewports: the window goes (near) full-screen — the rounded
|
||||||
|
// floating card needs breathing room it doesn't have on a phone.
|
||||||
|
// `--vojo-safe-top` is reset to 0 INSIDE the card (the TSX), so the
|
||||||
|
// status-bar clearance comes straight from env() here — the card is
|
||||||
|
// genuinely top-of-screen in this branch.
|
||||||
|
'screen and (max-width: 600px)': {
|
||||||
|
width: '100vw',
|
||||||
|
maxWidth: '100vw',
|
||||||
|
height: '100dvh',
|
||||||
|
maxHeight: '100dvh',
|
||||||
|
borderRadius: 0,
|
||||||
|
border: 'none',
|
||||||
|
paddingTop: 'env(safe-area-inset-top, 0px)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Single-pane variant (room settings): no nav column, so the two-pane
|
||||||
|
// width reads as a stretched empty form. 560px ≈ the page pane's share of
|
||||||
|
// the wide layout (860 minus the ~300px nav). The media override keeps the
|
||||||
|
// phone branch full-screen — this class is emitted after `Card`, so its
|
||||||
|
// base maxWidth would otherwise beat Card's `100vw` media rule in the
|
||||||
|
// cascade.
|
||||||
|
export const CardNarrow = style({
|
||||||
|
maxWidth: toRem(560),
|
||||||
|
'@media': {
|
||||||
|
'screen and (max-width: 600px)': {
|
||||||
|
maxWidth: '100vw',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,14 +1,23 @@
|
||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { Modal, Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
import { Overlay, OverlayBackdrop, OverlayCenter } from 'folds';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
import { HorseshoeEnabledContext } from './page';
|
import { HorseshoeEnabledContext } from './page';
|
||||||
|
import * as css from './Modal500.css';
|
||||||
|
|
||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
// Single-pane variant (room settings) — the wide default fits the
|
||||||
|
// two-pane nav+page shell (space settings).
|
||||||
|
narrow?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
|
||||||
|
// Settings-window shell (room / space settings). A self-styled Vojo card
|
||||||
|
// (see Modal500.css) instead of the stock folds Modal — near-fullscreen on
|
||||||
|
// phones, a floating rounded window on desktop.
|
||||||
|
export function Modal500({ requestClose, narrow, children }: Modal500Props) {
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
|
|
@ -20,26 +29,27 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||||
escapeDeactivates: stopPropagation,
|
escapeDeactivates: stopPropagation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Modal
|
<div
|
||||||
size="500"
|
className={classNames(css.Card, narrow && css.CardNarrow)}
|
||||||
variant="Background"
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
// Reset `--vojo-safe-top` for everything mounted inside the
|
// Reset `--vojo-safe-top` for everything mounted inside the
|
||||||
// dialog. The Android status-bar inset is reserved by each
|
// dialog. The Android status-bar inset is reserved by each
|
||||||
// page header's `padding-top: var(--vojo-safe-top)` for
|
// page header's `padding-top: var(--vojo-safe-top)` for
|
||||||
// top-of-screen surfaces — but a centred 500px modal sits
|
// top-of-screen surfaces — on phones this window IS
|
||||||
// away from the screen edge, and the same padding inside it
|
// top-of-screen, but its own header chrome supplies the
|
||||||
// just adds dead space above its header.
|
// spacing; the inherited padding would double it.
|
||||||
style={{ ['--vojo-safe-top' as string]: '0px' }}
|
style={{ ['--vojo-safe-top' as string]: '0px' }}
|
||||||
>
|
>
|
||||||
{/* PageRoot rendered inside the dialog (Settings,
|
{/* PageRoot rendered inside the dialog (Settings,
|
||||||
SpaceSettings, RoomSettings) would otherwise pick up
|
SpaceSettings, RoomSettings) would otherwise pick up
|
||||||
the web horseshoe layout — void column + rounded
|
the web horseshoe layout — void column + rounded
|
||||||
corners inside a fixed 500px shell. Disable horseshoe
|
corners inside a fixed shell. Disable horseshoe
|
||||||
for everything inside the modal. */}
|
for everything inside the modal. */}
|
||||||
<HorseshoeEnabledContext.Provider value={false}>
|
<HorseshoeEnabledContext.Provider value={false}>
|
||||||
{children}
|
{children}
|
||||||
</HorseshoeEnabledContext.Provider>
|
</HorseshoeEnabledContext.Provider>
|
||||||
</Modal>
|
</div>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
</OverlayCenter>
|
</OverlayCenter>
|
||||||
</Overlay>
|
</Overlay>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||||
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
import React, { MouseEventHandler, ReactNode, useMemo, useRef, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { stopPropagation } from '../utils/keyboard';
|
import { stopPropagation } from '../utils/keyboard';
|
||||||
|
|
@ -55,22 +55,37 @@ export function RoomNotificationModeSwitcher({
|
||||||
const changing = modeState.status === AsyncStatus.Loading;
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
// The trigger element of the currently-open popout. Lets the FocusTrap
|
||||||
|
// treat clicks on the trigger as "ours" instead of outside-clicks.
|
||||||
|
const anchorElRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const open = !!menuCords;
|
const open = !!menuCords;
|
||||||
const handleToggleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleToggleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
// Second click on the trigger CLOSES the popout instead of re-anchoring (reopening) it. `open`
|
// Second click on the trigger CLOSES the popout instead of re-anchoring
|
||||||
// is the pre-click render value: even though focus-trap's `clickOutsideDeactivates` also fires
|
// (reopening) it. The naive version was broken: focus-trap's
|
||||||
// `onDeactivate` for this same click, both paths resolve to "close" — so there's no reopen race
|
// `clickOutsideDeactivates` fires on MOUSEDOWN, flushing `open=false`
|
||||||
// regardless of listener order.
|
// and re-rendering before the trigger's CLICK handler ran — so the
|
||||||
|
// handler's closure always saw the closed state and re-opened. The
|
||||||
|
// `isAnchorTarget` guards below exclude the trigger from the trap's
|
||||||
|
// outside-click handling entirely, so this handler is the single owner
|
||||||
|
// of the trigger interaction and `open` really is the pre-click value.
|
||||||
if (open) {
|
if (open) {
|
||||||
setMenuCords(undefined);
|
setMenuCords(undefined);
|
||||||
|
anchorElRef.current = null;
|
||||||
} else {
|
} else {
|
||||||
|
anchorElRef.current = evt.currentTarget;
|
||||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
setMenuCords(undefined);
|
setMenuCords(undefined);
|
||||||
|
anchorElRef.current = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isAnchorTarget = (evt: MouseEvent | TouchEvent): boolean => {
|
||||||
|
const anchor = anchorElRef.current;
|
||||||
|
return !!anchor && evt.target instanceof Node && anchor.contains(evt.target);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelect = (mode: RoomNotificationMode) => {
|
const handleSelect = (mode: RoomNotificationMode) => {
|
||||||
|
|
@ -90,7 +105,12 @@ export function RoomNotificationModeSwitcher({
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: handleClose,
|
onDeactivate: handleClose,
|
||||||
clickOutsideDeactivates: true,
|
// Trigger clicks are NOT outside-clicks: deactivating on the
|
||||||
|
// trigger's mousedown closed the menu before the click could
|
||||||
|
// toggle, and the toggle re-opened it (the reopen bug). The
|
||||||
|
// trigger's own click handler owns open/close instead.
|
||||||
|
clickOutsideDeactivates: (evt) => !isAnchorTarget(evt),
|
||||||
|
allowOutsideClick: (evt) => isAnchorTarget(evt),
|
||||||
isKeyForward: (evt: KeyboardEvent) =>
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import {
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, { MouseEventHandler, ReactNode, useState } from 'react';
|
import React, { MouseEventHandler, ReactNode, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ReactEditor, useSlate } from 'slate-react';
|
import { ReactEditor, useSlate } from 'slate-react';
|
||||||
import {
|
import {
|
||||||
headingLevel,
|
headingLevel,
|
||||||
|
|
@ -119,6 +120,7 @@ export function BlockButton({ format, icon, tooltip }: BlockButtonProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HeadingBlockButton() {
|
export function HeadingBlockButton() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const editor = useSlate();
|
const editor = useSlate();
|
||||||
const level = headingLevel(editor);
|
const level = headingLevel(editor);
|
||||||
const [anchor, setAnchor] = useState<RectCords>();
|
const [anchor, setAnchor] = useState<RectCords>();
|
||||||
|
|
@ -158,7 +160,12 @@ export function HeadingBlockButton() {
|
||||||
<Menu style={{ padding: config.space.S100 }}>
|
<Menu style={{ padding: config.space.S100 }}>
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={<BtnTooltip text="Heading 1" shortCode={`${modKey} + 1`} />}
|
tooltip={
|
||||||
|
<BtnTooltip
|
||||||
|
text={t('Room.format_heading', { level: 1 })}
|
||||||
|
shortCode={`${modKey} + 1`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
|
@ -173,7 +180,12 @@ export function HeadingBlockButton() {
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={<BtnTooltip text="Heading 2" shortCode={`${modKey} + 2`} />}
|
tooltip={
|
||||||
|
<BtnTooltip
|
||||||
|
text={t('Room.format_heading', { level: 2 })}
|
||||||
|
shortCode={`${modKey} + 2`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
|
@ -188,7 +200,12 @@ export function HeadingBlockButton() {
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
tooltip={<BtnTooltip text="Heading 3" shortCode={`${modKey} + 3`} />}
|
tooltip={
|
||||||
|
<BtnTooltip
|
||||||
|
text={t('Room.format_heading', { level: 3 })}
|
||||||
|
shortCode={`${modKey} + 3`}
|
||||||
|
/>
|
||||||
|
}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
|
@ -224,6 +241,7 @@ export function HeadingBlockButton() {
|
||||||
|
|
||||||
type ExitFormattingProps = { tooltip: ReactNode };
|
type ExitFormattingProps = { tooltip: ReactNode };
|
||||||
export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const editor = useSlate();
|
const editor = useSlate();
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
|
|
@ -245,7 +263,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
||||||
size="400"
|
size="400"
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Text size="B400">{`Exit ${KeySymbol.Hyper}`}</Text>
|
<Text size="B400">{`${t('Room.format_exit')} ${KeySymbol.Hyper}`}</Text>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -253,6 +271,7 @@ export function ExitFormatting({ tooltip }: ExitFormattingProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar() {
|
export function Toolbar() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const editor = useSlate();
|
const editor = useSlate();
|
||||||
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||||
const disableInline = isBlockActive(editor, BlockType.CodeBlock);
|
const disableInline = isBlockActive(editor, BlockType.CodeBlock);
|
||||||
|
|
@ -271,32 +290,38 @@ export function Toolbar() {
|
||||||
<MarkButton
|
<MarkButton
|
||||||
format={MarkType.Bold}
|
format={MarkType.Bold}
|
||||||
icon={Icons.Bold}
|
icon={Icons.Bold}
|
||||||
tooltip={<BtnTooltip text="Bold" shortCode={`${modKey} + B`} />}
|
tooltip={<BtnTooltip text={t('Room.format_bold')} shortCode={`${modKey} + B`} />}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
format={MarkType.Italic}
|
format={MarkType.Italic}
|
||||||
icon={Icons.Italic}
|
icon={Icons.Italic}
|
||||||
tooltip={<BtnTooltip text="Italic" shortCode={`${modKey} + I`} />}
|
tooltip={<BtnTooltip text={t('Room.format_italic')} shortCode={`${modKey} + I`} />}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
format={MarkType.Underline}
|
format={MarkType.Underline}
|
||||||
icon={Icons.Underline}
|
icon={Icons.Underline}
|
||||||
tooltip={<BtnTooltip text="Underline" shortCode={`${modKey} + U`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_underline')} shortCode={`${modKey} + U`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
format={MarkType.StrikeThrough}
|
format={MarkType.StrikeThrough}
|
||||||
icon={Icons.Strike}
|
icon={Icons.Strike}
|
||||||
tooltip={<BtnTooltip text="Strike Through" shortCode={`${modKey} + S`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_strikethrough')} shortCode={`${modKey} + S`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
format={MarkType.Code}
|
format={MarkType.Code}
|
||||||
icon={Icons.Code}
|
icon={Icons.Code}
|
||||||
tooltip={<BtnTooltip text="Inline Code" shortCode={`${modKey} + [`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_inline_code')} shortCode={`${modKey} + [`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<MarkButton
|
<MarkButton
|
||||||
format={MarkType.Spoiler}
|
format={MarkType.Spoiler}
|
||||||
icon={Icons.EyeBlind}
|
icon={Icons.EyeBlind}
|
||||||
tooltip={<BtnTooltip text="Spoiler" shortCode={`${modKey} + H`} />}
|
tooltip={<BtnTooltip text={t('Room.format_spoiler')} shortCode={`${modKey} + H`} />}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
|
<Line variant="SurfaceVariant" direction="Vertical" style={{ height: toRem(12) }} />
|
||||||
|
|
@ -305,22 +330,30 @@ export function Toolbar() {
|
||||||
<BlockButton
|
<BlockButton
|
||||||
format={BlockType.BlockQuote}
|
format={BlockType.BlockQuote}
|
||||||
icon={Icons.BlockQuote}
|
icon={Icons.BlockQuote}
|
||||||
tooltip={<BtnTooltip text="Block Quote" shortCode={`${modKey} + '`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_block_quote')} shortCode={`${modKey} + '`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<BlockButton
|
<BlockButton
|
||||||
format={BlockType.CodeBlock}
|
format={BlockType.CodeBlock}
|
||||||
icon={Icons.BlockCode}
|
icon={Icons.BlockCode}
|
||||||
tooltip={<BtnTooltip text="Block Code" shortCode={`${modKey} + ;`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_block_code')} shortCode={`${modKey} + ;`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<BlockButton
|
<BlockButton
|
||||||
format={BlockType.OrderedList}
|
format={BlockType.OrderedList}
|
||||||
icon={Icons.OrderList}
|
icon={Icons.OrderList}
|
||||||
tooltip={<BtnTooltip text="Ordered List" shortCode={`${modKey} + 7`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_ordered_list')} shortCode={`${modKey} + 7`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<BlockButton
|
<BlockButton
|
||||||
format={BlockType.UnorderedList}
|
format={BlockType.UnorderedList}
|
||||||
icon={Icons.UnorderList}
|
icon={Icons.UnorderList}
|
||||||
tooltip={<BtnTooltip text="Unordered List" shortCode={`${modKey} + 8`} />}
|
tooltip={
|
||||||
|
<BtnTooltip text={t('Room.format_unordered_list')} shortCode={`${modKey} + 8`} />
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<HeadingBlockButton />
|
<HeadingBlockButton />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -330,7 +363,10 @@ export function Toolbar() {
|
||||||
<Box shrink="No" gap="100">
|
<Box shrink="No" gap="100">
|
||||||
<ExitFormatting
|
<ExitFormatting
|
||||||
tooltip={
|
tooltip={
|
||||||
<BtnTooltip text="Exit Formatting" shortCode={`Escape, ${modKey} + E`} />
|
<BtnTooltip
|
||||||
|
text={t('Room.format_exit_formatting')}
|
||||||
|
shortCode={`Escape, ${modKey} + E`}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -339,7 +375,15 @@ export function Toolbar() {
|
||||||
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
<Box className={css.MarkdownBtnBox} shrink="No" grow="Yes" justifyContent="End">
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
align="End"
|
align="End"
|
||||||
tooltip={<BtnTooltip text={isMarkdown ? 'Disable Markdown' : 'Enable Markdown'} />}
|
tooltip={
|
||||||
|
<BtnTooltip
|
||||||
|
text={
|
||||||
|
isMarkdown
|
||||||
|
? t('Room.format_markdown_disable')
|
||||||
|
: t('Room.format_markdown_enable')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
delay={500}
|
delay={500}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
|
|
||||||
|
|
@ -371,7 +371,6 @@ type EmojiBoardProps = {
|
||||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||||
allowTextCustomEmoji?: boolean;
|
allowTextCustomEmoji?: boolean;
|
||||||
addToRecentEmoji?: boolean;
|
addToRecentEmoji?: boolean;
|
||||||
dock?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function EmojiBoard({
|
export function EmojiBoard({
|
||||||
|
|
@ -385,7 +384,6 @@ export function EmojiBoard({
|
||||||
onStickerSelect,
|
onStickerSelect,
|
||||||
allowTextCustomEmoji,
|
allowTextCustomEmoji,
|
||||||
addToRecentEmoji = true,
|
addToRecentEmoji = true,
|
||||||
dock,
|
|
||||||
}: EmojiBoardProps) {
|
}: EmojiBoardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -515,7 +513,6 @@ export function EmojiBoard({
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiBoardLayout
|
<EmojiBoardLayout
|
||||||
dock={dock}
|
|
||||||
header={
|
header={
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
{onTabChange && <EmojiBoardTabs tab={tab} onTabChange={onTabChange} />}
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,11 @@ export const EmojiBoardLayout = as<
|
||||||
header: ReactNode;
|
header: ReactNode;
|
||||||
sidebar?: ReactNode;
|
sidebar?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
dock?: boolean;
|
|
||||||
}
|
}
|
||||||
>(({ className, header, sidebar, children, dock, ...props }, ref) => (
|
>(({ className, header, sidebar, children, ...props }, ref) => (
|
||||||
<Box
|
<Box
|
||||||
display={dock ? 'Flex' : 'InlineFlex'}
|
display="InlineFlex"
|
||||||
className={classNames(css.Base, dock && css.BaseDock, className)}
|
className={classNames(css.Base, className)}
|
||||||
direction="Row"
|
direction="Row"
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,12 @@ export function SearchInput({
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// Deep input tone + pill shape — the composer-field look, so the
|
||||||
|
// search reads as part of the Vojo window rather than a stock field.
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
variant="SurfaceVariant"
|
variant="Background"
|
||||||
|
radii="Pill"
|
||||||
size="400"
|
size="400"
|
||||||
placeholder={allowTextCustomEmoji ? t('EmojiBoard.search_or_react') : t('EmojiBoard.search')}
|
placeholder={allowTextCustomEmoji ? t('EmojiBoard.search_or_react') : t('EmojiBoard.search')}
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
import React, { CSSProperties } from 'react';
|
import React from 'react';
|
||||||
import { Badge, Box, Text } from 'folds';
|
import { Box } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { EmojiBoardTab } from '../types';
|
import { EmojiBoardTab } from '../types';
|
||||||
|
import * as css from './styles.css';
|
||||||
|
|
||||||
const styles: CSSProperties = {
|
// Window tabs (Эмодзи / Стикеры) — Vojo segment vocabulary (violet dot +
|
||||||
cursor: 'pointer',
|
// weight bump on the active one), replacing the old folds Badge pills.
|
||||||
};
|
function Tab({ active, label, onClick }: { active: boolean; label: string; onClick: () => void }) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={css.Tab} aria-pressed={active} onClick={onClick}>
|
||||||
|
<span aria-hidden className={css.TabDot} />
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function EmojiBoardTabs({
|
export function EmojiBoardTabs({
|
||||||
tab,
|
tab,
|
||||||
|
|
@ -17,30 +25,16 @@ export function EmojiBoardTabs({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
<Badge
|
<Tab
|
||||||
style={styles}
|
active={tab === EmojiBoardTab.Emoji}
|
||||||
as="button"
|
label={t('EmojiBoard.emoji')}
|
||||||
variant="Secondary"
|
|
||||||
fill={tab === EmojiBoardTab.Sticker ? 'Solid' : 'None'}
|
|
||||||
size="500"
|
|
||||||
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
|
|
||||||
>
|
|
||||||
<Text as="span" size="L400">
|
|
||||||
{t('EmojiBoard.sticker')}
|
|
||||||
</Text>
|
|
||||||
</Badge>
|
|
||||||
<Badge
|
|
||||||
style={styles}
|
|
||||||
as="button"
|
|
||||||
variant="Secondary"
|
|
||||||
fill={tab === EmojiBoardTab.Emoji ? 'Solid' : 'None'}
|
|
||||||
size="500"
|
|
||||||
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
onClick={() => onTabChange(EmojiBoardTab.Emoji)}
|
||||||
>
|
/>
|
||||||
<Text as="span" size="L400">
|
<Tab
|
||||||
{t('EmojiBoard.emoji')}
|
active={tab === EmojiBoardTab.Sticker}
|
||||||
</Text>
|
label={t('EmojiBoard.sticker')}
|
||||||
</Badge>
|
onClick={() => onTabChange(EmojiBoardTab.Sticker)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,73 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
|
import { toRem, color, config, DefaultReset, FocusOutline } from 'folds';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Layout
|
* Layout
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// The emoji/sticker WINDOW — self-styled Vojo card (same vocabulary as the
|
||||||
|
// other redesigned dialogs: AiChatPrivacy / LogoutDialog): own surface,
|
||||||
|
// 16px radius, hairline border, deep shadow. Opens as an anchored pop-out
|
||||||
|
// on desktop and as a centered overlay window on mobile/native (both the
|
||||||
|
// composer's emoji button and the message rail's reaction picker).
|
||||||
export const Base = style({
|
export const Base = style({
|
||||||
maxWidth: toRem(432),
|
maxWidth: toRem(432),
|
||||||
width: `calc(100vw - 2 * ${config.space.S400})`,
|
width: `calc(100vw - 2 * ${config.space.S400})`,
|
||||||
height: toRem(450),
|
height: toRem(450),
|
||||||
|
maxHeight: '70vh',
|
||||||
backgroundColor: color.Surface.Container,
|
backgroundColor: color.Surface.Container,
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
// Dawn: a faint hairline + soft shadow instead of the stock solid-bordered
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
// floating card.
|
borderRadius: toRem(16),
|
||||||
border: `${config.borderWidth.B300} solid rgba(255, 255, 255, 0.08)`,
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||||
borderRadius: config.radii.R400,
|
|
||||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Docked variant — the board lives inline at the top of the composer on native
|
|
||||||
// instead of floating as a pop-out. Full-width, capped height, no shadow/round
|
|
||||||
// corners (the composer pill already clips it); only a hairline parts it from
|
|
||||||
// the textarea row below.
|
|
||||||
export const BaseDock = style({
|
|
||||||
maxWidth: 'unset',
|
|
||||||
width: '100%',
|
|
||||||
height: toRem(320),
|
|
||||||
maxHeight: '46vh',
|
|
||||||
border: 'none',
|
|
||||||
borderBottom: `${config.borderWidth.B300} solid rgba(255, 255, 255, 0.08)`,
|
|
||||||
borderRadius: 0,
|
|
||||||
boxShadow: 'none',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const Header = style({
|
export const Header = style({
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Vojo segment tabs (Эмодзи / Стикеры) — the StreamHeader vocabulary:
|
||||||
|
// reset button, violet dot + label, weight bump on the active one.
|
||||||
|
export const Tab = style({
|
||||||
|
appearance: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: toRem(6),
|
||||||
|
padding: `${toRem(6)} ${toRem(8)}`,
|
||||||
|
borderRadius: toRem(8),
|
||||||
|
cursor: 'pointer',
|
||||||
|
font: 'inherit',
|
||||||
|
fontSize: toRem(14),
|
||||||
|
lineHeight: toRem(19),
|
||||||
|
fontWeight: 500,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
opacity: 0.6,
|
||||||
|
selectors: {
|
||||||
|
'&[aria-pressed="true"]': {
|
||||||
|
fontWeight: 600,
|
||||||
|
opacity: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TabDot = style({
|
||||||
|
width: toRem(6),
|
||||||
|
height: toRem(6),
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'transparent',
|
||||||
|
flexShrink: 0,
|
||||||
|
selectors: {
|
||||||
|
[`${Tab}[aria-pressed="true"] &`]: {
|
||||||
|
background: color.Primary.Main,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sidebar
|
* Sidebar
|
||||||
*/
|
*/
|
||||||
|
|
@ -146,13 +175,16 @@ export const EmojiItem = style([
|
||||||
lineHeight: toRem(32),
|
lineHeight: toRem(32),
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: config.radii.R400,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
||||||
':hover': {
|
|
||||||
backgroundColor: color.Surface.ContainerHover,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Mouse-only hover — Android WebView synthesises a sticky `:hover` after a
|
||||||
|
// tap (alt-tap keeps the board open, so the tint would linger); same
|
||||||
|
// `data-input` gate as the rest of the app (src/index.tsx).
|
||||||
|
globalStyle(`:root[data-input="mouse"] ${EmojiItem}:hover`, {
|
||||||
|
backgroundColor: color.Surface.ContainerHover,
|
||||||
|
});
|
||||||
|
|
||||||
export const StickerItem = style([
|
export const StickerItem = style([
|
||||||
EmojiItem,
|
EmojiItem,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,139 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, config } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const EventReaders = style([
|
// Read-receipts card — the self-styled Vojo card vocabulary (AiChatPrivacy /
|
||||||
DefaultReset,
|
// LogoutDialog): own surface/radius/border/shadow, badge header with no
|
||||||
{
|
// bottom-border bar. Replaces the old folds Modal + hardcoded «Seen by»
|
||||||
height: '100%',
|
// header that read as a bare cinny dialog (and wasn't localized).
|
||||||
},
|
export const DialogCard = style({
|
||||||
]);
|
width: '90vw',
|
||||||
|
maxWidth: toRem(360),
|
||||||
|
maxHeight: '70vh',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||||
|
});
|
||||||
|
|
||||||
export const Header = style({
|
export const Header = style({
|
||||||
paddingLeft: config.space.S400,
|
|
||||||
paddingRight: config.space.S300,
|
|
||||||
|
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S300,
|
||||||
|
padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const Content = style({
|
export const HeaderBadge = style({
|
||||||
paddingLeft: config.space.S200,
|
flexShrink: 0,
|
||||||
paddingBottom: config.space.S400,
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: color.Primary.Container,
|
||||||
|
color: color.Primary.Main,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeaderText = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(2),
|
||||||
|
paddingTop: toRem(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeaderTitle = style({
|
||||||
|
fontSize: toRem(18),
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: toRem(22),
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeaderSubtitle = style({
|
||||||
|
fontSize: toRem(13),
|
||||||
|
lineHeight: toRem(17),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const BodyScroll = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Body = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(1),
|
||||||
|
padding: `0 ${config.space.S200} ${config.space.S300}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flat reader row — avatar + (name / read-time) column. Same flat-on-surface
|
||||||
|
// treatment as the ⋮ menu's ActionRow.
|
||||||
|
export const ReaderRow = style({
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
|
borderRadius: toRem(10),
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
font: 'inherit',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
cursor: 'pointer',
|
||||||
|
selectors: {
|
||||||
|
'&:hover': { backgroundColor: color.Surface.ContainerHover },
|
||||||
|
'&:active': { backgroundColor: color.Surface.ContainerActive },
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: `${toRem(2)} solid ${color.Primary.Main}`,
|
||||||
|
outlineOffset: toRem(-2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReaderText = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(1),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ReaderName = style({
|
||||||
|
fontSize: toRem(14),
|
||||||
|
fontWeight: 600,
|
||||||
|
lineHeight: toRem(18),
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
});
|
||||||
|
|
||||||
|
// When the reader read it — the receipt timestamp, muted tabular numerals
|
||||||
|
// like every other technical metadata line in the Dawn vocabulary.
|
||||||
|
export const ReaderTime = style({
|
||||||
|
fontSize: toRem(11.5),
|
||||||
|
lineHeight: toRem(15),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
opacity: 0.65,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EmptyState = style({
|
||||||
|
padding: `${config.space.S300} ${config.space.S300} ${config.space.S400}`,
|
||||||
|
fontSize: toRem(13.5),
|
||||||
|
lineHeight: toRem(19),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
opacity: 0.7,
|
||||||
|
textAlign: 'center',
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,21 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import classNames from 'classnames';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Box,
|
|
||||||
Header,
|
|
||||||
Icon,
|
Icon,
|
||||||
IconButton,
|
IconButton,
|
||||||
Icons,
|
Icons,
|
||||||
MenuItem,
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
Scroll,
|
Scroll,
|
||||||
Text,
|
|
||||||
as,
|
|
||||||
config,
|
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName } from '../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
|
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||||
import * as css from './EventReaders.css';
|
import * as css from './EventReaders.css';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { UserAvatar } from '../user-avatar';
|
import { UserAvatar } from '../user-avatar';
|
||||||
|
|
@ -24,91 +23,143 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { getMouseEventCords } from '../../utils/dom';
|
import { getMouseEventCords } from '../../utils/dom';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
export type EventReadersProps = {
|
export type EventReadersProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
eventId: string;
|
eventId: string;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
export const EventReaders = as<'div', EventReadersProps>(
|
|
||||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const useAuthentication = useMediaAuthentication();
|
|
||||||
const latestEventReaders = useRoomEventReaders(room, eventId);
|
|
||||||
const openProfile = useOpenUserRoomProfile();
|
|
||||||
const space = useSpaceOptionally();
|
|
||||||
|
|
||||||
const getName = (userId: string) =>
|
// Read-receipts card («Прочитали») — self-contained Vojo card (owns its
|
||||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
// Overlay + FocusTrap; callers just conditionally render it). Each row is a
|
||||||
|
// reader with their receipt timestamp — the receipt is the user's CURRENT
|
||||||
|
// read marker, so for older messages it reads as «read at or before this
|
||||||
|
// time»; for the common case (checking your latest message) it's exact.
|
||||||
|
// Tapping a reader closes the card and opens their profile sheet — the
|
||||||
|
// profile surface (side pane / horseshoe) renders OUTSIDE this overlay, so
|
||||||
|
// leaving the card open would hide it behind the backdrop.
|
||||||
|
export function EventReaders({ room, eventId, requestClose }: EventReadersProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const latestEventReaders = useRoomEventReaders(room, eventId);
|
||||||
|
const openProfile = useOpenUserRoomProfile();
|
||||||
|
const space = useSpaceOptionally();
|
||||||
|
|
||||||
return (
|
const getName = (userId: string) =>
|
||||||
<Box
|
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
className={classNames(css.EventReaders, className)}
|
|
||||||
direction="Column"
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
<Header className={css.Header} variant="Surface" size="600">
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Text size="H3">Seen by</Text>
|
|
||||||
</Box>
|
|
||||||
<IconButton size="300" onClick={requestClose}>
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Header>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Scroll visibility="Hover" hideTrack size="300">
|
|
||||||
<Box className={css.Content} direction="Column">
|
|
||||||
{latestEventReaders.map((readerId) => {
|
|
||||||
const name = getName(readerId);
|
|
||||||
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
|
||||||
const avatarUrl = avatarMxcUrl
|
|
||||||
? mx.mxcUrlToHttp(
|
|
||||||
avatarMxcUrl,
|
|
||||||
100,
|
|
||||||
100,
|
|
||||||
'crop',
|
|
||||||
undefined,
|
|
||||||
false,
|
|
||||||
useAuthentication
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
const formatReadTime = (ts: number): string => {
|
||||||
<MenuItem
|
if (today(ts)) return `${t('Room.today')}, ${timeHourMinute(ts)}`;
|
||||||
key={readerId}
|
if (yesterday(ts)) return `${t('Room.yesterday')}, ${timeHourMinute(ts)}`;
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
return `${timeDayMonYear(ts)}, ${timeHourMinute(ts)}`;
|
||||||
radii="400"
|
};
|
||||||
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
openProfile(
|
return (
|
||||||
room.roomId,
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
space?.roomId,
|
<OverlayCenter>
|
||||||
readerId,
|
<FocusTrap
|
||||||
getMouseEventCords(event.nativeEvent),
|
focusTrapOptions={{
|
||||||
'Bottom'
|
initialFocus: false,
|
||||||
);
|
onDeactivate: requestClose,
|
||||||
}}
|
clickOutsideDeactivates: true,
|
||||||
before={
|
escapeDeactivates: stopPropagation,
|
||||||
<Avatar size="200">
|
}}
|
||||||
<UserAvatar
|
>
|
||||||
userId={readerId}
|
<div
|
||||||
src={avatarUrl ?? undefined}
|
className={css.DialogCard}
|
||||||
alt={name}
|
role="dialog"
|
||||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
aria-modal="true"
|
||||||
/>
|
aria-label={t('Room.seen_by')}
|
||||||
</Avatar>
|
>
|
||||||
}
|
<header className={css.Header}>
|
||||||
>
|
<span className={css.HeaderBadge} aria-hidden="true">
|
||||||
<Text size="T400" truncate>
|
<Icon src={Icons.CheckTwice} />
|
||||||
{name}
|
</span>
|
||||||
</Text>
|
<div className={css.HeaderText}>
|
||||||
</MenuItem>
|
<span className={css.HeaderTitle}>{t('Room.seen_by')}</span>
|
||||||
);
|
<span className={css.HeaderSubtitle}>
|
||||||
})}
|
{t('Room.seen_by_count', { count: latestEventReaders.length })}
|
||||||
</Box>
|
</span>
|
||||||
</Scroll>
|
</div>
|
||||||
</Box>
|
<IconButton
|
||||||
</Box>
|
size="300"
|
||||||
);
|
variant="Surface"
|
||||||
}
|
radii="300"
|
||||||
);
|
onClick={requestClose}
|
||||||
|
aria-label={t('Room.close')}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{latestEventReaders.length === 0 ? (
|
||||||
|
<div className={css.EmptyState}>{t('Room.seen_by_empty')}</div>
|
||||||
|
) : (
|
||||||
|
<Scroll
|
||||||
|
className={css.BodyScroll}
|
||||||
|
variant="Surface"
|
||||||
|
direction="Vertical"
|
||||||
|
size="300"
|
||||||
|
hideTrack
|
||||||
|
visibility="Hover"
|
||||||
|
>
|
||||||
|
<div className={css.Body}>
|
||||||
|
{latestEventReaders.map((readerId) => {
|
||||||
|
const name = getName(readerId);
|
||||||
|
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||||
|
const avatarUrl = avatarMxcUrl
|
||||||
|
? mx.mxcUrlToHttp(
|
||||||
|
avatarMxcUrl,
|
||||||
|
100,
|
||||||
|
100,
|
||||||
|
'crop',
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
useAuthentication
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
// `ignoreSynthesized: true` — the default prefers the
|
||||||
|
// SDK's synthetic receipt, whose ts is the moment the
|
||||||
|
// reader last POSTED, not when they read. Real m.read
|
||||||
|
// only; rows without one just show no time.
|
||||||
|
const receiptTs = room.getReadReceiptForUserId(readerId, true)?.data?.ts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={readerId}
|
||||||
|
type="button"
|
||||||
|
className={css.ReaderRow}
|
||||||
|
onClick={(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
|
const cords = getMouseEventCords(event.nativeEvent);
|
||||||
|
requestClose();
|
||||||
|
openProfile(room.roomId, space?.roomId, readerId, cords, 'Bottom');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar size="300">
|
||||||
|
<UserAvatar
|
||||||
|
userId={readerId}
|
||||||
|
src={avatarUrl ?? undefined}
|
||||||
|
alt={name}
|
||||||
|
renderFallback={() => <Icon size="100" src={Icons.User} filled />}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
|
<span className={css.ReaderText}>
|
||||||
|
<span className={css.ReaderName}>{name}</span>
|
||||||
|
{typeof receiptTs === 'number' && receiptTs > 0 && (
|
||||||
|
<span className={css.ReaderTime}>{formatReadTime(receiptTs)}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Scroll>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
Spinner,
|
Spinner,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
@ -27,6 +28,7 @@ type LeaveSpacePromptProps = {
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
};
|
};
|
||||||
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
|
export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
const [leaveState, leaveRoom] = useAsyncCallback<undefined, MatrixError, []>(
|
||||||
|
|
@ -66,7 +68,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||||
size="500"
|
size="500"
|
||||||
>
|
>
|
||||||
<Box grow="Yes">
|
<Box grow="Yes">
|
||||||
<Text size="H4">Leave Space</Text>
|
<Text size="H4">{t('Room.leave_space_title')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onCancel} radii="300">
|
<IconButton size="300" onClick={onCancel} radii="300">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
|
|
@ -74,10 +76,10 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||||
</Header>
|
</Header>
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text priority="400">Are you sure you want to leave this space?</Text>
|
<Text priority="400">{t('Room.leave_space_confirm')}</Text>
|
||||||
{leaveState.status === AsyncStatus.Error && (
|
{leaveState.status === AsyncStatus.Error && (
|
||||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
Failed to leave space! {leaveState.error.message}
|
{t('Room.leave_space_error', { error: leaveState.error.message })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -96,7 +98,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text size="B400">
|
<Text size="B400">
|
||||||
{leaveState.status === AsyncStatus.Loading ? 'Leaving...' : 'Leave'}
|
{leaveState.status === AsyncStatus.Loading ? t('Room.leaving') : t('Room.leave')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
79
src/app/components/logout-dialog/LogoutDialog.css.ts
Normal file
79
src/app/components/logout-dialog/LogoutDialog.css.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
// Logout confirmation — same self-styled Vojo card vocabulary as the AI
|
||||||
|
// privacy notice (AiChatPrivacy.css.ts): own surface/radius/border/shadow,
|
||||||
|
// branded badge header with NO bottom-border bar (the border bar is what made
|
||||||
|
// the old folds Dialog read as a generic cinny dialog). Narrower than the
|
||||||
|
// privacy card — it's a yes/no confirm, not a reading surface.
|
||||||
|
export const DialogCard = style({
|
||||||
|
width: '90vw',
|
||||||
|
maxWidth: toRem(400),
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||||
|
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Header = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
gap: config.space.S300,
|
||||||
|
padding: `${config.space.S400} ${config.space.S400} ${config.space.S300}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rounded-square badge in the critical tone — the destructive twin of the
|
||||||
|
// privacy card's fleet shield.
|
||||||
|
export const HeaderBadge = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
width: toRem(40),
|
||||||
|
height: toRem(40),
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: color.Critical.Container,
|
||||||
|
color: color.Critical.Main,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeaderText = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(2),
|
||||||
|
paddingTop: toRem(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeaderTitle = style({
|
||||||
|
fontSize: toRem(18),
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: toRem(22),
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const HeaderSubtitle = style({
|
||||||
|
fontSize: toRem(13),
|
||||||
|
lineHeight: toRem(17),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
opacity: 0.7,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Body = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: config.space.S400,
|
||||||
|
padding: `0 ${config.space.S400} ${config.space.S400}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Telegram-style horizontal confirm row: Cancel first, the destructive
|
||||||
|
// action trailing at the end.
|
||||||
|
export const ButtonRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: config.space.S200,
|
||||||
|
});
|
||||||
164
src/app/components/logout-dialog/LogoutDialog.tsx
Normal file
164
src/app/components/logout-dialog/LogoutDialog.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Spinner,
|
||||||
|
Text,
|
||||||
|
color,
|
||||||
|
} from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
|
import { logoutClient } from '../../../client/initMatrix';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||||
|
import { InfoCard } from '../info-card';
|
||||||
|
import {
|
||||||
|
useDeviceVerificationStatus,
|
||||||
|
VerificationStatus,
|
||||||
|
} from '../../hooks/useDeviceVerificationStatus';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
import * as css from './LogoutDialog.css';
|
||||||
|
|
||||||
|
type LogoutDialogProps = {
|
||||||
|
handleClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Logout confirmation — self-contained Vojo card (owns its Overlay +
|
||||||
|
// FocusTrap, callers just conditionally render it). Same chrome as the AI
|
||||||
|
// privacy notice: badge header without a border bar, horizontal confirm row.
|
||||||
|
// The encrypted-rooms key-loss warnings (InfoCard) stay — they are the only
|
||||||
|
// thing standing between an unverified device and losing message history.
|
||||||
|
export function LogoutDialog({ handleClose }: LogoutDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const hasEncryptedRoom = !!mx.getRooms().find((room) => room.hasEncryptionStateEvent());
|
||||||
|
const crossSigningActive = useCrossSigningActive();
|
||||||
|
const verificationStatus = useDeviceVerificationStatus(
|
||||||
|
mx.getCrypto(),
|
||||||
|
mx.getSafeUserId(),
|
||||||
|
mx.getDeviceId() ?? undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||||
|
useCallback(async () => {
|
||||||
|
await logoutClient(mx);
|
||||||
|
}, [mx])
|
||||||
|
);
|
||||||
|
|
||||||
|
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||||
|
// focus-trap-react copies focusTrapOptions ONCE at mount — plain values
|
||||||
|
// here would freeze at `ongoingLogout = false` and let a backdrop click /
|
||||||
|
// Esc dismiss the dialog mid-logout (hiding a potential failure while the
|
||||||
|
// session is still alive). Function options are called at event time, so
|
||||||
|
// they read the live state through this ref.
|
||||||
|
const ongoingLogoutRef = useRef(ongoingLogout);
|
||||||
|
ongoingLogoutRef.current = ongoingLogout;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => {
|
||||||
|
if (!ongoingLogoutRef.current) handleClose();
|
||||||
|
},
|
||||||
|
clickOutsideDeactivates: () => !ongoingLogoutRef.current,
|
||||||
|
escapeDeactivates: (evt) => {
|
||||||
|
// Mid-logout the Esc must still be CONSUMED: just returning
|
||||||
|
// false would let the keydown bubble on to the surface
|
||||||
|
// hosting the dialog (the mobile settings sheet's window
|
||||||
|
// listener, route-level handlers) and tear it down — the
|
||||||
|
// exact dismissal this ref-guard exists to prevent.
|
||||||
|
if (ongoingLogoutRef.current) {
|
||||||
|
evt.stopPropagation();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return stopPropagation(evt);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={css.DialogCard}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('Settings.logout')}
|
||||||
|
>
|
||||||
|
<header className={css.Header}>
|
||||||
|
<span className={css.HeaderBadge} aria-hidden="true">
|
||||||
|
<Icon src={Icons.Power} />
|
||||||
|
</span>
|
||||||
|
<div className={css.HeaderText}>
|
||||||
|
<span className={css.HeaderTitle}>{t('Settings.logout')}</span>
|
||||||
|
<span className={css.HeaderSubtitle}>{t('Settings.logout_confirm')}</span>
|
||||||
|
</div>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={ongoingLogout}
|
||||||
|
aria-label={t('Settings.cancel')}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className={css.Body}>
|
||||||
|
{hasEncryptedRoom &&
|
||||||
|
(crossSigningActive ? (
|
||||||
|
verificationStatus === VerificationStatus.Unverified && (
|
||||||
|
<InfoCard
|
||||||
|
variant="Critical"
|
||||||
|
title={t('Settings.logout_unverified_title')}
|
||||||
|
description={t('Settings.logout_unverified_desc')}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<InfoCard
|
||||||
|
variant="Critical"
|
||||||
|
title={t('Settings.logout_alert_title')}
|
||||||
|
description={t('Settings.logout_alert_desc')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{logoutState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
|
{t('Settings.logout_failed', { message: logoutState.error.message })}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Box className={css.ButtonRow}>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={ongoingLogout}
|
||||||
|
>
|
||||||
|
<Text size="B300">{t('Settings.cancel')}</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Critical"
|
||||||
|
radii="300"
|
||||||
|
onClick={logout}
|
||||||
|
disabled={ongoingLogout}
|
||||||
|
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="100" />}
|
||||||
|
>
|
||||||
|
<Text size="B300">{t('Settings.logout')}</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
1
src/app/components/logout-dialog/index.ts
Normal file
1
src/app/components/logout-dialog/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './LogoutDialog';
|
||||||
|
|
@ -85,13 +85,6 @@ export const MediaPlain = style({
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editing: the body is the composer card itself (wrapped in ChatComposer by the
|
|
||||||
// caller) — full width, no bubble background of our own.
|
|
||||||
export const EditContent = style({
|
|
||||||
width: '100%',
|
|
||||||
minWidth: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tap-rail open (or context menu / emoji board open) → the message reads as
|
// Tap-rail open (or context menu / emoji board open) → the message reads as
|
||||||
// «selected», the same affordance long-press gives. A soft brand ring rather
|
// «selected», the same affordance long-press gives. A soft brand ring rather
|
||||||
// than a fill so it works over both the bubble and bare peer text / media.
|
// than a fill so it works over both the bubble and bare peer text / media.
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,6 @@ export type BubbleLayoutProps = {
|
||||||
// media AND for the tail of a same-minute series (grouped messages). A single
|
// media AND for the tail of a same-minute series (grouped messages). A single
|
||||||
// text message keeps its timestamp on the side.
|
// text message keeps its timestamp on the side.
|
||||||
timeBelow?: boolean;
|
timeBelow?: boolean;
|
||||||
// While editing, the body IS a composer card — drop the bubble chrome so it
|
|
||||||
// reads as one box, and skip the timestamp.
|
|
||||||
editing?: boolean;
|
|
||||||
// Reactions chip-row, floats under the message on the page background.
|
// Reactions chip-row, floats under the message on the page background.
|
||||||
reactions?: ReactNode;
|
reactions?: ReactNode;
|
||||||
threadSummary?: ReactNode;
|
threadSummary?: ReactNode;
|
||||||
|
|
@ -45,7 +42,6 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||||
selected,
|
selected,
|
||||||
mediaMode,
|
mediaMode,
|
||||||
timeBelow,
|
timeBelow,
|
||||||
editing,
|
|
||||||
reactions,
|
reactions,
|
||||||
threadSummary,
|
threadSummary,
|
||||||
readStatus,
|
readStatus,
|
||||||
|
|
@ -63,10 +59,7 @@ export const BubbleLayout = as<'div', BubbleLayoutProps>(
|
||||||
);
|
);
|
||||||
|
|
||||||
let body: ReactNode;
|
let body: ReactNode;
|
||||||
if (editing) {
|
if (mediaMode || timeBelow) {
|
||||||
// The child is already a composer card; render it full width, no chrome.
|
|
||||||
body = <div className={css.EditContent}>{children}</div>;
|
|
||||||
} else if (mediaMode || timeBelow) {
|
|
||||||
// Media, and the tail of a same-minute series: timestamp BELOW the content,
|
// Media, and the tail of a same-minute series: timestamp BELOW the content,
|
||||||
// aligned to the message side by the Row (own → right, peer → left).
|
// aligned to the message side by the Row (own → right, peer → left).
|
||||||
body = (
|
body = (
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,39 @@
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
// 40px circular avatar — Discord's cozy-mode avatar size. Consumers override
|
// Avatar diameter — Discord's cozy-mode 40px on desktop, compacted to 32px
|
||||||
// the folds preset via inline style; the shared `CHANNEL_AVATAR_PX` constant
|
// on narrow/native viewports (the 40px slot + 16px gutters wasted ~a third
|
||||||
// keeps the CSS slot width and the inline override in sync.
|
// of a 360px screen on chrome — user request, redesign item 11). Exposed as
|
||||||
export const CHANNEL_AVATAR_PX = 40;
|
// a CSS var scoped on ChannelRow so the inline `<Avatar>` size override in
|
||||||
const ChannelAvatarWidth = toRem(CHANNEL_AVATAR_PX);
|
// Channel.tsx tracks the same breakpoint as the slot width without JS
|
||||||
|
// media-query plumbing.
|
||||||
|
export const CHANNEL_AVATAR_SIZE_VAR = createVar();
|
||||||
|
const ChannelAvatarWidth = CHANNEL_AVATAR_SIZE_VAR;
|
||||||
|
|
||||||
// Discord cozy-mode geometry: avatar 16px from the list edge, 16px gap to the
|
// Mirrors BubbleTimelineBand's desktop breakpoint (RoomTimeline.css.ts) —
|
||||||
// content, so the message column starts at 16 + 40 + 16 = 72px.
|
// keep the two media queries in sync.
|
||||||
|
const MOBILE_MEDIA = 'screen and (max-width: 599px)';
|
||||||
|
|
||||||
|
// Discord cozy-mode geometry on desktop: avatar 16px from the list edge,
|
||||||
|
// 16px gap to the content, so the message column starts at 16 + 40 + 16 =
|
||||||
|
// 72px. On mobile everything tightens: 6px edge, 32px avatar, 10px gap →
|
||||||
|
// body column at 48px from the band edge (60px from the screen edge with
|
||||||
|
// the band's 12px native gutter), vs the old 84px.
|
||||||
const ChannelEdgePad = toRem(16);
|
const ChannelEdgePad = toRem(16);
|
||||||
const ChannelAvatarGap = toRem(16);
|
const ChannelAvatarGap = toRem(16);
|
||||||
|
const ChannelEdgePadMobile = toRem(6);
|
||||||
|
const ChannelAvatarGapMobile = toRem(10);
|
||||||
|
|
||||||
export const ChannelRow = style({
|
export const ChannelRow = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
gap: ChannelAvatarGap,
|
gap: ChannelAvatarGap,
|
||||||
|
vars: {
|
||||||
|
[CHANNEL_AVATAR_SIZE_VAR]: toRem(40),
|
||||||
|
},
|
||||||
// Span the full message-column width so the hover highlight runs edge-to-edge
|
// Span the full message-column width so the hover highlight runs edge-to-edge
|
||||||
// like Discord: cancel MessageBase's S400/S200 horizontal padding with negative
|
// like Discord: cancel MessageBase's S400/S200 horizontal padding with negative
|
||||||
// margins, then re-add the 16px avatar gutter as paddingLeft (so the avatar's
|
// margins, then re-add the avatar gutter as paddingLeft (so the avatar's
|
||||||
// left edge lands 16px from the column edge — Discord cozy). NB: the column is
|
// left edge lands 16px from the column edge — Discord cozy). NB: the column is
|
||||||
// the centred BubbleTimelineBand, so that edge is the band's content edge, not
|
// the centred BubbleTimelineBand, so that edge is the band's content edge, not
|
||||||
// the screen edge (the band adds 12px native / 40px desktop outside this).
|
// the screen edge (the band adds 12px native / 40px desktop outside this).
|
||||||
|
|
@ -31,9 +46,9 @@ export const ChannelRow = style({
|
||||||
paddingTop: toRem(2),
|
paddingTop: toRem(2),
|
||||||
paddingBottom: toRem(2),
|
paddingBottom: toRem(2),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
// Hover bg subtle so adjacent rows still read as distinct units. `@media
|
|
||||||
// (hover: hover)` keeps this inert on touch where there's no pointer.
|
|
||||||
'@media': {
|
'@media': {
|
||||||
|
// Hover bg subtle so adjacent rows still read as distinct units. `@media
|
||||||
|
// (hover: hover)` keeps this inert on touch where there's no pointer.
|
||||||
'(hover: hover) and (pointer: fine)': {
|
'(hover: hover) and (pointer: fine)': {
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
|
|
@ -41,12 +56,20 @@ export const ChannelRow = style({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
[MOBILE_MEDIA]: {
|
||||||
|
vars: {
|
||||||
|
[CHANNEL_AVATAR_SIZE_VAR]: toRem(32),
|
||||||
|
},
|
||||||
|
gap: ChannelAvatarGapMobile,
|
||||||
|
paddingLeft: ChannelEdgePadMobile,
|
||||||
|
paddingRight: toRem(8),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fixed-width slot keeps the body column aligned across collapsed rows
|
// Fixed-width slot keeps the body column aligned across collapsed rows
|
||||||
// (where `avatar` is `undefined` — the slot still occupies `ChannelAvatarWidth`,
|
// (where `avatar` is `undefined` — the slot still occupies the avatar
|
||||||
// so the body's left edge stays put).
|
// diameter, so the body's left edge stays put).
|
||||||
export const ChannelAvatarSlot = style({
|
export const ChannelAvatarSlot = style({
|
||||||
width: ChannelAvatarWidth,
|
width: ChannelAvatarWidth,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|
@ -90,14 +113,23 @@ export const ChannelThreadSummary = style({
|
||||||
// continuous.
|
// continuous.
|
||||||
export const ChannelSysline = style({
|
export const ChannelSysline = style({
|
||||||
// Indent past the avatar gutter so the sysline body aligns with the message
|
// Indent past the avatar gutter so the sysline body aligns with the message
|
||||||
// body column (72px). The sysline sits inside MessageBase's S400 (16px) left
|
// body column (72px desktop). The sysline sits inside MessageBase's S400
|
||||||
// pad (it has no edge-to-edge negative margin), so paddingLeft = avatar (40)
|
// (16px) left pad (it has no edge-to-edge negative margin), so paddingLeft
|
||||||
// + gap (16) = 56 lands the content at 16 + 56 = 72px.
|
// = avatar (40) + gap (16) = 56 lands the content at 16 + 56 = 72px.
|
||||||
paddingLeft: `calc(${ChannelAvatarWidth} + ${ChannelAvatarGap})`,
|
// ChannelRow's avatar-size var is out of scope here (sysline rows render
|
||||||
|
// standalone), so the mobile compaction repeats the literals: 32 + 10 = 42,
|
||||||
|
// minus the 10px edge-pad difference (16 − 6) the message rows gained,
|
||||||
|
// = 32px — keeps the sysline body on the message-body column line.
|
||||||
|
paddingLeft: `calc(${toRem(40)} + ${ChannelAvatarGap})`,
|
||||||
paddingRight: config.space.S200,
|
paddingRight: config.space.S200,
|
||||||
paddingTop: config.space.S100,
|
paddingTop: config.space.S100,
|
||||||
paddingBottom: config.space.S100,
|
paddingBottom: config.space.S100,
|
||||||
color: color.SurfaceVariant.OnContainer,
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
'@media': {
|
||||||
|
[MOBILE_MEDIA]: {
|
||||||
|
paddingLeft: toRem(32),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ChannelSyslineIcon = style({
|
export const ChannelSyslineIcon = style({
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { Avatar, Box, Icon, Icons, type IconSrc, as } from 'folds';
|
||||||
import { type Room } from 'matrix-js-sdk';
|
import { type Room } from 'matrix-js-sdk';
|
||||||
|
|
||||||
import * as css from './Channel.css';
|
import * as css from './Channel.css';
|
||||||
import { CHANNEL_AVATAR_PX } from './Channel.css';
|
import { CHANNEL_AVATAR_SIZE_VAR } from './Channel.css';
|
||||||
import { UserAvatar } from '../../user-avatar';
|
import { UserAvatar } from '../../user-avatar';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
|
@ -137,7 +137,16 @@ export function ChannelMessageAvatar({
|
||||||
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
|
||||||
: undefined;
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<Avatar size="300" style={{ width: CHANNEL_AVATAR_PX, height: CHANNEL_AVATAR_PX }}>
|
// Size from the ChannelRow-scoped CSS var (40px desktop / 32px mobile) so
|
||||||
|
// the avatar tracks the slot's breakpoint while still out-ranking the
|
||||||
|
// folds size preset via inline style.
|
||||||
|
<Avatar
|
||||||
|
size="300"
|
||||||
|
style={{
|
||||||
|
width: `var(${CHANNEL_AVATAR_SIZE_VAR})`,
|
||||||
|
height: `var(${CHANNEL_AVATAR_SIZE_VAR})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
userId={senderId}
|
userId={senderId}
|
||||||
src={avatarUrl}
|
src={avatarUrl}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
import { createVar, globalStyle, keyframes, style, styleVariants } from '@vanilla-extract/css';
|
||||||
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
import { recipe, RecipeVariants } from '@vanilla-extract/recipes';
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
|
import { VOJO_HORSESHOE_GAP_PX } from '../../../styles/horseshoe';
|
||||||
|
|
||||||
const SpacingVar = createVar();
|
const SpacingVar = createVar();
|
||||||
const SpacingVariant = styleVariants({
|
const SpacingVariant = styleVariants({
|
||||||
|
|
@ -53,10 +54,36 @@ const highlightAnime = keyframes({
|
||||||
backgroundColor: color.Primary.Container,
|
backgroundColor: color.Primary.Container,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// Jump-to-message highlight — a clean FULL-WIDTH band: flat rectangle
|
||||||
|
// (overrides the base's rounded-right radius, which read as «square left /
|
||||||
|
// rounded right» — wrong shape for a row spotlight) that bleeds through the
|
||||||
|
// timeline band's horizontal gutters out to the screen edge on native and to
|
||||||
|
// the centred band's edges on web. The bleed is transparent borders +
|
||||||
|
// cancelling negative margins: border and margin sum to zero per side, so
|
||||||
|
// the row's content layout doesn't shift when the highlight engages/clears,
|
||||||
|
// while the animated background paints under the transparent borders
|
||||||
|
// (default border-box clip). Gutter widths mirror BubbleTimelineBand
|
||||||
|
// (RoomTimeline.css.ts): VOJO_HORSESHOE_GAP_PX native, 40px ≥600px — keep
|
||||||
|
// in sync with that media query.
|
||||||
|
const HighlightGutterMobile = toRem(VOJO_HORSESHOE_GAP_PX);
|
||||||
|
const HighlightGutterDesktop = toRem(40);
|
||||||
const HighlightVariant = styleVariants({
|
const HighlightVariant = styleVariants({
|
||||||
true: {
|
true: {
|
||||||
animation: `${highlightAnime} 2000ms ease-in-out`,
|
animation: `${highlightAnime} 2000ms ease-in-out`,
|
||||||
animationIterationCount: 'infinite',
|
animationIterationCount: 'infinite',
|
||||||
|
borderRadius: 0,
|
||||||
|
borderLeft: `${HighlightGutterMobile} solid transparent`,
|
||||||
|
borderRight: `${HighlightGutterMobile} solid transparent`,
|
||||||
|
marginLeft: `calc(-1 * ${HighlightGutterMobile})`,
|
||||||
|
marginRight: `calc(-1 * ${HighlightGutterMobile})`,
|
||||||
|
'@media': {
|
||||||
|
'screen and (min-width: 600px)': {
|
||||||
|
borderLeft: `${HighlightGutterDesktop} solid transparent`,
|
||||||
|
borderRight: `${HighlightGutterDesktop} solid transparent`,
|
||||||
|
marginLeft: `calc(-1 * ${HighlightGutterDesktop})`,
|
||||||
|
marginRight: `calc(-1 * ${HighlightGutterDesktop})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { useAtomValue } from 'jotai';
|
||||||
import { Box, Icon, IconButton, Icons } from 'folds';
|
import { Box, Icon, IconButton, Icons } from 'folds';
|
||||||
import {
|
import {
|
||||||
curtainPinnedByTabAtom,
|
curtainPinnedByTabAtom,
|
||||||
mobileHorseshoeActiveAtom,
|
mobileHorseshoeElevateHeaderAtom,
|
||||||
mobilePagerCurtainAtom,
|
mobilePagerCurtainAtom,
|
||||||
} from '../../state/mobilePagerHeader';
|
} from '../../state/mobilePagerHeader';
|
||||||
import { Segment } from '../stream-header/Segment';
|
import { Segment } from '../stream-header/Segment';
|
||||||
|
|
@ -89,42 +89,37 @@ export function MobileTabsPagerHeader({
|
||||||
// the «curtain-overlay invariants» comment in style.css.ts on
|
// the «curtain-overlay invariants» comment in style.css.ts on
|
||||||
// pagerStaticHeader for the bg / z-order contract.
|
// pagerStaticHeader for the bg / z-order contract.
|
||||||
//
|
//
|
||||||
// Z-elevation while a horseshoe sheet is GEOMETRICALLY active: the
|
// Z-elevation is requested ONLY by the void-carve sheet design (the
|
||||||
// MobileSettings / ChannelsWorkspace container paints
|
// Channels workspace switcher): while geometrically active
|
||||||
// `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark theme) across the
|
// (`expandedPx > 0`, first frame of drag) its container paints
|
||||||
// entire pane to drive the carve cut-out the moment `expandedPx > 0`.
|
// `VOJO_HORSESHOE_VOID_COLOR` across the pane to drive the carve,
|
||||||
// Without elevation that void bleeds up through the transparent
|
// and without elevation that void bleeds up through the transparent
|
||||||
// strip-stack into the safe-top + tabsRow zone, turning the system-
|
// strip-stack into the safe-top + tabsRow zone. A positive z-index
|
||||||
// tray strip + tabs black. Bumping the static header into a positive
|
// beats the strip's z:auto stacking context (CSS painting order),
|
||||||
// z-index puts it ABOVE the strip's stacking context (positive z
|
// covering the void in this band with SurfaceVariant bg + visible
|
||||||
// beats z:auto stacking contexts per CSS painting order), covering
|
// tabs. The near-fullscreen Settings sheet keeps its appBody
|
||||||
// the void in its own y-band with SurfaceVariant bg + visible tabs.
|
// transparent and simply slides OVER this header like a real
|
||||||
//
|
// curtain, so it never publishes the elevate atom — see
|
||||||
// The atom tracks the GEOMETRIC signal (`expandedPx > 0`), not the
|
// mobileHorseshoeElevateHeaderAtom's docs.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Pinned-overrides-elevation: when the active pane's curtain is
|
// Pinned-overrides-elevation: when the active pane's curtain is
|
||||||
// pinned the curtain itself contains the void — it covers everything
|
// pinned the curtain itself contains the void — it covers everything
|
||||||
// from `y = safe-top` downward inside the strip's stacking context,
|
// from `y = safe-top` downward inside the strip's stacking context,
|
||||||
// and the opaque appBody (also flipped on `horseshoeActive`) covers
|
// and the opaque appBody (flipped on the same geometric signal)
|
||||||
// the safe-top band above the curtain. Re-elevating the static
|
// covers the safe-top band above the curtain. Re-elevating the
|
||||||
// header in that state would visibly «slice» the pinned curtain in
|
// static header in that state would visibly «slice» the pinned
|
||||||
// the safe-top + tabsRow band, popping tabs back over what the user
|
// curtain in the safe-top + tabsRow band, popping tabs back over
|
||||||
// explicitly pulled up to cover. So we suppress elevation whenever
|
// what the user explicitly pulled up to cover. So we suppress
|
||||||
// the active tab's pin is set — preserves the «pinned hides tabs»
|
// elevation whenever the active tab's pin is set — preserves the
|
||||||
// invariant across sheet open/drag.
|
// «pinned hides tabs» invariant across sheet open/drag.
|
||||||
//
|
//
|
||||||
// The curtain pin gesture is suppressed while either sheet is open
|
// The curtain pin gesture is suppressed while either sheet is open
|
||||||
// (see `StreamHeader.gestureDisabled`), so this elevation never
|
// (see `StreamHeader.gestureDisabled`), so this elevation never
|
||||||
// races with a pin-in-progress drag.
|
// races with a pin-in-progress drag.
|
||||||
const horseshoeActive = useAtomValue(mobileHorseshoeActiveAtom);
|
const elevateHeader = useAtomValue(mobileHorseshoeElevateHeaderAtom);
|
||||||
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
|
const pinnedByTab = useAtomValue(curtainPinnedByTabAtom);
|
||||||
const activePinned = !!pinnedByTab[activeTab];
|
const activePinned = !!pinnedByTab[activeTab];
|
||||||
const elevated = horseshoeActive && !activePinned;
|
const elevated = elevateHeader && !activePinned;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -86,28 +86,31 @@ export const pagerRoot = style({
|
||||||
// the tabs visually slide with the curtain. See git history for the
|
// the tabs visually slide with the curtain. See git history for the
|
||||||
// user-feedback trail.
|
// 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
|
// The two horseshoe sheet designs interact differently with this
|
||||||
// geometrically active — i.e. `expandedPx > 0`, which covers both
|
// header. The near-fullscreen Settings sheet keeps its appBody
|
||||||
// the in-flight drag and the committed-open state — the wrapping
|
// 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
|
// container paints `VOJO_HORSESHOE_VOID_COLOR` (= #000 in dark
|
||||||
// theme) across the entire pane so the carve at the sheet's top
|
// theme) across the pane to drive the carve. With the transparent
|
||||||
// reads as a dark seam. With the transparent strip stack from (b),
|
// strip stack from (b), that void would bleed up through the
|
||||||
// that void would bleed up through the safe-top + tabsRow zone,
|
// safe-top + tabsRow zone, turning the system-tray strip + tabs
|
||||||
// turning the system-tray strip + tabs solid black.
|
// solid black.
|
||||||
//
|
//
|
||||||
// `MobileTabsPagerHeader.tsx` bumps this element to a positive
|
// So only the void-carve sheet publishes
|
||||||
// `zIndex` (inline style, driven by `mobileHorseshoeActiveAtom`)
|
// `mobileHorseshoeElevateHeaderAtom`; `MobileTabsPagerHeader.tsx`
|
||||||
// from the first frame of drag. Positive z beats the strip's
|
// bumps this element to a positive `zIndex` (inline style) on that
|
||||||
// `z: auto` stacking context, putting the static header back on
|
// signal. Positive z beats the strip's `z: auto` stacking context,
|
||||||
// top in the safe-top + tabsRow band — the void is contained to
|
// putting the static header back on top in the safe-top + tabsRow
|
||||||
// the carve area, tabs stay visible. The horseshoe's `appBody`
|
// band — the void is contained to the carve area, tabs stay
|
||||||
// flips back to opaque on the same signal so the void doesn't
|
// visible. That horseshoe's `appBody` flips back to opaque on the
|
||||||
// bleed into the mascot/form band between the static header and
|
// same signal so the void doesn't bleed into the band between the
|
||||||
// the curtain top either. The curtain pin gesture is gated off
|
// static header and the curtain top either. The curtain pin gesture
|
||||||
// in the same state (see `StreamHeader.gestureDisabled`) so no
|
// is gated off in the same state (see `StreamHeader.gestureDisabled`)
|
||||||
// pin can race the elevation flip.
|
// so no pin can race the elevation flip.
|
||||||
//
|
//
|
||||||
// Pinned-override: when the active pane's curtain is pinned, the
|
// Pinned-override: when the active pane's curtain is pinned, the
|
||||||
// curtain itself sits at the top of the stage (z:2 inside the
|
// curtain itself sits at the top of the stage (z:2 inside the
|
||||||
|
|
|
||||||
38
src/app/components/swipe-back/externalSwipeFeed.ts
Normal file
38
src/app/components/swipe-back/externalSwipeFeed.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Cross-boundary input feed for the swipe-back gesture. An iframe is a
|
||||||
|
// separate browsing context — touches inside it NEVER reach the host's
|
||||||
|
// listeners, so the swipe-back-from-widget gesture was dead over the
|
||||||
|
// bridge-bot widgets. The widget apps (apps/widget-*) forward their raw
|
||||||
|
// touch stream over the validated `io.vojo.bot-widget` side-channel
|
||||||
|
// (`swipe-touch` action); `BotWidgetMount` maps the coordinates into host
|
||||||
|
// viewport space and emits them here; `useSwipeBackGesture` subscribes and
|
||||||
|
// routes them through the SAME state machine as native touches (dead-zone
|
||||||
|
// axis resolve, edge guard, distance-commit).
|
||||||
|
//
|
||||||
|
// Module-level pub-sub (not context): the emitter (BotWidgetMount, inside
|
||||||
|
// the routed chat) and the consumer (SwipeBackOverlay, the router-level
|
||||||
|
// card) live in distant subtrees, and the feed is transient input — no
|
||||||
|
// render state, nothing to persist.
|
||||||
|
|
||||||
|
export type ExternalSwipePhase = 'start' | 'move' | 'end' | 'cancel';
|
||||||
|
|
||||||
|
export type ExternalSwipeTouch = {
|
||||||
|
phase: ExternalSwipePhase;
|
||||||
|
// Host-viewport coordinates (the emitter translates iframe-local ones).
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Listener = (touch: ExternalSwipeTouch) => void;
|
||||||
|
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
|
||||||
|
export const subscribeExternalSwipe = (listener: Listener): (() => void) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emitExternalSwipe = (touch: ExternalSwipeTouch): void => {
|
||||||
|
listeners.forEach((listener) => listener(touch));
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { MutableRefObject, useEffect, useRef } from 'react';
|
import { MutableRefObject, useEffect, useRef } from 'react';
|
||||||
import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX } from './geometry';
|
import { COMMIT_FRACTION, DEAD_ZONE_PX, EDGE_GUARD_PX, MIN_COMMIT_PX } from './geometry';
|
||||||
|
import { subscribeExternalSwipe } from './externalSwipeFeed';
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
// The sliding chat CARD element the listeners bind to. Touches outside it
|
// The sliding chat CARD element the listeners bind to. Touches outside it
|
||||||
|
|
@ -23,12 +24,21 @@ type Args = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Rightward "swipe-to-go-back" (interactive pop) driver. Mirrors
|
// Rightward "swipe-to-go-back" (interactive pop) driver. Mirrors
|
||||||
// `useMobileTabsPagerGesture`: single listener on the card root, refs for
|
// `useMobileTabsPagerGesture`: refs for live state, axis-resolve in the
|
||||||
// live state, axis-resolve in the dead-zone, distance threshold-commit on
|
// dead-zone, distance threshold-commit on release with snap-back below it.
|
||||||
// release with snap-back below it. Differences: rightward-only (back, never
|
// Rightward-only (back, never forward — leftward clamps at 0). The card is
|
||||||
// forward — leftward clamps at 0). The card is `touch-action: pan-y`, so the
|
// `touch-action: pan-y`, so the browser reserves horizontal panning for us
|
||||||
// browser reserves horizontal panning for us and our moves stay cancelable
|
// and our moves stay cancelable over the whole screen.
|
||||||
// over the whole screen.
|
//
|
||||||
|
// TWO input sources feed ONE state machine (begin/move/end/cancel below):
|
||||||
|
// 1. Native touch listeners on the card root — every React-rendered
|
||||||
|
// surface (chats, AI chat, hero headers).
|
||||||
|
// 2. The external swipe feed (`externalSwipeFeed.ts`) — touches forwarded
|
||||||
|
// by the bridge-bot widget iframes over the validated side-channel,
|
||||||
|
// already mapped into host-viewport coordinates by `BotWidgetMount`.
|
||||||
|
// An iframe consumes its touches natively, so without this feed the
|
||||||
|
// gesture is dead over the widget body. External moves carry no
|
||||||
|
// cancelable browser event — the widget side owns preventDefault.
|
||||||
export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void {
|
export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBack }: Args): void {
|
||||||
const disabledRef = useRef(disabled);
|
const disabledRef = useRef(disabled);
|
||||||
const setDragRef = useRef(setDrag);
|
const setDragRef = useRef(setDrag);
|
||||||
|
|
@ -66,37 +76,42 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
|
||||||
reset();
|
reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchStart = (e: TouchEvent) => {
|
// ── State machine (shared by both input sources) ──────────────────
|
||||||
if (disabledRef.current || e.touches.length !== 1) {
|
|
||||||
springHome();
|
const begin = (x: number, y: number) => {
|
||||||
return;
|
// Heal any orphaned offset FIRST: unlike native touches, the external
|
||||||
}
|
// feed has no guaranteed terminal event — an iframe dying mid-drag
|
||||||
const t = e.touches[0];
|
// (widget crash → destroy() drops the message listener) never posts
|
||||||
|
// 'end', which would otherwise leave the card frozen half-slid with
|
||||||
|
// no recovery (a plain tap's end() sees clean state and no-ops).
|
||||||
|
// springHome is a no-op after a cleanly terminated gesture.
|
||||||
|
springHome();
|
||||||
|
if (disabledRef.current) return;
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
// The L/R edge strip belongs to the Android system back-gesture in
|
// The L/R edge strip belongs to the Android system back-gesture in
|
||||||
// edge-to-edge mode. Ours shares its direction, so we MUST cede it —
|
// edge-to-edge mode. Ours shares its direction, so we MUST cede it —
|
||||||
// a drag must start at least EDGE_GUARD_PX inside the left edge.
|
// a drag must start at least EDGE_GUARD_PX inside the left edge.
|
||||||
if (t.clientX < EDGE_GUARD_PX || t.clientX > vw - EDGE_GUARD_PX) {
|
if (x < EDGE_GUARD_PX || x > vw - EDGE_GUARD_PX) return;
|
||||||
springHome();
|
startX = x;
|
||||||
return;
|
startY = y;
|
||||||
}
|
|
||||||
startX = t.clientX;
|
|
||||||
startY = t.clientY;
|
|
||||||
engaged = false;
|
engaged = false;
|
||||||
bailed = false;
|
bailed = false;
|
||||||
lastDragPx = 0;
|
lastDragPx = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchMove = (e: TouchEvent) => {
|
// `preventDefault` is the source-specific cancelation hook: native
|
||||||
if (e.touches.length !== 1 || disabledRef.current) {
|
// touches pass the cancelable TouchEvent's preventDefault; the external
|
||||||
|
// feed passes undefined (the widget side already prevented its own
|
||||||
|
// default once it resolved the gesture as ours).
|
||||||
|
const move = (x: number, y: number, preventDefault?: () => void) => {
|
||||||
|
if (disabledRef.current) {
|
||||||
springHome();
|
springHome();
|
||||||
bailed = true;
|
bailed = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (startX === null || startY === null || bailed) return;
|
if (startX === null || startY === null || bailed) return;
|
||||||
const t = e.touches[0];
|
const dx = x - startX;
|
||||||
const dx = t.clientX - startX;
|
const dy = y - startY;
|
||||||
const dy = t.clientY - startY;
|
|
||||||
|
|
||||||
if (!engaged) {
|
if (!engaged) {
|
||||||
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
|
if (Math.abs(dx) < DEAD_ZONE_PX && Math.abs(dy) < DEAD_ZONE_PX) return;
|
||||||
|
|
@ -119,7 +134,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
|
||||||
// screen (including over the vertical-scrolling timeline), so the
|
// screen (including over the vertical-scrolling timeline), so the
|
||||||
// gesture engages everywhere, not just over non-scrollable spots. The
|
// gesture engages everywhere, not just over non-scrollable spots. The
|
||||||
// guard is defensive only.
|
// guard is defensive only.
|
||||||
if (e.cancelable) e.preventDefault();
|
preventDefault?.();
|
||||||
|
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
// Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative:
|
// Follow the finger 1:1 to the right; clamp to [0, vw]. Never negative:
|
||||||
|
|
@ -130,7 +145,7 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
|
||||||
setDragRef.current(drag, true);
|
setDragRef.current(drag, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchEnd = () => {
|
const end = () => {
|
||||||
if (!engaged || disabledRef.current) {
|
if (!engaged || disabledRef.current) {
|
||||||
springHome();
|
springHome();
|
||||||
return;
|
return;
|
||||||
|
|
@ -149,20 +164,57 @@ export function useSwipeBackGesture({ rootRef, enabled, disabled, setDrag, onBac
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onTouchCancel = () => {
|
const cancel = () => {
|
||||||
// System cancel (incoming call, scroll take-over, …) never commits.
|
// System cancel (incoming call, scroll take-over, …) never commits.
|
||||||
springHome();
|
springHome();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Source 1: native touches on the card root ─────────────────────
|
||||||
|
|
||||||
|
const onTouchStart = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
springHome();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
begin(t.clientX, t.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchMove = (e: TouchEvent) => {
|
||||||
|
if (e.touches.length !== 1) {
|
||||||
|
springHome();
|
||||||
|
bailed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = e.touches[0];
|
||||||
|
move(t.clientX, t.clientY, () => {
|
||||||
|
if (e.cancelable) e.preventDefault();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onTouchEnd = () => end();
|
||||||
|
const onTouchCancel = () => cancel();
|
||||||
|
|
||||||
root.addEventListener('touchstart', onTouchStart, { passive: true });
|
root.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||||
root.addEventListener('touchmove', onTouchMove, { passive: false });
|
root.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||||
root.addEventListener('touchend', onTouchEnd, { passive: true });
|
root.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||||
root.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
root.addEventListener('touchcancel', onTouchCancel, { passive: true });
|
||||||
|
|
||||||
|
// ── Source 2: the external (widget iframe) feed ───────────────────
|
||||||
|
|
||||||
|
const unsubscribeExternal = subscribeExternalSwipe((touch) => {
|
||||||
|
if (touch.phase === 'start') begin(touch.x, touch.y);
|
||||||
|
else if (touch.phase === 'move') move(touch.x, touch.y);
|
||||||
|
else if (touch.phase === 'end') end();
|
||||||
|
else cancel();
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
root.removeEventListener('touchstart', onTouchStart);
|
root.removeEventListener('touchstart', onTouchStart);
|
||||||
root.removeEventListener('touchmove', onTouchMove);
|
root.removeEventListener('touchmove', onTouchMove);
|
||||||
root.removeEventListener('touchend', onTouchEnd);
|
root.removeEventListener('touchend', onTouchEnd);
|
||||||
root.removeEventListener('touchcancel', onTouchCancel);
|
root.removeEventListener('touchcancel', onTouchCancel);
|
||||||
|
unsubscribeExternal();
|
||||||
};
|
};
|
||||||
// setDrag / onBack are mirrored via refs so the listener never re-binds
|
// setDrag / onBack are mirrored via refs so the listener never re-binds
|
||||||
// mid-gesture; `enabled` is a real dep so toggling it binds/unbinds.
|
// mid-gesture; `enabled` is a real dep so toggling it binds/unbinds.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { useEffect, useCallback, FormEventHandler } from 'react';
|
import React, { useEffect, useCallback, FormEventHandler } from 'react';
|
||||||
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
|
import { Dialog, Text, Box, Button, config, Input, color, Spinner } from 'folds';
|
||||||
import { AuthType, MatrixError } from 'matrix-js-sdk';
|
import { AuthType, MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { StageComponentProps } from './types';
|
import { StageComponentProps } from './types';
|
||||||
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
|
import { AsyncState, AsyncStatus } from '../../hooks/useAsyncCallback';
|
||||||
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
|
import { RequestEmailTokenCallback, RequestEmailTokenResponse } from '../../hooks/types';
|
||||||
|
|
@ -18,13 +19,14 @@ function EmailErrorDialog({
|
||||||
onRetry: (email: string) => void;
|
onRetry: (email: string) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
const { retryEmailInput } = evt.target as HTMLFormElement & {
|
const { retryEmailInput } = evt.target as HTMLFormElement & {
|
||||||
retryEmailInput: HTMLInputElement;
|
retryEmailInput: HTMLInputElement;
|
||||||
};
|
};
|
||||||
const t = retryEmailInput.value;
|
onRetry(retryEmailInput.value);
|
||||||
onRetry(t);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -40,7 +42,7 @@ function EmailErrorDialog({
|
||||||
<Text size="H4">{title}</Text>
|
<Text size="H4">{title}</Text>
|
||||||
<Text>{message}</Text>
|
<Text>{message}</Text>
|
||||||
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
|
<Text as="label" size="L400" style={{ paddingTop: config.space.S400 }}>
|
||||||
Email
|
{t('Auth.uia_email_label')}
|
||||||
</Text>
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
name="retryEmailInput"
|
name="retryEmailInput"
|
||||||
|
|
@ -53,12 +55,12 @@ function EmailErrorDialog({
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="Primary" type="submit">
|
<Button variant="Primary" type="submit">
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
Send Verification Email
|
{t('Auth.uia_email_send_button')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
|
<Button variant="Critical" fill="None" outlined type="button" onClick={onCancel}>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
Cancel
|
{t('Auth.uia_email_cancel')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -80,6 +82,7 @@ export function EmailStageDialog({
|
||||||
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
|
emailTokenState: AsyncState<RequestEmailTokenResponse, MatrixError>;
|
||||||
requestEmailToken: RequestEmailTokenCallback;
|
requestEmailToken: RequestEmailTokenCallback;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { errorCode, error, session } = stageData;
|
const { errorCode, error, session } = stageData;
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
|
|
@ -115,7 +118,7 @@ export function EmailStageDialog({
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" alignItems="Center" gap="400">
|
<Box direction="Column" alignItems="Center" gap="400">
|
||||||
<Spinner variant="Secondary" size="600" />
|
<Spinner variant="Secondary" size="600" />
|
||||||
<Text style={{ color: color.Secondary.Main }}>Sending verification email...</Text>
|
<Text style={{ color: color.Secondary.Main }}>{t('Auth.uia_email_sending')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -123,11 +126,11 @@ export function EmailStageDialog({
|
||||||
if (emailTokenState.status === AsyncStatus.Error) {
|
if (emailTokenState.status === AsyncStatus.Error) {
|
||||||
return (
|
return (
|
||||||
<EmailErrorDialog
|
<EmailErrorDialog
|
||||||
title={emailTokenState.error.errcode ?? 'Verify Email'}
|
title={emailTokenState.error.errcode ?? t('Auth.uia_email_verify_title')}
|
||||||
message={
|
message={
|
||||||
emailTokenState.error?.data?.error ??
|
emailTokenState.error?.data?.error ??
|
||||||
emailTokenState.error.message ??
|
emailTokenState.error.message ??
|
||||||
'Failed to send verification Email request.'
|
t('Auth.uia_email_send_error')
|
||||||
}
|
}
|
||||||
onRetry={handleEmailSubmit}
|
onRetry={handleEmailSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
|
|
@ -140,8 +143,8 @@ export function EmailStageDialog({
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="H4">Verification Request Sent</Text>
|
<Text size="H4">{t('Auth.uia_email_sent_title')}</Text>
|
||||||
<Text>{`Please check your email "${emailTokenState.data.email}" and validate before continuing further.`}</Text>
|
<Text>{t('Auth.uia_email_sent_message', { email: emailTokenState.data.email })}</Text>
|
||||||
|
|
||||||
{errorCode && (
|
{errorCode && (
|
||||||
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
|
<Text style={{ color: color.Critical.Main }}>{`${errorCode}: ${error}`}</Text>
|
||||||
|
|
@ -149,7 +152,7 @@ export function EmailStageDialog({
|
||||||
</Box>
|
</Box>
|
||||||
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
|
<Button variant="Primary" onClick={() => handleSubmit(emailTokenState.data.result.sid)}>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
Continue
|
{t('Auth.uia_email_continue')}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -160,8 +163,8 @@ export function EmailStageDialog({
|
||||||
if (!email) {
|
if (!email) {
|
||||||
return (
|
return (
|
||||||
<EmailErrorDialog
|
<EmailErrorDialog
|
||||||
title="Provide Email"
|
title={t('Auth.uia_email_provide_title')}
|
||||||
message="Please provide email to send verification request."
|
message={t('Auth.uia_email_provide_message')}
|
||||||
onRetry={handleEmailSubmit}
|
onRetry={handleEmailSubmit}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
|
||||||
|
|
||||||
export const UploadBoardBase = style([
|
|
||||||
DefaultReset,
|
|
||||||
{
|
|
||||||
position: 'relative',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const UploadBoardContainer = style([
|
|
||||||
DefaultReset,
|
|
||||||
{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: config.space.S200,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
zIndex: config.zIndex.Max,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
export const UploadBoard = style({
|
|
||||||
maxWidth: toRem(400),
|
|
||||||
width: '100%',
|
|
||||||
maxHeight: toRem(450),
|
|
||||||
height: '100%',
|
|
||||||
backgroundColor: color.Surface.Container,
|
|
||||||
color: color.Surface.OnContainer,
|
|
||||||
borderRadius: config.radii.R400,
|
|
||||||
boxShadow: config.shadow.E200,
|
|
||||||
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
|
||||||
overflow: 'hidden',
|
|
||||||
pointerEvents: 'all',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UploadBoardHeaderContent = style({
|
|
||||||
height: '100%',
|
|
||||||
padding: `0 ${config.space.S200}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const UploadBoardContent = style({
|
|
||||||
padding: config.space.S200,
|
|
||||||
paddingBottom: 0,
|
|
||||||
paddingRight: 0,
|
|
||||||
});
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
|
|
||||||
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
|
|
||||||
import * as css from './UploadBoard.css';
|
|
||||||
import { TUploadFamilyObserverAtom, Upload, UploadStatus, UploadSuccess } from '../../state/upload';
|
|
||||||
|
|
||||||
type UploadBoardProps = {
|
|
||||||
header: ReactNode;
|
|
||||||
};
|
|
||||||
export const UploadBoard = as<'div', UploadBoardProps>(({ header, children, ...props }, ref) => (
|
|
||||||
<Box className={css.UploadBoardBase} {...props} ref={ref}>
|
|
||||||
<Box className={css.UploadBoardContainer} justifyContent="End">
|
|
||||||
<Box className={classNames(css.UploadBoard)} direction="Column">
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
<Box direction="Column" shrink="No">
|
|
||||||
{header}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
|
|
||||||
export type UploadBoardImperativeHandlers = { handleSend: () => Promise<void> };
|
|
||||||
|
|
||||||
type UploadBoardHeaderProps = {
|
|
||||||
open: boolean;
|
|
||||||
onToggle: () => void;
|
|
||||||
uploadFamilyObserverAtom: TUploadFamilyObserverAtom;
|
|
||||||
onCancel: (uploads: Upload[]) => void;
|
|
||||||
onSend: (uploads: UploadSuccess[]) => Promise<void>;
|
|
||||||
imperativeHandlerRef: MutableRefObject<UploadBoardImperativeHandlers | undefined>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function UploadBoardHeader({
|
|
||||||
open,
|
|
||||||
onToggle,
|
|
||||||
uploadFamilyObserverAtom,
|
|
||||||
onCancel,
|
|
||||||
onSend,
|
|
||||||
imperativeHandlerRef,
|
|
||||||
}: UploadBoardHeaderProps) {
|
|
||||||
const sendingRef = useRef(false);
|
|
||||||
const uploads = useAtomValue(uploadFamilyObserverAtom);
|
|
||||||
|
|
||||||
const isSuccess = uploads.every((upload) => upload.status === UploadStatus.Success);
|
|
||||||
const isError = uploads.some((upload) => upload.status === UploadStatus.Error);
|
|
||||||
const progress = uploads.reduce(
|
|
||||||
(acc, upload) => {
|
|
||||||
acc.total += upload.file.size;
|
|
||||||
if (upload.status === UploadStatus.Loading) {
|
|
||||||
acc.loaded += upload.progress.loaded;
|
|
||||||
}
|
|
||||||
if (upload.status === UploadStatus.Success) {
|
|
||||||
acc.loaded += upload.file.size;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{ loaded: 0, total: 0 }
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSend = async () => {
|
|
||||||
if (sendingRef.current) return;
|
|
||||||
sendingRef.current = true;
|
|
||||||
await onSend(
|
|
||||||
uploads.filter((upload) => upload.status === UploadStatus.Success) as UploadSuccess[]
|
|
||||||
);
|
|
||||||
sendingRef.current = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
useImperativeHandle(imperativeHandlerRef, () => ({
|
|
||||||
handleSend,
|
|
||||||
}));
|
|
||||||
const handleCancel = () => onCancel(uploads);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Header size="400">
|
|
||||||
<Box
|
|
||||||
as="button"
|
|
||||||
style={{ cursor: 'pointer' }}
|
|
||||||
onClick={onToggle}
|
|
||||||
className={css.UploadBoardHeaderContent}
|
|
||||||
alignItems="Center"
|
|
||||||
grow="Yes"
|
|
||||||
gap="100"
|
|
||||||
>
|
|
||||||
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
|
|
||||||
<Text size="H6">Files</Text>
|
|
||||||
</Box>
|
|
||||||
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
|
|
||||||
{isSuccess && (
|
|
||||||
<Chip
|
|
||||||
as="button"
|
|
||||||
onClick={handleSend}
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
after={<Icon src={Icons.Send} size="50" filled />}
|
|
||||||
>
|
|
||||||
<Text size="B300">Send</Text>
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
{isError && !open && (
|
|
||||||
<Badge variant="Critical" fill="Solid" radii="300">
|
|
||||||
<Text size="L400">Upload Failed</Text>
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{!isSuccess && !isError && !open && (
|
|
||||||
<>
|
|
||||||
<Badge variant="Secondary" fill="Solid" radii="Pill">
|
|
||||||
<Text size="L400">{Math.round(percent(0, progress.total, progress.loaded))}%</Text>
|
|
||||||
</Badge>
|
|
||||||
<Spinner variant="Secondary" size="200" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{!isSuccess && open && (
|
|
||||||
<Chip
|
|
||||||
as="button"
|
|
||||||
onClick={handleCancel}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
after={<Icon src={Icons.Cross} size="50" />}
|
|
||||||
>
|
|
||||||
<Text size="B300">{uploads.length === 1 ? 'Remove' : 'Remove All'}</Text>
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UploadBoardContent = as<'div'>(({ className, children, ...props }, ref) => (
|
|
||||||
<Box
|
|
||||||
className={classNames(css.UploadBoardContent, className)}
|
|
||||||
direction="Column"
|
|
||||||
gap="200"
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Box>
|
|
||||||
));
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './UploadBoard';
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
import React, { ReactNode, useEffect } from 'react';
|
|
||||||
import { Box, Chip, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
|
||||||
import { UploadCard, UploadCardError, UploadCardProgress } from './UploadCard';
|
|
||||||
import { UploadStatus, UploadSuccess, useBindUploadAtom } from '../../state/upload';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { TUploadContent } from '../../utils/matrix';
|
|
||||||
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
|
||||||
import {
|
|
||||||
roomUploadAtomFamily,
|
|
||||||
TUploadItem,
|
|
||||||
TUploadMetadata,
|
|
||||||
} from '../../state/room/roomInputDrafts';
|
|
||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
|
||||||
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
|
||||||
|
|
||||||
type PreviewImageProps = {
|
|
||||||
fileItem: TUploadItem;
|
|
||||||
};
|
|
||||||
function PreviewImage({ fileItem }: PreviewImageProps) {
|
|
||||||
const { originalFile, metadata } = fileItem;
|
|
||||||
const fileUrl = useObjectURL(originalFile);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<img
|
|
||||||
style={{
|
|
||||||
objectFit: 'contain',
|
|
||||||
width: '100%',
|
|
||||||
height: toRem(152),
|
|
||||||
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
|
||||||
}}
|
|
||||||
alt={originalFile.name}
|
|
||||||
src={fileUrl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type PreviewVideoProps = {
|
|
||||||
fileItem: TUploadItem;
|
|
||||||
};
|
|
||||||
function PreviewVideo({ fileItem }: PreviewVideoProps) {
|
|
||||||
const { originalFile, metadata } = fileItem;
|
|
||||||
const fileUrl = useObjectURL(originalFile);
|
|
||||||
|
|
||||||
return (
|
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
|
||||||
<video
|
|
||||||
style={{
|
|
||||||
objectFit: 'contain',
|
|
||||||
width: '100%',
|
|
||||||
height: toRem(152),
|
|
||||||
filter: metadata.markedAsSpoiler ? 'blur(44px)' : undefined,
|
|
||||||
}}
|
|
||||||
src={fileUrl}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type MediaPreviewProps = {
|
|
||||||
fileItem: TUploadItem;
|
|
||||||
onSpoiler: (marked: boolean) => void;
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
function MediaPreview({ fileItem, onSpoiler, children }: MediaPreviewProps) {
|
|
||||||
const { originalFile, metadata } = fileItem;
|
|
||||||
const fileUrl = useObjectURL(originalFile);
|
|
||||||
|
|
||||||
return fileUrl ? (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
borderRadius: config.radii.R300,
|
|
||||||
overflow: 'hidden',
|
|
||||||
backgroundColor: 'black',
|
|
||||||
position: 'relative',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
<Box
|
|
||||||
justifyContent="End"
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: config.space.S100,
|
|
||||||
left: config.space.S100,
|
|
||||||
right: config.space.S100,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Chip
|
|
||||||
variant={metadata.markedAsSpoiler ? 'Warning' : 'Secondary'}
|
|
||||||
fill="Soft"
|
|
||||||
radii="Pill"
|
|
||||||
aria-pressed={metadata.markedAsSpoiler}
|
|
||||||
before={<Icon src={Icons.EyeBlind} size="50" />}
|
|
||||||
onClick={() => onSpoiler(!metadata.markedAsSpoiler)}
|
|
||||||
>
|
|
||||||
<Text size="B300">Spoiler</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type UploadCardRendererProps = {
|
|
||||||
isEncrypted?: boolean;
|
|
||||||
fileItem: TUploadItem;
|
|
||||||
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
|
||||||
onRemove: (file: TUploadContent) => void;
|
|
||||||
onComplete?: (upload: UploadSuccess) => void;
|
|
||||||
};
|
|
||||||
export function UploadCardRenderer({
|
|
||||||
isEncrypted,
|
|
||||||
fileItem,
|
|
||||||
setMetadata,
|
|
||||||
onRemove,
|
|
||||||
onComplete,
|
|
||||||
}: UploadCardRendererProps) {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const mediaConfig = useMediaConfig();
|
|
||||||
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
|
||||||
|
|
||||||
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
|
||||||
const { metadata } = fileItem;
|
|
||||||
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
|
||||||
const { file } = upload;
|
|
||||||
const fileSizeExceeded = file.size >= allowSize;
|
|
||||||
|
|
||||||
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
|
||||||
startUpload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSpoiler = (marked: boolean) => {
|
|
||||||
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeUpload = () => {
|
|
||||||
cancelUpload();
|
|
||||||
onRemove(file);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (upload.status === UploadStatus.Success) {
|
|
||||||
onComplete?.(upload);
|
|
||||||
}
|
|
||||||
}, [upload, onComplete]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UploadCard
|
|
||||||
radii="300"
|
|
||||||
before={<Icon src={getFileTypeIcon(Icons, file.type)} />}
|
|
||||||
after={
|
|
||||||
<>
|
|
||||||
{upload.status === UploadStatus.Error && (
|
|
||||||
<Chip
|
|
||||||
as="button"
|
|
||||||
onClick={startUpload}
|
|
||||||
aria-label="Retry Upload"
|
|
||||||
variant="Critical"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
>
|
|
||||||
<Text size="B300">Retry</Text>
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
<IconButton
|
|
||||||
onClick={removeUpload}
|
|
||||||
aria-label="Cancel Upload"
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
size="300"
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Cross} size="200" />
|
|
||||||
</IconButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
bottom={
|
|
||||||
<>
|
|
||||||
{fileItem.originalFile.type.startsWith('image') && (
|
|
||||||
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
|
|
||||||
<PreviewImage fileItem={fileItem} />
|
|
||||||
</MediaPreview>
|
|
||||||
)}
|
|
||||||
{fileItem.originalFile.type.startsWith('video') && (
|
|
||||||
<MediaPreview fileItem={fileItem} onSpoiler={handleSpoiler}>
|
|
||||||
<PreviewVideo fileItem={fileItem} />
|
|
||||||
</MediaPreview>
|
|
||||||
)}
|
|
||||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
|
||||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
|
||||||
)}
|
|
||||||
{upload.status === UploadStatus.Loading && (
|
|
||||||
<UploadCardProgress sentBytes={upload.progress.loaded} totalBytes={file.size} />
|
|
||||||
)}
|
|
||||||
{upload.status === UploadStatus.Error && (
|
|
||||||
<UploadCardError>
|
|
||||||
<Text size="T200">{upload.error.message}</Text>
|
|
||||||
</UploadCardError>
|
|
||||||
)}
|
|
||||||
{upload.status === UploadStatus.Idle && fileSizeExceeded && (
|
|
||||||
<UploadCardError>
|
|
||||||
<Text size="T200">
|
|
||||||
The file size exceeds the limit. Maximum allowed size is{' '}
|
|
||||||
<b>{bytesToSize(allowSize)}</b>, but the uploaded file is{' '}
|
|
||||||
<b>{bytesToSize(file.size)}</b>.
|
|
||||||
</Text>
|
|
||||||
</UploadCardError>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="H6" truncate>
|
|
||||||
{file.name}
|
|
||||||
</Text>
|
|
||||||
{upload.status === UploadStatus.Success && (
|
|
||||||
<Icon style={{ color: color.Success.Main }} src={Icons.Check} size="100" />
|
|
||||||
)}
|
|
||||||
</UploadCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,3 +1,2 @@
|
||||||
export * from './UploadCard';
|
export * from './UploadCard';
|
||||||
export * from './UploadCardRenderer';
|
|
||||||
export * from './CompactUploadCardRenderer';
|
export * from './CompactUploadCardRenderer';
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Chip, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } fr
|
||||||
import React, { MouseEventHandler, useState } from 'react';
|
import React, { MouseEventHandler, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { PowerColorBadge, PowerIcon } from '../power';
|
import { PowerColorBadge, PowerIcon } from '../power';
|
||||||
import { getPowerTagIconSrc } from '../../hooks/useMemberPowerTag';
|
import { getPowerTagIconSrc } from '../../hooks/useMemberPowerTag';
|
||||||
|
|
@ -10,17 +11,19 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
|
||||||
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings';
|
||||||
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
import { SpaceSettingsPage } from '../../state/spaceSettings';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
|
||||||
|
|
||||||
|
// «Manage Powers» only exists for SPACES — the room-settings Permissions
|
||||||
|
// page was removed with the room-settings redesign (power-level editing is
|
||||||
|
// Matrix-protocol surface end users never needed in a chat). For ordinary
|
||||||
|
// rooms the creator chip is a plain, non-interactive label.
|
||||||
export function CreatorChip() {
|
export function CreatorChip() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const openRoomSettings = useOpenRoomSettings();
|
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
|
|
@ -33,6 +36,31 @@ export function CreatorChip() {
|
||||||
|
|
||||||
const close = () => setCords(undefined);
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
|
const isSpace = room.isSpaceRoom();
|
||||||
|
|
||||||
|
const chip = (
|
||||||
|
<Chip
|
||||||
|
// Outside spaces the chip is a plain badge — render it as a <span>
|
||||||
|
// so keyboard users don't tab onto a button that does nothing.
|
||||||
|
as={isSpace ? 'button' : 'span'}
|
||||||
|
variant="Success"
|
||||||
|
outlined
|
||||||
|
radii="Pill"
|
||||||
|
before={
|
||||||
|
cords ? <Icon size="50" src={Icons.ChevronBottom} /> : <PowerColorBadge color={tag.color} />
|
||||||
|
}
|
||||||
|
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
|
||||||
|
onClick={isSpace ? open : undefined}
|
||||||
|
aria-pressed={isSpace ? !!cords : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isSpace) return chip;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopOut
|
<PopOut
|
||||||
anchor={cords}
|
anchor={cords}
|
||||||
|
|
@ -58,44 +86,18 @@ export function CreatorChip() {
|
||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (room.isSpaceRoom()) {
|
openSpaceSettings(room.roomId, space?.roomId, SpaceSettingsPage.PermissionsPage);
|
||||||
openSpaceSettings(
|
|
||||||
room.roomId,
|
|
||||||
space?.roomId,
|
|
||||||
SpaceSettingsPage.PermissionsPage
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
openRoomSettings(room.roomId, space?.roomId, RoomSettingsPage.PermissionsPage);
|
|
||||||
}
|
|
||||||
close();
|
close();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text size="B300">Manage Powers</Text>
|
<Text size="B300">{t('User.manage_powers')}</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</div>
|
</div>
|
||||||
</Menu>
|
</Menu>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Chip
|
{chip}
|
||||||
variant="Success"
|
|
||||||
outlined
|
|
||||||
radii="Pill"
|
|
||||||
before={
|
|
||||||
cords ? (
|
|
||||||
<Icon size="50" src={Icons.ChevronBottom} />
|
|
||||||
) : (
|
|
||||||
<PowerColorBadge color={tag.color} />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
after={tagIconSrc ? <PowerIcon size="50" iconSrc={tagIconSrc} /> : undefined}
|
|
||||||
onClick={open}
|
|
||||||
aria-pressed={!!cords}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
{tag.name}
|
|
||||||
</Text>
|
|
||||||
</Chip>
|
|
||||||
</PopOut>
|
</PopOut>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { PowerColorBadge, PowerIcon } from '../power';
|
import { PowerColorBadge, PowerIcon } from '../power';
|
||||||
|
|
@ -30,8 +31,6 @@ import { useGetMemberPowerLevel, usePowerLevels } from '../../hooks/usePowerLeve
|
||||||
import { getPowers, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { getPowers, usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { CutoutCard } from '../cutout-card';
|
import { CutoutCard } from '../cutout-card';
|
||||||
|
|
@ -145,11 +144,11 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PowerChip({ userId }: { userId: string }) {
|
export function PowerChip({ userId }: { userId: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const openRoomSettings = useOpenRoomSettings();
|
|
||||||
const openSpaceSettings = useOpenSpaceSettings();
|
const openSpaceSettings = useOpenSpaceSettings();
|
||||||
|
|
||||||
const powerLevels = usePowerLevels(room);
|
const powerLevels = usePowerLevels(room);
|
||||||
|
|
@ -285,33 +284,33 @@ export function PowerChip({ userId }: { userId: string }) {
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Box>
|
</Box>
|
||||||
<Line size="300" />
|
{/* «Manage Powers» only for spaces — the room-settings
|
||||||
<div style={{ padding: config.space.S100 }}>
|
Permissions page was removed with the room-settings
|
||||||
<MenuItem
|
redesign; role assignment for rooms happens right here
|
||||||
variant="Surface"
|
via the tag list above. */}
|
||||||
fill="None"
|
{room.isSpaceRoom() && (
|
||||||
size="300"
|
<>
|
||||||
radii="300"
|
<Line size="300" />
|
||||||
onClick={() => {
|
<div style={{ padding: config.space.S100 }}>
|
||||||
if (room.isSpaceRoom()) {
|
<MenuItem
|
||||||
openSpaceSettings(
|
variant="Surface"
|
||||||
room.roomId,
|
fill="None"
|
||||||
space?.roomId,
|
size="300"
|
||||||
SpaceSettingsPage.PermissionsPage
|
radii="300"
|
||||||
);
|
onClick={() => {
|
||||||
} else {
|
openSpaceSettings(
|
||||||
openRoomSettings(
|
room.roomId,
|
||||||
room.roomId,
|
space?.roomId,
|
||||||
space?.roomId,
|
SpaceSettingsPage.PermissionsPage
|
||||||
RoomSettingsPage.PermissionsPage
|
);
|
||||||
);
|
close();
|
||||||
}
|
}}
|
||||||
close();
|
>
|
||||||
}}
|
<Text size="B300">{t('User.manage_powers')}</Text>
|
||||||
>
|
</MenuItem>
|
||||||
<Text size="B300">Manage Powers</Text>
|
</div>
|
||||||
</MenuItem>
|
</>
|
||||||
</div>
|
)}
|
||||||
</Menu>
|
</Menu>
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ import { copyToClipboard } from '../../utils/dom';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
import { factoryRoomIdByAtoZ } from '../../utils/sort';
|
||||||
import { openExternalUrl } from '../../utils/capacitor';
|
import { openExternalUrl } from '../../utils/capacitor';
|
||||||
import { getMxIdServer } from '../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix';
|
||||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||||
import { nameInitials } from '../../utils/common';
|
import { nameInitials } from '../../utils/common';
|
||||||
import { getExploreServerPath } from '../../pages/pathUtils';
|
import { getExploreServerPath } from '../../pages/pathUtils';
|
||||||
|
|
@ -80,7 +80,14 @@ export function InfoRow({ label, value, trailing, title }: InfoRowProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── id (handle + share menu) ─────────────────────────────────────
|
// ── id (nick + share menu) ───────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The value is the LOCALPART only — the server half lives inside the
|
||||||
|
// full mxid that «Copy user ID» puts on the clipboard. Showing
|
||||||
|
// `nick:server` here duplicated the server for every same-homeserver
|
||||||
|
// user and read as protocol noise (user request, redesign item 14).
|
||||||
|
// The trailing ⋯ menu carries exactly two actions: copy the full
|
||||||
|
// `@nick:server` and copy the matrix.to link.
|
||||||
|
|
||||||
export function IdRow({ userId }: { userId: string }) {
|
export function IdRow({ userId }: { userId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -92,7 +99,7 @@ export function IdRow({ userId }: { userId: string }) {
|
||||||
};
|
};
|
||||||
const close = () => setCords(undefined);
|
const close = () => setCords(undefined);
|
||||||
|
|
||||||
const handle = userId.replace(/^@/, '');
|
const handle = getMxIdLocalPart(userId) ?? userId;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PopOut
|
<PopOut
|
||||||
|
|
@ -477,4 +484,3 @@ export function MutualRoomsRow({ userId }: { userId: string }) {
|
||||||
</PopOut>
|
</PopOut>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
|
import { Box, Button, color, config, Icon, Icons, Spinner, Text, Input } from 'folds';
|
||||||
import React, { useCallback, useRef } from 'react';
|
import React, { useCallback, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { CutoutCard } from '../cutout-card';
|
import { CutoutCard } from '../cutout-card';
|
||||||
import { SettingTile } from '../setting-tile';
|
import { SettingTile } from '../setting-tile';
|
||||||
|
|
@ -14,6 +15,7 @@ type UserKickAlertProps = {
|
||||||
ts?: number;
|
ts?: number;
|
||||||
};
|
};
|
||||||
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const time = ts ? timeHourMinute(ts) : undefined;
|
const time = ts ? timeHourMinute(ts) : undefined;
|
||||||
const date = ts ? timeDayMonYear(ts) : undefined;
|
const date = ts ? timeDayMonYear(ts) : undefined;
|
||||||
|
|
||||||
|
|
@ -22,7 +24,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||||
<SettingTile>
|
<SettingTile>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Box gap="200" justifyContent="SpaceBetween">
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
<Text size="L400">Kicked User</Text>
|
<Text size="L400">{t('User.moderation_kicked_user')}</Text>
|
||||||
{time && date && (
|
{time && date && (
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{date} {time}
|
{date} {time}
|
||||||
|
|
@ -32,16 +34,16 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
{kickedBy && (
|
{kickedBy && (
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
Kicked by: <b>{kickedBy}</b>
|
{t('User.moderation_kicked_by')} <b>{kickedBy}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{reason ? (
|
{reason ? (
|
||||||
<>
|
<>
|
||||||
Reason: <b>{reason}</b>
|
{t('User.moderation_reason_label')} <b>{reason}</b>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<i>No Reason Provided.</i>
|
<i>{t('User.moderation_no_reason')}</i>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -59,6 +61,7 @@ type UserBanAlertProps = {
|
||||||
ts?: number;
|
ts?: number;
|
||||||
};
|
};
|
||||||
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const time = ts ? timeHourMinute(ts) : undefined;
|
const time = ts ? timeHourMinute(ts) : undefined;
|
||||||
|
|
@ -77,7 +80,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
|
||||||
<SettingTile>
|
<SettingTile>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Box gap="200" justifyContent="SpaceBetween">
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
<Text size="L400">Banned User</Text>
|
<Text size="L400">{t('User.moderation_banned_user')}</Text>
|
||||||
{time && date && (
|
{time && date && (
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{date} {time}
|
{date} {time}
|
||||||
|
|
@ -87,16 +90,16 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
{bannedBy && (
|
{bannedBy && (
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
Banned by: <b>{bannedBy}</b>
|
{t('User.moderation_banned_by')} <b>{bannedBy}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{reason ? (
|
{reason ? (
|
||||||
<>
|
<>
|
||||||
Reason: <b>{reason}</b>
|
{t('User.moderation_reason_label')} <b>{reason}</b>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<i>No Reason Provided.</i>
|
<i>{t('User.moderation_no_reason')}</i>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -114,7 +117,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
|
||||||
before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
|
before={banning && <Spinner size="100" variant="Critical" fill="Solid" />}
|
||||||
disabled={banning}
|
disabled={banning}
|
||||||
>
|
>
|
||||||
<Text size="B300">Unban</Text>
|
<Text size="B300">{t('User.moderation_unban')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -131,6 +134,7 @@ type UserInviteAlertProps = {
|
||||||
ts?: number;
|
ts?: number;
|
||||||
};
|
};
|
||||||
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const time = ts ? timeHourMinute(ts) : undefined;
|
const time = ts ? timeHourMinute(ts) : undefined;
|
||||||
|
|
@ -149,7 +153,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
|
||||||
<SettingTile>
|
<SettingTile>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Box gap="200" justifyContent="SpaceBetween">
|
<Box gap="200" justifyContent="SpaceBetween">
|
||||||
<Text size="L400">Invited User</Text>
|
<Text size="L400">{t('User.moderation_invited_user')}</Text>
|
||||||
{time && date && (
|
{time && date && (
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{date} {time}
|
{date} {time}
|
||||||
|
|
@ -159,16 +163,16 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
{invitedBy && (
|
{invitedBy && (
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
Invited by: <b>{invitedBy}</b>
|
{t('User.moderation_invited_by')} <b>{invitedBy}</b>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
<Text size="T200">
|
<Text size="T200">
|
||||||
{reason ? (
|
{reason ? (
|
||||||
<>
|
<>
|
||||||
Reason: <b>{reason}</b>
|
{t('User.moderation_reason_label')} <b>{reason}</b>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<i>No Reason Provided.</i>
|
<i>{t('User.moderation_no_reason')}</i>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -188,7 +192,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
|
||||||
before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
|
before={kicking && <Spinner size="100" variant="Success" fill="Soft" />}
|
||||||
disabled={kicking}
|
disabled={kicking}
|
||||||
>
|
>
|
||||||
<Text size="B300">Cancel Invite</Text>
|
<Text size="B300">{t('User.moderation_cancel_invite')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -204,6 +208,7 @@ type UserModerationProps = {
|
||||||
canInvite: boolean;
|
canInvite: boolean;
|
||||||
};
|
};
|
||||||
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
|
export function UserModeration({ userId, canKick, canBan, canInvite }: UserModerationProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const reasonInputRef = useRef<HTMLInputElement>(null);
|
const reasonInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -245,10 +250,10 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
<Text size="L400">Moderation</Text>
|
<Text size="L400">{t('User.moderation_title')}</Text>
|
||||||
<Input
|
<Input
|
||||||
ref={reasonInputRef}
|
ref={reasonInputRef}
|
||||||
placeholder="Reason"
|
placeholder={t('User.moderation_reason')}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Background"
|
variant="Background"
|
||||||
radii="300"
|
radii="300"
|
||||||
|
|
@ -288,7 +293,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
||||||
onClick={invite}
|
onClick={invite}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Text size="B300">Invite</Text>
|
<Text size="B300">{t('User.moderation_invite')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canKick && (
|
{canKick && (
|
||||||
|
|
@ -308,7 +313,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
||||||
onClick={kick}
|
onClick={kick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Text size="B300">Kick</Text>
|
<Text size="B300">{t('User.moderation_kick')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canBan && (
|
{canBan && (
|
||||||
|
|
@ -328,7 +333,7 @@ export function UserModeration({ userId, canKick, canBan, canInvite }: UserModer
|
||||||
onClick={ban}
|
onClick={ban}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
<Text size="B300">Ban</Text>
|
<Text size="B300">{t('User.moderation_ban')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
//
|
//
|
||||||
// • Hero — large gradient avatar, display name, monospaced
|
// • Hero — large gradient avatar, display name, monospaced
|
||||||
// handle, presence + last-seen, optional e2ee badge.
|
// handle, presence + last-seen, optional e2ee badge.
|
||||||
// • Info rows — Fleet-style attribute table: id / server / role /
|
// • Info rows — Fleet-style attribute table: nick / server / role /
|
||||||
// mutual chats. Each row has a trailing affordance
|
// mutual chats. Each row has a trailing affordance
|
||||||
// (popout menu) when there's something useful to do.
|
// (popout menu) when there's something useful to do.
|
||||||
// • Actions — single «Написать» chip in group rooms only
|
// • Actions — single «Написать» chip in group rooms only
|
||||||
|
|
@ -16,7 +16,8 @@
|
||||||
// surface.
|
// surface.
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box } from 'folds';
|
import { Box, Icon, IconButton, Icons } from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { UserHero } from './UserHero';
|
import { UserHero } from './UserHero';
|
||||||
import { IdRow, MutualRoomsRow, RoleRow, ServerRow } from './UserInfoRows';
|
import { IdRow, MutualRoomsRow, RoleRow, ServerRow } from './UserInfoRows';
|
||||||
|
|
@ -51,9 +52,23 @@ type UserRoomProfileProps = {
|
||||||
// and keeps using the full-rail avatar swap inside
|
// and keeps using the full-rail avatar swap inside
|
||||||
// `RoomViewProfilePanel`.
|
// `RoomViewProfilePanel`.
|
||||||
avatarExpanded?: boolean;
|
avatarExpanded?: boolean;
|
||||||
|
// Top-left affordance (redesign item 15). `onBack` renders a back
|
||||||
|
// arrow — used when the profile replaced a parent surface the user
|
||||||
|
// can return to (the group room's members sheet). `onClose` renders
|
||||||
|
// a plain cross on root cards (the 1:1 profile sheet) where there's
|
||||||
|
// nothing to go back to. `onBack` wins when both are passed.
|
||||||
|
onBack?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserRoomProfileProps) {
|
export function UserRoomProfile({
|
||||||
|
userId,
|
||||||
|
onAvatarClick,
|
||||||
|
avatarExpanded,
|
||||||
|
onBack,
|
||||||
|
onClose,
|
||||||
|
}: UserRoomProfileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -132,6 +147,22 @@ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserR
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="500" className={css.CardRoot}>
|
<Box direction="Column" gap="500" className={css.CardRoot}>
|
||||||
|
{/* Top-left back / close — mirror of the actions anchor. Back
|
||||||
|
returns to the parent surface (members sheet); the cross
|
||||||
|
closes a root card. */}
|
||||||
|
{(onBack || onClose) && (
|
||||||
|
<div className={css.CardBackAnchor}>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="Pill"
|
||||||
|
fill="None"
|
||||||
|
onClick={onBack ?? onClose}
|
||||||
|
aria-label={onBack ? t('User.back') : t('Room.close')}
|
||||||
|
>
|
||||||
|
<Icon size="200" src={onBack ? Icons.ArrowLeft : Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Top-right action menu — absolute-positioned so it floats
|
{/* Top-right action menu — absolute-positioned so it floats
|
||||||
above the hero without claiming a layout row. Hosts every
|
above the hero without claiming a layout row. Hosts every
|
||||||
imperative action (Write / Block / Mod). On mobile the
|
imperative action (Write / Block / Mod). On mobile the
|
||||||
|
|
@ -164,6 +195,9 @@ export function UserRoomProfile({ userId, onAvatarClick, avatarExpanded }: UserR
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={css.InfoSection}>
|
<div className={css.InfoSection}>
|
||||||
|
{/* The nick row shows the LOCALPART only (the server half lives in
|
||||||
|
the separate server row below and in the full mxid the ⋯ menu
|
||||||
|
copies — no duplication, redesign item 14). */}
|
||||||
<IdRow userId={userId} />
|
<IdRow userId={userId} />
|
||||||
<ServerRow userId={userId} />
|
<ServerRow userId={userId} />
|
||||||
{userId !== myUserId && <RoleRow roleLabel={roleLabel} emphasis={creator} />}
|
{userId !== myUserId && <RoleRow roleLabel={roleLabel} emphasis={creator} />}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@ export const CardActionsAnchor = style({
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Back / close anchor — top-left mirror of `CardActionsAnchor`. Hosts
|
||||||
|
// the «back to the room card» arrow when the profile was reached from
|
||||||
|
// a group room's members sheet, or a plain close cross on root cards
|
||||||
|
// (1:1 profile sheet) — redesign item 15.
|
||||||
|
export const CardBackAnchor = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Hero ─────────────────────────────────────────────────────────
|
// ── Hero ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const HeroRoot = style({
|
export const HeroRoot = style({
|
||||||
|
|
@ -260,4 +271,3 @@ export const InfoRowMixed = style({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: toRem(8),
|
gap: toRem(8),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import React, { forwardRef, useState } from 'react';
|
import React, { forwardRef, useState } from 'react';
|
||||||
import { Icons, Menu, Spinner } from 'folds';
|
import { Icons, Menu } from 'folds';
|
||||||
import type { Room } from 'matrix-js-sdk';
|
import type { Room } from 'matrix-js-sdk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
|
||||||
getRoomNotificationMode,
|
|
||||||
useRoomsNotificationPreferencesContext,
|
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
|
||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||||
// Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the
|
// Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the
|
||||||
// AI surface's overflow menu is the same popup, styled identically — only the action set differs.
|
// AI surface's overflow menu is the same popup, styled identically — only the action set differs.
|
||||||
import { ActionRow, ActionSectionLine, RowChevron } from '../room/room-actions/RoomActions';
|
import {
|
||||||
|
ActionRow,
|
||||||
|
ActionSectionLine,
|
||||||
|
NotificationsActionRow,
|
||||||
|
RowChevron,
|
||||||
|
} from '../room/room-actions/RoomActions';
|
||||||
import * as css from '../room/room-actions/RoomActions.css';
|
import * as css from '../room/room-actions/RoomActions.css';
|
||||||
|
|
||||||
type AiChatMenuProps = {
|
type AiChatMenuProps = {
|
||||||
|
|
@ -28,9 +28,6 @@ type AiChatMenuProps = {
|
||||||
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||||
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
|
||||||
|
|
||||||
const [promptLeave, setPromptLeave] = useState(false);
|
const [promptLeave, setPromptLeave] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -62,18 +59,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||||
ariaHasPopup
|
ariaHasPopup
|
||||||
trailing={<RowChevron />}
|
trailing={<RowChevron />}
|
||||||
/>
|
/>
|
||||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
<NotificationsActionRow roomId={room.roomId} />
|
||||||
{(handleOpen, opened, changing) => (
|
|
||||||
<ActionRow
|
|
||||||
icon={Icons.Bell}
|
|
||||||
label={t('Room.notifications')}
|
|
||||||
onClick={handleOpen}
|
|
||||||
ariaPressed={opened}
|
|
||||||
ariaHasPopup
|
|
||||||
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</RoomNotificationModeSwitcher>
|
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={Icons.ShieldLock}
|
icon={Icons.ShieldLock}
|
||||||
label={t('Bots.privacy.menu')}
|
label={t('Bots.privacy.menu')}
|
||||||
|
|
@ -93,7 +79,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||||
label={t('Room.leave_room')}
|
label={t('Room.leave_room')}
|
||||||
onClick={() => setPromptLeave(true)}
|
onClick={() => setPromptLeave(true)}
|
||||||
accent="critical"
|
accent="critical"
|
||||||
ariaPressed={promptLeave}
|
ariaExpanded={promptLeave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,9 @@ export function BotShell({ preset, room }: BotShellProps) {
|
||||||
setShowChat(true);
|
setShowChat(true);
|
||||||
}, [setFailed, setShowChat]);
|
}, [setFailed, setShowChat]);
|
||||||
|
|
||||||
|
// Swipe-back over the widget body works via the forwarded touch stream
|
||||||
|
// (widget → `swipe-touch` side-channel → BotWidgetMount →
|
||||||
|
// externalSwipeFeed) — no host-side capture strip needed.
|
||||||
return (
|
return (
|
||||||
<div className={css.Shell}>
|
<div className={css.Shell}>
|
||||||
<BotShellHero preset={preset} room={room} />
|
<BotShellHero preset={preset} room={room} />
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef, useState } from 'react';
|
||||||
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
|
import { Icons, Menu } from 'folds';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import type { Room } from 'matrix-js-sdk';
|
import type { Room } from 'matrix-js-sdk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
@ -8,27 +8,30 @@ import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import { useRoomUnread } from '../../state/hooks/unread';
|
import { useRoomUnread } from '../../state/hooks/unread';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import {
|
|
||||||
getRoomNotificationMode,
|
|
||||||
getRoomNotificationModeIcon,
|
|
||||||
useRoomsNotificationPreferencesContext,
|
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
|
||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
|
||||||
import { markAsRead } from '../../utils/notifications';
|
import { markAsRead } from '../../utils/notifications';
|
||||||
import { botShowChatAtomFamily } from './botExperienceState';
|
import { botShowChatAtomFamily } from './botExperienceState';
|
||||||
|
// Same row vocabulary + popout chrome as the 1:1 chat's ⋮ menu
|
||||||
|
// (RoomActionsMenu) and the AI chat's AiChatMenu — one popup style across
|
||||||
|
// every surface, only the action set differs.
|
||||||
|
import {
|
||||||
|
ActionRow,
|
||||||
|
ActionSectionLine,
|
||||||
|
NotificationsActionRow,
|
||||||
|
RowChevron,
|
||||||
|
} from '../room/room-actions/RoomActions';
|
||||||
|
import * as css from '../room/room-actions/RoomActions.css';
|
||||||
|
|
||||||
type BotShellMenuProps = {
|
type BotShellMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slim dropdown for the bot hero's «Настроить» button. Only items that make
|
// The bridge-bot widget hero's ⋮ menu. Only items that make sense in a 1:1
|
||||||
// sense in a 1:1 with a bridge bot:
|
// with a bridge bot:
|
||||||
// - Show chat (chat-fallback toggle, switches BotExperienceHost branch)
|
// - Show chat (chat-fallback toggle, switches BotExperienceHost branch)
|
||||||
// - Mark as read
|
// - Mark as read
|
||||||
// - Notifications (shared switcher)
|
// - Notifications (inline mode picker)
|
||||||
// - Leave room
|
// - Leave room
|
||||||
// Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are
|
// Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are
|
||||||
// dropped — none apply to a bridge bot's control DM.
|
// dropped — none apply to a bridge bot's control DM.
|
||||||
|
|
@ -38,12 +41,12 @@ export const BotShellMenu = forwardRef<HTMLDivElement, BotShellMenuProps>(
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
|
||||||
// Setter-only — the menu's only job around showChat is to flip it.
|
// Setter-only — the menu's only job around showChat is to flip it.
|
||||||
// useSetAtom skips the value-subscription that useAtom registers.
|
// useSetAtom skips the value-subscription that useAtom registers.
|
||||||
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
|
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
|
||||||
|
|
||||||
|
const [promptLeave, setPromptLeave] = useState(false);
|
||||||
|
|
||||||
const handleShowChat = () => {
|
const handleShowChat = () => {
|
||||||
setShowChat(true);
|
setShowChat(true);
|
||||||
requestClose();
|
requestClose();
|
||||||
|
|
@ -54,80 +57,41 @@ export const BotShellMenu = forwardRef<HTMLDivElement, BotShellMenuProps>(
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}>
|
<Menu ref={ref} variant="Surface" className={css.PopoutMenu}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
{promptLeave && (
|
||||||
<MenuItem
|
<LeaveRoomPrompt
|
||||||
|
roomId={room.roomId}
|
||||||
|
onDone={requestClose}
|
||||||
|
onCancel={() => setPromptLeave(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={css.PopoutGroup}>
|
||||||
|
<ActionRow
|
||||||
|
icon={Icons.Message}
|
||||||
|
label={t('Bots.show_chat')}
|
||||||
onClick={handleShowChat}
|
onClick={handleShowChat}
|
||||||
size="300"
|
trailing={<RowChevron />}
|
||||||
after={<Icon size="100" src={Icons.Message} />}
|
/>
|
||||||
radii="300"
|
<ActionRow
|
||||||
>
|
icon={Icons.CheckTwice}
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
label={t('Room.mark_as_read')}
|
||||||
{t('Bots.show_chat')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.CheckTwice} />}
|
|
||||||
radii="300"
|
|
||||||
disabled={!unread}
|
disabled={!unread}
|
||||||
>
|
/>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<NotificationsActionRow roomId={room.roomId} />
|
||||||
{t('Room.mark_as_read')}
|
</div>
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
<ActionSectionLine />
|
||||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
<div className={css.PopoutGroup}>
|
||||||
{(handleOpen, opened, changing) => (
|
<ActionRow
|
||||||
<MenuItem
|
icon={Icons.ArrowGoLeft}
|
||||||
size="300"
|
label={t('Room.leave_room')}
|
||||||
after={
|
onClick={() => setPromptLeave(true)}
|
||||||
changing ? (
|
accent="critical"
|
||||||
<Spinner size="100" variant="Secondary" />
|
ariaExpanded={promptLeave}
|
||||||
) : (
|
/>
|
||||||
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
|
</div>
|
||||||
)
|
|
||||||
}
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={opened}
|
|
||||||
onClick={handleOpen}
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
{t('Room.notifications')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
)}
|
|
||||||
</RoomNotificationModeSwitcher>
|
|
||||||
</Box>
|
|
||||||
<Line variant="Surface" size="300" />
|
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
<UseStateProvider initial={false}>
|
|
||||||
{(promptLeave, setPromptLeave) => (
|
|
||||||
<>
|
|
||||||
<MenuItem
|
|
||||||
onClick={() => setPromptLeave(true)}
|
|
||||||
variant="Critical"
|
|
||||||
fill="None"
|
|
||||||
size="300"
|
|
||||||
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
|
|
||||||
radii="300"
|
|
||||||
aria-pressed={promptLeave}
|
|
||||||
>
|
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
|
||||||
{t('Room.leave_room')}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
{promptLeave && (
|
|
||||||
<LeaveRoomPrompt
|
|
||||||
roomId={room.roomId}
|
|
||||||
onDone={requestClose}
|
|
||||||
onCancel={() => setPromptLeave(false)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</UseStateProvider>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
</Menu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,13 @@ export type BotWidgetEmbedOptions = {
|
||||||
// the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like
|
// the host's own picker. Plumbed from `BotWidgetMount` (via a ref-shim, like
|
||||||
// `onOpenMatrixToRoom`) where `mx` + navigation are available.
|
// `onOpenMatrixToRoom`) where `mx` + navigation are available.
|
||||||
onAddToChat?: () => void;
|
onAddToChat?: () => void;
|
||||||
|
// Forwarded raw touch stream from the widget (`swipe-touch` action) —
|
||||||
|
// coordinates are IFRAME-local; `BotWidgetMount` maps them into host
|
||||||
|
// viewport space and feeds the swipe-back gesture. An iframe consumes
|
||||||
|
// its touches natively, so this is the only way the
|
||||||
|
// swipe-back-from-widget gesture can see drags that start on the widget
|
||||||
|
// body. Carries no privileges — just pointer coordinates.
|
||||||
|
onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
|
const getBotWidgetId = (preset: BotPreset): string => `vojo-bot-${preset.id}`;
|
||||||
|
|
@ -247,7 +254,14 @@ export class BotWidgetEmbed {
|
||||||
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
// doesn't go through ClientWidgetApi at all — keeps the SDK ignorant
|
||||||
// of our extension and avoids the «unknown action» reply path.
|
// of our extension and avoids the «unknown action» reply path.
|
||||||
//
|
//
|
||||||
// Three actions today:
|
// Four actions today:
|
||||||
|
//
|
||||||
|
// * `swipe-touch` — unidirectional forwarded touch stream driving the
|
||||||
|
// host's swipe-back gesture over the iframe (an iframe consumes its
|
||||||
|
// own touches, so the widget cooperates via `swipe-forward.ts`).
|
||||||
|
// Carries only a phase + viewport-local coordinates; shape-validated
|
||||||
|
// in the handler below, mapped to host coordinates by
|
||||||
|
// `BotWidgetMount`, consumed by `externalSwipeFeed`.
|
||||||
//
|
//
|
||||||
// * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat`
|
// * `add-to-chat` — elevated verb, gated on the `vojo.add_to_chat`
|
||||||
// capability opt-in in config.json. Carries NO url and NO room/mxid:
|
// capability opt-in in config.json. Carries NO url and NO room/mxid:
|
||||||
|
|
@ -272,7 +286,7 @@ export class BotWidgetEmbed {
|
||||||
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
|
// BotWidgetMount with `useNavigate` + `getChannelsSpacePath`). The
|
||||||
// widget never sees a route — it only knows matrix.to URLs.
|
// widget never sees a route — it only knows matrix.to URLs.
|
||||||
//
|
//
|
||||||
// Security gates (defence in depth, apply to BOTH actions):
|
// Security gates (defence in depth, apply to ALL actions):
|
||||||
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
|
// 1. `ev.origin` must equal the widget's pinned origin. WITHOUT this
|
||||||
// check, a compromised widget bundle could `window.location.href
|
// check, a compromised widget bundle could `window.location.href
|
||||||
// = 'https://attacker.example/'` — the browser keeps the same
|
// = 'https://attacker.example/'` — the browser keeps the same
|
||||||
|
|
@ -307,6 +321,20 @@ export class BotWidgetEmbed {
|
||||||
if (!msg || typeof msg !== 'object') return;
|
if (!msg || typeof msg !== 'object') return;
|
||||||
if (msg.api !== 'io.vojo.bot-widget') return;
|
if (msg.api !== 'io.vojo.bot-widget') return;
|
||||||
|
|
||||||
|
// Forwarded touch stream for the swipe-back gesture (see
|
||||||
|
// `onSwipeTouch` in the options). Validated shape only — finite
|
||||||
|
// numbers and a known phase; everything else is silently dropped.
|
||||||
|
if (msg.action === 'swipe-touch') {
|
||||||
|
const data = msg.data as { phase?: unknown; x?: unknown; y?: unknown } | undefined;
|
||||||
|
const phase = data?.phase;
|
||||||
|
const { x, y } = data ?? {};
|
||||||
|
if (phase !== 'start' && phase !== 'move' && phase !== 'end' && phase !== 'cancel') return;
|
||||||
|
if (typeof x !== 'number' || !Number.isFinite(x)) return;
|
||||||
|
if (typeof y !== 'number' || !Number.isFinite(y)) return;
|
||||||
|
this.options.onSwipeTouch?.(phase, x, y);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Elevated verb — must be dispatched BEFORE any url extraction. Its `data`
|
// Elevated verb — must be dispatched BEFORE any url extraction. Its `data`
|
||||||
// is `{}` (no url), so a hoisted `typeof url === 'string'` gate would
|
// is `{}` (no url), so a hoisted `typeof url === 'string'` gate would
|
||||||
// early-return and the verb would never fire (F1). The host gate reads the
|
// early-return and the verb would never fire (F1). The host gate reads the
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { Membership } from '../../../types/matrix/room';
|
import { Membership } from '../../../types/matrix/room';
|
||||||
import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room';
|
import { getRoomToParents, isOneOnOneRoom, isSpace } from '../../utils/room';
|
||||||
|
import { emitExternalSwipe } from '../../components/swipe-back/externalSwipeFeed';
|
||||||
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
import { useBotWidgetEmbed } from './useBotWidgetEmbed';
|
||||||
import { BotAddToChatPicker } from './BotAddToChatPicker';
|
import { BotAddToChatPicker } from './BotAddToChatPicker';
|
||||||
import * as css from './BotWidgetMount.css';
|
import * as css from './BotWidgetMount.css';
|
||||||
|
|
@ -236,6 +237,24 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const handleAddToChat = useCallback(() => setPickerOpen(true), []);
|
const handleAddToChat = useCallback(() => setPickerOpen(true), []);
|
||||||
|
|
||||||
|
// The widget's forwarded touch stream → the swipe-back gesture. The
|
||||||
|
// widget reports IFRAME-local clientX/Y; offset by the mount's viewport
|
||||||
|
// rect so the host-side state machine (edge guard, axis resolve in
|
||||||
|
// `useSwipeBackGesture`) sees true host-viewport coordinates. Always
|
||||||
|
// emitted — the feed only has a subscriber when the swipe overlay is
|
||||||
|
// mounted (mobile + native), everywhere else it's a no-op.
|
||||||
|
const handleSwipeTouch = useCallback(
|
||||||
|
(phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => {
|
||||||
|
const rect = containerRef.current?.getBoundingClientRect();
|
||||||
|
emitExternalSwipe({
|
||||||
|
phase,
|
||||||
|
x: x + (rect?.left ?? 0),
|
||||||
|
y: y + (rect?.top ?? 0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
const { ready } = useBotWidgetEmbed({
|
const { ready } = useBotWidgetEmbed({
|
||||||
containerRef,
|
containerRef,
|
||||||
preset,
|
preset,
|
||||||
|
|
@ -243,6 +262,7 @@ export function BotWidgetMount({ preset, room, onError }: BotWidgetMountProps) {
|
||||||
onError,
|
onError,
|
||||||
onOpenMatrixToRoom: handleOpenMatrixToRoom,
|
onOpenMatrixToRoom: handleOpenMatrixToRoom,
|
||||||
onAddToChat: handleAddToChat,
|
onAddToChat: handleAddToChat,
|
||||||
|
onSwipeTouch: handleSwipeTouch,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track Matrix sync state so the bot loading bar yields to the global
|
// Track Matrix sync state so the bot loading bar yields to the global
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ type UseBotWidgetEmbedOptions = {
|
||||||
// `add-to-chat` verb (host opens its own room picker). Plumbed from
|
// `add-to-chat` verb (host opens its own room picker). Plumbed from
|
||||||
// `BotWidgetMount` where `mx` is available.
|
// `BotWidgetMount` where `mx` is available.
|
||||||
onAddToChat?: () => void;
|
onAddToChat?: () => void;
|
||||||
|
// Forwarded into the embed — the widget's raw touch stream (iframe-local
|
||||||
|
// coordinates) for the swipe-back gesture. `BotWidgetMount` maps the
|
||||||
|
// coordinates and feeds `externalSwipeFeed`.
|
||||||
|
onSwipeTouch?: (phase: 'start' | 'move' | 'end' | 'cancel', x: number, y: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UseBotWidgetEmbedResult = {
|
type UseBotWidgetEmbedResult = {
|
||||||
|
|
@ -40,6 +44,7 @@ export const useBotWidgetEmbed = ({
|
||||||
onError,
|
onError,
|
||||||
onOpenMatrixToRoom,
|
onOpenMatrixToRoom,
|
||||||
onAddToChat,
|
onAddToChat,
|
||||||
|
onSwipeTouch,
|
||||||
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
}: UseBotWidgetEmbedOptions): UseBotWidgetEmbedResult => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
@ -63,6 +68,10 @@ export const useBotWidgetEmbed = ({
|
||||||
// render) so the embed lifecycle effect doesn't remount the iframe.
|
// render) so the embed lifecycle effect doesn't remount the iframe.
|
||||||
const onAddToChatRef = useRef(onAddToChat);
|
const onAddToChatRef = useRef(onAddToChat);
|
||||||
onAddToChatRef.current = onAddToChat;
|
onAddToChatRef.current = onAddToChat;
|
||||||
|
// Same ref indirection for the swipe feed — per-render closure identity
|
||||||
|
// must not remount the iframe.
|
||||||
|
const onSwipeTouchRef = useRef(onSwipeTouch);
|
||||||
|
onSwipeTouchRef.current = onSwipeTouch;
|
||||||
|
|
||||||
// Depend on primitive identity for the embed lifecycle — using `preset`
|
// Depend on primitive identity for the embed lifecycle — using `preset`
|
||||||
// directly would remount the iframe (and re-handshake with the widget)
|
// directly would remount the iframe (and re-handshake with the widget)
|
||||||
|
|
@ -96,6 +105,7 @@ export const useBotWidgetEmbed = ({
|
||||||
// navigate-callback closes over a new render's `mx`/`navigate`.
|
// navigate-callback closes over a new render's `mx`/`navigate`.
|
||||||
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
|
onOpenMatrixToRoom: (target) => onOpenMatrixToRoomRef.current?.(target),
|
||||||
onAddToChat: () => onAddToChatRef.current?.(),
|
onAddToChat: () => onAddToChatRef.current?.(),
|
||||||
|
onSwipeTouch: (phase, x, y) => onSwipeTouchRef.current?.(phase, x, y),
|
||||||
});
|
});
|
||||||
embedRef.current = embed;
|
embedRef.current = embed;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import {
|
import {
|
||||||
|
|
@ -33,6 +34,7 @@ type CallControlsProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
};
|
};
|
||||||
export function CallControls({ callEmbed }: CallControlsProps) {
|
export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const controlRef = useRef<HTMLDivElement>(null);
|
const controlRef = useRef<HTMLDivElement>(null);
|
||||||
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
||||||
|
|
||||||
|
|
@ -140,7 +142,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
onClick={handleSpotlightClick}
|
onClick={handleSpotlightClick}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
{spotlight ? t('Call.view_grid') : t('Call.view_spotlight')}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -150,7 +152,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
onClick={handleReactionsClick}
|
onClick={handleReactionsClick}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
Reactions
|
{t('Call.reactions')}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</>
|
</>
|
||||||
|
|
@ -162,7 +164,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
onClick={handleSettingsClick}
|
onClick={handleSettingsClick}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate>
|
<Text size="B300" truncate>
|
||||||
Settings
|
{t('Call.settings')}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -198,7 +200,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||||
}
|
}
|
||||||
disabled={exiting}
|
disabled={exiting}
|
||||||
>
|
>
|
||||||
<Text size="B400">End</Text>
|
<Text size="B400">{t('Call.end_call')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
import { CallMembership, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
import { Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
|
|
@ -84,6 +85,7 @@ export function CallMemberRenderer({
|
||||||
members: CallMembership[];
|
members: CallMembership[];
|
||||||
max?: number;
|
max?: number;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [viewMore, setViewMore] = useState(false);
|
const [viewMore, setViewMore] = useState(false);
|
||||||
|
|
||||||
const truncatedMembers = viewMore ? members : members.slice(0, 4);
|
const truncatedMembers = viewMore ? members : members.slice(0, 4);
|
||||||
|
|
@ -105,11 +107,11 @@ export function CallMemberRenderer({
|
||||||
<Box grow="Yes" gap="300" alignItems="Center">
|
<Box grow="Yes" gap="300" alignItems="Center">
|
||||||
{viewMore ? (
|
{viewMore ? (
|
||||||
<Text size="L400" truncate>
|
<Text size="L400" truncate>
|
||||||
Collapse
|
{t('Call.collapse')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Text size="L400" truncate>
|
<Text size="L400" truncate>
|
||||||
{remaining === 0 ? `+${remaining} Other` : `+${remaining} Others`}
|
{t('Call.others_count', { count: remaining })}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React, { RefObject, useRef } from 'react';
|
import React, { RefObject, useRef } from 'react';
|
||||||
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
import { Badge, Box, color, Header, Scroll, Text, toRem } from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
import { useCallEmbed, useCallJoined, useCallEmbedPlacementSync } from '../../hooks/useCallEmbed';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { PrescreenControls } from './PrescreenControls';
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
|
|
@ -16,9 +17,10 @@ import { CallControls } from './CallControls';
|
||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
|
|
||||||
function LivekitServerMissingMessage() {
|
function LivekitServerMissingMessage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||||
Your homeserver does not support calling. But you can still join call started by others.
|
{t('Call.homeserver_no_calls')}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -30,6 +32,7 @@ function JoinMessage({
|
||||||
hasParticipant?: boolean;
|
hasParticipant?: boolean;
|
||||||
livekitSupported?: boolean;
|
livekitSupported?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
if (hasParticipant) return null;
|
if (hasParticipant) return null;
|
||||||
|
|
||||||
if (livekitSupported === false) {
|
if (livekitSupported === false) {
|
||||||
|
|
@ -38,28 +41,31 @@ function JoinMessage({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||||
Voice chat’s empty — Be the first to hop in!
|
{t('Call.empty_be_first')}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NoPermissionMessage() {
|
function NoPermissionMessage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
<Text style={{ margin: 'auto' }} size="L400" align="Center">
|
||||||
You don't have permission to join!
|
{t('Call.no_permission_to_join')}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlreadyInCallMessage() {
|
function AlreadyInCallMessage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
|
<Text style={{ margin: 'auto', color: color.Warning.Main }} size="L400" align="Center">
|
||||||
Already in another call — End the current call to join!
|
{t('Call.already_in_other_call')}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallPrescreen() {
|
function CallPrescreen() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const livekitSupported = useLivekitSupport();
|
const livekitSupported = useLivekitSupport();
|
||||||
|
|
@ -86,11 +92,11 @@ function CallPrescreen() {
|
||||||
{hasParticipant && (
|
{hasParticipant && (
|
||||||
<Header size="300">
|
<Header size="300">
|
||||||
<Box grow="Yes" alignItems="Center">
|
<Box grow="Yes" alignItems="Center">
|
||||||
<Text size="L400">Participant</Text>
|
<Text size="L400">{t('Call.participant')}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Badge variant="Critical" fill="Solid" size="400">
|
<Badge variant="Critical" fill="Solid" size="400">
|
||||||
<Text as="span" size="L400" truncate>
|
<Text as="span" size="L400" truncate>
|
||||||
{callMembers.length} Live
|
{t('Call.live_count', { count: callMembers.length })}
|
||||||
</Text>
|
</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { ChatButton, ControlDivider, MicrophoneButton, VideoButton } from './Controls';
|
import { ChatButton, ControlDivider, MicrophoneButton, VideoButton } from './Controls';
|
||||||
|
|
@ -11,6 +12,7 @@ type PrescreenControlsProps = {
|
||||||
canJoin?: boolean;
|
canJoin?: boolean;
|
||||||
};
|
};
|
||||||
export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
const callJoined = useCallJoined(callEmbed);
|
const callJoined = useCallJoined(callEmbed);
|
||||||
|
|
@ -57,7 +59,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text size="B400">Join</Text>
|
<Text size="B400">{t('Call.join')}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
|
||||||
|
|
@ -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';
|
import { color, config, DefaultReset, FocusOutline, toRem } from 'folds';
|
||||||
|
|
||||||
// Dawn settings rail — uppercase tracked muted labels, raised active row with a
|
// 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([
|
export const NavItem = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
FocusOutline,
|
FocusOutline,
|
||||||
|
|
@ -43,40 +48,27 @@ export const NavItem = style([
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: config.space.S300,
|
gap: config.space.S300,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: `${config.space.S300} ${config.space.S300}`,
|
minHeight: toRem(46),
|
||||||
|
padding: `${config.space.S200} ${config.space.S300}`,
|
||||||
marginBottom: toRem(2),
|
marginBottom: toRem(2),
|
||||||
borderRadius: config.radii.R400,
|
borderRadius: toRem(12),
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 120ms ease-out',
|
||||||
selectors: {
|
selectors: {
|
||||||
'&:hover': { backgroundColor: color.Background.ContainerHover },
|
'&:active': { backgroundColor: color.Background.ContainerActive },
|
||||||
'&[aria-pressed=true]': { 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({
|
globalStyle(`:root[data-input="mouse"] ${NavItem}:hover`, {
|
||||||
opacity: 0.6,
|
backgroundColor: color.Background.ContainerHover,
|
||||||
selectors: {
|
|
||||||
[`${NavItem}[aria-pressed=true] &`]: {
|
|
||||||
color: color.Primary.Main,
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const NavItemLabel = style({
|
export const NavItemLabel = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
fontWeight: config.fontWeight.W500,
|
fontWeight: config.fontWeight.W500,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${NavItem}[aria-pressed=true] &`]: {
|
[`${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 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';
|
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 = {
|
type SettingsNavItemProps = {
|
||||||
icon: IconSrc;
|
icon: IconSrc;
|
||||||
label: string;
|
label: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
|
tint?: SettingsNavTint;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
export function SettingsNavItem({ icon, label, active, onClick }: SettingsNavItemProps) {
|
export function SettingsNavItem({ icon, label, active, tint, onClick }: SettingsNavItemProps) {
|
||||||
return (
|
return (
|
||||||
<button type="button" className={css.NavItem} aria-pressed={active} onClick={onClick}>
|
<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>
|
<Text className={css.NavItemLabel} as="span" size="T300" truncate>
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Icon className={css.NavItemChevron} src={Icons.ChevronRight} size="100" />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||||
import type { AccountDataEvents, StateEvents } from 'matrix-js-sdk';
|
import type { AccountDataEvents, StateEvents } from 'matrix-js-sdk';
|
||||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
|
|
@ -150,6 +151,7 @@ const useCanDropLobbyItem = (
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Lobby() {
|
export function Lobby() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const mDirects = useAtomValue(mDirectAtom);
|
const mDirects = useAtomValue(mDirectAtom);
|
||||||
|
|
@ -459,7 +461,7 @@ export function Lobby() {
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
outlined
|
outlined
|
||||||
size="300"
|
size="300"
|
||||||
aria-label="Scroll to Top"
|
aria-label={t('Room.lobby_scroll_to_top')}
|
||||||
>
|
>
|
||||||
<Icon src={Icons.ChevronTop} size="300" />
|
<Icon src={Icons.ChevronTop} size="300" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
@ -535,7 +537,7 @@ export function Lobby() {
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
before={<Spinner variant="Secondary" fill="Soft" size="100" />}
|
before={<Spinner variant="Secondary" fill="Soft" size="100" />}
|
||||||
>
|
>
|
||||||
<Text size="L400">Reordering</Text>
|
<Text size="L400">{t('Room.lobby_reordering')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
import { useSetSetting } from '../../state/hooks/settings';
|
import { useSetSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
|
|
@ -45,6 +46,7 @@ type LobbyMenuProps = {
|
||||||
};
|
};
|
||||||
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||||
({ powerLevels, requestClose }, ref) => {
|
({ powerLevels, requestClose }, ref) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const creators = useRoomCreators(space);
|
const creators = useRoomCreators(space);
|
||||||
|
|
@ -87,7 +89,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||||
disabled={!canInvite}
|
disabled={!canInvite}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
Invite
|
{t('Room.invite')}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
@ -97,7 +99,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||||
radii="300"
|
radii="300"
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
Space Settings
|
{t('Room.lobby_space_settings')}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -116,7 +118,7 @@ const LobbyMenu = forwardRef<HTMLDivElement, LobbyMenuProps>(
|
||||||
aria-pressed={promptLeave}
|
aria-pressed={promptLeave}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
Leave Space
|
{t('Room.lobby_leave_space')}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
{promptLeave && (
|
{promptLeave && (
|
||||||
|
|
@ -140,6 +142,7 @@ type LobbyHeaderProps = {
|
||||||
powerLevels: IPowerLevels;
|
powerLevels: IPowerLevels;
|
||||||
};
|
};
|
||||||
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
|
|
@ -213,7 +216,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
||||||
offset={4}
|
offset={4}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text>Members</Text>
|
<Text>{t('Room.members')}</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -234,7 +237,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) {
|
||||||
offset={4}
|
offset={4}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Text>More Options</Text>
|
<Text>{t('Room.more_options')}</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import {
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
||||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
|
|
@ -44,6 +45,7 @@ type RoomJoinButtonProps = {
|
||||||
via?: string[];
|
via?: string[];
|
||||||
};
|
};
|
||||||
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||||
|
|
@ -91,7 +93,7 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||||
onClick={join}
|
onClick={join}
|
||||||
disabled={!canJoin}
|
disabled={!canJoin}
|
||||||
>
|
>
|
||||||
<Text size="B300">Join</Text>
|
<Text size="B300">{t('Room.lobby_join')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
@ -127,6 +129,7 @@ type RoomProfileErrorProps = {
|
||||||
via?: string[];
|
via?: string[];
|
||||||
};
|
};
|
||||||
function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
|
function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProfileErrorProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" gap="300">
|
<Box grow="Yes" gap="300">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
|
|
@ -146,12 +149,12 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
|
||||||
<Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
|
<Box grow="Yes" direction="Column" className={css.ErrorNameContainer}>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
<Text size="H5" truncate>
|
<Text size="H5" truncate>
|
||||||
Unknown
|
{t('Room.lobby_unknown')}
|
||||||
</Text>
|
</Text>
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<Box shrink="No" alignItems="Center">
|
<Box shrink="No" alignItems="Center">
|
||||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||||
<Text size="L400">Suggested</Text>
|
<Text size="L400">{t('Room.lobby_suggested')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
@ -159,7 +162,7 @@ function RoomProfileError({ roomId, suggested, inaccessibleRoom, via }: RoomProf
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
{inaccessibleRoom ? (
|
{inaccessibleRoom ? (
|
||||||
<Badge variant="Secondary" fill="Soft" radii="300" size="500">
|
<Badge variant="Secondary" fill="Soft" radii="300" size="500">
|
||||||
<Text size="L400">Inaccessible</Text>
|
<Text size="L400">{t('Room.lobby_inaccessible')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
) : (
|
) : (
|
||||||
<Text size="T200" truncate>
|
<Text size="T200" truncate>
|
||||||
|
|
@ -195,6 +198,7 @@ function RoomProfile({
|
||||||
joinRule,
|
joinRule,
|
||||||
options,
|
options,
|
||||||
}: RoomProfileProps) {
|
}: RoomProfileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" gap="300">
|
<Box grow="Yes" gap="300">
|
||||||
<Avatar>
|
<Avatar>
|
||||||
|
|
@ -213,7 +217,7 @@ function RoomProfile({
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<Box shrink="No" alignItems="Center">
|
<Box shrink="No" alignItems="Center">
|
||||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||||
<Text size="L400">Suggested</Text>
|
<Text size="L400">{t('Room.lobby_suggested')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
@ -221,7 +225,12 @@ function RoomProfile({
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
{memberCount && (
|
{memberCount && (
|
||||||
<Box shrink="No" gap="200">
|
<Box shrink="No" gap="200">
|
||||||
<Text size="T200" priority="300">{`${millify(memberCount)} Members`}</Text>
|
<Text size="T200" priority="300">
|
||||||
|
{t('Room.lobby_members_count', {
|
||||||
|
count: memberCount,
|
||||||
|
formattedCount: millify(memberCount),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{memberCount && topic && (
|
{memberCount && topic && (
|
||||||
|
|
@ -311,6 +320,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const { roomId, content } = item;
|
const { roomId, content } = item;
|
||||||
|
|
@ -359,7 +369,7 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
||||||
fill="None"
|
fill="None"
|
||||||
size="400"
|
size="400"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
aria-label="Open Room"
|
aria-label={t('Room.lobby_open_room')}
|
||||||
>
|
>
|
||||||
<Icon size="50" src={Icons.ArrowRight} />
|
<Icon size="50" src={Icons.ArrowRight} />
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ type InaccessibleSpaceProfileProps = {
|
||||||
suggested?: boolean;
|
suggested?: boolean;
|
||||||
};
|
};
|
||||||
function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
|
function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
as="span"
|
as="span"
|
||||||
|
|
@ -72,7 +73,7 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
renderFallback={() => (
|
renderFallback={() => (
|
||||||
<Text as="span" size="H6">
|
<Text as="span" size="H6">
|
||||||
U
|
{nameInitials(t('Room.lobby_unknown'))}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
@ -81,15 +82,15 @@ function InaccessibleSpaceProfile({ roomId, suggested }: InaccessibleSpaceProfil
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
Unknown
|
{t('Room.lobby_unknown')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
|
<Badge variant="Secondary" fill="Soft" radii="Pill" outlined>
|
||||||
<Text size="L400">Inaccessible</Text>
|
<Text size="L400">{t('Room.lobby_inaccessible')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||||
<Text size="L400">Suggested</Text>
|
<Text size="L400">{t('Room.lobby_suggested')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -111,6 +112,7 @@ function UnjoinedSpaceProfile({
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
suggested,
|
suggested,
|
||||||
}: UnjoinedSpaceProfileProps) {
|
}: UnjoinedSpaceProfileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
const [joinState, join] = useAsyncCallback<Room, MatrixError, []>(
|
||||||
|
|
@ -145,11 +147,11 @@ function UnjoinedSpaceProfile({
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{name || 'Unknown'}
|
{name || t('Room.lobby_unknown')}
|
||||||
</Text>
|
</Text>
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||||
<Text size="L400">Suggested</Text>
|
<Text size="L400">{t('Room.lobby_suggested')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{joinState.status === AsyncStatus.Error && (
|
{joinState.status === AsyncStatus.Error && (
|
||||||
|
|
@ -182,6 +184,7 @@ function SpaceProfile({
|
||||||
categoryId,
|
categoryId,
|
||||||
handleClose,
|
handleClose,
|
||||||
}: SpaceProfileProps) {
|
}: SpaceProfileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
data-category-id={categoryId}
|
data-category-id={categoryId}
|
||||||
|
|
@ -211,7 +214,7 @@ function SpaceProfile({
|
||||||
</Text>
|
</Text>
|
||||||
{suggested && (
|
{suggested && (
|
||||||
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
<Badge variant="Success" fill="Soft" radii="Pill" outlined>
|
||||||
<Text size="L400">Suggested</Text>
|
<Text size="L400">{t('Room.lobby_suggested')}</Text>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -225,6 +228,7 @@ type RootSpaceProfileProps = {
|
||||||
handleClose?: MouseEventHandler<HTMLButtonElement>;
|
handleClose?: MouseEventHandler<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
|
function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Chip
|
<Chip
|
||||||
data-category-id={categoryId}
|
data-category-id={categoryId}
|
||||||
|
|
@ -236,7 +240,7 @@ function RootSpaceProfile({ closed, categoryId, handleClose }: RootSpaceProfileP
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
Rooms
|
{t('Room.lobby_rooms')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Chip>
|
</Chip>
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ export function MessageSearch({
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
outlined
|
outlined
|
||||||
size="300"
|
size="300"
|
||||||
aria-label="Scroll to Top"
|
aria-label={t('Room.msg_search_scroll_to_top')}
|
||||||
>
|
>
|
||||||
<Icon src={Icons.ChevronTop} size="300" />
|
<Icon src={Icons.ChevronTop} size="300" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
||||||
|
|
@ -1,176 +1,68 @@
|
||||||
import React, { useMemo, useState } from 'react';
|
import React from 'react';
|
||||||
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Text } from 'folds';
|
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { JoinRule } from 'matrix-js-sdk';
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
import { SettingsSection } from '../settings/SettingsSection';
|
||||||
import {
|
import { usePowerLevels } from '../../hooks/usePowerLevels';
|
||||||
SettingsNavEyebrow,
|
|
||||||
SettingsNavItem,
|
|
||||||
SettingsNavSection,
|
|
||||||
} from '../common-settings/SettingsNav';
|
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|
||||||
import { useRoomAvatar, useRoomJoinRule, useRoomName } from '../../hooks/useRoomMeta';
|
|
||||||
import { isOneOnOneRoom } from '../../utils/room';
|
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
|
||||||
import { General } from './general';
|
|
||||||
import { Members } from '../common-settings/members';
|
|
||||||
import { EmojisStickers } from '../common-settings/emojis-stickers';
|
|
||||||
import { Permissions } from './permissions';
|
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useRoom } from '../../hooks/useRoom';
|
||||||
import { DeveloperTools } from '../common-settings/developer-tools';
|
import {
|
||||||
|
RoomProfile,
|
||||||
type RoomSettingsMenuItem = {
|
RoomEncryption,
|
||||||
page: RoomSettingsPage;
|
RoomHistoryVisibility,
|
||||||
name: string;
|
RoomJoinRules,
|
||||||
icon: IconSrc;
|
RoomVoiceMessages,
|
||||||
};
|
} from '../common-settings/general';
|
||||||
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
const useRoomSettingsMenuItems = (): RoomSettingsMenuItem[] => {
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
const { t } = useTranslation();
|
|
||||||
return useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
page: RoomSettingsPage.GeneralPage,
|
|
||||||
name: t('RoomSettings.general'),
|
|
||||||
icon: Icons.Setting,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: RoomSettingsPage.MembersPage,
|
|
||||||
name: t('RoomSettings.members'),
|
|
||||||
icon: Icons.User,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: RoomSettingsPage.PermissionsPage,
|
|
||||||
name: t('RoomSettings.permissions'),
|
|
||||||
icon: Icons.Lock,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: RoomSettingsPage.EmojisStickersPage,
|
|
||||||
name: t('RoomSettings.emojis_stickers'),
|
|
||||||
icon: Icons.Smile,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
page: RoomSettingsPage.DeveloperToolsPage,
|
|
||||||
name: t('RoomSettings.developer_tools'),
|
|
||||||
icon: Icons.Terminal,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[t]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
type RoomSettingsProps = {
|
type RoomSettingsProps = {
|
||||||
initialPage?: RoomSettingsPage;
|
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) {
|
|
||||||
|
// Single-page room settings, opened from the room's «⋯» menu. No nav
|
||||||
|
// column, no sub-pages: rooms keep only the profile + the four
|
||||||
|
// user-meaningful toggles. Addresses / directory publish / room upgrade /
|
||||||
|
// power levels / emoji packs / developer tools are Matrix-protocol
|
||||||
|
// surfaces end users never needed in a chat — dropped from rooms (spaces
|
||||||
|
// keep the full set in SpaceSettings).
|
||||||
|
export function RoomSettings({ requestClose }: RoomSettingsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
const mx = useMatrixClient();
|
const powerLevels = usePowerLevels(room);
|
||||||
const useAuthentication = useMediaAuthentication();
|
const creators = useRoomCreators(room);
|
||||||
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
// Peer-avatar fallback follows the member-count gate, mirroring the room
|
|
||||||
// header (post-P3c). Settings is a globally-mounted modal, so the
|
|
||||||
// IsOneOnOneProvider context isn't in scope here — call the helper directly.
|
|
||||||
const roomAvatar = useRoomAvatar(room, isOneOnOneRoom(room));
|
|
||||||
const roomName = useRoomName(room);
|
|
||||||
const joinRuleContent = useRoomJoinRule(room);
|
|
||||||
|
|
||||||
const avatarUrl = roomAvatar
|
|
||||||
? mxcUrlToHttp(mx, roomAvatar, useAuthentication, 96, 96, 'crop') ?? undefined
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const screenSize = useScreenSizeContext();
|
|
||||||
const [activePage, setActivePage] = useState<RoomSettingsPage | undefined>(() => {
|
|
||||||
if (initialPage) return initialPage;
|
|
||||||
return screenSize === ScreenSize.Mobile ? undefined : RoomSettingsPage.GeneralPage;
|
|
||||||
});
|
|
||||||
const menuItems = useRoomSettingsMenuItems();
|
|
||||||
|
|
||||||
const handlePageRequestClose = () => {
|
|
||||||
if (screenSize === ScreenSize.Mobile) {
|
|
||||||
setActivePage(undefined);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
requestClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageRoot
|
<Page>
|
||||||
nav={
|
<PageHeader outlined={false}>
|
||||||
screenSize === ScreenSize.Mobile && activePage !== undefined ? undefined : (
|
<Box grow="Yes" gap="200">
|
||||||
<PageNav size="300">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<PageNavHeader outlined={false}>
|
<Text size="H3" truncate>
|
||||||
<Box grow="Yes" gap="300" alignItems="Center">
|
{t('Room.room_settings')}
|
||||||
<Avatar size="300" radii="300">
|
</Text>
|
||||||
<RoomAvatar
|
</Box>
|
||||||
roomId={room.roomId}
|
<Box shrink="No">
|
||||||
src={avatarUrl}
|
<IconButton onClick={requestClose} variant="Surface">
|
||||||
alt={roomName}
|
<Icon src={Icons.Cross} />
|
||||||
renderFallback={() => (
|
</IconButton>
|
||||||
<RoomIcon
|
</Box>
|
||||||
size="50"
|
</Box>
|
||||||
roomType={room.getType()}
|
</PageHeader>
|
||||||
joinRule={joinRuleContent?.join_rule ?? JoinRule.Invite}
|
<Box grow="Yes">
|
||||||
filled
|
<Scroll hideTrack visibility="Hover">
|
||||||
/>
|
<PageContent>
|
||||||
)}
|
<Box direction="Column" gap="700">
|
||||||
/>
|
<RoomProfile permissions={permissions} />
|
||||||
</Avatar>
|
<SettingsSection label={t('RoomSettings.options')}>
|
||||||
<Box direction="Column" grow="Yes">
|
<RoomJoinRules permissions={permissions} />
|
||||||
<SettingsNavEyebrow>{t('RoomSettings.settings')}</SettingsNavEyebrow>
|
<RoomHistoryVisibility permissions={permissions} />
|
||||||
<Text size="H4" truncate>
|
<RoomEncryption permissions={permissions} />
|
||||||
{roomName}
|
<RoomVoiceMessages permissions={permissions} />
|
||||||
</Text>
|
</SettingsSection>
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No">
|
|
||||||
{screenSize === ScreenSize.Mobile && (
|
|
||||||
<IconButton onClick={requestClose} variant="Background">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</PageNavHeader>
|
|
||||||
<Box grow="Yes" direction="Column">
|
|
||||||
<PageNavContent>
|
|
||||||
<div style={{ flexGrow: 1 }}>
|
|
||||||
<SettingsNavSection>{t('RoomSettings.sections')}</SettingsNavSection>
|
|
||||||
{menuItems.map((item) => (
|
|
||||||
<SettingsNavItem
|
|
||||||
key={item.name}
|
|
||||||
icon={item.icon}
|
|
||||||
label={item.name}
|
|
||||||
active={activePage === item.page}
|
|
||||||
onClick={() => setActivePage(item.page)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageNavContent>
|
|
||||||
</Box>
|
</Box>
|
||||||
</PageNav>
|
</PageContent>
|
||||||
)
|
</Scroll>
|
||||||
}
|
</Box>
|
||||||
>
|
</Page>
|
||||||
{activePage === RoomSettingsPage.GeneralPage && (
|
|
||||||
<General requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
|
||||||
{activePage === RoomSettingsPage.MembersPage && (
|
|
||||||
<Members requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
|
||||||
{activePage === RoomSettingsPage.PermissionsPage && (
|
|
||||||
<Permissions requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
|
||||||
{activePage === RoomSettingsPage.EmojisStickersPage && (
|
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
|
||||||
{activePage === RoomSettingsPage.DeveloperToolsPage && (
|
|
||||||
<DeveloperTools requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
|
||||||
</PageRoot>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ type RenderSettingsProps = {
|
||||||
state: RoomSettingsState;
|
state: RoomSettingsState;
|
||||||
};
|
};
|
||||||
function RenderSettings({ state }: RenderSettingsProps) {
|
function RenderSettings({ state }: RenderSettingsProps) {
|
||||||
const { roomId, spaceId, page } = state;
|
const { roomId, spaceId } = state;
|
||||||
const closeSettings = useCloseRoomSettings();
|
const closeSettings = useCloseRoomSettings();
|
||||||
const allJoinedRooms = useAllJoinedRoomsSet();
|
const allJoinedRooms = useAllJoinedRoomsSet();
|
||||||
const getRoom = useGetRoom(allJoinedRooms);
|
const getRoom = useGetRoom(allJoinedRooms);
|
||||||
|
|
@ -21,10 +21,10 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 narrow requestClose={closeSettings}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
<RoomSettings requestClose={closeSettings} />
|
||||||
</RoomProvider>
|
</RoomProvider>
|
||||||
</SpaceProvider>
|
</SpaceProvider>
|
||||||
</Modal500>
|
</Modal500>
|
||||||
|
|
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
|
||||||
import { SettingsSection } from '../../settings/SettingsSection';
|
|
||||||
import { SectionLabel } from '../../settings/styles.css';
|
|
||||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|
||||||
import { useRoom } from '../../../hooks/useRoom';
|
|
||||||
import {
|
|
||||||
RoomProfile,
|
|
||||||
RoomEncryption,
|
|
||||||
RoomHistoryVisibility,
|
|
||||||
RoomJoinRules,
|
|
||||||
RoomLocalAddresses,
|
|
||||||
RoomPublishedAddresses,
|
|
||||||
RoomPublish,
|
|
||||||
RoomUpgrade,
|
|
||||||
RoomVoiceMessages,
|
|
||||||
} from '../../common-settings/general';
|
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
|
||||||
|
|
||||||
type GeneralProps = {
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
export function General({ requestClose }: GeneralProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const room = useRoom();
|
|
||||||
const powerLevels = usePowerLevels(room);
|
|
||||||
const creators = useRoomCreators(room);
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader outlined={false}>
|
|
||||||
<Box grow="Yes" gap="200">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<Text size="H3" truncate>
|
|
||||||
{t('RoomSettings.general')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No">
|
|
||||||
<IconButton onClick={requestClose} variant="Surface">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</PageHeader>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Scroll hideTrack visibility="Hover">
|
|
||||||
<PageContent>
|
|
||||||
<Box direction="Column" gap="700">
|
|
||||||
<RoomProfile permissions={permissions} />
|
|
||||||
<SettingsSection label={t('RoomSettings.options')}>
|
|
||||||
<RoomJoinRules permissions={permissions} />
|
|
||||||
<RoomHistoryVisibility permissions={permissions} />
|
|
||||||
<RoomEncryption permissions={permissions} />
|
|
||||||
<RoomVoiceMessages permissions={permissions} />
|
|
||||||
<RoomPublish permissions={permissions} />
|
|
||||||
</SettingsSection>
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<Text as="span" className={SectionLabel}>
|
|
||||||
{t('RoomSettings.addresses')}
|
|
||||||
</Text>
|
|
||||||
<RoomPublishedAddresses permissions={permissions} />
|
|
||||||
<RoomLocalAddresses permissions={permissions} />
|
|
||||||
</Box>
|
|
||||||
<Box direction="Column" gap="200">
|
|
||||||
<Text as="span" className={SectionLabel}>
|
|
||||||
{t('RoomSettings.advanced_options')}
|
|
||||||
</Text>
|
|
||||||
<RoomUpgrade permissions={permissions} requestClose={requestClose} />
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</PageContent>
|
|
||||||
</Scroll>
|
|
||||||
</Box>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './General';
|
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
import React, { useState } from 'react';
|
|
||||||
import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
|
||||||
import { useRoom } from '../../../hooks/useRoom';
|
|
||||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
||||||
import { StateEvent } from '../../../../types/matrix/room';
|
|
||||||
import { usePermissionGroups } from './usePermissionItems';
|
|
||||||
import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions';
|
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
|
||||||
|
|
||||||
type PermissionsProps = {
|
|
||||||
requestClose: () => void;
|
|
||||||
};
|
|
||||||
export function Permissions({ requestClose }: PermissionsProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const room = useRoom();
|
|
||||||
const powerLevels = usePowerLevels(room);
|
|
||||||
const creators = useRoomCreators(room);
|
|
||||||
|
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
|
||||||
|
|
||||||
const canEditPowers = permissions.stateEvent(StateEvent.PowerLevelTags, mx.getSafeUserId());
|
|
||||||
const canEditPermissions = permissions.stateEvent(StateEvent.RoomPowerLevels, mx.getSafeUserId());
|
|
||||||
const permissionGroups = usePermissionGroups(room.isCallRoom());
|
|
||||||
|
|
||||||
const [powerEditor, setPowerEditor] = useState(false);
|
|
||||||
|
|
||||||
const handleEditPowers = () => {
|
|
||||||
setPowerEditor(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (canEditPowers && powerEditor) {
|
|
||||||
return <PowersEditor powerLevels={powerLevels} requestClose={() => setPowerEditor(false)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Page>
|
|
||||||
<PageHeader outlined={false}>
|
|
||||||
<Box grow="Yes" gap="200">
|
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
|
||||||
<Text size="H3" truncate>
|
|
||||||
{t('RoomSettings.permissions')}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
<Box shrink="No">
|
|
||||||
<IconButton onClick={requestClose} variant="Surface">
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</PageHeader>
|
|
||||||
<Box grow="Yes">
|
|
||||||
<Scroll hideTrack visibility="Hover">
|
|
||||||
<PageContent>
|
|
||||||
<Box direction="Column" gap="700">
|
|
||||||
<Powers
|
|
||||||
powerLevels={powerLevels}
|
|
||||||
onEdit={canEditPowers ? handleEditPowers : undefined}
|
|
||||||
permissionGroups={permissionGroups}
|
|
||||||
/>
|
|
||||||
<PermissionGroups
|
|
||||||
canEdit={canEditPermissions}
|
|
||||||
powerLevels={powerLevels}
|
|
||||||
permissionGroups={permissionGroups}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</PageContent>
|
|
||||||
</Scroll>
|
|
||||||
</Box>
|
|
||||||
</Page>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export * from './Permissions';
|
|
||||||
|
|
@ -1,230 +0,0 @@
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
|
||||||
import { PermissionGroup } from '../../common-settings/permissions';
|
|
||||||
|
|
||||||
export const usePermissionGroups = (isCallRoom: boolean): PermissionGroup[] => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const groups: PermissionGroup[] = useMemo(() => {
|
|
||||||
const messagesGroup: PermissionGroup = {
|
|
||||||
name: t('RoomSettings.perm_messages'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
key: MessageEvent.RoomMessage,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_send_messages'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
key: MessageEvent.Sticker,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_send_stickers'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
key: MessageEvent.Reaction,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_send_reactions'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
notification: true,
|
|
||||||
key: 'room',
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_ping_room'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomPinnedEvents,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_pin_messages'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {},
|
|
||||||
name: t('RoomSettings.perm_other_message_events'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const callSettingsGroup: PermissionGroup = {
|
|
||||||
name: t('RoomSettings.perm_calls'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.GroupCallMemberPrefix,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_join_call'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const moderationGroup: PermissionGroup = {
|
|
||||||
name: t('RoomSettings.perm_moderation'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
action: true,
|
|
||||||
key: 'invite',
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_invite'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
action: true,
|
|
||||||
key: 'kick',
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_kick'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
action: true,
|
|
||||||
key: 'ban',
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_ban'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
action: true,
|
|
||||||
key: 'redact',
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_delete_others_messages'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
key: MessageEvent.RoomRedaction,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_delete_self_messages'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const roomOverviewGroup: PermissionGroup = {
|
|
||||||
name: t('RoomSettings.perm_room_overview'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomAvatar,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_room_avatar'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomName,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_room_name'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomTopic,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_room_topic'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const roomSettingsGroup: PermissionGroup = {
|
|
||||||
name: t('RoomSettings.perm_settings'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomJoinRules,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_change_room_access'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomCanonicalAlias,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_publish_address'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomPowerLevels,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_change_all_permission'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.PowerLevelTags,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_edit_power_levels'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomEncryption,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_enable_encryption'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomHistoryVisibility,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_history_visibility'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomTombstone,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_upgrade_room'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_other_settings'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const otherSettingsGroup: PermissionGroup = {
|
|
||||||
name: t('RoomSettings.perm_other'),
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.PoniesRoomEmotes,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_manage_emojis_stickers'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: StateEvent.RoomServerAcl,
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_change_server_acls'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
location: {
|
|
||||||
state: true,
|
|
||||||
key: 'im.vector.modular.widgets',
|
|
||||||
},
|
|
||||||
name: t('RoomSettings.perm_modify_widgets'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
messagesGroup,
|
|
||||||
...(isCallRoom ? [callSettingsGroup] : []),
|
|
||||||
moderationGroup,
|
|
||||||
roomOverviewGroup,
|
|
||||||
roomSettingsGroup,
|
|
||||||
otherSettingsGroup,
|
|
||||||
];
|
|
||||||
}, [isCallRoom, t]);
|
|
||||||
|
|
||||||
return groups;
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
|
||||||
import { config } from 'folds';
|
|
||||||
|
|
||||||
export const SequenceCardStyle = style({
|
|
||||||
padding: config.space.S300,
|
|
||||||
});
|
|
||||||
187
src/app/features/room/ComposerAttachments.css.ts
Normal file
187
src/app/features/room/ComposerAttachments.css.ts
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
import { color, config, toRem } from 'folds';
|
||||||
|
|
||||||
|
// In-composer attachment strip — replaces the old floating UploadBoard
|
||||||
|
// window. Attached media render as square thumbnails and other files as
|
||||||
|
// compact pills, all on ONE horizontally-scrollable row inside the
|
||||||
|
// composer card (the same `top` slot the reply/edit banners use) —
|
||||||
|
// Telegram-style: the attachment is a record in the input form, and the
|
||||||
|
// composer's own send button ships it.
|
||||||
|
export const Strip = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'hidden',
|
||||||
|
padding: `${config.space.S200} ${config.space.S300} 0`,
|
||||||
|
scrollbarWidth: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
globalStyle(`${Strip}::-webkit-scrollbar`, {
|
||||||
|
display: 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
const TILE_PX = 76;
|
||||||
|
|
||||||
|
// Square media thumbnail (image / video).
|
||||||
|
export const MediaTile = style({
|
||||||
|
position: 'relative',
|
||||||
|
width: toRem(TILE_PX),
|
||||||
|
height: toRem(TILE_PX),
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: toRem(12),
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileContent = style({
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const MediaTileSpoilered = style({
|
||||||
|
filter: 'blur(20px)',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Non-media file pill: type icon + name/size column.
|
||||||
|
export const FileTile = style({
|
||||||
|
position: 'relative',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S200,
|
||||||
|
height: toRem(TILE_PX),
|
||||||
|
maxWidth: toRem(220),
|
||||||
|
flexShrink: 0,
|
||||||
|
padding: `0 ${config.space.S300}`,
|
||||||
|
// Room for the floating remove badge so it doesn't sit on the text.
|
||||||
|
paddingRight: toRem(28),
|
||||||
|
borderRadius: toRem(12),
|
||||||
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileTileText = style({
|
||||||
|
minWidth: 0,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(2),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileTileName = style({
|
||||||
|
fontSize: toRem(12.5),
|
||||||
|
lineHeight: toRem(16),
|
||||||
|
fontWeight: 600,
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileTileMeta = style({
|
||||||
|
fontSize: toRem(11),
|
||||||
|
lineHeight: toRem(14),
|
||||||
|
color: color.SurfaceVariant.OnContainer,
|
||||||
|
opacity: 0.6,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const FileTileMetaCritical = style({
|
||||||
|
color: color.Critical.Main,
|
||||||
|
opacity: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Floating × — top-right corner of every tile. The visible badge stays
|
||||||
|
// 20px; the transparent ::after inflates the hit area to ~36px for
|
||||||
|
// thumbs (clipped by the tile's overflow:hidden at the outer edges, so
|
||||||
|
// the growth is effectively inward).
|
||||||
|
export const RemoveBtn = style({
|
||||||
|
position: 'absolute',
|
||||||
|
top: toRem(4),
|
||||||
|
right: toRem(4),
|
||||||
|
zIndex: 2,
|
||||||
|
width: toRem(20),
|
||||||
|
height: toRem(20),
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
color: '#fff',
|
||||||
|
selectors: {
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: toRem(-8),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Spoiler toggle — bottom-left of media tiles; violet when armed. Same
|
||||||
|
// inflated hit area as RemoveBtn.
|
||||||
|
export const SpoilerBtn = style({
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: toRem(4),
|
||||||
|
left: toRem(4),
|
||||||
|
zIndex: 2,
|
||||||
|
width: toRem(22),
|
||||||
|
height: toRem(22),
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
color: '#fff',
|
||||||
|
selectors: {
|
||||||
|
'&[aria-pressed="true"]': {
|
||||||
|
backgroundColor: color.Primary.Main,
|
||||||
|
color: color.Primary.OnMain,
|
||||||
|
},
|
||||||
|
'&::after': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: toRem(-7),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload progress / error overlay over a media tile.
|
||||||
|
export const TileOverlay = style({
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.45)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: toRem(12),
|
||||||
|
fontWeight: 600,
|
||||||
|
fontVariantNumeric: 'tabular-nums',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Failed / oversized tile — critical rim; the retry button lives in the
|
||||||
|
// overlay (media) or the meta line (files).
|
||||||
|
export const TileError = style({
|
||||||
|
boxShadow: `inset 0 0 0 ${toRem(1.5)} ${color.Critical.Main}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const RetryBtn = style({
|
||||||
|
border: 'none',
|
||||||
|
padding: `${toRem(2)} ${toRem(8)}`,
|
||||||
|
borderRadius: toRem(99),
|
||||||
|
cursor: 'pointer',
|
||||||
|
backgroundColor: color.Critical.Main,
|
||||||
|
color: color.Critical.OnMain,
|
||||||
|
fontSize: toRem(11),
|
||||||
|
fontWeight: 600,
|
||||||
|
});
|
||||||
213
src/app/features/room/ComposerAttachments.tsx
Normal file
213
src/app/features/room/ComposerAttachments.tsx
Normal file
|
|
@ -0,0 +1,213 @@
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { Icon, Icons, Text, percent } from 'folds';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
|
import { useMediaConfig } from '../../hooks/useMediaConfig';
|
||||||
|
import { UploadStatus, useBindUploadAtom } from '../../state/upload';
|
||||||
|
import {
|
||||||
|
roomUploadAtomFamily,
|
||||||
|
TUploadItem,
|
||||||
|
TUploadMetadata,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
|
import { TUploadContent } from '../../utils/matrix';
|
||||||
|
import { bytesToSize, getFileTypeIcon } from '../../utils/common';
|
||||||
|
import * as css from './ComposerAttachments.css';
|
||||||
|
|
||||||
|
// In-composer attachment strip — the record of attached media INSIDE the
|
||||||
|
// input form (Telegram-style), replacing the old floating UploadBoard
|
||||||
|
// window. Uploads start immediately on attach (same as before); the
|
||||||
|
// composer's send button ships every finished upload together with the
|
||||||
|
// text. Per item: × to remove, spoiler toggle on media, progress overlay
|
||||||
|
// while uploading, critical rim + retry on failure.
|
||||||
|
|
||||||
|
type AttachmentItemProps = {
|
||||||
|
fileItem: TUploadItem;
|
||||||
|
isEncrypted?: boolean;
|
||||||
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||||
|
onRemove: (file: TUploadContent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function AttachmentItem({ fileItem, isEncrypted, setMetadata, onRemove }: AttachmentItemProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const mediaConfig = useMediaConfig();
|
||||||
|
const allowSize = mediaConfig['m.upload.size'] || Infinity;
|
||||||
|
|
||||||
|
const uploadAtom = roomUploadAtomFamily(fileItem.file);
|
||||||
|
const { metadata, originalFile } = fileItem;
|
||||||
|
const { upload, startUpload, cancelUpload } = useBindUploadAtom(mx, uploadAtom, isEncrypted);
|
||||||
|
const { file } = upload;
|
||||||
|
const fileSizeExceeded = file.size >= allowSize;
|
||||||
|
|
||||||
|
// Auto-start on attach — same behaviour the old UploadCardRenderer had.
|
||||||
|
useEffect(() => {
|
||||||
|
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
||||||
|
startUpload();
|
||||||
|
}
|
||||||
|
}, [upload.status, fileSizeExceeded, startUpload]);
|
||||||
|
|
||||||
|
const previewUrl = useObjectURL(
|
||||||
|
originalFile.type.startsWith('image') || originalFile.type.startsWith('video')
|
||||||
|
? originalFile
|
||||||
|
: undefined
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleRemove = () => {
|
||||||
|
cancelUpload();
|
||||||
|
onRemove(file);
|
||||||
|
};
|
||||||
|
const handleSpoiler = () => {
|
||||||
|
setMetadata(fileItem, { ...metadata, markedAsSpoiler: !metadata.markedAsSpoiler });
|
||||||
|
};
|
||||||
|
|
||||||
|
const loading = upload.status === UploadStatus.Loading;
|
||||||
|
const failed = upload.status === UploadStatus.Error;
|
||||||
|
const progressPercent = loading
|
||||||
|
? Math.round(percent(0, file.size || 1, upload.progress.loaded))
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const removeBtn = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.RemoveBtn}
|
||||||
|
onClick={handleRemove}
|
||||||
|
aria-label={t('Room.upload_cancel_label')}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="50" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (previewUrl) {
|
||||||
|
const isVideo = originalFile.type.startsWith('video');
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(css.MediaTile, (failed || fileSizeExceeded) && css.TileError)}
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
{isVideo ? (
|
||||||
|
// eslint-disable-next-line jsx-a11y/media-has-caption
|
||||||
|
<video
|
||||||
|
className={classNames(
|
||||||
|
css.MediaTileContent,
|
||||||
|
metadata.markedAsSpoiler && css.MediaTileSpoilered
|
||||||
|
)}
|
||||||
|
src={previewUrl}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
className={classNames(
|
||||||
|
css.MediaTileContent,
|
||||||
|
metadata.markedAsSpoiler && css.MediaTileSpoilered
|
||||||
|
)}
|
||||||
|
src={previewUrl}
|
||||||
|
alt={file.name}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{loading && <div className={css.TileOverlay}>{progressPercent}%</div>}
|
||||||
|
{(failed || fileSizeExceeded) && (
|
||||||
|
<div className={css.TileOverlay}>
|
||||||
|
{failed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.RetryBtn}
|
||||||
|
onClick={startUpload}
|
||||||
|
aria-label={t('Room.upload_retry_label')}
|
||||||
|
>
|
||||||
|
{t('Room.upload_retry')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Text size="T200" align="Center">
|
||||||
|
{t('Room.upload_too_large')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{removeBtn}
|
||||||
|
{!fileSizeExceeded && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.SpoilerBtn}
|
||||||
|
onClick={handleSpoiler}
|
||||||
|
aria-pressed={metadata.markedAsSpoiler}
|
||||||
|
aria-label={t('Room.upload_spoiler')}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.EyeBlind} size="50" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let meta: React.ReactNode = bytesToSize(file.size);
|
||||||
|
if (loading) meta = `${progressPercent}%`;
|
||||||
|
else if (fileSizeExceeded) meta = t('Room.upload_too_large');
|
||||||
|
else if (failed) {
|
||||||
|
meta = (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.RetryBtn}
|
||||||
|
onClick={startUpload}
|
||||||
|
aria-label={t('Room.upload_retry_label')}
|
||||||
|
>
|
||||||
|
{t('Room.upload_retry')}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(css.FileTile, (failed || fileSizeExceeded) && css.TileError)}
|
||||||
|
title={file.name}
|
||||||
|
>
|
||||||
|
<Icon src={getFileTypeIcon(Icons, file.type)} size="200" />
|
||||||
|
<div className={css.FileTileText}>
|
||||||
|
<span className={css.FileTileName}>{file.name}</span>
|
||||||
|
<span
|
||||||
|
className={classNames(
|
||||||
|
css.FileTileMeta,
|
||||||
|
(failed || fileSizeExceeded) && css.FileTileMetaCritical
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{meta}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{removeBtn}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComposerAttachmentsProps = {
|
||||||
|
selectedFiles: TUploadItem[];
|
||||||
|
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||||
|
onRemove: (file: TUploadContent) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ComposerAttachments({
|
||||||
|
selectedFiles,
|
||||||
|
setMetadata,
|
||||||
|
onRemove,
|
||||||
|
}: ComposerAttachmentsProps) {
|
||||||
|
if (selectedFiles.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className={css.Strip}>
|
||||||
|
{selectedFiles.map((fileItem, index) => (
|
||||||
|
<AttachmentItem
|
||||||
|
// Index keys are safe here: per-item upload state lives in the
|
||||||
|
// jotai family keyed by the FILE OBJECT, so a remount after a
|
||||||
|
// mid-list removal re-binds to the same atom. (Name+size would
|
||||||
|
// collide when the same file is attached twice.)
|
||||||
|
// eslint-disable-next-line react/no-array-index-key
|
||||||
|
key={index}
|
||||||
|
fileItem={fileItem}
|
||||||
|
// E2EE rooms upload the pre-encrypted blob — hide the filename
|
||||||
|
// from the upload endpoint, same as the old per-card renderer.
|
||||||
|
isEncrypted={!!fileItem.encInfo}
|
||||||
|
setMetadata={setMetadata}
|
||||||
|
onRemove={onRemove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -132,7 +132,10 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
// PageRoot. The chat column applies an explicit Background bg so the
|
// PageRoot. The chat column applies an explicit Background bg so the
|
||||||
// parent void can't bleed through any transparent slivers.
|
// parent void can't bleed through any transparent slivers.
|
||||||
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
const profileOpen = !!useAtomValue(userRoomProfileAtom);
|
||||||
const membersSheetOpen = !!useAtomValue(roomMembersSheetAtom);
|
// Compare roomId, not truthiness — the atom is global, and a stale entry
|
||||||
|
// left by a surface that unmounted without cleanup must not auto-open the
|
||||||
|
// members pane in an unrelated room.
|
||||||
|
const membersSheetOpen = useAtomValue(roomMembersSheetAtom)?.roomId === room.roomId;
|
||||||
const callView = room.isCallRoom();
|
const callView = room.isCallRoom();
|
||||||
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
||||||
// The desktop/tablet media viewer is no longer a right-side pane — it's
|
// The desktop/tablet media viewer is no longer a right-side pane — it's
|
||||||
|
|
@ -152,9 +155,10 @@ export function Room({ renderRoomView }: RoomProps) {
|
||||||
!callView && !isOneOnOne && !showThreadDrawer && !isMobile && membersSheetOpen;
|
!callView && !isOneOnOne && !showThreadDrawer && !isMobile && membersSheetOpen;
|
||||||
// Mobile mounts the members horseshoe wrapper for every non-1:1,
|
// Mobile mounts the members horseshoe wrapper for every non-1:1,
|
||||||
// non-call room — group DMs, group rooms, AND channels. 1:1 rooms
|
// non-call room — group DMs, group rooms, AND channels. 1:1 rooms
|
||||||
// keep using `RoomViewProfilePanel`; the call surface routes member
|
// keep using `RoomViewProfilePanel`; the call surface shows the
|
||||||
// discovery through Room Settings (see the user-icon button kept in
|
// people who matter there — the participants — via `CallView`'s own
|
||||||
// `RoomViewHeaderDm` for `callView`).
|
// member cards (room-wide member management isn't reachable from a
|
||||||
|
// call room post-redesign).
|
||||||
const useMembersWrapper = !isOneOnOne && !callView;
|
const useMembersWrapper = !isOneOnOne && !callView;
|
||||||
// True whenever any right-side pane is mounted. Drives the parent flex
|
// True whenever any right-side pane is mounted. Drives the parent flex
|
||||||
// row's void background and the chat column's explicit Background paint
|
// row's void background and the chat column's explicit Background paint
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,24 @@ import React, {
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
import {
|
||||||
|
EventStatus,
|
||||||
|
EventType,
|
||||||
|
IContent,
|
||||||
|
IMentions,
|
||||||
|
MatrixEvent,
|
||||||
|
MsgType,
|
||||||
|
RelationType,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
|
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Transforms, Editor } from 'slate';
|
import { Transforms, Editor, Descendant } from 'slate';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
@ -26,7 +37,6 @@ import {
|
||||||
OverlayBackdrop,
|
OverlayBackdrop,
|
||||||
OverlayCenter,
|
OverlayCenter,
|
||||||
PopOut,
|
PopOut,
|
||||||
Scroll,
|
|
||||||
Text,
|
Text,
|
||||||
color,
|
color,
|
||||||
config,
|
config,
|
||||||
|
|
@ -56,6 +66,8 @@ import {
|
||||||
getBeginCommand,
|
getBeginCommand,
|
||||||
trimCommand,
|
trimCommand,
|
||||||
getMentions,
|
getMentions,
|
||||||
|
htmlToEditorInput,
|
||||||
|
plainToEditorInput,
|
||||||
} from '../../components/editor';
|
} from '../../components/editor';
|
||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
|
|
@ -68,6 +80,7 @@ import {
|
||||||
mxcUrlToHttp,
|
mxcUrlToHttp,
|
||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
import { useTypingStatusUpdater } from '../../hooks/useTypingStatusUpdater';
|
||||||
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||||
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
import { useFilePasteHandler } from '../../hooks/useFilePasteHandler';
|
||||||
import { useFileDropZone } from '../../hooks/useFileDrop';
|
import { useFileDropZone } from '../../hooks/useFileDrop';
|
||||||
|
|
@ -75,18 +88,13 @@ import {
|
||||||
TUploadItem,
|
TUploadItem,
|
||||||
TUploadMetadata,
|
TUploadMetadata,
|
||||||
draftKey,
|
draftKey,
|
||||||
|
roomIdToEditDraftAtomFamily,
|
||||||
roomIdToMsgDraftAtomFamily,
|
roomIdToMsgDraftAtomFamily,
|
||||||
roomIdToReplyDraftAtomFamily,
|
roomIdToReplyDraftAtomFamily,
|
||||||
roomIdToUploadItemsAtomFamily,
|
roomIdToUploadItemsAtomFamily,
|
||||||
roomUploadAtomFamily,
|
roomUploadAtomFamily,
|
||||||
} from '../../state/room/roomInputDrafts';
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { UploadCardRenderer } from '../../components/upload-card';
|
import { ComposerAttachments } from './ComposerAttachments';
|
||||||
import {
|
|
||||||
UploadBoard,
|
|
||||||
UploadBoardContent,
|
|
||||||
UploadBoardHeader,
|
|
||||||
UploadBoardImperativeHandlers,
|
|
||||||
} from '../../components/upload-board';
|
|
||||||
import {
|
import {
|
||||||
Upload,
|
Upload,
|
||||||
UploadStatus,
|
UploadStatus,
|
||||||
|
|
@ -94,6 +102,7 @@ import {
|
||||||
createUploadFamilyObserverAtom,
|
createUploadFamilyObserverAtom,
|
||||||
} from '../../state/upload';
|
} from '../../state/upload';
|
||||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { safeFile } from '../../utils/mimeTypes';
|
import { safeFile } from '../../utils/mimeTypes';
|
||||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
|
|
@ -109,7 +118,13 @@ import { VoiceRecording, VoiceRecordingResult } from '../../utils/voiceRecording
|
||||||
import { VoiceRecorder } from './VoiceRecorderForm';
|
import { VoiceRecorder } from './VoiceRecorderForm';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
import {
|
||||||
|
getEditedEvent,
|
||||||
|
getMemberDisplayName,
|
||||||
|
getMentionContent,
|
||||||
|
trimReplyFromBody,
|
||||||
|
trimReplyFromFormattedBody,
|
||||||
|
} from '../../utils/room';
|
||||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||||
import { mobileOrTablet } from '../../utils/user-agent';
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
|
|
@ -264,15 +279,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
}, [voiceDisabledBy]);
|
}, [voiceDisabledBy]);
|
||||||
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
const emojiBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
// On native / narrow screens the emoji-sticker board is docked inline at the
|
// On native / narrow screens the emoji-sticker board opens as a CENTERED
|
||||||
// top of the composer instead of floating as a pop-out.
|
// overlay window — the same presentation the message rail's reaction
|
||||||
const dockEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile;
|
// picker uses (an anchored PopOut drifts off the small viewport, and the
|
||||||
|
// old in-composer dock crowded the input). Desktop keeps the anchored
|
||||||
|
// pop-out by the emoji button.
|
||||||
|
const centerEmojiBoard = mobileOrTablet() || screenSize === ScreenSize.Mobile;
|
||||||
const [emojiBoardTab, setEmojiBoardTab] = useState<EmojiBoardTab | undefined>(undefined);
|
const [emojiBoardTab, setEmojiBoardTab] = useState<EmojiBoardTab | undefined>(undefined);
|
||||||
// Crossing the dock/pop-out breakpoint remounts the trigger; drop any open
|
// Crossing the center/pop-out breakpoint remounts the trigger; drop any
|
||||||
// dock state so the board doesn't silently re-open after a resize round-trip.
|
// open state so the board doesn't silently re-open after a resize
|
||||||
|
// round-trip.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEmojiBoardTab(undefined);
|
setEmojiBoardTab(undefined);
|
||||||
}, [dockEmojiBoard]);
|
}, [centerEmojiBoard]);
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
@ -283,6 +302,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const inputDraftKey = draftKey(roomId, threadId);
|
const inputDraftKey = draftKey(roomId, threadId);
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey));
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(inputDraftKey));
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey));
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(inputDraftKey));
|
||||||
|
const [editDraft, setEditDraft] = useAtom(roomIdToEditDraftAtomFamily(inputDraftKey));
|
||||||
const replyUserID = replyDraft?.userId;
|
const replyUserID = replyDraft?.userId;
|
||||||
|
|
||||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||||
|
|
@ -301,13 +321,26 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
: undefined;
|
: undefined;
|
||||||
const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
const replyUsernameColor = isOneOnOne ? colorMXID(replyUserID ?? '') : replyPowerColor;
|
||||||
|
|
||||||
const [uploadBoard, setUploadBoard] = useState(true);
|
|
||||||
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey));
|
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(inputDraftKey));
|
||||||
const uploadFamilyObserverAtom = createUploadFamilyObserverAtom(
|
// Memoised on the file list — a fresh derived atom every render would
|
||||||
roomUploadAtomFamily,
|
// churn submit()'s identity (it sits in submit's deps) and with it every
|
||||||
selectedFiles.map((f) => f.file)
|
// downstream useCallback.
|
||||||
|
const uploadFamilyObserverAtom = useMemo(
|
||||||
|
() =>
|
||||||
|
createUploadFamilyObserverAtom(
|
||||||
|
roomUploadAtomFamily,
|
||||||
|
selectedFiles.map((f) => f.file)
|
||||||
|
),
|
||||||
|
[selectedFiles]
|
||||||
);
|
);
|
||||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers>();
|
// Imperative store handle — submit() reads the live upload states once
|
||||||
|
// at send time instead of subscribing the whole composer to every
|
||||||
|
// (throttled) progress tick. The attachment strip's per-item components
|
||||||
|
// are the only progress subscribers.
|
||||||
|
const jotaiStore = useStore();
|
||||||
|
// Re-entrancy guard for the attachment send — mirrors the old
|
||||||
|
// UploadBoardHeader's sendingRef.
|
||||||
|
const sendingAttachmentsRef = useRef(false);
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||||
|
|
||||||
|
|
@ -319,13 +352,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
// no thread_id, so a thread-composer typing event would surface
|
// no thread_id, so a thread-composer typing event would surface
|
||||||
// in the main channel chat as if the user were typing there).
|
// in the main channel chat as if the user were typing there).
|
||||||
const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId);
|
const sendTypingStatus = useTypingStatusUpdater(mx, roomId, !!threadId);
|
||||||
|
const alive = useAlive();
|
||||||
|
|
||||||
const handleFiles = useCallback(
|
const handleFiles = useCallback(
|
||||||
async (files: File[]) => {
|
async (files: File[]) => {
|
||||||
// Text-only composer (AI new-chat landing) ignores every file vector —
|
// Text-only composer (AI new-chat landing) ignores every file vector —
|
||||||
// picker, paste and drop all funnel through here.
|
// picker, paste and drop all funnel through here.
|
||||||
if (textOnly) return;
|
if (textOnly) return;
|
||||||
setUploadBoard(true);
|
|
||||||
const safeFiles = files.map(safeFile);
|
const safeFiles = files.map(safeFile);
|
||||||
const fileItems: TUploadItem[] = [];
|
const fileItems: TUploadItem[] = [];
|
||||||
|
|
||||||
|
|
@ -375,15 +408,151 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
Transforms.insertFragment(editor, msgDraft);
|
Transforms.insertFragment(editor, msgDraft);
|
||||||
}, [editor, msgDraft]);
|
}, [editor, msgDraft]);
|
||||||
|
|
||||||
|
// ── Edit-in-composer (Telegram-style) ─────────────────────────────
|
||||||
|
// «Edit» on an own message sets the per-scope edit draft; this block
|
||||||
|
// loads the message body into THIS composer, shows the banner (in the
|
||||||
|
// `top` slot below) and routes submit() through the m.replace path.
|
||||||
|
// The user's unsent draft is stashed on entry and restored when the
|
||||||
|
// edit ends (cancel or save) — same behaviour as Telegram.
|
||||||
|
|
||||||
|
// Reads the target's CURRENT body (following any prior m.replace
|
||||||
|
// aggregation) — mirror of the old MessageEditor's
|
||||||
|
// getPrevBodyAndFormattedBody.
|
||||||
|
const getEditTargetContent = useCallback(
|
||||||
|
(
|
||||||
|
eventId: string
|
||||||
|
): {
|
||||||
|
body: string | undefined;
|
||||||
|
customHtml: string | undefined;
|
||||||
|
mentions: IMentions | undefined;
|
||||||
|
msgtype: string | undefined;
|
||||||
|
threadRootId: string | undefined;
|
||||||
|
} => {
|
||||||
|
const mEvent = room.findEventById(eventId);
|
||||||
|
if (!mEvent) {
|
||||||
|
return {
|
||||||
|
body: undefined,
|
||||||
|
customHtml: undefined,
|
||||||
|
mentions: undefined,
|
||||||
|
msgtype: undefined,
|
||||||
|
threadRootId: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const editedEvent = getEditedEvent(eventId, mEvent, room.getUnfilteredTimelineSet());
|
||||||
|
const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
||||||
|
const { body, formatted_body: customHtml }: Record<string, unknown> = content;
|
||||||
|
return {
|
||||||
|
body: typeof body === 'string' ? body : undefined,
|
||||||
|
customHtml: typeof customHtml === 'string' ? customHtml : undefined,
|
||||||
|
mentions: content['m.mentions'],
|
||||||
|
msgtype: mEvent.getContent().msgtype,
|
||||||
|
threadRootId: mEvent.threadRootId,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[room]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unsent text stashed while the editor hosts the edit body.
|
||||||
|
const preEditStashRef = useRef<Descendant[] | null>(null);
|
||||||
|
// A failed m.replace send re-enters edit mode with the USER'S edited
|
||||||
|
// text (set by submitEdit's .catch, consumed by the edit-load effect)
|
||||||
|
// instead of re-loading the target's old body — the edit banner
|
||||||
|
// re-opening with their text intact IS the failure feedback.
|
||||||
|
const failedEditRef = useRef<{ eventId: string; children: Descendant[] } | null>(null);
|
||||||
|
// Tracks which eventId the editor currently holds, so re-renders with
|
||||||
|
// the same draft don't re-load, while retargeting (edit A → edit B)
|
||||||
|
// and exit both do.
|
||||||
|
const loadedEditIdRef = useRef<string>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const evtId = editDraft?.eventId;
|
||||||
|
if (evtId === loadedEditIdRef.current) return;
|
||||||
|
const wasEditing = loadedEditIdRef.current !== undefined;
|
||||||
|
loadedEditIdRef.current = evtId;
|
||||||
|
|
||||||
|
if (!evtId) {
|
||||||
|
// Edit ended (cancel / save / external clear) — restore the stash.
|
||||||
|
resetEditor(editor);
|
||||||
|
resetEditorHistory(editor);
|
||||||
|
const stash = preEditStashRef.current;
|
||||||
|
preEditStashRef.current = null;
|
||||||
|
if (stash && stash.length > 0) {
|
||||||
|
Transforms.insertFragment(editor, stash);
|
||||||
|
}
|
||||||
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Entering edit (stash once) or retargeting (keep the original stash).
|
||||||
|
if (!wasEditing && !isEmptyEditor(editor)) {
|
||||||
|
preEditStashRef.current = JSON.parse(JSON.stringify(editor.children));
|
||||||
|
}
|
||||||
|
const failed = failedEditRef.current;
|
||||||
|
failedEditRef.current = null;
|
||||||
|
const initialValue =
|
||||||
|
failed && failed.eventId === evtId
|
||||||
|
? failed.children
|
||||||
|
: (() => {
|
||||||
|
const { body, customHtml } = getEditTargetContent(evtId);
|
||||||
|
return typeof customHtml === 'string'
|
||||||
|
? htmlToEditorInput(customHtml, isMarkdown)
|
||||||
|
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
|
||||||
|
})();
|
||||||
|
resetEditor(editor);
|
||||||
|
resetEditorHistory(editor);
|
||||||
|
Transforms.insertFragment(editor, initialValue);
|
||||||
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
|
// isMarkdown intentionally NOT a dep — toggling the setting mid-edit
|
||||||
|
// must not blow away the user's in-progress changes.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [editDraft, editor, getEditTargetContent]);
|
||||||
|
|
||||||
|
const editDraftRef = useRef(editDraft);
|
||||||
|
editDraftRef.current = editDraft;
|
||||||
|
|
||||||
|
// Editing a still-sending message: the draft stores the local-echo
|
||||||
|
// '~txn' id, and the remote echo DELETES that id from the timeline set
|
||||||
|
// (EventTimelineSet.replaceEventId) — without retargeting, submit would
|
||||||
|
// build an m.replace against a dead id (and the SDK throws on a '~'
|
||||||
|
// relation under Chronological pending ordering). Follow the swap and
|
||||||
|
// keep the user's in-progress text (bump loadedEditIdRef FIRST so the
|
||||||
|
// edit-load effect doesn't reload the body over their changes).
|
||||||
|
useEffect(() => {
|
||||||
|
const onLocalEchoUpdated = (event: MatrixEvent, _room: Room, oldEventId?: string): void => {
|
||||||
|
const draft = editDraftRef.current;
|
||||||
|
if (!draft) return;
|
||||||
|
// The edit TARGET failed to send: an m.replace can never apply to
|
||||||
|
// an event that never reached the server, so Save would stay a
|
||||||
|
// silent no-op forever ('~' guard in submitEdit). Cancel the edit
|
||||||
|
// (restoring the stash) — the failed message keeps its own
|
||||||
|
// retry/delete affordances in the timeline.
|
||||||
|
if (event.status === EventStatus.NOT_SENT && event.getId() === draft.eventId) {
|
||||||
|
setEditDraft(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!oldEventId || oldEventId !== draft.eventId) return;
|
||||||
|
const newId = event.getId();
|
||||||
|
if (!newId || newId === oldEventId) return;
|
||||||
|
loadedEditIdRef.current = newId;
|
||||||
|
setEditDraft({ eventId: newId });
|
||||||
|
};
|
||||||
|
room.on(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.LocalEchoUpdated, onLocalEchoUpdated);
|
||||||
|
};
|
||||||
|
}, [room, setEditDraft]);
|
||||||
|
|
||||||
|
const editTarget = editDraft ? getEditTargetContent(editDraft.eventId) : undefined;
|
||||||
|
|
||||||
// Drain the global share-target hand-off into THIS chat. The system
|
// Drain the global share-target hand-off into THIS chat. The system
|
||||||
// share-sheet flow doesn't open a picker — it just lights up the
|
// share-sheet flow doesn't open a picker — it just lights up the
|
||||||
// ShareTargetStrip banner and lets the user pick a chat by navigating
|
// ShareTargetStrip banner and lets the user pick a chat by navigating
|
||||||
// normally. The first RoomInput that mounts (or, if the user was
|
// normally. The first RoomInput that mounts (or, if the user was
|
||||||
// already inside a chat when the share arrived, the current RoomInput
|
// already inside a chat when the share arrived, the current RoomInput
|
||||||
// re-running this effect with a non-null `pendingShare`) consumes the
|
// re-running this effect with a non-null `pendingShare`) consumes the
|
||||||
// payload: files into the upload board, text into the composer. The
|
// payload: files into the composer's attachment strip, text into the
|
||||||
// user can still bail by tapping the [×] on each upload card before
|
// editor. The user can still bail by tapping the [×] on each
|
||||||
// pressing Send.
|
// attachment before pressing Send.
|
||||||
//
|
//
|
||||||
// Declared AFTER the msgDraft restore so the share text appends to
|
// Declared AFTER the msgDraft restore so the share text appends to
|
||||||
// any saved draft instead of getting overwritten by it.
|
// any saved draft instead of getting overwritten by it.
|
||||||
|
|
@ -391,8 +560,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
// Thread composers (threadId set) deliberately skip — sharing into a
|
// Thread composers (threadId set) deliberately skip — sharing into a
|
||||||
// thread isn't a flow users ask for, and would silently consume the
|
// thread isn't a flow users ask for, and would silently consume the
|
||||||
// share leaving the main composer empty.
|
// share leaving the main composer empty.
|
||||||
|
//
|
||||||
|
// Edit mode also defers: the editor currently holds the EDIT body, and
|
||||||
|
// inserting the shared text there would pollute the m.replace (or lose
|
||||||
|
// the share on cancel). The payload stays in pendingShareAtom (the
|
||||||
|
// strip banner stays up); `editDraft` in the deps re-runs the drain
|
||||||
|
// when the edit ends — by then the edit-end effect above (declared
|
||||||
|
// earlier, runs first in the same commit) has restored the user's
|
||||||
|
// stashed draft, so the share appends to their real draft as designed.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (threadId) return;
|
if (threadId) return;
|
||||||
|
if (editDraft) return;
|
||||||
if (!pendingShare) return;
|
if (!pendingShare) return;
|
||||||
// Clear first so a re-render mid-handleFiles can't queue another
|
// Clear first so a re-render mid-handleFiles can't queue another
|
||||||
// run for the same payload.
|
// run for the same payload.
|
||||||
|
|
@ -405,11 +583,21 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
if (text) {
|
if (text) {
|
||||||
Transforms.insertText(editor, text);
|
Transforms.insertText(editor, text);
|
||||||
}
|
}
|
||||||
}, [threadId, pendingShare, setPendingShare, handleFiles, editor]);
|
}, [threadId, editDraft, pendingShare, setPendingShare, handleFiles, editor]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (!isEmptyEditor(editor)) {
|
if (loadedEditIdRef.current !== undefined) {
|
||||||
|
// Unmounted mid-edit (navigation, thread drawer opening over the
|
||||||
|
// main composer): drop the edit — the editor holds the EDIT body,
|
||||||
|
// which must not be saved as the user's message draft. Persist
|
||||||
|
// the pre-edit stash instead so their unsent text survives.
|
||||||
|
setEditDraft(undefined);
|
||||||
|
loadedEditIdRef.current = undefined;
|
||||||
|
const stash = preEditStashRef.current;
|
||||||
|
preEditStashRef.current = null;
|
||||||
|
setMsgDraft(stash && stash.length > 0 ? stash : []);
|
||||||
|
} else if (!isEmptyEditor(editor)) {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
setMsgDraft(parsedDraft);
|
setMsgDraft(parsedDraft);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -423,7 +611,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
// setter to the correct atom. Today the drawer is fully
|
// setter to the correct atom. Today the drawer is fully
|
||||||
// remounted on rootId change via Room.tsx key, but the dep
|
// remounted on rootId change via Room.tsx key, but the dep
|
||||||
// pins the invariant explicitly.
|
// pins the invariant explicitly.
|
||||||
[roomId, threadId, editor, setMsgDraft]
|
[roomId, threadId, editor, setMsgDraft, setEditDraft]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFileMetadata = useCallback(
|
const handleFileMetadata = useCallback(
|
||||||
|
|
@ -449,37 +637,49 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
[setSelectedFiles, selectedFiles]
|
[setSelectedFiles, selectedFiles]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCancelUpload = (uploads: Upload[]) => {
|
const handleCancelUpload = useCallback(
|
||||||
uploads.forEach((upload) => {
|
(uploads: Upload[]) => {
|
||||||
if (upload.status === UploadStatus.Loading) {
|
uploads.forEach((upload) => {
|
||||||
mx.cancelUpload(upload.promise);
|
if (upload.status === UploadStatus.Loading) {
|
||||||
}
|
mx.cancelUpload(upload.promise);
|
||||||
});
|
}
|
||||||
handleRemoveUpload(uploads.map((upload) => upload.file));
|
});
|
||||||
};
|
handleRemoveUpload(uploads.map((upload) => upload.file));
|
||||||
|
},
|
||||||
|
[mx, handleRemoveUpload]
|
||||||
|
);
|
||||||
|
|
||||||
const handleSendUpload = async (uploads: UploadSuccess[]) => {
|
// useCallback (not a plain function) — submit() depends on it.
|
||||||
const contentsPromises = uploads.map(async (upload) => {
|
const handleSendUpload = useCallback(
|
||||||
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
async (uploads: UploadSuccess[]) => {
|
||||||
if (!fileItem) throw new Error('Broken upload');
|
const contentsPromises = uploads.map(async (upload) => {
|
||||||
|
const fileItem = selectedFiles.find((f) => f.file === upload.file);
|
||||||
|
if (!fileItem) throw new Error('Broken upload');
|
||||||
|
|
||||||
if (fileItem.file.type.startsWith('image')) {
|
if (fileItem.file.type.startsWith('image')) {
|
||||||
return getImageMsgContent(mx, fileItem, upload.mxc);
|
return getImageMsgContent(mx, fileItem, upload.mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('video')) {
|
if (fileItem.file.type.startsWith('video')) {
|
||||||
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
return getVideoMsgContent(mx, fileItem, upload.mxc);
|
||||||
}
|
}
|
||||||
if (fileItem.file.type.startsWith('audio')) {
|
if (fileItem.file.type.startsWith('audio')) {
|
||||||
return getAudioMsgContent(fileItem, upload.mxc);
|
return getAudioMsgContent(fileItem, upload.mxc);
|
||||||
}
|
}
|
||||||
return getFileMsgContent(fileItem, upload.mxc);
|
return getFileMsgContent(fileItem, upload.mxc);
|
||||||
});
|
});
|
||||||
handleCancelUpload(uploads);
|
// Settle the content builds BEFORE clearing the strip: an item
|
||||||
const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises));
|
// whose build failed (image decode, thumbnailing) stays visible
|
||||||
contents.forEach((content) =>
|
// for another Send instead of silently vanishing unsent.
|
||||||
mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
|
const settled = await Promise.allSettled(contentsPromises);
|
||||||
);
|
const builtUploads = uploads.filter((_, i) => settled[i].status === 'fulfilled');
|
||||||
};
|
handleCancelUpload(builtUploads);
|
||||||
|
const contents = fulfilledPromiseSettledResult(settled);
|
||||||
|
contents.forEach((content) =>
|
||||||
|
mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, roomId, threadId, selectedFiles, handleCancelUpload]
|
||||||
|
);
|
||||||
|
|
||||||
const voiceBlockedName = useCallback(
|
const voiceBlockedName = useCallback(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -559,8 +759,163 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
[voiceDisabledBy, voiceBlockedName, room, mx, roomId, threadId, t]
|
[voiceDisabledBy, voiceBlockedName, room, mx, roomId, threadId, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Edit submit — builds the m.replace content the way the old
|
||||||
|
// MessageEditor did: `m.new_content` carries the real body, the
|
||||||
|
// top-level body gets the `* ` fallback prefix, mentions merge the
|
||||||
|
// previous revision's users. No-op edits (unchanged / emptied text)
|
||||||
|
// just exit edit mode. threadRootId comes from the TARGET event so a
|
||||||
|
// thread message edited from the drawer keeps its thread relation
|
||||||
|
// (SDK sets the local-echo `.thread` pointer; m.replace itself owns
|
||||||
|
// `m.relates_to`, spec-correct — see the old MessageEditor notes).
|
||||||
|
const submitEdit = useCallback(() => {
|
||||||
|
if (!editDraft) return;
|
||||||
|
const { eventId } = editDraft;
|
||||||
|
|
||||||
|
// Still-sending local echo: a '~txn' relation makes the SDK throw
|
||||||
|
// synchronously (getPendingEvents under Chronological ordering) —
|
||||||
|
// stay in edit mode and wait for the LocalEchoUpdated retarget above
|
||||||
|
// to swap in the real id (usually a beat later).
|
||||||
|
if (eventId.startsWith('~')) return;
|
||||||
|
// Target vanished (redacted / not in the timeline set any more):
|
||||||
|
// there is nothing to replace — cancel the edit, restoring the stash.
|
||||||
|
if (!room.findEventById(eventId)) {
|
||||||
|
setEditDraft(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = getEditTargetContent(eventId);
|
||||||
|
|
||||||
|
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
|
const customHtml = trimCustomHtml(
|
||||||
|
toMatrixCustomHTML(editor.children, {
|
||||||
|
allowTextFormatting: true,
|
||||||
|
allowBlockMarkdown: isMarkdown,
|
||||||
|
allowInlineMarkdown: isMarkdown,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const exitEdit = () => {
|
||||||
|
setEditDraft(undefined);
|
||||||
|
sendTypingStatus(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (plainText === '') {
|
||||||
|
// Emptied-out edit: do nothing (deleting is the Delete action's job).
|
||||||
|
exitEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (target.body) {
|
||||||
|
const unchangedHtml =
|
||||||
|
target.customHtml && trimReplyFromFormattedBody(target.customHtml) === customHtml;
|
||||||
|
const unchangedPlain =
|
||||||
|
!target.customHtml &&
|
||||||
|
target.body === plainText &&
|
||||||
|
customHtmlEqualsPlainText(customHtml, plainText);
|
||||||
|
if (unchangedHtml || unchangedPlain) {
|
||||||
|
exitEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newContent: IContent = {
|
||||||
|
msgtype: target.msgtype,
|
||||||
|
body: plainText,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Spec (event replacements / m.mentions): `m.new_content` carries the
|
||||||
|
// FULL resolved mention set of the revision, while the TOP-LEVEL
|
||||||
|
// m.mentions must list only the mentions ADDED by this edit — anyone
|
||||||
|
// already mentioned was pinged by the original and must not be
|
||||||
|
// re-notified on every revision.
|
||||||
|
const mentionData = getMentions(mx, roomId, editor);
|
||||||
|
const prevUserIds = new Set(target.mentions?.user_ids ?? []);
|
||||||
|
const prevRoom = target.mentions?.room === true;
|
||||||
|
const addedUsers = Array.from(mentionData.users).filter((u) => !prevUserIds.has(u));
|
||||||
|
const fullUsers = new Set(mentionData.users);
|
||||||
|
prevUserIds.forEach((u) => fullUsers.add(u));
|
||||||
|
newContent['m.mentions'] = getMentionContent(
|
||||||
|
Array.from(fullUsers),
|
||||||
|
mentionData.room || prevRoom
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
|
||||||
|
newContent.format = 'org.matrix.custom.html';
|
||||||
|
newContent.formatted_body = customHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
const content: IContent = {
|
||||||
|
...newContent,
|
||||||
|
'm.mentions': getMentionContent(addedUsers, mentionData.room && !prevRoom),
|
||||||
|
body: `* ${plainText}`,
|
||||||
|
'm.new_content': newContent,
|
||||||
|
'm.relates_to': {
|
||||||
|
event_id: eventId,
|
||||||
|
rel_type: RelationType.Replace,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
// The `* ` fallback prefix applies to the formatted fallback too
|
||||||
|
// (mirrors element-web's edit content shape).
|
||||||
|
if (typeof newContent.formatted_body === 'string') {
|
||||||
|
content.formatted_body = `* ${newContent.formatted_body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic exit (Telegram-style: banner closes immediately). The
|
||||||
|
// failure leg re-enters edit mode with the user's text via
|
||||||
|
// failedEditRef — unless they have already started another edit by
|
||||||
|
// the time the send settles, or this composer is long gone.
|
||||||
|
const editedChildren: Descendant[] = JSON.parse(JSON.stringify(editor.children));
|
||||||
|
mx.sendMessage(roomId, target.threadRootId ?? null, content as RoomMessageEventContent).catch(
|
||||||
|
() => {
|
||||||
|
if (!alive()) return;
|
||||||
|
if (editDraftRef.current) return;
|
||||||
|
failedEditRef.current = { eventId, children: editedChildren };
|
||||||
|
setEditDraft({ eventId });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
exitEdit();
|
||||||
|
}, [
|
||||||
|
mx,
|
||||||
|
room,
|
||||||
|
roomId,
|
||||||
|
editor,
|
||||||
|
editDraft,
|
||||||
|
getEditTargetContent,
|
||||||
|
isMarkdown,
|
||||||
|
setEditDraft,
|
||||||
|
sendTypingStatus,
|
||||||
|
alive,
|
||||||
|
]);
|
||||||
|
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
// Edit mode owns the send path entirely — uploads/replies/commands
|
||||||
|
// don't apply to an m.replace.
|
||||||
|
if (editDraft) {
|
||||||
|
submitEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ship every FINISHED upload from the attachment strip. Still-running
|
||||||
|
// and failed ones stay in the strip (the user retries or sends again
|
||||||
|
// once they finish) — same semantics the old UploadBoard's Send had.
|
||||||
|
// Read the live states from the store so the composer doesn't
|
||||||
|
// subscribe to per-tick progress.
|
||||||
|
//
|
||||||
|
// The text send below CHAINS on the drain: handleSendUpload builds
|
||||||
|
// media contents asynchronously, and queueing the text in this tick
|
||||||
|
// would land the caption ABOVE the photos for everyone.
|
||||||
|
const uploads = jotaiStore.get(uploadFamilyObserverAtom);
|
||||||
|
const readyUploads = uploads.filter(
|
||||||
|
(upload): upload is UploadSuccess => upload.status === UploadStatus.Success
|
||||||
|
);
|
||||||
|
let attachmentsDrain: Promise<unknown> = Promise.resolve();
|
||||||
|
if (readyUploads.length > 0 && !sendingAttachmentsRef.current) {
|
||||||
|
sendingAttachmentsRef.current = true;
|
||||||
|
attachmentsDrain = handleSendUpload(readyUploads)
|
||||||
|
.catch(() => undefined)
|
||||||
|
.finally(() => {
|
||||||
|
sendingAttachmentsRef.current = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const commandName = getBeginCommand(editor);
|
const commandName = getBeginCommand(editor);
|
||||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||||
|
|
@ -635,7 +990,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
content['m.relates_to'].is_falling_back = false;
|
content['m.relates_to'].is_falling_back = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const pending = mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent);
|
// Queue the text AFTER the attachment drain so a caption lands below
|
||||||
|
// its media. The composer resets immediately regardless — the user's
|
||||||
|
// send gesture already happened.
|
||||||
|
const pending = attachmentsDrain.then(() =>
|
||||||
|
mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent)
|
||||||
|
);
|
||||||
if (onSend) {
|
if (onSend) {
|
||||||
pending.then((res) => onSend(res.event_id)).catch(() => undefined);
|
pending.then((res) => onSend(res.event_id)).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
@ -650,10 +1010,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
onSend,
|
onSend,
|
||||||
editor,
|
editor,
|
||||||
replyDraft,
|
replyDraft,
|
||||||
|
editDraft,
|
||||||
|
submitEdit,
|
||||||
sendTypingStatus,
|
sendTypingStatus,
|
||||||
setReplyDraft,
|
setReplyDraft,
|
||||||
isMarkdown,
|
isMarkdown,
|
||||||
commands,
|
commands,
|
||||||
|
jotaiStore,
|
||||||
|
uploadFamilyObserverAtom,
|
||||||
|
handleSendUpload,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
|
|
@ -671,10 +1036,24 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
setAutocompleteQuery(undefined);
|
setAutocompleteQuery(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Edit first: Esc cancels the edit (restoring the stashed draft);
|
||||||
|
// a second Esc then clears the reply preview as before.
|
||||||
|
if (editDraft) {
|
||||||
|
setEditDraft(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[submit, setReplyDraft, enterForNewline, autocompleteQuery, isComposing]
|
[
|
||||||
|
submit,
|
||||||
|
setReplyDraft,
|
||||||
|
editDraft,
|
||||||
|
setEditDraft,
|
||||||
|
enterForNewline,
|
||||||
|
autocompleteQuery,
|
||||||
|
isComposing,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
|
|
@ -755,25 +1134,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
</IconButton>
|
</IconButton>
|
||||||
);
|
);
|
||||||
|
|
||||||
// The native dock renders the board in the composer's top slot, so its
|
// Mobile / native: the board opens as a centered overlay WINDOW — same
|
||||||
// open/close state lives here (only read on native). Desktop keeps its
|
// presentation as the message rail's reaction picker — so its open
|
||||||
// state isolated inside the UseStateProvider below so opening the pop-out
|
// state lives here. Desktop keeps its state isolated inside the
|
||||||
// doesn't re-render the whole composer.
|
// UseStateProvider below so opening the pop-out doesn't re-render the
|
||||||
const dockedEmojiBoard = dockEmojiBoard && emojiBoardTab !== undefined && (
|
// whole composer.
|
||||||
<EmojiBoard
|
const centeredEmojiBoard = centerEmojiBoard && emojiBoardTab !== undefined && (
|
||||||
tab={emojiBoardTab}
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
onTabChange={setEmojiBoardTab}
|
<OverlayCenter>
|
||||||
imagePackRooms={imagePackRooms}
|
<FocusTrap
|
||||||
returnFocusOnDeactivate={false}
|
focusTrapOptions={{
|
||||||
dock
|
initialFocus: false,
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
clickOutsideDeactivates: true,
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
onDeactivate: () => setEmojiBoardTab(undefined),
|
||||||
onStickerSelect={handleStickerSelect}
|
escapeDeactivates: stopPropagation,
|
||||||
requestClose={() => setEmojiBoardTab(undefined)}
|
}}
|
||||||
/>
|
>
|
||||||
|
<EmojiBoard
|
||||||
|
tab={emojiBoardTab}
|
||||||
|
onTabChange={setEmojiBoardTab}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
returnFocusOnDeactivate={false}
|
||||||
|
onEmojiSelect={handleEmoticonSelect}
|
||||||
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
|
onStickerSelect={handleStickerSelect}
|
||||||
|
requestClose={() => setEmojiBoardTab(undefined)}
|
||||||
|
/>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
);
|
);
|
||||||
|
|
||||||
const emojiButton = dockEmojiBoard ? (
|
const emojiButton = centerEmojiBoard ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={emojiBtnRef}
|
ref={emojiBtnRef}
|
||||||
aria-pressed={!!emojiBoardTab}
|
aria-pressed={!!emojiBoardTab}
|
||||||
|
|
@ -873,39 +1265,6 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref}>
|
<div ref={ref}>
|
||||||
{selectedFiles.length > 0 && (
|
|
||||||
<UploadBoard
|
|
||||||
header={
|
|
||||||
<UploadBoardHeader
|
|
||||||
open={uploadBoard}
|
|
||||||
onToggle={() => setUploadBoard(!uploadBoard)}
|
|
||||||
uploadFamilyObserverAtom={uploadFamilyObserverAtom}
|
|
||||||
onSend={handleSendUpload}
|
|
||||||
imperativeHandlerRef={uploadBoardHandlers}
|
|
||||||
onCancel={handleCancelUpload}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{uploadBoard && (
|
|
||||||
<Scroll size="300" hideTrack visibility="Hover">
|
|
||||||
<UploadBoardContent>
|
|
||||||
{Array.from(selectedFiles)
|
|
||||||
.reverse()
|
|
||||||
.map((fileItem, index) => (
|
|
||||||
<UploadCardRenderer
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
key={index}
|
|
||||||
isEncrypted={!!fileItem.encInfo}
|
|
||||||
fileItem={fileItem}
|
|
||||||
setMetadata={handleFileMetadata}
|
|
||||||
onRemove={handleRemoveUpload}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</UploadBoardContent>
|
|
||||||
</Scroll>
|
|
||||||
)}
|
|
||||||
</UploadBoard>
|
|
||||||
)}
|
|
||||||
<Overlay
|
<Overlay
|
||||||
open={dropZoneVisible && !textOnly}
|
open={dropZoneVisible && !textOnly}
|
||||||
backdrop={<OverlayBackdrop />}
|
backdrop={<OverlayBackdrop />}
|
||||||
|
|
@ -975,7 +1334,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
}
|
}
|
||||||
top={
|
top={
|
||||||
<>
|
<>
|
||||||
{dockedEmojiBoard}
|
{centeredEmojiBoard}
|
||||||
|
{/* Attachment strip — the in-form record of attached media
|
||||||
|
(replaces the old floating UploadBoard window). Visible in
|
||||||
|
edit mode too: the m.replace send ignores it, and the
|
||||||
|
attachments are still there when the edit ends. */}
|
||||||
|
<ComposerAttachments
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
setMetadata={handleFileMetadata}
|
||||||
|
onRemove={handleRemoveUpload}
|
||||||
|
/>
|
||||||
{voiceError && (
|
{voiceError && (
|
||||||
<Box
|
<Box
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
|
|
@ -996,7 +1364,50 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{replyDraft && (
|
{/* Edit banner — Telegram-style: pencil + «Editing» + the
|
||||||
|
original text, with [×] to cancel (Esc does the same).
|
||||||
|
Same chrome slot/structure as the reply preview below. */}
|
||||||
|
{editDraft && (
|
||||||
|
<div>
|
||||||
|
<Box
|
||||||
|
alignItems="Center"
|
||||||
|
gap="300"
|
||||||
|
style={{ padding: `${config.space.S200} ${config.space.S300} 0` }}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
onClick={() => setEditDraft(undefined)}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
aria-label={t('Room.editing_cancel')}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} size="50" />
|
||||||
|
</IconButton>
|
||||||
|
<Box direction="Row" gap="200" alignItems="Center">
|
||||||
|
<Icon
|
||||||
|
src={Icons.Pencil}
|
||||||
|
size="50"
|
||||||
|
style={{ color: color.Primary.Main, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
<ReplyLayout
|
||||||
|
userColor={color.Primary.Main}
|
||||||
|
username={
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
<b>{t('Room.editing_message')}</b>
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Text size="T300" truncate>
|
||||||
|
{editTarget?.body ? trimReplyFromBody(editTarget.body) : ''}
|
||||||
|
</Text>
|
||||||
|
</ReplyLayout>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Reply preview hides while editing (the edit owns the send);
|
||||||
|
the reply draft itself is preserved and returns after. */}
|
||||||
|
{replyDraft && !editDraft && (
|
||||||
<div>
|
<div>
|
||||||
<Box
|
<Box
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
|
|
@ -1044,7 +1455,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
after={
|
after={
|
||||||
singleRow && !voiceMode ? (
|
singleRow && !voiceMode ? (
|
||||||
<>
|
<>
|
||||||
{!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
|
{/* Mic hides while editing — a recording would ship a NEW
|
||||||
|
message under a pending edit banner. */}
|
||||||
|
{!textOnly &&
|
||||||
|
!editDraft &&
|
||||||
|
voiceSupported &&
|
||||||
|
voiceDisabledBy === undefined &&
|
||||||
|
micButton}
|
||||||
{!textOnly && emojiButton}
|
{!textOnly && emojiButton}
|
||||||
{sendButton}
|
{sendButton}
|
||||||
</>
|
</>
|
||||||
|
|
@ -1059,7 +1476,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
>
|
>
|
||||||
{!textOnly && plusButton}
|
{!textOnly && plusButton}
|
||||||
<Box grow="Yes" />
|
<Box grow="Yes" />
|
||||||
{!textOnly && voiceSupported && voiceDisabledBy === undefined && micButton}
|
{/* Mic hides while editing — a recording would ship a NEW
|
||||||
|
message under a pending edit banner. */}
|
||||||
|
{!textOnly &&
|
||||||
|
!editDraft &&
|
||||||
|
voiceSupported &&
|
||||||
|
voiceDisabledBy === undefined &&
|
||||||
|
micButton}
|
||||||
{!textOnly && emojiButton}
|
{!textOnly && emojiButton}
|
||||||
{sendButton}
|
{sendButton}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
|
||||||
import { RecipeVariants, recipe } from '@vanilla-extract/recipes';
|
|
||||||
import { DefaultReset, color, config, toRem } from 'folds';
|
import { DefaultReset, color, config, toRem } from 'folds';
|
||||||
import {
|
import {
|
||||||
VOJO_BUBBLE_BAND_PX,
|
VOJO_BUBBLE_BAND_PX,
|
||||||
|
|
@ -101,31 +100,6 @@ export const TimelineScroll = style({
|
||||||
scrollbarColor: `${color.SurfaceVariant.ContainerLine} transparent`,
|
scrollbarColor: `${color.SurfaceVariant.ContainerLine} transparent`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TimelineFloat = recipe({
|
|
||||||
base: [
|
|
||||||
DefaultReset,
|
|
||||||
{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
zIndex: 1,
|
|
||||||
minWidth: 'max-content',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
variants: {
|
|
||||||
position: {
|
|
||||||
Top: {
|
|
||||||
top: config.space.S400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
position: 'Top',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TimelineFloatVariants = RecipeVariants<typeof TimelineFloat>;
|
|
||||||
|
|
||||||
// "Jump to latest" FAB. Bottom-right, circular, lavender brand accent.
|
// "Jump to latest" FAB. Bottom-right, circular, lavender brand accent.
|
||||||
// `data-hidden` encodes visibility (state inline-styled would clobber the
|
// `data-hidden` encodes visibility (state inline-styled would clobber the
|
||||||
// `:active` press feedback). Inline `bottom` at the use site offsets for
|
// `:active` press feedback). Inline `bottom` at the use site offsets for
|
||||||
|
|
|
||||||
|
|
@ -22,12 +22,11 @@ import {
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||||
import classNames from 'classnames';
|
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
import { DEFAULT_EXPIRE_DURATION, SessionMembershipData } from 'matrix-js-sdk/lib/matrixrtc';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { Box, Chip, Icon, Icons, Text, as, config } from 'folds';
|
import { Box, Icon, Icons, Text, config } from 'folds';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
@ -96,9 +95,14 @@ import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResize
|
||||||
import * as css from './RoomTimeline.css';
|
import * as css from './RoomTimeline.css';
|
||||||
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
|
import { inSameDay, minuteDifference, timeDayMonYear, today, yesterday } from '../../utils/time';
|
||||||
import { isEmptyEditor } from '../../components/editor';
|
import { isEmptyEditor } from '../../components/editor';
|
||||||
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import {
|
||||||
|
draftKey,
|
||||||
|
roomIdToEditDraftAtomFamily,
|
||||||
|
roomIdToReplyDraftAtomFamily,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
|
|
@ -127,19 +131,6 @@ import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { RoomTimelineTyping } from './RoomTimelineTyping';
|
import { RoomTimelineTyping } from './RoomTimelineTyping';
|
||||||
import { MessageErrorBoundary } from './MessageErrorBoundary';
|
import { MessageErrorBoundary } from './MessageErrorBoundary';
|
||||||
|
|
||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
|
||||||
({ position, className, ...props }, ref) => (
|
|
||||||
<Box
|
|
||||||
className={classNames(css.TimelineFloat({ position }), className)}
|
|
||||||
justifyContent="Center"
|
|
||||||
alignItems="Center"
|
|
||||||
gap="200"
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getLiveTimeline = (room: Room): EventTimeline =>
|
export const getLiveTimeline = (room: Room): EventTimeline =>
|
||||||
room.getUnfilteredTimelineSet().getLiveTimeline();
|
room.getUnfilteredTimelineSet().getLiveTimeline();
|
||||||
|
|
||||||
|
|
@ -593,6 +584,9 @@ export function RoomTimeline({
|
||||||
// / DM / legacy timeline). Drawer composer manages its own per-thread
|
// / DM / legacy timeline). Drawer composer manages its own per-thread
|
||||||
// reply draft via DraftKey([roomId, rootId]) inside RoomInput.
|
// reply draft via DraftKey([roomId, rootId]) inside RoomInput.
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId)));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId)));
|
||||||
|
// Edit-draft setter — same MAIN-composer scope. «Edit» loads the message
|
||||||
|
// into the composer (RoomInput's edit banner) instead of an in-timeline form.
|
||||||
|
const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId)));
|
||||||
const powerLevels = usePowerLevelsContext();
|
const powerLevels = usePowerLevelsContext();
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
|
|
||||||
|
|
@ -613,6 +607,14 @@ export function RoomTimeline({
|
||||||
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
const canDeleteOwn = permissions.event(MessageEvent.RoomRedaction, mx.getSafeUserId());
|
||||||
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
const canSendReaction = permissions.event(MessageEvent.Reaction, mx.getSafeUserId());
|
||||||
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
const canPinEvent = permissions.stateEvent(StateEvent.RoomPinnedEvents, mx.getSafeUserId());
|
||||||
|
// Mirror of RoomView's composer mount condition (tombstone / canMessage /
|
||||||
|
// thread drawer). Editing happens IN the composer now, so the Edit
|
||||||
|
// affordance must hide whenever the main composer is unmounted —
|
||||||
|
// otherwise the tap silently writes an edit draft no one consumes, which
|
||||||
|
// then hijacks the composer if it mounts later (e.g. permission restored).
|
||||||
|
const canMessage = permissions.event(MessageEvent.RoomMessage, mx.getSafeUserId());
|
||||||
|
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
||||||
|
const mainComposerSuspended = threadDrawerOpen || !canMessage || !!tombstoneEvent;
|
||||||
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
|
|
@ -735,21 +737,20 @@ export function RoomTimeline({
|
||||||
return () => scrollEl.removeEventListener('scroll', onScroll);
|
return () => scrollEl.removeEventListener('scroll', onScroll);
|
||||||
}, [getScrollElement, setOpenMessageId]);
|
}, [getScrollElement, setOpenMessageId]);
|
||||||
|
|
||||||
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
|
const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
|
||||||
useVirtualPaginator({
|
count: eventsLength,
|
||||||
count: eventsLength,
|
limit: PAGINATION_LIMIT,
|
||||||
limit: PAGINATION_LIMIT,
|
range: timeline.range,
|
||||||
range: timeline.range,
|
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
||||||
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
|
getScrollElement,
|
||||||
getScrollElement,
|
getItemElement: useCallback(
|
||||||
getItemElement: useCallback(
|
(index: number) =>
|
||||||
(index: number) =>
|
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
||||||
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
|
undefined,
|
||||||
undefined,
|
[]
|
||||||
[]
|
),
|
||||||
),
|
onEnd: handleTimelinePagination,
|
||||||
onEnd: handleTimelinePagination,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const loadEventTimeline = useEventTimelineLoader(
|
const loadEventTimeline = useEventTimelineLoader(
|
||||||
mx,
|
mx,
|
||||||
|
|
@ -947,7 +948,6 @@ export function RoomTimeline({
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editId,
|
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleOpenReply,
|
handleOpenReply,
|
||||||
handleUserClick,
|
handleUserClick,
|
||||||
|
|
@ -957,8 +957,9 @@ export function RoomTimeline({
|
||||||
} = useMessageInteractionHandlers({
|
} = useMessageInteractionHandlers({
|
||||||
room,
|
room,
|
||||||
editor,
|
editor,
|
||||||
composerSuspended: threadDrawerOpen,
|
composerSuspended: mainComposerSuspended,
|
||||||
setReplyDraft,
|
setReplyDraft,
|
||||||
|
setEditDraft,
|
||||||
onOpenEvent: handleOpenEvent,
|
onOpenEvent: handleOpenEvent,
|
||||||
channelsMode,
|
channelsMode,
|
||||||
isBridged,
|
isBridged,
|
||||||
|
|
@ -1263,22 +1264,6 @@ export function RoomTimeline({
|
||||||
}
|
}
|
||||||
}, [unread]);
|
}, [unread]);
|
||||||
|
|
||||||
// scroll out of view msg editor in view.
|
|
||||||
useEffect(() => {
|
|
||||||
if (editId) {
|
|
||||||
const editMsgElement =
|
|
||||||
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
|
||||||
undefined;
|
|
||||||
if (editMsgElement) {
|
|
||||||
scrollToElement(editMsgElement, {
|
|
||||||
align: 'center',
|
|
||||||
behavior: 'smooth',
|
|
||||||
stopInView: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [scrollToElement, editId]);
|
|
||||||
|
|
||||||
const handleJumpToLatest = () => {
|
const handleJumpToLatest = () => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
navigateRoom(room.roomId, undefined, { replace: true });
|
navigateRoom(room.roomId, undefined, { replace: true });
|
||||||
|
|
@ -1288,17 +1273,6 @@ export function RoomTimeline({
|
||||||
scrollToBottomRef.current.smooth = false;
|
scrollToBottomRef.current.smooth = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJumpToUnread = () => {
|
|
||||||
if (unreadInfo?.readUptoEventId) {
|
|
||||||
setTimeline(getEmptyTimeline());
|
|
||||||
loadEventTimeline(unreadInfo.readUptoEventId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
|
||||||
};
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Sticky day capsules (every room class — 1:1 bubble, group, channel). Each
|
// Sticky day capsules (every room class — 1:1 bubble, group, channel). Each
|
||||||
|
|
@ -1697,7 +1671,6 @@ export function RoomTimeline({
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
railHidden={railHidden}
|
railHidden={railHidden}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
edit={editId === mEventId}
|
|
||||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
|
|
@ -1707,7 +1680,7 @@ export function RoomTimeline({
|
||||||
onUsernameClick={handleUsernameClick}
|
onUsernameClick={handleUsernameClick}
|
||||||
onReplyClick={handleReplyClick}
|
onReplyClick={handleReplyClick}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
onEditId={handleEdit}
|
onEditId={mainComposerSuspended ? undefined : handleEdit}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
replyEventId && (
|
||||||
<Reply
|
<Reply
|
||||||
|
|
@ -1830,7 +1803,6 @@ export function RoomTimeline({
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
railHidden={railHidden}
|
railHidden={railHidden}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
edit={editId === mEventId}
|
|
||||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
|
|
@ -1840,7 +1812,7 @@ export function RoomTimeline({
|
||||||
onUsernameClick={handleUsernameClick}
|
onUsernameClick={handleUsernameClick}
|
||||||
onReplyClick={handleReplyClick}
|
onReplyClick={handleReplyClick}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
onEditId={handleEdit}
|
onEditId={mainComposerSuspended ? undefined : handleEdit}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
replyEventId && (
|
||||||
<Reply
|
<Reply
|
||||||
|
|
@ -2671,36 +2643,11 @@ export function RoomTimeline({
|
||||||
return eventJSX;
|
return eventJSX;
|
||||||
};
|
};
|
||||||
|
|
||||||
const unreadFloatShown = !!unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box grow="Yes" style={{ position: 'relative' }}>
|
<Box grow="Yes" style={{ position: 'relative' }}>
|
||||||
{/* Day dates are the inline capsules themselves (every room class), made
|
{/* Day dates are the inline capsules themselves (every room class), made
|
||||||
sticky via real CSS `position: sticky` (engaged by the effect above) —
|
sticky via real CSS `position: sticky` (engaged by the effect above) —
|
||||||
no separate floating pill. */}
|
no separate floating pill. */}
|
||||||
{unreadFloatShown && (
|
|
||||||
<TimelineFloat position="Top">
|
|
||||||
<Chip
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.MessageUnread} />}
|
|
||||||
onClick={handleJumpToUnread}
|
|
||||||
>
|
|
||||||
<Text size="L400">{t('Room.jump_to_unread')}</Text>
|
|
||||||
</Chip>
|
|
||||||
|
|
||||||
<Chip
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
radii="Pill"
|
|
||||||
outlined
|
|
||||||
before={<Icon size="50" src={Icons.CheckTwice} />}
|
|
||||||
onClick={handleMarkAsRead}
|
|
||||||
>
|
|
||||||
<Text size="L400">{t('Room.mark_as_read')}</Text>
|
|
||||||
</Chip>
|
|
||||||
</TimelineFloat>
|
|
||||||
)}
|
|
||||||
<div ref={scrollRef} className={css.TimelineScroll}>
|
<div ref={scrollRef} className={css.TimelineScroll}>
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,11 @@ import {
|
||||||
// surface mirror the same curvature. The thread-drawer composer in
|
// surface mirror the same curvature. The thread-drawer composer in
|
||||||
// `ThreadDrawer.tsx` also wraps `RoomInput` with this class
|
// `ThreadDrawer.tsx` also wraps `RoomInput` with this class
|
||||||
// (`${ThreadComposer} ${ChatComposer}`), so it inherits both the dark
|
// (`${ThreadComposer} ${ChatComposer}`), so it inherits both the dark
|
||||||
// card chrome and the compact two-row geometry. The message-edit overlay
|
// card chrome and the compact two-row geometry. `Editor.preview.tsx`
|
||||||
// and `Editor.preview.tsx` mount `CustomEditor` directly without the
|
// mounts `CustomEditor` directly without the `ChatComposer` wrap, so it
|
||||||
// `ChatComposer` wrap, so they keep the folds-default pill R400 +
|
// keeps the folds-default pill R400 + SurfaceVariant fill. (Message
|
||||||
// SurfaceVariant fill. The touch-hover gate at the bottom of this file
|
// editing happens IN the composer now — the old in-timeline edit overlay
|
||||||
|
// is gone.) The touch-hover gate at the bottom of this file
|
||||||
// also covers `RoomTombstone` / `RoomInputPlaceholder` since they share
|
// also covers `RoomTombstone` / `RoomInputPlaceholder` since they share
|
||||||
// the wrap, which is intentional: their action buttons benefit from the
|
// the wrap, which is intentional: their action buttons benefit from the
|
||||||
// same Android-WebView stuck-:hover suppression.
|
// same Android-WebView stuck-:hover suppression.
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { EventType } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
|
|
@ -54,6 +55,7 @@ const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function RoomView({ eventId }: { eventId?: string }) {
|
export function RoomView({ eventId }: { eventId?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const roomInputRef = useRef<HTMLDivElement>(null);
|
const roomInputRef = useRef<HTMLDivElement>(null);
|
||||||
const roomViewRef = useRef<HTMLDivElement>(null);
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
||||||
const composerWrapRef = useRef<HTMLDivElement>(null);
|
const composerWrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -200,7 +202,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
>
|
>
|
||||||
<Text align="Center">You do not have permission to post in this room</Text>
|
<Text align="Center">{t('Room.no_post_permission')}</Text>
|
||||||
</RoomInputPlaceholder>
|
</RoomInputPlaceholder>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,6 @@ import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
|
||||||
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
||||||
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
|
||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||||
|
|
@ -42,7 +41,6 @@ import {
|
||||||
useRoomMembersSheetState,
|
useRoomMembersSheetState,
|
||||||
} from '../../state/hooks/roomMembersSheet';
|
} from '../../state/hooks/roomMembersSheet';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import {
|
import {
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
|
|
@ -179,7 +177,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
||||||
const membersSheetState = useRoomMembersSheetState();
|
const membersSheetState = useRoomMembersSheetState();
|
||||||
const membersSheetOpen = membersSheetState?.roomId === room.roomId;
|
const membersSheetOpen = membersSheetState?.roomId === room.roomId;
|
||||||
const parentSpace = useSpaceOptionally();
|
const parentSpace = useSpaceOptionally();
|
||||||
const openSettings = useOpenRoomSettings();
|
|
||||||
|
|
||||||
// The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same
|
// The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same
|
||||||
// chrome on desktop and mobile.
|
// chrome on desktop and mobile.
|
||||||
|
|
@ -203,13 +200,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
||||||
openRoomMembersSheet(room.roomId);
|
openRoomMembersSheet(room.roomId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// `callView` keeps the legacy «open Members in Settings» fallback —
|
|
||||||
// the members side-pane isn't mounted while we're inside the call
|
|
||||||
// surface, so a separate path is still needed to reach the list.
|
|
||||||
const handleCallViewMembers = () => {
|
|
||||||
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const avatarNode = (
|
const avatarNode = (
|
||||||
<Avatar size="300">
|
<Avatar size="300">
|
||||||
{isOneOnOne && peerUserId ? (
|
{isOneOnOne && peerUserId ? (
|
||||||
|
|
@ -356,30 +346,9 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
||||||
<Box shrink="No" alignItems="Center">
|
<Box shrink="No" alignItems="Center">
|
||||||
{callButtonVisible && <DmCallButton room={room} />}
|
{callButtonVisible && <DmCallButton room={room} />}
|
||||||
|
|
||||||
{/* Member toggle — kept only for `callView` (1:1 or group): the
|
{/* No separate members button in callView — CallView renders its
|
||||||
members side-pane / horseshoe isn't mounted under the call
|
own member list, and the Room-Settings→Members page this used
|
||||||
surface, so we still need a way to reach the member list
|
to open was removed with the room-settings redesign. */}
|
||||||
via Room Settings → Members. Non-call group rooms now use
|
|
||||||
the identity button above (tap on avatar+title) — keeps
|
|
||||||
the gesture symmetrical with 1:1 peer profile. */}
|
|
||||||
{callView && screenSize === ScreenSize.Desktop && (
|
|
||||||
<TooltipProvider
|
|
||||||
position="Bottom"
|
|
||||||
offset={4}
|
|
||||||
tooltip={
|
|
||||||
<Tooltip>
|
|
||||||
<Text>{t('Room.members')}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{(triggerRef) => (
|
|
||||||
<IconButton fill="None" ref={triggerRef} onClick={handleCallViewMembers}>
|
|
||||||
<Icon size="400" src={Icons.User} />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
align="End"
|
align="End"
|
||||||
|
|
|
||||||
|
|
@ -519,6 +519,12 @@ function MobileMembersHorseshoe({ header, children }: RoomViewMembersPanelProps)
|
||||||
<UserRoomProfile
|
<UserRoomProfile
|
||||||
userId={profileState.userId}
|
userId={profileState.userId}
|
||||||
onAvatarClick={() => setUserAvatarMode(true)}
|
onAvatarClick={() => setUserAvatarMode(true)}
|
||||||
|
// Back to the room card (members sheet) — `openSheet`
|
||||||
|
// clears the profile atom via the hooks' mutual
|
||||||
|
// exclusion, so the silhouette body swaps in place
|
||||||
|
// (both atoms keep `open` true → no close/reopen
|
||||||
|
// animation).
|
||||||
|
onBack={() => openSheet(room.roomId)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -553,6 +553,10 @@ function MobileProfileHorseshoe({ header, children }: RoomViewProfilePanelProps)
|
||||||
<UserRoomProfile
|
<UserRoomProfile
|
||||||
userId={renderUserId}
|
userId={renderUserId}
|
||||||
onAvatarClick={() => setAvatarMode(true)}
|
onAvatarClick={() => setAvatarMode(true)}
|
||||||
|
// Root card (1:1 peer profile) — nothing to go
|
||||||
|
// back to, so the top-left affordance is a
|
||||||
|
// plain close cross (redesign item 15).
|
||||||
|
onClose={close}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,8 @@ import FocusTrap from 'focus-trap-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
||||||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useOpenRoomMembersSheet } from '../../state/hooks/roomMembersSheet';
|
||||||
|
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
||||||
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
|
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { PageHeader } from '../../components/page';
|
import { PageHeader } from '../../components/page';
|
||||||
|
|
@ -25,6 +26,8 @@ export function RoomViewProfileSidePanel() {
|
||||||
const profileState = useAtomValue(userRoomProfileAtom);
|
const profileState = useAtomValue(userRoomProfileAtom);
|
||||||
const close = useCloseUserRoomProfile();
|
const close = useCloseUserRoomProfile();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
|
const isOneOnOne = useIsOneOnOne();
|
||||||
|
const openMembersSheet = useOpenRoomMembersSheet();
|
||||||
|
|
||||||
const open = !!profileState;
|
const open = !!profileState;
|
||||||
// Inline avatar-expanded mode: the hero avatar stretches to fill
|
// Inline avatar-expanded mode: the hero avatar stretches to fill
|
||||||
|
|
@ -78,6 +81,25 @@ export function RoomViewProfileSidePanel() {
|
||||||
chat header uses, so the bottom rule lines up with the
|
chat header uses, so the bottom rule lines up with the
|
||||||
adjacent `RoomViewHeaderDm` row to the pixel. */}
|
adjacent `RoomViewHeaderDm` row to the pixel. */}
|
||||||
<PageHeader className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}>
|
<PageHeader className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}>
|
||||||
|
{/* Group rooms: leading back arrow returns to the members sheet
|
||||||
|
(the room card) the profile replaced in this pane — the open
|
||||||
|
hook clears the profile atom, so the panes swap in place
|
||||||
|
(redesign item 15). 1:1 has no members sheet → no arrow.
|
||||||
|
Call rooms neither: Room.tsx mounts the members side pane only
|
||||||
|
when `!callView`, so opening the sheet atom from inside a call
|
||||||
|
would close the profile into nothing AND leak a stale atom
|
||||||
|
that auto-opens the pane in the next group room. */}
|
||||||
|
{!isOneOnOne && !room.isCallRoom() && (
|
||||||
|
<Box shrink="No" alignItems="Center">
|
||||||
|
<IconButton
|
||||||
|
fill="None"
|
||||||
|
onClick={() => openMembersSheet(room.roomId)}
|
||||||
|
aria-label={t('User.back')}
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.ArrowLeft} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
<Box grow="Yes" alignItems="Center">
|
<Box grow="Yes" alignItems="Center">
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{t('User.profile_title')}
|
{t('User.profile_title')}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,11 @@ import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
import { usePowerLevelTags } from '../../hooks/usePowerLevelTags';
|
||||||
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
import { useAccessiblePowerTagColors, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag';
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||||
import { draftKey, roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
import {
|
||||||
|
draftKey,
|
||||||
|
roomIdToEditDraftAtomFamily,
|
||||||
|
roomIdToReplyDraftAtomFamily,
|
||||||
|
} from '../../state/room/roomInputDrafts';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../state/settings';
|
import { settingsAtom } from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
|
|
@ -383,6 +387,9 @@ export function ThreadDrawer({
|
||||||
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
|
// `draftKey(roomId, threadId)` (see `roomInputDrafts.ts:34-37`) so
|
||||||
// the chip surfaces inside the drawer composer.
|
// the chip surfaces inside the drawer composer.
|
||||||
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));
|
const setReplyDraft = useSetAtom(roomIdToReplyDraftAtomFamily(draftKey(room.roomId, rootId)));
|
||||||
|
// Edit-draft scope: same tuple key — «Edit» on a thread row loads it into
|
||||||
|
// the DRAWER composer (its RoomInput subscribes to this slot).
|
||||||
|
const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(draftKey(room.roomId, rootId)));
|
||||||
|
|
||||||
// Reply-chip click scrolls within the drawer to the matching
|
// Reply-chip click scrolls within the drawer to the matching
|
||||||
// `data-message-id` row. If the target lives in the main timeline
|
// `data-message-id` row. If the target lives in the main timeline
|
||||||
|
|
@ -397,7 +404,6 @@ export function ThreadDrawer({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
editId,
|
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleOpenReply,
|
handleOpenReply,
|
||||||
handleUserClick,
|
handleUserClick,
|
||||||
|
|
@ -417,6 +423,7 @@ export function ThreadDrawer({
|
||||||
// bail before touching the unmounted editor.
|
// bail before touching the unmounted editor.
|
||||||
composerSuspended: !canMessage,
|
composerSuspended: !canMessage,
|
||||||
setReplyDraft,
|
setReplyDraft,
|
||||||
|
setEditDraft,
|
||||||
onOpenEvent: handleScrollToDrawerEvent,
|
onOpenEvent: handleScrollToDrawerEvent,
|
||||||
channelsMode,
|
channelsMode,
|
||||||
isBridged,
|
isBridged,
|
||||||
|
|
@ -1136,7 +1143,6 @@ export function ThreadDrawer({
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
collapse={false}
|
collapse={false}
|
||||||
highlight={false}
|
highlight={false}
|
||||||
edit={editId === eventId}
|
|
||||||
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
canDelete={canRedact || (canDeleteOwn && mEvent.getSender() === mx.getUserId())}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
canPinEvent={canPinEvent}
|
canPinEvent={canPinEvent}
|
||||||
|
|
@ -1146,7 +1152,7 @@ export function ThreadDrawer({
|
||||||
onUsernameClick={handleUsernameClick}
|
onUsernameClick={handleUsernameClick}
|
||||||
onReplyClick={handleReplyClick}
|
onReplyClick={handleReplyClick}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
onEditId={handleEdit}
|
onEditId={canMessage ? handleEdit : undefined}
|
||||||
reply={
|
reply={
|
||||||
showReplyChip && (
|
showReplyChip && (
|
||||||
<Reply
|
<Reply
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,6 @@ import {
|
||||||
UsernameBold,
|
UsernameBold,
|
||||||
} from '../../../components/message';
|
} from '../../../components/message';
|
||||||
import { StreamMediaContext } from '../../../components/RenderMessageContent';
|
import { StreamMediaContext } from '../../../components/RenderMessageContent';
|
||||||
import { ChatComposer } from '../RoomView.css';
|
|
||||||
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
|
import { logMedia } from '../../../components/message/attachment/streamMediaDebug';
|
||||||
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
|
import { canEditEvent, getEventEdits, getMemberDisplayName } from '../../../utils/room';
|
||||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
|
|
@ -73,7 +72,6 @@ import { TextViewer } from '../../../components/text-viewer';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { ReactionViewer } from '../reaction-viewer';
|
import { ReactionViewer } from '../reaction-viewer';
|
||||||
import { MessageEditor } from './MessageEditor';
|
|
||||||
import { copyToClipboard } from '../../../utils/dom';
|
import { copyToClipboard } from '../../../utils/dom';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
import { stopPropagation } from '../../../utils/keyboard';
|
||||||
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
||||||
|
|
@ -343,22 +341,8 @@ export const MessageReadReceiptItem = as<
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Overlay open={open} backdrop={<OverlayBackdrop />}>
|
{/* EventReaders owns its Overlay + FocusTrap (self-contained Vojo card). */}
|
||||||
<OverlayCenter>
|
{open && <EventReaders room={room} eventId={eventId} requestClose={handleClose} />}
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: handleClose,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Modal variant="Surface" size="300">
|
|
||||||
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
|
|
||||||
</Modal>
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
after={<Icon size="100" src={RailIcons.Receipts} />}
|
after={<Icon size="100" src={RailIcons.Receipts} />}
|
||||||
|
|
@ -809,7 +793,6 @@ export type MessageProps = {
|
||||||
// by the channel layout.
|
// by the channel layout.
|
||||||
railHidden?: boolean;
|
railHidden?: boolean;
|
||||||
highlight: boolean;
|
highlight: boolean;
|
||||||
edit?: boolean;
|
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
canSendReaction?: boolean;
|
canSendReaction?: boolean;
|
||||||
canPinEvent?: boolean;
|
canPinEvent?: boolean;
|
||||||
|
|
@ -930,7 +913,6 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// but unused by the bubble/channel layouts. Pending the stream-rail cleanup.
|
// but unused by the bubble/channel layouts. Pending the stream-rail cleanup.
|
||||||
railHidden: _railHidden,
|
railHidden: _railHidden,
|
||||||
highlight,
|
highlight,
|
||||||
edit,
|
|
||||||
canDelete,
|
canDelete,
|
||||||
canSendReaction,
|
canSendReaction,
|
||||||
canPinEvent,
|
canPinEvent,
|
||||||
|
|
@ -1010,7 +992,7 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
if (id) setOpenMessageId((prev) => (prev === id ? null : id));
|
if (id) setOpenMessageId((prev) => (prev === id ? null : id));
|
||||||
};
|
};
|
||||||
const handleBubbleToggle: MouseEventHandler<HTMLDivElement> = (evt) => {
|
const handleBubbleToggle: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||||
if (!isBubble || edit) return;
|
if (!isBubble) return;
|
||||||
// Don't hijack a text selection, nor taps on interactive children (links,
|
// Don't hijack a text selection, nor taps on interactive children (links,
|
||||||
// buttons, media players, the waveform slider, the action rail, reaction
|
// buttons, media players, the waveform slider, the action rail, reaction
|
||||||
// chips) — those run their own action and must not also toggle the rail.
|
// chips) — those run their own action and must not also toggle the rail.
|
||||||
|
|
@ -1030,7 +1012,7 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// holds focus (not a child link/button), so Enter on a focused link still
|
// holds focus (not a child link/button), so Enter on a focused link still
|
||||||
// follows it. Replaces the focus-within reveal the hover rail used to give.
|
// follows it. Replaces the focus-within reveal the hover rail used to give.
|
||||||
const handleBubbleKeyDown: KeyboardEventHandler<HTMLDivElement> = (evt) => {
|
const handleBubbleKeyDown: KeyboardEventHandler<HTMLDivElement> = (evt) => {
|
||||||
if (!isBubble || edit) return;
|
if (!isBubble) return;
|
||||||
if (evt.target !== evt.currentTarget) return;
|
if (evt.target !== evt.currentTarget) return;
|
||||||
if (evt.key === 'Enter' || evt.key === ' ') {
|
if (evt.key === 'Enter' || evt.key === ' ') {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
@ -1048,20 +1030,19 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// a local useState here sidesteps the commit↔effect race where
|
// a local useState here sidesteps the commit↔effect race where
|
||||||
// decryption fires between render and listener attach.
|
// decryption fires between render and listener attach.
|
||||||
const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
|
const isMediaMessage = msgType === MsgType.Image || msgType === MsgType.Video;
|
||||||
const mediaMode = isMediaMessage && !edit;
|
const mediaMode = isMediaMessage;
|
||||||
// Voice notes are self-chromed cards — VoiceContent draws its own avatar +
|
// Voice notes are self-chromed cards — VoiceContent draws its own avatar +
|
||||||
// bubble. Collapse the asymmetric Stream bubble (DM) and drop the channel
|
// bubble. Collapse the asymmetric Stream bubble (DM) and drop the channel
|
||||||
// avatar (group) so a voice note renders identically for own/other, the only
|
// avatar (group) so a voice note renders identically for own/other, the only
|
||||||
// difference being the bubble fill. See docs/plans/voice_messages.md §5.
|
// difference being the bubble fill. See docs/plans/voice_messages.md §5.
|
||||||
const isVoiceMessage = msgType === MsgType.Audio && isVoiceMessageContent(mEvent.getContent());
|
const isVoiceMessage = msgType === MsgType.Audio && isVoiceMessageContent(mEvent.getContent());
|
||||||
const voiceMode = isVoiceMessage && !edit;
|
const voiceMode = isVoiceMessage;
|
||||||
|
|
||||||
if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
|
if (msgType === MsgType.Image || msgType === MsgType.Video || msgType === MsgType.File) {
|
||||||
logMedia('Message', {
|
logMedia('Message', {
|
||||||
eventId: mEvent.getId(),
|
eventId: mEvent.getId(),
|
||||||
msgType,
|
msgType,
|
||||||
isMediaMessage,
|
isMediaMessage,
|
||||||
edit,
|
|
||||||
mediaMode,
|
mediaMode,
|
||||||
isMobile,
|
isMobile,
|
||||||
screenSize,
|
screenSize,
|
||||||
|
|
@ -1082,29 +1063,12 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
const msgContentJSX = (
|
const msgContentJSX = (
|
||||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%', width: '100%' }}>
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%', width: '100%' }}>
|
||||||
{reply}
|
{reply}
|
||||||
{edit && onEditId ? (
|
{children}
|
||||||
// Edit form styled as the main composer — a single rounded card
|
|
||||||
// (`ChatComposer` paints the inner Editor with Surface.Container + 32px
|
|
||||||
// radius). The bubble drops its own chrome while editing (see
|
|
||||||
// BubbleLayout `editing`) so it's ONE box, not a bubble inside a bubble.
|
|
||||||
<div className={ChatComposer} style={{ width: '100%' }}>
|
|
||||||
<MessageEditor
|
|
||||||
style={{ maxWidth: '100%', width: '100%' }}
|
|
||||||
roomId={room.roomId}
|
|
||||||
room={room}
|
|
||||||
mEvent={mEvent}
|
|
||||||
imagePackRooms={imagePackRooms}
|
|
||||||
onCancel={() => onEditId()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
children
|
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||||
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
|
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
|
||||||
const tag = (evt.target as Element).tagName;
|
const tag = (evt.target as Element).tagName;
|
||||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
@ -1153,8 +1117,7 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// bar can render in two places: as a row-anchored overlay for channel/stream
|
// bar can render in two places: as a row-anchored overlay for channel/stream
|
||||||
// rows, and — for the bubble layout — passed into BubbleLayout so it floats
|
// rows, and — for the bubble layout — passed into BubbleLayout so it floats
|
||||||
// centred just above the bubble itself.
|
// centred just above the bubble itself.
|
||||||
const railVisible =
|
const railVisible = (isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor;
|
||||||
!edit && ((isBubble ? railOpen : hover) || !!menuAnchor || !!emojiBoardAnchor);
|
|
||||||
// The reaction emoji board, shared by the desktop anchored PopOut and the
|
// The reaction emoji board, shared by the desktop anchored PopOut and the
|
||||||
// mobile centred Overlay (an anchored PopOut drifts off-screen on the small
|
// mobile centred Overlay (an anchored PopOut drifts off-screen on the small
|
||||||
// native viewport — `съезжает` — so mobile centres it instead).
|
// native viewport — `съезжает` — so mobile centres it instead).
|
||||||
|
|
@ -1423,7 +1386,7 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
)}
|
)}
|
||||||
{/* Mobile: the reaction emoji board renders as a centred Overlay (an
|
{/* Mobile: the reaction emoji board renders as a centred Overlay (an
|
||||||
anchored PopOut drifts off the small native viewport). */}
|
anchored PopOut drifts off the small native viewport). */}
|
||||||
{isMobile && canSendReaction && !edit && !!emojiBoardAnchor && (
|
{isMobile && canSendReaction && !!emojiBoardAnchor && (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
|
|
@ -1493,9 +1456,6 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// `collapsed`.
|
// `collapsed`.
|
||||||
collapsed={collapse}
|
collapsed={collapse}
|
||||||
selected={bubbleSelected}
|
selected={bubbleSelected}
|
||||||
// While editing, the body IS a composer card — drop the bubble chrome
|
|
||||||
// so it reads as one box, not a composer nested in a bubble.
|
|
||||||
editing={!!edit}
|
|
||||||
// Grouped same-minute series → timestamp below the bubble; singles keep
|
// Grouped same-minute series → timestamp below the bubble; singles keep
|
||||||
// it on the side (RoomTimeline computes timeBelow). Media is always below.
|
// it on the side (RoomTimeline computes timeBelow). Media is always below.
|
||||||
timeBelow={timeBelow}
|
timeBelow={timeBelow}
|
||||||
|
|
@ -1505,7 +1465,7 @@ const MessageInner = as<'div', MessageProps>(
|
||||||
// Read/delivery status under the user's LAST own message only — at
|
// Read/delivery status under the user's LAST own message only — at
|
||||||
// the live end of the timeline (RoomTimeline computes isLatestOwn).
|
// the live end of the timeline (RoomTimeline computes isLatestOwn).
|
||||||
readStatus={
|
readStatus={
|
||||||
isOwnMessage && isLatestOwn && !edit ? (
|
isOwnMessage && isLatestOwn ? (
|
||||||
<DmReadStatusLine room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
|
<DmReadStatusLine room={room} mEvent={mEvent} hideReadReceipts={hideReadReceipts} />
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|
@ -1602,7 +1562,6 @@ function areMessagePropsEqual(
|
||||||
prev.collapse === next.collapse &&
|
prev.collapse === next.collapse &&
|
||||||
prev.railHidden === next.railHidden &&
|
prev.railHidden === next.railHidden &&
|
||||||
prev.highlight === next.highlight &&
|
prev.highlight === next.highlight &&
|
||||||
prev.edit === next.edit &&
|
|
||||||
prev.canDelete === next.canDelete &&
|
prev.canDelete === next.canDelete &&
|
||||||
prev.canSendReaction === next.canSendReaction &&
|
prev.canSendReaction === next.canSendReaction &&
|
||||||
prev.canPinEvent === next.canPinEvent &&
|
prev.canPinEvent === next.canPinEvent &&
|
||||||
|
|
|
||||||
|
|
@ -1,379 +0,0 @@
|
||||||
import React, {
|
|
||||||
KeyboardEventHandler,
|
|
||||||
MouseEventHandler,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Icons,
|
|
||||||
Line,
|
|
||||||
PopOut,
|
|
||||||
RectCords,
|
|
||||||
Spinner,
|
|
||||||
Text,
|
|
||||||
as,
|
|
||||||
config,
|
|
||||||
} from 'folds';
|
|
||||||
import { Editor, Transforms } from 'slate';
|
|
||||||
import { ReactEditor } from 'slate-react';
|
|
||||||
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
|
||||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/types';
|
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
|
||||||
import {
|
|
||||||
AUTOCOMPLETE_PREFIXES,
|
|
||||||
AutocompletePrefix,
|
|
||||||
AutocompleteQuery,
|
|
||||||
CustomEditor,
|
|
||||||
EmoticonAutocomplete,
|
|
||||||
RoomMentionAutocomplete,
|
|
||||||
Toolbar,
|
|
||||||
UserMentionAutocomplete,
|
|
||||||
createEmoticonElement,
|
|
||||||
customHtmlEqualsPlainText,
|
|
||||||
getAutocompleteQuery,
|
|
||||||
getPrevWorldRange,
|
|
||||||
htmlToEditorInput,
|
|
||||||
moveCursor,
|
|
||||||
plainToEditorInput,
|
|
||||||
toMatrixCustomHTML,
|
|
||||||
toPlainText,
|
|
||||||
trimCustomHtml,
|
|
||||||
useEditor,
|
|
||||||
getMentions,
|
|
||||||
} from '../../../components/editor';
|
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
|
||||||
import { settingsAtom } from '../../../state/settings';
|
|
||||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
|
||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
|
||||||
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
|
|
||||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
|
||||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
|
||||||
|
|
||||||
type MessageEditorProps = {
|
|
||||||
roomId: string;
|
|
||||||
room: Room;
|
|
||||||
mEvent: MatrixEvent;
|
|
||||||
imagePackRooms?: Room[];
|
|
||||||
onCancel: () => void;
|
|
||||||
};
|
|
||||||
export const MessageEditor = as<'div', MessageEditorProps>(
|
|
||||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const editor = useEditor();
|
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
|
||||||
// The edit toolbar starts collapsed (the `editorToolbar` opt-in setting was
|
|
||||||
// removed — it only ever gated this one initial-open flag).
|
|
||||||
const [toolbar, setToolbar] = useState(false);
|
|
||||||
const isComposing = useComposingCheck();
|
|
||||||
|
|
||||||
const [autocompleteQuery, setAutocompleteQuery] =
|
|
||||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
|
||||||
|
|
||||||
const getPrevBodyAndFormattedBody = useCallback((): [
|
|
||||||
string | undefined,
|
|
||||||
string | undefined,
|
|
||||||
IMentions | undefined
|
|
||||||
] => {
|
|
||||||
const evtId = mEvent.getId();
|
|
||||||
if (!evtId) return [undefined, undefined, undefined];
|
|
||||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
|
||||||
const editedEvent =
|
|
||||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
|
||||||
|
|
||||||
const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
|
|
||||||
const { body, formatted_body: customHtml }: Record<string, unknown> = content;
|
|
||||||
|
|
||||||
const mMentions: IMentions | undefined = content['m.mentions'];
|
|
||||||
|
|
||||||
return [
|
|
||||||
typeof body === 'string' ? body : undefined,
|
|
||||||
typeof customHtml === 'string' ? customHtml : undefined,
|
|
||||||
mMentions,
|
|
||||||
];
|
|
||||||
}, [room, mEvent]);
|
|
||||||
|
|
||||||
const [saveState, save] = useAsyncCallback(
|
|
||||||
useCallback(async () => {
|
|
||||||
const plainText = toPlainText(editor.children, isMarkdown).trim();
|
|
||||||
const customHtml = trimCustomHtml(
|
|
||||||
toMatrixCustomHTML(editor.children, {
|
|
||||||
allowTextFormatting: true,
|
|
||||||
allowBlockMarkdown: isMarkdown,
|
|
||||||
allowInlineMarkdown: isMarkdown,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
|
|
||||||
|
|
||||||
if (plainText === '') return undefined;
|
|
||||||
if (prevBody) {
|
|
||||||
if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!prevCustomHtml &&
|
|
||||||
prevBody === plainText &&
|
|
||||||
customHtmlEqualsPlainText(customHtml, plainText)
|
|
||||||
) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newContent: IContent = {
|
|
||||||
msgtype: mEvent.getContent().msgtype,
|
|
||||||
body: plainText,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mentionData = getMentions(mx, roomId, editor);
|
|
||||||
|
|
||||||
prevMentions?.user_ids?.forEach((prevMentionId) => {
|
|
||||||
mentionData.users.add(prevMentionId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
|
|
||||||
newContent['m.mentions'] = mMentions;
|
|
||||||
|
|
||||||
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
|
|
||||||
newContent.format = 'org.matrix.custom.html';
|
|
||||||
newContent.formatted_body = customHtml;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content: IContent = {
|
|
||||||
...newContent,
|
|
||||||
body: `* ${plainText}`,
|
|
||||||
'm.new_content': newContent,
|
|
||||||
'm.relates_to': {
|
|
||||||
event_id: mEvent.getId(),
|
|
||||||
rel_type: RelationType.Replace,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Pass threadRootId so the local-echo MatrixEvent gets its
|
|
||||||
// `.thread` pointer set via `localEvent.setThread(thread)`
|
|
||||||
// (client.js:1872-1875) and `Thread.onEcho` re-emits
|
|
||||||
// `ThreadEvent.Update` post-send, which the drawer subscribes
|
|
||||||
// to. SDK `addThreadRelationIfNeeded` (client.js:1810-1820)
|
|
||||||
// doesn't inject `m.thread` because m.replace already owns
|
|
||||||
// `m.relates_to.rel_type` — spec-correct, edit aggregation
|
|
||||||
// chains via the original event's relation. Element-web's
|
|
||||||
// EditMessageComposer.tsx:349-351 uses the same pattern.
|
|
||||||
// M4a-followup: own-send local-echo of m.replace doesn't
|
|
||||||
// aggregate into `thread.timelineSet.relations` (canContain
|
|
||||||
// rejects against unfilteredTimelineSet at
|
|
||||||
// event-timeline-set.js:786-803, and the thread-side path runs
|
|
||||||
// only on remote echo). User sees the new body only after
|
|
||||||
// /sync round-trip — same lag as M4 receipts. Optimistic
|
|
||||||
// local-replace via `mEvent.makeReplaced(localEvent)` is
|
|
||||||
// tracked for follow-up.
|
|
||||||
return mx.sendMessage(
|
|
||||||
roomId,
|
|
||||||
mEvent.threadRootId ?? null,
|
|
||||||
content as RoomMessageEventContent
|
|
||||||
);
|
|
||||||
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
|
||||||
if (saveState.status !== AsyncStatus.Loading) {
|
|
||||||
save();
|
|
||||||
}
|
|
||||||
}, [saveState, save]);
|
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
|
||||||
(evt) => {
|
|
||||||
if (
|
|
||||||
(isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) &&
|
|
||||||
!isComposing(evt)
|
|
||||||
) {
|
|
||||||
evt.preventDefault();
|
|
||||||
handleSave();
|
|
||||||
}
|
|
||||||
if (isKeyHotkey('escape', evt)) {
|
|
||||||
evt.preventDefault();
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onCancel, handleSave, enterForNewline, isComposing]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
|
||||||
(evt) => {
|
|
||||||
if (isKeyHotkey('escape', evt)) {
|
|
||||||
evt.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const prevWordRange = getPrevWorldRange(editor);
|
|
||||||
const query = prevWordRange
|
|
||||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
|
||||||
: undefined;
|
|
||||||
setAutocompleteQuery(query);
|
|
||||||
},
|
|
||||||
[editor]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => {
|
|
||||||
ReactEditor.focus(editor);
|
|
||||||
setAutocompleteQuery(undefined);
|
|
||||||
}, [editor]);
|
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
|
||||||
moveCursor(editor);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const [body, customHtml] = getPrevBodyAndFormattedBody();
|
|
||||||
|
|
||||||
const initialValue =
|
|
||||||
typeof customHtml === 'string'
|
|
||||||
? htmlToEditorInput(customHtml, isMarkdown)
|
|
||||||
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
|
|
||||||
|
|
||||||
Transforms.select(editor, {
|
|
||||||
anchor: Editor.start(editor, []),
|
|
||||||
focus: Editor.end(editor, []),
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.insertFragment(initialValue);
|
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
|
||||||
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (saveState.status === AsyncStatus.Success) {
|
|
||||||
onCancel();
|
|
||||||
}
|
|
||||||
}, [saveState, onCancel]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div {...props} ref={ref}>
|
|
||||||
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
|
||||||
<RoomMentionAutocomplete
|
|
||||||
roomId={roomId}
|
|
||||||
editor={editor}
|
|
||||||
query={autocompleteQuery}
|
|
||||||
requestClose={handleCloseAutocomplete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
|
||||||
<UserMentionAutocomplete
|
|
||||||
room={room}
|
|
||||||
editor={editor}
|
|
||||||
query={autocompleteQuery}
|
|
||||||
requestClose={handleCloseAutocomplete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
|
||||||
<EmoticonAutocomplete
|
|
||||||
imagePackRooms={imagePackRooms || []}
|
|
||||||
editor={editor}
|
|
||||||
query={autocompleteQuery}
|
|
||||||
requestClose={handleCloseAutocomplete}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<CustomEditor
|
|
||||||
editor={editor}
|
|
||||||
placeholder="Edit message..."
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onKeyUp={handleKeyUp}
|
|
||||||
bottom={
|
|
||||||
<>
|
|
||||||
<Box
|
|
||||||
style={{ padding: config.space.S200, paddingTop: 0 }}
|
|
||||||
alignItems="End"
|
|
||||||
justifyContent="SpaceBetween"
|
|
||||||
gap="100"
|
|
||||||
>
|
|
||||||
<Box gap="Inherit">
|
|
||||||
<Chip
|
|
||||||
onClick={handleSave}
|
|
||||||
variant="Primary"
|
|
||||||
radii="Pill"
|
|
||||||
disabled={saveState.status === AsyncStatus.Loading}
|
|
||||||
outlined
|
|
||||||
before={
|
|
||||||
saveState.status === AsyncStatus.Loading ? (
|
|
||||||
<Spinner variant="Primary" fill="Soft" size="100" />
|
|
||||||
) : undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Text size="B300">Save</Text>
|
|
||||||
</Chip>
|
|
||||||
<Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
|
|
||||||
<Text size="B300">Cancel</Text>
|
|
||||||
</Chip>
|
|
||||||
</Box>
|
|
||||||
<Box gap="Inherit">
|
|
||||||
<IconButton
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
onClick={() => setToolbar(!toolbar)}
|
|
||||||
>
|
|
||||||
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
|
||||||
</IconButton>
|
|
||||||
<UseStateProvider initial={undefined}>
|
|
||||||
{(anchor: RectCords | undefined, setAnchor) => (
|
|
||||||
<PopOut
|
|
||||||
anchor={anchor}
|
|
||||||
alignOffset={-8}
|
|
||||||
position="Top"
|
|
||||||
align="End"
|
|
||||||
content={
|
|
||||||
<EmojiBoard
|
|
||||||
imagePackRooms={imagePackRooms ?? []}
|
|
||||||
returnFocusOnDeactivate={false}
|
|
||||||
onEmojiSelect={handleEmoticonSelect}
|
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
|
||||||
requestClose={() => {
|
|
||||||
setAnchor((v) => {
|
|
||||||
if (v) {
|
|
||||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
aria-pressed={anchor !== undefined}
|
|
||||||
onClick={
|
|
||||||
((evt) =>
|
|
||||||
setAnchor(
|
|
||||||
evt.currentTarget.getBoundingClientRect()
|
|
||||||
)) as MouseEventHandler<HTMLButtonElement>
|
|
||||||
}
|
|
||||||
variant="SurfaceVariant"
|
|
||||||
size="300"
|
|
||||||
radii="300"
|
|
||||||
>
|
|
||||||
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
|
|
||||||
</IconButton>
|
|
||||||
</PopOut>
|
|
||||||
)}
|
|
||||||
</UseStateProvider>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
{toolbar && (
|
|
||||||
<div>
|
|
||||||
<Line variant="SurfaceVariant" size="300" />
|
|
||||||
<Toolbar />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { MouseEvent, MouseEventHandler, useCallback, useState } from 'react';
|
import { MouseEvent, MouseEventHandler, useCallback } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
|
@ -16,7 +16,7 @@ import {
|
||||||
getReactionContent,
|
getReactionContent,
|
||||||
} from '../../../utils/room';
|
} from '../../../utils/room';
|
||||||
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
import { IReplyDraft } from '../../../state/room/roomInputDrafts';
|
import { IEditDraft, IReplyDraft } from '../../../state/room/roomInputDrafts';
|
||||||
import { getChannelsThreadPath } from '../../../pages/pathUtils';
|
import { getChannelsThreadPath } from '../../../pages/pathUtils';
|
||||||
|
|
||||||
export type UseMessageInteractionHandlersOptions = {
|
export type UseMessageInteractionHandlersOptions = {
|
||||||
|
|
@ -26,16 +26,21 @@ export type UseMessageInteractionHandlersOptions = {
|
||||||
// insertion + reply-draft focus target this instance.
|
// insertion + reply-draft focus target this instance.
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
// True when the composer associated with `editor` is currently
|
// True when the composer associated with `editor` is currently
|
||||||
// unmounted/hidden. Main timeline passes `threadDrawerOpen` (drawer
|
// unmounted/hidden. Main timeline passes `mainComposerSuspended`
|
||||||
// open hides main composer); drawer passes `false`. When suspended,
|
// (drawer open / no post permission / tombstoned room); drawer passes
|
||||||
// the username-click mention insert no-ops to avoid writing into a
|
// `!canMessage`. When suspended, the username-click mention insert,
|
||||||
// hidden Slate instance the user can't see.
|
// edit and reply-draft writes all no-op to avoid writing into a hidden
|
||||||
|
// Slate instance / drafts the user can't see or clear.
|
||||||
composerSuspended: boolean;
|
composerSuspended: boolean;
|
||||||
// Reply-draft setter targeting the right composer scope. Caller
|
// Reply-draft setter targeting the right composer scope. Caller
|
||||||
// subscribes via `useSetAtom(roomIdToReplyDraftAtomFamily(...))` —
|
// subscribes via `useSetAtom(roomIdToReplyDraftAtomFamily(...))` —
|
||||||
// main timeline scopes to `[roomId, 'main']`, drawer scopes to
|
// main timeline scopes to `[roomId, 'main']`, drawer scopes to
|
||||||
// `[roomId, rootId]`.
|
// `[roomId, rootId]`.
|
||||||
setReplyDraft: (draft: IReplyDraft | undefined) => void;
|
setReplyDraft: (draft: IReplyDraft | undefined) => void;
|
||||||
|
// Edit-draft setter for the same composer scope — «Edit» on a message
|
||||||
|
// loads it into the composer (Telegram-style) instead of opening an
|
||||||
|
// in-timeline edit form. See roomInputDrafts.ts::IEditDraft.
|
||||||
|
setEditDraft: (draft: IEditDraft | undefined) => void;
|
||||||
// Caller-specific scroll-to-event for reply-chip clicks. Main timeline
|
// Caller-specific scroll-to-event for reply-chip clicks. Main timeline
|
||||||
// scrolls via virtual paginator + setFocusItem; drawer scrolls via DOM
|
// scrolls via virtual paginator + setFocusItem; drawer scrolls via DOM
|
||||||
// scrollIntoView on the matching `data-message-id` node.
|
// scrollIntoView on the matching `data-message-id` node.
|
||||||
|
|
@ -51,7 +56,6 @@ export type UseMessageInteractionHandlersOptions = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MessageInteractionHandlers = {
|
export type MessageInteractionHandlers = {
|
||||||
editId: string | undefined;
|
|
||||||
handleEdit: (editEvtId?: string) => void;
|
handleEdit: (editEvtId?: string) => void;
|
||||||
handleOpenReply: MouseEventHandler;
|
handleOpenReply: MouseEventHandler;
|
||||||
handleUserClick: MouseEventHandler<HTMLButtonElement>;
|
handleUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
|
@ -61,15 +65,17 @@ export type MessageInteractionHandlers = {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wiring layer for `<Message>` event-handler props, shared between
|
// Wiring layer for `<Message>` event-handler props, shared between
|
||||||
// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Each
|
// `RoomTimeline` (main column) and `ThreadDrawer` (thread column). Edit
|
||||||
// hook instance owns its own `editId` — editing in the drawer doesn't
|
// state lives in the per-(roomId, threadKey) edit-draft atom (the caller
|
||||||
// flip a row in the main timeline into edit mode and vice versa, which
|
// passes the scoped setter) — editing in the drawer doesn't put a main
|
||||||
// matches the user's mental model of two independent conversations.
|
// timeline row into edit mode and vice versa, which matches the user's
|
||||||
|
// mental model of two independent conversations.
|
||||||
export function useMessageInteractionHandlers({
|
export function useMessageInteractionHandlers({
|
||||||
room,
|
room,
|
||||||
editor,
|
editor,
|
||||||
composerSuspended,
|
composerSuspended,
|
||||||
setReplyDraft,
|
setReplyDraft,
|
||||||
|
setEditDraft,
|
||||||
onOpenEvent,
|
onOpenEvent,
|
||||||
channelsMode,
|
channelsMode,
|
||||||
isBridged,
|
isBridged,
|
||||||
|
|
@ -81,8 +87,6 @@ export function useMessageInteractionHandlers({
|
||||||
const space = useSpaceOptionally();
|
const space = useSpaceOptionally();
|
||||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||||
|
|
||||||
const [editId, setEditId] = useState<string>();
|
|
||||||
|
|
||||||
const handleOpenReply: MouseEventHandler = useCallback(
|
const handleOpenReply: MouseEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||||
|
|
@ -171,6 +175,11 @@ export function useMessageInteractionHandlers({
|
||||||
navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId));
|
navigate(getChannelsThreadPath(decodedSpace, decodedRoom, replyId));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Same orphan-draft guard as handleEdit below: while this scope's
|
||||||
|
// composer is hidden the reply banner could neither render nor be
|
||||||
|
// cleared. The thread-drawer navigation above is composer-independent
|
||||||
|
// and stays available.
|
||||||
|
if (composerSuspended) return;
|
||||||
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
const editedReply = getEditedEvent(replyId, replyEvt, room.getUnfilteredTimelineSet());
|
||||||
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
const content: IContent = editedReply?.getContent()['m.new_content'] ?? replyEvt.getContent();
|
||||||
const { body, formatted_body: formattedBody } = content;
|
const { body, formatted_body: formattedBody } = content;
|
||||||
|
|
@ -189,7 +198,17 @@ export function useMessageInteractionHandlers({
|
||||||
setTimeout(() => ReactEditor.focus(editor), 100);
|
setTimeout(() => ReactEditor.focus(editor), 100);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[room, setReplyDraft, editor, channelsMode, isBridged, navigate, spaceIdOrAlias, roomIdOrAlias]
|
[
|
||||||
|
room,
|
||||||
|
setReplyDraft,
|
||||||
|
editor,
|
||||||
|
channelsMode,
|
||||||
|
isBridged,
|
||||||
|
navigate,
|
||||||
|
spaceIdOrAlias,
|
||||||
|
roomIdOrAlias,
|
||||||
|
composerSuspended,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReactionToggle = useCallback(
|
const handleReactionToggle = useCallback(
|
||||||
|
|
@ -219,19 +238,21 @@ export function useMessageInteractionHandlers({
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
|
// Param stays optional because callers pass `mEvent.getId()` which is
|
||||||
|
// `string | undefined`; an id-less call is simply ignored (the old
|
||||||
|
// no-arg «cancel» semantics died with the in-timeline MessageEditor).
|
||||||
(editEvtId?: string) => {
|
(editEvtId?: string) => {
|
||||||
if (editEvtId) {
|
if (!editEvtId) return;
|
||||||
setEditId(editEvtId);
|
// The composer for this scope loads the message + shows the edit
|
||||||
return;
|
// banner (RoomInput's edit-draft effect). No-op while the composer
|
||||||
}
|
// is unmounted (callers also hide the Edit affordance then).
|
||||||
setEditId(undefined);
|
if (composerSuspended) return;
|
||||||
ReactEditor.focus(editor);
|
setEditDraft({ eventId: editEvtId });
|
||||||
},
|
},
|
||||||
[editor]
|
[setEditDraft, composerSuspended]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editId,
|
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleOpenReply,
|
handleOpenReply,
|
||||||
handleUserClick,
|
handleUserClick,
|
||||||
|
|
|
||||||
|
|
@ -73,16 +73,57 @@ export const ActionRowTrailing = style({
|
||||||
gap: config.space.S100,
|
gap: config.space.S100,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ActionRowTrailingText = style({
|
|
||||||
color: color.Surface.OnContainer,
|
|
||||||
opacity: config.opacity.P400,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ActionRowChevron = style({
|
export const ActionRowChevron = style({
|
||||||
color: color.Surface.OnContainer,
|
color: color.Surface.OnContainer,
|
||||||
opacity: config.opacity.P300,
|
opacity: config.opacity.P300,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Inline expansion group under a parent ActionRow (the Notifications mode
|
||||||
|
// picker). Indented past the parent's fixed icon slot so the sub-labels
|
||||||
|
// align with the parent label — reads as a nested tray INSIDE the popout
|
||||||
|
// instead of a second anchored PopOut (which rendered half-hidden over the
|
||||||
|
// menu on mobile and re-opened on the second tap — see NotificationsActionRow).
|
||||||
|
export const SubRowGroup = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: toRem(1),
|
||||||
|
paddingLeft: `calc(${toRem(20)} + ${config.space.S300})`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A mode option row — same flat vocabulary as ActionRow, just shorter.
|
||||||
|
export const SubRow = style({
|
||||||
|
width: '100%',
|
||||||
|
minHeight: toRem(36),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: config.space.S300,
|
||||||
|
padding: `0 ${config.space.S300}`,
|
||||||
|
borderRadius: toRem(8),
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
textAlign: 'left',
|
||||||
|
font: 'inherit',
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
cursor: 'pointer',
|
||||||
|
selectors: {
|
||||||
|
'&:hover:not(:disabled)': { backgroundColor: color.Surface.ContainerHover },
|
||||||
|
'&:active:not(:disabled)': { backgroundColor: color.Surface.ContainerActive },
|
||||||
|
'&:disabled': {
|
||||||
|
cursor: 'default',
|
||||||
|
opacity: config.opacity.P300,
|
||||||
|
},
|
||||||
|
'&:focus-visible': {
|
||||||
|
outline: `${toRem(2)} solid ${color.Primary.Main}`,
|
||||||
|
outlineOffset: toRem(-2),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SubRowSelectedIcon = style({
|
||||||
|
flexShrink: 0,
|
||||||
|
color: color.Primary.Main,
|
||||||
|
});
|
||||||
|
|
||||||
// Popout container: width bump from the legacy 220px so the Notifications
|
// Popout container: width bump from the legacy 220px so the Notifications
|
||||||
// mode word + Pinned badge breathe; softer mobile corner radius overriding
|
// mode word + Pinned badge breathe; softer mobile corner radius overriding
|
||||||
// the folds Menu default. Background/border/shadow come from the folds Menu
|
// the folds Menu default. Background/border/shadow come from the folds Menu
|
||||||
|
|
@ -91,7 +132,14 @@ export const PopoutMenu = style({
|
||||||
width: toRem(280),
|
width: toRem(280),
|
||||||
maxWidth: '100vw',
|
maxWidth: '100vw',
|
||||||
borderRadius: toRem(16),
|
borderRadius: toRem(16),
|
||||||
overflow: 'hidden',
|
// Scroll, don't clip: the inline Notifications tray grows the menu AFTER
|
||||||
|
// the folds PopOut has fixed its position (PopOut never repositions on
|
||||||
|
// content growth), so on short viewports the tail rows would otherwise
|
||||||
|
// extend past the viewport bottom unreachable. The max-height keeps the
|
||||||
|
// frozen `top` valid; overflowY:auto still clips to the rounded corners.
|
||||||
|
maxHeight: 'calc(100dvh - 5rem)',
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const PopoutGroup = style({
|
export const PopoutGroup = style({
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,16 @@
|
||||||
import React, { MouseEventHandler, ReactNode } from 'react';
|
import React, { MouseEventHandler, ReactNode, useState } from 'react';
|
||||||
import { Icon, Icons, IconSrc, Text, color } from 'folds';
|
import { Icon, Icons, IconSrc, Spinner, Text, color } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useAtom, useSetAtom } from 'jotai';
|
import { useAtom, useSetAtom } from 'jotai';
|
||||||
import { RoomNotificationMode } from '../../../hooks/useRoomsNotificationPreferences';
|
import {
|
||||||
|
getRoomNotificationMode,
|
||||||
|
getRoomNotificationModeIcon,
|
||||||
|
RoomNotificationMode,
|
||||||
|
useRoomsNotificationPreferencesContext,
|
||||||
|
useSetRoomNotificationPreference,
|
||||||
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
|
import { AsyncStatus } from '../../../hooks/useAsyncCallback';
|
||||||
import { botFailedAtomFamily, botShowChatAtomFamily } from '../../bots/botExperienceState';
|
import { botFailedAtomFamily, botShowChatAtomFamily } from '../../bots/botExperienceState';
|
||||||
import { getBotPath } from '../../../pages/pathUtils';
|
import { getBotPath } from '../../../pages/pathUtils';
|
||||||
import * as css from './RoomActions.css';
|
import * as css from './RoomActions.css';
|
||||||
|
|
@ -22,7 +29,7 @@ type ActionRowProps = {
|
||||||
trailing?: ReactNode;
|
trailing?: ReactNode;
|
||||||
accent?: 'primary' | 'critical';
|
accent?: 'primary' | 'critical';
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
ariaPressed?: boolean;
|
ariaExpanded?: boolean;
|
||||||
ariaHasPopup?: boolean;
|
ariaHasPopup?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -37,7 +44,7 @@ export function ActionRow({
|
||||||
trailing,
|
trailing,
|
||||||
accent,
|
accent,
|
||||||
disabled,
|
disabled,
|
||||||
ariaPressed,
|
ariaExpanded,
|
||||||
ariaHasPopup,
|
ariaHasPopup,
|
||||||
}: ActionRowProps) {
|
}: ActionRowProps) {
|
||||||
let accentColor: string | undefined;
|
let accentColor: string | undefined;
|
||||||
|
|
@ -50,7 +57,7 @@ export function ActionRow({
|
||||||
className={css.ActionRow}
|
className={css.ActionRow}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-pressed={ariaPressed}
|
aria-expanded={ariaExpanded}
|
||||||
aria-haspopup={ariaHasPopup}
|
aria-haspopup={ariaHasPopup}
|
||||||
style={accentColor ? { color: accentColor } : undefined}
|
style={accentColor ? { color: accentColor } : undefined}
|
||||||
>
|
>
|
||||||
|
|
@ -70,15 +77,6 @@ export function RowChevron() {
|
||||||
return <Icon className={css.ActionRowChevron} size="100" src={Icons.ChevronRight} />;
|
return <Icon className={css.ActionRowChevron} size="100" src={Icons.ChevronRight} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Muted trailing word (e.g. the current notification mode beside the row).
|
|
||||||
export function RowTrailingText({ children }: { children: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Text as="span" size="T200" className={css.ActionRowTrailingText}>
|
|
||||||
{children}
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subtle Fleet hairline between row groups.
|
// Subtle Fleet hairline between row groups.
|
||||||
export function ActionSectionLine() {
|
export function ActionSectionLine() {
|
||||||
return <div aria-hidden className={css.SectionLine} />;
|
return <div aria-hidden className={css.SectionLine} />;
|
||||||
|
|
@ -126,3 +124,109 @@ export function useNotificationModeLabel(mode: RoomNotificationMode): string {
|
||||||
};
|
};
|
||||||
return labels[mode];
|
return labels[mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NOTIFICATION_MODES: RoomNotificationMode[] = [
|
||||||
|
RoomNotificationMode.Unset,
|
||||||
|
RoomNotificationMode.AllMessages,
|
||||||
|
RoomNotificationMode.SpecialMessages,
|
||||||
|
RoomNotificationMode.Mute,
|
||||||
|
];
|
||||||
|
|
||||||
|
function NotificationModeSubRow({
|
||||||
|
mode,
|
||||||
|
selected,
|
||||||
|
disabled,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
mode: RoomNotificationMode;
|
||||||
|
selected: boolean;
|
||||||
|
disabled: boolean;
|
||||||
|
onSelect: (mode: RoomNotificationMode) => void;
|
||||||
|
}) {
|
||||||
|
const label = useNotificationModeLabel(mode);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={css.SubRow}
|
||||||
|
onClick={() => onSelect(mode)}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
|
>
|
||||||
|
<span className={css.ActionRowIcon}>
|
||||||
|
<Icon size="100" src={getRoomNotificationModeIcon(mode)} filled={selected} />
|
||||||
|
</span>
|
||||||
|
<Text
|
||||||
|
as="span"
|
||||||
|
size="T300"
|
||||||
|
className={css.ActionRowLabel}
|
||||||
|
style={selected ? { fontWeight: 600 } : undefined}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{selected && <Icon className={css.SubRowSelectedIcon} size="100" src={Icons.Check} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotificationsActionRowProps = {
|
||||||
|
roomId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Notifications row for the ⋮ popout menus. The mode picker expands INLINE
|
||||||
|
// (an indented tray of the four modes under the row) instead of the old
|
||||||
|
// nested anchored PopOut, which rendered half-hidden on top of the menu on
|
||||||
|
// mobile. A plain boolean expansion has no anchor and no second FocusTrap,
|
||||||
|
// so the whole class of nested-trap positioning/toggle foot-guns is out.
|
||||||
|
export function NotificationsActionRow({ roomId }: NotificationsActionRowProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const preferences = useRoomsNotificationPreferencesContext();
|
||||||
|
const mode = getRoomNotificationMode(preferences, roomId);
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
|
const { modeState, setMode } = useSetRoomNotificationPreference(roomId);
|
||||||
|
const changing = modeState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSelect = (nextMode: RoomNotificationMode) => {
|
||||||
|
if (changing) return;
|
||||||
|
setMode(nextMode, mode);
|
||||||
|
setExpanded(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* No current-mode word in the trailing slot — it crowded the row
|
||||||
|
label on narrow popouts; the expanded tray's check mark is the
|
||||||
|
mode indicator. */}
|
||||||
|
<ActionRow
|
||||||
|
icon={Icons.Bell}
|
||||||
|
label={t('Room.notifications')}
|
||||||
|
onClick={() => setExpanded(!expanded)}
|
||||||
|
ariaExpanded={expanded}
|
||||||
|
trailing={
|
||||||
|
changing ? (
|
||||||
|
<Spinner size="100" variant="Secondary" />
|
||||||
|
) : (
|
||||||
|
<Icon
|
||||||
|
className={css.ActionRowChevron}
|
||||||
|
size="100"
|
||||||
|
src={expanded ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{expanded && (
|
||||||
|
<div className={css.SubRowGroup}>
|
||||||
|
{NOTIFICATION_MODES.map((m) => (
|
||||||
|
<NotificationModeSubRow
|
||||||
|
key={m}
|
||||||
|
mode={m}
|
||||||
|
selected={m === mode}
|
||||||
|
disabled={changing}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||||
import { Badge, Icons, Menu, RectCords, Spinner, Text } from 'folds';
|
import { Badge, Icons, Menu, RectCords, Text } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
|
@ -14,11 +14,6 @@ import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||||
import { useOpenRoomSettings } from '../../../state/hooks/roomSettings';
|
import { useOpenRoomSettings } from '../../../state/hooks/roomSettings';
|
||||||
import {
|
|
||||||
getRoomNotificationMode,
|
|
||||||
useRoomsNotificationPreferencesContext,
|
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
|
||||||
import { RoomNotificationModeSwitcher } from '../../../components/RoomNotificationSwitcher';
|
|
||||||
import { useBotPresets } from '../../bots/catalog';
|
import { useBotPresets } from '../../bots/catalog';
|
||||||
import { findBotPresetForRoom } from '../../bots/room';
|
import { findBotPresetForRoom } from '../../bots/room';
|
||||||
import { LeaveRoomPrompt } from '../../../components/leave-room-prompt';
|
import { LeaveRoomPrompt } from '../../../components/leave-room-prompt';
|
||||||
|
|
@ -29,7 +24,13 @@ import { getViaServers } from '../../../plugins/via-servers';
|
||||||
import { copyToClipboard } from '../../../utils/dom';
|
import { copyToClipboard } from '../../../utils/dom';
|
||||||
import { markAsRead } from '../../../utils/notifications';
|
import { markAsRead } from '../../../utils/notifications';
|
||||||
import { JumpToTime } from '../jump-to-time';
|
import { JumpToTime } from '../jump-to-time';
|
||||||
import { ActionRow, ActionSectionLine, BotWidgetActionRow, RowChevron } from './RoomActions';
|
import {
|
||||||
|
ActionRow,
|
||||||
|
ActionSectionLine,
|
||||||
|
BotWidgetActionRow,
|
||||||
|
NotificationsActionRow,
|
||||||
|
RowChevron,
|
||||||
|
} from './RoomActions';
|
||||||
import * as css from './RoomActions.css';
|
import * as css from './RoomActions.css';
|
||||||
|
|
||||||
type RoomActionsMenuProps = {
|
type RoomActionsMenuProps = {
|
||||||
|
|
@ -43,9 +44,10 @@ type RoomActionsMenuProps = {
|
||||||
// Content of the room overflow (⋮) popout, on both desktop and mobile. The
|
// Content of the room overflow (⋮) popout, on both desktop and mobile. The
|
||||||
// folds Menu `variant="Surface"` paints the deep Vojo input-field tone
|
// folds Menu `variant="Surface"` paints the deep Vojo input-field tone
|
||||||
// (#0d0e11 = Surface.Container — the exact fill of the chat message composer,
|
// (#0d0e11 = Surface.Container — the exact fill of the chat message composer,
|
||||||
// see RoomView.css.ts). Nested sub-flows are the same as upstream:
|
// see RoomView.css.ts). Nested sub-flows: Notifications expands INLINE
|
||||||
// Notifications → the anchored RoomNotificationModeSwitcher PopOut; Pinned →
|
// (NotificationsActionRow — the old nested PopOut was half-hidden on mobile
|
||||||
// the RoomPinMenu PopOut via onPin(cords); Invite/Jump/Leave → centered overlays.
|
// and re-opened on second tap); Pinned → the RoomPinMenu PopOut via
|
||||||
|
// onPin(cords); Invite/Jump/Leave → centered overlays.
|
||||||
export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
({ room, callView, botControlRoom, onPin, requestClose }, ref) => {
|
({ room, callView, botControlRoom, onPin, requestClose }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -56,8 +58,6 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
const creators = useRoomCreators(room);
|
const creators = useRoomCreators(room);
|
||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const openSettings = useOpenRoomSettings();
|
const openSettings = useOpenRoomSettings();
|
||||||
|
|
@ -132,18 +132,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
onClick={handleMarkAsRead}
|
onClick={handleMarkAsRead}
|
||||||
disabled={!unread}
|
disabled={!unread}
|
||||||
/>
|
/>
|
||||||
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
|
<NotificationsActionRow roomId={room.roomId} />
|
||||||
{(handleOpen, opened, changing) => (
|
|
||||||
<ActionRow
|
|
||||||
icon={Icons.Bell}
|
|
||||||
label={t('Room.notifications')}
|
|
||||||
onClick={handleOpen}
|
|
||||||
ariaPressed={opened}
|
|
||||||
ariaHasPopup
|
|
||||||
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</RoomNotificationModeSwitcher>
|
|
||||||
<ActionRow
|
<ActionRow
|
||||||
icon={Icons.Pin}
|
icon={Icons.Pin}
|
||||||
label={t('Room.pinned_messages')}
|
label={t('Room.pinned_messages')}
|
||||||
|
|
@ -167,7 +156,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
icon={Icons.RecentClock}
|
icon={Icons.RecentClock}
|
||||||
label={t('Room.jump_to_time')}
|
label={t('Room.jump_to_time')}
|
||||||
onClick={() => setPromptJump(true)}
|
onClick={() => setPromptJump(true)}
|
||||||
ariaPressed={promptJump}
|
ariaExpanded={promptJump}
|
||||||
ariaHasPopup
|
ariaHasPopup
|
||||||
trailing={<RowChevron />}
|
trailing={<RowChevron />}
|
||||||
/>
|
/>
|
||||||
|
|
@ -187,7 +176,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
label={t('Room.invite')}
|
label={t('Room.invite')}
|
||||||
onClick={() => setInvitePrompt(true)}
|
onClick={() => setInvitePrompt(true)}
|
||||||
accent="primary"
|
accent="primary"
|
||||||
ariaPressed={invitePrompt}
|
ariaExpanded={invitePrompt}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -201,7 +190,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
onClick={() => setPromptLeave(true)}
|
onClick={() => setPromptLeave(true)}
|
||||||
accent="critical"
|
accent="critical"
|
||||||
disabled={callView}
|
disabled={callView}
|
||||||
ariaPressed={promptLeave}
|
ariaExpanded={promptLeave}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,13 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, toRem } from 'folds';
|
import { color, toRem } from 'folds';
|
||||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
import { 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;
|
|
||||||
|
|
||||||
// Outer container — `position: relative` anchor for the two absolutely-
|
// Outer container — `position: relative` anchor for the two absolutely-
|
||||||
// positioned panes (`appBody` and `silhouette`). `overflow: hidden`
|
// positioned panes (`appBody` and `silhouette`). `overflow: hidden`
|
||||||
// clips anything that overflows the wrapper's bounds and crops the
|
// clips the full-bleed children at the screen edges; the rounded top
|
||||||
// rounded carves on both panes against the container's bg (which is
|
// corners come from the silhouette's own static border-radius, whose
|
||||||
// painted with `VOJO_HORSESHOE_VOID_COLOR` inline when the sheet is
|
// corner triangles stay TRANSPARENT and reveal the live DM list behind
|
||||||
// active, so the carved-out areas read as the same near-black seam
|
// (the media-viewer composition — no void colour, no carve).
|
||||||
// used everywhere else in the app).
|
|
||||||
//
|
//
|
||||||
// `flex: 1` so the container fills whatever flex slot it's mounted in
|
// `flex: 1` so the container fills whatever flex slot it's mounted in
|
||||||
// (PageNav's inner column for the Direct route).
|
// (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`,
|
// status-bar safe-top zone reserved by `PageNav` via `padding-top`,
|
||||||
// and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
|
// and the compensating `paddingTop: var(--vojo-safe-top)` on `appBody`
|
||||||
// keeps the wrapped DM list anchored at the same visual Y as before
|
// keeps the wrapped DM list anchored at the same visual Y as before
|
||||||
// the shift. The combination has two load-bearing effects:
|
// the shift. The extension is what lets the near-fullscreen sheet's
|
||||||
//
|
// top edge (railHeight = containerHeight − safeTop) land exactly at
|
||||||
// (1) The settings-sheet clip-path mask on `appBody` carves rounded
|
// the bottom of the status bar, while `appBody`'s bg paints the strip
|
||||||
// BL/BR into an opaque surface that already paints THROUGH the
|
// in the same `SurfaceVariant.Container` tone as the pager header.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Note: the curtain never RESTS inside the safe-top zone — every snap
|
// Note: the curtain never RESTS inside the safe-top zone — every snap
|
||||||
// destination floors at `top: 0` of the stage (= `y = safe-top` in
|
// 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,
|
// === App body === Holds the wrapped children (the DM list — header,
|
||||||
// scroll content, DirectSelfRow). Fills the container via `inset: 0`.
|
// scroll content, DirectSelfRow), full-bleed behind the silhouette via
|
||||||
// Does NOT translate or shrink — the DM list stays exactly where it
|
// `inset: 0`. NOT clipped or translated: the DM list keeps its layout,
|
||||||
// was in the closed state. Instead, the bottom of the pane is masked
|
// scroll position and measured row heights; the opaque sheet simply
|
||||||
// away by an animated `clip-path:
|
// grows over it, and the sheet's transparent rounded corners reveal
|
||||||
// inset(...)` with rounded BL/BR corners — the user sees the visible
|
// this live surface — exactly the media-viewer composition.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// Why clip-path rather than flex-shrink + margin-bottom: a flex-
|
// `isolation: isolate` is load-bearing: it makes appBody a PERMANENT
|
||||||
// shrink approach changes the scroll-container's height every render,
|
// stacking context, which the pager refresh singleton's paint-order
|
||||||
// and the DM list's `@tanstack/react-virtual` re-measures items mid-
|
// contract counts on (see mobile-tabs-pager/style.css.ts::
|
||||||
// gesture. Why not `transform: translateY` either: translating moves
|
// pagerRefreshSingleton). The always-on clip-path used to provide this
|
||||||
// the whole pane up off the top of the viewport, including the
|
// as a side effect; with the carve gone, isolate keeps the guarantee.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// `backgroundColor: SurfaceVariant.Container` is load-bearing on two
|
// `backgroundColor: SurfaceVariant.Container` is load-bearing: it must
|
||||||
// counts: (1) it must be OPAQUE so the container's void colour
|
// be OPAQUE so nothing behind the wrapper bleeds through gaps between
|
||||||
// (painted inline when the sheet is active) doesn't bleed through gaps
|
// DM-list rows, and the safe-top padding region of THIS element is
|
||||||
// between DM list rows; (2) the safe-top padding region of THIS
|
// what paints the system-tray strip when the wrapper container is
|
||||||
// element is what paints the system-tray strip when the wrapper
|
// extended up over it (see `container.marginTop` above). Picking
|
||||||
// container is extended up over it (see `container.marginTop` above).
|
// `SurfaceVariant.Container` (not `Background.Container`) matches the
|
||||||
// Picking `SurfaceVariant.Container` (not `Background.Container`)
|
// Bots / ChannelsRoot status-bar tone exactly.
|
||||||
// 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.
|
|
||||||
//
|
//
|
||||||
// `flex: column` so the children (which expect a flex column parent
|
// `flex: column` so the children (which expect a flex column parent
|
||||||
// — PageNav uses it) still stack naturally. `paddingTop:
|
// — PageNav uses it) still stack naturally. `paddingTop:
|
||||||
|
|
@ -102,18 +77,17 @@ export const appBody = style({
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
backgroundColor: color.SurfaceVariant.Container,
|
||||||
paddingTop: 'var(--vojo-safe-top, 0px)',
|
paddingTop: 'var(--vojo-safe-top, 0px)',
|
||||||
willChange: 'clip-path',
|
isolation: 'isolate',
|
||||||
});
|
});
|
||||||
|
|
||||||
// === Silhouette === The Settings sheet's surface. Anchored at the
|
// === Silhouette === The Settings sheet's surface. Anchored at the
|
||||||
// bottom of the container; its height animates 0 → railHeight as 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
|
// user drags up. STATIC rounded top corners (same radius as the tab
|
||||||
// gap between it and the translated-up appBody.
|
// curtains / media sheet); `overflow: hidden` clips the panel content
|
||||||
//
|
// to the curve, and the two corner triangles paint NOTHING — they stay
|
||||||
// `overflow: hidden` clips `panelContent` (which is railHeight tall,
|
// transparent and reveal the live DM list behind (`appBody` is
|
||||||
// top-anchored) so the visible portion of the panel is just the
|
// full-bleed, not clipped), so the rounding reads against real content
|
||||||
// silhouette's current height — the user sees more of the panel
|
// with no backing square. Only the height animates (`willChange`).
|
||||||
// content reveal from the top as silhouette grows.
|
|
||||||
//
|
//
|
||||||
// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
|
// Background: `SurfaceVariant.Container` (Dawn bg = #181a20) — the
|
||||||
// chat-pane tone, same as the Settings PageNav inside (set via
|
// 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)
|
// matches the PageNav tone. With `Background.Container` (#0d0e11)
|
||||||
// here, the user saw a dark stripe at that seam on Samsung S24
|
// here, the user saw a dark stripe at that seam on Samsung S24
|
||||||
// edge-to-edge; matching silhouette to the PageNav tone closes it.
|
// 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({
|
export const silhouette = style({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
@ -134,7 +105,9 @@ export const silhouette = style({
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: color.SurfaceVariant.Container,
|
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
|
// 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.
|
// Bottom-up near-fullscreen Settings sheet that wraps the mobile
|
||||||
// Mirror of `MobileProfileHorseshoe` in features/room — the chat
|
// Direct DM list. Geometry mirrors the MEDIA-VIEWER sheet
|
||||||
// there is wrapped by a top-down horseshoe (panel above, chat below
|
// (`MobileMediaViewerHorseshoe`): the opaque sheet grows from the
|
||||||
// with a 12px void). Here we invert: the wrapped app body is above,
|
// bottom over the full-bleed app body and stops just under the system
|
||||||
// the Settings sheet emerges from below, and a 12px void separates
|
// status bar; its STATIC rounded top corners stay transparent and
|
||||||
// them in a )|( silhouette.
|
// 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:
|
// User-visible behaviour:
|
||||||
//
|
//
|
||||||
// • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
|
// • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
|
||||||
// stays exactly where it was — no translate,
|
// stays exactly where it was — no translate, no shrink, no clip.
|
||||||
// no shrink. The bottom of the visible portion is "masked away"
|
// The virtualized DM list (`@tanstack/react-virtual`) keeps its
|
||||||
// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx
|
// scroll position and measured row heights; the sheet simply
|
||||||
// Rpx)` with rounded BL/BR carves at the new visible edge. The
|
// paints over it.
|
||||||
// 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.
|
|
||||||
// • Drag-up origin is `DirectSelfRow` itself, marked with the
|
// • Drag-up origin is `DirectSelfRow` itself, marked with the
|
||||||
// `data-settings-drag-origin` attribute. A document-level
|
// `data-settings-drag-origin` attribute. A document-level
|
||||||
// touchstart / pointerdown listener uses `target.closest()` to
|
// touchstart / pointerdown listener uses `target.closest()` to
|
||||||
|
|
@ -56,7 +46,6 @@ import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { HorseshoeEnabledContext } from '../../components/page';
|
import { HorseshoeEnabledContext } from '../../components/page';
|
||||||
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
import { useMobilePagerPane } from '../../components/mobile-tabs-pager/MobilePagerPaneContext';
|
||||||
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
|
import { mobileHorseshoeActiveAtom } from '../../state/mobilePagerHeader';
|
||||||
import { VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe';
|
|
||||||
import { Settings } from './Settings';
|
import { Settings } from './Settings';
|
||||||
import * as css from './MobileSettingsHorseshoe.css';
|
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
|
// or close from the panel handle). Mirrors the profile horseshoe's
|
||||||
// 80px so the two gestures feel identical.
|
// 80px so the two gestures feel identical.
|
||||||
const COMMIT_THRESHOLD_PX = 80;
|
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';
|
type DragSource = 'directSelfRow' | 'handle';
|
||||||
|
|
||||||
// Axis dead-zone for horizontal-bail. The finger must travel this far
|
// 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 inPagerMode = useMobilePagerPane() !== null;
|
||||||
|
|
||||||
const [drag, setDrag] = useState<DragState | null>(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
|
typeof window === 'undefined' ? 800 : window.innerHeight
|
||||||
);
|
);
|
||||||
|
useLayoutEffect(() => {
|
||||||
useEffect(() => {
|
const el = containerRef.current;
|
||||||
const onResize = () => setViewportHeight(window.innerHeight);
|
if (!el) return undefined;
|
||||||
window.addEventListener('resize', onResize);
|
setContainerHeight(el.clientHeight);
|
||||||
return () => window.removeEventListener('resize', onResize);
|
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;
|
const open = !!sheet;
|
||||||
|
|
||||||
// Entry-animation gate. On cold-start deep-links (push notification
|
// Entry-animation gate. On cold-start deep-links (push notification
|
||||||
|
|
@ -195,15 +197,16 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
const expandedPx = drag
|
const expandedPx = drag
|
||||||
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
? Math.max(0, Math.min(railHeightPx, baseExpanded - drag.deltaY))
|
||||||
: baseExpanded;
|
: baseExpanded;
|
||||||
const expandedFraction = railHeightPx > 0 ? expandedPx / railHeightPx : 0;
|
|
||||||
const isDragging = drag !== null;
|
const isDragging = drag !== null;
|
||||||
const horseshoeActive = expandedPx > 0;
|
const horseshoeActive = expandedPx > 0;
|
||||||
|
|
||||||
// Bridge our local `horseshoeActive` (geometric) signal up to the
|
// Bridge our local `horseshoeActive` (geometric) signal up to the
|
||||||
// pager-shared atom so `MobileTabsPagerHeader` can z-elevate the
|
// pager-shared atom — it only feeds the refresh singleton's hide. The
|
||||||
// static header from the first frame of drag — see the atom's docs
|
// static pager header is deliberately NOT z-elevated for this sheet
|
||||||
// for the «no black flash» rationale. The cleanup writing `false`
|
// (no `mobileHorseshoeElevateHeaderAtom` publication): the appBody
|
||||||
// covers route unmount mid-drag.
|
// 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);
|
const setMobileHorseshoeActive = useSetAtom(mobileHorseshoeActiveAtom);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMobileHorseshoeActive(horseshoeActive);
|
setMobileHorseshoeActive(horseshoeActive);
|
||||||
|
|
@ -506,48 +509,12 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Geometry — radii and void gap ramp through `easeInOutCubic` (slow
|
// Geometry — media-viewer style (MobileMediaViewerHorseshoe): the opaque
|
||||||
// start → fast middle → slow finish) during finger-drag, matching
|
// silhouette simply grows from the bottom over the full-bleed appBody.
|
||||||
// the profile horseshoe's emerge curve. Same `HORSESHOE_EMERGE_PX`
|
// Its rounded TL/TR corners are STATIC (32px, in the css) and stay
|
||||||
// window as the profile horseshoe so the rounding finishes exactly
|
// transparent, revealing the live DM list behind — no clip-path carve,
|
||||||
// when the gesture qualifies to commit. During release (not
|
// no void gap, no emerge ramp. Only the silhouette's height animates.
|
||||||
// dragging), the values jump to their full target and the CSS
|
const silhouetteTransition = isDragging ? 'none' : `height ${ANIMATION_MS}ms ${VAUL_EASING}`;
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
const settingsState = sheet ?? lastSheetRef.current;
|
const settingsState = sheet ?? lastSheetRef.current;
|
||||||
const renderSettings = keepMounted || isDragging;
|
const renderSettings = keepMounted || isDragging;
|
||||||
|
|
@ -568,7 +535,22 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
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
|
{open && portalTarget
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
|
|
@ -582,40 +564,15 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
<div
|
<div
|
||||||
className={css.appBody}
|
className={css.appBody}
|
||||||
style={{
|
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',
|
overscrollBehaviorY: 'contain',
|
||||||
// In pager mode the appBody is transparent AT REST so the
|
// In pager mode the appBody stays transparent THROUGHOUT —
|
||||||
// static pager header (sitting behind the swipe strip in DOM
|
// at rest so the static pager header (behind the strip in DOM
|
||||||
// order) shows through the tabsRow zone — that's how the
|
// order) shows through the tabsRow zone, and during drag/open
|
||||||
// chats curtain visually rises ABOVE the header on pin.
|
// so the tabs keep showing exactly as at rest while the opaque
|
||||||
//
|
// sheet slides OVER them like a real curtain. Nothing dark can
|
||||||
// When the horseshoe is active (drag in flight OR sheet
|
// bleed through: the pager root paints the same SurfaceVariant
|
||||||
// open) we flip the appBody back to opaque
|
// tone behind the strip, and this sheet has no void paint.
|
||||||
// `SurfaceVariant.Container` (via the CSS class — unsetting
|
backgroundColor: inPagerMode ? 'transparent' : undefined,
|
||||||
// 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,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
@ -625,10 +582,13 @@ function MobileSettingsHorseshoeImpl({ children }: MobileSettingsHorseshoeProps)
|
||||||
className={css.silhouette}
|
className={css.silhouette}
|
||||||
style={{
|
style={{
|
||||||
height: `${expandedPx}px`,
|
height: `${expandedPx}px`,
|
||||||
borderTopLeftRadius: `${silhouetteRadiusPx}px`,
|
|
||||||
borderTopRightRadius: `${silhouetteRadiusPx}px`,
|
|
||||||
transition: silhouetteTransition,
|
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
|
// Reset `--vojo-safe-top` for everything mounted inside the
|
||||||
// sheet. The status-bar inset is reserved by PageNav's inner
|
// sheet. The status-bar inset is reserved by PageNav's inner
|
||||||
// column via `padding-top: var(--vojo-safe-top)` for surfaces
|
// column via `padding-top: var(--vojo-safe-top)` for surfaces
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,24 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { Box, color, config, Icon, IconButton, Icons, IconSrc, Text } from 'folds';
|
||||||
Box,
|
|
||||||
Button,
|
|
||||||
color,
|
|
||||||
config,
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
Icons,
|
|
||||||
IconSrc,
|
|
||||||
MenuItem,
|
|
||||||
Overlay,
|
|
||||||
OverlayBackdrop,
|
|
||||||
OverlayCenter,
|
|
||||||
Text,
|
|
||||||
} from 'folds';
|
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { General } from './general';
|
import { General } from './general';
|
||||||
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
import { PageNav, PageNavContent, PageNavHeader, PageRoot } from '../../components/page';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { Account } from './account';
|
import { Account } from './account';
|
||||||
import { Notifications } from './notifications';
|
import { Notifications } from './notifications';
|
||||||
import { Devices } from './devices';
|
import { Devices } from './devices';
|
||||||
import { EmojisStickers } from './emojis-stickers';
|
|
||||||
import { About } from './about';
|
import { About } from './about';
|
||||||
import { Network } from './network';
|
import { Network } from './network';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { LogoutDialog } from '../../components/logout-dialog';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
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 {
|
export enum SettingsPages {
|
||||||
GeneralPage,
|
GeneralPage,
|
||||||
|
|
@ -35,13 +26,16 @@ export enum SettingsPages {
|
||||||
NotificationPage,
|
NotificationPage,
|
||||||
NetworkPage,
|
NetworkPage,
|
||||||
DevicesPage,
|
DevicesPage,
|
||||||
EmojisStickersPage,
|
|
||||||
AboutPage,
|
AboutPage,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable string keys for URL deep-links: `/settings?page=devices`.
|
// Stable string keys for URL deep-links: `/settings?page=devices`.
|
||||||
// Append-only — the enum is local but the param values are part of
|
// Existing values never get RENAMED — the enum is local but the param
|
||||||
// the URL contract (push notifications, bookmarks, external links).
|
// 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
|
// `as const satisfies` so the value type is a literal union (not
|
||||||
// widened to `Record<string, SettingsPages>`): the lookup
|
// widened to `Record<string, SettingsPages>`): the lookup
|
||||||
// `SETTINGS_PAGE_PARAM[k]` with `k: keyof typeof SETTINGS_PAGE_PARAM`
|
// `SETTINGS_PAGE_PARAM[k]` with `k: keyof typeof SETTINGS_PAGE_PARAM`
|
||||||
|
|
@ -56,41 +50,88 @@ export const SETTINGS_PAGE_PARAM = {
|
||||||
notifications: SettingsPages.NotificationPage,
|
notifications: SettingsPages.NotificationPage,
|
||||||
network: SettingsPages.NetworkPage,
|
network: SettingsPages.NetworkPage,
|
||||||
devices: SettingsPages.DevicesPage,
|
devices: SettingsPages.DevicesPage,
|
||||||
emojis: SettingsPages.EmojisStickersPage,
|
|
||||||
about: SettingsPages.AboutPage,
|
about: SettingsPages.AboutPage,
|
||||||
} as const satisfies Record<string, SettingsPages>;
|
} as const satisfies Record<string, SettingsPages>;
|
||||||
export const SETTINGS_PARAM_DEVICES = 'devices';
|
export const SETTINGS_PARAM_DEVICES = 'devices';
|
||||||
|
|
||||||
// DOM id of the visible "Settings" title in PageNavHeader — referenced
|
type MenuRowTint = 'violet' | 'amber' | 'blue' | 'green' | 'rose' | 'neutral';
|
||||||
// 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 SettingsMenuItem = {
|
type SettingsMenuItem = {
|
||||||
page: SettingsPages;
|
page: SettingsPages;
|
||||||
nameKey: string;
|
nameKey: string;
|
||||||
icon: IconSrc;
|
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[] = [
|
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,
|
page: SettingsPages.NotificationPage,
|
||||||
nameKey: 'Settings.menu_notifications',
|
nameKey: 'Settings.menu_notifications',
|
||||||
icon: Icons.Bell,
|
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,
|
page: SettingsPages.NetworkPage,
|
||||||
nameKey: 'Settings.menu_emojis_stickers',
|
nameKey: 'Settings.network.menu',
|
||||||
icon: Icons.Smile,
|
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 = {
|
type SettingsProps = {
|
||||||
initialPage?: SettingsPages;
|
initialPage?: SettingsPages;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -101,9 +142,13 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||||
// `initialPage !== undefined` (not truthy-check) — `GeneralPage`
|
// `initialPage !== undefined` (not truthy-check) — `GeneralPage`
|
||||||
// happens to be enum value `0`, which the old `if (initialPage)`
|
// happens to be enum value `0`, which the old `if (initialPage)`
|
||||||
// form silently dropped, breaking `/settings?page=general`.
|
// 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>(() => {
|
const [activePage, setActivePage] = useState<SettingsPages | undefined>(() => {
|
||||||
if (initialPage !== undefined) return initialPage;
|
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
|
// Re-sync when the parent passes a new `initialPage` (e.g. the
|
||||||
// UnverifiedTab shield-icon shortcut navigating from `/settings` to
|
// UnverifiedTab shield-icon shortcut navigating from `/settings` to
|
||||||
|
|
@ -148,6 +193,17 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||||
) {
|
) {
|
||||||
return;
|
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);
|
setActivePage(undefined);
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
};
|
};
|
||||||
|
|
@ -162,7 +218,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||||
<PageNav size="350" surface="surfaceVariant">
|
<PageNav size="350" surface="surfaceVariant">
|
||||||
<PageNavHeader outlined={false}>
|
<PageNavHeader outlined={false}>
|
||||||
<Box grow="Yes" gap="200" alignItems="Center">
|
<Box grow="Yes" gap="200" alignItems="Center">
|
||||||
<Text id={SETTINGS_TITLE_ID} size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{t('Settings.title')}
|
{t('Settings.title')}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
@ -188,89 +244,58 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||||
</PageNavHeader>
|
</PageNavHeader>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<PageNavContent>
|
<PageNavContent>
|
||||||
<div style={{ flexGrow: 1 }}>
|
<Box direction="Column" gap="300" style={{ flexGrow: 1 }}>
|
||||||
{menuItems.map((item) => {
|
<SettingsProfileHero
|
||||||
const isActive = activePage === item.page;
|
active={activePage === SettingsPages.AccountPage}
|
||||||
// The whole PageNav sits on SurfaceVariant.Container
|
onClick={() => setActivePage(SettingsPages.AccountPage)}
|
||||||
// (the chat-pane tone). Active items step up to
|
/>
|
||||||
// SurfaceVariant.ContainerActive (the raised
|
<div>
|
||||||
// hover/active tone) to read as a subtle raised
|
{menuItems.map((item) => {
|
||||||
// row, and the active icon picks up
|
const isActive = activePage === item.page;
|
||||||
// `Primary.Main` (Fleet-violet) for a single
|
return (
|
||||||
// splash of accent — the same accent the
|
<button
|
||||||
// DM/Channels/Bots tab underline uses. Text stays
|
key={item.nameKey}
|
||||||
// neutral OnContainer; the weight bump conveys
|
type="button"
|
||||||
// the active state to the eye.
|
className={css.MenuRow}
|
||||||
return (
|
aria-pressed={isActive}
|
||||||
<MenuItem
|
onClick={() => setActivePage(item.page)}
|
||||||
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
|
|
||||||
>
|
>
|
||||||
{t(item.nameKey)}
|
<span className={css.MenuRowIcon({ tint: item.tint })}>
|
||||||
</Text>
|
<Icon src={item.icon} size="100" filled={isActive} />
|
||||||
</MenuItem>
|
</span>
|
||||||
);
|
<span
|
||||||
})}
|
className={css.MenuRowLabel}
|
||||||
</div>
|
style={isActive ? { fontWeight: 600 } : undefined}
|
||||||
|
>
|
||||||
|
{t(item.nameKey)}
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
className={css.MenuRowChevron}
|
||||||
|
size="100"
|
||||||
|
src={Icons.ChevronRight}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(logout, setLogout) => (
|
{(logout, setLogout) => (
|
||||||
<>
|
<>
|
||||||
<Button
|
<button
|
||||||
size="300"
|
type="button"
|
||||||
variant="Critical"
|
className={`${css.MenuRow} ${css.MenuRowCritical}`}
|
||||||
fill="None"
|
|
||||||
radii="Pill"
|
|
||||||
before={<Icon src={Icons.Power} size="100" />}
|
|
||||||
onClick={() => setLogout(true)}
|
onClick={() => setLogout(true)}
|
||||||
>
|
>
|
||||||
<Text size="B400">{t('Settings.logout')}</Text>
|
<span className={css.MenuRowIcon({ tint: 'critical' })}>
|
||||||
</Button>
|
<Icon src={Icons.Power} size="100" />
|
||||||
{logout && (
|
</span>
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<span className={css.MenuRowLabel}>{t('Settings.logout')}</span>
|
||||||
<OverlayCenter>
|
</button>
|
||||||
<FocusTrap
|
{/* LogoutDialog owns its Overlay + FocusTrap. */}
|
||||||
focusTrapOptions={{
|
{logout && <LogoutDialog handleClose={() => setLogout(false)} />}
|
||||||
onDeactivate: () => setLogout(false),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogoutDialog handleClose={() => setLogout(false)} />
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
|
|
@ -295,9 +320,6 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||||
{activePage === SettingsPages.DevicesPage && (
|
{activePage === SettingsPages.DevicesPage && (
|
||||||
<Devices requestClose={handlePageRequestClose} />
|
<Devices requestClose={handlePageRequestClose} />
|
||||||
)}
|
)}
|
||||||
{activePage === SettingsPages.EmojisStickersPage && (
|
|
||||||
<EmojisStickers requestClose={handlePageRequestClose} />
|
|
||||||
)}
|
|
||||||
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
{activePage === SettingsPages.AboutPage && <About requestClose={handlePageRequestClose} />}
|
||||||
</PageRoot>
|
</PageRoot>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,17 @@ import {
|
||||||
color,
|
color,
|
||||||
Spinner,
|
Spinner,
|
||||||
toRem,
|
toRem,
|
||||||
Overlay,
|
|
||||||
OverlayBackdrop,
|
|
||||||
OverlayCenter,
|
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import FocusTrap from 'focus-trap-react';
|
|
||||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { Mono, SettingRow } from '../styles.css';
|
import { DeviceSummary, MenuRowIcon, Mono, SettingRow } from '../styles.css';
|
||||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
import { LogoutDialog } from '../../../components/logout-dialog';
|
||||||
import { stopPropagation } from '../../../utils/keyboard';
|
|
||||||
|
|
||||||
export function DeviceTilePlaceholder() {
|
export function DeviceTilePlaceholder() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -211,21 +205,8 @@ export function DeviceLogoutBtn() {
|
||||||
<Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
|
<Chip variant="Secondary" fill="Soft" radii="Pill" onClick={() => setPrompt(true)}>
|
||||||
<Text size="B300">{t('Settings.logout')}</Text>
|
<Text size="B300">{t('Settings.logout')}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
{prompt && (
|
{/* LogoutDialog owns its Overlay + FocusTrap (self-contained Vojo card). */}
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
{prompt && <LogoutDialog handleClose={handleClose} />}
|
||||||
<OverlayCenter>
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
onDeactivate: handleClose,
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<LogoutDialog handleClose={handleClose} />
|
|
||||||
</FocusTrap>
|
|
||||||
</OverlayCenter>
|
|
||||||
</Overlay>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -269,14 +250,22 @@ export function DeviceDeleteBtn({
|
||||||
type DeviceTileProps = {
|
type DeviceTileProps = {
|
||||||
device: IMyDevice;
|
device: IMyDevice;
|
||||||
deleted?: boolean;
|
deleted?: boolean;
|
||||||
|
// Tints the leading device chip green — the «this device» accent.
|
||||||
|
current?: boolean;
|
||||||
refreshDeviceList: () => Promise<void>;
|
refreshDeviceList: () => Promise<void>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
options?: ReactNode;
|
options?: ReactNode;
|
||||||
children?: 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({
|
export function DeviceTile({
|
||||||
device,
|
device,
|
||||||
deleted,
|
deleted,
|
||||||
|
current,
|
||||||
refreshDeviceList,
|
refreshDeviceList,
|
||||||
disabled,
|
disabled,
|
||||||
options,
|
options,
|
||||||
|
|
@ -291,48 +280,52 @@ export function DeviceTile({
|
||||||
setEdit(false);
|
setEdit(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
let chipTint: 'green' | 'critical' | 'neutral' = 'neutral';
|
||||||
|
if (deleted) chipTint = 'critical';
|
||||||
|
else if (current) chipTint = 'green';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingTile
|
<Box alignItems="Center" gap="300">
|
||||||
before={
|
<span className={MenuRowIcon({ tint: chipTint })}>
|
||||||
<IconButton
|
<Icon size="100" src={Icons.Monitor} filled={!!current} />
|
||||||
variant={deleted ? 'Critical' : 'Secondary'}
|
</span>
|
||||||
outlined={deleted}
|
<button
|
||||||
radii="300"
|
type="button"
|
||||||
onClick={() => setDetails(!details)}
|
className={DeviceSummary}
|
||||||
>
|
onClick={() => setDetails(!details)}
|
||||||
<Icon size="50" src={details ? Icons.ChevronBottom : Icons.ChevronRight} />
|
aria-expanded={details}
|
||||||
</IconButton>
|
>
|
||||||
}
|
<Text size="T300" truncate style={{ fontWeight: 600 }}>
|
||||||
after={
|
{device.display_name ?? device.device_id}
|
||||||
!edit && (
|
</Text>
|
||||||
<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">
|
|
||||||
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
|
{typeof activeTs === 'number' && <DeviceActiveTime ts={activeTs} />}
|
||||||
{details && (
|
</button>
|
||||||
<>
|
{!edit && (
|
||||||
<DeviceDetails device={device} />
|
<Box shrink="No" alignItems="Center" gap="200">
|
||||||
{children}
|
{!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>
|
</Box>
|
||||||
</SettingTile>
|
)}
|
||||||
{edit && (
|
{edit && (
|
||||||
<DeviceRename
|
<DeviceRename
|
||||||
device={device}
|
device={device}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Text } from 'folds';
|
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 { SettingTile } from '../../../components/setting-tile';
|
||||||
import { SettingsPage } from '../SettingsPage';
|
import { SettingsPage } from '../SettingsPage';
|
||||||
import { SettingsSection } from '../SettingsSection';
|
import { SettingsSection } from '../SettingsSection';
|
||||||
|
|
@ -93,27 +93,33 @@ export function Devices({ requestClose }: DevicesProps) {
|
||||||
{t('Settings.current')}
|
{t('Settings.current')}
|
||||||
</Text>
|
</Text>
|
||||||
{currentDevice ? (
|
{currentDevice ? (
|
||||||
<Box className={SettingFlatRow} direction="Column" gap="400">
|
// Inset grouped panel — same flat-group chrome the rest of the
|
||||||
<DeviceTile
|
// settings pages use; the row itself is the Vojo device row
|
||||||
device={currentDevice}
|
// (green «this device» chip).
|
||||||
refreshDeviceList={refreshDeviceList}
|
<div className={SettingFlatGroup}>
|
||||||
options={<DeviceLogoutBtn />}
|
<Box className={SettingFlatRow} direction="Column" gap="400">
|
||||||
>
|
<DeviceTile
|
||||||
{crypto && <DeviceKeyDetails crypto={crypto} />}
|
device={currentDevice}
|
||||||
</DeviceTile>
|
current
|
||||||
{crossSigningActive &&
|
refreshDeviceList={refreshDeviceList}
|
||||||
verificationStatus === VerificationStatus.Unverified &&
|
options={<DeviceLogoutBtn />}
|
||||||
defaultSecretStorageKeyId &&
|
>
|
||||||
defaultSecretStorageKeyContent && (
|
{crypto && <DeviceKeyDetails crypto={crypto} />}
|
||||||
<VerifyCurrentDeviceTile
|
</DeviceTile>
|
||||||
secretStorageKeyId={defaultSecretStorageKeyId}
|
{crossSigningActive &&
|
||||||
secretStorageKeyContent={defaultSecretStorageKeyContent}
|
verificationStatus === VerificationStatus.Unverified &&
|
||||||
/>
|
defaultSecretStorageKeyId &&
|
||||||
|
defaultSecretStorageKeyContent && (
|
||||||
|
<VerifyCurrentDeviceTile
|
||||||
|
secretStorageKeyId={defaultSecretStorageKeyId}
|
||||||
|
secretStorageKeyContent={defaultSecretStorageKeyContent}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{crypto && verificationStatus === VerificationStatus.Verified && (
|
||||||
|
<BackupRestoreTile crypto={crypto} />
|
||||||
)}
|
)}
|
||||||
{crypto && verificationStatus === VerificationStatus.Verified && (
|
</Box>
|
||||||
<BackupRestoreTile crypto={crypto} />
|
</div>
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
) : (
|
) : (
|
||||||
<DeviceTilePlaceholder />
|
<DeviceTilePlaceholder />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue