Compare commits
24
Commits
2bdb2eb4cb
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bed70d335 | ||
|
|
c7aa4b9b19 | ||
|
|
81777ddfe1 | ||
|
|
eb4b88a028 | ||
|
|
e3883e0fce | ||
|
|
98c80f36cb | ||
|
|
ad1cbcf792 | ||
|
|
492b57c60d | ||
|
|
0c45bde832 | ||
|
|
082b8fc879 | ||
|
|
c6c2e88df5 | ||
|
|
dc0d524989 | ||
|
|
22d46a7922 | ||
|
|
be2c202543 | ||
|
|
6363939654 | ||
|
|
af244bba75 | ||
|
|
af1c0ee184 | ||
|
|
96a97a2f86 | ||
|
|
6aa77552b8 | ||
|
|
52e0cfaa83 | ||
|
|
bd8c79e0e6 | ||
|
|
4d4a76214a | ||
|
|
8d11a62e14 | ||
|
|
bf05751eca |
+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
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useAtom } from 'jotai';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box, color, config } from 'folds';
|
||||
import { TapToSendBar } from './tap-to-send/TapToSendBar';
|
||||
import { useRecentTouch } from '../hooks/useRecentTouch';
|
||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
@@ -120,13 +122,31 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
|
||||
const sendGif = useCallback(
|
||||
(gif: RecentGif) => {
|
||||
setRecents((prev) => addRecentGif(prev, gif));
|
||||
const { url, width, height, previewUrl } = gif;
|
||||
setRecents((prev) => addRecentGif(prev, { url, width, height, previewUrl }));
|
||||
onSelect(gif.url, gif.width, gif.height);
|
||||
requestClose();
|
||||
},
|
||||
[onSelect, requestClose, setRecents],
|
||||
);
|
||||
|
||||
// [Gitea #147] Touch: first tap parks the GIF in a preview bar, second tap
|
||||
// (or Send) sends. Mouse/keyboard/screen reader: one step, as before.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { wasTouch } = useRecentTouch(containerRef);
|
||||
const [pending, setPending] = useState<(RecentGif & { title?: string }) | undefined>();
|
||||
const pick = useCallback(
|
||||
(gif: RecentGif & { title?: string }) => {
|
||||
if (wasTouch() && pending?.url !== gif.url) {
|
||||
setPending(gif);
|
||||
return;
|
||||
}
|
||||
setPending(undefined);
|
||||
sendGif(gif);
|
||||
},
|
||||
[wasTouch, pending, sendGif],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(gif: IGif, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -135,14 +155,15 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
gif.images.fixed_width_small_still?.url ??
|
||||
gif.images.downsized_still?.url ??
|
||||
gif.images.original_still?.url;
|
||||
sendGif({
|
||||
pick({
|
||||
url: r.url,
|
||||
width: Number(r.width) || 200,
|
||||
height: Number(r.height) || 200,
|
||||
previewUrl,
|
||||
title: gif.title,
|
||||
});
|
||||
},
|
||||
[sendGif],
|
||||
[pick],
|
||||
);
|
||||
|
||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||
@@ -150,7 +171,6 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
||||
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||
// narrower than 312px with a fixed 296px grid.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||
useElementSizeObserver(
|
||||
useCallback(() => containerRef.current, []),
|
||||
@@ -180,11 +200,22 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<SearchBar />
|
||||
</div>
|
||||
</Box>
|
||||
{pending && (
|
||||
<TapToSendBar
|
||||
previewUrl={pending.previewUrl ?? pending.url}
|
||||
label={pending.title || 'GIF'}
|
||||
onSend={() => {
|
||||
setPending(undefined);
|
||||
sendGif(pending);
|
||||
}}
|
||||
onCancel={() => setPending(undefined)}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||
>
|
||||
{showRecents && (
|
||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={pick} />
|
||||
)}
|
||||
<Grid
|
||||
key={searchKey}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -31,6 +31,8 @@ import { useThrottle } from '../../hooks/useThrottle';
|
||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useRecentTouch } from '../../hooks/useRecentTouch';
|
||||
import { TapToSendBar } from '../tap-to-send/TapToSendBar';
|
||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||
import {
|
||||
@@ -52,7 +54,7 @@ import {
|
||||
EmojiGroup,
|
||||
EmojiBoardLayout,
|
||||
} from './components';
|
||||
import { EmojiBoardTab, EmojiType } from './types';
|
||||
import { EmojiBoardTab, EmojiItemInfo, EmojiType } from './types';
|
||||
import { VirtualTile } from '../virtualizer';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
@@ -518,10 +520,27 @@ export function EmojiBoard({
|
||||
});
|
||||
const vItems = virtualizer.getVirtualItems();
|
||||
|
||||
// [Gitea #147] Touch: first tap on a sticker parks it in a preview bar,
|
||||
// second tap (or the bar's Send) sends. Mouse/keyboard/screen reader: one step.
|
||||
const { wasTouch } = useRecentTouch(contentScrollRef);
|
||||
const [pendingSticker, setPendingSticker] = useState<EmojiItemInfo | undefined>();
|
||||
const stickerUseAuthentication = useMediaAuthentication();
|
||||
const sendSticker = (info: EmojiItemInfo, close: boolean) => {
|
||||
onStickerSelect?.(info.data, info.shortcode, info.label);
|
||||
setRecentStickers((prev) =>
|
||||
addRecentSticker(prev, { url: info.data, shortcode: info.shortcode, body: info.label }),
|
||||
);
|
||||
setPendingSticker(undefined);
|
||||
if (close) requestClose();
|
||||
};
|
||||
|
||||
const handleGroupItemClick: MouseEventHandler = (evt) => {
|
||||
const targetEl = targetFromEvent(evt.nativeEvent, 'button');
|
||||
const emojiInfo = targetEl && getEmojiItemInfo(targetEl);
|
||||
if (!emojiInfo) return;
|
||||
if (!emojiInfo) {
|
||||
if (pendingSticker) setPendingSticker(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (emojiInfo.type === EmojiType.Emoji) {
|
||||
onEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
@@ -533,14 +552,12 @@ export function EmojiBoard({
|
||||
onCustomEmojiSelect?.(emojiInfo.data, emojiInfo.shortcode);
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
setRecentStickers((prev) =>
|
||||
addRecentSticker(prev, {
|
||||
url: emojiInfo.data,
|
||||
shortcode: emojiInfo.shortcode,
|
||||
body: emojiInfo.label,
|
||||
}),
|
||||
);
|
||||
if (wasTouch() && pendingSticker?.data !== emojiInfo.data) {
|
||||
setPendingSticker(emojiInfo);
|
||||
return;
|
||||
}
|
||||
sendSticker(emojiInfo, !evt.altKey && !evt.shiftKey);
|
||||
return;
|
||||
}
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
};
|
||||
@@ -670,7 +687,18 @@ export function EmojiBoard({
|
||||
{tab === EmojiBoardTab.Sticker && groups.length === 0 && <NoStickerPacks />}
|
||||
</EmojiGroupHolder>
|
||||
</Box>
|
||||
<Preview previewAtom={previewAtom} />
|
||||
{pendingSticker && tab === EmojiBoardTab.Sticker ? (
|
||||
<TapToSendBar
|
||||
previewUrl={
|
||||
mxcUrlToHttp(mx, pendingSticker.data, stickerUseAuthentication) ?? undefined
|
||||
}
|
||||
label={pendingSticker.label}
|
||||
onSend={() => sendSticker(pendingSticker, true)}
|
||||
onCancel={() => setPendingSticker(undefined)}
|
||||
/>
|
||||
) : (
|
||||
<Preview previewAtom={previewAtom} />
|
||||
)}
|
||||
</EmojiBoardLayout>
|
||||
</FocusTrap>
|
||||
);
|
||||
|
||||
@@ -24,32 +24,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { today, yesterday, timeHourMinute, timeMon, timeDay, timeYear } from '../../utils/time';
|
||||
import { TimestampPrefs, formatTimestamp } from '../../utils/formatTimestamp';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
function formatReadTs(ts: number, hour24Clock: boolean): string {
|
||||
const timeStr = timeHourMinute(ts, hour24Clock);
|
||||
if (today(ts)) return `Today at ${timeStr}`;
|
||||
if (yesterday(ts)) return `Yesterday at ${timeStr}`;
|
||||
const sameYear = timeYear(ts) === timeYear(Date.now());
|
||||
return sameYear
|
||||
? `${timeMon(ts)} ${timeDay(ts)} at ${timeStr}`
|
||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
||||
}
|
||||
const formatReadTs = (ts: number, prefs: TimestampPrefs): string => formatTimestamp(ts, prefs);
|
||||
|
||||
type EventReaderItemProps = {
|
||||
room: Room;
|
||||
readerId: string;
|
||||
hour24Clock: boolean;
|
||||
prefs: TimestampPrefs;
|
||||
lotusTerminal: boolean;
|
||||
onSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
function EventReaderItem({
|
||||
room,
|
||||
readerId,
|
||||
hour24Clock,
|
||||
lotusTerminal,
|
||||
onSelect,
|
||||
}: EventReaderItemProps) {
|
||||
function EventReaderItem({ room, readerId, prefs, lotusTerminal, onSelect }: EventReaderItemProps) {
|
||||
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
|
||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||
|
||||
@@ -86,7 +73,7 @@ function EventReaderItem({
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatReadTs(receiptTs, hour24Clock)}
|
||||
{formatReadTs(receiptTs, prefs)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
@@ -106,7 +93,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
const space = useSpaceOptionally();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
return (
|
||||
@@ -157,7 +144,7 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
key={readerId}
|
||||
room={room}
|
||||
readerId={readerId}
|
||||
hour24Clock={hour24Clock}
|
||||
prefs={prefs}
|
||||
lotusTerminal={lotusTerminal}
|
||||
onSelect={(event) => {
|
||||
openProfile(
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as css from './Reply.css';
|
||||
import { ForwardedMeta } from '../../features/room/message/forwardContent';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||
import { formatTimestamp } from '../../utils/formatTimestamp';
|
||||
|
||||
type ForwardedHeaderProps = {
|
||||
mx: MatrixClient;
|
||||
@@ -32,11 +32,7 @@ export const ForwardedHeader = as<'div', ForwardedHeaderProps>(
|
||||
getMxIdLocalPart(meta.sender) ??
|
||||
meta.sender;
|
||||
const ts = meta.origin_server_ts;
|
||||
const when = today(ts)
|
||||
? timeHourMinute(ts, hour24Clock)
|
||||
: yesterday(ts)
|
||||
? `Yesterday ${timeHourMinute(ts, hour24Clock)}`
|
||||
: `${timeDayMonYear(ts, dateFormatString)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||
const when = formatTimestamp(ts, { hour24Clock, dateFormatString });
|
||||
const canJump = !!sourceRoom && !!onJump;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { Text, as } from 'folds';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../utils/time';
|
||||
import { formatTimestamp } from '../../utils/formatTimestamp';
|
||||
|
||||
export type TimeProps = {
|
||||
compact?: boolean;
|
||||
@@ -12,8 +12,8 @@ export type TimeProps = {
|
||||
/**
|
||||
* Renders a formatted timestamp, supporting compact and full display modes.
|
||||
*
|
||||
* Displays the time in hour:minute format if the message is from today, yesterday, or if `compact` is true.
|
||||
* For older messages, it shows the date and time.
|
||||
* `compact` always shows the clock time; otherwise the shared `formatTimestamp`
|
||||
* rules apply (today → time, yesterday/this week → day word + time, else date + time).
|
||||
*
|
||||
* @param {number} ts - The timestamp to display.
|
||||
* @param {boolean} [compact=false] - If true, always show only the time.
|
||||
@@ -23,18 +23,7 @@ export type TimeProps = {
|
||||
*/
|
||||
export const Time = as<'span', TimeProps & ComponentProps<typeof Text>>(
|
||||
({ compact, hour24Clock, dateFormatString, ts, ...props }, ref) => {
|
||||
const formattedTime = timeHourMinute(ts, hour24Clock);
|
||||
|
||||
let time = '';
|
||||
if (compact) {
|
||||
time = formattedTime;
|
||||
} else if (today(ts)) {
|
||||
time = formattedTime;
|
||||
} else if (yesterday(ts)) {
|
||||
time = `Yesterday ${formattedTime}`;
|
||||
} else {
|
||||
time = `${timeDayMonYear(ts, dateFormatString)} ${formattedTime}`;
|
||||
}
|
||||
const time = formatTimestamp(ts, { hour24Clock, dateFormatString }, compact ? 'time' : 'auto');
|
||||
|
||||
return (
|
||||
<Text as="time" style={{ flexShrink: 0 }} size="T200" priority="300" {...props} ref={ref}>
|
||||
|
||||
@@ -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)`]: {
|
||||
|
||||
@@ -19,15 +19,13 @@ import { getMemberDisplayName, getStateEvent } from '../../utils/room';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { timeDayMonthYear, timeHourMinute } from '../../utils/time';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { RoomAvatar } from '../room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { InviteUserPrompt } from '../invite-user-prompt';
|
||||
import { RoomTopicViewer } from '../room-topic-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
@@ -68,7 +66,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
useCallback(async (roomId: string) => mx.joinRoom(roomId), [mx]),
|
||||
);
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
return (
|
||||
<Box direction="Column" grow="Yes" gap="500" {...props} ref={ref}>
|
||||
@@ -135,7 +133,7 @@ export const RoomIntro = as<'div', RoomIntroProps>(({ room, ...props }, ref) =>
|
||||
<Text size="T200" priority="300">
|
||||
{'Created by '}
|
||||
<b>@{creatorName}</b>
|
||||
{` on ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`}
|
||||
{` on ${format(ts, 'dateTime')}`}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||
|
||||
type TapToSendBarProps = {
|
||||
previewUrl?: string;
|
||||
label: string;
|
||||
onSend: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #147] Fat-finger guard for touch screens: the first tap on a sticker
|
||||
* or GIF parks it here with a preview; "Send" (or a second tap on the same
|
||||
* item) sends it.
|
||||
*/
|
||||
export function TapToSendBar({ previewUrl, label, onSend, onCancel }: TapToSendBarProps) {
|
||||
const liveRef = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
liveRef.current?.focus?.();
|
||||
}, [label]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={liveRef}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
tabIndex={-1}
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="300"
|
||||
style={{
|
||||
margin: `0 ${config.space.S300} ${config.space.S200}`,
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt=""
|
||||
style={{
|
||||
width: toRem(40),
|
||||
height: toRem(40),
|
||||
objectFit: 'contain',
|
||||
borderRadius: config.radii.R300,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
|
||||
<Text size="T200" priority="300">
|
||||
Tap again to send
|
||||
</Text>
|
||||
<Text size="T300" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
<Button size="300" variant="Primary" fill="Solid" radii="300" onClick={onSend}>
|
||||
<Text size="B300">Send</Text>
|
||||
</Button>
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
onClick={onCancel}
|
||||
aria-label="Cancel"
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -6,9 +6,7 @@ import { SettingTile } from '../setting-tile';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../utils/time';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
type UserKickAlertProps = {
|
||||
reason?: string;
|
||||
@@ -16,11 +14,8 @@ type UserKickAlertProps = {
|
||||
ts?: number;
|
||||
};
|
||||
export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
return (
|
||||
<CutoutCard style={{ padding: config.space.S200 }} variant="Critical">
|
||||
@@ -28,11 +23,7 @@ export function UserKickAlert({ reason, kickedBy, ts }: UserKickAlertProps) {
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Kicked User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{kickedBy && (
|
||||
@@ -66,11 +57,8 @@ type UserBanAlertProps = {
|
||||
export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBanAlertProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
const [unbanState, unban] = useAsyncCallback<undefined, Error, []>(
|
||||
useCallback(async () => {
|
||||
@@ -86,11 +74,7 @@ export function UserBanAlert({ userId, reason, canUnban, bannedBy, ts }: UserBan
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Banned User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{bannedBy && (
|
||||
@@ -141,11 +125,8 @@ type UserInviteAlertProps = {
|
||||
export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: UserInviteAlertProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const time = ts ? timeHourMinute(ts, hour24Clock) : undefined;
|
||||
const date = ts ? timeDayMonYear(ts, dateFormatString) : undefined;
|
||||
const { format } = useTimestampFormatter();
|
||||
const when = ts ? format(ts) : undefined;
|
||||
|
||||
const [kickState, kick] = useAsyncCallback<undefined, Error, []>(
|
||||
useCallback(async () => {
|
||||
@@ -161,11 +142,7 @@ export function UserInviteAlert({ userId, reason, canKick, invitedBy, ts }: User
|
||||
<Box direction="Column" gap="200">
|
||||
<Box gap="200" justifyContent="SpaceBetween">
|
||||
<Text size="L400">Invited User</Text>
|
||||
{time && date && (
|
||||
<Text size="T200">
|
||||
{date} {time}
|
||||
</Text>
|
||||
)}
|
||||
{when && <Text size="T200">{when}</Text>}
|
||||
</Box>
|
||||
<Box direction="Column">
|
||||
{invitedBy && (
|
||||
|
||||
@@ -35,19 +35,8 @@ import { nameInitials } from '../../utils/common';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './BookmarksPanel.css';
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
|
||||
// getOnInit reads localStorage synchronously at init so the persisted sort is
|
||||
@@ -110,7 +99,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
|
||||
: undefined;
|
||||
// Prefer a live-resolved author name, then the stored snapshot.
|
||||
const author = senderName ?? bookmark.senderName;
|
||||
const timeAgo = formatTimeAgo(bookmark.savedAt);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const timeAgo = formatRelativeAge(bookmark.savedAt, prefs);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -52,6 +52,7 @@ import { SearchResultGroup } from './SearchResultGroup';
|
||||
import { SearchInput } from './SearchInput';
|
||||
import { SearchFilters } from './SearchFilters';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams =>
|
||||
useMemo(
|
||||
@@ -74,6 +75,7 @@ type EncryptedRoomCachePanelProps = {
|
||||
};
|
||||
function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { format } = useTimestampFormatter();
|
||||
const [loadingRooms, setLoadingRooms] = useState<Set<string>>(new Set());
|
||||
|
||||
const encryptedRooms = useMemo(
|
||||
@@ -140,7 +142,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{msgEvents.length > 0
|
||||
? `${msgEvents.length} messages cached · oldest: ${new Date(oldest!.getTs()).toLocaleDateString()}`
|
||||
? `${msgEvents.length} messages cached · oldest: ${format(oldest!.getTs(), 'date')}`
|
||||
: 'No messages cached yet'}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
color,
|
||||
config,
|
||||
PopOut,
|
||||
toRem,
|
||||
@@ -37,9 +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 dayjs from 'dayjs';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
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';
|
||||
@@ -97,29 +102,11 @@ import { MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { EmojiBoard } from '../../components/emoji-board';
|
||||
|
||||
dayjs.extend(isToday);
|
||||
dayjs.extend(isYesterday);
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatShortAge } from '../../utils/formatTimestamp';
|
||||
|
||||
const PREVIEW_MAX_CHARS = 48;
|
||||
|
||||
function formatDmTimestamp(ts: number): string {
|
||||
const d = dayjs(ts);
|
||||
const now = dayjs();
|
||||
const diffMinutes = now.diff(d, 'minute');
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes < 1 ? 0 : diffMinutes}m`;
|
||||
}
|
||||
const diffHours = now.diff(d, 'hour');
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
if (d.isYesterday()) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
return d.format('D MMM');
|
||||
}
|
||||
|
||||
type RenameRoomDialogProps = {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
@@ -298,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'];
|
||||
@@ -337,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 = () => {
|
||||
@@ -519,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"
|
||||
@@ -646,6 +751,7 @@ function RoomNavItem_({
|
||||
|
||||
const roomName = useLocalRoomName(room);
|
||||
const hasLocalName = useHasLocalRoomName(room.roomId);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
|
||||
// Whether this room has an unsent message draft. selectAtom maps to a boolean
|
||||
// so the row only re-renders when that flips (the draft atom itself is written
|
||||
@@ -677,7 +783,7 @@ function RoomNavItem_({
|
||||
}
|
||||
if (!body) return null;
|
||||
const preview = body.length > PREVIEW_MAX_CHARS ? `${body.slice(0, PREVIEW_MAX_CHARS)}…` : body;
|
||||
return { preview, time: formatDmTimestamp(ts) };
|
||||
return { preview, time: formatShortAge(ts, prefs) };
|
||||
})();
|
||||
|
||||
const handleContextMenu: MouseEventHandler<HTMLElement> = (evt) => {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
createDetachedTimelineSet,
|
||||
createTypesFilter,
|
||||
} from '../../utils/detachedTimeline';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -27,20 +29,6 @@ type StateEventType = (typeof STATE_EVENT_TYPES)[number];
|
||||
|
||||
// ── Timestamp formatting ──────────────────────────────────────────────────────
|
||||
|
||||
function formatRelativeTs(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 60000) return 'just now';
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
|
||||
const d = new Date(ts);
|
||||
const sameYear = d.getFullYear() === new Date().getFullYear();
|
||||
return d.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
...(sameYear ? {} : { year: 'numeric' }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Event description ─────────────────────────────────────────────────────────
|
||||
|
||||
function getDisplayName(mx: ReturnType<typeof useMatrixClient>, userId: string): string {
|
||||
@@ -296,6 +284,7 @@ type LogEntryProps = {
|
||||
};
|
||||
|
||||
function LogEntry({ ev, desc }: LogEntryProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
return (
|
||||
<Box
|
||||
alignItems="Center"
|
||||
@@ -326,7 +315,7 @@ function LogEntry({ ev, desc }: LogEntryProps) {
|
||||
{desc.text}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{formatRelativeTs(ev.getTs())}
|
||||
{formatRelativeAge(ev.getTs(), prefs)}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -9,21 +9,10 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function formatUpdatedAt(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
// Throttle window for re-computing stats on new timeline events - avoids
|
||||
// re-running every heatmap/list computation on every single incoming message
|
||||
// during a burst.
|
||||
@@ -74,6 +63,7 @@ type RoomInsightsProps = {
|
||||
};
|
||||
|
||||
export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
const { format } = useTimestampFormatter();
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
@@ -234,11 +224,11 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
</Text>
|
||||
{stats.oldestTs !== null && stats.newestTs !== null && (
|
||||
<Text size="T200" priority="300">
|
||||
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
|
||||
from {format(stats.oldestTs, 'date')} to {format(stats.newestTs, 'date')}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Last updated {formatUpdatedAt(lastUpdated)}
|
||||
Last updated {format(lastUpdated, 'time')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -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';
|
||||
@@ -35,6 +36,8 @@ import { useRoomMediaTimeline } from '../../hooks/useRoomMediaTimeline';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './MediaGallery.css';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../utils/formatTimestamp';
|
||||
|
||||
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||
|
||||
@@ -54,81 +57,8 @@ const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
|
||||
// ── Decrypt hook ──────────────────────────────────────────────────────────────
|
||||
|
||||
type DecryptState = { status: 'loading' } | { status: 'ok'; url: string } | { status: 'error' };
|
||||
|
||||
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 formatRelativeDate(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 2) return 'Just now';
|
||||
if (mins < 60) return `${mins}m ago`;
|
||||
const hrs = Math.floor(diff / 3600000);
|
||||
if (hrs < 24) return `${hrs}h ago`;
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -164,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.
|
||||
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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -323,6 +247,7 @@ export function Lightbox({
|
||||
onJump: (eventId: string) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
const item = items[index];
|
||||
const isImage = item?.msgtype === MsgType.Image;
|
||||
@@ -366,11 +291,7 @@ export function Lightbox({
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
const dateStr = format(item.ts, 'date');
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
@@ -614,7 +535,8 @@ function GalleryTile({
|
||||
mimeType,
|
||||
nearViewport,
|
||||
);
|
||||
const relDate = formatRelativeDate(ts);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const relDate = formatRelativeAge(ts, prefs);
|
||||
|
||||
return (
|
||||
<div className={css.GalleryTileWrap}>
|
||||
@@ -724,6 +646,7 @@ type MediaGalleryProps = {
|
||||
};
|
||||
|
||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
@@ -1040,7 +963,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
if (!url) return null;
|
||||
const body: string = c.body || 'Voice message';
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const relDate = formatRelativeDate(mEvent.getTs());
|
||||
const relDate = formatRelativeAge(mEvent.getTs(), prefs);
|
||||
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
|
||||
// audio/ogg) so the decrypted blob actually plays.
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
|
||||
|
||||
@@ -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>
|
||||
@@ -1339,12 +1342,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{(gifOpen: boolean, setGifOpen) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
// [Gitea #147] In the compact overflow row the GIF button sits
|
||||
// near the left edge; end-aligning a 312px picker to it pushed
|
||||
// most of it off-screen. Anchor to the row instead.
|
||||
alignOffset={compact ? 0 : -44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
gifOpen
|
||||
? (gifBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
? ((compact
|
||||
? gifBtnRef.current?.closest('#composer-more-actions')
|
||||
: gifBtnRef.current
|
||||
)?.getBoundingClientRect() ??
|
||||
gifBtnRef.current?.getBoundingClientRect() ??
|
||||
undefined)
|
||||
: undefined
|
||||
}
|
||||
content={
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
IContent,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -90,6 +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, 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';
|
||||
@@ -105,7 +111,8 @@ import { markAsRead } from '../../utils/notifications';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
import { inSameDay, minuteDifference } from '../../utils/time';
|
||||
import { formatDayDivider } from '../../utils/formatTimestamp';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
@@ -180,6 +187,9 @@ export const getFirstLinkedTimeline = (
|
||||
return getFirstLinkedTimeline(linkedTm, direction);
|
||||
};
|
||||
|
||||
/** [Gitea #137] Galleries the user asked to see as separate messages (keyed by the group's last event id). */
|
||||
const separatedGalleries = new Set<string>();
|
||||
|
||||
export const getLinkedTimelines = (timeline: EventTimeline): EventTimeline[] => {
|
||||
const firstTimeline = getFirstLinkedTimeline(timeline, Direction.Backward);
|
||||
const timelines: EventTimeline[] = [];
|
||||
@@ -469,6 +479,9 @@ 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');
|
||||
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
|
||||
const [urlPreview] = useSetting(settingsAtom, 'urlPreview');
|
||||
@@ -528,6 +541,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const [editHistoryEvent, setEditHistoryEvent] = useState<MatrixEvent | undefined>();
|
||||
// [Gitea #219] Timeline images open the shared media lightbox at that event.
|
||||
const [lightboxEventId, setLightboxEventId] = useState<string | undefined>();
|
||||
// [Gitea #137] Opened from a gallery grid: the viewer walks that group in send order.
|
||||
const [lightboxGroup, setLightboxGroup] = useState<MatrixEvent[] | undefined>();
|
||||
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -1144,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) => {
|
||||
@@ -1166,10 +1182,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderMatrixEvent = useMatrixEventRenderer<
|
||||
[string, MatrixEvent, number, EventTimelineSet, boolean]
|
||||
[
|
||||
string,
|
||||
MatrixEvent,
|
||||
number,
|
||||
EventTimelineSet,
|
||||
boolean,
|
||||
MatrixEvent[] | undefined,
|
||||
string | undefined,
|
||||
]
|
||||
>(
|
||||
{
|
||||
[MessageEvent.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => {
|
||||
[MessageEvent.RoomMessage]: (
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapse,
|
||||
mediaGroup,
|
||||
regroupId,
|
||||
) => {
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations && reactionRelations.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactions && reactions.length > 0;
|
||||
@@ -1262,24 +1294,47 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
>
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
) : mediaGroup ? (
|
||||
<MediaGroupGrid
|
||||
events={mediaGroup}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
onOpen={(id) => {
|
||||
setLightboxGroup(mediaGroup);
|
||||
setLightboxEventId(id);
|
||||
}}
|
||||
onShowSeparately={() => {
|
||||
separatedGalleries.add(mEventId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
msgType={mEvent.getContent().msgtype ?? ''}
|
||||
ts={mEvent.getTs()}
|
||||
edited={!!editedEvent}
|
||||
editDiff={editedEvent ? getLastEditDiff(mEvent, timelineSet) : undefined}
|
||||
onEditHistoryClick={editedEvent ? () => setEditHistoryEvent(mEvent) : undefined}
|
||||
getContent={getContent}
|
||||
mediaAutoLoad={mediaAutoLoad}
|
||||
urlPreview={showUrlPreview}
|
||||
htmlReactParserOptions={htmlReactParserOptions}
|
||||
linkifyOpts={linkifyOpts}
|
||||
outlineAttachment={messageLayout === MessageLayout.Bubble}
|
||||
eventId={mEventId}
|
||||
onOpenImageViewer={() => setLightboxEventId(mEventId)}
|
||||
mEvent={mEvent}
|
||||
/>
|
||||
{regroupId && (
|
||||
<RegroupChip
|
||||
onClick={() => {
|
||||
separatedGalleries.delete(regroupId);
|
||||
setSeparatedTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
@@ -2184,30 +2239,100 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
let isPrevRendered = false;
|
||||
let newDivider = false;
|
||||
let dayDivider = false;
|
||||
const eventRenderer = (item: number) => {
|
||||
// Perf-5: O(T) → O(log T) via precomputed segments
|
||||
let eventTimeline: EventTimeline | undefined;
|
||||
let baseIndex = 0;
|
||||
{
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const [base, len] = timelineSegments[mid];
|
||||
if (item < base) {
|
||||
hi = mid - 1;
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
eventTimeline = timelineSegments[mid][2];
|
||||
baseIndex = base;
|
||||
break;
|
||||
}
|
||||
// Perf-5: O(T) → O(log T) via precomputed segments
|
||||
const resolveItem = (
|
||||
item: number,
|
||||
): { eventTimeline: EventTimeline; baseIndex: number } | undefined => {
|
||||
let lo = 0;
|
||||
let hi = timelineSegments.length - 1;
|
||||
while (lo <= hi) {
|
||||
// eslint-disable-next-line no-bitwise
|
||||
const mid = (lo + hi) >>> 1;
|
||||
const [base, len] = timelineSegments[mid];
|
||||
if (item < base) {
|
||||
hi = mid - 1;
|
||||
} else if (item >= base + len) {
|
||||
lo = mid + 1;
|
||||
} else {
|
||||
return { eventTimeline: timelineSegments[mid][2], baseIndex: base };
|
||||
}
|
||||
}
|
||||
if (!eventTimeline) return null;
|
||||
const timelineSet = eventTimeline?.getTimelineSet();
|
||||
return undefined;
|
||||
};
|
||||
const eventAt = (
|
||||
item: number,
|
||||
): { mEvent: MatrixEvent; timelineSet: EventTimelineSet } | undefined => {
|
||||
const seg = resolveItem(item);
|
||||
if (!seg) return undefined;
|
||||
const mEvent = getTimelineEvent(
|
||||
seg.eventTimeline,
|
||||
getTimelineRelativeIndex(item, seg.baseIndex),
|
||||
);
|
||||
return mEvent ? { mEvent, timelineSet: seg.eventTimeline.getTimelineSet() } : undefined;
|
||||
};
|
||||
// [Gitea #137] Gallery grouping is planned lazily per render pass: the first
|
||||
// media event we meet plans its whole run (looking both ways, so a virtual
|
||||
// window that starts mid-run still agrees), and the plan is reused for the
|
||||
// run's other members.
|
||||
const groupPlans = new Map<number, GroupPlan | null>();
|
||||
const candidateAt = (index: number): GroupCandidate | undefined => {
|
||||
const found = eventAt(index);
|
||||
if (!found) return undefined;
|
||||
const { mEvent: ev, timelineSet } = found;
|
||||
const sender = ev.getSender() ?? '';
|
||||
const base = { sender, ts: ev.getTs(), hasRelation: false, redacted: false, mustEnd: false };
|
||||
if (
|
||||
reactionOrEditEvent(ev) ||
|
||||
ev.getType() === 'm.room.redaction' ||
|
||||
ignoredUsersSet.has(sender)
|
||||
)
|
||||
return { ...base, kind: 'skip' };
|
||||
if (ev.getType() === StateEvent.RoomMember && hideMembershipEvents)
|
||||
return { ...base, kind: 'skip' };
|
||||
const msgtype = ev.getContent().msgtype;
|
||||
const isMedia =
|
||||
ev.getType() === MessageEvent.RoomMessage &&
|
||||
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
|
||||
!!getThumbMxc(ev);
|
||||
if (!isMedia) return { ...base, kind: 'other' };
|
||||
const id = ev.getId() ?? '';
|
||||
const reactions = getEventReactions(timelineSet, id)?.getSortedAnnotationsByKey();
|
||||
const hasThread =
|
||||
ev.getThread() !== undefined ||
|
||||
ev.getServerAggregatedRelation(RelationType.Thread) !== undefined;
|
||||
return {
|
||||
...base,
|
||||
kind: 'media',
|
||||
hasRelation: !!ev.getContent()['m.relates_to'],
|
||||
redacted: ev.isRedacted(),
|
||||
mustEnd: (reactions?.length ?? 0) > 0 || hasThread,
|
||||
};
|
||||
};
|
||||
const mediaGroupFor = (
|
||||
item: number,
|
||||
): { hidden: boolean; events?: MatrixEvent[]; regroup?: string } | undefined => {
|
||||
if (!groupPlans.has(item)) {
|
||||
if (candidateAt(item)?.kind !== 'media') return undefined;
|
||||
const plans = planMediaGroups(candidateAt, item);
|
||||
plans.forEach((plan, index) => groupPlans.set(index, plan));
|
||||
if (!plans.has(item)) groupPlans.set(item, null);
|
||||
}
|
||||
const plan = groupPlans.get(item);
|
||||
if (!plan) return undefined;
|
||||
const lastId = eventAt(plan.members[plan.members.length - 1])?.mEvent.getId() ?? '';
|
||||
if (separatedGalleries.has(lastId))
|
||||
return plan.renders ? { hidden: false, regroup: lastId } : undefined;
|
||||
if (!plan.renders) return { hidden: true };
|
||||
const events = plan.members
|
||||
.map((index) => eventAt(index)?.mEvent)
|
||||
.filter((ev): ev is MatrixEvent => !!ev);
|
||||
return { hidden: false, events };
|
||||
};
|
||||
const eventRenderer = (item: number) => {
|
||||
const resolved = resolveItem(item);
|
||||
if (!resolved) return null;
|
||||
const { eventTimeline, baseIndex } = resolved;
|
||||
const timelineSet = eventTimeline.getTimelineSet();
|
||||
const mEvent = getTimelineEvent(eventTimeline, getTimelineRelativeIndex(item, baseIndex));
|
||||
const mEventId = mEvent?.getId();
|
||||
|
||||
@@ -2251,17 +2376,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
prevEvent.getType() === mEvent.getType() &&
|
||||
minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2;
|
||||
|
||||
const eventJSX = reactionOrEditEvent(mEvent)
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
);
|
||||
const mediaGroup = mediaGroupFor(item);
|
||||
const eventJSX =
|
||||
reactionOrEditEvent(mEvent) || mediaGroup?.hidden
|
||||
? null
|
||||
: renderMatrixEvent(
|
||||
mEvent.getType(),
|
||||
typeof mEvent.getStateKey() === 'string',
|
||||
mEventId,
|
||||
mEvent,
|
||||
item,
|
||||
timelineSet,
|
||||
collapsed,
|
||||
mediaGroup?.events,
|
||||
mediaGroup?.regroup,
|
||||
);
|
||||
prevEvent = mEvent;
|
||||
isPrevRendered = !!eventJSX;
|
||||
|
||||
@@ -2282,11 +2411,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
<TimelineDivider variant="Surface">
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">
|
||||
{(() => {
|
||||
if (today(mEvent.getTs())) return 'Today';
|
||||
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||
return timeDayMonthYear(mEvent.getTs());
|
||||
})()}
|
||||
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
|
||||
</Text>
|
||||
</Badge>
|
||||
</TimelineDivider>
|
||||
@@ -2461,7 +2586,26 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
onClose={() => setEditHistoryEvent(undefined)}
|
||||
/>
|
||||
)}
|
||||
{lightboxEventId && (
|
||||
{lightboxEventId && lightboxGroup && (
|
||||
<Lightbox
|
||||
items={toLightboxItems(room, lightboxGroup)}
|
||||
initialIndex={Math.max(
|
||||
0,
|
||||
lightboxGroup.findIndex((ev) => ev.getId() === lightboxEventId),
|
||||
)}
|
||||
useAuthentication={useAuthentication}
|
||||
onClose={() => {
|
||||
setLightboxEventId(undefined);
|
||||
setLightboxGroup(undefined);
|
||||
}}
|
||||
onJump={(id) => {
|
||||
setLightboxEventId(undefined);
|
||||
setLightboxGroup(undefined);
|
||||
navigateRoom(room.roomId, id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{lightboxEventId && !lightboxGroup && (
|
||||
<RoomMediaLightbox
|
||||
room={room}
|
||||
eventId={lightboxEventId}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
@@ -101,6 +102,7 @@ export function ScheduleMessageModal({
|
||||
};
|
||||
|
||||
// When editing, seed the pickers from the existing send-time; else default to +1h.
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
|
||||
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
||||
@@ -124,10 +126,10 @@ export function ScheduleMessageModal({
|
||||
return;
|
||||
}
|
||||
setPreview({
|
||||
label: formatFriendlyDateTime(sendAt.getTime()),
|
||||
label: formatFriendlyDateTime(sendAt.getTime(), prefs),
|
||||
relative: formatRelativeTime(diffMs),
|
||||
});
|
||||
}, [getSendAt]);
|
||||
}, [getSendAt, prefs]);
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
|
||||
@@ -6,32 +6,17 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: number): string {
|
||||
const date = new Date(sendAt);
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
date.getFullYear() === tomorrow.getFullYear() &&
|
||||
date.getMonth() === tomorrow.getMonth() &&
|
||||
date.getDate() === tomorrow.getDate();
|
||||
const timeStr = date.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow ${timeStr}`;
|
||||
return `${date.toLocaleDateString()} ${timeStr}`;
|
||||
}
|
||||
|
||||
export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const formatSendAt = (sendAt: number) => formatFriendlyDateTime(sendAt, prefs);
|
||||
const [scheduledMessages, setScheduledMessages] = useAtom(scheduledMessagesAtom);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
|
||||
@@ -27,7 +27,8 @@ import { useAlive } from '../../../hooks/useAlive';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../../utils/time';
|
||||
import { getToday, getYesterday } from '../../../utils/time';
|
||||
import { formatDate, formatTime } from '../../../utils/formatTimestamp';
|
||||
import { DatePicker, TimePicker } from '../../../components/time-date';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
@@ -50,6 +51,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
const [ts, setTs] = useState(() => Date.now());
|
||||
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
|
||||
const [timePickerCords, setTimePickerCords] = useState<RectCords>();
|
||||
const [datePickerCords, setDatePickerCords] = useState<RectCords>();
|
||||
@@ -131,7 +133,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||
onClick={handleTimePicker}
|
||||
>
|
||||
<Text size="B300">{timeHourMinute(ts, hour24Clock)}</Text>
|
||||
<Text size="B300">{formatTime(ts, { hour24Clock })}</Text>
|
||||
</Chip>
|
||||
<PopOut
|
||||
anchor={timePickerCords}
|
||||
@@ -172,7 +174,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
after={<Icon size="50" src={Icons.ChevronBottom} />}
|
||||
onClick={handleDatePicker}
|
||||
>
|
||||
<Text size="B300">{timeDayMonthYear(ts)}</Text>
|
||||
<Text size="B300">{formatDate(ts, { hour24Clock, dateFormatString })}</Text>
|
||||
</Chip>
|
||||
<PopOut
|
||||
anchor={datePickerCords}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { sanitizeCustomHtml } from '../../../utils/sanitize';
|
||||
import { LINKIFY_OPTS } from '../../../plugins/react-custom-html-parser';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||
import { formatTimestamp } from '../../../utils/formatTimestamp';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { diffWords } from '../../../utils/textDiff';
|
||||
@@ -229,11 +229,7 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
const initialLoading = historyState.status === AsyncStatus.Loading && edits.length === 0;
|
||||
const loadingMore = historyState.status === AsyncStatus.Loading && edits.length > 0;
|
||||
|
||||
const formatTs = (ts: number): string => {
|
||||
const time = timeHourMinute(ts, hour24Clock);
|
||||
const date = timeDayMonYear(ts, dateFormatString);
|
||||
return `${date} at ${time}`;
|
||||
};
|
||||
const formatTs = (ts: number): string => formatTimestamp(ts, { hour24Clock, dateFormatString });
|
||||
|
||||
const originalContent = getOriginalContent(mEvent);
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
|
||||
export const Wrap = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'block',
|
||||
width: toRem(480),
|
||||
maxWidth: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Grid = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'grid',
|
||||
gap: toRem(3),
|
||||
width: '100%',
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Cell = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
aspectRatio: '1 / 1',
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
selectors: {
|
||||
'&:focus-visible': {
|
||||
outline: `${config.borderWidth.B600} solid ${color.Primary.Main}`,
|
||||
outlineOffset: `calc(-1 * ${config.borderWidth.B600})`,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CellImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
display: 'block',
|
||||
transition: 'transform 150ms',
|
||||
selectors: {
|
||||
[`${Cell}:hover &`]: {
|
||||
transform: 'scale(1.03)',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const CellBlur = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
export const PlayBadge = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: toRem(36),
|
||||
height: toRem(36),
|
||||
borderRadius: config.radii.Pill,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.55)',
|
||||
color: 'white',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]);
|
||||
|
||||
export const Footer = style([
|
||||
DefaultReset,
|
||||
{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
marginTop: config.space.S100,
|
||||
},
|
||||
]);
|
||||
|
||||
export const FooterButton = style([
|
||||
DefaultReset,
|
||||
{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
textDecoration: 'underline',
|
||||
textDecorationColor: 'transparent',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
textDecorationColor: 'currentColor',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,149 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Icon, Icons, Spinner, Text } from 'folds';
|
||||
import { MatrixEvent, MsgType } from 'matrix-js-sdk';
|
||||
import { BlurhashCanvas } from 'react-blurhash';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
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';
|
||||
|
||||
/** Column count for `n` tiles: 2 → 2, 3 → 3, 4 → 2×2, 5–6 → 3, 7+ → 4. */
|
||||
export const gridColumns = (n: number): number => {
|
||||
if (n <= 2) return 2;
|
||||
if (n === 3) return 3;
|
||||
if (n === 4) return 2;
|
||||
if (n <= 6) return 3;
|
||||
return 4;
|
||||
};
|
||||
|
||||
/** "5 photos" / "2 videos" / "6 items". */
|
||||
export const describeGroup = (events: MatrixEvent[]): string => {
|
||||
const videos = events.filter((e) => e.getContent().msgtype === MsgType.Video).length;
|
||||
const n = events.length;
|
||||
if (videos === 0) return `${n} photos`;
|
||||
if (videos === n) return `${n} videos`;
|
||||
return `${n} items`;
|
||||
};
|
||||
|
||||
function Cell({
|
||||
mEvent,
|
||||
load,
|
||||
onOpen,
|
||||
}: {
|
||||
mEvent: MatrixEvent;
|
||||
load: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
}) {
|
||||
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 blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME] as string | undefined);
|
||||
const media = useDecryptedMediaUrl(mx, thumbMxc, encInfo, useAuthentication, mimeType, load);
|
||||
const body = typeof content.body === 'string' ? content.body : '';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.Cell}
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={() => onOpen(mEvent.getId() ?? '')}
|
||||
>
|
||||
{blurHash && media.status !== 'ok' && (
|
||||
<BlurhashCanvas className={css.CellBlur} hash={blurHash} width={32} height={32} punch={1} />
|
||||
)}
|
||||
{load && media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{!load && !blurHash && <Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />}
|
||||
{media.status === 'ok' && <img src={media.url} alt="" className={css.CellImg} />}
|
||||
{isVideo && (
|
||||
<span className={css.PlayBadge}>
|
||||
<Icon src={Icons.Play} size="200" filled />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type MediaGroupGridProps = {
|
||||
events: MatrixEvent[];
|
||||
mediaAutoLoad: boolean;
|
||||
onOpen: (eventId: string) => void;
|
||||
onShowSeparately: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #137] Several consecutive image/video events from one sender shown
|
||||
* as one grid. Each tile opens the room's shared lightbox at that event, so
|
||||
* ←/→ walk through the group (and beyond). Purely a render-time grouping.
|
||||
*/
|
||||
export function MediaGroupGrid({
|
||||
events,
|
||||
mediaAutoLoad,
|
||||
onOpen,
|
||||
onShowSeparately,
|
||||
}: MediaGroupGridProps) {
|
||||
const [load, setLoad] = useState(mediaAutoLoad);
|
||||
const columns = gridColumns(events.length);
|
||||
|
||||
return (
|
||||
<div className={css.Wrap}>
|
||||
<div
|
||||
className={css.Grid}
|
||||
role="group"
|
||||
aria-label={describeGroup(events)}
|
||||
style={{ gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))` }}
|
||||
onClick={load ? undefined : () => setLoad(true)}
|
||||
>
|
||||
{events.map((ev) => (
|
||||
<Cell
|
||||
key={ev.getId()}
|
||||
mEvent={ev}
|
||||
load={load}
|
||||
onOpen={load ? onOpen : () => setLoad(true)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={css.Footer}>
|
||||
<Text size="T200" priority="300">
|
||||
{describeGroup(events)}
|
||||
{!load && ' · tap to load'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
·
|
||||
</Text>
|
||||
<Text
|
||||
as="button"
|
||||
size="T200"
|
||||
priority="300"
|
||||
className={css.FooterButton}
|
||||
onClick={onShowSeparately}
|
||||
>
|
||||
Show separately
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Shown under the last of a gallery the user split up, to put it back together. */
|
||||
export function RegroupChip({ onClick }: { onClick: () => void }) {
|
||||
return (
|
||||
<div className={css.Footer}>
|
||||
<Text as="button" size="T200" priority="300" className={css.FooterButton} onClick={onClick}>
|
||||
Show as gallery
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}}
|
||||
@@ -1334,7 +1334,12 @@ export const Message = React.memo(
|
||||
})}
|
||||
role="article"
|
||||
aria-label={
|
||||
collapse ? messageAriaLabel(senderDisplayName, mEvent.getTs(), hour24Clock) : undefined
|
||||
collapse
|
||||
? messageAriaLabel(senderDisplayName, mEvent.getTs(), {
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
tabIndex={0}
|
||||
space={messageSpacing}
|
||||
@@ -1594,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({
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { MouseEventHandler, useCallback, useState } from 'react';
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
Box,
|
||||
Modal,
|
||||
@@ -23,6 +30,7 @@ import * as css from './styles.css';
|
||||
import { ReactionViewer } from '../reaction-viewer';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { expandedReactionMessages, fitReactionRow } from '../../../utils/reactionOverflow';
|
||||
|
||||
export type ReactionsProps = {
|
||||
room: Room;
|
||||
@@ -42,6 +50,49 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
useCallback((rel) => [...(rel.getSortedAnnotationsByKey() ?? [])], []),
|
||||
);
|
||||
|
||||
// [Gitea #138] Collapse to one row + "+N" when the chips would wrap.
|
||||
// Overflowing chips stay in the DOM (invisible, clipped by max-height) so
|
||||
// the container keeps its natural width and every chip stays measurable.
|
||||
const [expanded, setExpandedState] = useState(() => expandedReactionMessages.has(mEventId));
|
||||
const setExpanded = (next: boolean) => {
|
||||
if (next) expandedReactionMessages.add(mEventId);
|
||||
else expandedReactionMessages.delete(mEventId);
|
||||
setExpandedState(next);
|
||||
};
|
||||
const [limit, setLimit] = useState<number | undefined>();
|
||||
const [rowHeight, setRowHeight] = useState<number | undefined>();
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const moreRef = useRef<HTMLButtonElement | null>(null);
|
||||
const chipRefs = useRef(new Map<string, HTMLElement>());
|
||||
const keys = reactions.map(([key]) => key).join('\u0000');
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rowWidth = container.getBoundingClientRect().width;
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||
setLimit(fitReactionRow(widths, moreWidth, gap, rowWidth));
|
||||
setRowHeight(Math.max(0, ...chips.map((el) => el?.getBoundingClientRect().height ?? 0)));
|
||||
}, [keys]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
measure();
|
||||
}, [measure]);
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || typeof ResizeObserver === 'undefined') return undefined;
|
||||
const ro = new ResizeObserver(() => measure());
|
||||
ro.observe(container);
|
||||
return () => ro.disconnect();
|
||||
}, [measure]);
|
||||
|
||||
const collapsed = !expanded && limit !== undefined;
|
||||
const hiddenCount = collapsed ? reactions.length - (limit ?? 0) : 0;
|
||||
const hiddenStyle: React.CSSProperties = { visibility: 'hidden', pointerEvents: 'none' };
|
||||
|
||||
const handleViewReaction: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
evt.preventDefault();
|
||||
@@ -56,47 +107,102 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
{...props}
|
||||
ref={ref}
|
||||
style={{
|
||||
...props.style,
|
||||
...(collapsed && rowHeight
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
||||
: {}),
|
||||
}}
|
||||
ref={(el) => {
|
||||
containerRef.current = el;
|
||||
if (typeof ref === 'function') ref(el);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = el;
|
||||
}}
|
||||
>
|
||||
{reactions.map(([key, events]) => {
|
||||
{reactions.map(([key, events], index) => {
|
||||
const rEvents = Array.from(events);
|
||||
if (rEvents.length === 0 || typeof key !== 'string') return null;
|
||||
const myREvent = myUserId ? rEvents.find(factoryEventSentBy(myUserId)) : undefined;
|
||||
const isPressed = !!myREvent?.getRelation();
|
||||
const hidden = collapsed && index >= (limit ?? 0);
|
||||
const chip = (targetRef?: React.RefCallback<HTMLElement>) => (
|
||||
<Reaction
|
||||
ref={(el: HTMLElement | null) => {
|
||||
targetRef?.(el);
|
||||
if (el) chipRefs.current.set(key, el);
|
||||
else chipRefs.current.delete(key);
|
||||
}}
|
||||
data-reaction-key={key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={events.size}
|
||||
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
|
||||
onContextMenu={handleViewReaction}
|
||||
aria-disabled={!canSendReaction}
|
||||
aria-hidden={hidden || undefined}
|
||||
tabIndex={hidden ? -1 : undefined}
|
||||
style={hidden ? hiddenStyle : undefined}
|
||||
useAuthentication={useAuthentication}
|
||||
/>
|
||||
);
|
||||
// The "+N" chip sits right after the last visible chip so it lands on row 1.
|
||||
const moreChip = index === (limit ?? 0) - 1 && (
|
||||
<button
|
||||
key="more"
|
||||
ref={moreRef}
|
||||
type="button"
|
||||
className={css.ReactionsMore}
|
||||
onClick={() => setExpanded(true)}
|
||||
aria-expanded={false}
|
||||
aria-label={`Show ${hiddenCount} more reactions`}
|
||||
aria-hidden={!collapsed || undefined}
|
||||
tabIndex={collapsed ? undefined : -1}
|
||||
style={collapsed ? undefined : { ...hiddenStyle, position: 'absolute' }}
|
||||
>
|
||||
<Text as="span" size="T300" dir="ltr">
|
||||
{`+${collapsed ? hiddenCount : reactions.length - 1}`}
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
if (hidden) return <React.Fragment key={key}>{chip()}</React.Fragment>;
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
key={key}
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => (
|
||||
<Reaction
|
||||
ref={targetRef}
|
||||
data-reaction-key={key}
|
||||
aria-pressed={isPressed}
|
||||
key={key}
|
||||
mx={mx}
|
||||
reaction={key}
|
||||
count={events.size}
|
||||
onClick={canSendReaction ? () => onReactionToggle(mEventId, key) : undefined}
|
||||
onContextMenu={handleViewReaction}
|
||||
aria-disabled={!canSendReaction}
|
||||
useAuthentication={useAuthentication}
|
||||
/>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
<React.Fragment key={key}>
|
||||
<TooltipProvider
|
||||
position="Top"
|
||||
tooltip={
|
||||
<Tooltip style={{ maxWidth: toRem(200) }}>
|
||||
<Text className={css.ReactionsTooltipText} size="T300">
|
||||
<ReactionTooltipMsg room={room} reaction={key} events={rEvents} />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(targetRef) => chip(targetRef)}
|
||||
</TooltipProvider>
|
||||
{moreChip}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{expanded && limit !== undefined && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.ReactionsMore}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-expanded
|
||||
aria-label="Show fewer reactions"
|
||||
>
|
||||
<Text as="span" size="T300">
|
||||
less
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
{reactions.length > 0 && (
|
||||
<Overlay
|
||||
onContextMenu={(evt: any) => {
|
||||
onContextMenu={(evt: React.MouseEvent) => {
|
||||
evt.stopPropagation();
|
||||
}}
|
||||
open={!!viewer}
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { TimestampPrefs, formatTime } from '../../../utils/formatTimestamp';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useReminders } from '../../../hooks/useReminders';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
@@ -34,11 +36,11 @@ type RemindMeDialogProps = {
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function getPresets(): Array<{ label: string; ms: number }> {
|
||||
function getPresets(prefs: TimestampPrefs): Array<{ label: string; ms: number }> {
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
tomorrow.setHours(9, 0, 0, 0);
|
||||
const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
const timeLabel = formatTime(tomorrow.getTime(), prefs);
|
||||
return [
|
||||
{ label: 'In 20 minutes', ms: 20 * 60_000 },
|
||||
{ label: 'In 1 hour', ms: 60 * 60_000 },
|
||||
@@ -58,7 +60,8 @@ function defaultCustomDate(): Date {
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder, removeReminder, reminders } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const presets = useMemo(() => getPresets(prefs), [prefs]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
@@ -185,7 +188,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
|
||||
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
|
||||
{formatFriendlyDateTime(r.timestamp)}
|
||||
{formatFriendlyDateTime(r.timestamp, prefs)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
@@ -193,7 +196,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={() => handleCancelExisting(r.timestamp)}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp, prefs)}`}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, config, toRem } from 'folds';
|
||||
import { DefaultReset, FocusOutline, color, config, toRem } from 'folds';
|
||||
|
||||
export const MessageBase = style({
|
||||
position: 'relative',
|
||||
@@ -45,6 +45,7 @@ export const MessageMenuItemText = style({
|
||||
});
|
||||
|
||||
export const ReactionsContainer = style({
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&:empty': {
|
||||
display: 'none',
|
||||
@@ -52,6 +53,27 @@ export const ReactionsContainer = style({
|
||||
},
|
||||
});
|
||||
|
||||
/** [Gitea #138] "+N" / "less" chip at the end of a collapsed reaction row. */
|
||||
export const ReactionsMore = style([
|
||||
FocusOutline,
|
||||
{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
padding: `${toRem(2)} ${config.space.S200}`,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
backgroundColor: 'transparent',
|
||||
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
backgroundColor: color.SurfaceVariant.ContainerHover,
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const ReactionsTooltipText = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -3,9 +3,7 @@ import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { MobileTouchTarget } from '../../../styles/mobile.css';
|
||||
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { timeDayMonthYear, timeHourMinute, today } from '../../../utils/time';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
|
||||
type ThreadSummaryProps = {
|
||||
@@ -15,17 +13,12 @@ type ThreadSummaryProps = {
|
||||
};
|
||||
export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||
const { summary, unread, mode } = useThreadSummary(rootEvent, room);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
if (!summary || summary.count === 0) return null;
|
||||
|
||||
const { count, latestTs } = summary;
|
||||
const latestStr =
|
||||
latestTs !== undefined
|
||||
? today(latestTs)
|
||||
? timeHourMinute(latestTs, hour24Clock)
|
||||
: timeDayMonthYear(latestTs)
|
||||
: undefined;
|
||||
const latestStr = latestTs !== undefined ? format(latestTs) : undefined;
|
||||
|
||||
return (
|
||||
<Box style={{ marginTop: config.space.S200 }}>
|
||||
|
||||
@@ -76,13 +76,9 @@ import { RoomMediaLightbox } from '../RoomMediaLightbox';
|
||||
import { Image } from '../../../components/media';
|
||||
import { ImageViewer } from '../../../components/image-viewer';
|
||||
import * as css from './ThreadTimeline.css';
|
||||
import {
|
||||
inSameDay,
|
||||
minuteDifference,
|
||||
timeDayMonthYear,
|
||||
today,
|
||||
yesterday,
|
||||
} from '../../../utils/time';
|
||||
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';
|
||||
@@ -271,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');
|
||||
|
||||
@@ -578,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.
|
||||
@@ -587,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(
|
||||
@@ -910,11 +908,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||
<Badge as="span" size="500" variant="Secondary" fill="None" radii="300">
|
||||
<Text size="L400">
|
||||
{(() => {
|
||||
if (today(mEvent.getTs())) return 'Today';
|
||||
if (yesterday(mEvent.getTs())) return 'Yesterday';
|
||||
return timeDayMonthYear(mEvent.getTs());
|
||||
})()}
|
||||
{formatDayDivider(mEvent.getTs(), { hour24Clock, dateFormatString })}
|
||||
</Text>
|
||||
</Badge>
|
||||
<Line style={{ flexGrow: 1 }} variant="Surface" size="300" />
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
isThreadSort,
|
||||
} from '../../../utils/threadList';
|
||||
import { useRoomThreads } from '../../../hooks/useRoomThreads';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -55,19 +57,6 @@ const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [
|
||||
|
||||
const MAX_PARTICIPANTS = 5;
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Segmented button, mirroring the Bookmarks panel sort control for consistency.
|
||||
function SegButton({
|
||||
label,
|
||||
@@ -115,6 +104,7 @@ type ThreadRowProps = {
|
||||
onOpen: (threadId: string) => void;
|
||||
};
|
||||
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const rootEvent = thread.rootEvent;
|
||||
const rootSender = rootEvent?.getSender() ?? '';
|
||||
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
|
||||
@@ -126,7 +116,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
|
||||
const extra = participants.length - MAX_PARTICIPANTS;
|
||||
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
|
||||
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
|
||||
typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : ''
|
||||
typeof lastTs === 'number' ? `, last reply ${formatRelativeAge(lastTs, prefs)}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
@@ -175,7 +165,7 @@ function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: Th
|
||||
<Icon size="50" src={Icons.Thread} />
|
||||
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
|
||||
{count} {count === 1 ? 'reply' : 'replies'}
|
||||
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
|
||||
{typeof lastTs === 'number' ? ` · ${formatRelativeAge(lastTs, prefs)}` : ''}
|
||||
</Text>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,15 +20,13 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { IMyDevice, MatrixError } from 'matrix-js-sdk';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { timeDayMonYear, timeHourMinute, today, yesterday } from '../../../utils/time';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { LogoutDialog } from '../../../components/LogoutDialog';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
|
||||
export function DeviceTilePlaceholder() {
|
||||
return (
|
||||
@@ -43,20 +41,14 @@ export function DeviceTilePlaceholder() {
|
||||
}
|
||||
|
||||
function DeviceActiveTime({ ts }: { ts: number }) {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const { format } = useTimestampFormatter();
|
||||
|
||||
return (
|
||||
<Text className={BreakWord} size="T200">
|
||||
<Text size="Inherit" as="span" priority="300">
|
||||
{'Last activity: '}
|
||||
</Text>
|
||||
<>
|
||||
{today(ts) && 'Today'}
|
||||
{yesterday(ts) && 'Yesterday'}
|
||||
{!today(ts) && !yesterday(ts) && timeDayMonYear(ts, dateFormatString)}{' '}
|
||||
{timeHourMinute(ts, hour24Clock)}
|
||||
</>
|
||||
{format(ts)}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
|
||||
import { IPusherRequest } from 'matrix-js-sdk';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -139,6 +140,7 @@ const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }>
|
||||
// Cross-platform "pause notifications" — sets a snooze instant that the
|
||||
// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds.
|
||||
function PauseNotifications() {
|
||||
const { prefs } = useTimestampFormatter();
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
|
||||
// While paused, tick so the status flips to "on" the moment the snooze lapses.
|
||||
@@ -155,7 +157,7 @@ function PauseNotifications() {
|
||||
? 'Notifications are on.'
|
||||
: snoozeUntil >= SNOOZE_INDEFINITE
|
||||
? 'Paused until you resume.'
|
||||
: `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`;
|
||||
: `Paused until ${formatFriendlyDateTime(snoozeUntil, prefs)}.`;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
|
||||
@@ -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,36 @@
|
||||
import { RefObject, useCallback, useEffect, useRef } from 'react';
|
||||
import { useMediaQuery } from './useMediaQuery';
|
||||
|
||||
const RECENT_MS = 700;
|
||||
|
||||
/**
|
||||
* [Gitea #147] Tells whether a click was produced by a finger. Records the
|
||||
* last touch inside `ref`; `wasTouch()` is true for a short window after it.
|
||||
* Keyboard activation and screen-reader activation (TalkBack/VoiceOver send a
|
||||
* bare click, no touch events) report false, so they keep one-step behaviour.
|
||||
* Always false on devices without a coarse pointer.
|
||||
*/
|
||||
export function useRecentTouch(ref: RefObject<HTMLElement | null>) {
|
||||
const coarse = useMediaQuery('(pointer: coarse)');
|
||||
const last = useRef(0);
|
||||
|
||||
// Listen on the document (the ref'd element may be remounted, e.g. on a tab
|
||||
// switch) and only count touches that land inside it.
|
||||
useEffect(() => {
|
||||
if (!coarse) return undefined;
|
||||
const mark = (evt: TouchEvent) => {
|
||||
const el = ref.current;
|
||||
if (el && evt.target instanceof Node && !el.contains(evt.target)) return;
|
||||
last.current = Date.now();
|
||||
};
|
||||
document.addEventListener('touchstart', mark, { passive: true, capture: true });
|
||||
document.addEventListener('touchend', mark, { passive: true, capture: true });
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', mark, { capture: true });
|
||||
document.removeEventListener('touchend', mark, { capture: true });
|
||||
};
|
||||
}, [ref, coarse]);
|
||||
|
||||
const wasTouch = useCallback(() => coarse && Date.now() - last.current < RECENT_MS, [coarse]);
|
||||
return { coarse, wasTouch };
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import {
|
||||
TimestampStyle,
|
||||
formatShortAge,
|
||||
formatTimestamp,
|
||||
TimestampPrefs,
|
||||
} from '../utils/formatTimestamp';
|
||||
|
||||
/**
|
||||
* [Gitea #139] Timestamp formatting bound to the user's clock/date settings.
|
||||
* `format(ts)` is the everyday "auto" style; pass a style for the others.
|
||||
*/
|
||||
export function useTimestampFormatter() {
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const prefs = useMemo<TimestampPrefs>(
|
||||
() => ({ hour24Clock, dateFormatString }),
|
||||
[hour24Clock, dateFormatString],
|
||||
);
|
||||
const format = useCallback(
|
||||
(ts: number, style: TimestampStyle = 'auto') => formatTimestamp(ts, prefs, style),
|
||||
[prefs],
|
||||
);
|
||||
const shortAge = useCallback((ts: number) => formatShortAge(ts, prefs), [prefs]);
|
||||
return { format, shortAge, prefs };
|
||||
}
|
||||
@@ -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 |
@@ -2,27 +2,29 @@ import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import dayjs from 'dayjs';
|
||||
import { messageAriaLabel } from './a11y';
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
|
||||
test('messageAriaLabel composes sender, date and time (24h)', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Alice', ts, true),
|
||||
`Alice, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, true)}`,
|
||||
messageAriaLabel('Alice', ts, { hour24Clock: true, dateFormatString: 'D MMM YYYY' }),
|
||||
'Alice, 1 Jul 2026 14:30',
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel honours the 12-hour clock preference', () => {
|
||||
test('messageAriaLabel honours the 12-hour clock and date-format preferences', () => {
|
||||
const ts = dayjs('2026-07-01T14:30:00').valueOf();
|
||||
assert.equal(
|
||||
messageAriaLabel('Bob', ts, false),
|
||||
`Bob, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, false)}`,
|
||||
messageAriaLabel('Bob', ts, { hour24Clock: false, dateFormatString: 'MM/DD/YYYY' }),
|
||||
'Bob, 07/01/2026 02:30 PM',
|
||||
);
|
||||
});
|
||||
|
||||
test('messageAriaLabel keeps the sender name verbatim as plain text', () => {
|
||||
const ts = dayjs('2026-07-01T09:05:00').valueOf();
|
||||
const label = messageAriaLabel('@user:example.org', ts, true);
|
||||
const label = messageAriaLabel('@user:example.org', ts, {
|
||||
hour24Clock: true,
|
||||
dateFormatString: '',
|
||||
});
|
||||
assert.ok(label.startsWith('@user:example.org, '));
|
||||
assert.ok(!label.includes('<'));
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
import { TimestampPrefs, formatTimestamp } from './formatTimestamp';
|
||||
|
||||
/**
|
||||
* Builds a plain-text accessible label for a message row, used when the
|
||||
@@ -7,8 +7,8 @@ import { timeDayMonthYear, timeHourMinute } from './time';
|
||||
*
|
||||
* @param sender - Sender display name (already resolved to a human string).
|
||||
* @param ts - Message origin timestamp in milliseconds.
|
||||
* @param hour24Clock - Whether to format the time using a 24-hour clock.
|
||||
* @returns A label such as `Alice, 1 July 2026 14:30`.
|
||||
* @param prefs - The user's clock/date preferences.
|
||||
* @returns A label such as `Alice, 1 Jul 2026 14:30`.
|
||||
*/
|
||||
export const messageAriaLabel = (sender: string, ts: number, hour24Clock: boolean): string =>
|
||||
`${sender}, ${timeDayMonthYear(ts)} ${timeHourMinute(ts, hour24Clock)}`;
|
||||
export const messageAriaLabel = (sender: string, ts: number, prefs: TimestampPrefs): string =>
|
||||
`${sender}, ${formatTimestamp(ts, prefs, 'dateTime')}`;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user