Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4120ccdbcc | ||
|
|
ed5bf997b4 | ||
|
|
878a34712a | ||
|
|
62672787a0 | ||
|
|
0746e80941 | ||
|
|
6300e4170e | ||
|
|
7250322977 | ||
|
|
ac1a1c9a48 | ||
|
|
8645a55769 | ||
|
|
c3f897b6d1 | ||
|
|
8784a8158c | ||
|
|
f920fd82fb | ||
|
|
42040b6412 | ||
|
|
ccbafdb147 | ||
|
|
60c0b9ad30 | ||
|
|
1873285371 | ||
|
|
78dcf9ee9d | ||
|
|
3418a8865d | ||
|
|
5d2d7ca63c | ||
|
|
1a0e581f21 | ||
|
|
2fff7a5388 | ||
|
|
8ea03ab9b6 | ||
|
|
a1223ba5cf | ||
|
|
400545c79a | ||
|
|
885ec7c82e | ||
|
|
997df415dd | ||
|
|
8bfb8737d6 | ||
|
|
72277399f0 | ||
|
|
d223f847c6 | ||
|
|
7ad2a94e2e | ||
|
|
c6aac574ee | ||
|
|
53ac87befe | ||
|
|
e1168f47bb | ||
|
|
97d2d195c3 | ||
|
|
14f05a3d5c | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ce1354ed93 | ||
|
|
adf94d7bc1 |
+19
-1
@@ -118,8 +118,26 @@ test.describe('accessibility', () => {
|
|||||||
test('room settings', async ({ page }) => {
|
test('room settings', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'More Options' }).first().click();
|
await page.getByRole('button', { name: 'More Options' }).first().click();
|
||||||
await page.getByText('Room Settings', { exact: true }).click();
|
await page.getByText('Room Settings', { exact: true }).click();
|
||||||
await expect(page.getByRole('dialog').first()).toBeVisible();
|
// [Gitea #278] The dialog has an accessible name.
|
||||||
|
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
|
||||||
await auditPage(page, 'room settings');
|
await auditPage(page, 'room settings');
|
||||||
|
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
|
||||||
|
// export date inputs were only on these pages.
|
||||||
|
// One page at a time, in order.
|
||||||
|
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await auditPage(page, `room settings: ${name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inbox invites', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/invites/');
|
||||||
|
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
|
||||||
|
await auditPage(page, 'inbox invites');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.26",
|
"version": "0.25.0-lotus.29",
|
||||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.29/element-call-embedded-0.25.0-lotus.29.tgz",
|
||||||
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
|
"integrity": "sha512-IAIEyiOFfkfb1k2JfBNOkX4LOVaGF8LzeKVQYCqKoyKDVao6rYEYA71LON8ntTaxkredXOK4CeLFsnkCiJHuGg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+2
-2
@@ -12,7 +12,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"lint": "npm run check:eslint && npm run check:prettier",
|
"lint": "npm run check:eslint && npm run check:prettier",
|
||||||
"check:eslint": "eslint src/* --max-warnings 36",
|
"check:eslint": "eslint src/* --max-warnings 29",
|
||||||
"check:prettier": "prettier --check .",
|
"check:prettier": "prettier --check .",
|
||||||
"fix:prettier": "prettier --write .",
|
"fix:prettier": "prettier --write .",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.29",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
|
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
||||||
>
|
>
|
||||||
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-selected={value === ManualVerificationMethod.RecoveryKey}
|
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
|||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
|
||||||
|
label: string;
|
||||||
};
|
};
|
||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children, label }: Modal500Props) {
|
||||||
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
@@ -26,6 +28,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||||||
variant="Background"
|
variant="Background"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||||
// squish the two-pane settings layout.
|
// squish the two-pane settings layout.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { FormEventHandler, useCallback } from 'react';
|
import React, { FormEventHandler, useCallback } from 'react';
|
||||||
import { Box, Text, Button, Spinner, color } from 'folds';
|
import { Box, Text, Button, Spinner, color } from 'folds';
|
||||||
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
import { decodeRecoveryKey, deriveRecoveryKeyFromPassphrase } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
|
import type { SecretStorageKeyDescription } from 'matrix-js-sdk/lib/secret-storage';
|
||||||
import { PasswordInput } from './password-input';
|
import { PasswordInput } from './password-input';
|
||||||
import {
|
import {
|
||||||
SecretStorageKeyContent,
|
SecretStorageKeyContent,
|
||||||
@@ -39,7 +40,10 @@ export function SecretStorageRecoveryPassphrase({
|
|||||||
bits,
|
bits,
|
||||||
);
|
);
|
||||||
|
|
||||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
const match = await mx.secretStorage.checkKey(
|
||||||
|
decodedRecoveryKey,
|
||||||
|
keyContent as SecretStorageKeyDescription,
|
||||||
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Invalid recovery passphrase.');
|
throw new Error('Invalid recovery passphrase.');
|
||||||
@@ -132,7 +136,10 @@ export function SecretStorageRecoveryKey({
|
|||||||
async (recoveryKey) => {
|
async (recoveryKey) => {
|
||||||
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
const decodedRecoveryKey = decodeRecoveryKey(recoveryKey);
|
||||||
|
|
||||||
const match = await mx.secretStorage.checkKey(decodedRecoveryKey, keyContent as any);
|
const match = await mx.secretStorage.checkKey(
|
||||||
|
decodedRecoveryKey,
|
||||||
|
keyContent as SecretStorageKeyDescription,
|
||||||
|
);
|
||||||
|
|
||||||
if (!match) {
|
if (!match) {
|
||||||
throw new Error('Invalid recovery key.');
|
throw new Error('Invalid recovery key.');
|
||||||
|
|||||||
@@ -117,7 +117,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
|
|
||||||
const renderPlaceholder = useCallback(
|
const renderPlaceholder = useCallback(
|
||||||
({ attributes, children }: RenderPlaceholderProps) => (
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
<span
|
||||||
|
{...attributes}
|
||||||
|
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
|
||||||
|
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
|
||||||
|
// measured 2.66:1. Drop it so the class decides.
|
||||||
|
style={{ ...attributes.style, opacity: undefined }}
|
||||||
|
className={css.EditorPlaceholderContainer}
|
||||||
|
>
|
||||||
{/* Inner component to style the actual text position and appearance */}
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
|
|||||||
key={getUsageStr(usage)}
|
key={getUsageStr(usage)}
|
||||||
size="300"
|
size="300"
|
||||||
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
|
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
|
||||||
aria-selected={isSelected(usage)}
|
aria-pressed={isSelected(usage)}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => onChange(usage)}
|
onClick={() => onChange(usage)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="300">
|
// data-setting-tile: lets a group card draw dividers between tiles only (#273).
|
||||||
|
<Box alignItems="Center" gap="300" data-setting-tile="">
|
||||||
{before && <Box shrink="No">{before}</Box>}
|
{before && <Box shrink="No">{before}</Box>}
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
{title && (
|
{title && (
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|||||||
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedDay === day}
|
aria-pressed={selectedDay === day}
|
||||||
onClick={() => handleDay(day)}
|
onClick={() => handleDay(day)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
||||||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|||||||
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedMonth === month}
|
aria-pressed={selectedMonth === month}
|
||||||
onClick={() => handleMonth(month)}
|
onClick={() => handleMonth(month)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedYear === minYear && month < minMonth) ||
|
(selectedYear === minYear && month < minMonth) ||
|
||||||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|||||||
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedYear === year}
|
aria-pressed={selectedYear === year}
|
||||||
onClick={() => handleYear(year)}
|
onClick={() => handleYear(year)}
|
||||||
>
|
>
|
||||||
<Text size="T300">{year}</Text>
|
<Text size="T300">{year}</Text>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={hour === selectedHour}
|
aria-pressed={hour === selectedHour}
|
||||||
onClick={() => handleHour(hour)}
|
onClick={() => handleHour(hour)}
|
||||||
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||||
>
|
>
|
||||||
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={hour === selectedHour}
|
aria-pressed={hour === selectedHour}
|
||||||
onClick={() => handleHour(hour)}
|
onClick={() => handleHour(hour)}
|
||||||
disabled={
|
disabled={
|
||||||
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={minute === selectedMinute}
|
aria-pressed={minute === selectedMinute}
|
||||||
onClick={() => handleMinute(minute)}
|
onClick={() => handleMinute(minute)}
|
||||||
disabled={
|
disabled={
|
||||||
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
||||||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={!selectedPM}
|
aria-pressed={!selectedPM}
|
||||||
onClick={() => handlePeriod(false)}
|
onClick={() => handlePeriod(false)}
|
||||||
disabled={minDay && minPM}
|
disabled={minDay && minPM}
|
||||||
>
|
>
|
||||||
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedPM}
|
aria-pressed={selectedPM}
|
||||||
onClick={() => handlePeriod(true)}
|
onClick={() => handlePeriod(true)}
|
||||||
disabled={maxDay && !maxPM}
|
disabled={maxDay && !maxPM}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -33,7 +33,9 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={classNames(css.UserAvatar, className)}
|
className={classNames(css.UserAvatar, className)}
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
// [Gitea #278] An avatar sits next to the person's name or inside a
|
||||||
|
// labelled button, so without an explicit alt it is decorative.
|
||||||
|
alt={alt ?? ''}
|
||||||
onError={() => setError(true)}
|
onError={() => setError(true)}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import FocusTrap from 'focus-trap-react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
config,
|
||||||
|
Dialog,
|
||||||
|
Header,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
Icons,
|
||||||
|
Overlay,
|
||||||
|
OverlayBackdrop,
|
||||||
|
OverlayCenter,
|
||||||
|
Text,
|
||||||
|
} from 'folds';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
import { callSummaryViewAtom } from '../../state/callSummary';
|
||||||
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #253] The post-call "Details" card: opened from the call-ended toast,
|
||||||
|
* shows who was in the call, connection quality and whose side it was on,
|
||||||
|
* network numbers and your own activity (technical wording).
|
||||||
|
*/
|
||||||
|
export function CallSummaryDialog() {
|
||||||
|
const [view, setView] = useAtom(callSummaryViewAtom);
|
||||||
|
const modalStyle = useModalStyle(400);
|
||||||
|
if (!view) return null;
|
||||||
|
const close = () => setView(undefined);
|
||||||
|
return (
|
||||||
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
|
<OverlayCenter>
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: close,
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Dialog
|
||||||
|
variant="Surface"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="call-summary-title"
|
||||||
|
style={modalStyle}
|
||||||
|
>
|
||||||
|
<Header
|
||||||
|
variant="Surface"
|
||||||
|
size="500"
|
||||||
|
style={{
|
||||||
|
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||||
|
borderBottomWidth: config.borderWidth.B300,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
|
<Icon src={Icons.Phone} size="100" />
|
||||||
|
<Text id="call-summary-title" size="H4" truncate>
|
||||||
|
Call summary · {view.roomName}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Header>
|
||||||
|
<Box direction="Column" gap="400" style={{ padding: config.space.S400 }}>
|
||||||
|
{view.sections.map((section) => (
|
||||||
|
<Box key={section.title} direction="Column" gap="100">
|
||||||
|
<Text size="L400">{section.title}</Text>
|
||||||
|
{section.lines.map((line) => (
|
||||||
|
<Text key={line} size="T300" style={{ fontVariantNumeric: 'tabular-nums' }}>
|
||||||
|
{line}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
</FocusTrap>
|
||||||
|
</OverlayCenter>
|
||||||
|
</Overlay>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||||
|
import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl';
|
||||||
import { CallSoundboard } from './CallSoundboard';
|
import { CallSoundboard } from './CallSoundboard';
|
||||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||||
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
||||||
@@ -90,6 +91,21 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
|
|
||||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||||
useCallControlState(callEmbed.control);
|
useCallControlState(callEmbed.control);
|
||||||
|
// [Gitea #277] Only offer "Mute Screenshare Audio" while a remote share's
|
||||||
|
// audio is playing, or while it is muted (so a hidden mute can't silently
|
||||||
|
// swallow the next share).
|
||||||
|
const [remoteScreenshareAudio, setRemoteScreenshareAudio] = useState(
|
||||||
|
callEmbed.control.remoteScreenshareAudio,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = () => setRemoteScreenshareAudio(callEmbed.control.remoteScreenshareAudio);
|
||||||
|
onState();
|
||||||
|
callEmbed.control.on(SCREENSHARE_AUDIO_STATE, onState);
|
||||||
|
return () => {
|
||||||
|
callEmbed.control.off(SCREENSHARE_AUDIO_STATE, onState);
|
||||||
|
};
|
||||||
|
}, [callEmbed]);
|
||||||
|
const showScreenshareAudio = remoteScreenshareAudio || screenshareAudioMuted;
|
||||||
const micLevel = useCallMicLevel(callEmbed);
|
const micLevel = useCallMicLevel(callEmbed);
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
@@ -122,6 +138,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
};
|
};
|
||||||
const showScreenshare = allowScreenshare || screenshare;
|
const showScreenshare = allowScreenshare || screenshare;
|
||||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
|
// [Gitea #265] Phones can't share their screen from a browser; don't offer it.
|
||||||
|
const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function';
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
@@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [hangupState.status, callEmbed, setCallEmbed]);
|
}, [hangupState.status, callEmbed, setCallEmbed]);
|
||||||
|
|
||||||
|
const moreMenu = (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||||
|
{compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setCords(undefined);
|
||||||
|
handleScreenshareToggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{screenshare ? 'Stop Screenshare' : 'Share Screen'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{compact && showScreenshareAudio && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setCords(undefined);
|
||||||
|
callEmbed.control.toggleScreenshareAudio();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{compact && !!document.fullscreenEnabled && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setCords(undefined);
|
||||||
|
handleFullscreen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSpotlightClick}>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem size="300" variant="Surface" radii="300" onClick={handleReactionsClick}>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Reactions
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSettingsClick}>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
outlined
|
||||||
|
aria-label="More options"
|
||||||
|
aria-expanded={!!cords}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.VerticalDots} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hangupIcon = exiting ? (
|
||||||
|
<Spinner variant="Critical" fill="Solid" size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.PhoneDown} size="200" filled />
|
||||||
|
);
|
||||||
|
|
||||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,6 +327,44 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
}}
|
}}
|
||||||
onCancel={() => setShareConfirm(false)}
|
onCancel={() => setShareConfirm(false)}
|
||||||
/>
|
/>
|
||||||
|
{compact ? (
|
||||||
|
// [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio
|
||||||
|
// mute and fullscreen live in More (they rarely matter on a phone), and
|
||||||
|
// End is icon-only. The old stacked layout took four rows (~40% of a
|
||||||
|
// portrait phone).
|
||||||
|
<SequenceCard
|
||||||
|
className={css.ControlCard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
gap="200"
|
||||||
|
radii="500"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
wrap="NoWrap"
|
||||||
|
>
|
||||||
|
<MicrophoneButton
|
||||||
|
enabled={microphone}
|
||||||
|
onToggle={handleMicrophoneToggle}
|
||||||
|
level={micLevel}
|
||||||
|
/>
|
||||||
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
|
<ChatButton />
|
||||||
|
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||||
|
{moreMenu}
|
||||||
|
<Button
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={hangup}
|
||||||
|
disabled={exiting}
|
||||||
|
aria-label="End Call"
|
||||||
|
style={{ paddingInline: config.space.S300 }}
|
||||||
|
>
|
||||||
|
{hangupIcon}
|
||||||
|
</Button>
|
||||||
|
</SequenceCard>
|
||||||
|
) : (
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={css.ControlCard}
|
className={css.ControlCard}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -213,7 +374,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
justifyContent="SpaceBetween"
|
justifyContent="SpaceBetween"
|
||||||
wrap="Wrap"
|
wrap="Wrap"
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton
|
<MicrophoneButton
|
||||||
enabled={microphone}
|
enabled={microphone}
|
||||||
@@ -222,7 +383,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
/>
|
/>
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && showVideoGroup && <ControlDivider />}
|
{showVideoGroup && <ControlDivider />}
|
||||||
{showVideoGroup && (
|
{showVideoGroup && (
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
{/* Show a forbidden control while its track is still live so the
|
{/* Show a forbidden control while its track is still live so the
|
||||||
@@ -238,10 +399,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
)}
|
)}
|
||||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
control since they're the same concern. */}
|
control since they're the same concern. */}
|
||||||
|
{showScreenshareAudio && (
|
||||||
<ScreenshareAudioButton
|
<ScreenshareAudioButton
|
||||||
muted={screenshareAudioMuted}
|
muted={screenshareAudioMuted}
|
||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!!document.fullscreenEnabled && (
|
{!!document.fullscreenEnabled && (
|
||||||
@@ -250,77 +413,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
<ControlDivider />
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<ChatButton />
|
<ChatButton />
|
||||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||||
<PopOut
|
{moreMenu}
|
||||||
anchor={cords}
|
|
||||||
position="Top"
|
|
||||||
align="Center"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
variant="Surface"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleSpotlightClick}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
variant="Surface"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleReactionsClick}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
Reactions
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
variant="Surface"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleSettingsClick}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
Settings
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
variant="Surface"
|
|
||||||
fill="Soft"
|
|
||||||
radii="400"
|
|
||||||
size="400"
|
|
||||||
onClick={handleOpenMenu}
|
|
||||||
outlined
|
|
||||||
aria-label="More options"
|
|
||||||
aria-expanded={!!cords}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
>
|
|
||||||
<Icon size="400" src={Icons.VerticalDots} />
|
|
||||||
</IconButton>
|
|
||||||
</PopOut>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column">
|
<Box shrink="No" direction="Column">
|
||||||
<Button
|
<Button
|
||||||
@@ -328,13 +426,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
variant="Critical"
|
variant="Critical"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
onClick={hangup}
|
onClick={hangup}
|
||||||
before={
|
before={hangupIcon}
|
||||||
exiting ? (
|
|
||||||
<Spinner variant="Critical" fill="Solid" size="200" />
|
|
||||||
) : (
|
|
||||||
<Icon src={Icons.PhoneDown} size="200" filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={exiting}
|
disabled={exiting}
|
||||||
>
|
>
|
||||||
<Text size="B400">End</Text>
|
<Text size="B400">End</Text>
|
||||||
@@ -342,6 +434,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
AccountDataSubmitCallback,
|
AccountDataSubmitCallback,
|
||||||
} from '../../../components/AccountDataEditor';
|
} from '../../../components/AccountDataEditor';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { setRoomAccountData } from '../../../utils/accountData';
|
||||||
|
|
||||||
type DeveloperToolsProps = {
|
type DeveloperToolsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -58,8 +59,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
|||||||
|
|
||||||
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
const submitAccountData: AccountDataSubmitCallback = useCallback(
|
||||||
async (type, content) => {
|
async (type, content) => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
await setRoomAccountData(mx, room.roomId, type, content);
|
||||||
await mx.setRoomAccountData(room.roomId, type as any, content);
|
|
||||||
},
|
},
|
||||||
[mx, room.roomId],
|
[mx, room.roomId],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
import React, { useCallback, useRef, useState, FormEventHandler, useEffect } from 'react';
|
||||||
import { MatrixError } from 'matrix-js-sdk';
|
import { IContent, MatrixError } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Chip,
|
Chip,
|
||||||
@@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { syntaxErrorPosition } from '../../../utils/dom';
|
import { syntaxErrorPosition } from '../../../utils/dom';
|
||||||
import { Cursor } from '../../../plugins/text-area';
|
import { Cursor } from '../../../plugins/text-area';
|
||||||
|
import { sendRoomEvent, sendStateEvent } from '../../../utils/room';
|
||||||
|
|
||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
@@ -51,9 +52,9 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro
|
|||||||
useCallback(
|
useCallback(
|
||||||
(evtType, evtStateKey, evtContent) => {
|
(evtType, evtStateKey, evtContent) => {
|
||||||
if (typeof evtStateKey === 'string') {
|
if (typeof evtStateKey === 'string') {
|
||||||
return mx.sendStateEvent(room.roomId, evtType as any, evtContent, evtStateKey);
|
return sendStateEvent(mx, room.roomId, evtType, evtContent, evtStateKey);
|
||||||
}
|
}
|
||||||
return mx.sendEvent(room.roomId, evtType as any, evtContent);
|
return sendRoomEvent(mx, room.roomId, null, evtType, evtContent as IContent);
|
||||||
},
|
},
|
||||||
[mx, room],
|
[mx, room],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
|||||||
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { sendStateEvent } from '../../../utils/room';
|
||||||
|
|
||||||
const EDITOR_INTENT_SPACE_COUNT = 2;
|
const EDITOR_INTENT_SPACE_COUNT = 2;
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ function StateEventEdit({ type, stateKey, content, requestClose }: StateEventEdi
|
|||||||
|
|
||||||
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
const [submitState, submit] = useAsyncCallback<object, MatrixError, [object]>(
|
||||||
useCallback(
|
useCallback(
|
||||||
(c) => mx.sendStateEvent(room.roomId, type as any, c, stateKey),
|
(c) => sendStateEvent(mx, room.roomId, type, c, stateKey),
|
||||||
[mx, room, type, stateKey],
|
[mx, room, type, stateKey],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
|||||||
alignItems="End"
|
alignItems="End"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100" grow="Yes">
|
<Box direction="Column" gap="100" grow="Yes">
|
||||||
<Text size="L400">Name</Text>
|
<Text as="label" htmlFor="room-pack-name" size="L400">
|
||||||
|
Name
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="room-pack-name"
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
required
|
required
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { buildTopicContent } from '../../../utils/topicMarkdown';
|
||||||
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
|
||||||
|
|
||||||
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
|
|||||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicMarkdownToHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
||||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
|
||||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
||||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTopicContent(topic: string): Record<string, string> {
|
|
||||||
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
|
|
||||||
const formattedBody = topicMarkdownToHtml(topic);
|
|
||||||
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
|
|
||||||
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
|
|
||||||
|
|
||||||
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoomProfileEditProps = {
|
type RoomProfileEditProps = {
|
||||||
canEditAvatar: boolean;
|
canEditAvatar: boolean;
|
||||||
canEditName: boolean;
|
canEditName: boolean;
|
||||||
|
|||||||
@@ -136,7 +136,8 @@ export function PermissionGroups({
|
|||||||
outlined={powerChanges}
|
outlined={powerChanges}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
aria-selected={opened}
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={opened}
|
||||||
disabled={!canEdit || applyingChanges}
|
disabled={!canEdit || applyingChanges}
|
||||||
after={
|
after={
|
||||||
powerChanges && (
|
powerChanges && (
|
||||||
@@ -200,7 +201,8 @@ export function PermissionGroups({
|
|||||||
outlined={powerChanges}
|
outlined={powerChanges}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
aria-selected={opened}
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={opened}
|
||||||
disabled={!canEdit || applyingChanges}
|
disabled={!canEdit || applyingChanges}
|
||||||
after={
|
after={
|
||||||
powerChanges && (
|
powerChanges && (
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
|
|||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">This room / space (shared)</Text>
|
<Text size="L400">
|
||||||
|
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
|
||||||
|
</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Clips here are shared with everyone, and inherited by every room under this space
|
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
|
||||||
— just like emoji/sticker packs. Only members with permission can edit.
|
{room?.isSpaceRoom()
|
||||||
|
? 'Clips here are shared with everyone, and inherited by every room under this space — just like emoji/sticker packs. Only members with permission can edit.'
|
||||||
|
: 'Clips here are shared with everyone in this room — just like emoji/sticker packs. Only members with permission can edit.'}
|
||||||
</Text>
|
</Text>
|
||||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -94,10 +94,15 @@ export function OutboxFeature() {
|
|||||||
OFFLINE.has(mx.getSyncState()) ||
|
OFFLINE.has(mx.getSyncState()) ||
|
||||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||||
|
|
||||||
const persist = (next: OutboxEntry[]) => {
|
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
|
||||||
if (next === entries) return;
|
// entries since we last looked, and saving our own copy over the key
|
||||||
|
// would drop theirs. Entries are added/removed by txnId, so applying our
|
||||||
|
// change to the stored list merges cleanly.
|
||||||
|
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
|
||||||
|
const stored = loadOutbox(userId);
|
||||||
|
const next = update(stored);
|
||||||
entries = next;
|
entries = next;
|
||||||
saveOutbox(userId, entries);
|
if (next !== stored) saveOutbox(userId, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,8 +128,8 @@ export function OutboxFeature() {
|
|||||||
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
||||||
live.set(txnId, { event, room });
|
live.set(txnId, { event, room });
|
||||||
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
||||||
persist(
|
persist((list) =>
|
||||||
withEntry(entries, {
|
withEntry(list, {
|
||||||
txnId,
|
txnId,
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
threadId: event.threadRootId ?? null,
|
threadId: event.threadRootId ?? null,
|
||||||
@@ -138,7 +143,7 @@ export function OutboxFeature() {
|
|||||||
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
||||||
live.delete(txnId);
|
live.delete(txnId);
|
||||||
autoRetries.delete(txnId);
|
autoRetries.delete(txnId);
|
||||||
persist(withoutEntry(entries, txnId));
|
persist((list) => withoutEntry(list, txnId));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
||||||
@@ -191,7 +196,7 @@ export function OutboxFeature() {
|
|||||||
return !!room && isDelivered(room, entry.txnId);
|
return !!room && isDelivered(room, entry.txnId);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
|
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
|
||||||
plan.restore.forEach((entry) => {
|
plan.restore.forEach((entry) => {
|
||||||
const room = mx.getRoom(entry.roomId);
|
const room = mx.getRoom(entry.roomId);
|
||||||
if (!room || live.has(entry.txnId)) return;
|
if (!room || live.has(entry.txnId)) return;
|
||||||
|
|||||||
@@ -353,8 +353,11 @@ ${msgRows}
|
|||||||
>
|
>
|
||||||
<Box gap="400" wrap="Wrap">
|
<Box gap="400" wrap="Wrap">
|
||||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||||
<Text size="T300">From</Text>
|
<Text as="label" htmlFor="export-from-date" size="T300">
|
||||||
|
From
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="export-from-date"
|
||||||
type="date"
|
type="date"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="400"
|
size="400"
|
||||||
@@ -364,8 +367,11 @@ ${msgRows}
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||||
<Text size="T300">To</Text>
|
<Text as="label" htmlFor="export-to-date" size="T300">
|
||||||
|
To
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="export-to-date"
|
||||||
type="date"
|
type="date"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -401,6 +401,9 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
|||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
|
{/* [Gitea #270] The bar's % width is of its own track,
|
||||||
|
not of the row, so the count always fits beside it. */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 6,
|
height: 6,
|
||||||
@@ -408,9 +411,9 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
|||||||
background: color.Primary.Main,
|
background: color.Primary.Main,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
{count}
|
{count}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServerAclContent = {
|
|
||||||
allow: string[];
|
|
||||||
deny: string[];
|
|
||||||
allow_ip_literals: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_ACL: ServerAclContent = {
|
|
||||||
allow: ['*'],
|
|
||||||
deny: [],
|
|
||||||
allow_ip_literals: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServerListProps = {
|
type ServerListProps = {
|
||||||
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
|
|
||||||
// Read current ACL from room state
|
// Read current ACL from room state
|
||||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
||||||
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
|
||||||
|
|
||||||
// Local draft state — initialised from current ACL
|
// Local draft state — initialised from current ACL
|
||||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
|
||||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
|
||||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||||
() => currentAcl.allow_ip_literals ?? false,
|
() => currentAcl.allow_ip_literals,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track whether there are unsaved changes
|
// Track whether there are unsaved changes
|
||||||
const isDirty =
|
const isDirty =
|
||||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
|
||||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
|
||||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
const [saveState, save] = useAsyncCallback(
|
const [saveState, save] = useAsyncCallback(
|
||||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
id="allow-ip-literals"
|
id="allow-ip-literals"
|
||||||
checked={allowIpLiterals}
|
checked={allowIpLiterals}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
|
// folds' Checkbox has no onChange; readOnly tells React the
|
||||||
|
// click handler controls it (no "checked without onChange").
|
||||||
|
readOnly
|
||||||
|
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
|||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
|
|||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
import {
|
||||||
|
describeScheduleError,
|
||||||
|
MAX_SCHEDULE_AHEAD_MS,
|
||||||
|
scheduleMessage,
|
||||||
|
SCHEDULE_TOO_FAR_TEXT,
|
||||||
|
} from '../../utils/scheduledMessages';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import {
|
import {
|
||||||
toLocalDate,
|
toLocalDate,
|
||||||
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const diffMs = sendAt.getTime() - Date.now();
|
const diffMs = sendAt.getTime() - Date.now();
|
||||||
if (diffMs < 60_000) {
|
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
|
||||||
|
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
|
||||||
|
setError((e) => {
|
||||||
|
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
|
||||||
|
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
|
||||||
|
});
|
||||||
|
if (diffMs < 60_000 || tooFar) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,6 +160,10 @@ export function ScheduleMessageModal({
|
|||||||
setError('Scheduled time must be at least 1 minute in the future.');
|
setError('Scheduled time must be at least 1 minute in the future.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
|
||||||
|
setError(SCHEDULE_TOO_FAR_TEXT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!messageText.trim()) {
|
if (!messageText.trim()) {
|
||||||
setError('Please enter a message to schedule.');
|
setError('Please enter a message to schedule.');
|
||||||
return;
|
return;
|
||||||
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
|
|||||||
onScheduled(delayId, sendAt.getTime(), content);
|
onScheduled(delayId, sendAt.getTime(), content);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
setError(describeScheduleError(err));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -247,6 +262,8 @@ export function ScheduleMessageModal({
|
|||||||
<input
|
<input
|
||||||
id="schedule-date"
|
id="schedule-date"
|
||||||
type="date"
|
type="date"
|
||||||
|
min={toLocalDate(new Date())}
|
||||||
|
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
|
||||||
value={dateValue}
|
value={dateValue}
|
||||||
onChange={(e) => setDateValue(e.target.value)}
|
onChange={(e) => setDateValue(e.target.value)}
|
||||||
style={pickerInputStyle(color, config)}
|
style={pickerInputStyle(color, config)}
|
||||||
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
(dateValue || timeValue) && (
|
(dateValue || timeValue) &&
|
||||||
|
error !== SCHEDULE_TOO_FAR_TEXT && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Must be at least 1 minute in the future
|
Must be at least 1 minute in the future
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -1137,7 +1137,12 @@ export const Message = React.memo(
|
|||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [Gitea #269] The content column shrinks to the message's width; the
|
||||||
|
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
|
||||||
|
// so their `margin-left: auto` reaches the row's right edge instead of
|
||||||
|
// wherever the text happens to end.
|
||||||
const msgContentJSX = (
|
const msgContentJSX = (
|
||||||
|
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
|
||||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||||
{reply}
|
{reply}
|
||||||
{edit && onEditId ? (
|
{edit && onEditId ? (
|
||||||
@@ -1156,6 +1161,7 @@ export const Message = React.memo(
|
|||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
{reactions}
|
{reactions}
|
||||||
|
</Box>
|
||||||
{readReceiptUsers.length > 0 && (
|
{readReceiptUsers.length > 0 && (
|
||||||
<ReadReceiptAvatars
|
<ReadReceiptAvatars
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||||
|
|
||||||
export const MessageBase = style({
|
export const MessageBase = style({
|
||||||
@@ -77,3 +77,14 @@ export const ReactionsMore = style([
|
|||||||
export const ReactionsTooltipText = style({
|
export const ReactionsTooltipText = style({
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [Gitea #199] Long-press opens the message action sheet on touch screens. On
|
||||||
|
// iOS a long-press otherwise belongs to the system: a link/image callout, or
|
||||||
|
// the text-selection loupe, both of which race (and win against) the 450 ms
|
||||||
|
// press. No callout on message rows, and no selection while a finger is
|
||||||
|
// down; copying text is in the sheet.
|
||||||
|
globalStyle('[data-touch-actions]', { WebkitTouchCallout: 'none' });
|
||||||
|
globalStyle('[data-touch-actions][data-pressing], [data-touch-actions][data-pressing] *', {
|
||||||
|
userSelect: 'none',
|
||||||
|
WebkitUserSelect: 'none',
|
||||||
|
});
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||||||
No Pinned Messages
|
No Pinned Messages
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T400" align="Center">
|
<Text size="T400" align="Center">
|
||||||
Users with sufficient power level can pin a messages from its context menu.
|
Users with sufficient power level can pin a message from its context menu.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import React, { useEffect, useSyncExternalStore } from 'react';
|
||||||
|
import { Box, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import {
|
||||||
|
destroyAllWidgetSessions,
|
||||||
|
destroyWidgetSession,
|
||||||
|
FLOATING_WIDGET,
|
||||||
|
floatingWidgetSessions,
|
||||||
|
subscribeWidgetSessions,
|
||||||
|
widgetSessionsVersion,
|
||||||
|
} from './widgetSessions';
|
||||||
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
|
import { widgetViewingAtom, widgetsPanelAtom } from '../../../state/widgetsPanel';
|
||||||
|
import { zIndices } from '../../../styles/zIndex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #205] Title bar for a widget kept on screen (MSC2931) after its room
|
||||||
|
* or the Widgets panel was left. The widget's own frame is positioned by
|
||||||
|
* widgetSessions.ts right below this bar. Mounted once for the signed-in
|
||||||
|
* client; unmounting (logout) stops every widget.
|
||||||
|
*/
|
||||||
|
export function FloatingWidget() {
|
||||||
|
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||||
|
const { navigateRoom } = useRoomNavigate();
|
||||||
|
const setPanel = useSetAtom(widgetsPanelAtom);
|
||||||
|
const setViewing = useSetAtom(widgetViewingAtom);
|
||||||
|
useEffect(() => () => destroyAllWidgetSessions(), []);
|
||||||
|
|
||||||
|
const session = floatingWidgetSessions()[0];
|
||||||
|
if (!session) return null;
|
||||||
|
const f = FLOATING_WIDGET;
|
||||||
|
const back = () => {
|
||||||
|
setViewing((prev) => ({ ...prev, [session.roomId]: session.widgetId }));
|
||||||
|
setPanel(true);
|
||||||
|
navigateRoom(session.roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
role="region"
|
||||||
|
aria-label={`${session.name} (kept on screen)`}
|
||||||
|
alignItems="Center"
|
||||||
|
gap="100"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
right: f.right,
|
||||||
|
bottom: f.bottom + f.height,
|
||||||
|
width: f.width,
|
||||||
|
height: f.bar,
|
||||||
|
zIndex: zIndices.floatingWidget,
|
||||||
|
padding: `0 ${config.space.S100} 0 ${config.space.S300}`,
|
||||||
|
background: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
borderRadius: '8px 8px 0 0',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Category} />
|
||||||
|
<Text size="T300" truncate style={{ flexGrow: 1 }}>
|
||||||
|
{session.name}
|
||||||
|
</Text>
|
||||||
|
<IconButton size="300" radii="300" variant="Surface" aria-label="Back to room" onClick={back}>
|
||||||
|
<Icon size="100" src={Icons.ArrowGoLeft} />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
aria-label={`Close ${session.name}`}
|
||||||
|
onClick={() => destroyWidgetSession(session.key)}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,138 +1,48 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useSyncExternalStore } from 'react';
|
||||||
import { Box, Icon, Icons, Text, color } from 'folds';
|
import { Box, Icon, Icons, Text, color } from 'folds';
|
||||||
import {
|
import { Room } from 'matrix-js-sdk';
|
||||||
MatrixEvent,
|
import { Widget } from 'matrix-widget-api';
|
||||||
MatrixEventEvent,
|
|
||||||
Room,
|
|
||||||
RoomEvent,
|
|
||||||
RoomEventHandlerMap,
|
|
||||||
RoomStateEvent,
|
|
||||||
} from 'matrix-js-sdk';
|
|
||||||
import { Capability, ClientWidgetApi, IRoomEvent, Widget } from 'matrix-widget-api';
|
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
|
||||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
|
||||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
|
||||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
import {
|
||||||
|
attachWidgetSession,
|
||||||
type PendingAsk = {
|
detachWidgetSession,
|
||||||
requests: WidgetPermissionRequest[];
|
getWidgetSession,
|
||||||
origin: string;
|
subscribeWidgetSessions,
|
||||||
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
widgetSessionKey,
|
||||||
};
|
widgetSessionsVersion,
|
||||||
|
} from './widgetSessions';
|
||||||
|
|
||||||
type RoomWidgetViewProps = {
|
type RoomWidgetViewProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
widget: Widget;
|
widget: Widget;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
|
// Shows one room widget in the Widgets panel. The widget itself runs in a
|
||||||
// that wait on the client handshake load). Display-only access is granted
|
// session (widgetSessions.ts) whose sandboxed iframe lives in an app-level
|
||||||
// silently; reading/sending in this room is asked for (WidgetPermissionPrompt)
|
// layer and is positioned over this placeholder, so a widget that asked to
|
||||||
// and, once granted, the room's live events are fed to the widget.
|
// stay on screen can keep running in a floating window after the panel closes.
|
||||||
// Re-mounts only when the widget id or its (template) URL changes — not on every
|
// Display-only access is granted silently; reading/sending in this room is
|
||||||
// unrelated room-state update — so viewing a widget doesn't reload constantly.
|
// asked for (WidgetPermissionPrompt) and, once granted, the room's live events
|
||||||
|
// are fed to the widget. Re-attaches only when the widget id or its (template)
|
||||||
|
// URL changes, so viewing a widget doesn't reload it on unrelated state updates.
|
||||||
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const slotRef = useRef<HTMLDivElement>(null);
|
||||||
const widgetRef = useRef(widget);
|
const widgetRef = useRef(widget);
|
||||||
widgetRef.current = widget;
|
widgetRef.current = widget;
|
||||||
const [blocked, setBlocked] = useState(false);
|
const key = widgetSessionKey(room.roomId, widget.id);
|
||||||
const [pendingAsk, setPendingAsk] = useState<PendingAsk>();
|
useSyncExternalStore(subscribeWidgetSessions, widgetSessionsVersion);
|
||||||
|
const session = getWidgetSession(key);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = containerRef.current;
|
const slot = slotRef.current;
|
||||||
if (!container) return undefined;
|
if (!slot) return undefined;
|
||||||
const current = widgetRef.current;
|
attachWidgetSession(mx, room, widgetRef.current, slot);
|
||||||
|
return () => detachWidgetSession(key, slot);
|
||||||
|
}, [mx, room, key, widget.templateUrl]);
|
||||||
|
|
||||||
const completeUrl = current.getCompleteUrl({
|
if (session?.blocked) {
|
||||||
currentUserId: mx.getSafeUserId(),
|
|
||||||
widgetRoomId: room.roomId,
|
|
||||||
deviceId: mx.getDeviceId() ?? undefined,
|
|
||||||
baseUrl: mx.baseUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Security: never render a same-origin widget with allow-same-origin (a
|
|
||||||
// same-origin frame could break out of the sandbox against our own origin).
|
|
||||||
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
|
|
||||||
setBlocked(true);
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
setBlocked(false);
|
|
||||||
|
|
||||||
const iframe = document.createElement('iframe');
|
|
||||||
iframe.title = current.name || 'Widget';
|
|
||||||
iframe.sandbox.value =
|
|
||||||
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
|
||||||
iframe.allow = 'autoplay; clipboard-write;';
|
|
||||||
iframe.src = completeUrl;
|
|
||||||
iframe.style.width = '100%';
|
|
||||||
iframe.style.height = '100%';
|
|
||||||
iframe.style.border = 'none';
|
|
||||||
container.append(iframe);
|
|
||||||
|
|
||||||
let origin = completeUrl;
|
|
||||||
try {
|
|
||||||
origin = new URL(completeUrl).host;
|
|
||||||
} catch {
|
|
||||||
// isWidgetUrlSafe already rejected unparsable URLs
|
|
||||||
}
|
|
||||||
let cancelAsk: (() => void) | undefined;
|
|
||||||
const driver = new GeneralWidgetDriver(
|
|
||||||
mx,
|
|
||||||
room.roomId,
|
|
||||||
{ id: current.id, url: current.templateUrl },
|
|
||||||
(requests) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
let settled = false;
|
|
||||||
const done = (allowed: Set<Capability>, remember: boolean) => {
|
|
||||||
if (settled) return;
|
|
||||||
settled = true;
|
|
||||||
cancelAsk = undefined;
|
|
||||||
setPendingAsk(undefined);
|
|
||||||
resolve({ allowed, remember });
|
|
||||||
};
|
|
||||||
cancelAsk = () => done(new Set(), false);
|
|
||||||
setPendingAsk({ requests, origin, resolve: done });
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const clientApi = new ClientWidgetApi(current, iframe, driver);
|
|
||||||
// Only messages from this widget's own frame and origin are handled, so a
|
|
||||||
// widget can't impersonate another one (or the call).
|
|
||||||
restrictWidgetMessages(clientApi, iframe, current.origin);
|
|
||||||
clientApi.setViewedRoomId(room.roomId);
|
|
||||||
|
|
||||||
// Live room events for widgets that were allowed to see them
|
|
||||||
// (ClientWidgetApi drops anything the widget has no capability for).
|
|
||||||
const feed = (ev: MatrixEvent) => {
|
|
||||||
if (ev.getRoomId() !== room.roomId) return;
|
|
||||||
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
|
||||||
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
||||||
};
|
|
||||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
|
||||||
if (toStart || removed || !data?.liveEvent) return;
|
|
||||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
|
||||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
|
||||||
else feed(ev);
|
|
||||||
};
|
|
||||||
const onState = (ev: MatrixEvent) => {
|
|
||||||
if (ev.getRoomId() !== room.roomId) return;
|
|
||||||
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
|
||||||
};
|
|
||||||
mx.on(RoomEvent.Timeline, onTimeline);
|
|
||||||
mx.on(RoomStateEvent.Events, onState);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mx.off(RoomEvent.Timeline, onTimeline);
|
|
||||||
mx.off(RoomStateEvent.Events, onState);
|
|
||||||
cancelAsk?.();
|
|
||||||
clientApi.stop();
|
|
||||||
iframe.remove();
|
|
||||||
};
|
|
||||||
}, [mx, room.roomId, widget.id, widget.templateUrl]);
|
|
||||||
|
|
||||||
if (blocked) {
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
|
||||||
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
|
||||||
@@ -149,14 +59,14 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
<Box ref={slotRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />
|
||||||
{pendingAsk && (
|
{session?.pendingAsk && (
|
||||||
<WidgetPermissionPrompt
|
<WidgetPermissionPrompt
|
||||||
widgetName={widget.name || 'Widget'}
|
widgetName={widget.name || 'Widget'}
|
||||||
origin={pendingAsk.origin}
|
origin={session.pendingAsk.origin}
|
||||||
addedBy={addedBy}
|
addedBy={addedBy}
|
||||||
requests={pendingAsk.requests}
|
requests={session.pendingAsk.requests}
|
||||||
onDone={pendingAsk.resolve}
|
onDone={session.pendingAsk.resolve}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
config,
|
config,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { Room } from 'matrix-js-sdk';
|
import { Room } from 'matrix-js-sdk';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { widgetViewingAtom } from '../../../state/widgetsPanel';
|
||||||
import * as css from './WidgetsPanel.css';
|
import * as css from './WidgetsPanel.css';
|
||||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||||
import { RoomWidgetView } from './RoomWidgetView';
|
import { RoomWidgetView } from './RoomWidgetView';
|
||||||
@@ -27,7 +29,13 @@ import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
|||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
import { StateEvent } from '../../../../types/matrix/room';
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
import { sendStateEvent } from '../../../utils/room';
|
import { sendStateEvent } from '../../../utils/room';
|
||||||
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
|
import {
|
||||||
|
generateWidgetId,
|
||||||
|
validateWidgetUrl,
|
||||||
|
widgetCompleteUrl,
|
||||||
|
widgetPopoutUrl,
|
||||||
|
WidgetUrlError,
|
||||||
|
} from './widgetUtils';
|
||||||
|
|
||||||
const urlErrorMessage = (err: WidgetUrlError): string => {
|
const urlErrorMessage = (err: WidgetUrlError): string => {
|
||||||
switch (err) {
|
switch (err) {
|
||||||
@@ -54,12 +62,23 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
const permissions = useRoomPermissions(creators, powerLevels);
|
const permissions = useRoomPermissions(creators, powerLevels);
|
||||||
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
|
||||||
|
|
||||||
const [viewingId, setViewingId] = useState<string | null>(null);
|
const [viewingByRoom, setViewingByRoom] = useAtom(widgetViewingAtom);
|
||||||
|
const viewingId = viewingByRoom[room.roomId] ?? null;
|
||||||
|
const setViewingId = (id: string | null) =>
|
||||||
|
setViewingByRoom((prev) => ({ ...prev, [room.roomId]: id }));
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [error, setError] = useState<string>();
|
const [error, setError] = useState<string>();
|
||||||
|
|
||||||
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
|
||||||
|
// [Gitea #205] Open a widget in its own window, beside the chat. It runs
|
||||||
|
// standalone there (no widget API), as in Element's popout.
|
||||||
|
const popoutUrl = (widget: (typeof widgets)[number]) =>
|
||||||
|
widgetPopoutUrl(widgetCompleteUrl(widget, mx, room.roomId), window.location.origin);
|
||||||
|
const popout = (widget: (typeof widgets)[number]) => {
|
||||||
|
const url = popoutUrl(widget);
|
||||||
|
if (url) window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
@@ -144,7 +163,13 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
<Box grow="Yes" className={css.WidgetsPanelContent}>
|
||||||
{viewing ? (
|
{viewing ? (
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Box shrink="No" style={{ padding: config.space.S200 }}>
|
<Box
|
||||||
|
shrink="No"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: config.space.S200 }}
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
@@ -157,6 +182,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
{viewing.name || 'Widget'}
|
{viewing.name || 'Widget'}
|
||||||
</Text>
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
{popoutUrl(viewing) && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label={`Open ${viewing.name || 'widget'} in a new window`}
|
||||||
|
onClick={() => popout(viewing)}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.External} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<RoomWidgetView room={room} widget={viewing} />
|
<RoomWidgetView room={room} widget={viewing} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -184,6 +220,17 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
|
|||||||
{widget.name || widget.templateUrl}
|
{widget.name || widget.templateUrl}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
{popoutUrl(widget) && (
|
||||||
|
<IconButton
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Background"
|
||||||
|
aria-label={`Open ${widget.name || 'widget'} in a new window`}
|
||||||
|
onClick={() => popout(widget)}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={Icons.External} />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
{canModify && (
|
{canModify && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="300"
|
size="300"
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import {
|
||||||
|
MatrixClient,
|
||||||
|
MatrixEvent,
|
||||||
|
MatrixEventEvent,
|
||||||
|
Room,
|
||||||
|
RoomEvent,
|
||||||
|
RoomEventHandlerMap,
|
||||||
|
RoomStateEvent,
|
||||||
|
} from 'matrix-js-sdk';
|
||||||
|
import {
|
||||||
|
Capability,
|
||||||
|
ClientWidgetApi,
|
||||||
|
IRoomEvent,
|
||||||
|
IWidgetApiRequest,
|
||||||
|
MatrixCapabilities,
|
||||||
|
Widget,
|
||||||
|
WidgetApiFromWidgetAction,
|
||||||
|
} from 'matrix-widget-api';
|
||||||
|
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||||
|
import { WidgetPermissionRequest, isWidgetUrlSafe, widgetCompleteUrl } from './widgetUtils';
|
||||||
|
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||||
|
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||||
|
import { zIndices } from '../../../styles/zIndex';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #205] Running room widgets, kept outside React so a widget that asked
|
||||||
|
* to stay on screen (MSC2931 `set_always_on_screen`) survives closing the
|
||||||
|
* Widgets panel or switching rooms.
|
||||||
|
*
|
||||||
|
* Moving an iframe in the DOM reloads it, so every widget frame lives from the
|
||||||
|
* start in one app-level fixed layer. While the panel shows the widget, the
|
||||||
|
* frame is positioned over the panel's placeholder (`attach`), so it looks and
|
||||||
|
* behaves as before. When the placeholder goes away (`detach`) the widget is
|
||||||
|
* torn down, unless it is always-on-screen: then the same live frame moves to
|
||||||
|
* a small floating window (see FloatingWidget) until it is closed, the widget
|
||||||
|
* says it no longer needs to be on screen, or it is removed from the room.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PendingAsk = {
|
||||||
|
requests: WidgetPermissionRequest[];
|
||||||
|
origin: string;
|
||||||
|
resolve: (allowed: Set<Capability>, remember: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WidgetSession = {
|
||||||
|
readonly key: string;
|
||||||
|
readonly roomId: string;
|
||||||
|
readonly widgetId: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly templateUrl: string;
|
||||||
|
/** The URL is on this app's own origin: never rendered. */
|
||||||
|
readonly blocked: boolean;
|
||||||
|
alwaysOnScreen: boolean;
|
||||||
|
floating: boolean;
|
||||||
|
pendingAsk?: PendingAsk;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Internal = WidgetSession & {
|
||||||
|
frame: HTMLDivElement;
|
||||||
|
slot: HTMLElement | null;
|
||||||
|
raf: number | undefined;
|
||||||
|
stop: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The floating window's geometry (FloatingWidget draws its title bar above). */
|
||||||
|
export const FLOATING_WIDGET = { right: 16, bottom: 96, width: 360, height: 240, bar: 36 };
|
||||||
|
|
||||||
|
const DOCKED_Z = 600; // just above the side panel (500 on phones), below popovers/modals
|
||||||
|
|
||||||
|
const sessions = new Map<string, Internal>();
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let version = 0;
|
||||||
|
const notify = () => {
|
||||||
|
version += 1;
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
};
|
||||||
|
export const subscribeWidgetSessions = (l: () => void): (() => void) => {
|
||||||
|
listeners.add(l);
|
||||||
|
return () => listeners.delete(l);
|
||||||
|
};
|
||||||
|
export const widgetSessionsVersion = (): number => version;
|
||||||
|
|
||||||
|
export const widgetSessionKey = (roomId: string, widgetId: string): string =>
|
||||||
|
`${roomId}|${widgetId}`;
|
||||||
|
|
||||||
|
export const getWidgetSession = (key: string): WidgetSession | undefined => sessions.get(key);
|
||||||
|
|
||||||
|
export const floatingWidgetSessions = (): WidgetSession[] =>
|
||||||
|
[...sessions.values()].filter((s) => s.floating);
|
||||||
|
|
||||||
|
let layerEl: HTMLDivElement | undefined;
|
||||||
|
const layer = (): HTMLDivElement => {
|
||||||
|
if (!layerEl || !layerEl.isConnected) {
|
||||||
|
layerEl = document.createElement('div');
|
||||||
|
layerEl.id = 'lotus-widget-layer';
|
||||||
|
// The layer itself is click-through; only the frames inside take input.
|
||||||
|
Object.assign(layerEl.style, { position: 'fixed', inset: '0', pointerEvents: 'none' });
|
||||||
|
document.body.append(layerEl);
|
||||||
|
}
|
||||||
|
return layerEl;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideFrame = (s: Internal) => {
|
||||||
|
s.frame.style.visibility = 'hidden';
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeDocked = (s: Internal) => {
|
||||||
|
const slot = s.slot;
|
||||||
|
if (!slot) return;
|
||||||
|
const r = slot.getBoundingClientRect();
|
||||||
|
if (r.width === 0 || r.height === 0) {
|
||||||
|
hideFrame(s);
|
||||||
|
} else {
|
||||||
|
Object.assign(s.frame.style, {
|
||||||
|
visibility: 'visible',
|
||||||
|
left: `${r.left}px`,
|
||||||
|
top: `${r.top}px`,
|
||||||
|
width: `${r.width}px`,
|
||||||
|
height: `${r.height}px`,
|
||||||
|
right: '',
|
||||||
|
bottom: '',
|
||||||
|
borderRadius: '0',
|
||||||
|
boxShadow: 'none',
|
||||||
|
zIndex: String(DOCKED_Z),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
s.raf = requestAnimationFrame(() => placeDocked(s));
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeFloating = (s: Internal) => {
|
||||||
|
const f = FLOATING_WIDGET;
|
||||||
|
Object.assign(s.frame.style, {
|
||||||
|
visibility: 'visible',
|
||||||
|
left: '',
|
||||||
|
top: '',
|
||||||
|
right: `${f.right}px`,
|
||||||
|
bottom: `${f.bottom}px`,
|
||||||
|
width: `${f.width}px`,
|
||||||
|
height: `${f.height}px`,
|
||||||
|
borderRadius: '0 0 8px 8px',
|
||||||
|
boxShadow: '0 8px 24px rgba(0,0,0,0.35)',
|
||||||
|
zIndex: String(zIndices.floatingWidget),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopPlacing = (s: Internal) => {
|
||||||
|
if (s.raf !== undefined) cancelAnimationFrame(s.raf);
|
||||||
|
s.raf = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const destroyWidgetSession = (key: string): void => {
|
||||||
|
const s = sessions.get(key);
|
||||||
|
if (!s) return;
|
||||||
|
sessions.delete(key);
|
||||||
|
stopPlacing(s);
|
||||||
|
s.stop();
|
||||||
|
s.frame.remove();
|
||||||
|
notify();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const destroyAllWidgetSessions = (): void => {
|
||||||
|
[...sessions.keys()].forEach(destroyWidgetSession);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Start (or reuse) the widget's session and dock its frame over `slot`. */
|
||||||
|
export function attachWidgetSession(
|
||||||
|
mx: MatrixClient,
|
||||||
|
room: Room,
|
||||||
|
widget: Widget,
|
||||||
|
slot: HTMLElement,
|
||||||
|
): WidgetSession {
|
||||||
|
const key = widgetSessionKey(room.roomId, widget.id);
|
||||||
|
let s = sessions.get(key);
|
||||||
|
if (s && s.templateUrl !== widget.templateUrl) {
|
||||||
|
destroyWidgetSession(key);
|
||||||
|
s = undefined;
|
||||||
|
}
|
||||||
|
if (!s) s = startSession(mx, room, widget, key);
|
||||||
|
stopPlacing(s);
|
||||||
|
s.slot = slot;
|
||||||
|
s.floating = false;
|
||||||
|
if (!s.blocked) placeDocked(s);
|
||||||
|
notify();
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The panel stopped showing the widget: float it, or tear it down. */
|
||||||
|
export function detachWidgetSession(key: string, slot: HTMLElement): void {
|
||||||
|
const s = sessions.get(key);
|
||||||
|
if (!s || s.slot !== slot) return;
|
||||||
|
s.slot = null;
|
||||||
|
stopPlacing(s);
|
||||||
|
if (!s.alwaysOnScreen || s.blocked) {
|
||||||
|
destroyWidgetSession(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// One floating widget at a time: a newer one replaces an older one.
|
||||||
|
floatingWidgetSessions()
|
||||||
|
.filter((o) => o.key !== key)
|
||||||
|
.forEach((o) => destroyWidgetSession(o.key));
|
||||||
|
// Nobody is there to answer a permission prompt any more.
|
||||||
|
s.pendingAsk?.resolve(new Set(), false);
|
||||||
|
s.floating = true;
|
||||||
|
placeFloating(s);
|
||||||
|
notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSession(mx: MatrixClient, room: Room, widget: Widget, key: string): Internal {
|
||||||
|
const frame = document.createElement('div');
|
||||||
|
Object.assign(frame.style, {
|
||||||
|
position: 'fixed',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
overflow: 'hidden',
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeUrl = widgetCompleteUrl(widget, mx, room.roomId);
|
||||||
|
const blocked = !isWidgetUrlSafe(completeUrl, window.location.origin);
|
||||||
|
const s: Internal = {
|
||||||
|
key,
|
||||||
|
roomId: room.roomId,
|
||||||
|
widgetId: widget.id,
|
||||||
|
name: widget.name || 'Widget',
|
||||||
|
templateUrl: widget.templateUrl,
|
||||||
|
blocked,
|
||||||
|
alwaysOnScreen: false,
|
||||||
|
floating: false,
|
||||||
|
frame,
|
||||||
|
slot: null,
|
||||||
|
raf: undefined,
|
||||||
|
stop: () => undefined,
|
||||||
|
};
|
||||||
|
sessions.set(key, s);
|
||||||
|
if (blocked) return s;
|
||||||
|
|
||||||
|
// Security: never render a same-origin widget with allow-same-origin (a
|
||||||
|
// same-origin frame could break out of the sandbox against our own origin).
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.title = widget.name || 'Widget';
|
||||||
|
iframe.sandbox.value = 'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
|
||||||
|
iframe.allow = 'autoplay; clipboard-write;';
|
||||||
|
iframe.src = completeUrl;
|
||||||
|
Object.assign(iframe.style, { width: '100%', height: '100%', border: 'none' });
|
||||||
|
frame.append(iframe);
|
||||||
|
layer().append(frame);
|
||||||
|
|
||||||
|
let origin = completeUrl;
|
||||||
|
try {
|
||||||
|
origin = new URL(completeUrl).host;
|
||||||
|
} catch {
|
||||||
|
// isWidgetUrlSafe already rejected unparsable URLs
|
||||||
|
}
|
||||||
|
let cancelAsk: (() => void) | undefined;
|
||||||
|
const driver = new GeneralWidgetDriver(
|
||||||
|
mx,
|
||||||
|
room.roomId,
|
||||||
|
{ id: widget.id, url: widget.templateUrl },
|
||||||
|
(requests) =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const done = (allowed: Set<Capability>, remember: boolean) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
cancelAsk = undefined;
|
||||||
|
s.pendingAsk = undefined;
|
||||||
|
notify();
|
||||||
|
resolve({ allowed, remember });
|
||||||
|
};
|
||||||
|
cancelAsk = () => done(new Set(), false);
|
||||||
|
// A floating widget has nobody to ask: grant nothing extra.
|
||||||
|
if (s.floating) {
|
||||||
|
done(new Set(), false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.pendingAsk = { requests, origin, resolve: done };
|
||||||
|
notify();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const clientApi = new ClientWidgetApi(widget, iframe, driver);
|
||||||
|
// Only messages from this widget's own frame and origin are handled, so a
|
||||||
|
// widget can't impersonate another one (or the call).
|
||||||
|
restrictWidgetMessages(clientApi, iframe, widget.origin);
|
||||||
|
clientApi.setViewedRoomId(room.roomId);
|
||||||
|
|
||||||
|
// MSC2931: the widget asks to stay visible when the user navigates away.
|
||||||
|
const onAlwaysOnScreen = (ev: CustomEvent<IWidgetApiRequest>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
if (!clientApi.hasCapability(MatrixCapabilities.AlwaysOnScreen)) {
|
||||||
|
clientApi.transport.reply(ev.detail, {
|
||||||
|
error: { message: 'Missing capability' },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const value = (ev.detail.data as { value?: unknown } | undefined)?.value === true;
|
||||||
|
s.alwaysOnScreen = value;
|
||||||
|
clientApi.transport.reply(ev.detail, {});
|
||||||
|
// Floating only because it asked: once it no longer needs to be, go.
|
||||||
|
if (!value && s.floating) destroyWidgetSession(key);
|
||||||
|
else notify();
|
||||||
|
};
|
||||||
|
clientApi.on(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
||||||
|
|
||||||
|
// Live room events for widgets that were allowed to see them
|
||||||
|
// (ClientWidgetApi drops anything the widget has no capability for).
|
||||||
|
const feed = (ev: MatrixEvent) => {
|
||||||
|
if (ev.getRoomId() !== room.roomId) return;
|
||||||
|
if (ev.isBeingDecrypted() || ev.isDecryptionFailure()) return;
|
||||||
|
clientApi.feedEvent(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||||
|
};
|
||||||
|
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||||
|
if (toStart || removed || !data?.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||||
|
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||||
|
else feed(ev);
|
||||||
|
};
|
||||||
|
const onState = (ev: MatrixEvent) => {
|
||||||
|
if (ev.getRoomId() !== room.roomId) return;
|
||||||
|
// The widget was removed from the room while it was floating.
|
||||||
|
if (
|
||||||
|
s.floating &&
|
||||||
|
(ev.getType() === 'im.vector.modular.widgets' || ev.getType() === 'm.widget') &&
|
||||||
|
ev.getStateKey() === widget.id &&
|
||||||
|
!ev.getContent()?.url
|
||||||
|
) {
|
||||||
|
destroyWidgetSession(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clientApi.feedStateUpdate(ev.getEffectiveEvent() as IRoomEvent).catch(() => undefined);
|
||||||
|
};
|
||||||
|
mx.on(RoomEvent.Timeline, onTimeline);
|
||||||
|
mx.on(RoomStateEvent.Events, onState);
|
||||||
|
|
||||||
|
s.stop = () => {
|
||||||
|
mx.off(RoomEvent.Timeline, onTimeline);
|
||||||
|
mx.off(RoomStateEvent.Events, onState);
|
||||||
|
clientApi.off(`action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, onAlwaysOnScreen);
|
||||||
|
cancelAsk?.();
|
||||||
|
clientApi.stop();
|
||||||
|
};
|
||||||
|
return s;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
generateWidgetId,
|
generateWidgetId,
|
||||||
classifyWidgetCapabilities,
|
classifyWidgetCapabilities,
|
||||||
loadWidgetConsent,
|
loadWidgetConsent,
|
||||||
|
widgetPopoutUrl,
|
||||||
} from './widgetUtils';
|
} from './widgetUtils';
|
||||||
|
|
||||||
const APP = 'https://chat.lotusguild.org';
|
const APP = 'https://chat.lotusguild.org';
|
||||||
@@ -106,3 +107,33 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
|
|||||||
test('loadWidgetConsent tolerates missing storage', () => {
|
test('loadWidgetConsent tolerates missing storage', () => {
|
||||||
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
|
||||||
|
const { auto, ask } = classifyWidgetCapabilities(
|
||||||
|
[
|
||||||
|
'm.space.child',
|
||||||
|
'm.space.parent',
|
||||||
|
'org.matrix.msc3401.call',
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
'm.call.member',
|
||||||
|
'io.lotus.voice_limit',
|
||||||
|
'io.lotus.room_quality',
|
||||||
|
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
|
||||||
|
);
|
||||||
|
assert.equal(auto.size, 0);
|
||||||
|
assert.deepEqual(ask, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[#205] widgetPopoutUrl: only cross-origin https opens in a new window', () => {
|
||||||
|
// Built at runtime so the no-script-url lint rule doesn't flag the test data.
|
||||||
|
const scriptUrl = ['javascript', 'alert(1)'].join(':');
|
||||||
|
assert.equal(
|
||||||
|
widgetPopoutUrl('https://pad.example.org/p/room', APP),
|
||||||
|
'https://pad.example.org/p/room',
|
||||||
|
);
|
||||||
|
assert.equal(widgetPopoutUrl('https://chat.lotusguild.org/evil', APP), null, 'same origin');
|
||||||
|
assert.equal(widgetPopoutUrl('http://pad.example.org/', APP), null, 'not https');
|
||||||
|
assert.equal(widgetPopoutUrl(scriptUrl, APP), null, 'script URL');
|
||||||
|
assert.equal(widgetPopoutUrl('data:text/html,<b>x</b>', APP), null, 'data:');
|
||||||
|
assert.equal(widgetPopoutUrl('not a url', APP), null);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import type { MatrixClient } from 'matrix-js-sdk';
|
||||||
import {
|
import {
|
||||||
Capability,
|
Capability,
|
||||||
EventDirection,
|
EventDirection,
|
||||||
EventKind,
|
EventKind,
|
||||||
MatrixCapabilities,
|
MatrixCapabilities,
|
||||||
|
type Widget,
|
||||||
WidgetEventCapability,
|
WidgetEventCapability,
|
||||||
} from 'matrix-widget-api';
|
} from 'matrix-widget-api';
|
||||||
|
|
||||||
@@ -48,6 +50,29 @@ export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The widget's URL with its template variables filled in for this viewer. */
|
||||||
|
export const widgetCompleteUrl = (widget: Widget, mx: MatrixClient, roomId: string): string =>
|
||||||
|
widget.getCompleteUrl({
|
||||||
|
currentUserId: mx.getSafeUserId(),
|
||||||
|
widgetRoomId: roomId,
|
||||||
|
deviceId: mx.getDeviceId() ?? undefined,
|
||||||
|
baseUrl: mx.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #205] The URL to open a widget in its own window, or null when it
|
||||||
|
* mustn't be opened: only cross-origin https (room state can carry anything,
|
||||||
|
* e.g. a `javascript:` URL from another client, whose origin is "null").
|
||||||
|
*/
|
||||||
|
export const widgetPopoutUrl = (completeUrl: string, appOrigin: string): string | null => {
|
||||||
|
try {
|
||||||
|
const u = new URL(completeUrl);
|
||||||
|
return u.protocol === 'https:' && u.origin !== appOrigin ? u.href : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const generateWidgetId = (): string =>
|
export const generateWidgetId = (): string =>
|
||||||
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
|
||||||
@@ -67,6 +92,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'm.room.third_party_invite',
|
'm.room.third_party_invite',
|
||||||
'im.vector.modular.widgets',
|
'im.vector.modular.widgets',
|
||||||
'm.widget',
|
'm.widget',
|
||||||
|
// [Gitea #266] Space structure, call membership and Lotus room policy: a
|
||||||
|
// widget could restructure a space, fake call membership or lift the
|
||||||
|
// voice-channel limit, so these can't be granted even with consent.
|
||||||
|
'm.space.child',
|
||||||
|
'm.space.parent',
|
||||||
|
'org.matrix.msc3401.call',
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
'm.call.member',
|
||||||
|
'io.lotus.voice_limit',
|
||||||
|
'io.lotus.room_quality',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type WidgetPermissionRequest = {
|
export type WidgetPermissionRequest = {
|
||||||
@@ -137,8 +172,11 @@ export const classifyWidgetCapabilities = (
|
|||||||
*/
|
*/
|
||||||
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
||||||
|
|
||||||
|
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
|
||||||
|
// approvals never apply to the next account on the same device.
|
||||||
|
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
|
||||||
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
||||||
`lotus.widgetConsent.${roomId}.${widgetId}`;
|
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
|
||||||
|
|
||||||
export const loadWidgetConsent = (
|
export const loadWidgetConsent = (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
|||||||
import pkg from '../../../../../package.json';
|
import pkg from '../../../../../package.json';
|
||||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { tauriInvoke } from '../../../hooks/useTauri';
|
||||||
|
|
||||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
||||||
|
|
||||||
@@ -78,6 +79,51 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
|||||||
type AboutProps = {
|
type AboutProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* [Gitea #276] Which build someone is on: the web deploy's commit and build
|
||||||
|
* date (stamped in by vite.config.js), plus the desktop app's own version from
|
||||||
|
* Tauri, the number a desktop or updater bug report needs.
|
||||||
|
*/
|
||||||
|
function BuildInfo() {
|
||||||
|
const [desktopVersion, setDesktopVersion] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
tauriInvoke()?.('plugin:app|version')
|
||||||
|
.then((v) => {
|
||||||
|
if (typeof v === 'string') setDesktopVersion(v);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
const commit = __LOTUS_BUILD_COMMIT__;
|
||||||
|
const built = new Date(__LOTUS_BUILD_DATE__);
|
||||||
|
const builtText = Number.isNaN(built.getTime())
|
||||||
|
? undefined
|
||||||
|
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="0">
|
||||||
|
{desktopVersion && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Desktop {desktopVersion}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Web build{' '}
|
||||||
|
{commit !== 'unknown' ? (
|
||||||
|
<a
|
||||||
|
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{commit}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
commit
|
||||||
|
)}
|
||||||
|
{builtText && ` · built ${builtText}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function About({ requestClose }: AboutProps) {
|
export function About({ requestClose }: AboutProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
||||||
@@ -117,6 +163,7 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
<Text size="T200">v{pkg.version}</Text>
|
<Text size="T200">v{pkg.version}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text>A Matrix client for Lotus Guild.</Text>
|
<Text>A Matrix client for Lotus Guild.</Text>
|
||||||
|
<BuildInfo />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box gap="200" wrap="Wrap">
|
<Box gap="200" wrap="Wrap">
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||||
import { Method } from 'matrix-js-sdk';
|
import { Method } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { nameInitials } from '../../../utils/common';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
|||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
const CELL_SIZE = 72;
|
const CELL_SIZE = 72;
|
||||||
|
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
|
||||||
|
const PREVIEW_INSET = 8;
|
||||||
|
|
||||||
function DecorationPreviewCell({
|
function DecorationPreviewCell({
|
||||||
slug,
|
slug,
|
||||||
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
|
|||||||
export function ProfileDecoration() {
|
export function ProfileDecoration() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const profile = useUserProfile(userId);
|
||||||
|
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const myAvatarUrl = profile.avatarUrl
|
||||||
|
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [current, setCurrent] = useState<string | null>(null);
|
const [current, setCurrent] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
|
|||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
{/* Current selection preview */}
|
{/* Current selection preview */}
|
||||||
<Box alignItems="Center" gap="300">
|
<Box alignItems="Center" gap="300">
|
||||||
|
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
|
||||||
|
timeline draws it (AvatarDecoration: the image is the avatar plus
|
||||||
|
an 8px margin on every side). With no decoration the circle gets
|
||||||
|
a visible dashed outline instead of vanishing into the card. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
|
|||||||
height: CELL_SIZE,
|
height: CELL_SIZE,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: color.SurfaceVariant.Container,
|
boxSizing: 'border-box',
|
||||||
overflow: 'hidden',
|
border: selected
|
||||||
|
? 'none'
|
||||||
|
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Avatar
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: PREVIEW_INSET,
|
||||||
|
width: CELL_SIZE - PREVIEW_INSET * 2,
|
||||||
|
height: CELL_SIZE - PREVIEW_INSET * 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={myAvatarUrl}
|
||||||
|
alt=""
|
||||||
|
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
{selected && (
|
{selected && (
|
||||||
<img
|
<img
|
||||||
src={decorationUrl(selected)}
|
src={decorationUrl(selected)}
|
||||||
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ import {
|
|||||||
translationUnavailableText,
|
translationUnavailableText,
|
||||||
} from '../../../utils/translation/langUtils';
|
} from '../../../utils/translation/langUtils';
|
||||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
|
||||||
|
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
|
||||||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import {
|
import {
|
||||||
describeUpdateError,
|
describeUpdateError,
|
||||||
@@ -1598,6 +1599,7 @@ function Privacy() {
|
|||||||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||||||
// device-local (never synced) so switching it off here sticks.
|
// device-local (never synced) so switching it off here sticks.
|
||||||
function SettingsSyncSection() {
|
function SettingsSyncSection() {
|
||||||
|
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||||||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||||||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||||||
@@ -1621,22 +1623,7 @@ function SettingsSyncSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const syncButtons = (
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Sync</Text>
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
||||||
<SettingTile
|
|
||||||
title="Sync Settings Across Devices"
|
|
||||||
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
|
||||||
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
|
||||||
/>
|
|
||||||
</SequenceCard>
|
|
||||||
{settingsSync && (
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
||||||
<SettingTile
|
|
||||||
title="Use This Device's Settings Everywhere"
|
|
||||||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
|
||||||
after={
|
|
||||||
<Box gap="200">
|
<Box gap="200">
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
@@ -1662,8 +1649,29 @@ function SettingsSyncSection() {
|
|||||||
<Text size="B300">Clear synced copy</Text>
|
<Text size="B300">Clear synced copy</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Sync</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Sync Settings Across Devices"
|
||||||
|
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
||||||
|
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
||||||
/>
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
{settingsSync && (
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Use This Device's Settings Everywhere"
|
||||||
|
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||||||
|
// [Gitea #273] On phones the two buttons squeezed the text into a
|
||||||
|
// narrow column; put them under the description there.
|
||||||
|
after={isMobile ? undefined : syncButtons}
|
||||||
|
>
|
||||||
|
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
|
||||||
|
</SettingTile>
|
||||||
{note && (
|
{note && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1741,6 +1749,7 @@ function Calls() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
|
const [callSummaryToast, setCallSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||||
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||||
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
||||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
@@ -1800,7 +1809,8 @@ function Calls() {
|
|||||||
title="Noise Suppression"
|
title="Noise Suppression"
|
||||||
description={
|
description={
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text>
|
{/* [Gitea #271] T200 like every other setting description. */}
|
||||||
|
<Text size="T200">
|
||||||
Filter background noise from your mic during calls. Browser-native uses the built-in
|
Filter background noise from your mic during calls. Browser-native uses the built-in
|
||||||
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -2033,7 +2043,7 @@ function Calls() {
|
|||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
@@ -2053,6 +2063,13 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SettingTile
|
||||||
|
title="Summary After Calls"
|
||||||
|
description="When a call ends, show how long it was and how the connection held up, with a Details button for who was there, network numbers and your own talk/mute time."
|
||||||
|
after={
|
||||||
|
<Switch variant="Primary" value={callSummaryToast} onChange={setCallSummaryToast} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
{hapticsSupported() && (
|
{hapticsSupported() && (
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Haptic Feedback"
|
title="Haptic Feedback"
|
||||||
@@ -2089,7 +2106,12 @@ function Calls() {
|
|||||||
description={
|
description={
|
||||||
deafenBind.error ??
|
deafenBind.error ??
|
||||||
(deafenHotkey
|
(deafenHotkey
|
||||||
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
? `${
|
||||||
|
// [Gitea #279] The Escape hint only applies while rebinding.
|
||||||
|
deafenBind.listening
|
||||||
|
? 'Press a key to bind it, or Escape to cancel.'
|
||||||
|
: `Press ${keyLabel(deafenKey)} during a call to mute your speakers.`
|
||||||
|
}${
|
||||||
!isSafeGlobalToggleKey(deafenKey)
|
!isSafeGlobalToggleKey(deafenKey)
|
||||||
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
||||||
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
||||||
@@ -2130,7 +2152,7 @@ function Calls() {
|
|||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
@@ -2264,7 +2286,11 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard
|
||||||
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Soundboard"
|
title="Soundboard"
|
||||||
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||||||
@@ -2751,7 +2777,11 @@ function Messages() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard
|
||||||
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Translate Messages Into"
|
title="Translate Messages Into"
|
||||||
description={
|
description={
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
|
|||||||
key={mode}
|
key={mode}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-selected={mode === selectedMode}
|
aria-pressed={mode === selectedMode}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSelect(mode)}
|
onClick={() => handleSelect(mode)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { config } from 'folds';
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
export const SequenceCardStyle = style({
|
export const SequenceCardStyle = style({
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #273] Several settings share one card; without a separator the next
|
||||||
|
* tile's title read like part of the previous description. A thin line goes
|
||||||
|
* between tiles only (not between a tile and its own slider or note).
|
||||||
|
*/
|
||||||
|
export const SettingTileGroup = style({});
|
||||||
|
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
|
||||||
|
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
|
||||||
|
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
|
||||||
|
paddingTop: config.space.S400,
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
|||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<SpaceSettings initialPage={page} requestClose={closeSettings} />
|
<SpaceSettings initialPage={page} requestClose={closeSettings} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
import { Button, color, config, Icon, IconButton, Icons, Text } from 'folds';
|
||||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -43,13 +43,17 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (toast.sticky) return;
|
if (toast.sticky) return;
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(
|
||||||
|
() => {
|
||||||
dismiss(toast.id);
|
dismiss(toast.id);
|
||||||
}, 4000);
|
},
|
||||||
|
// [Gitea #253] A toast with a button stays long enough to use it.
|
||||||
|
toast.actionLabel ? 10_000 : 4000,
|
||||||
|
);
|
||||||
return () => {
|
return () => {
|
||||||
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
if (timerRef.current !== null) clearTimeout(timerRef.current);
|
||||||
};
|
};
|
||||||
}, [dismiss, toast.id, toast.sticky]);
|
}, [dismiss, toast.id, toast.sticky, toast.actionLabel]);
|
||||||
|
|
||||||
const handleCardClick = () => {
|
const handleCardClick = () => {
|
||||||
if (toast.onClick) {
|
if (toast.onClick) {
|
||||||
@@ -212,6 +216,24 @@ function ToastCard({ toast }: ToastCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div style={bodyStyle}>{toast.body}</div>
|
<div style={bodyStyle}>{toast.body}</div>
|
||||||
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
{toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
|
||||||
|
{toast.actionLabel && toast.onAction && (
|
||||||
|
<div style={{ marginTop: config.space.S200 }}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toast.onAction?.();
|
||||||
|
dismiss(toast.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300">{toast.actionLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { useSetAtom } from 'jotai';
|
|||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { useCallHangupEvent } from './useCallEmbed';
|
import { useCallHangupEvent } from './useCallEmbed';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
import { describeCallEnd } from '../utils/callSummary';
|
import { describeCallDetails, describeCallEnd } from '../utils/callSummary';
|
||||||
|
import { callSummaryViewAtom } from '../state/callSummary';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
// The fork sends io.lotus.call_summary on SFU disconnect, which normally lands
|
||||||
// before Element Call's hangup echo; give a late one this long before toasting.
|
// before Element Call's hangup echo; give a late one this long before toasting.
|
||||||
@@ -16,6 +19,8 @@ const SUMMARY_GRACE_MS = 400;
|
|||||||
*/
|
*/
|
||||||
export function useCallEndedToast(embed: CallEmbed): void {
|
export function useCallEndedToast(embed: CallEmbed): void {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const setSummaryView = useSetAtom(callSummaryViewAtom);
|
||||||
|
const [callSummaryToast] = useSetting(settingsAtom, 'callSummaryToast');
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -25,14 +30,25 @@ export function useCallEndedToast(embed: CallEmbed): void {
|
|||||||
useCallHangupEvent(embed, () => {
|
useCallHangupEvent(embed, () => {
|
||||||
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
||||||
fired.current = true;
|
fired.current = true;
|
||||||
|
if (!callSummaryToast) return;
|
||||||
const durationMs = Date.now() - embed.joinedAt;
|
const durationMs = Date.now() - embed.joinedAt;
|
||||||
|
const deafenedMs = embed.deafenedMs();
|
||||||
|
const roomName = embed.room.name ?? 'Voice call';
|
||||||
|
const nameOf = (userId: string) => embed.room.getMember(userId)?.name ?? userId;
|
||||||
const toast = () =>
|
const toast = () =>
|
||||||
setToast({
|
setToast({
|
||||||
id: `call-ended-${Date.now()}`,
|
id: `call-ended-${Date.now()}`,
|
||||||
displayName: 'Lotus Chat',
|
displayName: 'Lotus Chat',
|
||||||
body: describeCallEnd(durationMs, embed.lastSummary),
|
body: describeCallEnd(durationMs, embed.lastSummary),
|
||||||
roomName: embed.room.name ?? 'Voice call',
|
roomName,
|
||||||
roomId: embed.roomId,
|
roomId: embed.roomId,
|
||||||
|
// [Gitea #253] The full breakdown, snapshotted at hangup.
|
||||||
|
actionLabel: 'Details',
|
||||||
|
onAction: () =>
|
||||||
|
setSummaryView({
|
||||||
|
roomName,
|
||||||
|
sections: describeCallDetails(embed.lastSummary, durationMs, deafenedMs, nameOf),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (embed.lastSummary) toast();
|
if (embed.lastSummary) toast();
|
||||||
else setTimeout(toast, SUMMARY_GRACE_MS);
|
else setTimeout(toast, SUMMARY_GRACE_MS);
|
||||||
|
|||||||
@@ -17,22 +17,37 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
|||||||
const timer = useRef<number | undefined>(undefined);
|
const timer = useRef<number | undefined>(undefined);
|
||||||
const start = useRef<{ x: number; y: number } | null>(null);
|
const start = useRef<{ x: number; y: number } | null>(null);
|
||||||
const fired = useRef(false);
|
const fired = useRef(false);
|
||||||
|
// [Gitea #199] The element being pressed carries `data-pressing` (set on the
|
||||||
|
// DOM directly, no re-render) so iOS can't start a text selection under
|
||||||
|
// the finger; see `[data-touch-actions]` in room/message/styles.css.ts.
|
||||||
|
const pressed = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
const clear = useCallback(() => {
|
const stopTimer = useCallback(() => {
|
||||||
if (timer.current !== undefined) window.clearTimeout(timer.current);
|
if (timer.current !== undefined) window.clearTimeout(timer.current);
|
||||||
timer.current = undefined;
|
timer.current = undefined;
|
||||||
start.current = null;
|
start.current = null;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// The finger is up (or the gesture was cancelled): only now drop
|
||||||
|
// `data-pressing`, so the system selection can't start under a finger that
|
||||||
|
// is still down after the sheet opened.
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
stopTimer();
|
||||||
|
pressed.current?.removeAttribute('data-pressing');
|
||||||
|
pressed.current = null;
|
||||||
|
}, [stopTimer]);
|
||||||
|
|
||||||
const onTouchStart = useCallback(
|
const onTouchStart = useCallback(
|
||||||
(evt: React.TouchEvent) => {
|
(evt: React.TouchEvent) => {
|
||||||
if (!coarse || evt.touches.length !== 1) return;
|
if (!coarse || evt.touches.length !== 1) return;
|
||||||
const t = evt.touches[0];
|
const t = evt.touches[0];
|
||||||
start.current = { x: t.clientX, y: t.clientY };
|
start.current = { x: t.clientX, y: t.clientY };
|
||||||
fired.current = false;
|
fired.current = false;
|
||||||
|
pressed.current = evt.currentTarget as HTMLElement;
|
||||||
|
pressed.current.setAttribute('data-pressing', '');
|
||||||
timer.current = window.setTimeout(() => {
|
timer.current = window.setTimeout(() => {
|
||||||
const s = start.current;
|
const s = start.current;
|
||||||
clear();
|
stopTimer();
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
fired.current = true;
|
fired.current = true;
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
@@ -41,7 +56,7 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
|||||||
onLongPress(s.x, s.y);
|
onLongPress(s.x, s.y);
|
||||||
}, LONG_PRESS_MS);
|
}, LONG_PRESS_MS);
|
||||||
},
|
},
|
||||||
[coarse, clear, onLongPress],
|
[coarse, stopTimer, onLongPress],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onTouchMove = useCallback(
|
const onTouchMove = useCallback(
|
||||||
@@ -72,7 +87,16 @@ export function useLongPress(onLongPress: (x: number, y: number) => void) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
coarse,
|
coarse,
|
||||||
handlers: coarse ? { onTouchStart, onTouchMove, onTouchEnd, onTouchCancel: clear } : {},
|
handlers: coarse
|
||||||
|
? {
|
||||||
|
// [Gitea #199] Marks the element for the iOS hardening CSS.
|
||||||
|
'data-touch-actions': '',
|
||||||
|
onTouchStart,
|
||||||
|
onTouchMove,
|
||||||
|
onTouchEnd,
|
||||||
|
onTouchCancel: clear,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
|
/** True for ~1 s after a long-press fired (drop the Android contextmenu echo). */
|
||||||
suppressContextMenu: fired,
|
suppressContextMenu: fired,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ const fillMissingPowers = (powerLevels: IPowerLevels): IPowerLevels =>
|
|||||||
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
|
const keys = Object.keys(DEFAULT_POWER_LEVELS) as unknown as (keyof IPowerLevels)[];
|
||||||
keys.forEach((key) => {
|
keys.forEach((key) => {
|
||||||
if (draftPl[key] === undefined) {
|
if (draftPl[key] === undefined) {
|
||||||
draftPl[key] = DEFAULT_POWER_LEVELS[key] as any;
|
(draftPl as Record<string, unknown>)[key] = DEFAULT_POWER_LEVELS[key];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
|
if (draftPl.notifications && typeof draftPl.notifications.room !== 'number') {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
|||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { isWithinTimeWindow } from '../utils/timeWindow';
|
import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
|
import { CallSummaryDialog } from '../features/call-summary/CallSummaryDialog';
|
||||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
import { useSessionAppeared } from '../hooks/useSessionAppeared';
|
||||||
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
|
||||||
@@ -248,6 +249,7 @@ function App() {
|
|||||||
<SeasonalEffect />
|
<SeasonalEffect />
|
||||||
<NightLightOverlay />
|
<NightLightOverlay />
|
||||||
<LotusToastContainer />
|
<LotusToastContainer />
|
||||||
|
<CallSummaryDialog />
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
<ReactQueryDevtools initialIsOpen={false} />
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ import { SHARE_PAGE_PATH } from '../../swShare';
|
|||||||
import { RouteError } from './RouteError';
|
import { RouteError } from './RouteError';
|
||||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||||
|
import { FloatingWidget } from '../features/room/widgets/FloatingWidget';
|
||||||
|
|
||||||
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
|
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
|
||||||
|
|
||||||
@@ -197,6 +198,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||||||
<Outlet />
|
<Outlet />
|
||||||
</ClientLayout>
|
</ClientLayout>
|
||||||
<CallStatusRenderer />
|
<CallStatusRenderer />
|
||||||
|
<FloatingWidget />
|
||||||
</CallEmbedProvider>
|
</CallEmbedProvider>
|
||||||
<React.Suspense fallback={null}>
|
<React.Suspense fallback={null}>
|
||||||
<SearchModalRenderer />
|
<SearchModalRenderer />
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ import {
|
|||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||||
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
import { clearPlaintextCaches } from '../../state/plaintextCaches';
|
||||||
|
import { clearSoundboardClipCache } from '../../utils/soundboardClips';
|
||||||
|
import { clearSharedPayload } from '../../../swShare';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
@@ -172,6 +174,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
|||||||
// (translation, scheduled messages, recent searches/forwards/gifs/
|
// (translation, scheduled messages, recent searches/forwards/gifs/
|
||||||
// stickers, nav paths) — wipe them on server-forced logout too.
|
// stickers, nav paths) — wipe them on server-forced logout too.
|
||||||
clearPlaintextCaches(mx?.getUserId() ?? undefined);
|
clearPlaintextCaches(mx?.getUserId() ?? undefined);
|
||||||
|
// [Gitea #57] As on manual logout: no decrypted clip bytes via blob: URLs.
|
||||||
|
clearSoundboardClipCache();
|
||||||
|
// [Gitea #267] As on manual logout: drop files stashed by the share target.
|
||||||
|
await clearSharedPayload().catch(() => undefined);
|
||||||
// Remove only the session credential keys — NOT settings, drafts, and
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
// [Gitea #274] Only while catching up after a drop. Upstream also showed it
|
||||||
(stateData.current === SyncState.Prepared ||
|
// for PREPARED and for the first SYNCING after PREPARED/RECONNECTING, i.e.
|
||||||
stateData.current === SyncState.Syncing ||
|
// until the NEXT /sync returned: on a quiet account that long-polls ~30 s
|
||||||
stateData.current === SyncState.Catchup) &&
|
// while the app already works (a SYNCING means a sync just succeeded).
|
||||||
stateData.previous !== SyncState.Syncing
|
// A real failure still surfaces as Reconnecting / Connection lost.
|
||||||
) {
|
if (stateData.current === SyncState.Catchup) {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" shrink="No">
|
<Box direction="Column" shrink="No">
|
||||||
<Box
|
<Box
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ function InviteFilters({
|
|||||||
<Box gap="200">
|
<Box gap="200">
|
||||||
<Chip
|
<Chip
|
||||||
variant={isKnown ? 'Success' : 'Surface'}
|
variant={isKnown ? 'Success' : 'Surface'}
|
||||||
aria-selected={isKnown}
|
aria-pressed={isKnown}
|
||||||
outlined={!isKnown}
|
outlined={!isKnown}
|
||||||
onClick={() => onFilter(InviteFilter.Known)}
|
onClick={() => onFilter(InviteFilter.Known)}
|
||||||
before={isKnown && <Icon size="100" src={Icons.Check} />}
|
before={isKnown && <Icon size="100" src={Icons.Check} />}
|
||||||
@@ -385,7 +385,7 @@ function InviteFilters({
|
|||||||
</Chip>
|
</Chip>
|
||||||
<Chip
|
<Chip
|
||||||
variant={isUnknown ? 'Warning' : 'Surface'}
|
variant={isUnknown ? 'Warning' : 'Surface'}
|
||||||
aria-selected={isUnknown}
|
aria-pressed={isUnknown}
|
||||||
outlined={!isUnknown}
|
outlined={!isUnknown}
|
||||||
onClick={() => onFilter(InviteFilter.Unknown)}
|
onClick={() => onFilter(InviteFilter.Unknown)}
|
||||||
before={isUnknown && <Icon size="100" src={Icons.Check} />}
|
before={isUnknown && <Icon size="100" src={Icons.Check} />}
|
||||||
@@ -401,7 +401,7 @@ function InviteFilters({
|
|||||||
</Chip>
|
</Chip>
|
||||||
<Chip
|
<Chip
|
||||||
variant={isSpam ? 'Critical' : 'Surface'}
|
variant={isSpam ? 'Critical' : 'Surface'}
|
||||||
aria-selected={isSpam}
|
aria-pressed={isSpam}
|
||||||
outlined={!isSpam}
|
outlined={!isSpam}
|
||||||
onClick={() => onFilter(InviteFilter.Spam)}
|
onClick={() => onFilter(InviteFilter.Spam)}
|
||||||
before={isSpam && <Icon size="100" src={Icons.Check} />}
|
before={isSpam && <Icon size="100" src={Icons.Check} />}
|
||||||
|
|||||||
@@ -145,6 +145,11 @@ export function Share() {
|
|||||||
<Box direction="Column" gap="400">
|
<Box direction="Column" gap="400">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">Shared</Text>
|
<Text size="L400">Shared</Text>
|
||||||
|
{/* [Gitea #267] Not everything that arrives here came from the
|
||||||
|
user's share sheet; say so before they pick a room. */}
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
This came from another app or website. Check it before you send it.
|
||||||
|
</Text>
|
||||||
{shared.files.map((f) => (
|
{shared.files.map((f) => (
|
||||||
<Box key={f.name} alignItems="Center" gap="200">
|
<Box key={f.name} alignItems="Center" gap="200">
|
||||||
<Icon
|
<Icon
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ export function SettingsTab() {
|
|||||||
</SidebarItemTooltip>
|
</SidebarItemTooltip>
|
||||||
<PresencePicker />
|
<PresencePicker />
|
||||||
{settingsOpen && (
|
{settingsOpen && (
|
||||||
<Modal500 requestClose={() => setSettingsOpen(false)}>
|
<Modal500 requestClose={() => setSettingsOpen(false)} label="Settings">
|
||||||
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
|
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
|
||||||
</Modal500>
|
</Modal500>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ function UnverifiedIndicator() {
|
|||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
)}
|
)}
|
||||||
{settings && (
|
{settings && (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 requestClose={closeSettings} label="Settings">
|
||||||
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
|
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
|
||||||
</Modal500>
|
</Modal500>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -57,6 +57,15 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
|
|||||||
|
|
||||||
export const CALL_ERROR_CHANGED = 'call_error_changed';
|
export const CALL_ERROR_CHANGED = 'call_error_changed';
|
||||||
|
|
||||||
|
export const SCREENSHARE_AUDIO_STATE = 'screenshare_audio_state';
|
||||||
|
|
||||||
|
/** Pure: parse the fork's `io.lotus.screenshare_audio_state` (#277). */
|
||||||
|
export function parseScreenshareAudioState(data: unknown): boolean | undefined {
|
||||||
|
if (typeof data !== 'object' || data === null) return undefined;
|
||||||
|
const { present } = data as { present?: unknown };
|
||||||
|
return typeof present === 'boolean' ? present : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
export type AudioOutputOption = { id: string; label: string };
|
export type AudioOutputOption = { id: string; label: string };
|
||||||
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
|
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
|
||||||
|
|
||||||
@@ -281,6 +290,22 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
this.emit(AUDIO_OUTPUT_STATE);
|
this.emit(AUDIO_OUTPUT_STATE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #277] Whether a remote screenshare's audio is being received (the
|
||||||
|
// fork's `io.lotus.screenshare_audio_state`), so the call bar only offers
|
||||||
|
// "Mute Screenshare Audio" when there is something to mute.
|
||||||
|
private _remoteScreenshareAudio = false;
|
||||||
|
|
||||||
|
public get remoteScreenshareAudio(): boolean {
|
||||||
|
return this._remoteScreenshareAudio;
|
||||||
|
}
|
||||||
|
|
||||||
|
public onScreenshareAudioState(data: unknown) {
|
||||||
|
const present = parseScreenshareAudioState(data);
|
||||||
|
if (present === undefined || present === this._remoteScreenshareAudio) return;
|
||||||
|
this._remoteScreenshareAudio = present;
|
||||||
|
this.emit(SCREENSHARE_AUDIO_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
private sendAudioOutput(deviceId: string): void {
|
private sendAudioOutput(deviceId: string): void {
|
||||||
if (!this.joined) return;
|
if (!this.joined) return;
|
||||||
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
WidgetApiFromWidgetAction,
|
WidgetApiFromWidgetAction,
|
||||||
WidgetApiToWidgetAction,
|
WidgetApiToWidgetAction,
|
||||||
WidgetDriver,
|
WidgetDriver,
|
||||||
|
IWidgetApiRequest,
|
||||||
} from 'matrix-widget-api';
|
} from 'matrix-widget-api';
|
||||||
import { CallWidgetDriver } from './CallWidgetDriver';
|
import { CallWidgetDriver } from './CallWidgetDriver';
|
||||||
import { trimTrailingSlash } from '../../utils/common';
|
import { trimTrailingSlash } from '../../utils/common';
|
||||||
@@ -25,13 +26,14 @@ import {
|
|||||||
ElementMediaStateDetail,
|
ElementMediaStateDetail,
|
||||||
ElementWidgetActions,
|
ElementWidgetActions,
|
||||||
} from './types';
|
} from './types';
|
||||||
import { CallControl } from './CallControl';
|
import { CallControl, CallControlEvent } from './CallControl';
|
||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
import { getCallPageUrl } from './callPageUrl';
|
import { getCallPageUrl } from './callPageUrl';
|
||||||
import { restrictWidgetMessages } from '../widgetTransport';
|
import { restrictWidgetMessages } from '../widgetTransport';
|
||||||
import { isRingableRoom } from '../../utils/callRing';
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
|
import { parseCallSummaryDetails, type LotusCallSummaryDetails } from '../../utils/callSummary';
|
||||||
|
|
||||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
@@ -58,6 +60,8 @@ export interface LotusCallSummary {
|
|||||||
reconnects: number;
|
reconnects: number;
|
||||||
poorMs: number;
|
poorMs: number;
|
||||||
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
||||||
|
/** [Gitea #253] Breakdown for the Details card (fork lotus.29+). */
|
||||||
|
details?: LotusCallSummaryDetails;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CallEmbed {
|
export class CallEmbed {
|
||||||
@@ -77,6 +81,25 @@ export class CallEmbed {
|
|||||||
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
||||||
public lastSummary: LotusCallSummary | undefined;
|
public lastSummary: LotusCallSummary | undefined;
|
||||||
|
|
||||||
|
// [Gitea #253] Time spent deafened (sound off), for the post-call Details.
|
||||||
|
private deafenedSince: number | undefined;
|
||||||
|
|
||||||
|
private deafenedTotal = 0;
|
||||||
|
|
||||||
|
public deafenedMs(now = Date.now()): number {
|
||||||
|
return this.deafenedTotal + (this.deafenedSince === undefined ? 0 : now - this.deafenedSince);
|
||||||
|
}
|
||||||
|
|
||||||
|
private trackDeafened = (): void => {
|
||||||
|
if (!this.joined) return;
|
||||||
|
const deaf = !this.control.sound;
|
||||||
|
if (deaf && this.deafenedSince === undefined) this.deafenedSince = Date.now();
|
||||||
|
if (!deaf && this.deafenedSince !== undefined) {
|
||||||
|
this.deafenedTotal += Date.now() - this.deafenedSince;
|
||||||
|
this.deafenedSince = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
// C-M4: set once dispose() has run so the hangup fallback timer can tell
|
||||||
// whether the embed was already torn down by the normal Close/Hangup echo.
|
// whether the embed was already torn down by the normal Close/Hangup echo.
|
||||||
public disposed = false;
|
public disposed = false;
|
||||||
@@ -338,6 +361,8 @@ export class CallEmbed {
|
|||||||
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
const controlState = initialControlState ?? new CallControlState(true, false, true);
|
||||||
this.control = new CallControl(controlState, call, iframe);
|
this.control = new CallControl(controlState, call, iframe);
|
||||||
this.initialState = controlState;
|
this.initialState = controlState;
|
||||||
|
this.control.on(CallControlEvent.StateUpdate, this.trackDeafened);
|
||||||
|
this.disposables.push(() => this.control.off(CallControlEvent.StateUpdate, this.trackDeafened));
|
||||||
// If the iframe document itself fails to load, fail fast.
|
// If the iframe document itself fails to load, fail fast.
|
||||||
iframe.onerror = () => {
|
iframe.onerror = () => {
|
||||||
this.settleLoad('iframe');
|
this.settleLoad('iframe');
|
||||||
@@ -433,6 +458,7 @@ export class CallEmbed {
|
|||||||
reconnects: data.reconnects ?? 0,
|
reconnects: data.reconnects ?? 0,
|
||||||
poorMs: data.poorMs ?? 0,
|
poorMs: data.poorMs ?? 0,
|
||||||
verdict: data.verdict ?? 'unknown',
|
verdict: data.verdict ?? 'unknown',
|
||||||
|
details: parseCallSummaryDetails((data as { details?: unknown }).details),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -465,6 +491,12 @@ export class CallEmbed {
|
|||||||
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
|
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// [Gitea #277] Remote screenshare audio being received, or not.
|
||||||
|
this.disposables.push(
|
||||||
|
this.listenAction('io.lotus.screenshare_audio_state', (evt) => {
|
||||||
|
this.control.onScreenshareAudioState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
this.listenAction('io.lotus.hotkey', (evt) => {
|
this.listenAction('io.lotus.hotkey', (evt) => {
|
||||||
@@ -616,6 +648,7 @@ export class CallEmbed {
|
|||||||
}
|
}
|
||||||
this.joined = true;
|
this.joined = true;
|
||||||
this.joinedAt = Date.now();
|
this.joinedAt = Date.now();
|
||||||
|
this.trackDeafened();
|
||||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||||
this.control.forceState(this.initialState);
|
this.control.forceState(this.initialState);
|
||||||
}
|
}
|
||||||
@@ -790,7 +823,7 @@ export class CallEmbed {
|
|||||||
const wrapped = (ev: CustomEvent<T>) => {
|
const wrapped = (ev: CustomEvent<T>) => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
// Reply with success so the widget transport doesn't time out waiting for a response
|
// Reply with success so the widget transport doesn't time out waiting for a response
|
||||||
this.call.transport.reply(ev.detail as any, {});
|
this.call.transport.reply(ev.detail as unknown as IWidgetApiRequest, {});
|
||||||
callback(ev);
|
callback(ev);
|
||||||
};
|
};
|
||||||
return this.listenEvent(`action:${type}`, wrapped);
|
return this.listenEvent(`action:${type}`, wrapped);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseScreenshareAudioState } from './CallControl';
|
||||||
|
|
||||||
|
test('parseScreenshareAudioState: reads present, ignores anything else (#277)', () => {
|
||||||
|
assert.equal(parseScreenshareAudioState({ present: true }), true);
|
||||||
|
assert.equal(parseScreenshareAudioState({ present: false }), false);
|
||||||
|
assert.equal(parseScreenshareAudioState({ present: 'yes' }), undefined);
|
||||||
|
assert.equal(parseScreenshareAudioState({}), undefined);
|
||||||
|
assert.equal(parseScreenshareAudioState(null), undefined);
|
||||||
|
assert.equal(parseScreenshareAudioState('present'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { atom } from 'jotai';
|
||||||
|
import type { CallSummarySection } from '../utils/callSummary';
|
||||||
|
|
||||||
|
/** [Gitea #253] The post-call Details card currently open (undefined = closed). */
|
||||||
|
export type CallSummaryView = { roomName: string; sections: CallSummarySection[] };
|
||||||
|
export const callSummaryViewAtom = atom<CallSummaryView | undefined>(undefined);
|
||||||
@@ -26,7 +26,10 @@ const api = {
|
|||||||
: Object.getOwnPropertyDescriptor(target, key),
|
: Object.getOwnPropertyDescriptor(target, key),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
const { clearPlaintextCaches, plaintextCachesWiped, WIDGET_CONSENT_KEY_PREFIX } =
|
||||||
|
await import('./plaintextCaches');
|
||||||
|
const { WIDGET_CONSENT_PREFIX, widgetConsentKey } =
|
||||||
|
await import('../features/room/widgets/widgetUtils');
|
||||||
|
|
||||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||||
store.clear();
|
store.clear();
|
||||||
@@ -104,3 +107,17 @@ test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe
|
|||||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#266] clearPlaintextCaches sweeps remembered widget approvals', () => {
|
||||||
|
assert.equal(WIDGET_CONSENT_KEY_PREFIX, WIDGET_CONSENT_PREFIX, 'prefix kept in sync');
|
||||||
|
store.clear();
|
||||||
|
const consent = widgetConsentKey('!room:server', 'widget-1');
|
||||||
|
store.set(consent, '{"url":"https://pad.example","allowed":[],"denied":[]}');
|
||||||
|
store.set('lotus.widgetConsentNotAKey', 'kept');
|
||||||
|
removed.length = 0;
|
||||||
|
|
||||||
|
clearPlaintextCaches('@me:server');
|
||||||
|
|
||||||
|
assert.ok(removed.includes(consent), 'consent cleared');
|
||||||
|
assert.ok(store.has('lotus.widgetConsentNotAKey'), 'unrelated key kept');
|
||||||
|
});
|
||||||
|
|||||||
@@ -139,6 +139,31 @@ const clearAccountScopedFlags = (userId?: string): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #266] Remembered widget approvals (`lotus.widgetConsent.<room>.<widget>`,
|
||||||
|
* widgetUtils.ts) would otherwise apply silently to the next account on this
|
||||||
|
* device, and their keys list the previous account's rooms. Kept as a string
|
||||||
|
* here so logout doesn't pull the widget code into the entry chunk; a test
|
||||||
|
* checks it matches WIDGET_CONSENT_PREFIX.
|
||||||
|
*/
|
||||||
|
export const WIDGET_CONSENT_KEY_PREFIX = 'lotus.widgetConsent.';
|
||||||
|
const clearWidgetConsents = (): void => {
|
||||||
|
let keys: string[];
|
||||||
|
try {
|
||||||
|
keys = Object.keys(localStorage);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
keys.forEach((key) => {
|
||||||
|
if (!key.startsWith(WIDGET_CONSENT_KEY_PREFIX)) return;
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const clearPlaintextCaches = (userId?: string): void => {
|
export const clearPlaintextCaches = (userId?: string): void => {
|
||||||
wiped = true;
|
wiped = true;
|
||||||
clearTranslationCache();
|
clearTranslationCache();
|
||||||
@@ -153,5 +178,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearStatusMessage();
|
clearStatusMessage();
|
||||||
clearCallVolumesAndSyncMeta(userId);
|
clearCallVolumesAndSyncMeta(userId);
|
||||||
clearAccountScopedFlags(userId);
|
clearAccountScopedFlags(userId);
|
||||||
|
clearWidgetConsents();
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { atom, useSetAtom } from 'jotai';
|
|||||||
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
import { setRoomAccountData } from '../../utils/accountData';
|
||||||
|
|
||||||
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
|
// MSC2867 — "mark a room as unread". A per-room account-data flag `{ unread }`.
|
||||||
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
|
// Stable type `m.marked_unread`; servers/clients predating the stabilization use
|
||||||
@@ -25,11 +26,9 @@ export const setMarkedUnread = (
|
|||||||
unread: boolean,
|
unread: boolean,
|
||||||
): Promise<unknown> =>
|
): Promise<unknown> =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
setRoomAccountData(mx, roomId, AccountDataEvent.MarkedUnread, { unread }),
|
||||||
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
|
|
||||||
// Best-effort mirror for older servers; never fail the primary write on it.
|
// Best-effort mirror for older servers; never fail the primary write on it.
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
setRoomAccountData(mx, roomId, UNSTABLE_MARKED_UNREAD, { unread }).catch(() => undefined),
|
||||||
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
|
||||||
|
|||||||
@@ -310,6 +310,8 @@ export interface Settings {
|
|||||||
screenshareFramerate: ScreenshareFramerate;
|
screenshareFramerate: ScreenshareFramerate;
|
||||||
// [P5-15] In-call soundboard
|
// [P5-15] In-call soundboard
|
||||||
soundboardEnabled: boolean;
|
soundboardEnabled: boolean;
|
||||||
|
// [Gitea #253] The call-ended toast (with its Details card).
|
||||||
|
callSummaryToast: boolean;
|
||||||
soundboardVolume: number; // 0–100
|
soundboardVolume: number; // 0–100
|
||||||
|
|
||||||
seasonalThemeOverride: SeasonalThemeOverride;
|
seasonalThemeOverride: SeasonalThemeOverride;
|
||||||
@@ -429,6 +431,7 @@ const defaultSettings: Settings = {
|
|||||||
screenshareBitrate: 'auto',
|
screenshareBitrate: 'auto',
|
||||||
screenshareFramerate: 'auto',
|
screenshareFramerate: 'auto',
|
||||||
soundboardEnabled: true,
|
soundboardEnabled: true,
|
||||||
|
callSummaryToast: true,
|
||||||
soundboardVolume: 80,
|
soundboardVolume: 80,
|
||||||
|
|
||||||
seasonalThemeOverride: 'auto',
|
seasonalThemeOverride: 'auto',
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ export type ToastNotif = {
|
|||||||
onClick?: () => void; // custom click handler; skips hash navigation when set
|
onClick?: () => void; // custom click handler; skips hash navigation when set
|
||||||
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
|
||||||
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
onDismiss?: () => void; // called when the user closes the toast with its X (not on click/eviction)
|
||||||
|
actionLabel?: string; // [Gitea #253] a small button on the toast, e.g. "Details"
|
||||||
|
onAction?: () => void;
|
||||||
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
createdAt?: number; // set by toastQueueAtom when enqueued; used for #80 eviction ordering
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,7 @@ import { atom } from 'jotai';
|
|||||||
|
|
||||||
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
|
||||||
export const widgetsPanelAtom = atom<boolean>(false);
|
export const widgetsPanelAtom = atom<boolean>(false);
|
||||||
|
|
||||||
|
// [Gitea #205] Which widget the panel is showing, per room, so a floating
|
||||||
|
// widget's "Back to room" can open the panel on it.
|
||||||
|
export const widgetViewingAtom = atom<Record<string, string | null>>({});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
* z-index values and is intentionally not listed here.
|
* z-index values and is intentionally not listed here.
|
||||||
*/
|
*/
|
||||||
export const zIndices = {
|
export const zIndices = {
|
||||||
|
/** [Gitea #205] A room widget kept on screen after leaving its room. */
|
||||||
|
floatingWidget: 9980,
|
||||||
/** In-call incoming-call banner — below seasonal/night-light/modals. */
|
/** In-call incoming-call banner — below seasonal/night-light/modals. */
|
||||||
inCallBanner: 9990,
|
inCallBanner: 9990,
|
||||||
/** Seasonal particle effect — below the night-light tint so particles tint. */
|
/** Seasonal particle effect — below the night-light tint so particles tint. */
|
||||||
|
|||||||
@@ -18,6 +18,17 @@ export function getAccountData<T>(
|
|||||||
return event?.getContent() as T | undefined;
|
return event?.getContent() as T | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Room account data under a custom or developer-chosen type (same cast). */
|
||||||
|
export function setRoomAccountData<T>(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
eventType: string,
|
||||||
|
content: T,
|
||||||
|
): Promise<unknown> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return (mx as any).setRoomAccountData(roomId, eventType, content);
|
||||||
|
}
|
||||||
|
|
||||||
export function setAccountData<T>(
|
export function setAccountData<T>(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
eventType: AccountDataEvent | string,
|
eventType: AccountDataEvent | string,
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { describeCallDuration, describeCallEnd } from './callSummary';
|
import {
|
||||||
|
describeCallDuration,
|
||||||
|
describeCallEnd,
|
||||||
|
describeCallDetails,
|
||||||
|
parseCallSummaryDetails,
|
||||||
|
} from './callSummary';
|
||||||
|
|
||||||
describe('describeCallDuration', () => {
|
describe('describeCallDuration', () => {
|
||||||
it('picks the unit', () => {
|
it('picks the unit', () => {
|
||||||
@@ -65,3 +70,97 @@ describe('describeCallEnd', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('[#253] parseCallSummaryDetails keeps well-formed fields and drops the rest', () => {
|
||||||
|
const d = parseCallSummaryDetails({
|
||||||
|
participants: ['@bob:x', 7, '@carol:x'],
|
||||||
|
peakParticipants: 3,
|
||||||
|
quality: {
|
||||||
|
youPoorMs: 1000,
|
||||||
|
othersPoorMs: { '@bob:x': 500, evil: 9, '@c:x': -1 },
|
||||||
|
everyonePoorMs: 0,
|
||||||
|
},
|
||||||
|
network: { rttMedianMs: 38, rttP95Ms: 'fast', inboundLossPct: 0.5 },
|
||||||
|
activity: { speakingMs: 60_000, mutedMs: 'x', screenshareMs: 0 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(d, {
|
||||||
|
participants: ['@bob:x', '@carol:x'],
|
||||||
|
peakParticipants: 3,
|
||||||
|
quality: { youPoorMs: 1000, othersPoorMs: { '@bob:x': 500 }, everyonePoorMs: 0 },
|
||||||
|
network: { rttMedianMs: 38, inboundLossPct: 0.5 },
|
||||||
|
activity: { speakingMs: 60_000, mutedMs: 0, screenshareMs: 0 },
|
||||||
|
});
|
||||||
|
assert.equal(parseCallSummaryDetails(undefined), undefined);
|
||||||
|
assert.equal(parseCallSummaryDetails('details'), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[#253] describeCallDetails: technical sections with names, blame and numbers', () => {
|
||||||
|
const names: Record<string, string> = { '@bob:x': 'Bob', '@carol:x': 'Carol' };
|
||||||
|
const sections = describeCallDetails(
|
||||||
|
{
|
||||||
|
durationMs: 2_460_000,
|
||||||
|
reconnects: 2,
|
||||||
|
poorMs: 120_000,
|
||||||
|
verdict: 'fair',
|
||||||
|
details: {
|
||||||
|
participants: ['@bob:x', '@carol:x'],
|
||||||
|
peakParticipants: 3,
|
||||||
|
quality: {
|
||||||
|
youPoorMs: 120_000,
|
||||||
|
othersPoorMs: { '@carol:x': 30_000 },
|
||||||
|
everyonePoorMs: 10_000,
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
rttMedianMs: 38,
|
||||||
|
rttP95Ms: 310,
|
||||||
|
outboundLossPct: 4.1,
|
||||||
|
inboundLossPct: 0.5,
|
||||||
|
jitterMs: 6,
|
||||||
|
},
|
||||||
|
activity: { speakingMs: 600_000, mutedMs: 1_200_000, screenshareMs: 300_000 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
2_460_000,
|
||||||
|
90_000,
|
||||||
|
(u) => names[u] ?? u,
|
||||||
|
);
|
||||||
|
assert.deepEqual(sections, [
|
||||||
|
{ title: 'Call', lines: ['Duration 41 min · peak 3 in the call', 'With Bob, Carol'] },
|
||||||
|
{
|
||||||
|
title: 'Connection',
|
||||||
|
lines: [
|
||||||
|
'Verdict: fair · 2 reconnects',
|
||||||
|
'Your connection poor/lost: 2 min',
|
||||||
|
'Carol poor/lost: 30 s',
|
||||||
|
'Everyone poor at once: 10 s (likely server or SFU side)',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Network',
|
||||||
|
lines: [
|
||||||
|
'RTT median 38 ms · p95 310 ms',
|
||||||
|
'Outbound packet loss 4.1%',
|
||||||
|
'Inbound packet loss 0.5%',
|
||||||
|
'Jitter 6 ms',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'You',
|
||||||
|
lines: ['Talking 10 min · muted 20 min · deafened 2 min', 'Screensharing 5 min'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('[#253] describeCallDetails without fork details (older fork) still says something useful', () => {
|
||||||
|
const sections = describeCallDetails(
|
||||||
|
{ durationMs: 60_000, reconnects: 0, poorMs: 0, verdict: 'good' },
|
||||||
|
60_000,
|
||||||
|
0,
|
||||||
|
(u) => u,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
sections.map((s) => s.title),
|
||||||
|
['Call', 'Connection'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(sections[0].lines, ['Duration 1 min', 'Participants unknown']);
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,3 +32,123 @@ export function describeCallEnd(durationMs: number, summary?: LotusCallSummary):
|
|||||||
}
|
}
|
||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [Gitea #253] The fork's `details` in io.lotus.call_summary (lotus.29+). */
|
||||||
|
export type LotusCallSummaryDetails = {
|
||||||
|
participants: string[];
|
||||||
|
peakParticipants: number;
|
||||||
|
quality: { youPoorMs: number; othersPoorMs: Record<string, number>; everyonePoorMs: number };
|
||||||
|
network: {
|
||||||
|
rttMedianMs?: number;
|
||||||
|
rttP95Ms?: number;
|
||||||
|
outboundLossPct?: number;
|
||||||
|
inboundLossPct?: number;
|
||||||
|
jitterMs?: number;
|
||||||
|
};
|
||||||
|
activity: { speakingMs: number; mutedMs: number; screenshareMs: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
const num = (v: unknown): number | undefined =>
|
||||||
|
typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : undefined;
|
||||||
|
const obj = (v: unknown): Record<string, unknown> =>
|
||||||
|
typeof v === 'object' && v !== null && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
|
||||||
|
|
||||||
|
/** Defensive: the frame is another origin; take only well-formed fields. */
|
||||||
|
export function parseCallSummaryDetails(raw: unknown): LotusCallSummaryDetails | undefined {
|
||||||
|
if (typeof raw !== 'object' || raw === null) return undefined;
|
||||||
|
const r = obj(raw);
|
||||||
|
const quality = obj(r.quality);
|
||||||
|
const network = obj(r.network);
|
||||||
|
const activity = obj(r.activity);
|
||||||
|
const othersPoorMs: Record<string, number> = {};
|
||||||
|
Object.entries(obj(quality.othersPoorMs)).forEach(([user, ms]) => {
|
||||||
|
const n = num(ms);
|
||||||
|
if (n !== undefined && user.startsWith('@')) othersPoorMs[user] = n;
|
||||||
|
});
|
||||||
|
const net: LotusCallSummaryDetails['network'] = {};
|
||||||
|
(['rttMedianMs', 'rttP95Ms', 'outboundLossPct', 'inboundLossPct', 'jitterMs'] as const).forEach(
|
||||||
|
(k) => {
|
||||||
|
const n = num(network[k]);
|
||||||
|
if (n !== undefined) net[k] = n;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
participants: Array.isArray(r.participants)
|
||||||
|
? r.participants.filter((p): p is string => typeof p === 'string').slice(0, 200)
|
||||||
|
: [],
|
||||||
|
peakParticipants: num(r.peakParticipants) ?? 1,
|
||||||
|
quality: {
|
||||||
|
youPoorMs: num(quality.youPoorMs) ?? 0,
|
||||||
|
othersPoorMs,
|
||||||
|
everyonePoorMs: num(quality.everyonePoorMs) ?? 0,
|
||||||
|
},
|
||||||
|
network: net,
|
||||||
|
activity: {
|
||||||
|
speakingMs: num(activity.speakingMs) ?? 0,
|
||||||
|
mutedMs: num(activity.mutedMs) ?? 0,
|
||||||
|
screenshareMs: num(activity.screenshareMs) ?? 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CallSummarySection = { title: string; lines: string[] };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #253] The Details card, in technical wording (the owner's choice):
|
||||||
|
* who was there, connection quality and whose side it was on, network
|
||||||
|
* numbers, and your own activity. `nameOf` turns a user id into a display name.
|
||||||
|
*/
|
||||||
|
export function describeCallDetails(
|
||||||
|
summary: LotusCallSummary | undefined,
|
||||||
|
durationMs: number,
|
||||||
|
deafenedMs: number,
|
||||||
|
nameOf: (userId: string) => string,
|
||||||
|
): CallSummarySection[] {
|
||||||
|
const sections: CallSummarySection[] = [];
|
||||||
|
const d = summary?.details;
|
||||||
|
const dur = describeCallDuration;
|
||||||
|
const people = d?.participants ?? [];
|
||||||
|
sections.push({
|
||||||
|
title: 'Call',
|
||||||
|
lines: [
|
||||||
|
`Duration ${dur(durationMs)}${d ? ` · peak ${d.peakParticipants} in the call` : ''}`,
|
||||||
|
people.length
|
||||||
|
? `With ${people.map(nameOf).join(', ')}`
|
||||||
|
: d
|
||||||
|
? 'Nobody else joined'
|
||||||
|
: 'Participants unknown',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
if (summary) {
|
||||||
|
const quality: string[] = [
|
||||||
|
`Verdict: ${summary.verdict} · ${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`,
|
||||||
|
];
|
||||||
|
if (d) {
|
||||||
|
quality.push(`Your connection poor/lost: ${dur(d.quality.youPoorMs)}`);
|
||||||
|
Object.entries(d.quality.othersPoorMs)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.forEach(([user, ms]) => quality.push(`${nameOf(user)} poor/lost: ${dur(ms)}`));
|
||||||
|
if (d.quality.everyonePoorMs > 0)
|
||||||
|
quality.push(
|
||||||
|
`Everyone poor at once: ${dur(d.quality.everyonePoorMs)} (likely server or SFU side)`,
|
||||||
|
);
|
||||||
|
} else quality.push(`Your connection poor/lost: ${dur(summary.poorMs)}`);
|
||||||
|
sections.push({ title: 'Connection', lines: quality });
|
||||||
|
}
|
||||||
|
if (d) {
|
||||||
|
const n = d.network;
|
||||||
|
const net: string[] = [];
|
||||||
|
if (n.rttMedianMs !== undefined)
|
||||||
|
net.push(`RTT median ${n.rttMedianMs} ms · p95 ${n.rttP95Ms ?? n.rttMedianMs} ms`);
|
||||||
|
if (n.outboundLossPct !== undefined) net.push(`Outbound packet loss ${n.outboundLossPct}%`);
|
||||||
|
if (n.inboundLossPct !== undefined) net.push(`Inbound packet loss ${n.inboundLossPct}%`);
|
||||||
|
if (n.jitterMs !== undefined) net.push(`Jitter ${n.jitterMs} ms`);
|
||||||
|
if (net.length) sections.push({ title: 'Network', lines: net });
|
||||||
|
const act = [
|
||||||
|
`Talking ${dur(d.activity.speakingMs)} · muted ${dur(d.activity.mutedMs)} · deafened ${dur(deafenedMs)}`,
|
||||||
|
];
|
||||||
|
if (d.activity.screenshareMs > 0) act.push(`Screensharing ${dur(d.activity.screenshareMs)}`);
|
||||||
|
sections.push({ title: 'You', lines: act });
|
||||||
|
}
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[]
|
|||||||
export function sendStateEvent<T extends object>(
|
export function sendStateEvent<T extends object>(
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
roomId: string,
|
roomId: string,
|
||||||
eventType: StateEvent,
|
eventType: StateEvent | string,
|
||||||
content: T,
|
content: T,
|
||||||
stateKey = '',
|
stateKey = '',
|
||||||
): Promise<ISendEventResponse> {
|
): Promise<ISendEventResponse> {
|
||||||
|
|||||||
@@ -179,3 +179,15 @@ test('groupScheduledByRoom: groups and sorts by sendAt', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(g.get('!2')?.length, 1);
|
assert.equal(g.get('!2')?.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#275] the server limit maps to friendly text; other errors keep theirs', async () => {
|
||||||
|
const { describeScheduleError, SCHEDULE_TOO_FAR_TEXT, MAX_SCHEDULE_AHEAD_MS } =
|
||||||
|
await import('./scheduledMessages');
|
||||||
|
assert.equal(MAX_SCHEDULE_AHEAD_MS, 86_400_000);
|
||||||
|
const tooLarge = Object.assign(new Error('The requested delay (172800000ms) exceeds…'), {
|
||||||
|
errcode: 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE',
|
||||||
|
});
|
||||||
|
assert.equal(describeScheduleError(tooLarge), SCHEDULE_TOO_FAR_TEXT);
|
||||||
|
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
|
||||||
|
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,22 @@
|
|||||||
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||||
import type { ScheduledMessage } from '../state/scheduledMessages';
|
import type { ScheduledMessage } from '../state/scheduledMessages';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #275] Production Synapse caps delayed events at
|
||||||
|
* `max_event_delay_duration: 24h`; a later time is rejected with
|
||||||
|
* ORG.MATRIX.MSC4140_DELAY_TOO_LARGE. The dialog enforces the same limit and
|
||||||
|
* the server's error maps to the same text, in case the limit changes.
|
||||||
|
*/
|
||||||
|
export const MAX_SCHEDULE_AHEAD_MS = 24 * 60 * 60 * 1000;
|
||||||
|
export const SCHEDULE_TOO_FAR_TEXT = 'You can schedule up to 24 hours ahead.';
|
||||||
|
|
||||||
|
/** A user-facing sentence for a failed schedule. */
|
||||||
|
export const describeScheduleError = (err: unknown): string => {
|
||||||
|
const errcode = (err as { errcode?: unknown } | null)?.errcode;
|
||||||
|
if (errcode === 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE') return SCHEDULE_TOO_FAR_TEXT;
|
||||||
|
return err instanceof Error ? err.message : 'Failed to schedule message.';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a message via MSC4140 (delayed messages).
|
* Schedule a message via MSC4140 (delayed messages).
|
||||||
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
|
import {
|
||||||
|
isValidServerPattern,
|
||||||
|
globToRegExp,
|
||||||
|
matchesAnyGlob,
|
||||||
|
normalizeServerAcl,
|
||||||
|
} from './serverAcl';
|
||||||
|
|
||||||
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
||||||
for (const v of [
|
for (const v of [
|
||||||
@@ -42,3 +47,35 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
|
|||||||
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
|
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
|
||||||
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
|
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
|
||||||
|
assert.deepEqual(normalizeServerAcl(undefined), {
|
||||||
|
allow: ['*'],
|
||||||
|
deny: [],
|
||||||
|
allow_ip_literals: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
|
||||||
|
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an explicit allow_ip_literals is kept', () => {
|
||||||
|
assert.equal(
|
||||||
|
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing or malformed lists become empty, non-strings are dropped', () => {
|
||||||
|
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
|
||||||
|
allow: ['a.example'],
|
||||||
|
deny: [],
|
||||||
|
allow_ip_literals: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -50,3 +50,29 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `m.room.server_acl` content with the spec's defaults filled in (#261).
|
||||||
|
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
|
||||||
|
* default to empty lists, but with no ACL event at all every server is allowed,
|
||||||
|
* so the editor starts from `allow: ['*']` in that case.
|
||||||
|
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
|
||||||
|
*/
|
||||||
|
export type ServerAclContent = {
|
||||||
|
allow: string[];
|
||||||
|
deny: string[];
|
||||||
|
allow_ip_literals: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringList = (value: unknown): string[] | undefined =>
|
||||||
|
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
|
||||||
|
|
||||||
|
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
|
||||||
|
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
|
||||||
|
return {
|
||||||
|
allow: stringList(content.allow) ?? [],
|
||||||
|
deny: stringList(content.deny) ?? [],
|
||||||
|
allow_ip_literals:
|
||||||
|
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { buildTopicContent } from './topicMarkdown';
|
||||||
|
|
||||||
|
test('a URL with underscores is sent verbatim (#262)', () => {
|
||||||
|
const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq';
|
||||||
|
assert.deepEqual(buildTopicContent(t), { topic: t });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a markdown link keeps its underscored URL in both fields (#262)', () => {
|
||||||
|
const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/);
|
||||||
|
assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('room aliases and user IDs with underscores are untouched (#262)', () => {
|
||||||
|
for (const t of [
|
||||||
|
'Join #my_room_name:matrix.org',
|
||||||
|
'Ask @some_user_x:matrix.org',
|
||||||
|
'see snake_case_word',
|
||||||
|
]) {
|
||||||
|
assert.deepEqual(buildTopicContent(t), { topic: t });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emphasis still works at word boundaries', () => {
|
||||||
|
const c = buildTopicContent('this is _really_ *great* and **bold**');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.match(c.formatted_body, /<i data-md="_">really<\/i>/);
|
||||||
|
assert.match(c.formatted_body, /<i data-md="\*">great<\/i>/);
|
||||||
|
assert.match(c.formatted_body, /<strong data-md="\*\*">bold<\/strong>/);
|
||||||
|
assert.equal(c.topic, 'this is really great and bold');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('emphasis around an alias keeps the alias intact', () => {
|
||||||
|
const c = buildTopicContent('**Join #my_room:matrix.org**');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.match(c.formatted_body, /<strong data-md="\*\*">Join #my_room:matrix\.org<\/strong>/);
|
||||||
|
assert.equal(c.topic, 'Join #my_room:matrix.org');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inline code keeps its underscores and no backslashes appear', () => {
|
||||||
|
const c = buildTopicContent('run `a_b_c` now');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.match(c.formatted_body, /<code data-md="`">a_b_c<\/code>/);
|
||||||
|
assert.equal(c.topic, 'run a_b_c now');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a double quote in a link target cannot break out of the attribute (#262)', () => {
|
||||||
|
const c = buildTopicContent('[x](https://a" onclick="alert(1))');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.doesNotMatch(c.formatted_body, /" onclick="/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('HTML in the topic is escaped, and the plain topic shows it as typed', () => {
|
||||||
|
const c = buildTopicContent('**<b>hi</b> & bye**');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.match(c.formatted_body, /<b>hi<\/b> & bye/);
|
||||||
|
assert.equal(c.topic, '<b>hi</b> & bye');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('line breaks become <br> and survive in the plain topic', () => {
|
||||||
|
const c = buildTopicContent('**Rules**\nBe nice');
|
||||||
|
assert.ok('formatted_body' in c);
|
||||||
|
assert.equal(c.formatted_body, '<strong data-md="**">Rules</strong><br>Be nice');
|
||||||
|
assert.equal(c.topic, 'Rules\nBe nice');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('plain text with no markdown is sent as typed', () => {
|
||||||
|
assert.deepEqual(buildTopicContent("It's <fun> & games"), { topic: "It's <fun> & games" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { parseInlineMD } from '../plugins/markdown';
|
||||||
|
import { sanitizeText } from './sanitize';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Room topic markdown → `m.room.topic` content (#262).
|
||||||
|
*
|
||||||
|
* Uses the composer's inline markdown parser (the same rules as messages: links,
|
||||||
|
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
|
||||||
|
* two letters/digits is literal, as in CommonMark. Topics are full of
|
||||||
|
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
|
||||||
|
* shared parser would otherwise turn into italics and, worse, drop from the
|
||||||
|
* plain `topic` that every other client shows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Spans the guard must not touch: inline code, link targets, bare URLs.
|
||||||
|
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
|
||||||
|
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
|
||||||
|
|
||||||
|
const escapeIntrawordUnderscores = (line: string): string =>
|
||||||
|
line
|
||||||
|
.split(PROTECTED)
|
||||||
|
.map((part, i) =>
|
||||||
|
// split() with one capture group puts the protected spans at odd indexes.
|
||||||
|
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
|
||||||
|
)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const ENTITIES: Record<string, string> = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
''': "'",
|
||||||
|
};
|
||||||
|
const decodeEntities = (s: string): string =>
|
||||||
|
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
|
||||||
|
|
||||||
|
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
|
||||||
|
const htmlToPlain = (html: string): string =>
|
||||||
|
decodeEntities(
|
||||||
|
html
|
||||||
|
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
|
||||||
|
const text = label.replace(/<[^>]+>/g, '');
|
||||||
|
return text === href ? href : `${text} (${href})`;
|
||||||
|
})
|
||||||
|
.replace(/<br>/g, '\n')
|
||||||
|
.replace(/<[^>]+>/g, ''),
|
||||||
|
);
|
||||||
|
|
||||||
|
export type TopicContent =
|
||||||
|
| { topic: string }
|
||||||
|
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
|
||||||
|
|
||||||
|
export function buildTopicContent(text: string): TopicContent {
|
||||||
|
const lines = text.split('\n');
|
||||||
|
const html = lines
|
||||||
|
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
|
||||||
|
.join('<br>');
|
||||||
|
const plainHtml = lines.map(sanitizeText).join('<br>');
|
||||||
|
// No markdown at all: send the text as typed, without a formatted body.
|
||||||
|
if (html === plainHtml) return { topic: text };
|
||||||
|
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
|
||||||
|
}
|
||||||
@@ -109,3 +109,10 @@ describe('stripTrackingParamsInHtml', () => {
|
|||||||
assert.equal(stripTrackingParamsInHtml(html), html);
|
assert.equal(stripTrackingParamsInHtml(html), html);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('[#280] Reddit keeps context= (it shows the parent comments) but drops share_id', () => {
|
||||||
|
assert.equal(
|
||||||
|
stripTrackingParams('https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3&share_id=1'),
|
||||||
|
'https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -103,7 +103,9 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
hosts: ['reddit.com'],
|
hosts: ['reddit.com'],
|
||||||
params: ['share_id', 'rdt', 'context'],
|
// [Gitea #280] Not `context`: on a comment permalink, ?context=N shows the
|
||||||
|
// N parent comments, i.e. the conversation the sender meant to share.
|
||||||
|
params: ['share_id', 'rdt'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hosts: ['tiktok.com'],
|
hosts: ['tiktok.com'],
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
spotifyEmbedHeight,
|
spotifyEmbedHeight,
|
||||||
parseMediaEmbed,
|
parseMediaEmbed,
|
||||||
isTwitterNoResults,
|
isTwitterNoResults,
|
||||||
|
getVimeoEventParts,
|
||||||
} from './videoEmbed';
|
} from './videoEmbed';
|
||||||
|
|
||||||
const HOST = 'chat.lotusguild.org';
|
const HOST = 'chat.lotusguild.org';
|
||||||
@@ -429,3 +430,34 @@ test('isTwitterNoResults: ignores normal embed traffic and junk', () => {
|
|||||||
assert.equal(isTwitterNoResults(null), false);
|
assert.equal(isTwitterNoResults(null), false);
|
||||||
assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false);
|
assert.equal(isTwitterNoResults({ type: 'MEASURE' }), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#200] Vimeo live events embed from vimeo.com/event/{id}/embed', () => {
|
||||||
|
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567'), {
|
||||||
|
id: '4321567',
|
||||||
|
hash: undefined,
|
||||||
|
});
|
||||||
|
assert.deepEqual(getVimeoEventParts('https://vimeo.com/event/4321567/a1b2c3d4e5'), {
|
||||||
|
id: '4321567',
|
||||||
|
hash: 'a1b2c3d4e5',
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
getVimeoEventParts('https://vimeo.com/event/4321567/embed/a1b2c3d4e5')?.hash,
|
||||||
|
'a1b2c3d4e5',
|
||||||
|
);
|
||||||
|
assert.equal(getVimeoEventParts('https://vimeo.com/event/4321567/chat'), null);
|
||||||
|
assert.equal(getVimeoEventParts('https://vimeo.com/123456789'), null);
|
||||||
|
assert.equal(getVimeoEventParts('https://evil.example/event/4321567'), null);
|
||||||
|
assert.equal(
|
||||||
|
parseMediaEmbed('https://vimeo.com/event/4321567', 'h')?.embedUrl,
|
||||||
|
'https://vimeo.com/event/4321567/embed?autoplay=1&dnt=1',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
parseMediaEmbed('https://vimeo.com/event/4321567/a1b2c3d4e5', 'h')?.embedUrl,
|
||||||
|
'https://vimeo.com/event/4321567/embed/a1b2c3d4e5?autoplay=1&dnt=1',
|
||||||
|
);
|
||||||
|
// An ordinary video still goes to the player.
|
||||||
|
assert.match(
|
||||||
|
parseMediaEmbed('https://vimeo.com/123456789', 'h')?.embedUrl ?? '',
|
||||||
|
/^https:\/\/player\.vimeo\.com\/video\/123456789/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -80,6 +80,25 @@ export function getVimeoParts(url: string): { id: string; hash?: string } | null
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #200] Vimeo live events: vimeo.com/event/{id}, or /event/{id}/{hash}
|
||||||
|
* when unlisted. Embedded from vimeo.com itself (not player.vimeo.com); the
|
||||||
|
* web and desktop CSP `frame-src https:` already allow it.
|
||||||
|
*/
|
||||||
|
export function getVimeoEventParts(url: string): { id: string; hash?: string } | null {
|
||||||
|
try {
|
||||||
|
const { hostname, pathname } = new URL(url);
|
||||||
|
if (hostname !== 'vimeo.com' && hostname !== 'www.vimeo.com') return null;
|
||||||
|
const m = pathname.match(/^\/event\/(\d+)(?:\/(?:embed\/)?([0-9a-f]{6,}))?\/?$/);
|
||||||
|
return m ? { id: m[1], hash: m[2] } : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildVimeoEventEmbedUrl = (id: string, hash?: string): string =>
|
||||||
|
`https://vimeo.com/event/${enc(id)}/embed${hash ? `/${enc(hash)}` : ''}?autoplay=1&dnt=1`;
|
||||||
|
|
||||||
export function getVimeoVideoId(url: string): string | null {
|
export function getVimeoVideoId(url: string): string | null {
|
||||||
return getVimeoParts(url)?.id ?? null;
|
return getVimeoParts(url)?.id ?? null;
|
||||||
}
|
}
|
||||||
@@ -713,6 +732,14 @@ export function parseMediaEmbed(url: string, host: string): MediaEmbed | null {
|
|||||||
embedUrl: buildVideoEmbedUrl('youtube', ytId),
|
embedUrl: buildVideoEmbedUrl('youtube', ytId),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const vimeoEvent = getVimeoEventParts(url);
|
||||||
|
if (vimeoEvent)
|
||||||
|
return {
|
||||||
|
provider: 'vimeo',
|
||||||
|
kind: 'landscape',
|
||||||
|
embedUrl: buildVimeoEventEmbedUrl(vimeoEvent.id, vimeoEvent.hash),
|
||||||
|
};
|
||||||
|
|
||||||
const vimeo = getVimeoParts(url);
|
const vimeo = getVimeoParts(url);
|
||||||
if (vimeo)
|
if (vimeo)
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { pushSessionToSW } from '../sw-session';
|
|||||||
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
|
||||||
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
|
||||||
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
|
||||||
|
import { clearSharedPayload } from '../swShare';
|
||||||
|
|
||||||
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
// Thrown when the local IndexedDB has a higher schema version than this SDK expects.
|
||||||
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
// This happens after a downgrade (e.g. matrix-js-sdk was briefly upgraded and then reverted).
|
||||||
@@ -146,6 +147,9 @@ export const logoutClient = async (mx: MatrixClient) => {
|
|||||||
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
// [Gitea #57] Same reasoning as clearPlaintextCaches: decrypted soundboard
|
||||||
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
// clip bytes stay reachable via live blob: URLs until the reload otherwise.
|
||||||
clearSoundboardClipCache();
|
clearSoundboardClipCache();
|
||||||
|
// [Gitea #267] Files stashed by the share target stay in Cache Storage until
|
||||||
|
// /share is opened; a shared photo must not outlive the logout.
|
||||||
|
await clearSharedPayload().catch(() => undefined);
|
||||||
// Remove only the session credential keys, preserving user preferences and
|
// Remove only the session credential keys, preserving user preferences and
|
||||||
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
|
||||||
removeFallbackSession();
|
removeFallbackSession();
|
||||||
|
|||||||
Vendored
+4
@@ -1,5 +1,9 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
// [Gitea #276] Set by vite.config.js `define`.
|
||||||
|
declare const __LOTUS_BUILD_COMMIT__: string;
|
||||||
|
declare const __LOTUS_BUILD_DATE__: string;
|
||||||
|
|
||||||
declare module 'browser-encrypt-attachment' {
|
declare module 'browser-encrypt-attachment' {
|
||||||
export interface EncryptedAttachmentInfo {
|
export interface EncryptedAttachmentInfo {
|
||||||
v: string;
|
v: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/// <reference lib="WebWorker" />
|
/// <reference lib="WebWorker" />
|
||||||
|
|
||||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
import { isTrustedShareRequest, SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||||
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||||
|
|
||||||
export type {};
|
export type {};
|
||||||
@@ -249,6 +249,11 @@ self.addEventListener('fetch', (event: FetchEvent) => {
|
|||||||
// share_target). Stash the form and bounce to the in-app /share page.
|
// share_target). Stash the form and bounce to the in-app /share page.
|
||||||
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
||||||
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
||||||
|
// [Gitea #267] A cross-site form posting here is not a share: drop it.
|
||||||
|
if (!isTrustedShareRequest(event.request.headers, self.location.origin)) {
|
||||||
|
event.respondWith(Response.redirect(`${base}/`, 303));
|
||||||
|
return;
|
||||||
|
}
|
||||||
event.respondWith(
|
event.respondWith(
|
||||||
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
||||||
);
|
);
|
||||||
|
|||||||
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { shareDraftText } from './swShare';
|
import { isTrustedShareRequest, shareDraftText } from './swShare';
|
||||||
|
|
||||||
const base = { files: [], receivedAt: 0 };
|
const base = { files: [], receivedAt: 0 };
|
||||||
|
|
||||||
@@ -25,3 +25,14 @@ test('shareDraftText drops a title/url the text already contains', () => {
|
|||||||
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
|
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
|
||||||
assert.equal(shareDraftText(base), '');
|
assert.equal(shareDraftText(base), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#267] a share another website submitted is refused', () => {
|
||||||
|
const app = 'https://chat.lotusguild.org';
|
||||||
|
const h = (origin?: string) => new Headers(origin ? { Origin: origin } : {});
|
||||||
|
assert.equal(isTrustedShareRequest(h(), app), true, 'no Origin (OS share sheet)');
|
||||||
|
assert.equal(isTrustedShareRequest(h('null'), app), true, 'opaque origin (OS share sheet)');
|
||||||
|
assert.equal(isTrustedShareRequest(h(app), app), true, 'the app itself');
|
||||||
|
assert.equal(isTrustedShareRequest(h('https://evil.example'), app), false);
|
||||||
|
assert.equal(isTrustedShareRequest(h('https://chat.lotusguild.org.evil.example'), app), false);
|
||||||
|
assert.equal(isTrustedShareRequest(h('http://chat.lotusguild.org'), app), false, 'other scheme');
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,6 +20,27 @@ export type SharePayload = {
|
|||||||
receivedAt: number;
|
receivedAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #267] Refuse a share that another website submitted: any site could
|
||||||
|
* otherwise auto-submit a cross-site form here and drop attacker-chosen text
|
||||||
|
* and files into the "Share to…" picker as if the user had shared them.
|
||||||
|
*
|
||||||
|
* The worker can't use `Sec-Fetch-Site`: fetch-metadata headers are added
|
||||||
|
* after the service worker and never appear on `event.request` (checked in
|
||||||
|
* Chromium). `Origin` does: a cross-site form carries the site's origin. The
|
||||||
|
* OS share sheet's POST has no web initiator, so its Origin is `null` or
|
||||||
|
* absent, and both stay accepted. A page can also produce `Origin: null`
|
||||||
|
* (sandboxed frame, no-referrer), which is why /share also says the content
|
||||||
|
* came from elsewhere.
|
||||||
|
*/
|
||||||
|
export const isTrustedShareRequest = (
|
||||||
|
headers: Pick<Headers, 'get'>,
|
||||||
|
selfOrigin: string,
|
||||||
|
): boolean => {
|
||||||
|
const origin = headers.get('Origin');
|
||||||
|
return origin === null || origin === 'null' || origin === selfOrigin;
|
||||||
|
};
|
||||||
|
|
||||||
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
||||||
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
|
|||||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||||
import { VitePWA } from 'vite-plugin-pwa';
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import { execSync } from 'child_process';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import buildConfig from './build.config';
|
import buildConfig from './build.config';
|
||||||
|
|
||||||
@@ -272,8 +273,26 @@ const vendorChunks = (id) => {
|
|||||||
if (id.includes('node_modules/chroma-js')) return 'chroma';
|
if (id.includes('node_modules/chroma-js')) return 'chroma';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #276] Which build is this? The short commit and build time are shown
|
||||||
|
// in Settings → About (web deploys change many times a day while the package
|
||||||
|
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
|
||||||
|
const buildCommit = (() => {
|
||||||
|
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
|
||||||
|
try {
|
||||||
|
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
|
.toString()
|
||||||
|
.trim();
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
|
define: {
|
||||||
|
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
|
||||||
|
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
||||||
|
},
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
base: buildConfig.base,
|
base: buildConfig.base,
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
Reference in New Issue
Block a user