Compare commits
20
Commits
bd8c79e0e6
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bed70d335 | ||
|
|
c7aa4b9b19 | ||
|
|
81777ddfe1 | ||
|
|
eb4b88a028 | ||
|
|
e3883e0fce | ||
|
|
98c80f36cb | ||
|
|
ad1cbcf792 | ||
|
|
492b57c60d | ||
|
|
0c45bde832 | ||
|
|
082b8fc879 | ||
|
|
c6c2e88df5 | ||
|
|
dc0d524989 | ||
|
|
22d46a7922 | ||
|
|
be2c202543 | ||
|
|
6363939654 | ||
|
|
af244bba75 | ||
|
|
af1c0ee184 | ||
|
|
96a97a2f86 | ||
|
|
6aa77552b8 | ||
|
|
52e0cfaa83 |
+21
-2
@@ -132,11 +132,27 @@ jobs:
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/lotus' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# [matrix #9] Debounce: a Tauri build takes ~30 min on the shared runner,
|
||||
# so at most one bump per DEBOUNCE_MIN. Commits that land inside the
|
||||
# window are picked up by cinny-desktop's nightly catch-up workflow (or a
|
||||
# manual dispatch of it) — the desktop cadence no longer tracks every web
|
||||
# commit. The bump is also skipped when nothing changed.
|
||||
- name: Bump cinny submodule
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
DEBOUNCE_MIN: '60'
|
||||
run: |
|
||||
CINNY_SHA="${{ github.sha }}"
|
||||
LAST=$(curl -fsSL -H "Authorization: token $TOKEN" \
|
||||
"https://code.lotusguild.org/api/v1/repos/LotusGuild/cinny-desktop/commits?sha=main&limit=1&stat=false&verification=false&files=false" \
|
||||
| python3 -c 'import sys,json; c=json.load(sys.stdin); print(c[0]["commit"]["committer"]["date"] if c else "")' 2>/dev/null || true)
|
||||
if [ -n "$LAST" ]; then
|
||||
AGE=$(python3 -c "import sys,datetime; d=datetime.datetime.fromisoformat(sys.argv[1].replace('Z','+00:00')); print(int((datetime.datetime.now(datetime.timezone.utc)-d).total_seconds()//60))" "$LAST")
|
||||
if [ "$AGE" -lt "$DEBOUNCE_MIN" ]; then
|
||||
echo "Last desktop bump was ${AGE} min ago (< ${DEBOUNCE_MIN}); skipping — the nightly catch-up will pick this up."
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
||||
cd desktop
|
||||
git config user.email "ci@lotusguild.org"
|
||||
@@ -275,8 +291,11 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install --with-deps chromium
|
||||
# [Gitea #221] WebKit too — Playwright's Safari/iOS proxy for the tagged
|
||||
# subset (see playwright.config.ts projects). `--with-deps` pulls the
|
||||
# GTK/GStreamer libraries WebKit needs on the runner.
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install --with-deps chromium webkit
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** sinc
|
||||
|
||||
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
|
||||
|
||||
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.9`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.9`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||
**Publish a new version (CI on tag push; needs the `NPM_PUBLISH_TOKEN` org secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.11`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.11`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||
|
||||
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
|
||||
|
||||
|
||||
@@ -57,6 +57,10 @@ Browser-level smoke tests under `e2e/` (config: `playwright.config.ts`). They bo
|
||||
| **E2EE composer** (gated) | `e2e/e2ee-composer.spec.ts` | only when `E2E_HOMESERVER`, `E2E_USER`, `E2E_PASSWORD` are all set | password login → `/home/create/` with the End-to-End Encryption switch on (asserts `createRoom` carries `m.room.encryption`) → text message renders → attach a generated JPEG with "Compress image before uploading" ticked, image renders → every `PUT …/rooms/*/send/*` was `m.room.encrypted` with `ciphertext` and no plaintext `body` / `url` / `file` / `mxc://` |
|
||||
| **Local homeserver** (Gitea #220) | `e2e/local-homeserver.spec.ts` | when a Synapse answers at `E2E_LOCAL_HS` (default `http://localhost:8008`); the CI `e2e` job starts one with `scripts/dev-homeserver.sh start` + `dev-seed.py`, locally run the same two commands | registers its own `e2e_alice_*`/`e2e_bob_*` users and rooms over the CS API, then drives the built client: login + send/receive, own message scrolls into view (#212), `/kick` failure toast (#216), upload 413 sentence (#213), forward provenance header, thread panel + drawer at 1400 px (#218), timeline image → gallery lightbox (#219), clock-skew banner via `page.clock` (#158), status save under the presence rate limit (#226), long-press action sheet on a Pixel 7 emulation (#166). Helpers in `e2e/localHs.ts`; add a test here whenever a fix was reproduced with a scratch Playwright script |
|
||||
|
||||
**Accessibility gate** (Gitea #222): `e2e/a11y.spec.ts` runs `@axe-core/playwright` (WCAG 2.x A/AA tags) over the login page, room timeline + composer, message options menu, thread panel, user settings and room settings, and fails on any **critical/serious** finding except `color-contrast` (reported in the log, not gated — generated avatar colours and portal false positives). It also keeps accessibility-tree snapshots (`e2e/a11y.spec.ts-snapshots/*.aria.yml`) of the composer, message menu, thread panel and settings nav, so a lost name/role/live-region shows as a diff; update them deliberately with `npx playwright test e2e/a11y --update-snapshots` and keep dynamic bits as regexes. A real NVDA/VoiceOver pass is still manual.
|
||||
|
||||
**Browsers** (Gitea #221): everything runs under Chromium; the tests tagged `@webkit` (boot, login + send/receive, thread panel, lightbox) also run under Playwright's WebKit as desktop Safari, and those tagged `@ios` under the `iPhone 14` descriptor — the closest CI gets to Safari/iOS. It catches WebKit-only breakage (CSS, `dvh`, IndexedDB, media decode) but does not emulate the on-screen keyboard or Home-Screen install; a real iPhone pass (#166/#199) stays manual. Locally: `npx playwright install --with-deps webkit` once, then `npx playwright test --project=webkit --project=iphone`. WebKit words handled fetch failures as page errors (`TypeError: Load failed`, `due to access control checks`), so the allowlist in `e2e/helpers.ts` applies to page errors too.
|
||||
|
||||
**CI secrets** (Gitea → repo → Settings → Actions → Secrets; the `e2e` job forwards them via `env:`; until they exist the E2EE tier reports `skipped`, the boot tier still runs):
|
||||
|
||||
- `E2E_HOMESERVER` — server name as typed on the login page (e.g. `matrix.example.org`). Must offer `m.login.password`; a next-gen-auth (MAS/OIDC-issuer) server shows only the OIDC button and the tier will fail at the username field.
|
||||
|
||||
@@ -196,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
|
||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||
|
||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||
(`@lotusguild/element-call-embedded` `0.25.0-lotus.9`, upstream base v0.25.0, source at
|
||||
(`@lotusguild/element-call-embedded` `0.25.0-lotus.11`, upstream base v0.25.0, source at
|
||||
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||
behavior is editable source instead of fragile DOM/widget hacks.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# more info: https://caddyserver.com/docs/caddyfile/patterns#single-page-apps-spas
|
||||
cinny.domain.tld {
|
||||
root * /path/to/cinny/dist
|
||||
# [Gitea #155] PWA share target: the service worker answers this POST; if it
|
||||
# isn't controlling the page yet, land on /share instead of a 405.
|
||||
redir /share-target /share 303
|
||||
|
||||
try_files {path} /index.html
|
||||
file_server
|
||||
|
||||
|
||||
@@ -26,6 +26,13 @@ server {
|
||||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||||
add_header Permissions-Policy "accelerometer=(), autoplay=(self), camera=(self), display-capture=(self), encrypted-media=(self), fullscreen=(self), geolocation=(self), gyroscope=(), magnetometer=(), microphone=(self), midi=(), payment=(), usb=()" always;
|
||||
|
||||
# [Gitea #155] PWA share target. The service worker normally answers this
|
||||
# POST itself; if it isn't controlling the page yet, land on /share
|
||||
# (the shared files are lost, but nothing 405s).
|
||||
location = /share-target {
|
||||
return 303 /share;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /opt/cinny/dist/;
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import {
|
||||
createRoom,
|
||||
hsReachable,
|
||||
loginUI,
|
||||
openRoom,
|
||||
ensureUser,
|
||||
sendText,
|
||||
uniq,
|
||||
TestUser,
|
||||
} from './localHs';
|
||||
|
||||
// [Gitea #222] Machine-checkable accessibility on every push. Two parts:
|
||||
// 1. axe-core over the main surfaces, failing on critical/serious findings.
|
||||
// Colour-contrast is reported but not gated: several hits are generated
|
||||
// avatar colours and portal false positives (see the issue for the list).
|
||||
// 2. Accessibility-tree snapshots of the composer, message menu, thread
|
||||
// panel and settings nav, so a lost name/role/live-region shows up as a
|
||||
// diff. Update deliberately with `npx playwright test e2e/a11y --update-snapshots`.
|
||||
// A real screen-reader pass still needs a human.
|
||||
|
||||
const TAGS = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];
|
||||
|
||||
async function auditPage(page: Page, label: string) {
|
||||
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
|
||||
const describe = (v: (typeof results.violations)[number]) =>
|
||||
`[${v.impact}] ${v.id}: ${v.help}\n${v.nodes
|
||||
.slice(0, 5)
|
||||
.map((n) => ` ${n.html.replace(/\s+/g, ' ').slice(0, 140)}`)
|
||||
.join('\n')}`;
|
||||
const contrast = results.violations.filter((v) => v.id === 'color-contrast');
|
||||
if (contrast.length) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`axe ${label}: contrast (not gated)\n${contrast.map(describe).join('\n')}`);
|
||||
}
|
||||
const gated = results.violations.filter(
|
||||
(v) => v.id !== 'color-contrast' && (v.impact === 'critical' || v.impact === 'serious'),
|
||||
);
|
||||
expect(gated.map(describe), `axe ${label}: critical/serious findings`).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe('accessibility', () => {
|
||||
test('login page passes axe @webkit', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByLabel('Username or email')).toBeVisible();
|
||||
await auditPage(page, 'login');
|
||||
});
|
||||
|
||||
test.describe('signed in', () => {
|
||||
let alice: TestUser;
|
||||
let room: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), 'no local homeserver');
|
||||
alice = await ensureUser(uniq('e2e_a11y_'));
|
||||
room = await createRoom(alice, 'A11y Room');
|
||||
const root = await sendText(alice, room, 'thread root for a11y');
|
||||
await sendText(alice, room, 'a reply', {
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.thread',
|
||||
event_id: root,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: root },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 850 });
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await expect(page.getByText('thread root for a11y')).toBeVisible();
|
||||
});
|
||||
|
||||
test('room timeline + composer', async ({ page }) => {
|
||||
await auditPage(page, 'room');
|
||||
await expect(page.locator('[data-slate-editor]').first()).toMatchAriaSnapshot({
|
||||
name: 'composer-editor.aria.yml',
|
||||
});
|
||||
await expect(page.getByRole('button', { name: 'Send message' })).toMatchAriaSnapshot({
|
||||
name: 'composer-send.aria.yml',
|
||||
});
|
||||
});
|
||||
|
||||
test('message options menu', async ({ page }) => {
|
||||
const msg = page.locator('[data-message-item]', { hasText: 'thread root for a11y' });
|
||||
await msg.hover();
|
||||
await msg.getByRole('button', { name: 'More options' }).click();
|
||||
const menu = page.locator('[data-message-menu]').first();
|
||||
await expect(menu).toBeVisible();
|
||||
await auditPage(page, 'message menu');
|
||||
await expect(menu).toMatchAriaSnapshot({ name: 'message-menu.aria.yml' });
|
||||
});
|
||||
|
||||
test('thread panel', async ({ page }) => {
|
||||
await page
|
||||
.locator('[data-message-item]', { hasText: 'thread root for a11y' })
|
||||
.getByText(/1 reply/)
|
||||
.click();
|
||||
await expect(page.locator('[data-slate-editor]')).toHaveCount(2);
|
||||
await auditPage(page, 'thread panel');
|
||||
await expect(page.getByRole('complementary').first()).toMatchAriaSnapshot({
|
||||
name: 'thread-panel.aria.yml',
|
||||
});
|
||||
});
|
||||
|
||||
test('user settings', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'User Settings' }).click();
|
||||
const dialog = page.getByRole('dialog').first();
|
||||
await expect(dialog).toBeVisible();
|
||||
await auditPage(page, 'settings');
|
||||
await expect(dialog.getByRole('navigation').first()).toMatchAriaSnapshot({
|
||||
name: 'settings-nav.aria.yml',
|
||||
});
|
||||
});
|
||||
|
||||
test('room settings', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'More Options' }).first().click();
|
||||
await page.getByText('Room Settings', { exact: true }).click();
|
||||
await expect(page.getByRole('dialog').first()).toBeVisible();
|
||||
await auditPage(page, 'room settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
- textbox "Send a message...":
|
||||
- paragraph: Send a message...
|
||||
@@ -0,0 +1,2 @@
|
||||
- button "Send message":
|
||||
- img
|
||||
@@ -0,0 +1,39 @@
|
||||
- button "Add Reaction":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Reply":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Forward":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Bookmark Message":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Remind Me":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Edit Message":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Read Receipts":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Copy Text":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Translate":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Copy Link":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Copy Lotus Link":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Pin Message":
|
||||
- text: ''
|
||||
- img
|
||||
- button "Delete":
|
||||
- text: ''
|
||||
- img
|
||||
@@ -0,0 +1,22 @@
|
||||
- navigation "Settings sections":
|
||||
- button "General" [pressed]:
|
||||
- img
|
||||
- paragraph: General
|
||||
- button "Account":
|
||||
- img
|
||||
- paragraph: Account
|
||||
- button "Notifications":
|
||||
- img
|
||||
- paragraph: Notifications
|
||||
- button "Devices":
|
||||
- img
|
||||
- paragraph: Devices
|
||||
- button "Emojis & Stickers":
|
||||
- img
|
||||
- paragraph: Emojis & Stickers
|
||||
- button "Developer Tools":
|
||||
- img
|
||||
- paragraph: Developer Tools
|
||||
- button "About":
|
||||
- img
|
||||
- paragraph: About
|
||||
@@ -0,0 +1,31 @@
|
||||
- complementary "Thread":
|
||||
- paragraph: Thread
|
||||
- paragraph: A11y Room
|
||||
- button "Thread notifications":
|
||||
- img
|
||||
- button "Close thread":
|
||||
- img
|
||||
- log "Thread timeline":
|
||||
- article:
|
||||
- button /e2e_a11y_\w+, open profile/:
|
||||
- img
|
||||
- button /e2e_a11y_\w+/
|
||||
- time: /\d+:\d+ (AM|PM)/
|
||||
- text: thread root for a11y
|
||||
- paragraph: 1 reply
|
||||
- article:
|
||||
- button /e2e_a11y_\w+, open profile/:
|
||||
- img
|
||||
- button /e2e_a11y_\w+/
|
||||
- time: /\d+:\d+ (AM|PM)/
|
||||
- text: a reply
|
||||
- button "More actions":
|
||||
- img
|
||||
- textbox "Send a message...":
|
||||
- paragraph: Send a message...
|
||||
- button "Insert sticker":
|
||||
- img
|
||||
- button "Insert emoji":
|
||||
- img
|
||||
- button "Send message":
|
||||
- img
|
||||
+2
-2
@@ -5,7 +5,7 @@ import { collectConsole } from './helpers';
|
||||
// `vite preview` (see playwright.config.ts webServer). No homeserver needed.
|
||||
|
||||
test.describe('boot', () => {
|
||||
test('client boots to the login screen without errors', async ({ page }) => {
|
||||
test('client boots to the login screen without errors @webkit @ios', async ({ page }) => {
|
||||
const consoleLog = collectConsole(page);
|
||||
|
||||
await page.goto('/');
|
||||
@@ -24,7 +24,7 @@ test.describe('boot', () => {
|
||||
expect(consoleLog.unexpected(), 'unexpected console/page errors during boot').toEqual([]);
|
||||
});
|
||||
|
||||
test('service worker script is served and registers', async ({ page }) => {
|
||||
test('service worker script is served and registers @webkit @ios', async ({ page }) => {
|
||||
const swResponse = await page.request.get('/sw.js');
|
||||
expect(swResponse.status(), 'GET /sw.js').toBe(200);
|
||||
expect(swResponse.headers()['content-type'] ?? '').toMatch(/javascript/);
|
||||
|
||||
+10
-1
@@ -18,6 +18,11 @@ const BENIGN_CONSOLE_PATTERNS: RegExp[] = [
|
||||
// autodiscovery probes https://localhost/, which is either not listening
|
||||
// (CI) or a self-signed dev server (local calls stack).
|
||||
/ERR_CONNECTION_REFUSED|ERR_CERT_AUTHORITY_INVALID|ERR_SSL_PROTOCOL_ERROR/i,
|
||||
// WebKit's spellings of the same discovery failures (#221).
|
||||
/Unacceptable TLS certificate|Could not connect to|Connection refused|TypeError: Load failed/i,
|
||||
// Also a fetch cut short by our own navigation (e.g. the crypto wasm while
|
||||
// the test moves from /home to a room) — WebKit words that the same way.
|
||||
/due to access control checks/i,
|
||||
// React devtools hint in production bundles.
|
||||
/Download the React DevTools/i,
|
||||
];
|
||||
@@ -45,8 +50,12 @@ export function collectConsole(page: Page): ConsoleCollector {
|
||||
return {
|
||||
errors,
|
||||
pageErrors,
|
||||
// WebKit surfaces handled fetch failures (well-known probes) as page
|
||||
// errors rather than console lines, so the allowlist applies to both.
|
||||
unexpected: () => [
|
||||
...pageErrors.map((m) => `pageerror: ${m}`),
|
||||
...pageErrors
|
||||
.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m)))
|
||||
.map((m) => `pageerror: ${m}`),
|
||||
...errors.filter((m) => !BENIGN_CONSOLE_PATTERNS.some((re) => re.test(m))),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@ test.describe('local homeserver regression', () => {
|
||||
bob = await ensureUser(uniq('e2e_bob_'));
|
||||
});
|
||||
|
||||
test('logs in, opens a room, sends and receives', async ({ page }) => {
|
||||
test('logs in, opens a room, sends and receives @webkit @ios', async ({ page }) => {
|
||||
const console_ = collectConsole(page);
|
||||
const room = await createRoom(alice, 'Regression Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
@@ -140,7 +140,7 @@ test.describe('local homeserver regression', () => {
|
||||
await expect(page.getByText('the original message')).toBeVisible();
|
||||
});
|
||||
|
||||
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218)', async ({
|
||||
test('thread panel: opens from the chip and yields the member drawer at 1400px (#218) @webkit', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 850 } });
|
||||
@@ -169,7 +169,7 @@ test.describe('local homeserver regression', () => {
|
||||
await ctx.close();
|
||||
});
|
||||
|
||||
test('timeline image opens the gallery lightbox (#219)', async ({ page }) => {
|
||||
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Lightbox Room');
|
||||
const png = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
|
||||
|
||||
Generated
+22
-7
@@ -80,7 +80,8 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.9",
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.11",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -172,6 +173,19 @@
|
||||
"@babel/runtime": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@axe-core/playwright": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
|
||||
"integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"axe-core": "~4.13.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"playwright-core": ">= 1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@@ -2681,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.9",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.9/element-call-embedded-0.25.0-lotus.9.tgz",
|
||||
"integrity": "sha512-+mIrBA4SywAmb00EYgThyyVqDLShICjC2Mh8GR13NAWo/u4nPY+j4rMhZidfOJMdFTF5eaMHigX6SE0lHZazZA==",
|
||||
"version": "0.25.0-lotus.11",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.11/element-call-embedded-0.25.0-lotus.11.tgz",
|
||||
"integrity": "sha512-wLsVsEBLZ4UjhAdLhhk5BmAap1kZ4x8KKirOm2Ie+DbtM34GCv30oUFmQnUmVdO4by3EQAmNK+v+vYyIwq4DOg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
@@ -4847,10 +4861,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axe-core": {
|
||||
"version": "4.10.2",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.10.2.tgz",
|
||||
"integrity": "sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
|
||||
"integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
|
||||
+4
-3
@@ -12,13 +12,13 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint src/* --max-warnings 68",
|
||||
"check:eslint": "eslint src/* --max-warnings 49",
|
||||
"check:prettier": "prettier --check .",
|
||||
"fix:prettier": "prettier --write .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --import tsx --test $(find src -name '*.test.ts')",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:install": "playwright install chromium",
|
||||
"test:e2e:install": "playwright install chromium webkit",
|
||||
"prepare": "husky",
|
||||
"commit": "git-cz",
|
||||
"postinstall": "node scripts/patch-folds.mjs",
|
||||
@@ -107,7 +107,8 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.9",
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.11",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
+10
-1
@@ -25,7 +25,16 @@ export default defineConfig({
|
||||
trace: 'retain-on-failure',
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
// [Gitea #221] WebKit as the Safari/iOS proxy. It runs the subset tagged
|
||||
// @webkit / @ios (boot, composer round-trip, thread panel, lightbox): it
|
||||
// catches WebKit-only breakage (CSS, dvh, IndexedDB, media decode) that
|
||||
// Chromium emulation can't, but does not emulate the iOS keyboard or the
|
||||
// Home-Screen install flow.
|
||||
{ name: 'webkit', use: { ...devices['Desktop Safari'] }, grep: /@webkit/ },
|
||||
{ name: 'iphone', use: { ...devices['iPhone 14'] }, grep: /@ios/ },
|
||||
],
|
||||
webServer: {
|
||||
command: `npx vite preview --port ${PORT} --strictPort`,
|
||||
url: BASE_URL,
|
||||
|
||||
@@ -69,6 +69,22 @@
|
||||
}
|
||||
],
|
||||
"categories": ["social", "communication", "productivity"],
|
||||
"share_target": {
|
||||
"action": "./share-target",
|
||||
"method": "POST",
|
||||
"enctype": "multipart/form-data",
|
||||
"params": {
|
||||
"title": "title",
|
||||
"text": "text",
|
||||
"url": "url",
|
||||
"files": [
|
||||
{
|
||||
"name": "files",
|
||||
"accept": ["image/*", "video/*", "audio/*", "application/pdf", "text/plain"]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "New Message",
|
||||
|
||||
@@ -47,6 +47,10 @@ import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ri
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallPolicyRevokedToast } from '../hooks/useCallPolicyRevokedToast';
|
||||
import { useCallEndedToast } from '../hooks/useCallEndedToast';
|
||||
import { useCallRejoin } from '../hooks/useCallRejoin';
|
||||
import { usePttHaptics } from '../hooks/usePttHaptics';
|
||||
import { useScreenshareNotices } from '../hooks/useScreenshareNotices';
|
||||
import { useCallAnnouncements } from '../hooks/useCallAnnouncements';
|
||||
import { useMutedTalkWarning } from '../hooks/useMutedTalkWarning';
|
||||
import { callAnnouncementAtom } from '../state/callAnnouncement';
|
||||
@@ -744,6 +748,9 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useAfkAutoMute(joined ? embed : undefined);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallEndedToast(embed);
|
||||
useScreenshareNotices(embed);
|
||||
usePttHaptics();
|
||||
useCallAnnouncements(embed, joined);
|
||||
useMutedTalkWarning(embed, joined);
|
||||
useCallThemeSync(embed);
|
||||
@@ -840,6 +847,12 @@ function PipMuteOverlay({ callEmbed }: { callEmbed: CallEmbed }) {
|
||||
type CallEmbedProviderProps = {
|
||||
children?: ReactNode;
|
||||
};
|
||||
/** [Gitea #118] Heartbeat + rejoin after a crash/restart; needs the embed container mounted. */
|
||||
function CallRejoin({ callEmbed, joined }: { callEmbed?: CallEmbed; joined: boolean }) {
|
||||
useCallRejoin(callEmbed, joined);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const callEmbed = useAtomValue(callEmbedAtom);
|
||||
const callEmbedRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
@@ -1292,6 +1305,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
<CallAnnouncementRegion />
|
||||
<CallEmbedRefContextProvider value={callEmbedRef}>
|
||||
<IncomingCallListener callEmbed={callEmbed} joined={joined} />
|
||||
<CallRejoin callEmbed={callEmbed} joined={joined} />
|
||||
{children}
|
||||
</CallEmbedRefContextProvider>
|
||||
<div
|
||||
|
||||
@@ -30,7 +30,7 @@ export const ImageOverlay = as<'div', ImageOverlayProps>(
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src,
|
||||
|
||||
@@ -24,6 +24,8 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
||||
<Modal
|
||||
size="500"
|
||||
variant="Background"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||
// squish the two-pane settings layout.
|
||||
|
||||
@@ -67,7 +67,9 @@ export const EditorTextarea = style([
|
||||
export const EditorPlaceholderContainer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
opacity: config.opacity.Placeholder,
|
||||
// [Gitea #222] folds' Placeholder opacity (0.5) lands at ~2.3:1 on the
|
||||
// composer surface; P300 keeps it visibly secondary at AA contrast.
|
||||
opacity: config.opacity.P300,
|
||||
pointerEvents: 'none',
|
||||
userSelect: 'none',
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { LinePlaceholder } from './placeholder';
|
||||
import { randomNumberBetween } from '../../utils/common';
|
||||
import * as css from './Reply.css';
|
||||
import { ReplyMediaThumb, hasReplyMedia } from './ReplyMediaThumb';
|
||||
import { MessageBadEncryptedContent, MessageDeletedContent, MessageFailedContent } from './content';
|
||||
import { scaleSystemEmoji } from '../../plugins/react-custom-html-parser';
|
||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
@@ -143,9 +144,12 @@ export const Reply = as<'div', ReplyProps>(
|
||||
<i>Original message not available</i>
|
||||
</Text>
|
||||
) : (
|
||||
<Text size="T300" truncate>
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||
{hasReplyMedia(replyEvent) && <ReplyMediaThumb mEvent={replyEvent} />}
|
||||
<Text size="T300" truncate>
|
||||
{badEncryption ? <MessageBadEncryptedContent /> : bodyJSX}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React from 'react';
|
||||
import { Icon, Icons, config, toRem } from 'folds';
|
||||
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
|
||||
import { getThumbMxc } from '../../utils/mediaThumb';
|
||||
import { MessageEvent } from '../../../types/matrix/room';
|
||||
|
||||
const SIZE = 36;
|
||||
|
||||
/** Whether a reply quote for this event should carry a thumbnail. */
|
||||
export const hasReplyMedia = (mEvent: MatrixEvent | null | undefined): boolean => {
|
||||
if (!mEvent || mEvent.isRedacted()) return false;
|
||||
if (mEvent.getType() === MessageEvent.Sticker) return true;
|
||||
const msgtype = mEvent.getContent().msgtype;
|
||||
return msgtype === MsgType.Image || msgtype === MsgType.Video;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #151] 36 px thumbnail in a reply quote for an image/video/sticker.
|
||||
* Uses the event's own thumbnail (decrypting it for E2EE media), never the
|
||||
* full-size file.
|
||||
*/
|
||||
export function ReplyMediaThumb({ mEvent }: { mEvent: MatrixEvent }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const content = mEvent.getContent();
|
||||
const isVideo = content.msgtype === MsgType.Video;
|
||||
const thumbMxc = getThumbMxc(mEvent);
|
||||
const info = content.info as Record<string, unknown> | undefined;
|
||||
const encInfo = content.file
|
||||
? ((info?.thumbnail_file as typeof content.file | undefined) ?? content.file)
|
||||
: undefined;
|
||||
const mimeType =
|
||||
(info?.thumbnail_info as { mimetype?: string } | undefined)?.mimetype ??
|
||||
(info?.mimetype as string | undefined);
|
||||
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType);
|
||||
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(SIZE),
|
||||
height: toRem(SIZE),
|
||||
flexShrink: 0,
|
||||
borderRadius: config.radii.R300,
|
||||
overflow: 'hidden',
|
||||
background: 'rgba(127, 127, 127, 0.15)',
|
||||
}}
|
||||
>
|
||||
{media.status === 'ok' ? (
|
||||
<img
|
||||
src={media.url}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<Icon size="100" src={isVideo ? Icons.Play : Icons.Photo} />
|
||||
)}
|
||||
{isVideo && media.status === 'ok' && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Play}
|
||||
filled
|
||||
style={{ position: 'absolute', color: 'white', filter: 'drop-shadow(0 0 2px black)' }}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -114,7 +114,7 @@ export function ReadTextFile({ body, mimeType, url, encInfo, renderViewer }: Rea
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
@@ -203,7 +203,7 @@ export function ReadPdfFile({ body, mimeType, url, encInfo, renderViewer }: Read
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
name: body,
|
||||
|
||||
@@ -143,7 +143,7 @@ export const ImageContent = as<'div', ImageContentProps>(
|
||||
<Modal
|
||||
className={ModalWide}
|
||||
size="500"
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
|
||||
>
|
||||
{renderViewer({
|
||||
src: srcState.data,
|
||||
|
||||
@@ -9,16 +9,35 @@ export const NavItem = as<
|
||||
{
|
||||
highlight?: boolean;
|
||||
} & css.RoomSelectorVariants
|
||||
>(({ as: AsNavItem = 'div', className, highlight, variant, radii, children, ...props }, ref) => (
|
||||
<AsNavItem
|
||||
className={classNames(css.NavItem({ variant, radii }), className)}
|
||||
data-highlight={highlight}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</AsNavItem>
|
||||
));
|
||||
>(
|
||||
(
|
||||
{
|
||||
as: AsNavItem = 'div',
|
||||
className,
|
||||
highlight,
|
||||
variant,
|
||||
radii,
|
||||
children,
|
||||
'aria-selected': selected,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => (
|
||||
// [Gitea #222] Callers pass `aria-selected`, but that attribute is only
|
||||
// valid on option/tab/row roles; on a plain div axe flags it as critical.
|
||||
// Keep the prop for callers and styling, expose the state as aria-current.
|
||||
<AsNavItem
|
||||
className={classNames(css.NavItem({ variant, radii }), className)}
|
||||
data-highlight={highlight}
|
||||
data-selected={selected === true || selected === 'true' ? true : undefined}
|
||||
aria-current={selected === true || selected === 'true' ? 'page' : undefined}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</AsNavItem>
|
||||
),
|
||||
);
|
||||
|
||||
export const NavLink = forwardRef<HTMLAnchorElement, ComponentProps<typeof Link>>(
|
||||
({ className, ...props }, ref) => (
|
||||
|
||||
@@ -69,7 +69,7 @@ const NavItemBase = style({
|
||||
[`&:has(.${NavLink}:active)`]: {
|
||||
backgroundColor: ContainerActive,
|
||||
},
|
||||
'&[aria-selected=true]': {
|
||||
'&[data-selected=true]': {
|
||||
backgroundColor: ContainerActive,
|
||||
},
|
||||
[`&:has(.${NavLink}:focus-visible)`]: {
|
||||
|
||||
@@ -283,6 +283,12 @@ type UploadCardRendererProps = {
|
||||
setMetadata: (fileItem: TUploadItem, metadata: TUploadMetadata) => void;
|
||||
onRemove: (file: TUploadContent) => void;
|
||||
onComplete?: (upload: UploadSuccess) => void;
|
||||
/** [Gitea #129] Focus the caption field on mount (single image into an empty composer). */
|
||||
autoFocusCaption?: boolean;
|
||||
/** Enter in the caption field: send the board (and any composer text). */
|
||||
onCaptionSubmit?: () => void;
|
||||
/** Escape in the caption field: hand focus back to the composer. */
|
||||
onCaptionEscape?: () => void;
|
||||
};
|
||||
export function UploadCardRenderer({
|
||||
isEncrypted,
|
||||
@@ -290,6 +296,9 @@ export function UploadCardRenderer({
|
||||
setMetadata,
|
||||
onRemove,
|
||||
onComplete,
|
||||
autoFocusCaption,
|
||||
onCaptionSubmit,
|
||||
onCaptionEscape,
|
||||
}: UploadCardRendererProps) {
|
||||
const mx = useMatrixClient();
|
||||
const mediaConfig = useMediaConfig();
|
||||
@@ -379,9 +388,27 @@ export function UploadCardRenderer({
|
||||
size="300"
|
||||
radii="300"
|
||||
style={{ marginTop: config.space.S200, width: '100%' }}
|
||||
autoFocus={autoFocusCaption}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
onCaptionSubmit?.();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onCaptionEscape?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<CompressionCheckbox fileItem={fileItem} metadata={metadata} setMetadata={setMetadata} />
|
||||
{metadata.metadataStripped && (
|
||||
<Box alignItems="Center" gap="100" style={{ marginTop: config.space.S100 }}>
|
||||
<Icon size="50" src={Icons.Shield} style={{ color: color.Success.Main }} />
|
||||
<Text size="T200" priority="300">
|
||||
Photo metadata removed (location, camera, time)
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{upload.status === UploadStatus.Idle && !fileSizeExceeded && (
|
||||
<UploadCardProgress sentBytes={0} totalBytes={file.size} />
|
||||
)}
|
||||
|
||||
@@ -88,7 +88,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
<Modal
|
||||
size="500"
|
||||
className={ModalMobileFull}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
onContextMenu={(evt: React.MouseEvent) => evt.stopPropagation()}
|
||||
>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
/** Output selection needs setSinkId; Firefox/Safari/Android Chrome lack it. */
|
||||
export const audioOutputSelectable = (): boolean =>
|
||||
typeof HTMLMediaElement !== 'undefined' &&
|
||||
'setSinkId' in HTMLMediaElement.prototype &&
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.mediaDevices?.enumerateDevices;
|
||||
|
||||
type OutputDevice = { id: string; label: string };
|
||||
|
||||
/**
|
||||
* [Gitea #119] Speaker button in the call bar: a small menu of audio outputs
|
||||
* (headset ↔ speakers) without opening Settings. The choice goes to the fork
|
||||
* as io.lotus.set_audio_output; the fork's own picker is unreachable here
|
||||
* because the embed hides Element Call's footer.
|
||||
*/
|
||||
export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disabled?: boolean }) {
|
||||
const [anchor, setAnchor] = useState<RectCords>();
|
||||
const [devices, setDevices] = useState<OutputDevice[]>([]);
|
||||
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
setDevices(
|
||||
all
|
||||
.filter((d) => d.kind === 'audiooutput')
|
||||
.map((d, i) => ({ id: d.deviceId, label: d.label || `Output ${i + 1}` })),
|
||||
);
|
||||
} catch {
|
||||
setDevices([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return undefined;
|
||||
refresh();
|
||||
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
||||
}, [anchor, refresh]);
|
||||
|
||||
const choose = (id: string) => {
|
||||
embed.control.setAudioOutput(id);
|
||||
setSelected(id);
|
||||
setAnchor(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={anchor}
|
||||
position="Top"
|
||||
align="Center"
|
||||
offset={6}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
// The list is enumerated after the menu opens; until then the only
|
||||
// tabbable node is the menu itself.
|
||||
fallbackFocus: '#call-audio-output-menu',
|
||||
onDeactivate: () => setAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
id="call-audio-output-menu"
|
||||
tabIndex={-1}
|
||||
style={{ maxWidth: toRem(280), width: '100vw' }}
|
||||
aria-label="Audio output"
|
||||
>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{devices.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No audio outputs found.
|
||||
</Text>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const isSelected = selected ? d.id === selected : d.id === 'default';
|
||||
return (
|
||||
<MenuItem
|
||||
key={d.id}
|
||||
size="300"
|
||||
radii="300"
|
||||
role="menuitemradio"
|
||||
aria-checked={isSelected}
|
||||
after={isSelected ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||
onClick={() => choose(d.id)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{d.label}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text size="T200">Audio output</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(tipRef) => (
|
||||
<IconButton
|
||||
ref={tipRef}
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
aria-label="Audio output"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={!!anchor}
|
||||
onClick={(e) => setAnchor(e.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<Icon size="100" src={Icons.VolumeHigh} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||
import { ScreenshareConfirm } from '../call/ScreenshareConfirm';
|
||||
import { AudioOutputButton, audioOutputSelectable } from './AudioOutputButton';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
@@ -230,6 +231,9 @@ export function CallControl({
|
||||
onToggle={() => callEmbed.control.toggleSound()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
{!compact && audioOutputSelectable() && (
|
||||
<AudioOutputButton embed={callEmbed} disabled={!callJoined} />
|
||||
)}
|
||||
{!compact && (showCamera || showScreenshare) && <StatusDivider />}
|
||||
{showCamera && (
|
||||
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
|
||||
|
||||
@@ -41,7 +41,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
||||
className={css.CallMemberCard}
|
||||
variant="SurfaceVariant"
|
||||
radii="500"
|
||||
onClick={(evt: any) =>
|
||||
onClick={(evt: React.MouseEvent) =>
|
||||
openUserProfile(
|
||||
room.roomId,
|
||||
undefined,
|
||||
|
||||
@@ -40,10 +40,15 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { describeRoomVisibility } from '../../../utils/roomVisibilityLine';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
@@ -138,7 +143,19 @@ export function RoomProfileEdit({
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
// [Gitea #109] Avatars go through the same metadata strip as messages.
|
||||
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||
const pickFile = useFilePicker(
|
||||
useCallback(
|
||||
(file: File) => {
|
||||
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
|
||||
setImageFile(r.file),
|
||||
);
|
||||
},
|
||||
[stripImageMetadata],
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
@@ -419,6 +436,14 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
||||
const name = useRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
const joinRule = useRoomJoinRule(room);
|
||||
// [Gitea #133] One line, no controls: encryption · join rule · history.
|
||||
const historyVisibilityEvent = useStateEvent(room, StateEvent.RoomHistoryVisibility);
|
||||
const visibilityLine = describeRoomVisibility({
|
||||
encrypted: room.hasEncryptionStateEvent(),
|
||||
joinRule: joinRule?.join_rule,
|
||||
historyVisibility: historyVisibilityEvent?.getContent<{ history_visibility?: string }>()
|
||||
.history_visibility,
|
||||
});
|
||||
|
||||
const canEditAvatar = permissions.stateEvent(StateEvent.RoomAvatar, mx.getSafeUserId());
|
||||
const canEditName = permissions.stateEvent(StateEvent.RoomName, mx.getSafeUserId());
|
||||
@@ -459,6 +484,9 @@ export function RoomProfile({ permissions }: RoomProfileProps) {
|
||||
<Text className={BreakWord} size="H5">
|
||||
{name ?? 'Unknown'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" className={BreakWord}>
|
||||
{visibilityLine}
|
||||
</Text>
|
||||
{topic && (
|
||||
<Text className={classNames(BreakWord, LineClamp3)} size="T200">
|
||||
{topic.format === 'org.matrix.custom.html' &&
|
||||
|
||||
@@ -65,6 +65,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
|
||||
<Input
|
||||
key={maxUsers}
|
||||
name="limitInput"
|
||||
aria-label="Voice channel participant limit"
|
||||
defaultValue={maxUsers}
|
||||
type="number"
|
||||
min={0}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
color,
|
||||
config,
|
||||
PopOut,
|
||||
toRem,
|
||||
@@ -37,6 +38,13 @@ import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import {
|
||||
isSectionTag,
|
||||
listSectionNames,
|
||||
sectionName,
|
||||
sectionTag,
|
||||
validateSectionName,
|
||||
} from '../../utils/roomSections';
|
||||
import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
@@ -277,6 +285,14 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
||||
// [Gitea #108] "Add to section" submenu: existing u.* sections + new one.
|
||||
const [sectionMenuAnchor, setSectionMenuAnchor] = useState<RectCords>();
|
||||
const [newSectionName, setNewSectionName] = useState('');
|
||||
const [newSectionError, setNewSectionError] = useState<string>();
|
||||
const sectionNames = useMemo(() => listSectionNames(mx), [mx]);
|
||||
const roomSections = Object.keys(room.tags ?? {})
|
||||
.filter(isSectionTag)
|
||||
.map(sectionName);
|
||||
const isServerNotice = room.getType() === 'm.server_notice';
|
||||
|
||||
const isFavorite = !!room.tags?.['m.favourite'];
|
||||
@@ -316,6 +332,25 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleToggleSection = (name: string) => {
|
||||
const tag = sectionTag(name);
|
||||
const op = room.tags?.[tag]
|
||||
? mx.deleteRoomTag(room.roomId, tag)
|
||||
: mx.setRoomTag(room.roomId, tag, { order: 0.5 });
|
||||
op.catch(notifyTagFailure);
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleNewSection = (evt: React.FormEvent) => {
|
||||
evt.preventDefault();
|
||||
const error = validateSectionName(newSectionName, sectionNames);
|
||||
if (error) {
|
||||
setNewSectionError(error);
|
||||
return;
|
||||
}
|
||||
handleToggleSection(newSectionName.trim());
|
||||
};
|
||||
|
||||
const markedUnread = useAtomValue(markedUnreadAtom).has(room.roomId);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
@@ -498,6 +533,97 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
{isLowPriority ? 'Remove from Low Priority' : 'Add to Low Priority'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<PopOut
|
||||
anchor={sectionMenuAnchor}
|
||||
position="Right"
|
||||
align="Start"
|
||||
offset={4}
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSectionMenuAnchor(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: toRem(220), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{sectionNames.map((name) => {
|
||||
const inSection = roomSections.includes(name);
|
||||
return (
|
||||
<MenuItem
|
||||
key={name}
|
||||
size="300"
|
||||
radii="300"
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={inSection}
|
||||
after={inSection ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||
onClick={() => handleToggleSection(name)}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{sectionNames.length > 0 && <Line variant="Surface" size="300" />}
|
||||
<Box
|
||||
as="form"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
onSubmit={handleNewSection}
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<Input
|
||||
size="300"
|
||||
variant="Background"
|
||||
radii="300"
|
||||
placeholder="New section…"
|
||||
aria-label="New section name"
|
||||
value={newSectionName}
|
||||
onChange={(e) => {
|
||||
setNewSectionName(e.currentTarget.value);
|
||||
setNewSectionError(undefined);
|
||||
}}
|
||||
after={
|
||||
<IconButton
|
||||
type="submit"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
aria-label="Create section"
|
||||
>
|
||||
<Icon size="100" src={Icons.Plus} />
|
||||
</IconButton>
|
||||
}
|
||||
/>
|
||||
{newSectionError && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{newSectionError}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ChevronRight} />}
|
||||
radii="300"
|
||||
aria-pressed={!!sectionMenuAnchor}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => setSectionMenuAnchor(e.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{roomSections.length > 0
|
||||
? `Sections: ${roomSections.join(', ')}`
|
||||
: 'Add to Section'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</PopOut>
|
||||
<MenuItem
|
||||
onClick={handleInvite}
|
||||
variant="Primary"
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import { useNearViewport } from '../../hooks/useNearViewport';
|
||||
@@ -26,7 +26,8 @@ import { usePan, Pan } from '../../hooks/usePan';
|
||||
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useDecryptedMediaUrl } from '../../hooks/useDecryptedMediaUrl';
|
||||
import { getThumbMxc } from '../../utils/mediaThumb';
|
||||
import { AudioContent, FileDownloadButton } from '../../components/message';
|
||||
import { MediaControl } from '../../components/media';
|
||||
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
@@ -56,67 +57,6 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
|
||||
// ── Decrypt hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||
|
||||
export function useDecryptedMediaUrl(
|
||||
mx: MatrixClient,
|
||||
mxcUrl: string | undefined,
|
||||
encInfo: IEncryptedFile | undefined,
|
||||
useAuthentication: boolean,
|
||||
mimeType?: string,
|
||||
enabled = true,
|
||||
): DecryptState {
|
||||
const [state, setState] = useState<DecryptState>({ status: 'loading' });
|
||||
const prevBlobUrl = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
if (!mxcUrl) {
|
||||
setState({ status: 'error' });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const run = async () => {
|
||||
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
|
||||
if (!httpUrl) throw new Error('bad url');
|
||||
if (encInfo) {
|
||||
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
|
||||
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
|
||||
);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
|
||||
prevBlobUrl.current = blobUrl;
|
||||
setState({ status: 'ok', url: blobUrl });
|
||||
} else {
|
||||
setState({ status: 'ok', url: httpUrl });
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(() => {
|
||||
if (!cancelled) setState({ status: 'error' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -154,12 +94,6 @@ function getSenderName(room: Room, userId: string): string {
|
||||
// the grid and the lightbox must use this so their positional indices stay in
|
||||
// lockstep — otherwise a tile skipped for lack of a thumb would shift the
|
||||
// lightbox and open the wrong media.
|
||||
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
|
||||
const c = mEvent.getContent();
|
||||
const isEnc = !!c.file;
|
||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
|
||||
}
|
||||
|
||||
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -95,7 +95,8 @@ import {
|
||||
createUploadFamilyObserverAtom,
|
||||
} from '../../state/upload';
|
||||
import { getImageUrlBlob, loadImageElement } from '../../utils/dom';
|
||||
import { safeFile } from '../../utils/mimeTypes';
|
||||
import { filesToUploadItems } from '../../utils/uploadItems';
|
||||
import { ReplyMediaThumb, hasReplyMedia } from '../../components/message/ReplyMediaThumb';
|
||||
import { fulfilledPromiseSettledResult } from '../../utils/common';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
@@ -157,6 +158,13 @@ const EmojiBoard = React.lazy(() =>
|
||||
);
|
||||
|
||||
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
|
||||
/** [Gitea #129] Same picked file, allowing for re-wrapped File objects. */
|
||||
const isAutoFocusTarget = (file: TUploadContent, target: File | undefined): boolean =>
|
||||
!!target &&
|
||||
file instanceof File &&
|
||||
file.name === target.name &&
|
||||
file.lastModified === target.lastModified;
|
||||
|
||||
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
|
||||
|
||||
interface RoomInputProps {
|
||||
@@ -225,6 +233,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
|
||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
|
||||
const replyUserID = replyDraft?.userId;
|
||||
// [Gitea #151] The quoted event, for a media thumbnail in the draft preview.
|
||||
const replyDraftEvent = replyDraft ? room.findEventById(replyDraft.eventId) : undefined;
|
||||
|
||||
const powerLevelTags = usePowerLevelTags(room, powerLevels);
|
||||
const creatorsTag = useRoomCreatorsTag();
|
||||
@@ -250,6 +260,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
selectedFiles.map((f) => f.file),
|
||||
);
|
||||
const uploadBoardHandlers = useRef<UploadBoardImperativeHandlers | undefined>(undefined);
|
||||
// [Gitea #129] The file whose caption input should take focus once its card mounts.
|
||||
// (Matched by name + mtime: the metadata strip and safeFile may re-wrap the File.)
|
||||
const autoFocusCaptionRef = useRef<File | undefined>(undefined);
|
||||
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
@@ -279,6 +292,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
|
||||
// [Gitea #103] Privacy: drop tracking params from links on paste and on send.
|
||||
const [stripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
@@ -375,39 +389,17 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const handleFiles = useCallback(
|
||||
async (files: File[]) => {
|
||||
setUploadBoard(true);
|
||||
const safeFiles = files.map(safeFile);
|
||||
const fileItems: TUploadItem[] = [];
|
||||
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encryptFiles = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(safeFiles.map((f) => encryptFile(f))),
|
||||
);
|
||||
encryptFiles.forEach((ef) =>
|
||||
fileItems.push({
|
||||
...ef,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
safeFiles.forEach((f) =>
|
||||
fileItems.push({
|
||||
file: f,
|
||||
originalFile: f,
|
||||
encInfo: undefined,
|
||||
metadata: {
|
||||
markedAsSpoiler: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// [Gitea #129] One image into an empty composer: land in its caption
|
||||
// field so "here's the bug" is one motion (Enter sends, Esc returns).
|
||||
const focusCaption =
|
||||
files.length === 1 && files[0].type.startsWith('image/') && isEmptyEditor(editor);
|
||||
setSelectedFiles({
|
||||
type: 'PUT',
|
||||
item: fileItems,
|
||||
item: await filesToUploadItems(room, files, stripImageMetadata),
|
||||
});
|
||||
if (focusCaption) autoFocusCaptionRef.current = files[0];
|
||||
},
|
||||
[setSelectedFiles, room],
|
||||
[setSelectedFiles, room, stripImageMetadata, editor],
|
||||
);
|
||||
const pickFile = useFilePicker(handleFiles, true);
|
||||
const handleFilePaste = useFilePasteHandler(handleFiles);
|
||||
@@ -1060,6 +1052,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
fileItem={fileItem}
|
||||
setMetadata={handleFileMetadata}
|
||||
onRemove={handleRemoveUpload}
|
||||
autoFocusCaption={isAutoFocusTarget(
|
||||
fileItem.originalFile,
|
||||
autoFocusCaptionRef.current,
|
||||
)}
|
||||
onCaptionSubmit={submit}
|
||||
onCaptionEscape={() => ReactEditor.focus(editor)}
|
||||
/>
|
||||
))}
|
||||
</UploadBoardContent>
|
||||
@@ -1185,9 +1183,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||
{replyDraftEvent && hasReplyMedia(replyDraftEvent) && (
|
||||
<ReplyMediaThumb mEvent={replyDraftEvent} />
|
||||
)}
|
||||
<Text size="T300" truncate>
|
||||
{trimReplyFromBody(replyDraft.body)}
|
||||
</Text>
|
||||
</Box>
|
||||
</ReplyLayout>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -91,9 +91,11 @@ import {
|
||||
reactionOrEditEvent,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
import { GroupCandidate, GroupPlan, planMediaGroups } from '../../utils/mediaGroups';
|
||||
import { MediaGroupGrid, RegroupChip } from './message/MediaGroupGrid';
|
||||
import { Lightbox, getThumbMxc, toLightboxItems } from './MediaGallery';
|
||||
import { Lightbox, toLightboxItems } from './MediaGallery';
|
||||
import { getThumbMxc } from '../../utils/mediaThumb';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { MessageLayout, settingsAtom } from '../../state/settings';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
@@ -477,6 +479,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
|
||||
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
// [Gitea #137] Re-render after "Show separately" (the Set itself is module-level).
|
||||
const [, setSeparatedTick] = useState(0);
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
@@ -1156,13 +1159,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const rShortcode =
|
||||
shortcode ||
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
tick('reaction', hapticFeedback);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room],
|
||||
[mx, room, hapticFeedback],
|
||||
);
|
||||
const handleEdit = useCallback(
|
||||
(editEvtId?: string) => {
|
||||
|
||||
@@ -36,7 +36,7 @@ export function RoomTombstone({ roomId, body, replacementRoomId }: RoomTombstone
|
||||
<Text size="T400">{body || 'This room has been replaced and is no longer active.'}</Text>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(joinState.error as any)?.message ?? 'Failed to join replacement room!'}
|
||||
{(joinState.error as Error | undefined)?.message ?? 'Failed to join replacement room!'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -4,7 +4,8 @@ import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { getThumbMxc, useDecryptedMediaUrl } from '../MediaGallery';
|
||||
import { useDecryptedMediaUrl } from '../../../hooks/useDecryptedMediaUrl';
|
||||
import { getThumbMxc } from '../../../utils/mediaThumb';
|
||||
import { validBlurHash } from '../../../utils/blurHash';
|
||||
import { MATRIX_BLUR_HASH_PROPERTY_NAME } from '../../../../types/matrix/common';
|
||||
import * as css from './MediaGroupGrid.css';
|
||||
|
||||
@@ -214,7 +214,7 @@ export const MessageAllReactionItem = as<
|
||||
return (
|
||||
<>
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={open}
|
||||
@@ -1087,7 +1087,7 @@ export const Message = React.memo(
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
|
||||
const tag = (evt.target as any).tagName;
|
||||
const tag = (evt.target as HTMLElement | null)?.tagName;
|
||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||
evt.preventDefault();
|
||||
if (coarsePointer) {
|
||||
@@ -1133,7 +1133,7 @@ export const Message = React.memo(
|
||||
// The full action menu, shared by the desktop PopOut and the touch
|
||||
// bottom sheet (#166).
|
||||
const menuJSX = (
|
||||
<Menu>
|
||||
<Menu data-message-menu aria-label="Message actions">
|
||||
<Box direction="Column" gap="100" className={css.MessageMenuGroup}>
|
||||
{canSendReaction && (
|
||||
<MenuItem
|
||||
@@ -1155,7 +1155,7 @@ export const Message = React.memo(
|
||||
after={<Icon size="100" src={Icons.ReplyArrow} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: any) => {
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onReplyClick(evt);
|
||||
closeMenu();
|
||||
}}
|
||||
@@ -1232,7 +1232,7 @@ export const Message = React.memo(
|
||||
after={<Icon src={Icons.ThreadPlus} size="100" />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={(evt: any) => {
|
||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onReplyClick(evt, true);
|
||||
closeMenu();
|
||||
}}
|
||||
@@ -1599,7 +1599,7 @@ export const Event = React.memo(
|
||||
const stateEvent = typeof mEvent.getStateKey() === 'string';
|
||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
|
||||
const tag = (evt.target as any).tagName;
|
||||
const tag = (evt.target as HTMLElement | null)?.tagName;
|
||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||
evt.preventDefault();
|
||||
setMenuAnchor({
|
||||
|
||||
@@ -202,7 +202,7 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
)}
|
||||
{reactions.length > 0 && (
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={!!viewer}
|
||||
|
||||
@@ -177,6 +177,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="aside"
|
||||
aria-label="Thread"
|
||||
className={classNames(css.ThreadPanel, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
|
||||
@@ -78,6 +78,7 @@ import { ImageViewer } from '../../../components/image-viewer';
|
||||
import * as css from './ThreadTimeline.css';
|
||||
import { inSameDay, minuteDifference } from '../../../utils/time';
|
||||
import { formatDayDivider } from '../../../utils/formatTimestamp';
|
||||
import { tick } from '../../../utils/haptics';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||
@@ -266,6 +267,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const [encUrlPreview] = useSetting(settingsAtom, 'encUrlPreview');
|
||||
const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview;
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [hapticFeedback] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
@@ -573,6 +575,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const rShortcode =
|
||||
shortcode ||
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
tick('reaction', hapticFeedback);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
// A reaction on the root is a main-timeline event, not a thread reply.
|
||||
@@ -582,7 +585,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room, thread, getRelationTimelineSet],
|
||||
[mx, room, thread, getRelationTimelineSet, hapticFeedback],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
|
||||
@@ -153,7 +153,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
</PageNavHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<PageNavContent>
|
||||
<div style={{ flexGrow: 1 }}>
|
||||
<nav aria-label="Settings sections" style={{ flexGrow: 1 }}>
|
||||
{menuItems.map((item) => (
|
||||
<MenuItem
|
||||
key={item.name}
|
||||
@@ -174,7 +174,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) {
|
||||
</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
</PageNavContent>
|
||||
<Box style={{ padding: config.space.S200 }} shrink="No" direction="Column">
|
||||
<UseStateProvider initial={false}>
|
||||
|
||||
@@ -159,7 +159,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
>
|
||||
<SettingTile
|
||||
title="Clear Cache & Reload"
|
||||
description="Clear all your locally stored data and reload from server."
|
||||
description="Clear all your locally stored data and reload from server. This also deletes this device's encryption keys — older encrypted messages stay unreadable here unless key backup is set up."
|
||||
after={
|
||||
<Button
|
||||
onClick={() => clearCacheAndReload(mx)}
|
||||
|
||||
@@ -47,6 +47,7 @@ import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { stripImageMetadata as stripImageMetadata_ } from '../../../utils/stripImageMetadata';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
@@ -89,7 +90,19 @@ function ProfileAvatar({ profile, userId }: ProfileProps) {
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
// [Gitea #109] Avatars go through the same metadata strip as messages.
|
||||
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||
const pickFile = useFilePicker(
|
||||
useCallback(
|
||||
(file: File) => {
|
||||
(stripImageMetadata ? stripImageMetadata_(file) : Promise.resolve({ file })).then((r) =>
|
||||
setImageFile(r.file),
|
||||
);
|
||||
},
|
||||
[stripImageMetadata],
|
||||
),
|
||||
false,
|
||||
);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
|
||||
@@ -121,6 +121,8 @@ import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||
import { DenoiseTester } from './DenoiseTester';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||||
import { StorageUsage } from './StorageUsage';
|
||||
import { hapticsSupported } from '../../../utils/haptics';
|
||||
|
||||
/**
|
||||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||
@@ -1442,6 +1444,7 @@ function Privacy() {
|
||||
'warnOnUnverifiedDevices',
|
||||
);
|
||||
const [stripTracking, setStripTracking] = useSetting(settingsAtom, 'stripTrackingParams');
|
||||
const [stripImageMeta, setStripImageMeta] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -1453,6 +1456,13 @@ function Privacy() {
|
||||
after={<Switch variant="Primary" value={stripTracking} onChange={setStripTracking} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Remove Photo Metadata Before Sending"
|
||||
description="Strip location, camera model and timestamp (EXIF/XMP) from JPEG, PNG and WebP images you send or set as an avatar. Pixels are untouched; the rotation is kept. Videos and GIFs are not covered."
|
||||
after={<Switch variant="Primary" value={stripImageMeta} onChange={setStripImageMeta} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Hide Typing & Read Receipts"
|
||||
@@ -1643,6 +1653,8 @@ function Calls() {
|
||||
);
|
||||
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [callRejoin, setCallRejoin] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||
const [haptics, setHaptics] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [deafenHotkey, setDeafenHotkey] = useSetting(settingsAtom, 'deafenHotkey');
|
||||
@@ -1938,6 +1950,28 @@ function Calls() {
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="After a Restart"
|
||||
description="If Lotus crashes, reloads or restarts for an update while you are in a voice room, put you back in."
|
||||
after={
|
||||
<SettingsSelect<'ask' | 'auto' | 'off'>
|
||||
value={callRejoin}
|
||||
onChange={setCallRejoin}
|
||||
options={[
|
||||
{ value: 'ask', label: 'Ask' },
|
||||
{ value: 'auto', label: 'Rejoin automatically' },
|
||||
{ value: 'off', label: 'Do nothing' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{hapticsSupported() && (
|
||||
<SettingTile
|
||||
title="Haptic Feedback"
|
||||
description="A short vibration when push-to-talk engages or releases and when you send a reaction. Off automatically when your system prefers reduced motion."
|
||||
after={<Switch variant="Primary" value={haptics} onChange={setHaptics} />}
|
||||
/>
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Talk"
|
||||
description="Mute your microphone by default. Hold the PTT key to speak."
|
||||
@@ -2717,6 +2751,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Editor />
|
||||
<Messages />
|
||||
<Privacy />
|
||||
<StorageUsage />
|
||||
<SettingsSyncSection />
|
||||
<Calls />
|
||||
<AppUpdates />
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Badge, Box, Button, Text } from 'folds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import {
|
||||
percentOf,
|
||||
readStorageUsage,
|
||||
requestPersistentStorage,
|
||||
StorageUsage as Usage,
|
||||
storageUsageSupported,
|
||||
} from '../../../utils/storageUsage';
|
||||
|
||||
/**
|
||||
* [Gitea #120] One tile: how much this device stores for Lotus, whether the
|
||||
* browser has promised to keep it, and a way to ask. Hidden entirely when
|
||||
* `navigator.storage.estimate()` is unavailable rather than showing zeros.
|
||||
*/
|
||||
export function StorageUsage() {
|
||||
const [usage, setUsage] = useState<Usage | undefined>();
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
readStorageUsage()
|
||||
.then(setUsage)
|
||||
.catch(() => setUsage(undefined));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (storageUsageSupported()) refresh();
|
||||
}, [refresh]);
|
||||
|
||||
if (!usage) return null;
|
||||
|
||||
const handlePersist = async () => {
|
||||
setRequesting(true);
|
||||
await requestPersistentStorage();
|
||||
setRequesting(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const breakdown: string[] = [];
|
||||
if (typeof usage.indexedDB === 'number')
|
||||
breakdown.push(
|
||||
`${bytesToSize(usage.indexedDB)} in IndexedDB (sync cache, encryption keys, search index)`,
|
||||
);
|
||||
if (typeof usage.caches === 'number')
|
||||
breakdown.push(`${bytesToSize(usage.caches)} offline app files`);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Storage</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title={`${bytesToSize(usage.usage)} used on this device`}
|
||||
description={
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200">
|
||||
{percentOf(usage.usage, usage.quota)}% of the {bytesToSize(usage.quota)} the browser
|
||||
allows this site.
|
||||
{breakdown.length > 0 && ` ${breakdown.join(' · ')}.`}
|
||||
</Text>
|
||||
<Text size="T200">
|
||||
Images and videos you have viewed are kept in the browser's own cache, which it
|
||||
manages and empties itself; they are not counted here. Encryption keys are never
|
||||
cleared from this page — losing them makes older encrypted messages unreadable on
|
||||
this device. Cached search results can be cleared from Message Search.
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
after={
|
||||
usage.persisted === undefined ? undefined : (
|
||||
<Box direction="Column" alignItems="End" gap="100">
|
||||
<Badge
|
||||
variant={usage.persisted ? 'Success' : 'Warning'}
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="400"
|
||||
>
|
||||
<Text size="L400">{usage.persisted ? 'Protected' : 'May be evicted'}</Text>
|
||||
</Badge>
|
||||
{!usage.persisted && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
outlined
|
||||
disabled={requesting}
|
||||
onClick={handlePersist}
|
||||
>
|
||||
<Text size="B300">Keep my data</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useCallHangupEvent } from './useCallEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { describeCallEnd } from '../utils/callSummary';
|
||||
|
||||
// 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.
|
||||
const SUMMARY_GRACE_MS = 400;
|
||||
|
||||
/**
|
||||
* [Gitea #143] "Call ended · 41 min · connection was good" in the existing
|
||||
* toast style when a call you were in ends. Duration comes from our own
|
||||
* join clock; the quality readout from the fork, if it arrived.
|
||||
*/
|
||||
export function useCallEndedToast(embed: CallEmbed): void {
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const fired = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
fired.current = false;
|
||||
}, [embed]);
|
||||
|
||||
useCallHangupEvent(embed, () => {
|
||||
if (fired.current || !embed.joined || embed.joinedAt === undefined) return;
|
||||
fired.current = true;
|
||||
const durationMs = Date.now() - embed.joinedAt;
|
||||
const toast = () =>
|
||||
setToast({
|
||||
id: `call-ended-${Date.now()}`,
|
||||
displayName: 'Lotus Chat',
|
||||
body: describeCallEnd(durationMs, embed.lastSummary),
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomId: embed.roomId,
|
||||
});
|
||||
if (embed.lastSummary) toast();
|
||||
else setTimeout(toast, SUMMARY_GRACE_MS);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import { MatrixRTCSessionEvent } from 'matrix-js-sdk/lib/matrixrtc';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Icons } from 'folds';
|
||||
import { ElementWidgetActions } from '../plugins/call/types';
|
||||
import { CallEmbed, useCallControlState, useClientWidgetApiEvent } from '../plugins/call';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useSyncState } from './useSyncState';
|
||||
import { useCallStart } from './useCallEmbed';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { toastQueueAtom, dismissToastAtom } from '../state/toast';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
import {
|
||||
CALL_SESSION_HEARTBEAT_MS,
|
||||
clearCallSession,
|
||||
decideRejoin,
|
||||
readCallSession,
|
||||
writeCallSession,
|
||||
} from '../utils/callRejoin';
|
||||
|
||||
const TOAST_ID = 'call-rejoin';
|
||||
/** How long after the first sync to wait for the RTC session to report members. */
|
||||
const MEMBERSHIP_WAIT_MS = 10_000;
|
||||
|
||||
const isSynced = (mx: MatrixClient): boolean => {
|
||||
const state = mx.getSyncState();
|
||||
return state === SyncState.Prepared || state === SyncState.Syncing;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #118] Keep a heartbeat record of the voice room you are in, clear
|
||||
* it on a deliberate hangup, and on the next start put you back (or ask)
|
||||
* when the call is still going and the record is fresh.
|
||||
*/
|
||||
export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): void {
|
||||
const mx = useMatrixClient();
|
||||
const [mode] = useSetting(settingsAtom, 'callRejoinAfterRestart');
|
||||
const { microphone, video } = useCallControlState(embed?.control);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
const dismissToast = useSetAtom(dismissToastAtom);
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const { microphone: prefMic, sound } = useCallPreferences();
|
||||
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
|
||||
const startRoomCall = useCallStart(false);
|
||||
const startDmCall = useCallStart(true);
|
||||
|
||||
// --- record while joined
|
||||
useEffect(() => {
|
||||
if (!embed || !joined) return undefined;
|
||||
const deviceId = mx.getDeviceId() ?? '';
|
||||
const write = () =>
|
||||
writeCallSession({
|
||||
roomId: embed.roomId,
|
||||
deviceId,
|
||||
joinedAt: embed.joinedAt ?? Date.now(),
|
||||
lastSeen: Date.now(),
|
||||
microphone,
|
||||
video,
|
||||
});
|
||||
write();
|
||||
const timer = window.setInterval(write, CALL_SESSION_HEARTBEAT_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [embed, joined, mx, microphone, video]);
|
||||
|
||||
// --- deliberate hangup clears it
|
||||
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.HangupCall, clearCallSession);
|
||||
useClientWidgetApiEvent(embed?.call, ElementWidgetActions.Close, clearCallSession);
|
||||
|
||||
// --- startup: offer / perform a rejoin once
|
||||
// The startup effect must run exactly once and outlive re-renders (its
|
||||
// membership wait spans several seconds), so it reads these through a ref.
|
||||
const latest = useRef({
|
||||
mode,
|
||||
prefMic,
|
||||
sound,
|
||||
cameraOnJoin,
|
||||
directs,
|
||||
startRoomCall,
|
||||
startDmCall,
|
||||
});
|
||||
latest.current = { mode, prefMic, sound, cameraOnJoin, directs, startRoomCall, startDmCall };
|
||||
const checkedRef = useRef(false);
|
||||
const [synced, setSynced] = useState(() => isSynced(mx));
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback(() => {
|
||||
if (isSynced(mx)) setSynced(true);
|
||||
}, [mx]),
|
||||
);
|
||||
useEffect(() => {
|
||||
// Wait for the first sync so the room and its call memberships are known;
|
||||
// deciding earlier would read an empty session and wrongly drop the record.
|
||||
if (checkedRef.current || embed || !synced) return undefined;
|
||||
checkedRef.current = true;
|
||||
const record = readCallSession();
|
||||
if (!record) return undefined;
|
||||
const room = mx.getRoom(record.roomId);
|
||||
if (!room) {
|
||||
clearCallSession();
|
||||
return undefined;
|
||||
}
|
||||
const session = mx.matrixRTC.getRoomSession(room);
|
||||
const rejoin = () => {
|
||||
const l = latest.current;
|
||||
const pref = {
|
||||
microphone: record.microphone ?? l.prefMic,
|
||||
video: l.cameraOnJoin && record.video,
|
||||
sound: l.sound,
|
||||
};
|
||||
dismissToast(TOAST_ID);
|
||||
clearCallSession();
|
||||
try {
|
||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||
} catch {
|
||||
/* no embed container yet — the user can join from the room */
|
||||
}
|
||||
};
|
||||
let done = false;
|
||||
let timer: number | undefined;
|
||||
const evaluate = (final: boolean) => {
|
||||
if (done) return;
|
||||
const memberships = session.memberships;
|
||||
const decision = decideRejoin({
|
||||
record,
|
||||
mode: latest.current.mode,
|
||||
now: Date.now(),
|
||||
myDeviceId: mx.getDeviceId() ?? '',
|
||||
roomJoined: room.getMyMembership() === 'join',
|
||||
ownMemberDevices: memberships
|
||||
.filter((m) => m.userId === mx.getUserId())
|
||||
.map((m) => m.deviceId),
|
||||
memberCount: memberships.length,
|
||||
});
|
||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||
// empty unless this is the final (timed-out) look.
|
||||
if (decision.action === 'none' && memberships.length === 0 && !final) return;
|
||||
done = true;
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
|
||||
if (decision.action === 'none') {
|
||||
clearCallSession();
|
||||
return;
|
||||
}
|
||||
if (decision.action === 'auto') {
|
||||
rejoin();
|
||||
return;
|
||||
}
|
||||
setToast({
|
||||
id: TOAST_ID,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Rejoin voice?',
|
||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
sticky: true,
|
||||
onClick: rejoin,
|
||||
onDismiss: clearCallSession,
|
||||
});
|
||||
};
|
||||
const onChange = () => evaluate(false);
|
||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, onChange);
|
||||
timer = window.setTimeout(() => evaluate(true), MEMBERSHIP_WAIT_MS);
|
||||
evaluate(false);
|
||||
return () => {
|
||||
session.off(MatrixRTCSessionEvent.MembershipsChanged, onChange);
|
||||
if (timer !== undefined) window.clearTimeout(timer);
|
||||
};
|
||||
}, [embed, synced, mx, setToast, dismissToast]);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
import { IEncryptedFile } from '../../types/matrix/common';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../utils/matrix';
|
||||
|
||||
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||
|
||||
export function useDecryptedMediaUrl(
|
||||
mx: MatrixClient,
|
||||
mxcUrl: string | undefined,
|
||||
encInfo: IEncryptedFile | undefined,
|
||||
useAuthentication: boolean,
|
||||
mimeType?: string,
|
||||
enabled = true,
|
||||
): DecryptState {
|
||||
const [state, setState] = useState<DecryptState>({ status: 'loading' });
|
||||
const prevBlobUrl = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return undefined;
|
||||
if (!mxcUrl) {
|
||||
setState({ status: 'error' });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setState({ status: 'loading' });
|
||||
|
||||
const run = async () => {
|
||||
const httpUrl = mxcUrlToHttp(mx, mxcUrl, useAuthentication);
|
||||
if (!httpUrl) throw new Error('bad url');
|
||||
if (encInfo) {
|
||||
const blob = await downloadEncryptedMedia(httpUrl, (buf) =>
|
||||
decryptFile(buf, mimeType ?? 'application/octet-stream', encInfo),
|
||||
);
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
if (cancelled) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
return;
|
||||
}
|
||||
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
|
||||
prevBlobUrl.current = blobUrl;
|
||||
setState({ status: 'ok', url: blobUrl });
|
||||
} else {
|
||||
setState({ status: 'ok', url: httpUrl });
|
||||
}
|
||||
};
|
||||
|
||||
run().catch(() => {
|
||||
if (!cancelled) setState({ status: 'error' });
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, mxcUrl, encInfo, useAuthentication, mimeType, enabled]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (prevBlobUrl.current) URL.revokeObjectURL(prevBlobUrl.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { pttActiveAtom } from './useCallHotkeys';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { tick } from '../utils/haptics';
|
||||
|
||||
/**
|
||||
* [Gitea #125] One observer for every push-to-talk path (keyboard, global
|
||||
* hotkey, on-screen chip): a tick on engage and on release.
|
||||
*/
|
||||
export function usePttHaptics(): void {
|
||||
const active = useAtomValue(pttActiveAtom);
|
||||
const [enabled] = useSetting(settingsAtom, 'hapticFeedback');
|
||||
const prev = useRef(active);
|
||||
useEffect(() => {
|
||||
if (prev.current === active) return;
|
||||
prev.current = active;
|
||||
tick(active ? 'ptt-on' : 'ptt-off', enabled);
|
||||
}, [active, enabled]);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { Icons } from 'folds';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
|
||||
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
||||
|
||||
export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string | undefined => {
|
||||
switch (kind) {
|
||||
case 'ended':
|
||||
return 'Screen sharing stopped — the shared window was closed.';
|
||||
case 'no-frames':
|
||||
return 'Your screen share is showing nothing — the shared window may be minimised or hidden.';
|
||||
case 'alone':
|
||||
return "Still sharing? You've been sharing your screen for 30 minutes with nobody else in the call.";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
||||
* closed, no frames for a while, or a long share with nobody watching.
|
||||
*/
|
||||
export function useScreenshareNotices(embed: CallEmbed): void {
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
useEffect(
|
||||
() =>
|
||||
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
||||
'io.lotus.screenshare_notice',
|
||||
(evt) => {
|
||||
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
|
||||
if (!body) return;
|
||||
setToast({
|
||||
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
||||
iconSrc: Icons.ScreenShare,
|
||||
displayName: 'Screen share',
|
||||
body,
|
||||
roomName: embed.room.name ?? 'Voice call',
|
||||
roomId: embed.roomId,
|
||||
sticky: evt.detail?.data?.kind === 'alone',
|
||||
});
|
||||
},
|
||||
),
|
||||
[embed, setToast],
|
||||
);
|
||||
}
|
||||
@@ -63,10 +63,14 @@ import { UserRoomProfileRenderer } from '../components/UserRoomProfileRenderer';
|
||||
import { HomeCreateRoom } from './client/home/CreateRoom';
|
||||
import { Create } from './client/create';
|
||||
import { getFallbackSession } from '../state/sessions';
|
||||
import { SHARE_PAGE_PATH } from '../../swShare';
|
||||
|
||||
import { RouteError } from './RouteError';
|
||||
import { CallStatusRenderer } from './CallStatusRenderer';
|
||||
import { CallEmbedProvider } from '../components/CallEmbedProvider';
|
||||
|
||||
const Share = React.lazy(() => import('./client/share/Share').then((m) => ({ default: m.Share })));
|
||||
|
||||
const AuthLayout = React.lazy(() => import('./auth').then((m) => ({ default: m.AuthLayout })));
|
||||
const Login = React.lazy(() => import('./auth').then((m) => ({ default: m.Login })));
|
||||
const Register = React.lazy(() => import('./auth').then((m) => ({ default: m.Register })));
|
||||
@@ -380,6 +384,15 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
{/* [Gitea #155] PWA share-target landing page. */}
|
||||
<Route
|
||||
path={SHARE_PAGE_PATH}
|
||||
element={
|
||||
<React.Suspense fallback={null}>
|
||||
<Share />
|
||||
</React.Suspense>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path={INBOX_PATH}
|
||||
element={
|
||||
|
||||
@@ -73,6 +73,7 @@ import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
RoomsNotificationPreferences,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
@@ -80,6 +81,7 @@ import { JoinAddressPrompt } from '../../../components/join-address-prompt';
|
||||
import { _RoomSearchParams } from '../../paths';
|
||||
import { getLocalRoomNamesContent } from '../../../hooks/useRoomMeta';
|
||||
import { useRoomTagsVersion } from '../../../hooks/useRoomTagsVersion';
|
||||
import { RoomSection, deriveRoomSections } from '../../../utils/roomSections';
|
||||
|
||||
type HomeMenuProps = {
|
||||
requestClose: () => void;
|
||||
@@ -217,6 +219,73 @@ function HomeEmpty() {
|
||||
const DEFAULT_CATEGORY_ID = makeNavCategoryId('home', 'room');
|
||||
const FAVORITES_CATEGORY_ID = makeNavCategoryId('home', 'favorite');
|
||||
const LOW_PRIORITY_CATEGORY_ID = makeNavCategoryId('home', 'lowpriority');
|
||||
type HomeCustomSectionProps = {
|
||||
section: RoomSection;
|
||||
closed: boolean;
|
||||
onCategoryClick: MouseEventHandler<HTMLButtonElement>;
|
||||
filterQuery: string;
|
||||
selectedRoomId?: string;
|
||||
roomsWithUnreadSet: Set<string>;
|
||||
notificationPreferences: RoomsNotificationPreferences;
|
||||
};
|
||||
/** [Gitea #108] One collapsible u.* section; mirrors the Favorites block. */
|
||||
function HomeCustomSection({
|
||||
section,
|
||||
closed,
|
||||
onCategoryClick,
|
||||
filterQuery,
|
||||
selectedRoomId,
|
||||
roomsWithUnreadSet,
|
||||
notificationPreferences,
|
||||
}: HomeCustomSectionProps) {
|
||||
const mx = useMatrixClient();
|
||||
const categoryId = makeNavCategoryId('home', section.tag);
|
||||
const items = useMemo(() => {
|
||||
// Open: the tag's own order. Closed: only unread/selected, by activity.
|
||||
const base = closed
|
||||
? [...section.rooms]
|
||||
.sort(factoryRoomIdByActivity(mx))
|
||||
.filter((rId) => roomsWithUnreadSet.has(rId) || rId === selectedRoomId)
|
||||
: section.rooms;
|
||||
if (!filterQuery.trim()) return base;
|
||||
const query = filterQuery.toLowerCase();
|
||||
const localNames = getLocalRoomNamesContent(mx);
|
||||
return base.filter((rId) => {
|
||||
const localName = localNames.rooms[rId];
|
||||
const matrixName = mx.getRoom(rId)?.name ?? '';
|
||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||
});
|
||||
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
||||
// Sections are user-curated and small, so they render plainly — no
|
||||
// virtualizer per section (a late-mounted one never measured its scroller).
|
||||
return (
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
closed={closed}
|
||||
data-category-id={categoryId}
|
||||
onClick={onCategoryClick}
|
||||
>
|
||||
{section.name}
|
||||
</RoomNavCategoryButton>
|
||||
</NavCategoryHeader>
|
||||
{items.map((roomId) => {
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
return (
|
||||
<RoomNavItem
|
||||
key={roomId}
|
||||
room={room}
|
||||
selected={selectedRoomId === roomId}
|
||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</NavCategory>
|
||||
);
|
||||
}
|
||||
|
||||
export function Home() {
|
||||
const mx = useMatrixClient();
|
||||
useNavToActivePathMapper('home');
|
||||
@@ -260,21 +329,34 @@ export function Home() {
|
||||
// once some unrelated event (e.g. an unread-count change) forces a re-render.
|
||||
const roomTagsVersion = useRoomTagsVersion(mx);
|
||||
|
||||
const { favoriteRooms, lowPriorityRooms, otherRooms } = useMemo(() => {
|
||||
const { favoriteRooms, lowPriorityRooms, otherRooms, customSections } = useMemo(() => {
|
||||
const favs: string[] = [];
|
||||
const low: string[] = [];
|
||||
const others: string[] = [];
|
||||
// [Gitea #108] u.* tags → custom sections. A room in a section leaves the
|
||||
// plain "Rooms" list but keeps its Favorite / Low Priority placement.
|
||||
const { sections, sectioned } = deriveRoomSections(
|
||||
rooms.map((rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
return { roomId: rId, name: room?.name ?? rId, tags: room?.tags };
|
||||
}),
|
||||
);
|
||||
rooms.forEach((rId) => {
|
||||
const room = mx.getRoom(rId);
|
||||
if (room?.tags?.['m.favourite']) {
|
||||
favs.push(rId);
|
||||
} else if (room?.tags?.['m.lowpriority']) {
|
||||
low.push(rId);
|
||||
} else {
|
||||
} else if (!sectioned.has(rId)) {
|
||||
others.push(rId);
|
||||
}
|
||||
});
|
||||
return { favoriteRooms: favs, lowPriorityRooms: low, otherRooms: others };
|
||||
return {
|
||||
favoriteRooms: favs,
|
||||
lowPriorityRooms: low,
|
||||
otherRooms: others,
|
||||
customSections: sections,
|
||||
};
|
||||
// roomTagsVersion is a trigger-only counter, not read in the body; it forces
|
||||
// this memo to re-run whenever any room's tags change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -536,6 +618,18 @@ export function Home() {
|
||||
</div>
|
||||
</NavCategory>
|
||||
)}
|
||||
{customSections.map((section) => (
|
||||
<HomeCustomSection
|
||||
key={section.tag}
|
||||
section={section}
|
||||
closed={closedCategories.has(makeNavCategoryId('home', section.tag))}
|
||||
onCategoryClick={handleCategoryClick}
|
||||
filterQuery={filterQuery}
|
||||
selectedRoomId={selectedRoomId}
|
||||
roomsWithUnreadSet={roomsWithUnreadSet}
|
||||
notificationPreferences={notificationPreferences}
|
||||
/>
|
||||
))}
|
||||
<NavCategory>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Icon,
|
||||
Icons,
|
||||
Input,
|
||||
MenuItem,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue, useStore } from 'jotai';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import {
|
||||
roomIdToMsgDraftAtomFamily,
|
||||
roomIdToUploadItemsAtomFamily,
|
||||
} from '../../../state/room/roomInputDrafts';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { filesToUploadItems } from '../../../utils/uploadItems';
|
||||
import {
|
||||
clearSharedPayload,
|
||||
readSharedPayload,
|
||||
shareDraftText,
|
||||
SharePayload,
|
||||
} from '../../../../swShare';
|
||||
import { BlockType } from '../../../components/editor';
|
||||
import { bytesToSize } from '../../../utils/common';
|
||||
import { getHomePath } from '../../pathUtils';
|
||||
|
||||
type Shared = { payload: SharePayload; files: File[] };
|
||||
|
||||
/**
|
||||
* [Gitea #155] Landing page for the PWA share target: what was shared, pick
|
||||
* a room, and the files land on that room's composer upload board (text/url
|
||||
* as the draft) — the user still presses Send.
|
||||
*/
|
||||
export function Share() {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [shared, setShared] = useState<Shared | null | undefined>(undefined);
|
||||
const [query, setQuery] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [stripImageMetadata] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||
|
||||
useEffect(() => {
|
||||
readSharedPayload()
|
||||
.then((s) => setShared(s ?? null))
|
||||
.catch(() => setShared(null));
|
||||
}, []);
|
||||
|
||||
const rooms = useMemo(() => {
|
||||
const all = mx
|
||||
.getRooms()
|
||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
||||
const q = query.trim().toLowerCase();
|
||||
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
|
||||
}, [mx, query]);
|
||||
|
||||
// Written straight into the room's draft atoms so its composer shows the
|
||||
// upload board + text as soon as it mounts.
|
||||
const store = useStore();
|
||||
const pick = useCallback(
|
||||
async (room: Room) => {
|
||||
if (!shared || busy) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (shared.files.length) {
|
||||
store.set(roomIdToUploadItemsAtomFamily(room.roomId), {
|
||||
type: 'PUT',
|
||||
item: await filesToUploadItems(room, shared.files, stripImageMetadata),
|
||||
});
|
||||
}
|
||||
const text = shareDraftText(shared.payload);
|
||||
if (text) {
|
||||
store.set(roomIdToMsgDraftAtomFamily(room.roomId), [
|
||||
{ type: BlockType.Paragraph, children: [{ text }] },
|
||||
]);
|
||||
}
|
||||
await clearSharedPayload();
|
||||
navigateRoom(room.roomId);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
},
|
||||
[shared, busy, navigateRoom, store, stripImageMetadata],
|
||||
);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon size="400" src={Icons.Send} />
|
||||
<Text size="H3" truncate>
|
||||
Share to Lotus Chat
|
||||
</Text>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
{shared === undefined && (
|
||||
<Box justifyContent="Center" style={{ padding: config.space.S500 }}>
|
||||
<Spinner size="400" />
|
||||
</Box>
|
||||
)}
|
||||
{shared === null && (
|
||||
<Box direction="Column" gap="300" alignItems="Start">
|
||||
<Text size="T300">
|
||||
Nothing was shared, or it has already been placed in a room.
|
||||
</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => navigate(getHomePath())}
|
||||
>
|
||||
<Text size="B300">Go home</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{shared && (
|
||||
<Box direction="Column" gap="400">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Shared</Text>
|
||||
{shared.files.map((f) => (
|
||||
<Box key={f.name} alignItems="Center" gap="200">
|
||||
<Icon
|
||||
size="100"
|
||||
src={f.type.startsWith('image/') ? Icons.Photo : Icons.File}
|
||||
/>
|
||||
<Text size="T300" truncate>
|
||||
{f.name}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{bytesToSize(f.size)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
{shareDraftText(shared.payload) && (
|
||||
<Text size="T300" style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
|
||||
{shareDraftText(shared.payload)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">Send to</Text>
|
||||
<Input
|
||||
size="400"
|
||||
variant="Background"
|
||||
radii="400"
|
||||
placeholder="Search rooms"
|
||||
value={query}
|
||||
onChange={(evt) => setQuery(evt.currentTarget.value)}
|
||||
autoFocus
|
||||
/>
|
||||
<Box direction="Column" gap="100">
|
||||
{rooms.slice(0, 60).map((room) => (
|
||||
<ShareRoomRow
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
dm={directs.has(room.roomId)}
|
||||
useAuthentication={useAuthentication}
|
||||
disabled={busy}
|
||||
onPick={pick}
|
||||
/>
|
||||
))}
|
||||
{rooms.length === 0 && (
|
||||
<Text size="T300" priority="300">
|
||||
No rooms match.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
function ShareRoomRow({
|
||||
room,
|
||||
dm,
|
||||
useAuthentication,
|
||||
disabled,
|
||||
onPick,
|
||||
}: {
|
||||
room: Room;
|
||||
dm: boolean;
|
||||
useAuthentication: boolean;
|
||||
disabled: boolean;
|
||||
onPick: (room: Room) => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
return (
|
||||
<MenuItem
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Background"
|
||||
disabled={disabled}
|
||||
onClick={() => onPick(room)}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
{room.name}
|
||||
</Text>
|
||||
{dm && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
Direct Message
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
@@ -378,7 +378,7 @@ export function SpaceTombstone({ roomId, replacementRoomId }: SpaceTombstoneProp
|
||||
<Text size="T200">This space has been replaced and is no longer active.</Text>
|
||||
{joinState.status === AsyncStatus.Error && (
|
||||
<Text className={BreakWord} style={{ color: color.Critical.Main }} size="T200">
|
||||
{(joinState.error as any)?.message ?? 'Failed to join replacement space!'}
|
||||
{(joinState.error as Error | undefined)?.message ?? 'Failed to join replacement space!'}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -191,6 +191,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
public resendForkState(): void {
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
if (this._audioOutputId !== undefined) this.sendAudioOutput(this._audioOutputId);
|
||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||
}
|
||||
@@ -277,6 +278,24 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// P6-2: send deafen state to the fork (io.lotus.set_deafen). Join-gated: the
|
||||
// fork's handler only exists once joined; onCallJoined() re-sends the current
|
||||
// state so a pre-join deafen is not lost.
|
||||
// [Gitea #119] Output device (headset ↔ speakers) chosen from the host's
|
||||
// call bar; the fork applies it with mediaDevices.audioOutput.select().
|
||||
private _audioOutputId: string | undefined;
|
||||
|
||||
public get audioOutputId(): string | undefined {
|
||||
return this._audioOutputId;
|
||||
}
|
||||
|
||||
public setAudioOutput(deviceId: string): void {
|
||||
this._audioOutputId = deviceId;
|
||||
this.sendAudioOutput(deviceId);
|
||||
}
|
||||
|
||||
private sendAudioOutput(deviceId: string): void {
|
||||
if (!this.joined) return;
|
||||
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
||||
}
|
||||
|
||||
private sendDeafenState(): void {
|
||||
if (!this.joined) return;
|
||||
this.call.transport
|
||||
|
||||
@@ -49,6 +49,14 @@ export interface LotusCallParticipant {
|
||||
speakingWhileMuted?: boolean;
|
||||
}
|
||||
|
||||
/** [Gitea #143] The fork's one-shot io.lotus.call_summary payload. */
|
||||
export interface LotusCallSummary {
|
||||
durationMs: number;
|
||||
reconnects: number;
|
||||
poorMs: number;
|
||||
verdict: 'good' | 'fair' | 'poor' | 'unknown';
|
||||
}
|
||||
|
||||
export class CallEmbed {
|
||||
private mx: MatrixClient;
|
||||
|
||||
@@ -60,6 +68,12 @@ export class CallEmbed {
|
||||
|
||||
public joined = false;
|
||||
|
||||
/** [Gitea #143] When the first JoinCall landed, for the hangup readout. */
|
||||
public joinedAt: number | undefined;
|
||||
|
||||
/** [Gitea #143] The fork's end-of-call summary, once it arrives. */
|
||||
public lastSummary: LotusCallSummary | undefined;
|
||||
|
||||
// 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.
|
||||
public disposed = false;
|
||||
@@ -354,7 +368,7 @@ export class CallEmbed {
|
||||
return this.listenEvent('preparing', callback);
|
||||
}
|
||||
|
||||
public onPreparingError(callback: (error: any) => void) {
|
||||
public onPreparingError(callback: (error: unknown) => void) {
|
||||
return this.listenEvent('error:preparing', callback);
|
||||
}
|
||||
|
||||
@@ -390,6 +404,19 @@ export class CallEmbed {
|
||||
this.forkStateRequestListeners.forEach((l) => l());
|
||||
}),
|
||||
);
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.call_summary', (evt) => {
|
||||
const data = (evt.detail as { data?: Partial<LotusCallSummary> } | undefined)?.data;
|
||||
if (data && typeof data.durationMs === 'number') {
|
||||
this.lastSummary = {
|
||||
durationMs: data.durationMs,
|
||||
reconnects: data.reconnects ?? 0,
|
||||
poorMs: data.poorMs ?? 0,
|
||||
verdict: data.verdict ?? 'unknown',
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.call_state', (evt) => {
|
||||
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
||||
@@ -537,6 +564,7 @@ export class CallEmbed {
|
||||
return;
|
||||
}
|
||||
this.joined = true;
|
||||
this.joinedAt = Date.now();
|
||||
// EC ignores io.element.device_mute before join; re-apply desired state now that EC is live
|
||||
this.control.forceState(this.initialState);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { clearRecentGifs } from './recentGifs';
|
||||
import { clearRecentStickers } from './recentStickers';
|
||||
import { clearNavToActivePathStore } from './navToActivePath';
|
||||
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
||||
import { clearCallSession } from '../utils/callRejoin';
|
||||
|
||||
/**
|
||||
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
|
||||
@@ -92,6 +93,7 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearRecentGifs();
|
||||
clearRecentStickers();
|
||||
clearMsgDrafts();
|
||||
clearCallSession();
|
||||
clearStatusMessage();
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -15,6 +15,8 @@ export type TUploadMetadata = {
|
||||
compressImage?: boolean;
|
||||
/** Cached compression result (populated in the background when compressImage is set to true) */
|
||||
compressionResult?: CompressionResult | null;
|
||||
/** [Gitea #109] EXIF/XMP/IPTC was removed from this image before upload. */
|
||||
metadataStripped?: boolean;
|
||||
};
|
||||
|
||||
export type TUploadItem = {
|
||||
|
||||
@@ -268,6 +268,13 @@ export interface Settings {
|
||||
// [Gitea #103] Remove utm_/fbclid/… tracking params from links you paste or
|
||||
// send, and from links rendered in the timeline. Local only.
|
||||
stripTrackingParams: boolean;
|
||||
// [Gitea #109] Drop EXIF/XMP/IPTC (GPS, camera, timestamp) from JPEG/PNG/WebP
|
||||
// uploads without re-encoding. Default on.
|
||||
stripImageMetadata: boolean;
|
||||
// [Gitea #118] After a crash/update/reload while in a voice room: ask, rejoin, or nothing.
|
||||
callRejoinAfterRestart: 'ask' | 'auto' | 'off';
|
||||
// [Gitea #125] Vibration ticks on PTT press/release and reactions (Android only).
|
||||
hapticFeedback: boolean;
|
||||
|
||||
// [Gitea #104] Mirror user preferences to `io.lotus.settings` account data
|
||||
// so other devices pick them up. Device-local itself (utils/settingsSync).
|
||||
@@ -390,6 +397,9 @@ const defaultSettings: Settings = {
|
||||
warnOnUnverifiedDevices: false,
|
||||
|
||||
stripTrackingParams: true,
|
||||
stripImageMetadata: true,
|
||||
callRejoinAfterRestart: 'ask',
|
||||
hapticFeedback: true,
|
||||
|
||||
settingsSync: true,
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 633 B |
Binary file not shown.
|
After Width: | Height: | Size: 841 B |
Binary file not shown.
|
After Width: | Height: | Size: 368 B |
Binary file not shown.
|
After Width: | Height: | Size: 268 B |
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
@@ -0,0 +1,56 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
|
||||
|
||||
const base: RejoinContext = {
|
||||
record: {
|
||||
roomId: '!r',
|
||||
deviceId: 'DEV',
|
||||
joinedAt: 900_000,
|
||||
lastSeen: 1_000_000,
|
||||
microphone: true,
|
||||
video: false,
|
||||
},
|
||||
mode: 'ask',
|
||||
now: 1_060_000,
|
||||
myDeviceId: 'DEV',
|
||||
roomJoined: true,
|
||||
ownMemberDevices: [],
|
||||
memberCount: 2,
|
||||
};
|
||||
|
||||
describe('decideRejoin', () => {
|
||||
it('asks by default when the call is still going', () => {
|
||||
assert.deepEqual(decideRejoin(base), { action: 'ask', roomId: '!r' });
|
||||
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
|
||||
});
|
||||
|
||||
it('does nothing when off, stale, another device, or no record', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, mode: 'off' }), { action: 'none' });
|
||||
assert.deepEqual(decideRejoin({ ...base, record: undefined }), { action: 'none' });
|
||||
assert.deepEqual(decideRejoin({ ...base, now: 1_000_000 + CALL_REJOIN_MAX_AGE_MS + 1 }), {
|
||||
action: 'none',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, myDeviceId: 'OTHER' }), { action: 'none' });
|
||||
assert.deepEqual(decideRejoin({ ...base, roomJoined: false }), { action: 'none' });
|
||||
});
|
||||
|
||||
it('skips when we already rejoined from another device', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['PHONE'], memberCount: 2 }), {
|
||||
action: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips when only our own stale membership is left', () => {
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 1 }), {
|
||||
action: 'none',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: [], memberCount: 0 }), {
|
||||
action: 'none',
|
||||
});
|
||||
assert.deepEqual(decideRejoin({ ...base, ownMemberDevices: ['DEV'], memberCount: 2 }), {
|
||||
action: 'ask',
|
||||
roomId: '!r',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* [Gitea #118] Remember the voice room you were in so a crash, update or
|
||||
* reload can put you back. Device-local (localStorage), never synced;
|
||||
* cleared on a deliberate hangup and on logout.
|
||||
*/
|
||||
|
||||
export const CALL_SESSION_KEY = 'lotus-call-session';
|
||||
/** A record older than this (by last heartbeat) is stale — the call is over. */
|
||||
export const CALL_REJOIN_MAX_AGE_MS = 10 * 60_000;
|
||||
export const CALL_SESSION_HEARTBEAT_MS = 30_000;
|
||||
|
||||
export type CallSessionRecord = {
|
||||
roomId: string;
|
||||
deviceId: string;
|
||||
joinedAt: number;
|
||||
/** Refreshed while joined so a crash leaves a recent timestamp behind. */
|
||||
lastSeen: number;
|
||||
microphone: boolean;
|
||||
video: boolean;
|
||||
};
|
||||
|
||||
export type CallRejoinMode = 'ask' | 'auto' | 'off';
|
||||
|
||||
export const readCallSession = (): CallSessionRecord | undefined => {
|
||||
try {
|
||||
const raw = localStorage.getItem(CALL_SESSION_KEY);
|
||||
if (!raw) return undefined;
|
||||
const r = JSON.parse(raw) as Partial<CallSessionRecord>;
|
||||
if (typeof r.roomId !== 'string' || typeof r.deviceId !== 'string') return undefined;
|
||||
return {
|
||||
roomId: r.roomId,
|
||||
deviceId: r.deviceId,
|
||||
joinedAt: Number(r.joinedAt) || 0,
|
||||
lastSeen: Number(r.lastSeen) || 0,
|
||||
microphone: r.microphone !== false,
|
||||
video: r.video === true,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const writeCallSession = (record: CallSessionRecord): void => {
|
||||
try {
|
||||
localStorage.setItem(CALL_SESSION_KEY, JSON.stringify(record));
|
||||
} catch {
|
||||
/* quota / private mode */
|
||||
}
|
||||
};
|
||||
|
||||
export const clearCallSession = (): void => {
|
||||
try {
|
||||
localStorage.removeItem(CALL_SESSION_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
export type RejoinContext = {
|
||||
record: CallSessionRecord | undefined;
|
||||
mode: CallRejoinMode;
|
||||
now: number;
|
||||
myDeviceId: string;
|
||||
/** Whether the room still exists and we are joined to it. */
|
||||
roomJoined: boolean;
|
||||
/** Device ids of our own live call memberships in that room (other tabs/devices). */
|
||||
ownMemberDevices: string[];
|
||||
/** Number of call memberships in the room, ours included. */
|
||||
memberCount: number;
|
||||
};
|
||||
|
||||
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
|
||||
|
||||
/** Pure: whether startup should offer (or perform) a rejoin. */
|
||||
export function decideRejoin(ctx: RejoinContext): RejoinDecision {
|
||||
const { record, mode, now, myDeviceId } = ctx;
|
||||
if (!record || mode === 'off') return { action: 'none' };
|
||||
if (record.deviceId !== myDeviceId) return { action: 'none' };
|
||||
if (now - record.lastSeen > CALL_REJOIN_MAX_AGE_MS) return { action: 'none' };
|
||||
if (!ctx.roomJoined) return { action: 'none' };
|
||||
// Already back in from another device/tab — don't double-join.
|
||||
if (ctx.ownMemberDevices.some((d) => d !== myDeviceId)) return { action: 'none' };
|
||||
// Nobody there any more (our own stale membership doesn't count).
|
||||
const others = ctx.memberCount - ctx.ownMemberDevices.length;
|
||||
if (others <= 0) return { action: 'none' };
|
||||
return { action: mode, roomId: record.roomId };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeCallDuration, describeCallEnd } from './callSummary';
|
||||
|
||||
describe('describeCallDuration', () => {
|
||||
it('picks the unit', () => {
|
||||
assert.equal(describeCallDuration(40_000), '40 s');
|
||||
assert.equal(describeCallDuration(41 * 60_000), '41 min');
|
||||
assert.equal(describeCallDuration(65 * 60_000), '1 h 05 min');
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeCallEnd', () => {
|
||||
const min = 60_000;
|
||||
it('without a summary shows just the duration', () => {
|
||||
assert.equal(describeCallEnd(41 * min), 'Call ended · 41 min');
|
||||
});
|
||||
it('good / unknown', () => {
|
||||
assert.equal(
|
||||
describeCallEnd(41 * min, {
|
||||
durationMs: 41 * min,
|
||||
reconnects: 1,
|
||||
poorMs: 0,
|
||||
verdict: 'good',
|
||||
}),
|
||||
'Call ended · 41 min · connection was good',
|
||||
);
|
||||
assert.equal(
|
||||
describeCallEnd(2 * min, {
|
||||
durationMs: 2 * min,
|
||||
reconnects: 0,
|
||||
poorMs: 0,
|
||||
verdict: 'unknown',
|
||||
}),
|
||||
'Call ended · 2 min',
|
||||
);
|
||||
});
|
||||
it('reconnects and poor spells', () => {
|
||||
assert.equal(
|
||||
describeCallEnd(41 * min, {
|
||||
durationMs: 41 * min,
|
||||
reconnects: 3,
|
||||
poorMs: 0,
|
||||
verdict: 'fair',
|
||||
}),
|
||||
'Call ended · 41 min · 3 reconnects',
|
||||
);
|
||||
assert.equal(
|
||||
describeCallEnd(12 * min, {
|
||||
durationMs: 12 * min,
|
||||
reconnects: 1,
|
||||
poorMs: 4 * min,
|
||||
verdict: 'poor',
|
||||
}),
|
||||
'Call ended · 12 min · 1 reconnect, connection was poor for 4 min',
|
||||
);
|
||||
assert.equal(
|
||||
describeCallEnd(12 * min, {
|
||||
durationMs: 12 * min,
|
||||
reconnects: 0,
|
||||
poorMs: 2_000,
|
||||
verdict: 'fair',
|
||||
}),
|
||||
'Call ended · 12 min · connection was fair',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LotusCallSummary } from '../plugins/call/CallEmbed';
|
||||
|
||||
/** "41 min", "1 h 05 min", "40 s". */
|
||||
export const describeCallDuration = (ms: number): string => {
|
||||
const s = Math.round(ms / 1000);
|
||||
if (s < 60) return `${s} s`;
|
||||
const m = Math.round(s / 60);
|
||||
if (m < 60) return `${m} min`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h} h ${String(m % 60).padStart(2, '0')} min`;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #143] One line for the call-ended toast: duration (cinny's own
|
||||
* clock) plus the fork's verdict when it arrived in time.
|
||||
* "Call ended · 41 min · connection was good"
|
||||
* "Call ended · 41 min · 3 reconnects"
|
||||
* "Call ended · 12 min · connection was poor for 4 min"
|
||||
*/
|
||||
export function describeCallEnd(durationMs: number, summary?: LotusCallSummary): string {
|
||||
const parts = ['Call ended', describeCallDuration(durationMs)];
|
||||
if (summary) {
|
||||
if (summary.verdict === 'good') parts.push('connection was good');
|
||||
else if (summary.verdict === 'fair' || summary.verdict === 'poor') {
|
||||
const bits: string[] = [];
|
||||
if (summary.reconnects > 0)
|
||||
bits.push(`${summary.reconnects} reconnect${summary.reconnects === 1 ? '' : 's'}`);
|
||||
if (summary.poorMs >= 5_000)
|
||||
bits.push(`connection was poor for ${describeCallDuration(summary.poorMs)}`);
|
||||
parts.push(bits.length ? bits.join(', ') : `connection was ${summary.verdict}`);
|
||||
}
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { hapticsSupported, tick } from './haptics';
|
||||
|
||||
// Node 22 exposes a read-only `globalThis.navigator`; swap it via defineProperty.
|
||||
const original = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
const setGlobal = (name: 'navigator' | 'window', value: unknown) =>
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
const restore = () => {
|
||||
if (original) Object.defineProperty(globalThis, 'navigator', original);
|
||||
else delete (globalThis as { navigator?: unknown }).navigator;
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
};
|
||||
|
||||
test('tick is a no-op without the API, with the setting off, or under reduced motion', () => {
|
||||
setGlobal('navigator', {});
|
||||
assert.equal(hapticsSupported(), false);
|
||||
assert.equal(tick('ptt-on'), false);
|
||||
|
||||
const calls: number[] = [];
|
||||
setGlobal('navigator', { vibrate: (ms: number) => calls.push(ms) === 1 });
|
||||
setGlobal('window', { matchMedia: () => ({ matches: true }) });
|
||||
assert.equal(tick('ptt-on'), false);
|
||||
assert.deepEqual(calls, []);
|
||||
|
||||
setGlobal('window', { matchMedia: () => ({ matches: false }) });
|
||||
assert.equal(tick('ptt-on', false), false);
|
||||
assert.deepEqual(calls, []);
|
||||
restore();
|
||||
});
|
||||
|
||||
test('tick vibrates per kind when allowed', () => {
|
||||
const calls: number[] = [];
|
||||
setGlobal('navigator', { vibrate: (ms: number) => calls.push(ms) > 0 });
|
||||
setGlobal('window', { matchMedia: () => ({ matches: false }) });
|
||||
assert.equal(tick('ptt-on'), true);
|
||||
assert.equal(tick('ptt-off'), true);
|
||||
assert.equal(tick('reaction'), true);
|
||||
assert.deepEqual(calls, [10, 10, 8]);
|
||||
restore();
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* [Gitea #125] Tiny vibration ticks for touch feedback. Android Chrome/PWA
|
||||
* only — iOS has no web vibration API — and nothing at all when the user
|
||||
* prefers reduced motion. Every call is a cheap no-op elsewhere.
|
||||
*/
|
||||
export type HapticKind = 'ptt-on' | 'ptt-off' | 'reaction';
|
||||
|
||||
const PATTERN: Record<HapticKind, number> = { 'ptt-on': 10, 'ptt-off': 10, reaction: 8 };
|
||||
|
||||
export const hapticsSupported = (): boolean =>
|
||||
typeof navigator !== 'undefined' && typeof navigator.vibrate === 'function';
|
||||
|
||||
const reducedMotion = (): boolean =>
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.matchMedia === 'function' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
/** Fire a tick; returns whether one was requested. */
|
||||
export function tick(kind: HapticKind, enabled = true): boolean {
|
||||
if (!enabled || !hapticsSupported() || reducedMotion()) return false;
|
||||
try {
|
||||
return navigator.vibrate(PATTERN[kind]) === true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,9 @@ export const isMLDenoiseSupported = (): boolean => {
|
||||
// 1. AudioContext/webkitAudioContext (Web Audio API)
|
||||
// 2. AudioWorklet (Real-time processing in a background thread)
|
||||
// 3. getUserMedia (Microphone access)
|
||||
const hasAudioContext = !!(window.AudioContext || (window as any).webkitAudioContext);
|
||||
const hasAudioContext = !!(
|
||||
window.AudioContext || (window as Window & { webkitAudioContext?: unknown }).webkitAudioContext
|
||||
);
|
||||
// Use `typeof` rather than `!!AudioWorkletNode`: on a browser that has
|
||||
// AudioContext but no AudioWorkletNode binding (older Safari/Edge), a bare
|
||||
// reference throws ReferenceError, making this feature-detection helper throw
|
||||
|
||||
@@ -81,3 +81,18 @@ test('block and inline mixed with text', () => {
|
||||
{ type: 'text', value: ' ok' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('math inside a backtick code span stays literal text (#194 P4-4)', () => {
|
||||
assert.deepEqual(splitMathSegments('inline `$y^2$` here'), [
|
||||
{ type: 'text', value: 'inline `$y^2$` here' },
|
||||
]);
|
||||
assert.deepEqual(splitMathSegments('``$a$`` and $b$'), [
|
||||
{ type: 'text', value: '``$a$`` and ' },
|
||||
{ type: 'inline', value: 'b' },
|
||||
]);
|
||||
// An unclosed backtick is just a character.
|
||||
assert.deepEqual(splitMathSegments('tick ` then $c$'), [
|
||||
{ type: 'text', value: 'tick ` then ' },
|
||||
{ type: 'inline', value: 'c' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -88,6 +88,23 @@ export const splitMathSegments = (text: string): MathSegment[] => {
|
||||
};
|
||||
|
||||
while (i < text.length) {
|
||||
// Backtick code spans are literal: `$x$` inside them must stay text so
|
||||
// markdown's inline code wins (a run of N backticks closes with N).
|
||||
if (text[i] === '`') {
|
||||
let ticks = 0;
|
||||
while (text[i + ticks] === '`') ticks += 1;
|
||||
const fence = '`'.repeat(ticks);
|
||||
const close = text.indexOf(fence, i + ticks);
|
||||
if (close !== -1) {
|
||||
buffer += text.slice(i, close + ticks);
|
||||
i = close + ticks;
|
||||
continue;
|
||||
}
|
||||
buffer += fence;
|
||||
i += ticks;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Escaped dollar: consume `\$` and emit a literal `$` as text.
|
||||
if (text[i] === '\\' && text[i + 1] === '$') {
|
||||
buffer += '$';
|
||||
|
||||
@@ -72,7 +72,10 @@ export const getLoginTermUrl = (params: UIAParams): string | undefined => {
|
||||
if (terms.policies === null) return undefined;
|
||||
if ('privacy_policy' in terms.policies && typeof terms.policies.privacy_policy === 'object') {
|
||||
if (terms.policies.privacy_policy === null) return undefined;
|
||||
const langToPolicy = terms.policies.privacy_policy as Record<string, any>;
|
||||
const langToPolicy = terms.policies.privacy_policy as Record<
|
||||
string,
|
||||
{ url?: string } | undefined
|
||||
>;
|
||||
const url = langToPolicy.en?.url;
|
||||
if (typeof url === 'string') return url;
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { MatrixEvent } from 'matrix-js-sdk';
|
||||
import { IImageInfo, IThumbnailContent } from '../../types/matrix/common';
|
||||
|
||||
/** The mxc to show as a thumbnail for an image/video event (thumbnail if present, else the file). */
|
||||
export function getThumbMxc(mEvent: MatrixEvent): string | undefined {
|
||||
const c = mEvent.getContent();
|
||||
const isEnc = !!c.file;
|
||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||
return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { deriveRoomSections, sectionTag, validateSectionName } from './roomSections';
|
||||
|
||||
describe('deriveRoomSections', () => {
|
||||
it('groups by u.* tags, orders members by order then name, sections by name', () => {
|
||||
const { sections, sectioned } = deriveRoomSections([
|
||||
{ roomId: '!a', name: 'Alpha', tags: { 'u.Raids': { order: 0.7 } } },
|
||||
{ roomId: '!b', name: 'Beta', tags: { 'u.Raids': { order: 0.2 }, 'm.favourite': {} } },
|
||||
{ roomId: '!c', name: 'Gamma', tags: { 'u.Raids': {}, 'u.off-topic': { order: '0.1' } } },
|
||||
{ roomId: '!d', name: 'Delta', tags: { 'm.lowpriority': {} } },
|
||||
{ roomId: '!e', name: 'Eps', tags: undefined },
|
||||
]);
|
||||
assert.deepEqual(
|
||||
sections.map((s) => [s.name, s.tag, s.rooms]),
|
||||
[
|
||||
['off-topic', 'u.off-topic', ['!c']],
|
||||
['Raids', 'u.Raids', ['!b', '!a', '!c']],
|
||||
],
|
||||
);
|
||||
assert.deepEqual([...sectioned].sort(), ['!a', '!b', '!c']);
|
||||
});
|
||||
|
||||
it('has no empty sections', () => {
|
||||
assert.deepEqual(deriveRoomSections([{ roomId: '!a', name: 'A', tags: {} }]).sections, []);
|
||||
});
|
||||
|
||||
it('ignores m.* and malformed u. tags', () => {
|
||||
const { sections } = deriveRoomSections([
|
||||
{ roomId: '!a', name: 'A', tags: { 'm.favourite': {}, 'u.': {}, 'x.y': {} } },
|
||||
]);
|
||||
assert.deepEqual(sections, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('section names', () => {
|
||||
it('builds the tag and validates input', () => {
|
||||
assert.equal(sectionTag(' Raids '), 'u.Raids');
|
||||
assert.equal(validateSectionName('Raids', []), undefined);
|
||||
assert.ok(validateSectionName('', []));
|
||||
assert.ok(validateSectionName('raids', ['Raids']));
|
||||
assert.ok(validateSectionName('a.b', []));
|
||||
assert.ok(validateSectionName('x'.repeat(41), []));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { MatrixClient } from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* [Gitea #108] User-defined sidebar sections from standard `u.<name>` room
|
||||
* tags, so they sync across devices and other clients see the same tags.
|
||||
*/
|
||||
export const SECTION_TAG_PREFIX = 'u.';
|
||||
|
||||
export type RoomSection = {
|
||||
/** Display name ("Raids"). */
|
||||
name: string;
|
||||
/** Full tag name ("u.Raids"). */
|
||||
tag: string;
|
||||
/** Member room ids, by the tag's `order` (ascending), then by name. */
|
||||
rooms: string[];
|
||||
};
|
||||
|
||||
export type TaggedRoom = {
|
||||
roomId: string;
|
||||
name: string;
|
||||
tags: Record<string, { order?: number | string } | undefined> | undefined;
|
||||
};
|
||||
|
||||
export const sectionTag = (name: string): string => `${SECTION_TAG_PREFIX}${name.trim()}`;
|
||||
export const isSectionTag = (tag: string): boolean =>
|
||||
tag.startsWith(SECTION_TAG_PREFIX) && tag.length > SECTION_TAG_PREFIX.length;
|
||||
export const sectionName = (tag: string): string => tag.slice(SECTION_TAG_PREFIX.length);
|
||||
|
||||
const orderOf = (v: { order?: number | string } | undefined): number => {
|
||||
const n = typeof v?.order === 'string' ? parseFloat(v.order) : v?.order;
|
||||
return typeof n === 'number' && Number.isFinite(n) ? n : 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sections (alphabetical by name) with their members ordered by tag `order`
|
||||
* then name; `sectioned` is the set of rooms that appear in any section.
|
||||
* Empty sections simply don't exist — a section is its members' tags.
|
||||
*/
|
||||
export function deriveRoomSections(rooms: TaggedRoom[]): {
|
||||
sections: RoomSection[];
|
||||
sectioned: Set<string>;
|
||||
} {
|
||||
const byTag = new Map<string, { roomId: string; order: number; name: string }[]>();
|
||||
const sectioned = new Set<string>();
|
||||
rooms.forEach((room) => {
|
||||
Object.entries(room.tags ?? {}).forEach(([tag, value]) => {
|
||||
if (!isSectionTag(tag)) return;
|
||||
const list = byTag.get(tag) ?? [];
|
||||
list.push({ roomId: room.roomId, order: orderOf(value), name: room.name });
|
||||
byTag.set(tag, list);
|
||||
sectioned.add(room.roomId);
|
||||
});
|
||||
});
|
||||
const sections = [...byTag.entries()]
|
||||
.map(([tag, members]) => ({
|
||||
tag,
|
||||
name: sectionName(tag),
|
||||
rooms: members
|
||||
.sort((a, b) => a.order - b.order || a.name.localeCompare(b.name))
|
||||
.map((m) => m.roomId),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
|
||||
return { sections, sectioned };
|
||||
}
|
||||
|
||||
/** Every section name in use across the user's rooms (for the "Add to section" menu). */
|
||||
export function listSectionNames(mx: MatrixClient): string[] {
|
||||
const names = new Set<string>();
|
||||
mx.getRooms().forEach((room) => {
|
||||
Object.keys(room.tags ?? {}).forEach((tag) => {
|
||||
if (isSectionTag(tag)) names.add(sectionName(tag));
|
||||
});
|
||||
});
|
||||
return [...names].sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
|
||||
}
|
||||
|
||||
/** Validate a new section name; returns the error to show, or undefined. */
|
||||
export const validateSectionName = (raw: string, existing: string[]): string | undefined => {
|
||||
const name = raw.trim();
|
||||
if (!name) return 'Give the section a name.';
|
||||
if (name.length > 40) return 'Keep it under 40 characters.';
|
||||
if (name.includes('.')) return 'Dots are not allowed in section names.';
|
||||
if (existing.some((e) => e.toLowerCase() === name.toLowerCase()))
|
||||
return 'A section with that name already exists.';
|
||||
return undefined;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { describeRoomVisibility } from './roomVisibilityLine';
|
||||
|
||||
test('describeRoomVisibility composes the three facts', () => {
|
||||
assert.equal(
|
||||
describeRoomVisibility({ encrypted: true, joinRule: 'invite', historyVisibility: 'joined' }),
|
||||
'Encrypted · Invite only · History visible to members since they joined',
|
||||
);
|
||||
assert.equal(
|
||||
describeRoomVisibility({ encrypted: false, joinRule: 'public', historyVisibility: 'shared' }),
|
||||
'Not encrypted · Anyone can join · History visible to members, including before they joined',
|
||||
);
|
||||
assert.equal(
|
||||
describeRoomVisibility({
|
||||
encrypted: false,
|
||||
joinRule: 'knock',
|
||||
historyVisibility: 'world_readable',
|
||||
}),
|
||||
'Not encrypted · Ask to join · History visible to anyone, even guests',
|
||||
);
|
||||
// Missing state: spec defaults are invite-only and "shared".
|
||||
assert.equal(
|
||||
describeRoomVisibility({ encrypted: true }),
|
||||
'Encrypted · Invite only · History visible to members, including before they joined',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HistoryVisibility, JoinRule } from 'matrix-js-sdk';
|
||||
|
||||
/**
|
||||
* [Gitea #133] One sentence for Room Settings → General, under the name:
|
||||
* "Encrypted · Invite only · History visible to members since they joined".
|
||||
* Wording mirrors the room preview card chips and the history-visibility
|
||||
* setting so the same words mean the same thing everywhere.
|
||||
*/
|
||||
export function describeRoomVisibility(input: {
|
||||
encrypted: boolean;
|
||||
joinRule?: string;
|
||||
historyVisibility?: string;
|
||||
}): string {
|
||||
const parts: string[] = [input.encrypted ? 'Encrypted' : 'Not encrypted'];
|
||||
switch (input.joinRule) {
|
||||
case JoinRule.Public:
|
||||
parts.push('Anyone can join');
|
||||
break;
|
||||
case JoinRule.Knock:
|
||||
case 'knock_restricted':
|
||||
parts.push('Ask to join');
|
||||
break;
|
||||
case JoinRule.Restricted:
|
||||
parts.push('Restricted');
|
||||
break;
|
||||
case JoinRule.Private:
|
||||
parts.push('Private');
|
||||
break;
|
||||
case JoinRule.Invite:
|
||||
default:
|
||||
parts.push('Invite only');
|
||||
}
|
||||
switch (input.historyVisibility) {
|
||||
case HistoryVisibility.WorldReadable:
|
||||
parts.push('History visible to anyone, even guests');
|
||||
break;
|
||||
case HistoryVisibility.Invited:
|
||||
parts.push('History visible to members since they were invited');
|
||||
break;
|
||||
case HistoryVisibility.Joined:
|
||||
parts.push('History visible to members since they joined');
|
||||
break;
|
||||
case HistoryVisibility.Shared:
|
||||
default:
|
||||
// "shared" is also the spec default when the state event is absent.
|
||||
parts.push('History visible to members, including before they joined');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* [Gitea #120] What this device stores for Lotus, from the APIs that are cheap
|
||||
* to ask. `estimate()` is the total the browser accounts for; Chromium adds a
|
||||
* per-bucket breakdown (`usageDetails`), Firefox/Safari don't. Media files are
|
||||
* not in here at all: they live in the browser's own HTTP cache, which manages
|
||||
* and evicts them itself.
|
||||
*/
|
||||
export type StorageUsage = {
|
||||
usage: number;
|
||||
quota: number;
|
||||
/** Chromium only: IndexedDB (sync store, crypto store, search index) bytes. */
|
||||
indexedDB?: number;
|
||||
/** Chromium only: Cache Storage (offline app shell) bytes. */
|
||||
caches?: number;
|
||||
/** Whether the browser granted persistent storage (won't evict under pressure). */
|
||||
persisted: boolean | undefined;
|
||||
};
|
||||
|
||||
type EstimateWithDetails = StorageEstimate & {
|
||||
usageDetails?: { indexedDB?: number; caches?: number; serviceWorkerRegistrations?: number };
|
||||
};
|
||||
|
||||
export const storageUsageSupported = (): boolean =>
|
||||
typeof navigator !== 'undefined' && !!navigator.storage && !!navigator.storage.estimate;
|
||||
|
||||
export async function readStorageUsage(): Promise<StorageUsage | undefined> {
|
||||
if (!storageUsageSupported()) return undefined;
|
||||
const est = (await navigator.storage.estimate()) as EstimateWithDetails;
|
||||
let persisted: boolean | undefined;
|
||||
try {
|
||||
persisted = navigator.storage.persisted ? await navigator.storage.persisted() : undefined;
|
||||
} catch {
|
||||
persisted = undefined;
|
||||
}
|
||||
return {
|
||||
usage: est.usage ?? 0,
|
||||
quota: est.quota ?? 0,
|
||||
indexedDB: est.usageDetails?.indexedDB,
|
||||
caches: est.usageDetails?.caches,
|
||||
persisted,
|
||||
};
|
||||
}
|
||||
|
||||
/** Ask the browser to keep this origin's storage; returns the new state. */
|
||||
export async function requestPersistentStorage(): Promise<boolean | undefined> {
|
||||
if (!navigator.storage?.persist) return undefined;
|
||||
try {
|
||||
return await navigator.storage.persist();
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const percentOf = (usage: number, quota: number): number =>
|
||||
quota > 0 ? Math.min(100, Math.round((usage / quota) * 100)) : 0;
|
||||
@@ -0,0 +1,125 @@
|
||||
/* eslint-disable no-bitwise -- byte-level container parsing */
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
buildOrientationApp1,
|
||||
readJpegOrientation,
|
||||
stripJpeg,
|
||||
stripPng,
|
||||
stripWebp,
|
||||
stripImageMetadataBytes,
|
||||
} from './stripImageMetadata';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = (name: string) =>
|
||||
new Uint8Array(fs.readFileSync(path.join(here, '__fixtures__', name)));
|
||||
const has = (bytes: Uint8Array, text: string) =>
|
||||
Buffer.from(bytes).includes(Buffer.from(text, 'latin1'));
|
||||
|
||||
/** Walk JPEG segments up to SOS; returns marker list (sanity: structure intact). */
|
||||
const jpegMarkers = (b: Uint8Array): string[] => {
|
||||
const out: string[] = [];
|
||||
let pos = 2;
|
||||
while (pos + 4 <= b.length && b[pos] === 0xff) {
|
||||
const m = b[pos + 1];
|
||||
out.push(m.toString(16));
|
||||
if (m === 0xda) break;
|
||||
pos += 2 + ((b[pos + 2] << 8) | b[pos + 3]);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
describe('stripJpeg', () => {
|
||||
it('removes GPS, model and timestamp, keeps a valid JPEG', () => {
|
||||
const src = fixture('gps.jpg');
|
||||
assert.ok(has(src, 'LotusPhone 9'));
|
||||
const { out, stripped } = stripJpeg(src);
|
||||
assert.equal(stripped, true);
|
||||
assert.ok(!has(out, 'LotusPhone 9'));
|
||||
assert.ok(!has(out, '2026:09:20'));
|
||||
assert.ok(out.length < src.length);
|
||||
assert.deepEqual([out[0], out[1]], [0xff, 0xd8]);
|
||||
assert.deepEqual([out[out.length - 2], out[out.length - 1]], [0xff, 0xd9]);
|
||||
assert.ok(jpegMarkers(out).includes('da'));
|
||||
// Pixel data untouched: everything from SOS on is byte-identical.
|
||||
const sos = (b: Uint8Array) => Buffer.from(b).indexOf(Buffer.from([0xff, 0xda]));
|
||||
assert.deepEqual(Buffer.from(out.subarray(sos(out))), Buffer.from(src.subarray(sos(src))));
|
||||
});
|
||||
|
||||
it('keeps a non-default orientation in a minimal EXIF block', () => {
|
||||
const { out, orientation } = stripJpeg(fixture('gps.jpg'));
|
||||
assert.equal(orientation, 6);
|
||||
const markers = jpegMarkers(out);
|
||||
assert.ok(markers.includes('e1'));
|
||||
// Find the APP1 we wrote and read the orientation back.
|
||||
let pos = 2;
|
||||
let found: number | undefined;
|
||||
while (pos + 4 <= out.length && out[pos] === 0xff) {
|
||||
const len = (out[pos + 2] << 8) | out[pos + 3];
|
||||
if (out[pos + 1] === 0xe1) {
|
||||
found = readJpegOrientation(out.subarray(pos + 4, pos + 2 + len));
|
||||
assert.equal(len, buildOrientationApp1(6).length - 2);
|
||||
}
|
||||
if (out[pos + 1] === 0xda) break;
|
||||
pos += 2 + len;
|
||||
}
|
||||
assert.equal(found, 6);
|
||||
assert.ok(!has(out, 'LotusPhone 9'));
|
||||
});
|
||||
|
||||
it('drops EXIF and the comment segment when orientation is 1 (no EXIF written back)', () => {
|
||||
const src = fixture('plain.jpg');
|
||||
assert.ok(has(src, 'made with love'));
|
||||
const { out, stripped, orientation } = stripJpeg(src);
|
||||
assert.equal(stripped, true);
|
||||
assert.equal(orientation, undefined);
|
||||
assert.ok(!has(out, 'made with love'));
|
||||
assert.ok(!has(out, 'Cam'));
|
||||
assert.ok(!jpegMarkers(out).includes('e1'));
|
||||
});
|
||||
|
||||
it('returns the input untouched when there is nothing to strip', () => {
|
||||
const src = fixture('clean.jpg');
|
||||
const { out, stripped } = stripJpeg(src);
|
||||
assert.equal(stripped, false);
|
||||
assert.equal(out, src);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripPng', () => {
|
||||
it('drops eXIf and text chunks, keeps IHDR/IDAT/IEND', () => {
|
||||
const src = fixture('meta.png');
|
||||
assert.ok(has(src, 'xmpmeta'));
|
||||
const { out, stripped } = stripPng(src);
|
||||
assert.equal(stripped, true);
|
||||
assert.ok(!has(out, 'xmpmeta'));
|
||||
assert.ok(!has(out, 'LotusPhone'));
|
||||
assert.ok(!has(out, 'eXIf'));
|
||||
assert.ok(has(out, 'IHDR') && has(out, 'IDAT') && has(out, 'IEND'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripWebp', () => {
|
||||
it('drops the EXIF chunk, clears the VP8X flag and fixes the RIFF size', () => {
|
||||
const src = fixture('meta.webp');
|
||||
assert.ok(has(src, 'LotusPhone'));
|
||||
const { out, stripped } = stripWebp(src);
|
||||
assert.equal(stripped, true);
|
||||
assert.ok(!has(out, 'LotusPhone'));
|
||||
assert.ok(!has(out, 'EXIF'));
|
||||
const riff = out[4] | (out[5] << 8) | (out[6] << 16) | (out[7] << 24);
|
||||
assert.equal(riff, out.length - 8);
|
||||
const vp8x = Buffer.from(out).indexOf(Buffer.from('VP8X'));
|
||||
if (vp8x !== -1) assert.equal(out[vp8x + 8] & 0x0c, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripImageMetadataBytes', () => {
|
||||
it('passes unknown types through', () => {
|
||||
const b = new Uint8Array([1, 2, 3]);
|
||||
assert.deepEqual(stripImageMetadataBytes(b, 'image/gif'), { out: b, stripped: false });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,250 @@
|
||||
/* eslint-disable no-bitwise -- byte-level container parsing */
|
||||
/**
|
||||
* [Gitea #109] Remove EXIF / XMP / IPTC metadata from an image WITHOUT
|
||||
* re-encoding its pixels, so phone photos stop carrying GPS coordinates,
|
||||
* device model and timestamps into the room. JPEG, PNG and WebP are handled
|
||||
* at the container level; anything else is returned untouched.
|
||||
*
|
||||
* JPEG orientation: a bare strip would un-rotate photos whose pixels are
|
||||
* stored sideways, so when the EXIF Orientation tag is not 1 a minimal EXIF
|
||||
* block carrying only that tag is written back.
|
||||
*/
|
||||
|
||||
export type StripResult = {
|
||||
blob: Blob;
|
||||
/** Whether any metadata segment was actually removed. */
|
||||
stripped: boolean;
|
||||
/** EXIF orientation that was preserved (JPEG only), if any. */
|
||||
orientation?: number;
|
||||
};
|
||||
|
||||
const EXIF_HEADER = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00]; // "Exif\0\0"
|
||||
|
||||
/** Read the EXIF Orientation tag (1–8) from an APP1 payload, or undefined. */
|
||||
export function readJpegOrientation(app1: Uint8Array): number | undefined {
|
||||
if (app1.length < 14) return undefined;
|
||||
for (let i = 0; i < 6; i += 1) if (app1[i] !== EXIF_HEADER[i]) return undefined;
|
||||
const tiff = 6;
|
||||
const le = app1[tiff] === 0x49 && app1[tiff + 1] === 0x49;
|
||||
const view = new DataView(app1.buffer, app1.byteOffset, app1.byteLength);
|
||||
const u16 = (o: number) => view.getUint16(o, le);
|
||||
const u32 = (o: number) => view.getUint32(o, le);
|
||||
if (u16(tiff + 2) !== 0x2a) return undefined;
|
||||
const ifd0 = tiff + u32(tiff + 4);
|
||||
if (ifd0 + 2 > app1.length) return undefined;
|
||||
const count = u16(ifd0);
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
const entry = ifd0 + 2 + i * 12;
|
||||
if (entry + 12 > app1.length) return undefined;
|
||||
if (u16(entry) === 0x0112) {
|
||||
const value = u16(entry + 8);
|
||||
return value >= 1 && value <= 8 ? value : undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A minimal APP1 segment: EXIF header + TIFF + one IFD0 entry (Orientation). */
|
||||
export function buildOrientationApp1(orientation: number): Uint8Array {
|
||||
// TIFF (big-endian): "MM", 0x002A, IFD offset 8; IFD0: count 1, entry, next 0.
|
||||
const tiff = new Uint8Array([
|
||||
0x4d,
|
||||
0x4d,
|
||||
0x00,
|
||||
0x2a,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08,
|
||||
0x00,
|
||||
0x01,
|
||||
0x01,
|
||||
0x12,
|
||||
0x00,
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
orientation & 0xff,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
const payload = new Uint8Array(EXIF_HEADER.length + tiff.length);
|
||||
payload.set(EXIF_HEADER, 0);
|
||||
payload.set(tiff, EXIF_HEADER.length);
|
||||
const seg = new Uint8Array(4 + payload.length);
|
||||
seg[0] = 0xff;
|
||||
seg[1] = 0xe1;
|
||||
const len = payload.length + 2;
|
||||
seg[2] = (len >> 8) & 0xff;
|
||||
seg[3] = len & 0xff;
|
||||
seg.set(payload, 4);
|
||||
return seg;
|
||||
}
|
||||
|
||||
/** JPEG: drop APP1 (EXIF/XMP), APP13 (IPTC), COM; keep JFIF/ICC and the image. */
|
||||
export function stripJpeg(bytes: Uint8Array): {
|
||||
out: Uint8Array;
|
||||
stripped: boolean;
|
||||
orientation?: number;
|
||||
} {
|
||||
if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8)
|
||||
return { out: bytes, stripped: false };
|
||||
const parts: Uint8Array[] = [bytes.subarray(0, 2)];
|
||||
let pos = 2;
|
||||
let stripped = false;
|
||||
let orientation: number | undefined;
|
||||
let insertedOrientation = false;
|
||||
while (pos + 4 <= bytes.length) {
|
||||
if (bytes[pos] !== 0xff) break;
|
||||
const marker = bytes[pos + 1];
|
||||
// Start of scan: everything from here to the end is entropy-coded data.
|
||||
if (marker === 0xda) break;
|
||||
const len = (bytes[pos + 2] << 8) | bytes[pos + 3];
|
||||
const segEnd = pos + 2 + len;
|
||||
if (len < 2 || segEnd > bytes.length) break;
|
||||
const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;
|
||||
if (drop) {
|
||||
stripped = true;
|
||||
if (marker === 0xe1 && orientation === undefined) {
|
||||
orientation = readJpegOrientation(bytes.subarray(pos + 4, segEnd));
|
||||
}
|
||||
} else {
|
||||
parts.push(bytes.subarray(pos, segEnd));
|
||||
}
|
||||
pos = segEnd;
|
||||
// Put the orientation-only EXIF right after SOI/JFIF, before anything else.
|
||||
if (!insertedOrientation && orientation !== undefined && orientation !== 1 && marker !== 0xe0) {
|
||||
insertedOrientation = true;
|
||||
parts.push(buildOrientationApp1(orientation));
|
||||
}
|
||||
}
|
||||
if (!stripped) return { out: bytes, stripped: false };
|
||||
if (orientation !== undefined && orientation !== 1 && !insertedOrientation) {
|
||||
parts.push(buildOrientationApp1(orientation));
|
||||
}
|
||||
parts.push(bytes.subarray(pos));
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let o = 0;
|
||||
parts.forEach((p) => {
|
||||
out.set(p, o);
|
||||
o += p.length;
|
||||
});
|
||||
return { out, stripped, orientation: orientation !== 1 ? orientation : undefined };
|
||||
}
|
||||
|
||||
const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
const PNG_DROP = new Set(['eXIf', 'tEXt', 'iTXt', 'zTXt', 'tIME']);
|
||||
|
||||
/** PNG: drop eXIf and text chunks (XMP lives in iTXt); keep colour chunks. */
|
||||
export function stripPng(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } {
|
||||
if (bytes.length < 8) return { out: bytes, stripped: false };
|
||||
for (let i = 0; i < 8; i += 1)
|
||||
if (bytes[i] !== PNG_SIG[i]) return { out: bytes, stripped: false };
|
||||
const parts: Uint8Array[] = [bytes.subarray(0, 8)];
|
||||
let pos = 8;
|
||||
let stripped = false;
|
||||
while (pos + 8 <= bytes.length) {
|
||||
const len =
|
||||
((bytes[pos] << 24) | (bytes[pos + 1] << 16) | (bytes[pos + 2] << 8) | bytes[pos + 3]) >>> 0;
|
||||
const type = String.fromCharCode(
|
||||
bytes[pos + 4],
|
||||
bytes[pos + 5],
|
||||
bytes[pos + 6],
|
||||
bytes[pos + 7],
|
||||
);
|
||||
const end = pos + 12 + len;
|
||||
if (end > bytes.length) break;
|
||||
if (PNG_DROP.has(type)) stripped = true;
|
||||
else parts.push(bytes.subarray(pos, end));
|
||||
pos = end;
|
||||
}
|
||||
if (!stripped) return { out: bytes, stripped: false };
|
||||
parts.push(bytes.subarray(pos));
|
||||
const total = parts.reduce((n, p) => n + p.length, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let o = 0;
|
||||
parts.forEach((p) => {
|
||||
out.set(p, o);
|
||||
o += p.length;
|
||||
});
|
||||
return { out, stripped };
|
||||
}
|
||||
|
||||
const fourcc = (b: Uint8Array, o: number) =>
|
||||
String.fromCharCode(b[o], b[o + 1], b[o + 2], b[o + 3]);
|
||||
|
||||
/** WebP: drop EXIF/XMP chunks and clear their VP8X flags; fix the RIFF size. */
|
||||
export function stripWebp(bytes: Uint8Array): { out: Uint8Array; stripped: boolean } {
|
||||
if (bytes.length < 12 || fourcc(bytes, 0) !== 'RIFF' || fourcc(bytes, 8) !== 'WEBP') {
|
||||
return { out: bytes, stripped: false };
|
||||
}
|
||||
const chunks: Uint8Array[] = [];
|
||||
let pos = 12;
|
||||
let stripped = false;
|
||||
let vp8x: Uint8Array | undefined;
|
||||
while (pos + 8 <= bytes.length) {
|
||||
const id = fourcc(bytes, pos);
|
||||
const len =
|
||||
(bytes[pos + 4] | (bytes[pos + 5] << 8) | (bytes[pos + 6] << 16) | (bytes[pos + 7] << 24)) >>>
|
||||
0;
|
||||
const end = pos + 8 + len + (len % 2);
|
||||
if (pos + 8 + len > bytes.length) break;
|
||||
if (id === 'EXIF' || id === 'XMP ') stripped = true;
|
||||
else {
|
||||
const chunk = bytes.slice(pos, Math.min(end, bytes.length));
|
||||
if (id === 'VP8X') vp8x = chunk;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
pos = end;
|
||||
}
|
||||
if (!stripped) return { out: bytes, stripped: false };
|
||||
// VP8X flags byte: bit 3 = EXIF, bit 2 = XMP (0x08 / 0x04).
|
||||
if (vp8x && vp8x.length >= 9) vp8x[8] &= ~0x0c;
|
||||
const body = chunks.reduce((n, c) => n + c.length, 0);
|
||||
const out = new Uint8Array(12 + body);
|
||||
out.set(bytes.subarray(0, 12), 0);
|
||||
const riffSize = 4 + body;
|
||||
out[4] = riffSize & 0xff;
|
||||
out[5] = (riffSize >> 8) & 0xff;
|
||||
out[6] = (riffSize >> 16) & 0xff;
|
||||
out[7] = (riffSize >> 24) & 0xff;
|
||||
let o = 12;
|
||||
chunks.forEach((c) => {
|
||||
out.set(c, o);
|
||||
o += c.length;
|
||||
});
|
||||
return { out, stripped };
|
||||
}
|
||||
|
||||
export const STRIPPABLE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
|
||||
|
||||
export function stripImageMetadataBytes(
|
||||
bytes: Uint8Array,
|
||||
mimeType: string,
|
||||
): { out: Uint8Array; stripped: boolean; orientation?: number } {
|
||||
if (mimeType === 'image/jpeg') return stripJpeg(bytes);
|
||||
if (mimeType === 'image/png') return stripPng(bytes);
|
||||
if (mimeType === 'image/webp') return stripWebp(bytes);
|
||||
return { out: bytes, stripped: false };
|
||||
}
|
||||
|
||||
/** Strip a File/Blob; returns the same object when nothing changed or the type is unsupported. */
|
||||
export async function stripImageMetadata(file: File): Promise<StripResult & { file: File }> {
|
||||
if (!STRIPPABLE_TYPES.includes(file.type)) return { blob: file, file, stripped: false };
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const { out, stripped, orientation } = stripImageMetadataBytes(bytes, file.type);
|
||||
if (!stripped) return { blob: file, file, stripped: false };
|
||||
const next = new File([out as BlobPart], file.name, {
|
||||
type: file.type,
|
||||
lastModified: file.lastModified,
|
||||
});
|
||||
return { blob: next, file: next, stripped: true, orientation };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { TUploadItem } from '../state/room/roomInputDrafts';
|
||||
import { encryptFile } from './matrix';
|
||||
import { safeFile } from './mimeTypes';
|
||||
import { fulfilledPromiseSettledResult } from './common';
|
||||
import { stripImageMetadata } from './stripImageMetadata';
|
||||
|
||||
/**
|
||||
* Files → composer upload items for `room`, encrypting first when the room
|
||||
* is E2EE. Shared by the composer's pick/drop/paste paths and the PWA share
|
||||
* target (#155).
|
||||
*/
|
||||
export async function filesToUploadItems(
|
||||
room: Room,
|
||||
files: File[],
|
||||
stripMetadata = true,
|
||||
): Promise<TUploadItem[]> {
|
||||
const prepared = await Promise.all(
|
||||
files.map(async (raw) => {
|
||||
const f = safeFile(raw);
|
||||
// [Gitea #109] Drop EXIF/XMP/IPTC before anything else sees the bytes
|
||||
// (including encryption), so what's uploaded never carried them.
|
||||
const { file, stripped } = stripMetadata
|
||||
? await stripImageMetadata(f)
|
||||
: { file: f, stripped: false };
|
||||
return {
|
||||
file,
|
||||
metadata: { markedAsSpoiler: false, metadataStripped: stripped || undefined },
|
||||
};
|
||||
}),
|
||||
);
|
||||
if (room.hasEncryptionStateEvent()) {
|
||||
const encrypted = fulfilledPromiseSettledResult(
|
||||
await Promise.allSettled(
|
||||
prepared.map(async ({ file, metadata }) => ({ ...(await encryptFile(file)), metadata })),
|
||||
),
|
||||
);
|
||||
return encrypted;
|
||||
}
|
||||
return prepared.map(({ file, metadata }) => ({
|
||||
file,
|
||||
originalFile: file,
|
||||
encInfo: undefined,
|
||||
metadata,
|
||||
}));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/// <reference lib="WebWorker" />
|
||||
|
||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||
import { sendNotificationReply } from './swReply';
|
||||
|
||||
export type {};
|
||||
@@ -62,7 +63,7 @@ async function cleanupDeadClients() {
|
||||
});
|
||||
}
|
||||
|
||||
function setSession(clientId: string, accessToken: any, baseUrl: any) {
|
||||
function setSession(clientId: string, accessToken: string, baseUrl: string) {
|
||||
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
||||
sessions.set(clientId, { accessToken, baseUrl });
|
||||
} else {
|
||||
@@ -234,6 +235,16 @@ function fetchConfig(token: string): RequestInit {
|
||||
self.addEventListener('fetch', (event: FetchEvent) => {
|
||||
const { url, method } = event.request;
|
||||
|
||||
// [Gitea #155] Android share sheet → POST /share-target (manifest
|
||||
// share_target). Stash the form and bounce to the in-app /share page.
|
||||
if (method === 'POST' && new URL(url).pathname.endsWith(SHARE_TARGET_PATH)) {
|
||||
const base = new URL(url).pathname.slice(0, -SHARE_TARGET_PATH.length);
|
||||
event.respondWith(
|
||||
stashSharedForm(event.request, base).catch(() => Response.redirect(`${base}/`, 303)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method !== 'GET' || !mediaPath(url)) return;
|
||||
|
||||
const { clientId } = event;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { shareDraftText } from './swShare';
|
||||
|
||||
const base = { files: [], receivedAt: 0 };
|
||||
|
||||
test('shareDraftText joins title, text and url', () => {
|
||||
assert.equal(
|
||||
shareDraftText({ ...base, title: 'A page', text: 'look at this', url: 'https://x.test/p' }),
|
||||
'A page\nlook at this\nhttps://x.test/p',
|
||||
);
|
||||
});
|
||||
|
||||
test('shareDraftText drops a title/url the text already contains', () => {
|
||||
assert.equal(
|
||||
shareDraftText({
|
||||
...base,
|
||||
title: 'A page',
|
||||
text: 'A page https://x.test/p',
|
||||
url: 'https://x.test/p',
|
||||
}),
|
||||
'A page https://x.test/p',
|
||||
);
|
||||
assert.equal(shareDraftText({ ...base, text: 'from the share sheet' }), 'from the share sheet');
|
||||
assert.equal(shareDraftText({ ...base, url: 'https://x.test' }), 'https://x.test');
|
||||
assert.equal(shareDraftText(base), '');
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* [Gitea #155] Web Share Target plumbing shared by the service worker and the
|
||||
* app. Android's share sheet POSTs a multipart form to /share-target (see
|
||||
* public/manifest.json); the worker stashes the payload in the Cache API and
|
||||
* redirects to the in-app /share page, which reads it back, lets the user
|
||||
* pick a room and pre-attaches the files to that room's composer.
|
||||
*/
|
||||
|
||||
export const SHARE_TARGET_PATH = '/share-target';
|
||||
export const SHARE_PAGE_PATH = '/share';
|
||||
export const SHARE_CACHE = 'lotus-share-target';
|
||||
export const SHARE_PAYLOAD_KEY = '/share-target/payload';
|
||||
export const shareFileKey = (index: number) => `/share-target/file/${index}`;
|
||||
|
||||
export type SharePayload = {
|
||||
title?: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
files: { name: string; type: string; size: number }[];
|
||||
receivedAt: number;
|
||||
};
|
||||
|
||||
/** Worker side: stash the POSTed form and answer with a redirect to /share. */
|
||||
export async function stashSharedForm(request: Request, base: string): Promise<Response> {
|
||||
const form = await request.formData();
|
||||
const files = form.getAll('files').filter((f): f is File => f instanceof File);
|
||||
const str = (k: string) => {
|
||||
const v = form.get(k);
|
||||
return typeof v === 'string' && v.trim() ? v : undefined;
|
||||
};
|
||||
const payload: SharePayload = {
|
||||
title: str('title'),
|
||||
text: str('text'),
|
||||
url: str('url'),
|
||||
files: files.map((f) => ({ name: f.name, type: f.type, size: f.size })),
|
||||
receivedAt: Date.now(),
|
||||
};
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
await Promise.all([...(await cache.keys()).map((k) => cache.delete(k))]);
|
||||
await cache.put(
|
||||
SHARE_PAYLOAD_KEY,
|
||||
new Response(JSON.stringify(payload), { headers: { 'Content-Type': 'application/json' } }),
|
||||
);
|
||||
await Promise.all(
|
||||
files.map((f, i) =>
|
||||
cache.put(
|
||||
shareFileKey(i),
|
||||
new Response(f, {
|
||||
headers: { 'Content-Type': f.type || 'application/octet-stream' },
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
return Response.redirect(`${base}${SHARE_PAGE_PATH}`, 303);
|
||||
}
|
||||
|
||||
/** The share sheet's title/text/url as one draft; title and url are dropped when the text already carries them. */
|
||||
export const shareDraftText = (p: SharePayload): string => {
|
||||
const text = p.text?.trim() ?? '';
|
||||
const keep = (s: string | undefined) => !!s && s.trim() !== '' && !text.includes(s.trim());
|
||||
return [keep(p.title) ? p.title : '', text, keep(p.url) ? p.url : ''].filter(Boolean).join('\n');
|
||||
};
|
||||
|
||||
/** App side: read what the worker stashed (undefined when there is nothing). */
|
||||
export async function readSharedPayload(): Promise<
|
||||
{ payload: SharePayload; files: File[] } | undefined
|
||||
> {
|
||||
if (typeof caches === 'undefined') return undefined;
|
||||
const cache = await caches.open(SHARE_CACHE);
|
||||
const res = await cache.match(SHARE_PAYLOAD_KEY);
|
||||
if (!res) return undefined;
|
||||
const payload = (await res.json()) as SharePayload;
|
||||
const files = await Promise.all(
|
||||
payload.files.map(async (meta, i) => {
|
||||
const r = await cache.match(shareFileKey(i));
|
||||
const blob = r ? await r.blob() : new Blob();
|
||||
return new File([blob], meta.name || `shared-${i + 1}`, {
|
||||
type: meta.type || blob.type,
|
||||
});
|
||||
}),
|
||||
);
|
||||
return { payload, files };
|
||||
}
|
||||
|
||||
export async function clearSharedPayload(): Promise<void> {
|
||||
if (typeof caches === 'undefined') return;
|
||||
await caches.delete(SHARE_CACHE);
|
||||
}
|
||||
Reference in New Issue
Block a user