Compare commits
141
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1873285371 | ||
|
|
78dcf9ee9d | ||
|
|
3418a8865d | ||
|
|
5d2d7ca63c | ||
|
|
1a0e581f21 | ||
|
|
2fff7a5388 | ||
|
|
8ea03ab9b6 | ||
|
|
a1223ba5cf | ||
|
|
400545c79a | ||
|
|
885ec7c82e | ||
|
|
997df415dd | ||
|
|
8bfb8737d6 | ||
|
|
72277399f0 | ||
|
|
d223f847c6 | ||
|
|
7ad2a94e2e | ||
|
|
c6aac574ee | ||
|
|
53ac87befe | ||
|
|
e1168f47bb | ||
|
|
97d2d195c3 | ||
|
|
14f05a3d5c | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ed494d2ae5 | ||
|
|
ce1354ed93 | ||
|
|
adf94d7bc1 | ||
|
|
0eccf9bea9 | ||
|
|
62c9ac8436 | ||
|
|
aaf75fe74a | ||
|
|
968507b4b1 | ||
|
|
893bf0f6c4 | ||
|
|
8459427c1e | ||
|
|
ec47d542dd | ||
|
|
ef3849eb2f | ||
|
|
aaa915fe6b | ||
|
|
029550c855 | ||
|
|
5bc0942eea | ||
|
|
a937831e6b | ||
|
|
0b940a6df7 | ||
|
|
00f962a463 | ||
|
|
46da76c21b | ||
|
|
fee3fbb7fc | ||
|
|
a20accd446 | ||
|
|
674aef94fb | ||
|
|
080831d4ea | ||
|
|
2ac764cd45 | ||
|
|
8b6464c8b9 | ||
|
|
2a9b7f68b7 | ||
|
|
2815767d47 | ||
|
|
7ef24c14e0 | ||
|
|
ca284a75e3 | ||
|
|
179be38e2c | ||
|
|
708e5c2c14 | ||
|
|
b07eb35159 | ||
|
|
04605f2680 | ||
|
|
e4b2405a19 | ||
|
|
bc7458ac99 | ||
|
|
6648ca928d | ||
|
|
258ffc88be | ||
|
|
338b24c43f | ||
|
|
562198860f | ||
|
|
d7bc6d94bb | ||
|
|
9c3fdfff39 | ||
|
|
6191690389 | ||
|
|
3845d2e4a8 | ||
|
|
4bfb51c5fe | ||
|
|
72db4c110e | ||
|
|
a02eda05cb | ||
|
|
8a2d37b5a1 | ||
|
|
3f94d73c7a | ||
|
|
1b887de81d | ||
|
|
1037d458f4 | ||
|
|
8a3b2eeb61 | ||
|
|
099ada5d18 | ||
|
|
80ef1179c9 | ||
|
|
200ef4993c | ||
|
|
0ebf7f0806 | ||
|
|
397eb7da81 | ||
|
|
a20e68596a | ||
|
|
df9591c44c | ||
|
|
ed24d7f78e | ||
|
|
37b3ae1ad7 | ||
|
|
667925733e | ||
|
|
ea5a0d4906 | ||
|
|
a6e90c7bc4 | ||
|
|
c87fa4f6d5 | ||
|
|
f8efbfe38b | ||
|
|
5ecf4132f5 | ||
|
|
c4c54dad7a | ||
|
|
d412f9c56c | ||
|
|
fe70616412 | ||
|
|
acc17f422b | ||
|
|
ad5631819d | ||
|
|
5feb2184be | ||
|
|
86b77ac9f3 | ||
|
|
83933fb5ac | ||
|
|
f41de57893 | ||
|
|
1511cb0b97 | ||
|
|
3e11a6fc0b | ||
|
|
5ce4692a7b | ||
|
|
4486f02bc6 | ||
|
|
a7c9d15824 | ||
|
|
7b3d1bad8f | ||
|
|
2c11bd3b8e | ||
|
|
6f7a8f64e2 | ||
|
|
e6e52cdc37 | ||
|
|
4969ef9b6d | ||
|
|
be71931a81 | ||
|
|
4ab5a8fd06 | ||
|
|
4e30ae763e | ||
|
|
f9265d2d90 | ||
|
|
82c5728a96 | ||
|
|
dd6cf156ba | ||
|
|
e94cdfe5dc | ||
|
|
bb2ace82eb | ||
|
|
3a74699fef | ||
|
|
7fc42b32d4 | ||
|
|
005fc9e768 | ||
|
|
33cc0de97c | ||
|
|
3bf040f17a | ||
|
|
c8576cbb1f | ||
|
|
69e05bbf8d | ||
|
|
15f2fcc4d1 | ||
|
|
8e7127a350 | ||
|
|
f30a65eb74 | ||
|
|
0a2f18e3d5 | ||
|
|
0d08ba0a55 | ||
|
|
6ae4d7adc0 | ||
|
|
f7bdd2855a | ||
|
|
26cf1204b5 | ||
|
|
e16c923bea | ||
|
|
f9172cc339 | ||
|
|
ef7690135d | ||
|
|
8bbb436ca7 | ||
|
|
4ddcda122a | ||
|
|
9c433d9d77 | ||
|
|
e8fd0a1112 | ||
|
|
00e27a24e6 | ||
|
|
46d6570df3 | ||
|
|
a425aa3572 | ||
|
|
d1270956ea | ||
|
|
5b5fbdb7b7 |
@@ -12,6 +12,9 @@ regexes = [
|
|||||||
'''https?://matrix\.lotusguild\.org''',
|
'''https?://matrix\.lotusguild\.org''',
|
||||||
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
||||||
'''matrix\.lotusguild\.org''',
|
'''matrix\.lotusguild\.org''',
|
||||||
|
# Lotus Chat desktop updater's minisign PUBLIC key (published in the
|
||||||
|
# cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it.
|
||||||
|
'''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''',
|
||||||
]
|
]
|
||||||
paths = [
|
paths = [
|
||||||
'''config\.json''',
|
'''config\.json''',
|
||||||
|
|||||||
+96
-3
@@ -1,7 +1,7 @@
|
|||||||
# Lotus Chat — Feature Reference
|
# Lotus Chat — Feature Reference
|
||||||
|
|
||||||
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
||||||
Last updated: July 2026.
|
Last updated: October 2026.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ Last updated: July 2026.
|
|||||||
19. [Infrastructure](#infrastructure)
|
19. [Infrastructure](#infrastructure)
|
||||||
20. [Localization](#localization)
|
20. [Localization](#localization)
|
||||||
21. [Desktop App Features](#desktop-app-features)
|
21. [Desktop App Features](#desktop-app-features)
|
||||||
22. [Key Custom Files](#key-custom-files)
|
22. [June–October 2026 Additions](#juneoctober-2026-additions)
|
||||||
|
23. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -232,7 +233,7 @@ All decoration files are 256×256 APNGs. They animate natively in all modern bro
|
|||||||
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
|
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
|
||||||
|
|
||||||
**CDN:**
|
**CDN:**
|
||||||
Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png`. `<img>` elements load cross-origin freely — no CORS headers needed.
|
Static files at `https://chat.lotusguild.org/decorations/{slug}.png` (thumbnails: `thumbs/{slug}.webp`), cached for a week. They come from the versioned `avatar-decorations` package in Gitea's package registry, installed by the matrix repo's LXC 106 deploy (see its README, "Avatar decorations"). To add or replace decorations, publish a new package version there, then update the catalog in `avatarDecorations.ts`. They used to be served from Nextcloud (matrix #22). `<img>` elements load cross-origin freely, so no CORS headers are needed.
|
||||||
|
|
||||||
**Module-level cache with in-flight deduplication:**
|
**Module-level cache with in-flight deduplication:**
|
||||||
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
|
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
|
||||||
@@ -1522,6 +1523,98 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## June–October 2026 Additions
|
||||||
|
|
||||||
|
Features shipped since the sections above were last updated (Gitea #294). Each
|
||||||
|
line names the commit that introduced it.
|
||||||
|
|
||||||
|
### Calls
|
||||||
|
|
||||||
|
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
|
||||||
|
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
|
||||||
|
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
|
||||||
|
- **Mic level meter** on the mute button (`3449338b`, #146).
|
||||||
|
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
|
||||||
|
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
|
||||||
|
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
|
||||||
|
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
|
||||||
|
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
|
||||||
|
- **Live-call dot on the space rail** (`4fe9c870`, #148).
|
||||||
|
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
|
||||||
|
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
|
||||||
|
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
|
||||||
|
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
|
||||||
|
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
|
||||||
|
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
|
||||||
|
|
||||||
|
### Messaging & composer
|
||||||
|
|
||||||
|
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
|
||||||
|
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
|
||||||
|
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
|
||||||
|
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
|
||||||
|
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
|
||||||
|
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
|
||||||
|
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
|
||||||
|
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
|
||||||
|
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
|
||||||
|
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
|
||||||
|
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
|
||||||
|
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
|
||||||
|
|
||||||
|
### Search & navigation
|
||||||
|
|
||||||
|
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
|
||||||
|
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
|
||||||
|
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
|
||||||
|
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
|
||||||
|
|
||||||
|
### Rooms & threads
|
||||||
|
|
||||||
|
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
|
||||||
|
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
|
||||||
|
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
|
||||||
|
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
|
||||||
|
|
||||||
|
### Media viewer
|
||||||
|
|
||||||
|
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
|
||||||
|
- **Two-finger pinch zoom** (`39589b40`, #164).
|
||||||
|
|
||||||
|
### Profile, privacy & safety
|
||||||
|
|
||||||
|
- **Report User** (MSC4260) (`26f90087`).
|
||||||
|
- **Active now / Last active** from presence (`dea1f7af`, #150).
|
||||||
|
- **Delete all my messages** in a room (`edb46247`, #169).
|
||||||
|
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
|
||||||
|
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
|
||||||
|
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
|
||||||
|
|
||||||
|
### Appearance
|
||||||
|
|
||||||
|
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
|
||||||
|
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
|
||||||
|
|
||||||
|
### Mobile & PWA
|
||||||
|
|
||||||
|
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
|
||||||
|
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
|
||||||
|
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
|
||||||
|
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
|
||||||
|
|
||||||
|
### Desktop app
|
||||||
|
|
||||||
|
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
|
||||||
|
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
|
||||||
|
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
|
||||||
|
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
|
|||||||
|
|
||||||
## 🚫 Blocked Features (server / upstream gated)
|
## 🚫 Blocked Features (server / upstream gated)
|
||||||
|
|
||||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
|
||||||
|
|
||||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||||
|
|||||||
+19
-1
@@ -118,8 +118,26 @@ test.describe('accessibility', () => {
|
|||||||
test('room settings', async ({ page }) => {
|
test('room settings', async ({ page }) => {
|
||||||
await page.getByRole('button', { name: 'More Options' }).first().click();
|
await page.getByRole('button', { name: 'More Options' }).first().click();
|
||||||
await page.getByText('Room Settings', { exact: true }).click();
|
await page.getByText('Room Settings', { exact: true }).click();
|
||||||
await expect(page.getByRole('dialog').first()).toBeVisible();
|
// [Gitea #278] The dialog has an accessible name.
|
||||||
|
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
|
||||||
await auditPage(page, 'room settings');
|
await auditPage(page, 'room settings');
|
||||||
|
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
|
||||||
|
// export date inputs were only on these pages.
|
||||||
|
// One page at a time, in order.
|
||||||
|
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
await auditPage(page, `room settings: ${name}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('inbox invites', async ({ page }) => {
|
||||||
|
await page.goto('/inbox/invites/');
|
||||||
|
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
|
||||||
|
await auditPage(page, 'inbox invites');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
|
|||||||
expect(console_.unexpected()).toEqual([]);
|
expect(console_.unexpected()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
|
||||||
|
const console_ = collectConsole(page);
|
||||||
|
const room = await createRoom(alice, 'Poll Room');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
const question = uniq('Lunch? ');
|
||||||
|
await page.getByRole('button', { name: 'Create poll' }).first().click();
|
||||||
|
await page.getByPlaceholder('Ask a question…').fill(question);
|
||||||
|
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
|
||||||
|
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
|
||||||
|
await page.getByRole('button', { name: 'Create Poll' }).last().click();
|
||||||
|
const poll = page.locator('[data-message-id]', { hasText: question }).last();
|
||||||
|
await expect(poll).toBeVisible();
|
||||||
|
type Ev = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
content: { 'm.relates_to'?: { event_id?: string } };
|
||||||
|
};
|
||||||
|
const events = async () =>
|
||||||
|
(
|
||||||
|
await api<{ chunk: Ev[] }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
|
||||||
|
alice.token,
|
||||||
|
)
|
||||||
|
).chunk;
|
||||||
|
// Vote once the poll has reached the server (its row may still carry the local id).
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
|
||||||
|
.toBe(true);
|
||||||
|
await poll.getByText('Tacos', { exact: true }).first().click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const chunk = await events();
|
||||||
|
const start = chunk.find((e) => /poll\.start/.test(e.type));
|
||||||
|
return chunk.some(
|
||||||
|
(e) =>
|
||||||
|
/poll\.response/.test(e.type) &&
|
||||||
|
e.content['m.relates_to']?.event_id === start?.event_id &&
|
||||||
|
start?.event_id.startsWith('$'),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
expect(console_.unexpected()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
||||||
const room = await createRoom(alice, 'Scroll Room');
|
const room = await createRoom(alice, 'Scroll Room');
|
||||||
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||||
@@ -219,6 +265,46 @@ test.describe('local homeserver regression', () => {
|
|||||||
await expect.poll(threadUnread).toBe(false);
|
await expect.poll(threadUnread).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a thread I don't follow stays unread while I view the room (#314)", async ({ page }) => {
|
||||||
|
const room = await createRoom(alice, 'Unfollowed Thread Room', { invite: [bob.userId] });
|
||||||
|
await joinRoom(bob, room);
|
||||||
|
const inThread = (root: string) => ({
|
||||||
|
'm.relates_to': {
|
||||||
|
rel_type: 'm.thread',
|
||||||
|
event_id: root,
|
||||||
|
is_falling_back: true,
|
||||||
|
'm.in_reply_to': { event_id: root },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const root = await sendText(bob, room, "bob's thread root");
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
await sendText(bob, room, 'reply in bob thread', inThread(root));
|
||||||
|
await sendText(bob, room, 'newer main message 314');
|
||||||
|
await expect(page.getByText('newer main message 314')).toBeVisible();
|
||||||
|
const threadUnread = async () => {
|
||||||
|
const filter = {
|
||||||
|
room: {
|
||||||
|
rooms: [room],
|
||||||
|
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const sync = await api<{
|
||||||
|
rooms: {
|
||||||
|
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
|
||||||
|
};
|
||||||
|
}>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
|
||||||
|
alice.token,
|
||||||
|
);
|
||||||
|
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
|
||||||
|
};
|
||||||
|
await page.waitForTimeout(2000); // let any receipt the room view would send go out
|
||||||
|
// Viewing the room used to read every thread you don't follow (#314).
|
||||||
|
expect(await threadUnread()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||||
const room = await createRoom(alice, 'Lightbox Room');
|
const room = await createRoom(alice, 'Lightbox Room');
|
||||||
const png = Buffer.from(
|
const png = Buffer.from(
|
||||||
|
|||||||
@@ -29,6 +29,32 @@ async function settled(page: Page) {
|
|||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
|
||||||
|
async function mockAnnouncement(page: Page) {
|
||||||
|
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const cfg = await res.json();
|
||||||
|
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
|
||||||
|
await route.fulfill({ response: res, json: cfg });
|
||||||
|
});
|
||||||
|
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||||
|
const cors = { 'access-control-allow-origin': '*' };
|
||||||
|
if (route.request().url().includes('/heartbeat/')) {
|
||||||
|
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
headers: cors,
|
||||||
|
json: {
|
||||||
|
config: { slug: 'matrix' },
|
||||||
|
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
|
||||||
|
maintenanceList: [],
|
||||||
|
publicGroupList: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('security nudge (#110, #123)', () => {
|
test.describe('security nudge (#110, #123)', () => {
|
||||||
let user: TestUser;
|
let user: TestUser;
|
||||||
|
|
||||||
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
|
|||||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||||
|
test.setTimeout(200_000);
|
||||||
|
await mockAnnouncement(page);
|
||||||
|
await loginUI(page, user);
|
||||||
|
await pastGrace(page);
|
||||||
|
await settled(page);
|
||||||
|
// The announcement wins the one strip slot until it's dismissed.
|
||||||
|
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
|
||||||
|
await expect(announcement).toBeVisible();
|
||||||
|
await expect(strip(page)).toHaveCount(0);
|
||||||
|
await announcement.getByRole('button', { name: 'Dismiss' }).click();
|
||||||
|
await page.reload();
|
||||||
|
await settled(page);
|
||||||
|
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
|
||||||
|
});
|
||||||
|
|
||||||
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
||||||
test.setTimeout(200_000);
|
test.setTimeout(200_000);
|
||||||
await loginUI(page, user);
|
await loginUI(page, user);
|
||||||
|
|||||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.23",
|
"version": "0.25.0-lotus.27",
|
||||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.27/element-call-embedded-0.25.0-lotus.27.tgz",
|
||||||
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
"integrity": "sha512-24ZAvGR/t0C6LWFygZkvtk7HalPUvjERDWWkxpJJf4AZCDOJGuhoOuBrjmO+tUGwIrgLOTN6EFAWSaPpTU9rlQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"dir": "auto",
|
"dir": "auto",
|
||||||
"lang": "en-US",
|
"lang": "en-US",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"background_color": "#0a0a0a",
|
"background_color": "#0a0a0a",
|
||||||
"theme_color": "#980000",
|
"theme_color": "#980000",
|
||||||
@@ -90,7 +89,7 @@
|
|||||||
"name": "New Message",
|
"name": "New Message",
|
||||||
"short_name": "DM",
|
"short_name": "DM",
|
||||||
"description": "Open a new direct message",
|
"description": "Open a new direct message",
|
||||||
"url": "/",
|
"url": "./direct/create/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "public/res/android/android-chrome-96x96.png",
|
"src": "public/res/android/android-chrome-96x96.png",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
|
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin. Re-seeded 2026-10-18 from lotus d7bc6d94 (total 1666894, largest 360238 bytes gzip) after the October audit fix batch (~40 PRs) grew the entry chunk past the old budget.",
|
||||||
"totalGzipBytes": 1731120,
|
"totalGzipBytes": 1833583,
|
||||||
"largestChunkGzipBytes": 358317
|
"largestChunkGzipBytes": 396262
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getMemberName } from '../utils/room';
|
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
@@ -86,9 +86,16 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
|
|||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
import { useReconnectWhenSynced } from '../hooks/useCallError';
|
||||||
|
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
const PIP_MIN_H = 112;
|
||||||
|
/** [Gitea #323] Minimised PiP bar size. */
|
||||||
|
const PIP_MINI_W = 220;
|
||||||
|
const PIP_MINI_H = 40;
|
||||||
|
/** Default PiP top: just below the 64px-ish room header. */
|
||||||
|
const PIP_DEFAULT_TOP = 72;
|
||||||
|
|
||||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||||
|
|
||||||
@@ -111,6 +118,8 @@ type IncomingCallInfo = {
|
|||||||
intent?: string;
|
intent?: string;
|
||||||
notificationType: RTCNotificationType;
|
notificationType: RTCNotificationType;
|
||||||
refEventId: string;
|
refEventId: string;
|
||||||
|
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
|
||||||
|
notificationEventId?: string;
|
||||||
};
|
};
|
||||||
type IncomingCallProps = {
|
type IncomingCallProps = {
|
||||||
dm: boolean;
|
dm: boolean;
|
||||||
@@ -283,7 +292,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
size="400"
|
size="400"
|
||||||
radii="400"
|
radii="400"
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
onClick={() =>
|
||||||
|
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||||
|
}
|
||||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
@@ -455,7 +466,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
onClick={() =>
|
||||||
|
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||||
|
}
|
||||||
before={<Icon size="100" src={Icons.Cross} filled />}
|
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
@@ -496,6 +509,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||||
async (event, room, toStartOfTimeline, removed, data) => {
|
async (event, room, toStartOfTimeline, removed, data) => {
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
// only process rtc notification reference events.
|
// only process rtc notification reference events.
|
||||||
// we do not want to wait to decrypt all events.
|
// we do not want to wait to decrypt all events.
|
||||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||||
@@ -596,6 +610,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
: undefined,
|
: undefined,
|
||||||
notificationType,
|
notificationType,
|
||||||
refEventId,
|
refEventId,
|
||||||
|
notificationEventId: event.getId(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||||
@@ -604,7 +619,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
// stale RTC session). The caller's membership can arrive just after the
|
// stale RTC session). The caller's membership can arrive just after the
|
||||||
// ring, so an absent one gets a short grace period before giving up.
|
// ring, so an absent one gets a short grace period before giving up.
|
||||||
const myUserId = mx.getSafeUserId();
|
const myUserId = mx.getSafeUserId();
|
||||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
if (
|
||||||
|
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||||
|
hasActiveCallMembership(room, myUserId)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasActiveCallMembership(room, sender)) {
|
if (hasActiveCallMembership(room, sender)) {
|
||||||
@@ -613,7 +631,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (Date.now() >= senderTs + lifetime) return;
|
if (Date.now() >= senderTs + lifetime) return;
|
||||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
if (
|
||||||
|
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||||
|
hasActiveCallMembership(room, myUserId)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||||
@@ -683,9 +704,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||||
const related = event.getRelation()?.event_id;
|
const related = event.getRelation()?.event_id;
|
||||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
// [Gitea #313] Correct clients reference the notification; older Lotus
|
||||||
|
// builds referenced the call membership. Accept both.
|
||||||
|
if (!related || related === refEventId || related === callInfo.notificationEventId)
|
||||||
|
dismiss(); // declined elsewhere
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||||
@@ -793,6 +818,8 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
useCallEndedToast(embed);
|
useCallEndedToast(embed);
|
||||||
useScreenshareNotices(embed);
|
useScreenshareNotices(embed);
|
||||||
|
useReconnectWhenSynced(embed.control);
|
||||||
|
useDenoiseNotices(embed);
|
||||||
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||||
// as the host bar's (hidden where the server would refuse the share).
|
// as the host bar's (hidden where the server would refuse the share).
|
||||||
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
@@ -981,6 +1008,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||||
|
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
|
||||||
|
// instead of the video. Remembered; phones start minimised.
|
||||||
|
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem('pip-minimized');
|
||||||
|
if (v !== null) return v === '1';
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
return window.innerWidth < 600;
|
||||||
|
});
|
||||||
|
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
|
||||||
|
const togglePipMinimized = useCallback(() => {
|
||||||
|
setPipMinimized((m) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('pip-minimized', m ? '0' : '1');
|
||||||
|
} catch {
|
||||||
|
/* storage unavailable */
|
||||||
|
}
|
||||||
|
return !m;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onFsChange);
|
document.addEventListener('fullscreenchange', onFsChange);
|
||||||
@@ -1046,8 +1095,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
|
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
|
||||||
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
|
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
|
||||||
} else {
|
} else {
|
||||||
el.style.left = `${window.innerWidth - 280 - 16}px`;
|
// [Gitea #323] First use: top-right under the room header, over the
|
||||||
el.style.top = `${window.innerHeight - 158 - 72}px`;
|
// timeline. Bottom-right sat on the composer (Send / Emoji, and on
|
||||||
|
// phones the whole message box), so typing landed on the call.
|
||||||
|
el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`;
|
||||||
|
el.style.top = `${PIP_DEFAULT_TOP}px`;
|
||||||
}
|
}
|
||||||
el.style.width = '280px';
|
el.style.width = '280px';
|
||||||
el.style.height = '158px';
|
el.style.height = '158px';
|
||||||
@@ -1078,6 +1130,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
}
|
}
|
||||||
}, [pipMode, callVisible]);
|
}, [pipMode, callVisible]);
|
||||||
|
|
||||||
|
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
|
||||||
|
// below has positioned and sized it). Keeps the bar on screen.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = callEmbedRef.current;
|
||||||
|
if (!el || !pipMode) return;
|
||||||
|
if (pipMinimized) {
|
||||||
|
if (el.offsetHeight > PIP_MINI_H) {
|
||||||
|
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
|
||||||
|
}
|
||||||
|
el.style.width = `${PIP_MINI_W}px`;
|
||||||
|
el.style.height = `${PIP_MINI_H}px`;
|
||||||
|
} else {
|
||||||
|
el.style.width = `${pipFullSizeRef.current.width}px`;
|
||||||
|
el.style.height = `${pipFullSizeRef.current.height}px`;
|
||||||
|
}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
|
||||||
|
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
|
||||||
|
}, [pipMode, pipMinimized, callEmbedRef]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!pipMode) return;
|
if (!pipMode) return;
|
||||||
const onPipWindowResize = (): void => {
|
const onPipWindowResize = (): void => {
|
||||||
@@ -1396,19 +1468,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
inset: 0,
|
||||||
zIndex: 1,
|
zIndex: 1,
|
||||||
background: 'transparent',
|
// Minimised: cover the frame so only the bar shows.
|
||||||
|
background: pipMinimized ? color.Surface.Container : 'transparent',
|
||||||
cursor: 'grab',
|
cursor: 'grab',
|
||||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||||
// inside the frame: let the click reach its Share button.
|
// inside the frame: let the click reach its Share button.
|
||||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: pipMinimized ? 'center' : 'flex-start',
|
||||||
padding: '6px',
|
padding: '6px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
<div
|
||||||
{document.fullscreenEnabled && (
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: config.space.S100,
|
||||||
|
alignItems: 'center',
|
||||||
|
...(pipMinimized ? { width: '100%' } : {}),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Leftmost: the top-right corner is a resize handle. */}
|
||||||
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Surface"
|
||||||
|
fill="None"
|
||||||
|
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
|
||||||
|
aria-pressed={pipMinimized}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePipMinimized();
|
||||||
|
}}
|
||||||
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
|
onTouchStart={(e) => e.stopPropagation()}
|
||||||
|
style={{
|
||||||
|
background: 'rgba(0,0,0,0.65)',
|
||||||
|
backdropFilter: 'blur(4px)',
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
|
||||||
|
</IconButton>
|
||||||
|
{!pipMinimized && document.fullscreenEnabled && (
|
||||||
<IconButton
|
<IconButton
|
||||||
type="button"
|
type="button"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -1440,14 +1543,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
...(pipMinimized
|
||||||
|
? {
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
↗ Return to call
|
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PipMuteOverlay callEmbed={callEmbed} />
|
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
|
||||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
|
||||||
const s = corner.includes('s');
|
const s = corner.includes('s');
|
||||||
const e2 = corner.includes('e');
|
const e2 = corner.includes('e');
|
||||||
const dots = [
|
const dots = [
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
|||||||
export function CloseBehaviorPrompt() {
|
export function CloseBehaviorPrompt() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
||||||
|
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
|
||||||
|
// tray" would hide the window with no icon to bring it back. Older desktop
|
||||||
|
// builds lack the command; assume a tray then (the old behaviour).
|
||||||
|
const [hasTray, setHasTray] = useState(true);
|
||||||
const modalStyle = useModalStyle(420);
|
const modalStyle = useModalStyle(420);
|
||||||
|
|
||||||
// Tells the native side we're listening (see get_close_behavior).
|
// Tells the native side we're listening (see get_close_behavior).
|
||||||
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
|
|||||||
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useTauriEvent('lotus-close-requested', () => setOpen(true));
|
useTauriEvent('lotus-close-requested', () => {
|
||||||
|
const ask = tauriInvoke()?.('has_tray_host');
|
||||||
|
if (!ask) {
|
||||||
|
setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ask
|
||||||
|
.then((v) => setHasTray(v !== false))
|
||||||
|
.catch(() => setHasTray(true))
|
||||||
|
.finally(() => setOpen(true));
|
||||||
|
});
|
||||||
|
|
||||||
const choose = useCallback(
|
const choose = useCallback(
|
||||||
(value: 'tray' | 'quit') => {
|
(value: 'tray' | 'quit') => {
|
||||||
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
|
|||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: '#close-behavior-keep',
|
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
|
||||||
// Dismissing without choosing keeps the window open; the next close
|
// Dismissing without choosing keeps the window open; the next close
|
||||||
// asks again.
|
// asks again.
|
||||||
onDeactivate: () => setOpen(false),
|
onDeactivate: () => setOpen(false),
|
||||||
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
|
|||||||
</Header>
|
</Header>
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
<Text priority="400" id="close-behavior-body">
|
<Text priority="400" id="close-behavior-body">
|
||||||
Lotus Chat can keep running in the system tray when you close this window, so
|
{hasTray
|
||||||
messages, notifications and calls still reach you. Open it again from the tray icon.
|
? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.'
|
||||||
|
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
|
||||||
</Text>
|
</Text>
|
||||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
|
|||||||
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
|
{hasTray ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-keep"
|
||||||
|
variant="Primary"
|
||||||
|
onClick={() => choose('tray')}
|
||||||
|
>
|
||||||
<Text size="B400">Keep running in the tray</Text>
|
<Text size="B400">Keep running in the tray</Text>
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
<Button
|
||||||
|
id="close-behavior-quit"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => choose('quit')}
|
||||||
|
>
|
||||||
<Text size="B400">Quit when I close the window</Text>
|
<Text size="B400">Quit when I close the window</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-quit"
|
||||||
|
variant="Primary"
|
||||||
|
onClick={() => choose('quit')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Quit when I close the window</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-keep"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => choose('tray')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Keep running in the background</Text>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
You can change this in Settings → General.
|
You can change this in Settings → General.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() {
|
|||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [blockedCount, setBlockedCount] = useState(0);
|
const [blockedCount, setBlockedCount] = useState(0);
|
||||||
const snoozedUntil = useRef(0);
|
const snoozedUntil = useRef(0);
|
||||||
|
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
|
||||||
|
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
|
||||||
|
const closingForRetry = useRef(false);
|
||||||
const blocked = useRef(new Map<MatrixEvent, Room>());
|
const blocked = useRef(new Map<MatrixEvent, Room>());
|
||||||
const modalStyle = useModalStyle(440);
|
const modalStyle = useModalStyle(440);
|
||||||
const server = mx.getDomain() ?? 'Your homeserver';
|
const server = mx.getDomain() ?? 'Your homeserver';
|
||||||
@@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() {
|
|||||||
}, [consentUri]);
|
}, [consentUri]);
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
|
closingForRetry.current = true;
|
||||||
|
snoozedUntil.current = 0;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
const pending = Array.from(blocked.current.entries());
|
const pending = Array.from(blocked.current.entries());
|
||||||
@@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() {
|
|||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: '#consent-review',
|
initialFocus: '#consent-review',
|
||||||
fallbackFocus: '#consent-later',
|
fallbackFocus: '#consent-later',
|
||||||
onDeactivate: later,
|
onDeactivate: () => {
|
||||||
|
if (closingForRetry.current) {
|
||||||
|
closingForRetry.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
later();
|
||||||
|
},
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: true,
|
escapeDeactivates: true,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { forwardRef, useCallback } from 'react';
|
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
|||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||||
import { InfoCard } from './info-card';
|
import { InfoCard } from './info-card';
|
||||||
|
import { ScheduledMessage } from '../state/scheduledMessages';
|
||||||
|
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
|
||||||
import {
|
import {
|
||||||
useDeviceVerificationStatus,
|
useDeviceVerificationStatus,
|
||||||
VerificationStatus,
|
VerificationStatus,
|
||||||
@@ -36,8 +38,25 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
|
|
||||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||||
|
|
||||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
// [Gitea #322] Scheduled messages live on the server and still send after
|
||||||
useCallback(async () => {
|
// logout, where no client would show them. Say so, and offer to cancel.
|
||||||
|
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
fetchScheduledMessages(mx)
|
||||||
|
.then((list) => alive && setScheduled(list))
|
||||||
|
.catch(() => undefined);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
|
||||||
|
useCallback(
|
||||||
|
async (cancelScheduled?: boolean) => {
|
||||||
|
if (cancelScheduled) {
|
||||||
|
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
|
||||||
|
}
|
||||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||||
// everyone else sees you "in call" until it times out.
|
// everyone else sees you "in call" until it times out.
|
||||||
@@ -46,7 +65,9 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
setCallEmbed(undefined);
|
setCallEmbed(undefined);
|
||||||
}
|
}
|
||||||
await logoutClient(mx);
|
await logoutClient(mx);
|
||||||
}, [mx, callEmbed, setCallEmbed]),
|
},
|
||||||
|
[mx, callEmbed, setCallEmbed, scheduled],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||||
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{scheduled.length > 0 && (
|
||||||
|
<InfoCard
|
||||||
|
variant="Warning"
|
||||||
|
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
|
||||||
|
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||||||
{logoutState.status === AsyncStatus.Error && (
|
{logoutState.status === AsyncStatus.Error && (
|
||||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button
|
<Button
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
onClick={logout}
|
onClick={() => logout()}
|
||||||
disabled={ongoingLogout}
|
disabled={ongoingLogout}
|
||||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">Logout</Text>
|
<Text size="B400">
|
||||||
|
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
|
||||||
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
{scheduled.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => logout(true)}
|
||||||
|
disabled={ongoingLogout}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel scheduled messages and logout</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
||||||
<Text size="B400">Cancel</Text>
|
<Text size="B400">Cancel</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
|
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
|
||||||
>
|
>
|
||||||
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
|
|||||||
<MenuItem
|
<MenuItem
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-selected={value === ManualVerificationMethod.RecoveryKey}
|
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
|||||||
type Modal500Props = {
|
type Modal500Props = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
|
||||||
|
label: string;
|
||||||
};
|
};
|
||||||
export function Modal500({ requestClose, children }: Modal500Props) {
|
export function Modal500({ requestClose, children, label }: Modal500Props) {
|
||||||
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||||
return (
|
return (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
@@ -26,6 +28,7 @@ export function Modal500({ requestClose, children }: Modal500Props) {
|
|||||||
variant="Background"
|
variant="Background"
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
|
aria-label={label}
|
||||||
// On mobile expand to fill the viewport. On desktop fall back to the
|
// On mobile expand to fill the viewport. On desktop fall back to the
|
||||||
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
// folds `size="500"` width (~50rem) — overriding maxWidth here would
|
||||||
// squish the two-pane settings layout.
|
// squish the two-pane settings layout.
|
||||||
|
|||||||
@@ -117,7 +117,14 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||||||
|
|
||||||
const renderPlaceholder = useCallback(
|
const renderPlaceholder = useCallback(
|
||||||
({ attributes, children }: RenderPlaceholderProps) => (
|
({ attributes, children }: RenderPlaceholderProps) => (
|
||||||
<span {...attributes} className={css.EditorPlaceholderContainer}>
|
<span
|
||||||
|
{...attributes}
|
||||||
|
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
|
||||||
|
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
|
||||||
|
// measured 2.66:1. Drop it so the class decides.
|
||||||
|
style={{ ...attributes.style, opacity: undefined }}
|
||||||
|
className={css.EditorPlaceholderContainer}
|
||||||
|
>
|
||||||
{/* Inner component to style the actual text position and appearance */}
|
{/* Inner component to style the actual text position and appearance */}
|
||||||
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||||
@@ -612,7 +612,13 @@ export function EmojiBoard({
|
|||||||
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
||||||
isKeyBackward: (evt: KeyboardEvent) =>
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
||||||
escapeDeactivates: stopPropagation,
|
// [Gitea #304] The board opens with focus in its search field, where
|
||||||
|
// the shared stopPropagation helper ignores Escape. Always close here,
|
||||||
|
// without letting the key also close the room/modal underneath.
|
||||||
|
escapeDeactivates: (evt: KeyboardEvent) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiBoardLayout
|
<EmojiBoardLayout
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { SettingTile } from '../setting-tile';
|
|||||||
import { UsageSwitcher } from './UsageSwitcher';
|
import { UsageSwitcher } from './UsageSwitcher';
|
||||||
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
|
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||||
import { CompactUploadCardRenderer } from '../upload-card';
|
import { CompactUploadCardRenderer } from '../upload-card';
|
||||||
import { UploadSuccess } from '../../state/upload';
|
import { UploadSuccess } from '../../state/upload';
|
||||||
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
||||||
@@ -60,7 +60,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
|
|||||||
[imagePack, savedImages, uploadedImages],
|
[imagePack, savedImages, uploadedImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickFiles = useFilePicker(
|
const pickFiles = useStrippedFilePicker(
|
||||||
useCallback(
|
useCallback(
|
||||||
(pickedFiles: File[]) => {
|
(pickedFiles: File[]) => {
|
||||||
const uniqueFiles = pickedFiles.map((file) => {
|
const uniqueFiles = pickedFiles.map((file) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
||||||
import { CompactUploadCardRenderer } from '../upload-card';
|
import { CompactUploadCardRenderer } from '../upload-card';
|
||||||
@@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [imageFile]);
|
}, [imageFile]);
|
||||||
|
|
||||||
const pickFile = useFilePicker(setImageFile, false);
|
const pickFile = useStrippedFilePicker(setImageFile, false);
|
||||||
|
|
||||||
const handleRemoveUpload = useCallback(() => {
|
const handleRemoveUpload = useCallback(() => {
|
||||||
setImageFile(undefined);
|
setImageFile(undefined);
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
|
|||||||
key={getUsageStr(usage)}
|
key={getUsageStr(usage)}
|
||||||
size="300"
|
size="300"
|
||||||
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
|
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
|
||||||
aria-selected={isSelected(usage)}
|
aria-pressed={isSelected(usage)}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => onChange(usage)}
|
onClick={() => onChange(usage)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
|
||||||
|
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
|
|
||||||
|
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
|
||||||
|
const lotusBase = useLotusShareBase();
|
||||||
const inviteUrl = (() => {
|
const inviteUrl = (() => {
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||||
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
return room.isSpaceRoom()
|
||||||
|
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
|
||||||
|
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
|||||||
import { formatAccuracy } from '../../utils/geoShare';
|
import { formatAccuracy } from '../../utils/geoShare';
|
||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
import { previewUrlsFor } from '../../utils/linkSafety';
|
||||||
|
|
||||||
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||||
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||||
@@ -343,7 +344,13 @@ export function MText({
|
|||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body);
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||||
|
const urls = urlsMatch
|
||||||
|
? previewUrlsFor(
|
||||||
|
[...new Set(urlsMatch)],
|
||||||
|
typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -401,7 +408,13 @@ export function MEmote({
|
|||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body);
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||||
|
const urls = urlsMatch
|
||||||
|
? previewUrlsFor(
|
||||||
|
[...new Set(urlsMatch)],
|
||||||
|
typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -458,7 +471,13 @@ export function MNotice({
|
|||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body);
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||||
|
const urls = urlsMatch
|
||||||
|
? previewUrlsFor(
|
||||||
|
[...new Set(urlsMatch)],
|
||||||
|
typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
||||||
import {
|
import {
|
||||||
@@ -99,7 +99,21 @@ export function PollContent({
|
|||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const roomId = room.roomId;
|
const roomId = room.roomId;
|
||||||
const eventId = mEvent.getId();
|
// [Gitea #284] A poll you just created starts with its local-echo id
|
||||||
|
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
|
||||||
|
// doesn't re-render us. Track the id so votes and the tally use the real one.
|
||||||
|
const [eventId, setEventId] = useState(() => mEvent.getId());
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setEventId(mEvent.getId());
|
||||||
|
sync();
|
||||||
|
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||||
|
mEvent.on(MatrixEventEvent.Status, sync);
|
||||||
|
return () => {
|
||||||
|
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||||
|
mEvent.off(MatrixEventEvent.Status, sync);
|
||||||
|
};
|
||||||
|
}, [mEvent]);
|
||||||
|
const isLocalEcho = !!eventId?.startsWith('~');
|
||||||
const senderId = mEvent.getSender();
|
const senderId = mEvent.getSender();
|
||||||
|
|
||||||
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||||
@@ -192,8 +206,9 @@ export function PollContent({
|
|||||||
const isMultiple = maxSelections > 1;
|
const isMultiple = maxSelections > 1;
|
||||||
|
|
||||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||||
const canVote = !!roomId && !!eventId && !isEnded;
|
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
|
||||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
const canEnd =
|
||||||
|
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||||
|
|
||||||
const myVotes = pending ?? tally.myVotes;
|
const myVotes = pending ?? tally.myVotes;
|
||||||
const { counts, total, voters } = tally;
|
const { counts, total, voters } = tally;
|
||||||
@@ -219,15 +234,21 @@ export function PollContent({
|
|||||||
setPending(next);
|
setPending(next);
|
||||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||||
// accepts both namespaces).
|
// accepts both namespaces).
|
||||||
|
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||||
|
try {
|
||||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
'm.selections': Array.from(next),
|
'm.selections': Array.from(next),
|
||||||
}).catch(() => setPending(null));
|
}).catch(() => setPending(null));
|
||||||
|
} catch {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndPoll = () => {
|
const handleEndPoll = () => {
|
||||||
if (!roomId || !eventId || ending) return;
|
if (!roomId || !eventId || ending) return;
|
||||||
setEnding(true);
|
setEnding(true);
|
||||||
|
try {
|
||||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
'm.poll.end': {},
|
'm.poll.end': {},
|
||||||
@@ -238,6 +259,9 @@ export function PollContent({
|
|||||||
setEnding(false);
|
setEnding(false);
|
||||||
})
|
})
|
||||||
.catch(() => setEnding(false));
|
.catch(() => setEnding(false));
|
||||||
|
} catch {
|
||||||
|
setEnding(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||||
|
import { isDndPresence } from '../../utils/busyPresence';
|
||||||
|
|
||||||
const PresenceToColor: Record<Presence, MainColor> = {
|
const PresenceToColor: Record<Presence, MainColor> = {
|
||||||
[Presence.Online]: 'Success',
|
[Presence.Online]: 'Success',
|
||||||
[Presence.Unavailable]: 'Warning',
|
[Presence.Unavailable]: 'Warning',
|
||||||
[Presence.Offline]: 'Secondary',
|
[Presence.Offline]: 'Secondary',
|
||||||
|
[Presence.Busy]: 'Critical',
|
||||||
};
|
};
|
||||||
|
|
||||||
type PresenceBadgeProps = {
|
type PresenceBadgeProps = {
|
||||||
@@ -31,9 +33,11 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
||||||
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
||||||
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
||||||
const isDnd = presence === Presence.Unavailable && status === 'dnd';
|
// [Gitea #283] or MSC3026 busy, which keeps a real status line.
|
||||||
|
const isDnd = isDndPresence(presence, status);
|
||||||
|
const legacyDnd = isDnd && status === 'dnd';
|
||||||
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
||||||
const displayStatus = isDnd ? undefined : status;
|
const displayStatus = legacyDnd ? undefined : status;
|
||||||
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
|||||||
|
|
||||||
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
|
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
|
||||||
if (!presence || presence === Presence.Offline) return null;
|
if (!presence || presence === Presence.Offline) return null;
|
||||||
|
if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND
|
||||||
if (presence === Presence.Unavailable) {
|
if (presence === Presence.Unavailable) {
|
||||||
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
|
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box alignItems="Center" gap="300">
|
// data-setting-tile: lets a group card draw dividers between tiles only (#273).
|
||||||
|
<Box alignItems="Center" gap="300" data-setting-tile="">
|
||||||
{before && <Box shrink="No">{before}</Box>}
|
{before && <Box shrink="No">{before}</Box>}
|
||||||
<Box grow="Yes" direction="Column" gap="100">
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
{title && (
|
{title && (
|
||||||
@@ -43,7 +44,9 @@ export function SettingTile({ title, description, before, after, children }: Set
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{description && (
|
||||||
<Text className={BreakWord} size="T200" priority="300">
|
// [Gitea #291] A <div>, not a <p>: many tiles pass blocks (badges,
|
||||||
|
// <Box>, <Text>) as the description, which is invalid inside a <p>.
|
||||||
|
<Text as="div" className={BreakWord} size="T200" priority="300">
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|||||||
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedDay === day}
|
aria-pressed={selectedDay === day}
|
||||||
onClick={() => handleDay(day)}
|
onClick={() => handleDay(day)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
|
||||||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|||||||
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedMonth === month}
|
aria-pressed={selectedMonth === month}
|
||||||
onClick={() => handleMonth(month)}
|
onClick={() => handleMonth(month)}
|
||||||
disabled={
|
disabled={
|
||||||
(selectedYear === minYear && month < minMonth) ||
|
(selectedYear === minYear && month < minMonth) ||
|
||||||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
|
|||||||
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedYear === year}
|
aria-pressed={selectedYear === year}
|
||||||
onClick={() => handleYear(year)}
|
onClick={() => handleYear(year)}
|
||||||
>
|
>
|
||||||
<Text size="T300">{year}</Text>
|
<Text size="T300">{year}</Text>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={hour === selectedHour}
|
aria-pressed={hour === selectedHour}
|
||||||
onClick={() => handleHour(hour)}
|
onClick={() => handleHour(hour)}
|
||||||
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
|
||||||
>
|
>
|
||||||
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
variant={hour === selectedHour ? 'Primary' : 'Background'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={hour === selectedHour}
|
aria-pressed={hour === selectedHour}
|
||||||
onClick={() => handleHour(hour)}
|
onClick={() => handleHour(hour)}
|
||||||
disabled={
|
disabled={
|
||||||
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
|
||||||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
variant={minute === selectedMinute ? 'Primary' : 'Background'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={minute === selectedMinute}
|
aria-pressed={minute === selectedMinute}
|
||||||
onClick={() => handleMinute(minute)}
|
onClick={() => handleMinute(minute)}
|
||||||
disabled={
|
disabled={
|
||||||
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
(minDay && hour24 === minHour24 && minute < minMinute) ||
|
||||||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={!selectedPM}
|
aria-pressed={!selectedPM}
|
||||||
onClick={() => handlePeriod(false)}
|
onClick={() => handlePeriod(false)}
|
||||||
disabled={minDay && minPM}
|
disabled={minDay && minPM}
|
||||||
>
|
>
|
||||||
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
|
|||||||
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
|
||||||
fill="None"
|
fill="None"
|
||||||
radii="300"
|
radii="300"
|
||||||
aria-selected={selectedPM}
|
aria-pressed={selectedPM}
|
||||||
onClick={() => handlePeriod(true)}
|
onClick={() => handlePeriod(true)}
|
||||||
disabled={maxDay && !maxPM}
|
disabled={maxDay && !maxPM}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -310,9 +310,13 @@ export function UploadCardRenderer({
|
|||||||
const { file } = upload;
|
const { file } = upload;
|
||||||
const fileSizeExceeded = file.size >= allowSize;
|
const fileSizeExceeded = file.size >= allowSize;
|
||||||
|
|
||||||
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
// [Gitea #291] Start in an effect, not during render: the desktop taskbar
|
||||||
startUpload();
|
// progress hook subscribes to the upload atoms, so a render-time start
|
||||||
}
|
// updated another component mid-render (React warning on every image send).
|
||||||
|
const idle = upload.status === UploadStatus.Idle;
|
||||||
|
useEffect(() => {
|
||||||
|
if (idle && !fileSizeExceeded) startUpload();
|
||||||
|
}, [idle, fileSizeExceeded, startUpload]);
|
||||||
|
|
||||||
const handleSpoiler = (marked: boolean) => {
|
const handleSpoiler = (marked: boolean) => {
|
||||||
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
GIF_PREVIEW_MAX_WIDTH,
|
GIF_PREVIEW_MAX_WIDTH,
|
||||||
gifPreviewBox,
|
gifPreviewBox,
|
||||||
} from '../../utils/gifPreviewSize';
|
} from '../../utils/gifPreviewSize';
|
||||||
|
import { sameProvider } from './embedProvider';
|
||||||
|
|
||||||
const linkStyles = { color: color.Success.Main };
|
const linkStyles = { color: color.Success.Main };
|
||||||
|
|
||||||
@@ -2497,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||||
if (embed) return embed;
|
if (embed) return embed;
|
||||||
const ogUrl = prev['og:url'];
|
const ogUrl = prev['og:url'];
|
||||||
return typeof ogUrl === 'string' && ogUrl !== url
|
// [Gitea #326] …but only from an og:url on the posted link's own
|
||||||
|
// provider: the page sets og:url, so any site could otherwise show up
|
||||||
|
// as a playable YouTube (etc.) card that links to itself.
|
||||||
|
return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl)
|
||||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { sameProvider } from './embedProvider';
|
||||||
|
|
||||||
|
test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => {
|
||||||
|
const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
assert.equal(sameProvider('https://evil.example/free-gift', yt), false);
|
||||||
|
assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false);
|
||||||
|
assert.equal(sameProvider('https://youtu.be/abc', yt), true);
|
||||||
|
assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true);
|
||||||
|
assert.equal(
|
||||||
|
sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true);
|
||||||
|
assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true);
|
||||||
|
assert.equal(
|
||||||
|
sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(sameProvider('not a url', yt), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #326] A page's og:url is chosen by the page itself, so a phishing
|
||||||
|
* link could serve `og:url = youtube.com/watch?v=…` and render as a playable
|
||||||
|
* YouTube card linking to the attacker. Only trust an og:url on the same
|
||||||
|
* provider as the link actually posted (including the provider's own
|
||||||
|
* short-link domains).
|
||||||
|
*/
|
||||||
|
const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
|
'youtu.be': 'youtube.com',
|
||||||
|
'youtube-nocookie.com': 'youtube.com',
|
||||||
|
'spotify.link': 'spotify.com',
|
||||||
|
'redd.it': 'reddit.com',
|
||||||
|
'x.com': 'twitter.com',
|
||||||
|
'twitch.tv': 'twitch.tv',
|
||||||
|
'apple.co': 'apple.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: the provider a host belongs to (last two labels, aliases folded). Hosts
|
||||||
|
* under two-part public suffixes (`*.co.uk`) share a value, which is harmless
|
||||||
|
* here: no embeddable provider lives under one.
|
||||||
|
*/
|
||||||
|
export const providerOf = (host: string): string => {
|
||||||
|
const parts = host.toLowerCase().replace(/\.$/, '').split('.');
|
||||||
|
const base = parts.slice(-2).join('.');
|
||||||
|
return PROVIDER_ALIASES[base] ?? base;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */
|
||||||
|
export function sameProvider(url: string, ogUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,7 +33,9 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
|
|||||||
<AvatarImage
|
<AvatarImage
|
||||||
className={classNames(css.UserAvatar, className)}
|
className={classNames(css.UserAvatar, className)}
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
// [Gitea #278] An avatar sits next to the person's name or inside a
|
||||||
|
// labelled button, so without an explicit alt it is decorative.
|
||||||
|
alt={alt ?? ''}
|
||||||
onError={() => setError(true)}
|
onError={() => setError(true)}
|
||||||
onLoad={handleLoad}
|
onLoad={handleLoad}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -286,10 +286,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
|||||||
</Box>
|
</Box>
|
||||||
<textarea
|
<textarea
|
||||||
aria-label="Private note about this user"
|
aria-label="Private note about this user"
|
||||||
|
aria-describedby="private-note-storage"
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
maxLength={USER_NOTE_MAX_LENGTH}
|
maxLength={USER_NOTE_MAX_LENGTH}
|
||||||
placeholder="Notes only visible to you…"
|
placeholder="Add a private note…"
|
||||||
rows={3}
|
rows={3}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
@@ -305,6 +306,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
|||||||
outline: 'none',
|
outline: 'none',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* [Gitea #329] Account data isn't end-to-end encrypted: say so. */}
|
||||||
|
<Text id="private-note-storage" size="T200" priority="300">
|
||||||
|
Only you can see this in Lotus Chat. It's stored on your homeserver and isn't
|
||||||
|
end-to-end encrypted.
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||||
|
|
||||||
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
|
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
|
||||||
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
|
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
|
||||||
export const BookmarksPanel = style({
|
export const BookmarksPanel = style({
|
||||||
width: toRem(266),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import { CallEmbed } from '../../plugins/call';
|
import { CallEmbed } from '../../plugins/call';
|
||||||
|
import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
|
|
||||||
@@ -39,6 +40,21 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
|||||||
const [devices, setDevices] = useState<OutputDevice[]>([]);
|
const [devices, setDevices] = useState<OutputDevice[]>([]);
|
||||||
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
|
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
|
||||||
|
|
||||||
|
// [Gitea #310] Prefer the fork's list: its ids are the ones valid inside
|
||||||
|
// the call iframe. Older forks don't report one; fall back to our own.
|
||||||
|
const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs);
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = () => {
|
||||||
|
setForkDevices(embed.control.audioOutputs);
|
||||||
|
setSelected(embed.control.audioOutputId);
|
||||||
|
};
|
||||||
|
onState();
|
||||||
|
embed.control.on(AUDIO_OUTPUT_STATE, onState);
|
||||||
|
return () => {
|
||||||
|
embed.control.off(AUDIO_OUTPUT_STATE, onState);
|
||||||
|
};
|
||||||
|
}, [embed]);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const all = await navigator.mediaDevices.enumerateDevices();
|
const all = await navigator.mediaDevices.enumerateDevices();
|
||||||
@@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!anchor) return undefined;
|
if (!anchor || forkDevices) return undefined;
|
||||||
refresh();
|
refresh();
|
||||||
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
||||||
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
||||||
}, [anchor, refresh]);
|
}, [anchor, refresh, forkDevices]);
|
||||||
|
|
||||||
|
const shown = forkDevices ?? devices;
|
||||||
|
|
||||||
const choose = (id: string) => {
|
const choose = (id: string) => {
|
||||||
embed.control.setAudioOutput(id);
|
embed.control.setAudioOutput(id);
|
||||||
@@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
|||||||
aria-label="Audio output"
|
aria-label="Audio output"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
{devices.length === 0 && (
|
{shown.length === 0 && (
|
||||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||||
No audio outputs found.
|
No audio outputs found.
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{devices.map((d) => {
|
{shown.map((d) => {
|
||||||
const isSelected = selected ? d.id === selected : d.id === 'default';
|
const isSelected =
|
||||||
|
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={d.id}
|
key={d.id}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Box, Spinner, Text, color } from 'folds';
|
import { Box, Button, Spinner, Text, color } from 'folds';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { LiveChip } from './LiveChip';
|
import { LiveChip } from './LiveChip';
|
||||||
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { MemberSpeaking } from './MemberSpeaking';
|
import { MemberSpeaking } from './MemberSpeaking';
|
||||||
import { clockSkewAtom } from '../../state/clockSkew';
|
import { clockSkewAtom } from '../../state/clockSkew';
|
||||||
import { describeSkewVsServer } from '../../utils/clockSkew';
|
import { describeSkewVsServer } from '../../utils/clockSkew';
|
||||||
|
import { useCallError } from '../../hooks/useCallError';
|
||||||
|
|
||||||
type CallStatusProps = {
|
type CallStatusProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
@@ -42,6 +43,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||||||
const clockSkew = useAtomValue(clockSkewAtom);
|
const clockSkew = useAtomValue(clockSkewAtom);
|
||||||
|
|
||||||
const compact = screenSize === ScreenSize.Mobile;
|
const compact = screenSize === ScreenSize.Mobile;
|
||||||
|
// [Gitea #318] The call dropped ("Connection lost" in the frame).
|
||||||
|
const callError = useCallError(callEmbed.control);
|
||||||
|
|
||||||
const memberVisible = callJoined && callMembers.length > 0;
|
const memberVisible = callJoined && callMembers.length > 0;
|
||||||
|
|
||||||
@@ -65,6 +68,25 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||||||
{!compact && (
|
{!compact && (
|
||||||
<>
|
<>
|
||||||
<CallRoomName room={room} />
|
<CallRoomName room={room} />
|
||||||
|
{callError && (
|
||||||
|
<>
|
||||||
|
<StatusDivider />
|
||||||
|
<Text size="T200" truncate role="alert" style={{ color: color.Critical.Main }}>
|
||||||
|
Call disconnected
|
||||||
|
</Text>
|
||||||
|
{callError.recoverable && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => callEmbed.control.reconnect()}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reconnect</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{clockSkew.warning && clockSkew.skewMs !== null && (
|
{clockSkew.warning && clockSkew.skewMs !== null && (
|
||||||
<>
|
<>
|
||||||
<StatusDivider />
|
<StatusDivider />
|
||||||
@@ -102,8 +124,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
|||||||
{memberVisible && !compact && <StatusDivider />}
|
{memberVisible && !compact && <StatusDivider />}
|
||||||
<Box shrink="No" alignItems="Center" gap="Inherit">
|
<Box shrink="No" alignItems="Center" gap="Inherit">
|
||||||
{compact && (
|
{compact && (
|
||||||
<Box grow="Yes">
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<CallRoomName room={room} />
|
<CallRoomName room={room} />
|
||||||
|
{callError?.recoverable && (
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => callEmbed.control.reconnect()}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reconnect</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||||
|
import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl';
|
||||||
import { CallSoundboard } from './CallSoundboard';
|
import { CallSoundboard } from './CallSoundboard';
|
||||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||||
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
||||||
@@ -90,6 +91,21 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
|
|
||||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||||
useCallControlState(callEmbed.control);
|
useCallControlState(callEmbed.control);
|
||||||
|
// [Gitea #277] Only offer "Mute Screenshare Audio" while a remote share's
|
||||||
|
// audio is playing, or while it is muted (so a hidden mute can't silently
|
||||||
|
// swallow the next share).
|
||||||
|
const [remoteScreenshareAudio, setRemoteScreenshareAudio] = useState(
|
||||||
|
callEmbed.control.remoteScreenshareAudio,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
const onState = () => setRemoteScreenshareAudio(callEmbed.control.remoteScreenshareAudio);
|
||||||
|
onState();
|
||||||
|
callEmbed.control.on(SCREENSHARE_AUDIO_STATE, onState);
|
||||||
|
return () => {
|
||||||
|
callEmbed.control.off(SCREENSHARE_AUDIO_STATE, onState);
|
||||||
|
};
|
||||||
|
}, [callEmbed]);
|
||||||
|
const showScreenshareAudio = remoteScreenshareAudio || screenshareAudioMuted;
|
||||||
const micLevel = useCallMicLevel(callEmbed);
|
const micLevel = useCallMicLevel(callEmbed);
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
@@ -122,6 +138,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
};
|
};
|
||||||
const showScreenshare = allowScreenshare || screenshare;
|
const showScreenshare = allowScreenshare || screenshare;
|
||||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||||
|
// [Gitea #265] Phones can't share their screen from a browser; don't offer it.
|
||||||
|
const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function';
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
setCords(evt.currentTarget.getBoundingClientRect());
|
setCords(evt.currentTarget.getBoundingClientRect());
|
||||||
};
|
};
|
||||||
@@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
return () => clearTimeout(id);
|
return () => clearTimeout(id);
|
||||||
}, [hangupState.status, callEmbed, setCallEmbed]);
|
}, [hangupState.status, callEmbed, setCallEmbed]);
|
||||||
|
|
||||||
|
const moreMenu = (
|
||||||
|
<PopOut
|
||||||
|
anchor={cords}
|
||||||
|
position="Top"
|
||||||
|
align="Center"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||||
|
{compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setCords(undefined);
|
||||||
|
handleScreenshareToggle();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{screenshare ? 'Stop Screenshare' : 'Share Screen'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{compact && showScreenshareAudio && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setCords(undefined);
|
||||||
|
callEmbed.control.toggleScreenshareAudio();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
{compact && !!document.fullscreenEnabled && (
|
||||||
|
<MenuItem
|
||||||
|
size="300"
|
||||||
|
variant="Surface"
|
||||||
|
radii="300"
|
||||||
|
onClick={() => {
|
||||||
|
setCords(undefined);
|
||||||
|
handleFullscreen();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSpotlightClick}>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem size="300" variant="Surface" radii="300" onClick={handleReactionsClick}>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Reactions
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSettingsClick}>
|
||||||
|
<Text size="B300" truncate>
|
||||||
|
Settings
|
||||||
|
</Text>
|
||||||
|
</MenuItem>
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
variant="Surface"
|
||||||
|
fill="Soft"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={handleOpenMenu}
|
||||||
|
outlined
|
||||||
|
aria-label="More options"
|
||||||
|
aria-expanded={!!cords}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<Icon size="400" src={Icons.VerticalDots} />
|
||||||
|
</IconButton>
|
||||||
|
</PopOut>
|
||||||
|
);
|
||||||
|
|
||||||
|
const hangupIcon = exiting ? (
|
||||||
|
<Spinner variant="Critical" fill="Solid" size="200" />
|
||||||
|
) : (
|
||||||
|
<Icon src={Icons.PhoneDown} size="200" filled />
|
||||||
|
);
|
||||||
|
|
||||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -204,6 +327,44 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
}}
|
}}
|
||||||
onCancel={() => setShareConfirm(false)}
|
onCancel={() => setShareConfirm(false)}
|
||||||
/>
|
/>
|
||||||
|
{compact ? (
|
||||||
|
// [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio
|
||||||
|
// mute and fullscreen live in More (they rarely matter on a phone), and
|
||||||
|
// End is icon-only. The old stacked layout took four rows (~40% of a
|
||||||
|
// portrait phone).
|
||||||
|
<SequenceCard
|
||||||
|
className={css.ControlCard}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
gap="200"
|
||||||
|
radii="500"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="SpaceBetween"
|
||||||
|
wrap="NoWrap"
|
||||||
|
>
|
||||||
|
<MicrophoneButton
|
||||||
|
enabled={microphone}
|
||||||
|
onToggle={handleMicrophoneToggle}
|
||||||
|
level={micLevel}
|
||||||
|
/>
|
||||||
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
|
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||||
|
<ChatButton />
|
||||||
|
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||||
|
{moreMenu}
|
||||||
|
<Button
|
||||||
|
variant="Critical"
|
||||||
|
fill="Solid"
|
||||||
|
radii="400"
|
||||||
|
size="400"
|
||||||
|
onClick={hangup}
|
||||||
|
disabled={exiting}
|
||||||
|
aria-label="End Call"
|
||||||
|
style={{ paddingInline: config.space.S300 }}
|
||||||
|
>
|
||||||
|
{hangupIcon}
|
||||||
|
</Button>
|
||||||
|
</SequenceCard>
|
||||||
|
) : (
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={css.ControlCard}
|
className={css.ControlCard}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -213,7 +374,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
justifyContent="SpaceBetween"
|
justifyContent="SpaceBetween"
|
||||||
wrap="Wrap"
|
wrap="Wrap"
|
||||||
>
|
>
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<MicrophoneButton
|
<MicrophoneButton
|
||||||
enabled={microphone}
|
enabled={microphone}
|
||||||
@@ -222,7 +383,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
/>
|
/>
|
||||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && showVideoGroup && <ControlDivider />}
|
{showVideoGroup && <ControlDivider />}
|
||||||
{showVideoGroup && (
|
{showVideoGroup && (
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
{/* Show a forbidden control while its track is still live so the
|
{/* Show a forbidden control while its track is still live so the
|
||||||
@@ -238,10 +399,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
)}
|
)}
|
||||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||||
control since they're the same concern. */}
|
control since they're the same concern. */}
|
||||||
|
{showScreenshareAudio && (
|
||||||
<ScreenshareAudioButton
|
<ScreenshareAudioButton
|
||||||
muted={screenshareAudioMuted}
|
muted={screenshareAudioMuted}
|
||||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!!document.fullscreenEnabled && (
|
{!!document.fullscreenEnabled && (
|
||||||
@@ -250,77 +413,12 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
{!compact && <ControlDivider />}
|
<ControlDivider />
|
||||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||||
<ChatButton />
|
<ChatButton />
|
||||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||||
<PopOut
|
{moreMenu}
|
||||||
anchor={cords}
|
|
||||||
position="Top"
|
|
||||||
align="Center"
|
|
||||||
content={
|
|
||||||
<FocusTrap
|
|
||||||
focusTrapOptions={{
|
|
||||||
initialFocus: false,
|
|
||||||
onDeactivate: () => setCords(undefined),
|
|
||||||
clickOutsideDeactivates: true,
|
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
|
||||||
escapeDeactivates: stopPropagation,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Menu>
|
|
||||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
variant="Surface"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleSpotlightClick}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
variant="Surface"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleReactionsClick}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
Reactions
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
<MenuItem
|
|
||||||
size="300"
|
|
||||||
variant="Surface"
|
|
||||||
radii="300"
|
|
||||||
onClick={handleSettingsClick}
|
|
||||||
>
|
|
||||||
<Text size="B300" truncate>
|
|
||||||
Settings
|
|
||||||
</Text>
|
|
||||||
</MenuItem>
|
|
||||||
</Box>
|
|
||||||
</Menu>
|
|
||||||
</FocusTrap>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<IconButton
|
|
||||||
variant="Surface"
|
|
||||||
fill="Soft"
|
|
||||||
radii="400"
|
|
||||||
size="400"
|
|
||||||
onClick={handleOpenMenu}
|
|
||||||
outlined
|
|
||||||
aria-label="More options"
|
|
||||||
aria-expanded={!!cords}
|
|
||||||
aria-haspopup="menu"
|
|
||||||
>
|
|
||||||
<Icon size="400" src={Icons.VerticalDots} />
|
|
||||||
</IconButton>
|
|
||||||
</PopOut>
|
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" direction="Column">
|
<Box shrink="No" direction="Column">
|
||||||
<Button
|
<Button
|
||||||
@@ -328,13 +426,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
variant="Critical"
|
variant="Critical"
|
||||||
fill="Solid"
|
fill="Solid"
|
||||||
onClick={hangup}
|
onClick={hangup}
|
||||||
before={
|
before={hangupIcon}
|
||||||
exiting ? (
|
|
||||||
<Spinner variant="Critical" fill="Solid" size="200" />
|
|
||||||
) : (
|
|
||||||
<Icon src={Icons.PhoneDown} size="200" filled />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={exiting}
|
disabled={exiting}
|
||||||
>
|
>
|
||||||
<Text size="B400">End</Text>
|
<Text size="B400">End</Text>
|
||||||
@@ -342,6 +434,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,14 @@ type FlatClip = {
|
|||||||
* A management toggle reveals the pack editors (personal + this room, if
|
* A management toggle reveals the pack editors (personal + this room, if
|
||||||
* permitted). Space-wide packs are managed from Space settings.
|
* permitted). Space-wide packs are managed from Space settings.
|
||||||
*/
|
*/
|
||||||
|
/** [EC#13, EC#47] Why the fork refused to play a clip, for the user. */
|
||||||
|
export const soundboardRefusalText = (reason: string | undefined): string => {
|
||||||
|
if (reason === 'muted') return 'Unmute your microphone to play a soundboard clip.';
|
||||||
|
if (reason === 'not_permitted')
|
||||||
|
return "The soundboard isn't available in this voice channel (only microphone audio is allowed here).";
|
||||||
|
return 'Could not play that clip.';
|
||||||
|
};
|
||||||
|
|
||||||
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { room } = callEmbed;
|
const { room } = callEmbed;
|
||||||
@@ -122,13 +130,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
||||||
if (!result.played) {
|
if (!result.played) {
|
||||||
// [EC#13] Refused fork-side (only reason today: local mic muted) —
|
// [EC#13, EC#47] Refused fork-side (mic muted, or the room's token
|
||||||
|
// only allows the microphone source, as in audio-only rooms) —
|
||||||
// don't play it locally either, or the user would think it went out.
|
// don't play it locally either, or the user would think it went out.
|
||||||
setError(
|
setError(soundboardRefusalText(result.reason));
|
||||||
result.reason === 'muted'
|
|
||||||
? 'Unmute your microphone to play a soundboard clip.'
|
|
||||||
: 'Could not play that clip.',
|
|
||||||
);
|
|
||||||
done();
|
done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import {
|
|||||||
useCallJoined,
|
useCallJoined,
|
||||||
useCallEmbedPlacementSync,
|
useCallEmbedPlacementSync,
|
||||||
useCallLoadError,
|
useCallLoadError,
|
||||||
|
useCallStart,
|
||||||
} from '../../hooks/useCallEmbed';
|
} from '../../hooks/useCallEmbed';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { PrescreenControls } from './PrescreenControls';
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -160,6 +161,16 @@ function CallLoadErrorMessage() {
|
|||||||
// prescreen, where they can choose to join again.
|
// prescreen, where they can choose to join again.
|
||||||
const dismiss = () => setCallEmbed(undefined);
|
const dismiss = () => setCallEmbed(undefined);
|
||||||
|
|
||||||
|
// [Gitea #301] Try again: start a fresh embed for the same room with the
|
||||||
|
// same mic/camera/sound choices (starting one disposes the hung one).
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const startCall = useCallStart(useIsDirectRoom());
|
||||||
|
const retry = () => {
|
||||||
|
if (!callEmbed) return;
|
||||||
|
const { room, control } = callEmbed;
|
||||||
|
startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={css.CallViewContent}
|
className={css.CallViewContent}
|
||||||
@@ -173,6 +184,9 @@ function CallLoadErrorMessage() {
|
|||||||
The call failed to load. Check your connection and try again.
|
The call failed to load. Check your connection and try again.
|
||||||
</Text>
|
</Text>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Button variant="Primary" size="300" radii="400" onClick={retry} disabled={!callEmbed}>
|
||||||
|
<Text size="B400">Try again</Text>
|
||||||
|
</Button>
|
||||||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||||||
<Text size="B400">Back</Text>
|
<Text size="B400">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
|||||||
alignItems="End"
|
alignItems="End"
|
||||||
>
|
>
|
||||||
<Box direction="Column" gap="100" grow="Yes">
|
<Box direction="Column" gap="100" grow="Yes">
|
||||||
<Text size="L400">Name</Text>
|
<Text as="label" htmlFor="room-pack-name" size="L400">
|
||||||
|
Name
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="room-pack-name"
|
||||||
name="nameInput"
|
name="nameInput"
|
||||||
required
|
required
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
|
|||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useAlive } from '../../../hooks/useAlive';
|
import { useAlive } from '../../../hooks/useAlive';
|
||||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
import { buildTopicContent } from '../../../utils/topicMarkdown';
|
||||||
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
|
||||||
|
|
||||||
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
|
|||||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||||
}
|
}
|
||||||
|
|
||||||
function topicMarkdownToHtml(text: string): string {
|
|
||||||
return text
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
||||||
.replace(/__(.+?)__/g, '<strong>$1</strong>')
|
|
||||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
||||||
.replace(/_(.+?)_/g, '<em>$1</em>')
|
|
||||||
.replace(/~~(.+?)~~/g, '<del>$1</del>')
|
|
||||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
|
||||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
|
||||||
.replace(/\n/g, '<br>');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTopicContent(topic: string): Record<string, string> {
|
|
||||||
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
|
|
||||||
const formattedBody = topicMarkdownToHtml(topic);
|
|
||||||
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
|
|
||||||
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
|
|
||||||
|
|
||||||
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
|
|
||||||
}
|
|
||||||
|
|
||||||
type RoomProfileEditProps = {
|
type RoomProfileEditProps = {
|
||||||
canEditAvatar: boolean;
|
canEditAvatar: boolean;
|
||||||
canEditName: boolean;
|
canEditName: boolean;
|
||||||
|
|||||||
Binary file not shown.
@@ -136,7 +136,8 @@ export function PermissionGroups({
|
|||||||
outlined={powerChanges}
|
outlined={powerChanges}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
aria-selected={opened}
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={opened}
|
||||||
disabled={!canEdit || applyingChanges}
|
disabled={!canEdit || applyingChanges}
|
||||||
after={
|
after={
|
||||||
powerChanges && (
|
powerChanges && (
|
||||||
@@ -200,7 +201,8 @@ export function PermissionGroups({
|
|||||||
outlined={powerChanges}
|
outlined={powerChanges}
|
||||||
fill="Soft"
|
fill="Soft"
|
||||||
radii="Pill"
|
radii="Pill"
|
||||||
aria-selected={opened}
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={opened}
|
||||||
disabled={!canEdit || applyingChanges}
|
disabled={!canEdit || applyingChanges}
|
||||||
after={
|
after={
|
||||||
powerChanges && (
|
powerChanges && (
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
|||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useStrippedFilePicker } from '../../../hooks/useStrippedFilePicker';
|
||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
@@ -68,7 +68,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
const [iconFile, setIconFile] = useState<File>();
|
const [iconFile, setIconFile] = useState<File>();
|
||||||
const pickFile = useFilePicker(setIconFile, false);
|
const pickFile = useStrippedFilePicker(setIconFile, false);
|
||||||
|
|
||||||
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
|
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
|
||||||
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
|
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
|
||||||
|
|||||||
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
|
|||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text size="L400">This room / space (shared)</Text>
|
<Text size="L400">
|
||||||
|
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
|
||||||
|
</Text>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
Clips here are shared with everyone, and inherited by every room under this space
|
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
|
||||||
— just like emoji/sticker packs. Only members with permission can edit.
|
{room?.isSpaceRoom()
|
||||||
|
? 'Clips here are shared with everyone, and inherited by every room under this space — just like emoji/sticker packs. Only members with permission can edit.'
|
||||||
|
: 'Clips here are shared with everyone in this room — just like emoji/sticker packs. Only members with permission can edit.'}
|
||||||
</Text>
|
</Text>
|
||||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
||||||
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
||||||
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
||||||
export const DECORATION_CDN =
|
// matrix #22: static files on chat.lotusguild.org (a versioned package installed
|
||||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
// by the matrix repo's LXC 106 deploy), not Nextcloud's public DAV. Absolute, so
|
||||||
|
// the desktop app and the Element Call fork's origin allowlist both work.
|
||||||
|
export const DECORATION_CDN = 'https://chat.lotusguild.org/decorations';
|
||||||
|
|
||||||
// Runtime base. A deployment can repoint the decorations at a different host
|
// Runtime base. A deployment can repoint the decorations at a different host
|
||||||
// without editing the catalog literal above (which scripts/syncDecorations.mjs
|
// without editing the catalog literal above (which scripts/syncDecorations.mjs
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
foldEdits,
|
||||||
filterGroupsByDateRange,
|
filterGroupsByDateRange,
|
||||||
filterGroupsByMsgType,
|
filterGroupsByMsgType,
|
||||||
filterGroupsByPinned,
|
filterGroupsByPinned,
|
||||||
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
|||||||
['$2'],
|
['$2'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
|
||||||
|
({
|
||||||
|
rank: 1,
|
||||||
|
result: {
|
||||||
|
event_id: eventId,
|
||||||
|
room_id: '!r:x',
|
||||||
|
origin_server_ts: ts,
|
||||||
|
type: 'm.room.message',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}) as unknown as Parameters<typeof foldEdits>[0][number];
|
||||||
|
|
||||||
|
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
|
||||||
|
const edit = (id: string, ts: number, body: string) =>
|
||||||
|
sr(id, ts, {
|
||||||
|
body: `* ${body}`,
|
||||||
|
'm.new_content': { body },
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||||
|
});
|
||||||
|
const out = foldEdits([
|
||||||
|
edit('$e2', 30, 'Tuesday 11am room B'),
|
||||||
|
sr('$orig', 10, { body: 'Monday 10am room A' }),
|
||||||
|
edit('$e1', 20, 'Tuesday 10am room A'),
|
||||||
|
sr('$other', 5, { body: 'meeting notes' }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((r) => r.result.event_id),
|
||||||
|
['$orig', '$other'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
|
||||||
|
assert.equal(out[0].result.content.body, 'Monday 10am room A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foldEdits: an edit whose original did not match stands in for it', () => {
|
||||||
|
const out = foldEdits([
|
||||||
|
sr('$e1', 20, {
|
||||||
|
'm.new_content': { body: 'x' },
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].result.event_id, '$orig');
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,10 +96,58 @@ export const filterGroupsByDateRange = (
|
|||||||
.filter((group) => group.items.length > 0);
|
.filter((group) => group.items.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #288] Server search also returns `m.replace` events, so an edited
|
||||||
|
* message showed once per version. Fold each edit into its message: one row,
|
||||||
|
* at the first position any version ranked, showing the latest text and
|
||||||
|
* pointing at the original event (so "jump" lands on the real message).
|
||||||
|
*/
|
||||||
|
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
|
||||||
|
const targetOf = (r: ISearchResult): string => {
|
||||||
|
const rel = r.result.content?.['m.relates_to'];
|
||||||
|
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
|
||||||
|
? rel.event_id
|
||||||
|
: r.result.event_id;
|
||||||
|
};
|
||||||
|
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
|
||||||
|
const order: string[] = [];
|
||||||
|
const originals = new Map<string, ISearchResult>();
|
||||||
|
const latestEdit = new Map<string, ISearchResult>();
|
||||||
|
results.forEach((r) => {
|
||||||
|
const target = targetOf(r);
|
||||||
|
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
|
||||||
|
if (!isEdit(r)) {
|
||||||
|
originals.set(target, r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = latestEdit.get(target);
|
||||||
|
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
|
||||||
|
latestEdit.set(target, r);
|
||||||
|
});
|
||||||
|
return order.map((target) => {
|
||||||
|
const original = originals.get(target);
|
||||||
|
const edit = latestEdit.get(target);
|
||||||
|
const newContent = edit?.result.content?.['m.new_content'];
|
||||||
|
if (original && !newContent) return original;
|
||||||
|
if (original) {
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
result: {
|
||||||
|
...original.result,
|
||||||
|
content: { ...original.result.content, 'm.new_content': newContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Only an edit matched: show it as its message.
|
||||||
|
const only = edit as ISearchResult;
|
||||||
|
return { ...only, result: { ...only.result, event_id: target } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
results.forEach((item) => {
|
foldEdits(results).forEach((item) => {
|
||||||
const roomId = item.result.room_id;
|
const roomId = item.result.room_id;
|
||||||
const resultItem: ResultItem = {
|
const resultItem: ResultItem = {
|
||||||
rank: item.rank,
|
rank: item.rank,
|
||||||
|
|||||||
@@ -94,10 +94,15 @@ export function OutboxFeature() {
|
|||||||
OFFLINE.has(mx.getSyncState()) ||
|
OFFLINE.has(mx.getSyncState()) ||
|
||||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||||
|
|
||||||
const persist = (next: OutboxEntry[]) => {
|
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
|
||||||
if (next === entries) return;
|
// entries since we last looked, and saving our own copy over the key
|
||||||
|
// would drop theirs. Entries are added/removed by txnId, so applying our
|
||||||
|
// change to the stored list merges cleanly.
|
||||||
|
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
|
||||||
|
const stored = loadOutbox(userId);
|
||||||
|
const next = update(stored);
|
||||||
entries = next;
|
entries = next;
|
||||||
saveOutbox(userId, entries);
|
if (next !== stored) saveOutbox(userId, next);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,8 +128,8 @@ export function OutboxFeature() {
|
|||||||
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
||||||
live.set(txnId, { event, room });
|
live.set(txnId, { event, room });
|
||||||
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
||||||
persist(
|
persist((list) =>
|
||||||
withEntry(entries, {
|
withEntry(list, {
|
||||||
txnId,
|
txnId,
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
threadId: event.threadRootId ?? null,
|
threadId: event.threadRootId ?? null,
|
||||||
@@ -138,7 +143,7 @@ export function OutboxFeature() {
|
|||||||
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
||||||
live.delete(txnId);
|
live.delete(txnId);
|
||||||
autoRetries.delete(txnId);
|
autoRetries.delete(txnId);
|
||||||
persist(withoutEntry(entries, txnId));
|
persist((list) => withoutEntry(list, txnId));
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
||||||
@@ -191,7 +196,7 @@ export function OutboxFeature() {
|
|||||||
return !!room && isDelivered(room, entry.txnId);
|
return !!room && isDelivered(room, entry.txnId);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
|
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
|
||||||
plan.restore.forEach((entry) => {
|
plan.restore.forEach((entry) => {
|
||||||
const room = mx.getRoom(entry.roomId);
|
const room = mx.getRoom(entry.roomId);
|
||||||
if (!room || live.has(entry.txnId)) return;
|
if (!room || live.has(entry.txnId)) return;
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
setRoomNotificationPreference,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
|
||||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
@@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
prevMode,
|
prevMode,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
|
||||||
unmuteRoom(mx, room.roomId);
|
} else {
|
||||||
});
|
// An indefinite mute replaces any timed one.
|
||||||
|
await clearMuteTimer(mx, room.roomId);
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { shouldResetMuteOnUnmute } from './muteTimers';
|
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
|
||||||
|
|
||||||
test('resets to Unset when the room is still Mute at expiry', () => {
|
test('resets to Unset when the room is still Mute at expiry', () => {
|
||||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
||||||
@@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute
|
|||||||
test('does not reset when the mode is already Unset', () => {
|
test('does not reset when the mode is already Unset', () => {
|
||||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => {
|
||||||
|
const entries = [
|
||||||
|
{ roomId: '!a:x', unmuteAt: 1000 },
|
||||||
|
{ roomId: '!b:x', unmuteAt: 2000 },
|
||||||
|
];
|
||||||
|
assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries);
|
||||||
|
assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [
|
||||||
|
{ roomId: '!b:x', unmuteAt: 5 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(parseMuteTimers(undefined), []);
|
||||||
|
assert.deepEqual(parseMuteTimers({ timers: null }), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
setRoomNotificationPreference,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
|
||||||
// localStorage key for timed mute timers
|
// Legacy localStorage key (before #333); migrated into account data once.
|
||||||
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||||
|
|
||||||
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||||
@@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
|||||||
|
|
||||||
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||||
|
|
||||||
export function loadMuteTimers(): MuteTimerEntry[] {
|
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
|
||||||
try {
|
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
|
||||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
const timers = (content as { timers?: unknown } | undefined)?.timers;
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
if (!timers || typeof timers !== 'object') return [];
|
||||||
} catch {
|
return Object.entries(timers as Record<string, unknown>)
|
||||||
return [];
|
.filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]))
|
||||||
}
|
.map(([roomId, unmuteAt]) => ({ roomId, unmuteAt }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
/** Pure: account data content for `entries`. */
|
||||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
export const muteTimersContent = (entries: MuteTimerEntry[]) => ({
|
||||||
|
timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #333] Timed mutes live in account data: the mute itself is a
|
||||||
|
* server-side push rule on every device, so the timer must be too — kept in one
|
||||||
|
* browser's localStorage, only that device ever unmuted (and never after a
|
||||||
|
* logout there).
|
||||||
|
*/
|
||||||
|
export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] {
|
||||||
|
return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise<void> {
|
||||||
|
await mx.setAccountData(
|
||||||
|
AccountDataEvent.LotusMuteTimers as never,
|
||||||
|
muteTimersContent(entries) as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move timers kept in this browser's localStorage (pre-#333) into account data. */
|
||||||
|
export async function migrateLocalMuteTimers(mx: MatrixClient): Promise<void> {
|
||||||
|
let legacy: MuteTimerEntry[] = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
|
legacy = Array.isArray(parsed)
|
||||||
|
? parsed.filter(
|
||||||
|
(e): e is MuteTimerEntry =>
|
||||||
|
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
legacy = [];
|
||||||
|
}
|
||||||
|
if (legacy.length === 0) return;
|
||||||
|
const current = loadMuteTimers(mx);
|
||||||
|
const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))];
|
||||||
|
await saveMuteTimers(mx, merged);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(MUTE_TIMERS_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
||||||
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
|
|||||||
RoomNotificationMode.Mute,
|
RoomNotificationMode.Mute,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
await clearMuteTimer(mx, roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
|
||||||
const unmuteAt = Date.now() + durationMs;
|
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
|
||||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
const timers = loadMuteTimers(mx);
|
||||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
if (!timers.some((e) => e.roomId === roomId)) return;
|
||||||
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
await saveMuteTimers(
|
||||||
|
mx,
|
||||||
|
timers.filter((e) => e.roomId !== roomId),
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record when a timed mute ends. MuteTimerRestore (on every Lotus device)
|
||||||
|
* schedules the unmute from account data, so whichever is online at expiry
|
||||||
|
* lifts it.
|
||||||
|
*/
|
||||||
|
export async function scheduleMuteTimer(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
durationMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const unmuteAt = Date.now() + durationMs;
|
||||||
|
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
|
||||||
|
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,8 +353,11 @@ ${msgRows}
|
|||||||
>
|
>
|
||||||
<Box gap="400" wrap="Wrap">
|
<Box gap="400" wrap="Wrap">
|
||||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||||
<Text size="T300">From</Text>
|
<Text as="label" htmlFor="export-from-date" size="T300">
|
||||||
|
From
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="export-from-date"
|
||||||
type="date"
|
type="date"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="400"
|
size="400"
|
||||||
@@ -364,8 +367,11 @@ ${msgRows}
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
|
||||||
<Text size="T300">To</Text>
|
<Text as="label" htmlFor="export-to-date" size="T300">
|
||||||
|
To
|
||||||
|
</Text>
|
||||||
<Input
|
<Input
|
||||||
|
id="export-to-date"
|
||||||
type="date"
|
type="date"
|
||||||
variant="Secondary"
|
variant="Secondary"
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
|
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
|
||||||
|
|
||||||
// ── Policy event types ────────────────────────────────────────────────────────
|
// ── Policy event types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,13 +39,6 @@ type PolicyRuleContent = {
|
|||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PolicyEntry = {
|
|
||||||
entity: string;
|
|
||||||
reason: string;
|
|
||||||
recommendation: string;
|
|
||||||
stateKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PolicyTab = 'users' | 'rooms' | 'servers';
|
type PolicyTab = 'users' | 'rooms' | 'servers';
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -53,13 +47,6 @@ function isGlob(entity: string): boolean {
|
|||||||
return entity.includes('*') || entity.includes('?');
|
return entity.includes('*') || entity.includes('?');
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendationLabel(rec: string): string {
|
|
||||||
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
|
|
||||||
// (pre-stabilization) recommendation still emitted by older bots.
|
|
||||||
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
|
|
||||||
return rec;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all state events of a given type from a room's live forward state.
|
* Fetch all state events of a given type from a room's live forward state.
|
||||||
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
||||||
@@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
|||||||
.filter((entry) => entry.entity !== '');
|
.filter((entry) => entry.entity !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge policy entries from the stable and legacy event types for a rule
|
|
||||||
* kind, de-duplicating by entity+recommendation so a room that emits both a
|
|
||||||
* stable and a legacy rule for the same target isn't double-listed.
|
|
||||||
*/
|
|
||||||
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const result: PolicyEntry[] = [];
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const key = `${entry.entity} ${entry.recommendation}`;
|
|
||||||
if (seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
result.push(entry);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entry row ─────────────────────────────────────────────────────────────────
|
// ── Entry row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
||||||
|
|||||||
@@ -401,6 +401,9 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
|||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
<Box alignItems="Center" gap="200">
|
<Box alignItems="Center" gap="200">
|
||||||
|
{/* [Gitea #270] The bar's % width is of its own track,
|
||||||
|
not of the row, so the count always fits beside it. */}
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 6,
|
height: 6,
|
||||||
@@ -408,9 +411,9 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
|||||||
background: color.Primary.Main,
|
background: color.Primary.Main,
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
transition: 'width 0.3s ease',
|
transition: 'width 0.3s ease',
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||||
{count}
|
{count}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
|||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
|
||||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServerAclContent = {
|
|
||||||
allow: string[];
|
|
||||||
deny: string[];
|
|
||||||
allow_ip_literals: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_ACL: ServerAclContent = {
|
|
||||||
allow: ['*'],
|
|
||||||
deny: [],
|
|
||||||
allow_ip_literals: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||||
|
|
||||||
type ServerListProps = {
|
type ServerListProps = {
|
||||||
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
|
|
||||||
// Read current ACL from room state
|
// Read current ACL from room state
|
||||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
||||||
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
|
||||||
|
|
||||||
// Local draft state — initialised from current ACL
|
// Local draft state — initialised from current ACL
|
||||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
|
||||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
|
||||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||||
() => currentAcl.allow_ip_literals ?? false,
|
() => currentAcl.allow_ip_literals,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track whether there are unsaved changes
|
// Track whether there are unsaved changes
|
||||||
const isDirty =
|
const isDirty =
|
||||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
|
||||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
|
||||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||||
|
|
||||||
// Save handler
|
// Save handler
|
||||||
const [saveState, save] = useAsyncCallback(
|
const [saveState, save] = useAsyncCallback(
|
||||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
|||||||
id="allow-ip-literals"
|
id="allow-ip-literals"
|
||||||
checked={allowIpLiterals}
|
checked={allowIpLiterals}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
|
// folds' Checkbox has no onChange; readOnly tells React the
|
||||||
|
// click handler controls it (no "checked without onChange").
|
||||||
|
readOnly
|
||||||
|
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
|||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
<RoomSettings initialPage={page} requestClose={closeSettings} />
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { dedupePolicyEntries, recommendationLabel } from './policyEntries';
|
||||||
|
|
||||||
|
const e = (entity: string, recommendation: string, stateKey = entity) => ({
|
||||||
|
entity,
|
||||||
|
reason: 'spam waves',
|
||||||
|
recommendation,
|
||||||
|
stateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => {
|
||||||
|
const out = dedupePolicyEntries([
|
||||||
|
e('@spammer:evil.example', 'm.ban'),
|
||||||
|
e('@spammer:evil.example', 'org.matrix.mjolnir.ban'),
|
||||||
|
e('@*:spam.example', 'm.ban'),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((x) => x.entity),
|
||||||
|
['@spammer:evil.example', '@*:spam.example'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupePolicyEntries: different recommendations stay separate', () => {
|
||||||
|
assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendationLabel', () => {
|
||||||
|
assert.equal(recommendationLabel('m.ban'), 'Ban');
|
||||||
|
assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban');
|
||||||
|
assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute');
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type PolicyEntry = {
|
||||||
|
entity: string;
|
||||||
|
reason: string;
|
||||||
|
recommendation: string;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
|
||||||
|
* (pre-stabilization) recommendation still emitted by older bots.
|
||||||
|
*/
|
||||||
|
export const normalizeRecommendation = (rec: string): string =>
|
||||||
|
rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec;
|
||||||
|
|
||||||
|
export function recommendationLabel(rec: string): string {
|
||||||
|
if (normalizeRecommendation(rec) === 'm.ban') return 'Ban';
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge stable and legacy rule lists, one entry per entity + recommendation.
|
||||||
|
* [Gitea #321] Keyed on the normalised recommendation: a bot publishing both
|
||||||
|
* `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice.
|
||||||
|
*/
|
||||||
|
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: PolicyEntry[] = [];
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(entry);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||||
|
|
||||||
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
|
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
|
||||||
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
|
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
|
||||||
// little wider than the 266px member/bookmark drawers because it hosts a media
|
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
|
||||||
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
|
// it becomes a full-screen fixed panel, matching the app's other drawers.
|
||||||
// app's other drawers.
|
|
||||||
export const MediaGalleryDrawer = style({
|
export const MediaGalleryDrawer = style({
|
||||||
width: toRem(320),
|
width: SIDE_PANEL_WIDTH,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { keyframes, style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||||
|
|
||||||
export const MembersDrawer = style({
|
export const MembersDrawer = style({
|
||||||
width: toRem(266),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
isMembershipChanged,
|
isMembershipChanged,
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
sendRoomEvent,
|
sendRoomEvent,
|
||||||
|
isFromFilteredTimelineSet,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { getLastEditDiff } from '../../utils/editDiff';
|
import { getLastEditDiff } from '../../utils/editDiff';
|
||||||
import { tick } from '../../utils/haptics';
|
import { tick } from '../../utils/haptics';
|
||||||
@@ -138,7 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
import { isRingableRoom } from '../../utils/callRing';
|
import { declinedRing, isRingableRoom } from '../../utils/callRing';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
@@ -410,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||||
|
// [Gitea #259] The media gallery / activity log register filtered timeline
|
||||||
|
// sets whose events the room re-emits as live: don't count them twice.
|
||||||
|
if (isFromFilteredTimelineSet(data)) return;
|
||||||
onArrive(mEvent);
|
onArrive(mEvent);
|
||||||
};
|
};
|
||||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||||
@@ -2037,6 +2041,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||||
// channels, where starting a call is just joining the channel.
|
// channels, where starting a call is just joining the channel.
|
||||||
if (!isRingableRoom(room, direct)) return null;
|
if (!isRingableRoom(room, direct)) return null;
|
||||||
|
// [Gitea #313] A call we declined isn't a missed call.
|
||||||
|
const ringRef = mEvent.getRelation()?.event_id;
|
||||||
|
if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null;
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
const mention =
|
const mention =
|
||||||
content['m.mentions']?.room ||
|
content['m.mentions']?.room ||
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
|
|||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
import {
|
||||||
|
describeScheduleError,
|
||||||
|
MAX_SCHEDULE_AHEAD_MS,
|
||||||
|
scheduleMessage,
|
||||||
|
SCHEDULE_TOO_FAR_TEXT,
|
||||||
|
} from '../../utils/scheduledMessages';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
import {
|
import {
|
||||||
toLocalDate,
|
toLocalDate,
|
||||||
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const diffMs = sendAt.getTime() - Date.now();
|
const diffMs = sendAt.getTime() - Date.now();
|
||||||
if (diffMs < 60_000) {
|
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
|
||||||
|
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
|
||||||
|
setError((e) => {
|
||||||
|
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
|
||||||
|
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
|
||||||
|
});
|
||||||
|
if (diffMs < 60_000 || tooFar) {
|
||||||
setPreview(null);
|
setPreview(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -149,6 +160,10 @@ export function ScheduleMessageModal({
|
|||||||
setError('Scheduled time must be at least 1 minute in the future.');
|
setError('Scheduled time must be at least 1 minute in the future.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
|
||||||
|
setError(SCHEDULE_TOO_FAR_TEXT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!messageText.trim()) {
|
if (!messageText.trim()) {
|
||||||
setError('Please enter a message to schedule.');
|
setError('Please enter a message to schedule.');
|
||||||
return;
|
return;
|
||||||
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
|
|||||||
onScheduled(delayId, sendAt.getTime(), content);
|
onScheduled(delayId, sendAt.getTime(), content);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
setError(describeScheduleError(err));
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -247,6 +262,8 @@ export function ScheduleMessageModal({
|
|||||||
<input
|
<input
|
||||||
id="schedule-date"
|
id="schedule-date"
|
||||||
type="date"
|
type="date"
|
||||||
|
min={toLocalDate(new Date())}
|
||||||
|
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
|
||||||
value={dateValue}
|
value={dateValue}
|
||||||
onChange={(e) => setDateValue(e.target.value)}
|
onChange={(e) => setDateValue(e.target.value)}
|
||||||
style={pickerInputStyle(color, config)}
|
style={pickerInputStyle(color, config)}
|
||||||
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
(dateValue || timeValue) && (
|
(dateValue || timeValue) &&
|
||||||
|
error !== SCHEDULE_TOO_FAR_TEXT && (
|
||||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||||
Must be at least 1 minute in the future
|
Must be at least 1 minute in the future
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
|
|||||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
import {
|
||||||
|
cancelScheduledMessage,
|
||||||
|
fetchScheduledMessages,
|
||||||
|
groupScheduledByRoom,
|
||||||
|
sendScheduledMessageNow,
|
||||||
|
} from '../../utils/scheduledMessages';
|
||||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||||
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
|
|
||||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||||
|
|
||||||
|
// [Gitea #322] The server is the source of truth: messages scheduled on
|
||||||
|
// another device (or before a logout) show up here and can be cancelled.
|
||||||
|
// localStorage is only a cache for the first paint; on error we keep it.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => {
|
||||||
|
fetchScheduledMessages(mx)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setScheduledMessages(groupScheduledByRoom(list));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
window.addEventListener('focus', load);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.removeEventListener('focus', load);
|
||||||
|
};
|
||||||
|
}, [mx, roomId, setScheduledMessages]);
|
||||||
|
|
||||||
// Remove scheduled messages whose time has passed
|
// Remove scheduled messages whose time has passed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return undefined;
|
if (messages.length === 0) return undefined;
|
||||||
|
|||||||
@@ -47,8 +47,12 @@ import {
|
|||||||
recentForwardTargetsAtom,
|
recentForwardTargetsAtom,
|
||||||
addRecentForwardTarget,
|
addRecentForwardTarget,
|
||||||
} from '../../../state/recentForwardTargets';
|
} from '../../../state/recentForwardTargets';
|
||||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
import {
|
||||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
buildForwardContent,
|
||||||
|
buildPlaintextAttachmentContent,
|
||||||
|
forwardEncryptionWarning,
|
||||||
|
} from './forwardContent';
|
||||||
|
import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -275,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
// Selection persists across query changes: a room selected then filtered out
|
// Selection persists across query changes: a room selected then filtered out
|
||||||
// of the rendered slice stays selected.
|
// of the rendered slice stays selected.
|
||||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||||
|
const encryptionWarning = forwardEncryptionWarning(
|
||||||
|
!!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(),
|
||||||
|
[...selectedRoomIds]
|
||||||
|
.map((id) => mx.getRoom(id))
|
||||||
|
.filter((r): r is Room => !!r && !r.hasEncryptionStateEvent())
|
||||||
|
// The name the user sees (their own "Rename for me" name, if set).
|
||||||
|
.map((r) => getLocalRoomName(mx, r.roomId) ?? r.name),
|
||||||
|
);
|
||||||
|
|
||||||
const toggleRoom = useCallback((roomId: string) => {
|
const toggleRoom = useCallback((roomId: string) => {
|
||||||
setSelectedRoomIds((prev) => {
|
setSelectedRoomIds((prev) => {
|
||||||
@@ -590,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
gap="200"
|
||||||
>
|
>
|
||||||
|
{encryptionWarning && (
|
||||||
|
<Text size="T200" role="note" style={{ color: color.Warning.Main }}>
|
||||||
|
{encryptionWarning}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -1137,7 +1137,12 @@ export const Message = React.memo(
|
|||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [Gitea #269] The content column shrinks to the message's width; the
|
||||||
|
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
|
||||||
|
// so their `margin-left: auto` reaches the row's right edge instead of
|
||||||
|
// wherever the text happens to end.
|
||||||
const msgContentJSX = (
|
const msgContentJSX = (
|
||||||
|
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
|
||||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||||
{reply}
|
{reply}
|
||||||
{edit && onEditId ? (
|
{edit && onEditId ? (
|
||||||
@@ -1156,6 +1161,7 @@ export const Message = React.memo(
|
|||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
{reactions}
|
{reactions}
|
||||||
|
</Box>
|
||||||
{readReceiptUsers.length > 0 && (
|
{readReceiptUsers.length > 0 && (
|
||||||
<ReadReceiptAvatars
|
<ReadReceiptAvatars
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { buildForwardContent, getForwardedMeta } from './forwardContent';
|
import { buildForwardContent, forwardEncryptionWarning, getForwardedMeta } from './forwardContent';
|
||||||
|
|
||||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||||
@@ -171,3 +171,16 @@ test('getForwardedMeta rejects malformed metadata', () => {
|
|||||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
||||||
assert.equal(getForwardedMeta({}), undefined);
|
assert.equal(getForwardedMeta({}), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forwardEncryptionWarning: only from an encrypted room into unencrypted ones (#330)', () => {
|
||||||
|
assert.equal(forwardEncryptionWarning(false, ['general']), undefined);
|
||||||
|
assert.equal(forwardEncryptionWarning(true, []), undefined);
|
||||||
|
assert.match(
|
||||||
|
forwardEncryptionWarning(true, ['general']) ?? '',
|
||||||
|
/^general isn't encrypted\..*that room, along with who originally wrote it\.$/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
forwardEncryptionWarning(true, ['a', 'b']) ?? '',
|
||||||
|
/^2 of the rooms you picked aren't encrypted\..*those rooms/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,3 +122,23 @@ export async function buildPlaintextAttachmentContent(
|
|||||||
|
|
||||||
return plainContent;
|
return plainContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #330] The note shown when forwarding out of an encrypted room into
|
||||||
|
* rooms that aren't: the text (and a re-uploaded attachment) becomes readable
|
||||||
|
* by the server, and the original author is shown with it. Undefined when
|
||||||
|
* there's nothing to warn about.
|
||||||
|
*/
|
||||||
|
export function forwardEncryptionWarning(
|
||||||
|
sourceEncrypted: boolean,
|
||||||
|
unencryptedDestNames: string[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!sourceEncrypted || unencryptedDestNames.length === 0) return undefined;
|
||||||
|
const where =
|
||||||
|
unencryptedDestNames.length === 1
|
||||||
|
? `${unencryptedDestNames[0]} isn't encrypted.`
|
||||||
|
: `${unencryptedDestNames.length} of the rooms you picked aren't encrypted.`;
|
||||||
|
return `${where} The forwarded message (and any attachment) will be readable by the server and everyone in ${
|
||||||
|
unencryptedDestNames.length === 1 ? 'that room' : 'those rooms'
|
||||||
|
}, along with who originally wrote it.`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
|||||||
No Pinned Messages
|
No Pinned Messages
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T400" align="Center">
|
<Text size="T400" align="Center">
|
||||||
Users with sufficient power level can pin a messages from its context menu.
|
Users with sufficient power level can pin a message from its context menu.
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||||
|
|
||||||
export const ThreadPanel = style({
|
export const ThreadPanel = style({
|
||||||
width: toRem(360),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||||
|
|
||||||
export const ThreadsListPanel = style({
|
export const ThreadsListPanel = style({
|
||||||
width: toRem(340),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
|||||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||||
|
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||||
|
|
||||||
type PendingAsk = {
|
type PendingAsk = {
|
||||||
requests: WidgetPermissionRequest[];
|
requests: WidgetPermissionRequest[];
|
||||||
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
};
|
};
|
||||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||||
if (toStart || removed || !data?.liveEvent) return;
|
if (toStart || removed || !data?.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||||
else feed(ev);
|
else feed(ev);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { config, toRem } from 'folds';
|
import { config } from 'folds';
|
||||||
|
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||||
|
|
||||||
export const WidgetsPanel = style({
|
export const WidgetsPanel = style({
|
||||||
width: toRem(360),
|
width: SIDE_PANEL_WIDTH,
|
||||||
'@media': {
|
'@media': {
|
||||||
'(max-width: 750px)': {
|
'(max-width: 750px)': {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
|
|||||||
@@ -106,3 +106,19 @@ test('classifyWidgetCapabilities: reading protected state is fine, changing unpr
|
|||||||
test('loadWidgetConsent tolerates missing storage', () => {
|
test('loadWidgetConsent tolerates missing storage', () => {
|
||||||
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
assert.equal(loadWidgetConsent('!r:x', 'w', 'https://w.example'), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('[#266] space structure, call membership and Lotus room policy can never be granted', () => {
|
||||||
|
const { auto, ask } = classifyWidgetCapabilities(
|
||||||
|
[
|
||||||
|
'm.space.child',
|
||||||
|
'm.space.parent',
|
||||||
|
'org.matrix.msc3401.call',
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
'm.call.member',
|
||||||
|
'io.lotus.voice_limit',
|
||||||
|
'io.lotus.room_quality',
|
||||||
|
].map((t) => `org.matrix.msc2762.send.state_event:${t}`),
|
||||||
|
);
|
||||||
|
assert.equal(auto.size, 0);
|
||||||
|
assert.deepEqual(ask, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,6 +67,16 @@ export const PROTECTED_STATE_TYPES: ReadonlySet<string> = new Set([
|
|||||||
'm.room.third_party_invite',
|
'm.room.third_party_invite',
|
||||||
'im.vector.modular.widgets',
|
'im.vector.modular.widgets',
|
||||||
'm.widget',
|
'm.widget',
|
||||||
|
// [Gitea #266] Space structure, call membership and Lotus room policy: a
|
||||||
|
// widget could restructure a space, fake call membership or lift the
|
||||||
|
// voice-channel limit, so these can't be granted even with consent.
|
||||||
|
'm.space.child',
|
||||||
|
'm.space.parent',
|
||||||
|
'org.matrix.msc3401.call',
|
||||||
|
'org.matrix.msc3401.call.member',
|
||||||
|
'm.call.member',
|
||||||
|
'io.lotus.voice_limit',
|
||||||
|
'io.lotus.room_quality',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export type WidgetPermissionRequest = {
|
export type WidgetPermissionRequest = {
|
||||||
@@ -137,8 +147,11 @@ export const classifyWidgetCapabilities = (
|
|||||||
*/
|
*/
|
||||||
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
export type StoredWidgetConsent = { url: string; allowed: Capability[]; denied: Capability[] };
|
||||||
|
|
||||||
|
// [Gitea #266] Swept on logout by clearPlaintextCaches, so one account's
|
||||||
|
// approvals never apply to the next account on the same device.
|
||||||
|
export const WIDGET_CONSENT_PREFIX = 'lotus.widgetConsent.';
|
||||||
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
export const widgetConsentKey = (roomId: string, widgetId: string): string =>
|
||||||
`lotus.widgetConsent.${roomId}.${widgetId}`;
|
`${WIDGET_CONSENT_PREFIX}${roomId}.${widgetId}`;
|
||||||
|
|
||||||
export const loadWidgetConsent = (
|
export const loadWidgetConsent = (
|
||||||
roomId: string,
|
roomId: string,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
|
|||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||||
import { pickBanner } from '../../utils/kumaStatus';
|
import { pickBanner } from '../../utils/kumaStatus';
|
||||||
import {
|
import {
|
||||||
dismissNudge,
|
dismissNudge,
|
||||||
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
const backupInfo = useKeyBackupInfo(crypto);
|
const backupInfo = useKeyBackupInfo(crypto);
|
||||||
const backupActive = useBackupActive(crypto);
|
const backupActive = useBackupActive(crypto);
|
||||||
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
||||||
|
const statusDismissed = useAtomValue(statusDismissedAtom);
|
||||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||||
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
||||||
// never stacks under the "Connecting…" strip at startup.
|
// never stacks under the "Connecting…" strip at startup.
|
||||||
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// One strip at a time: outages, maintenance and a lost connection win.
|
// One strip at a time: outages, maintenance and a lost connection win.
|
||||||
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
|
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||||
|
// on screen and must not keep the nudge hidden for as long as it's posted.
|
||||||
|
const otherStrip =
|
||||||
|
syncLost ||
|
||||||
|
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
@@ -7,20 +7,9 @@ import { useClientConfig } from '../../hooks/useClientConfig';
|
|||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { useServerStatus } from '../../hooks/useServerStatus';
|
import { useServerStatus } from '../../hooks/useServerStatus';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||||
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
||||||
|
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
||||||
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
|
||||||
const DISMISS_MAX = 50;
|
|
||||||
|
|
||||||
const readDismissed = (): string[] => {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
|
||||||
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bannerIcon = (b: StripContent): IconSrc => {
|
const bannerIcon = (b: StripContent): IconSrc => {
|
||||||
if (b.kind === 'maintenance') return Icons.Setting;
|
if (b.kind === 'maintenance') return Icons.Setting;
|
||||||
@@ -163,7 +152,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
|||||||
});
|
});
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
const syncLost = lostAt !== null;
|
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
|
||||||
|
// watchdog flags it sooner, and Kuma is asked at once.
|
||||||
|
const stalled = useHomeserverStall(mx);
|
||||||
|
const setStalled = useSetAtom(homeserverStalledAtom);
|
||||||
|
useEffect(() => setStalled(stalled), [setStalled, stalled]);
|
||||||
|
const syncLost = lostAt !== null || stalled;
|
||||||
const status = useServerStatus(page, syncLost);
|
const status = useServerStatus(page, syncLost);
|
||||||
const setAtom = useSetAtom(serverStatusAtom);
|
const setAtom = useSetAtom(serverStatusAtom);
|
||||||
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
||||||
@@ -173,22 +167,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
|||||||
/** The logged-in app's status strip. */
|
/** The logged-in app's status strip. */
|
||||||
export function ServerStatusBanner() {
|
export function ServerStatusBanner() {
|
||||||
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
||||||
const [dismissed, setDismissed] = useState(readDismissed);
|
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
|
||||||
const banner = pickBanner(status, {
|
const banner = pickBanner(status, {
|
||||||
syncLost,
|
syncLost,
|
||||||
where: 'client',
|
where: 'client',
|
||||||
dismissed: new Set(dismissed),
|
dismissed: new Set(dismissed),
|
||||||
});
|
});
|
||||||
if (!banner) return null;
|
if (!banner) return null;
|
||||||
const dismiss = () => {
|
const dismiss = () => dismissKey(banner.key);
|
||||||
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
|
||||||
} catch {
|
|
||||||
// storage unavailable — dismissed for this session only
|
|
||||||
}
|
|
||||||
setDismissed(next);
|
|
||||||
};
|
|
||||||
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,16 +33,9 @@ import { About } from './about';
|
|||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||||
|
import { SettingsPages } from './pages';
|
||||||
|
|
||||||
export enum SettingsPages {
|
export { SettingsPages };
|
||||||
GeneralPage,
|
|
||||||
AccountPage,
|
|
||||||
NotificationPage,
|
|
||||||
DevicesPage,
|
|
||||||
EmojisStickersPage,
|
|
||||||
DeveloperToolsPage,
|
|
||||||
AboutPage,
|
|
||||||
}
|
|
||||||
|
|
||||||
type SettingsMenuItem = {
|
type SettingsMenuItem = {
|
||||||
page: SettingsPages;
|
page: SettingsPages;
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { lazy, Suspense } from 'react';
|
||||||
|
import { Box, Spinner, config } from 'folds';
|
||||||
|
import type { SettingsPages } from './pages';
|
||||||
|
|
||||||
|
// Settings is a large, rarely-open dialog (all its pages, pickers, the
|
||||||
|
// denoise tester…). Load it on first open instead of in the entry chunk, and
|
||||||
|
// warm the chunk once the app is idle so opening it rarely waits.
|
||||||
|
const loadSettings = () => import('./Settings');
|
||||||
|
const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings })));
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const warm = () => {
|
||||||
|
loadSettings().catch(() => undefined);
|
||||||
|
};
|
||||||
|
if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 });
|
||||||
|
else setTimeout(warm, 5_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsProps = {
|
||||||
|
initialPage?: SettingsPages;
|
||||||
|
requestClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Settings(props: SettingsProps) {
|
||||||
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
// Settings opens inside a FocusTrap, which needs a tabbable node.
|
||||||
|
<Box
|
||||||
|
tabIndex={0}
|
||||||
|
role="status"
|
||||||
|
aria-label="Loading settings"
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
style={{ padding: config.space.S700, outline: 'none' }}
|
||||||
|
>
|
||||||
|
<Spinner variant="Secondary" size="400" />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsDialog {...props} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
|||||||
import pkg from '../../../../../package.json';
|
import pkg from '../../../../../package.json';
|
||||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { tauriInvoke } from '../../../hooks/useTauri';
|
||||||
|
|
||||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
||||||
|
|
||||||
@@ -78,6 +79,51 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
|||||||
type AboutProps = {
|
type AboutProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* [Gitea #276] Which build someone is on: the web deploy's commit and build
|
||||||
|
* date (stamped in by vite.config.js), plus the desktop app's own version from
|
||||||
|
* Tauri, the number a desktop or updater bug report needs.
|
||||||
|
*/
|
||||||
|
function BuildInfo() {
|
||||||
|
const [desktopVersion, setDesktopVersion] = useState<string>();
|
||||||
|
useEffect(() => {
|
||||||
|
tauriInvoke()?.('plugin:app|version')
|
||||||
|
.then((v) => {
|
||||||
|
if (typeof v === 'string') setDesktopVersion(v);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
const commit = __LOTUS_BUILD_COMMIT__;
|
||||||
|
const built = new Date(__LOTUS_BUILD_DATE__);
|
||||||
|
const builtText = Number.isNaN(built.getTime())
|
||||||
|
? undefined
|
||||||
|
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="0">
|
||||||
|
{desktopVersion && (
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Desktop {desktopVersion}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Web build{' '}
|
||||||
|
{commit !== 'unknown' ? (
|
||||||
|
<a
|
||||||
|
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{commit}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
commit
|
||||||
|
)}
|
||||||
|
{builtText && ` · built ${builtText}`}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function About({ requestClose }: AboutProps) {
|
export function About({ requestClose }: AboutProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
||||||
@@ -117,6 +163,7 @@ export function About({ requestClose }: AboutProps) {
|
|||||||
<Text size="T200">v{pkg.version}</Text>
|
<Text size="T200">v{pkg.version}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text>A Matrix client for Lotus Guild.</Text>
|
<Text>A Matrix client for Lotus Guild.</Text>
|
||||||
|
<BuildInfo />
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box gap="200" wrap="Wrap">
|
<Box gap="200" wrap="Wrap">
|
||||||
|
|||||||
@@ -38,7 +38,11 @@ import { SettingTile } from '../../../components/setting-tile';
|
|||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||||
import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
|
import {
|
||||||
|
describePresenceError,
|
||||||
|
setPresenceWithRetry,
|
||||||
|
writePresence,
|
||||||
|
} from '../../../utils/presenceWrite';
|
||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import { settingsAtom } from '../../../state/settings';
|
import { settingsAtom } from '../../../state/settings';
|
||||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
@@ -67,6 +71,8 @@ import {
|
|||||||
makePresetId,
|
makePresetId,
|
||||||
normalizeLabel,
|
normalizeLabel,
|
||||||
} from '../../../utils/statusPresets';
|
} from '../../../utils/statusPresets';
|
||||||
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
|
||||||
|
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||||
|
|
||||||
type ProfileProps = {
|
type ProfileProps = {
|
||||||
profile: UserProfile;
|
profile: UserProfile;
|
||||||
@@ -365,8 +371,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
|
||||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
|
||||||
|
|
||||||
const CLEAR_AFTER_OPTIONS = [
|
const CLEAR_AFTER_OPTIONS = [
|
||||||
{ label: 'Never', value: '0' },
|
{ label: 'Never', value: '0' },
|
||||||
@@ -508,12 +513,16 @@ function ProfileStatus() {
|
|||||||
if (msg && delayMs > 0) {
|
if (msg && delayMs > 0) {
|
||||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
|
const expiresAt = Date.now() + delayMs;
|
||||||
|
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt));
|
||||||
|
// [Gitea #300] …and on every other Lotus device, via account data.
|
||||||
|
writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined);
|
||||||
} else {
|
} else {
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
|
writeStatusExpiry(mx, msg).catch(() => undefined);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[saveStatus, userId],
|
[saveStatus, userId, mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
@@ -553,8 +562,9 @@ function ProfileStatus() {
|
|||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
// Preserve the user's chosen presence when clearing the status message.
|
// Preserve the user's chosen presence when clearing the status message.
|
||||||
mx.setPresence({
|
writePresence(mx, {
|
||||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||||
status_msg: '',
|
status_msg: '',
|
||||||
}).catch(() => undefined);
|
}).catch(() => undefined);
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||||
import { Method } from 'matrix-js-sdk';
|
import { Method } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
|
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||||
|
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||||
|
import { nameInitials } from '../../../utils/common';
|
||||||
|
import { UserAvatar } from '../../../components/user-avatar';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
import {
|
import {
|
||||||
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
|||||||
|
|
||||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
const CELL_SIZE = 72;
|
const CELL_SIZE = 72;
|
||||||
|
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
|
||||||
|
const PREVIEW_INSET = 8;
|
||||||
|
|
||||||
function DecorationPreviewCell({
|
function DecorationPreviewCell({
|
||||||
slug,
|
slug,
|
||||||
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
|
|||||||
export function ProfileDecoration() {
|
export function ProfileDecoration() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
const profile = useUserProfile(userId);
|
||||||
|
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||||
|
const myAvatarUrl = profile.avatarUrl
|
||||||
|
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const [current, setCurrent] = useState<string | null>(null);
|
const [current, setCurrent] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
|
|||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
{/* Current selection preview */}
|
{/* Current selection preview */}
|
||||||
<Box alignItems="Center" gap="300">
|
<Box alignItems="Center" gap="300">
|
||||||
|
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
|
||||||
|
timeline draws it (AvatarDecoration: the image is the avatar plus
|
||||||
|
an 8px margin on every side). With no decoration the circle gets
|
||||||
|
a visible dashed outline instead of vanishing into the card. */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
|
|||||||
height: CELL_SIZE,
|
height: CELL_SIZE,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: color.SurfaceVariant.Container,
|
boxSizing: 'border-box',
|
||||||
overflow: 'hidden',
|
border: selected
|
||||||
|
? 'none'
|
||||||
|
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Avatar
|
||||||
|
size="500"
|
||||||
|
radii="Pill"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: PREVIEW_INSET,
|
||||||
|
width: CELL_SIZE - PREVIEW_INSET * 2,
|
||||||
|
height: CELL_SIZE - PREVIEW_INSET * 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAvatar
|
||||||
|
userId={userId}
|
||||||
|
src={myAvatarUrl}
|
||||||
|
alt=""
|
||||||
|
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
|
||||||
|
/>
|
||||||
|
</Avatar>
|
||||||
{selected && (
|
{selected && (
|
||||||
<img
|
<img
|
||||||
src={decorationUrl(selected)}
|
src={decorationUrl(selected)}
|
||||||
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
pointerEvents: 'none',
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -108,9 +108,11 @@ import {
|
|||||||
TRANSLATE_TARGET_LANGUAGES,
|
TRANSLATE_TARGET_LANGUAGES,
|
||||||
normalizeLang,
|
normalizeLang,
|
||||||
isSupportedTargetLang,
|
isSupportedTargetLang,
|
||||||
|
translationUnavailableText,
|
||||||
} from '../../../utils/translation/langUtils';
|
} from '../../../utils/translation/langUtils';
|
||||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
|
||||||
|
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
|
||||||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
import {
|
import {
|
||||||
describeUpdateError,
|
describeUpdateError,
|
||||||
@@ -596,10 +598,6 @@ function Appearance() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
<DesktopChromeSetting />
|
|
||||||
<AutostartSetting />
|
|
||||||
<KeychainMirrorSetting />
|
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Twitter Emoji"
|
title="Twitter Emoji"
|
||||||
@@ -1601,6 +1599,7 @@ function Privacy() {
|
|||||||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||||||
// device-local (never synced) so switching it off here sticks.
|
// device-local (never synced) so switching it off here sticks.
|
||||||
function SettingsSyncSection() {
|
function SettingsSyncSection() {
|
||||||
|
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||||||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||||||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||||||
@@ -1624,22 +1623,7 @@ function SettingsSyncSection() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const syncButtons = (
|
||||||
<Box direction="Column" gap="100">
|
|
||||||
<Text size="L400">Sync</Text>
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
||||||
<SettingTile
|
|
||||||
title="Sync Settings Across Devices"
|
|
||||||
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
|
||||||
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
|
||||||
/>
|
|
||||||
</SequenceCard>
|
|
||||||
{settingsSync && (
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
|
||||||
<SettingTile
|
|
||||||
title="Use This Device's Settings Everywhere"
|
|
||||||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
|
||||||
after={
|
|
||||||
<Box gap="200">
|
<Box gap="200">
|
||||||
<Button
|
<Button
|
||||||
size="300"
|
size="300"
|
||||||
@@ -1665,8 +1649,29 @@ function SettingsSyncSection() {
|
|||||||
<Text size="B300">Clear synced copy</Text>
|
<Text size="B300">Clear synced copy</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
}
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Sync</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Sync Settings Across Devices"
|
||||||
|
description="Keep your preferences (theme, composer toolbar, notifications, quiet hours, call keys…) the same on every device, stored as account data on your own homeserver. Device-specific settings such as zoom, media auto-load, noise suppression and volumes stay local."
|
||||||
|
after={<Switch variant="Primary" value={settingsSync} onChange={setSettingsSync} />}
|
||||||
/>
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
{settingsSync && (
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Use This Device's Settings Everywhere"
|
||||||
|
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||||||
|
// [Gitea #273] On phones the two buttons squeezed the text into a
|
||||||
|
// narrow column; put them under the description there.
|
||||||
|
after={isMobile ? undefined : syncButtons}
|
||||||
|
>
|
||||||
|
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
|
||||||
|
</SettingTile>
|
||||||
{note && (
|
{note && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1803,7 +1808,8 @@ function Calls() {
|
|||||||
title="Noise Suppression"
|
title="Noise Suppression"
|
||||||
description={
|
description={
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Text>
|
{/* [Gitea #271] T200 like every other setting description. */}
|
||||||
|
<Text size="T200">
|
||||||
Filter background noise from your mic during calls. Browser-native uses the built-in
|
Filter background noise from your mic during calls. Browser-native uses the built-in
|
||||||
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -2036,7 +2042,7 @@ function Calls() {
|
|||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
@@ -2092,7 +2098,12 @@ function Calls() {
|
|||||||
description={
|
description={
|
||||||
deafenBind.error ??
|
deafenBind.error ??
|
||||||
(deafenHotkey
|
(deafenHotkey
|
||||||
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
? `${
|
||||||
|
// [Gitea #279] The Escape hint only applies while rebinding.
|
||||||
|
deafenBind.listening
|
||||||
|
? 'Press a key to bind it, or Escape to cancel.'
|
||||||
|
: `Press ${keyLabel(deafenKey)} during a call to mute your speakers.`
|
||||||
|
}${
|
||||||
!isSafeGlobalToggleKey(deafenKey)
|
!isSafeGlobalToggleKey(deafenKey)
|
||||||
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
||||||
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
||||||
@@ -2133,7 +2144,7 @@ function Calls() {
|
|||||||
)}
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard
|
<SequenceCard
|
||||||
className={SequenceCardStyle}
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="400"
|
gap="400"
|
||||||
@@ -2267,7 +2278,11 @@ function Calls() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard
|
||||||
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Soundboard"
|
title="Soundboard"
|
||||||
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||||||
@@ -2754,13 +2769,17 @@ function Messages() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard
|
||||||
|
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
>
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Translate Messages Into"
|
title="Translate Messages Into"
|
||||||
description={
|
description={
|
||||||
translationSupported
|
translationSupported
|
||||||
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
||||||
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
: translationUnavailableText(isTauriEnv())
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<SettingsSelect
|
<SettingsSelect
|
||||||
@@ -2856,6 +2875,23 @@ function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #290] Desktop-only settings in their own section (they used to sit
|
||||||
|
* inside Appearance, between Monochrome Mode and Twitter Emoji). Renders
|
||||||
|
* nothing outside the desktop app.
|
||||||
|
*/
|
||||||
|
function DesktopSettings() {
|
||||||
|
if (!isTauriEnv()) return null;
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Desktop</Text>
|
||||||
|
<AutostartSetting />
|
||||||
|
<KeychainMirrorSetting />
|
||||||
|
<DesktopChromeSetting />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppUpdates() {
|
function AppUpdates() {
|
||||||
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
||||||
if (!isTauri) return null;
|
if (!isTauri) return null;
|
||||||
@@ -2964,6 +3000,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<StorageUsage />
|
<StorageUsage />
|
||||||
<SettingsSyncSection />
|
<SettingsSyncSection />
|
||||||
<Calls />
|
<Calls />
|
||||||
|
<DesktopSettings />
|
||||||
<AppUpdates />
|
<AppUpdates />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
export * from './Settings';
|
export { SettingsPages } from './pages';
|
||||||
|
export { Settings } from './SettingsLazy';
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
|
|||||||
key={mode}
|
key={mode}
|
||||||
size="300"
|
size="300"
|
||||||
variant="Surface"
|
variant="Surface"
|
||||||
aria-selected={mode === selectedMode}
|
aria-pressed={mode === selectedMode}
|
||||||
radii="300"
|
radii="300"
|
||||||
onClick={() => handleSelect(mode)}
|
onClick={() => handleSelect(mode)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
||||||
|
* page without pulling the dialog into the entry chunk. */
|
||||||
|
export enum SettingsPages {
|
||||||
|
GeneralPage,
|
||||||
|
AccountPage,
|
||||||
|
NotificationPage,
|
||||||
|
DevicesPage,
|
||||||
|
EmojisStickersPage,
|
||||||
|
DeveloperToolsPage,
|
||||||
|
AboutPage,
|
||||||
|
}
|
||||||
@@ -1,6 +1,18 @@
|
|||||||
import { style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
import { config } from 'folds';
|
import { color, config } from 'folds';
|
||||||
|
|
||||||
export const SequenceCardStyle = style({
|
export const SequenceCardStyle = style({
|
||||||
padding: config.space.S300,
|
padding: config.space.S300,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #273] Several settings share one card; without a separator the next
|
||||||
|
* tile's title read like part of the previous description. A thin line goes
|
||||||
|
* between tiles only (not between a tile and its own slider or note).
|
||||||
|
*/
|
||||||
|
export const SettingTileGroup = style({});
|
||||||
|
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
|
||||||
|
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
|
||||||
|
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
|
||||||
|
paddingTop: config.space.S400,
|
||||||
|
});
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { settingsAtom } from '../../state/settings';
|
|||||||
import { isMacOS } from '../../utils/user-agent';
|
import { isMacOS } from '../../utils/user-agent';
|
||||||
import { KeySymbol } from '../../utils/key-symbol';
|
import { KeySymbol } from '../../utils/key-symbol';
|
||||||
import * as css from './KeyboardShortcutsDialog.css';
|
import * as css from './KeyboardShortcutsDialog.css';
|
||||||
|
import { BLOCK_HOTKEYS, INLINE_HOTKEYS } from '../../components/editor/keyboard';
|
||||||
|
import { MarkType } from '../../components/editor/types';
|
||||||
|
import { BLOCK_LABELS, MARK_LABELS, hotkeyKeys, keyCodeLabel } from './shortcutLabels';
|
||||||
|
|
||||||
/** Global open-state for the keyboard shortcuts help dialog. */
|
/** Global open-state for the keyboard shortcuts help dialog. */
|
||||||
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
||||||
@@ -98,6 +101,9 @@ export function KeyboardShortcutsDialog() {
|
|||||||
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
||||||
const modalStyle = useModalStyle(480);
|
const modalStyle = useModalStyle(480);
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
|
||||||
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||||
|
|
||||||
@@ -109,6 +115,8 @@ export function KeyboardShortcutsDialog() {
|
|||||||
rows: [
|
rows: [
|
||||||
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
||||||
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
|
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
|
||||||
|
// [Gitea #334] the quick switcher / search was missing
|
||||||
|
{ description: 'Quick switcher: jump to a room or person', keys: [modKey, 'K'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,6 +132,26 @@ export function KeyboardShortcutsDialog() {
|
|||||||
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
|
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
|
||||||
},
|
},
|
||||||
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
|
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
|
||||||
|
{ description: 'Edit your last message (empty composer)', keys: ['↑'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// [Gitea #334] generated from the editor's own bindings so they can't drift
|
||||||
|
title: 'Composer formatting',
|
||||||
|
rows: [
|
||||||
|
...Object.entries(INLINE_HOTKEYS).map(([hotkey, mark]) => ({
|
||||||
|
description:
|
||||||
|
mark === MarkType.StrikeThrough
|
||||||
|
? `${MARK_LABELS[mark]} (not the browser's Save)`
|
||||||
|
: MARK_LABELS[mark],
|
||||||
|
keys: hotkeyKeys(hotkey, modKey),
|
||||||
|
})),
|
||||||
|
{ description: 'Heading 1 / 2 / 3', keys: [modKey, '1 / 2 / 3'] },
|
||||||
|
...Object.entries(BLOCK_HOTKEYS).map(([hotkey, block]) => ({
|
||||||
|
description: BLOCK_LABELS[block] ?? block,
|
||||||
|
keys: hotkeyKeys(hotkey, modKey),
|
||||||
|
})),
|
||||||
|
{ description: 'Clear formatting', keys: [modKey, 'E'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -132,6 +160,19 @@ export function KeyboardShortcutsDialog() {
|
|||||||
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
|
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// [Gitea #334] the user's own bindings (Settings → Calls)
|
||||||
|
title: 'Calls',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
description: pttMode
|
||||||
|
? 'Push to talk (hold)'
|
||||||
|
: 'Push to talk (hold; turn on in Settings → Calls)',
|
||||||
|
keys: [keyCodeLabel(pttKey)],
|
||||||
|
},
|
||||||
|
{ description: 'Deafen / undeafen', keys: [keyCodeLabel(deafenKey)] },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { hotkeyKeys, keyCodeLabel } from './shortcutLabels';
|
||||||
|
|
||||||
|
test('hotkeyKeys (#334)', () => {
|
||||||
|
assert.deepEqual(hotkeyKeys('mod+b', 'Ctrl'), ['Ctrl', 'B']);
|
||||||
|
assert.deepEqual(hotkeyKeys("mod+'", '⌘'), ['⌘', "'"]);
|
||||||
|
assert.deepEqual(hotkeyKeys('mod+[', 'Ctrl'), ['Ctrl', '[']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyCodeLabel (#334)', () => {
|
||||||
|
assert.equal(keyCodeLabel('KeyM'), 'M');
|
||||||
|
assert.equal(keyCodeLabel('Digit1'), '1');
|
||||||
|
assert.equal(keyCodeLabel('Space'), 'Space');
|
||||||
|
assert.equal(keyCodeLabel(''), 'Not set');
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BlockType, MarkType } from '../../components/editor/types';
|
||||||
|
|
||||||
|
/** Pure: an is-hotkey string ('mod+b', "mod+'") as keycap labels. */
|
||||||
|
export const hotkeyKeys = (hotkey: string, modKey: string): string[] =>
|
||||||
|
hotkey.split('+').map((k) => (k === 'mod' ? modKey : k.length === 1 ? k.toUpperCase() : k));
|
||||||
|
|
||||||
|
/** Pure: a KeyboardEvent.code ('KeyM', 'Digit1', 'Space') as a keycap label. */
|
||||||
|
export const keyCodeLabel = (code: string | undefined): string => {
|
||||||
|
if (!code) return 'Not set';
|
||||||
|
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||||||
|
if (/^Digit\d$/.test(code)) return code.slice(5);
|
||||||
|
return code;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MARK_LABELS: Record<MarkType, string> = {
|
||||||
|
[MarkType.Bold]: 'Bold',
|
||||||
|
[MarkType.Italic]: 'Italic',
|
||||||
|
[MarkType.Underline]: 'Underline',
|
||||||
|
[MarkType.StrikeThrough]: 'Strikethrough',
|
||||||
|
[MarkType.Code]: 'Inline code',
|
||||||
|
[MarkType.Spoiler]: 'Spoiler',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLOCK_LABELS: Partial<Record<BlockType, string>> = {
|
||||||
|
[BlockType.OrderedList]: 'Numbered list',
|
||||||
|
[BlockType.UnorderedList]: 'Bulleted list',
|
||||||
|
[BlockType.BlockQuote]: 'Quote',
|
||||||
|
[BlockType.CodeBlock]: 'Code block',
|
||||||
|
};
|
||||||
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
|
|||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal500 requestClose={closeSettings}>
|
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
|
||||||
<SpaceProvider value={space ?? null}>
|
<SpaceProvider value={space ?? null}>
|
||||||
<RoomProvider value={room}>
|
<RoomProvider value={room}>
|
||||||
<SpaceSettings initialPage={page} requestClose={closeSettings} />
|
<SpaceSettings initialPage={page} requestClose={closeSettings} />
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { SyncState } from 'matrix-js-sdk';
|
||||||
|
import { CALL_ERROR_CHANGED, CallControl } from '../plugins/call/CallControl';
|
||||||
|
import { CallErrorState } from '../plugins/call/callError';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useSyncState } from './useSyncState';
|
||||||
|
|
||||||
|
/** [Gitea #318] The fork's current call error (null while the call is fine). */
|
||||||
|
export function useCallError(control: CallControl): CallErrorState {
|
||||||
|
const [err, setErr] = useState(control.callError);
|
||||||
|
useEffect(() => {
|
||||||
|
const on = () => setErr(control.callError);
|
||||||
|
on();
|
||||||
|
control.on(CALL_ERROR_CHANGED, on);
|
||||||
|
return () => {
|
||||||
|
control.off(CALL_ERROR_CHANGED, on);
|
||||||
|
};
|
||||||
|
}, [control]);
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #318] After a homeserver stall the call drops with "Connection
|
||||||
|
* lost". Once sync is back, reconnect automatically (once per drop).
|
||||||
|
*/
|
||||||
|
export function useReconnectWhenSynced(control: CallControl): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const err = useCallError(control);
|
||||||
|
const tried = useRef<CallErrorState>(null);
|
||||||
|
useSyncState(
|
||||||
|
mx,
|
||||||
|
useCallback(
|
||||||
|
(state: SyncState | null) => {
|
||||||
|
if (state !== SyncState.Syncing || !err?.recoverable || tried.current === err) return;
|
||||||
|
tried.current = err;
|
||||||
|
control.reconnect();
|
||||||
|
},
|
||||||
|
[err, control],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
CALL_SESSION_HEARTBEAT_MS,
|
CALL_SESSION_HEARTBEAT_MS,
|
||||||
clearCallSession,
|
clearCallSession,
|
||||||
decideRejoin,
|
decideRejoin,
|
||||||
|
othersInCall,
|
||||||
|
rejoinToastBody,
|
||||||
readCallSession,
|
readCallSession,
|
||||||
writeCallSession,
|
writeCallSession,
|
||||||
} from '../utils/callRejoin';
|
} from '../utils/callRejoin';
|
||||||
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
|||||||
};
|
};
|
||||||
dismissToast(TOAST_ID);
|
dismissToast(TOAST_ID);
|
||||||
clearCallSession();
|
clearCallSession();
|
||||||
|
// [Gitea #302] A DM call the other person has since left is over;
|
||||||
|
// starting it again would ring them, so just say so.
|
||||||
|
const dm = l.directs.has(room.roomId);
|
||||||
|
if (
|
||||||
|
dm &&
|
||||||
|
!othersInCall(
|
||||||
|
session.memberships.map((m) => m.userId ?? ''),
|
||||||
|
mx.getUserId() ?? '',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Own id: clicking a toast removes it by id after its onClick runs.
|
||||||
|
setToast({
|
||||||
|
id: `${TOAST_ID}-ended`,
|
||||||
|
iconSrc: Icons.Phone,
|
||||||
|
displayName: 'Call ended',
|
||||||
|
body: `${room.name ?? 'They'} already left the call.`,
|
||||||
|
roomName: room.name ?? '',
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
|||||||
.filter((m) => m.userId === mx.getUserId())
|
.filter((m) => m.userId === mx.getUserId())
|
||||||
.map((m) => m.deviceId),
|
.map((m) => m.deviceId),
|
||||||
memberCount: memberships.length,
|
memberCount: memberships.length,
|
||||||
|
dm: latest.current.directs.has(room.roomId),
|
||||||
});
|
});
|
||||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||||
// empty unless this is the final (timed-out) look.
|
// empty unless this is the final (timed-out) look.
|
||||||
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
|||||||
id: TOAST_ID,
|
id: TOAST_ID,
|
||||||
iconSrc: Icons.Phone,
|
iconSrc: Icons.Phone,
|
||||||
displayName: 'Rejoin voice?',
|
displayName: 'Rejoin voice?',
|
||||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
|
||||||
roomName: room.name ?? '',
|
roomName: room.name ?? '',
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
sticky: true,
|
sticky: true,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { denoiseNotice, DenoiseStateReport } from '../utils/denoiseNotice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #260] Tell the user when ML noise suppression didn't attach (raw mic)
|
||||||
|
* or fell back to another model. The fork's `io.lotus.denoise_state` used to
|
||||||
|
* go unanswered ("unsupported action"), so failures were silent.
|
||||||
|
*/
|
||||||
|
export function useDenoiseNotices(embed: CallEmbed): void {
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const [mode] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||||
|
const [model] = useSetting(settingsAtom, 'callDenoiseModel');
|
||||||
|
const latest = useRef({ mode, model });
|
||||||
|
latest.current = { mode, model };
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
embed.listenAction<{ data?: DenoiseStateReport }>('io.lotus.denoise_state', (evt) => {
|
||||||
|
if (latest.current.mode !== 'ml') return;
|
||||||
|
const body = denoiseNotice(latest.current.model, evt.detail?.data);
|
||||||
|
if (!body) return;
|
||||||
|
setToast({
|
||||||
|
id: `denoise-state-${Date.now()}`,
|
||||||
|
iconSrc: Icons.Mic,
|
||||||
|
displayName: 'Noise suppression',
|
||||||
|
body,
|
||||||
|
roomName: embed.room.name ?? 'Voice call',
|
||||||
|
roomId: embed.roomId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[embed, setToast],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { SyncState } from 'matrix-js-sdk';
|
||||||
|
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
|
||||||
|
|
||||||
|
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
|
||||||
|
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
|
||||||
|
assert.equal(
|
||||||
|
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
|
||||||
|
false,
|
||||||
|
'SyncStatus already shows that',
|
||||||
|
);
|
||||||
|
assert.equal(syncOverdue(now - 120_000, now, null), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
|
||||||
|
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
|
||||||
|
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
|
||||||
|
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
|
||||||
|
* /sync that's overdue and confirms with a short probe of /versions.
|
||||||
|
*/
|
||||||
|
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
|
||||||
|
const CHECK_EVERY_MS = 10_000;
|
||||||
|
const PROBE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
export const homeserverStalledAtom = atom(false);
|
||||||
|
|
||||||
|
/** Pure: should we probe, given the last sync response time? */
|
||||||
|
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
|
||||||
|
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
|
||||||
|
|
||||||
|
export function useHomeserverStall(mx: MatrixClient): boolean {
|
||||||
|
const [stalled, setStalled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let lastSyncAt = Date.now();
|
||||||
|
let probing = false;
|
||||||
|
const onSync = () => {
|
||||||
|
lastSyncAt = Date.now();
|
||||||
|
setStalled(false);
|
||||||
|
};
|
||||||
|
const check = async () => {
|
||||||
|
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
|
||||||
|
probing = true;
|
||||||
|
let answered = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
answered = res.ok;
|
||||||
|
} catch {
|
||||||
|
answered = false;
|
||||||
|
}
|
||||||
|
probing = false;
|
||||||
|
// Still overdue and the server didn't answer a trivial request: stalled.
|
||||||
|
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
|
||||||
|
if (answered) setStalled(false);
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.Sync, onSync);
|
||||||
|
const id = setInterval(check, CHECK_EVERY_MS);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.Sync, onSync);
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
return stalled;
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
markAutoTranslateTried,
|
markAutoTranslateTried,
|
||||||
} from '../state/translation';
|
} from '../state/translation';
|
||||||
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
|
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
|
||||||
import { sameLanguage } from '../utils/translation/langUtils';
|
import { sameLanguage, translateSkipMessage } from '../utils/translation/langUtils';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
|
||||||
export type MsgTranslationStatus =
|
export type MsgTranslationStatus =
|
||||||
| 'idle' // not translating (original shown)
|
| 'idle' // not translating (original shown)
|
||||||
@@ -52,6 +53,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
|||||||
// Write-only: reading the whole cache array here (via useAtom) would
|
// Write-only: reading the whole cache array here (via useAtom) would
|
||||||
// resubscribe this message to every other message's writes. [Gitea #39]
|
// resubscribe this message to every other message's writes. [Gitea #39]
|
||||||
const setCache = useSetAtom(translationCacheAtom);
|
const setCache = useSetAtom(translationCacheAtom);
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
// The key includes a fingerprint of `text`, so an edit (same event id, new
|
// The key includes a fingerprint of `text`, so an edit (same event id, new
|
||||||
// body) misses the old entry and re-translates instead of showing stale
|
// body) misses the old entry and re-translates instead of showing stale
|
||||||
@@ -124,6 +126,16 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
|||||||
if (!from || sameLanguage(from, targetLang)) {
|
if (!from || sameLanguage(from, targetLang)) {
|
||||||
setStatus('skipped');
|
setStatus('skipped');
|
||||||
setActive(false);
|
setActive(false);
|
||||||
|
// [Gitea #287] Say why nothing happened instead of silently undoing
|
||||||
|
// the click (no chip shows once translation is switched back off).
|
||||||
|
setToast({
|
||||||
|
id: `translate-skipped-${eventId}-${Date.now()}`,
|
||||||
|
displayName: 'Translate',
|
||||||
|
body: translateSkipMessage(from, targetLang),
|
||||||
|
roomName: '',
|
||||||
|
roomId: '',
|
||||||
|
onClick: () => undefined,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFromLang(from);
|
setFromLang(from);
|
||||||
@@ -154,7 +166,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
|||||||
// Invalidate this run's writes if inputs change / unmount.
|
// Invalidate this run's writes if inputs change / unmount.
|
||||||
runIdRef.current += 1;
|
runIdRef.current += 1;
|
||||||
};
|
};
|
||||||
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
|
}, [active, cached, supported, text, targetLang, key, setActive, setCache, setToast, eventId]);
|
||||||
|
|
||||||
// When switched off, drop back to idle (keep the cached translation around so
|
// When switched off, drop back to idle (keep the cached translation around so
|
||||||
// toggling back on is instant).
|
// toggling back on is instant).
|
||||||
|
|||||||
@@ -1,20 +1,27 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { SetPresence } from 'matrix-js-sdk';
|
import { SetPresence } from 'matrix-js-sdk';
|
||||||
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
|
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
|
||||||
import { useMatrixClient } from './useMatrixClient';
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import {
|
||||||
|
BUSY_PRESENCE,
|
||||||
|
BUSY_PRESENCE_FEATURE,
|
||||||
|
isBusyPresenceSupported,
|
||||||
|
setBusyPresenceSupported,
|
||||||
|
} from '../utils/busyPresence';
|
||||||
|
|
||||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||||
const ACTIVITY_THROTTLE_MS = 1000;
|
const ACTIVITY_THROTTLE_MS = 1000;
|
||||||
|
|
||||||
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||||
export type PresenceState = 'online' | 'unavailable' | 'offline';
|
export type PresenceState = 'online' | 'unavailable' | 'offline' | typeof BUSY_PRESENCE;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single source of truth for mapping the user's presence preference to the
|
* Single source of truth for mapping the user's presence preference to the
|
||||||
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
|
* Matrix presence value: auto/online → 'online', idle → 'unavailable', dnd →
|
||||||
|
* MSC3026 busy (or 'unavailable' where the server lacks it),
|
||||||
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
||||||
* status writer so setting/clearing a status message never overrides the user's
|
* status writer so setting/clearing a status message never overrides the user's
|
||||||
* chosen presence (e.g. outing an Invisible user as online).
|
* chosen presence (e.g. outing an Invisible user as online).
|
||||||
@@ -24,7 +31,10 @@ export function presenceStateFromSetting(
|
|||||||
hidePresence: boolean,
|
hidePresence: boolean,
|
||||||
): PresenceState {
|
): PresenceState {
|
||||||
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
||||||
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
|
// [Gitea #283] DND is MSC3026 busy where the server supports it, so status
|
||||||
|
// writes (save / clear / auto-clear) don't downgrade it to "unavailable".
|
||||||
|
if (presenceStatus === 'dnd') return isBusyPresenceSupported() ? BUSY_PRESENCE : 'unavailable';
|
||||||
|
if (presenceStatus === 'idle') return 'unavailable';
|
||||||
return 'online';
|
return 'online';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,8 +72,7 @@ export function usePresenceUpdater() {
|
|||||||
// by a heartbeat that changed nothing.
|
// by a heartbeat that changed nothing.
|
||||||
const send = (write: PresenceWrite, label: string) => {
|
const send = (write: PresenceWrite, label: string) => {
|
||||||
if (!deduper.shouldSend(write)) return Promise.resolve();
|
if (!deduper.shouldSend(write)) return Promise.resolve();
|
||||||
return mx
|
return writePresence(mx, write)
|
||||||
.setPresence(write)
|
|
||||||
.then(() => deduper.sent(write))
|
.then(() => deduper.sent(write))
|
||||||
.catch((err) => warnPresenceFailure(label, err));
|
.catch((err) => warnPresenceFailure(label, err));
|
||||||
};
|
};
|
||||||
@@ -134,7 +143,22 @@ export function usePresenceUpdater() {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
if (presenceStatus === 'dnd') {
|
if (presenceStatus === 'dnd') {
|
||||||
|
// [Gitea #283] Real "busy" presence where supported: the status text
|
||||||
|
// stays yours. /sync reporting "online" doesn't override busy on
|
||||||
|
// Synapse, so the sync loop can stay online. Older servers keep the
|
||||||
|
// legacy unavailable + "dnd" text encoding.
|
||||||
|
mx.doesServerSupportUnstableFeature(BUSY_PRESENCE_FEATURE)
|
||||||
|
.catch(() => false)
|
||||||
|
.then(async (busy) => {
|
||||||
|
setBusyPresenceSupported(busy);
|
||||||
|
if (!busy) {
|
||||||
setUnavailable('dnd');
|
setUnavailable('dnd');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncPresence(SetPresence.Online);
|
||||||
|
const status = await resolveStatus();
|
||||||
|
send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy');
|
||||||
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user