Compare commits
127
Commits
fix/rejoin-dm
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
cd760ebc3c | ||
|
|
f41de57893 | ||
|
|
1511cb0b97 | ||
|
|
3e11a6fc0b | ||
|
|
5ce4692a7b | ||
|
|
4486f02bc6 | ||
|
|
a7c9d15824 | ||
|
|
7b3d1bad8f | ||
|
|
2c11bd3b8e | ||
|
|
6f7a8f64e2 | ||
|
|
e6e52cdc37 | ||
|
|
4969ef9b6d | ||
|
|
be71931a81 | ||
|
|
4ab5a8fd06 | ||
|
|
4e30ae763e | ||
|
|
f9265d2d90 | ||
|
|
82c5728a96 | ||
|
|
dd6cf156ba | ||
|
|
e94cdfe5dc | ||
|
|
bb2ace82eb | ||
|
|
3a74699fef | ||
|
|
7fc42b32d4 | ||
|
|
005fc9e768 | ||
|
|
33cc0de97c | ||
|
|
3bf040f17a | ||
|
|
906a034e26 | ||
|
|
c8576cbb1f | ||
|
|
69e05bbf8d | ||
|
|
15f2fcc4d1 | ||
|
|
8e7127a350 | ||
|
|
f30a65eb74 | ||
|
|
0d08ba0a55 | ||
|
|
6ae4d7adc0 | ||
|
|
b855eb505d | ||
|
|
f7bdd2855a | ||
|
|
26cf1204b5 | ||
|
|
e16c923bea | ||
|
|
cc8e7444f6 | ||
|
|
f9172cc339 | ||
|
|
ef7690135d | ||
|
|
8bbb436ca7 | ||
|
|
4ddcda122a | ||
|
|
9c433d9d77 | ||
|
|
e8fd0a1112 | ||
|
|
00e27a24e6 | ||
|
|
46d6570df3 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
a425aa3572 | ||
|
|
f0b7400d1b | ||
|
|
d1270956ea | ||
|
|
a86947be48 | ||
|
|
b6f9af3f58 | ||
|
|
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.
|
||||||
|
|||||||
@@ -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.22",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||||
"@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.22",
|
"version": "0.25.0-lotus.26",
|
||||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
|
||||||
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
|
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
|
||||||
"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.22",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||||
"@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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<Text size="B400">Keep running in the tray</Text>
|
<>
|
||||||
</Button>
|
<Button
|
||||||
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
id="close-behavior-keep"
|
||||||
<Text size="B400">Quit when I close the window</Text>
|
variant="Primary"
|
||||||
</Button>
|
onClick={() => choose('tray')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Keep running in the tray</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-quit"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => choose('quit')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Quit when I close the window</Text>
|
||||||
|
</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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
Setup Device Verification
|
Setup Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
Reset Device Verification
|
Reset Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -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,17 +38,36 @@ 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.
|
||||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
useEffect(() => {
|
||||||
// everyone else sees you "in call" until it times out.
|
let alive = true;
|
||||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
fetchScheduledMessages(mx)
|
||||||
await hangupCallAndWait(mx, callEmbed);
|
.then((list) => alive && setScheduled(list))
|
||||||
setCallEmbed(undefined);
|
.catch(() => undefined);
|
||||||
}
|
return () => {
|
||||||
await logoutClient(mx);
|
alive = false;
|
||||||
}, [mx, callEmbed, setCallEmbed]),
|
};
|
||||||
|
}, [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
|
||||||
|
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||||
|
// everyone else sees you "in call" until it times out.
|
||||||
|
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||||
|
await hangupCallAndWait(mx, callEmbed);
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}
|
||||||
|
await logoutClient(mx);
|
||||||
|
},
|
||||||
|
[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>
|
||||||
|
|||||||
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||||||
data.allowFederation,
|
data.allowFederation,
|
||||||
data.additionalCreators,
|
data.additionalCreators,
|
||||||
),
|
),
|
||||||
power_level_content_override:
|
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
// voice rooms: the call button is shown in private group rooms too, and with
|
||||||
|
// Synapse's state_default (50) only admins could start or join a call there
|
||||||
|
// ("You don't have permission to join!").
|
||||||
|
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
|
||||||
initial_state: initialState,
|
initial_state: initialState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,25 +234,34 @@ 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).
|
||||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
try {
|
||||||
'm.selections': Array.from(next),
|
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||||
}).catch(() => setPending(null));
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
|
'm.selections': Array.from(next),
|
||||||
|
}).catch(() => setPending(null));
|
||||||
|
} catch {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndPoll = () => {
|
const handleEndPoll = () => {
|
||||||
if (!roomId || !eventId || ending) return;
|
if (!roomId || !eventId || ending) return;
|
||||||
setEnding(true);
|
setEnding(true);
|
||||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
try {
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||||
'm.poll.end': {},
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
'm.text': 'The poll has ended.',
|
'm.poll.end': {},
|
||||||
})
|
'm.text': 'The poll has ended.',
|
||||||
.then(() => {
|
|
||||||
setConfirmEnd(false);
|
|
||||||
setEnding(false);
|
|
||||||
})
|
})
|
||||||
.catch(() => setEnding(false));
|
.then(() => {
|
||||||
|
setConfirmEnd(false);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Binary file not shown.
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
|||||||
Create Poll
|
Create Poll
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
|||||||
Report Room
|
Report Room
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
|||||||
Report User
|
Report User
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -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 ||
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -725,7 +725,13 @@ export const MessageDeleteItem = as<
|
|||||||
Delete Message
|
Delete Message
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -865,7 +871,13 @@ export const MessageReportItem = as<
|
|||||||
Report Message
|
Report Message
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
const measure = useCallback(() => {
|
const measure = useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const rowWidth = container.getBoundingClientRect().width;
|
// [Gitea #305] The width the chips actually get (content box). The border
|
||||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
|
||||||
|
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
|
||||||
|
const cs = getComputedStyle(container);
|
||||||
|
const rowWidth =
|
||||||
|
container.clientWidth -
|
||||||
|
(parseFloat(cs.paddingLeft) || 0) -
|
||||||
|
(parseFloat(cs.paddingRight) || 0);
|
||||||
|
const gap = parseFloat(cs.columnGap) || 8;
|
||||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||||
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
style={{
|
style={{
|
||||||
...props.style,
|
...props.style,
|
||||||
...(collapsed && rowHeight
|
...(collapsed && rowHeight
|
||||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
// margins so the timeline's marginTop survives (Gitea #305: the
|
||||||
|
// `margin` shorthand overrode it and, once removed, cleared it).
|
||||||
|
{
|
||||||
|
maxHeight: rowHeight + 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 2,
|
||||||
|
marginLeft: -2,
|
||||||
|
marginRight: -2,
|
||||||
|
marginBottom: -2,
|
||||||
|
marginTop:
|
||||||
|
props.style?.marginTop !== undefined
|
||||||
|
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
|
||||||
|
: -2,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|||||||
@@ -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.`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ 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 } from '../styles.css';
|
||||||
@@ -596,10 +597,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"
|
||||||
@@ -2760,7 +2757,7 @@ function Messages() {
|
|||||||
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 +2853,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 +2978,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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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') {
|
||||||
setUnavailable('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');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
syncPresence(SetPresence.Online);
|
||||||
|
const status = await resolveStatus();
|
||||||
|
send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy');
|
||||||
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { shouldShowScreenshareNotice } from './useScreenshareNotices';
|
||||||
|
|
||||||
|
test('shouldShowScreenshareNotice: no "window was closed" when an admin turned sharing off (#297)', () => {
|
||||||
|
assert.equal(shouldShowScreenshareNotice('ended', false), false);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('ended', true), true);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('no-frames', false), true);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('alone', true), true);
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Icons } from 'folds';
|
import { Icons } from 'folds';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useRoomCallPolicy } from './useRoomCallPolicy';
|
||||||
|
|
||||||
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
||||||
|
|
||||||
@@ -19,18 +20,32 @@ export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string |
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Pure: a policy-forced stop is explained by its own toast (#297). */
|
||||||
|
export const shouldShowScreenshareNotice = (
|
||||||
|
kind: ScreenshareNoticeKind,
|
||||||
|
allowScreenshare: boolean,
|
||||||
|
): boolean => !(kind === 'ended' && !allowScreenshare);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
||||||
* closed, no frames for a while, or a long share with nobody watching.
|
* closed, no frames for a while, or a long share with nobody watching.
|
||||||
*/
|
*/
|
||||||
export function useScreenshareNotices(embed: CallEmbed): void {
|
export function useScreenshareNotices(embed: CallEmbed): void {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
// [Gitea #297] When a room admin turns screen sharing off, the share ends
|
||||||
|
// and the fork reports `ended` ("the shared window was closed"), which is
|
||||||
|
// wrong; useCallPolicyRevokedToast already says what happened.
|
||||||
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
|
const allowRef = useRef(allowScreenshare);
|
||||||
|
allowRef.current = allowScreenshare;
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
||||||
'io.lotus.screenshare_notice',
|
'io.lotus.screenshare_notice',
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
|
const kind = evt.detail?.data?.kind as ScreenshareNoticeKind;
|
||||||
|
if (!shouldShowScreenshareNotice(kind, allowRef.current)) return;
|
||||||
|
const body = describeScreenshareNotice(kind);
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
setToast({
|
setToast({
|
||||||
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useFilePicker } from './useFilePicker';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { stripImageMetadata } from '../utils/stripImageMetadata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #332] `useFilePicker` for images that get published (emoji / sticker
|
||||||
|
* packs, pack avatars, power-level badges): applies "Remove Photo Metadata"
|
||||||
|
* like message attachments and avatars, so a phone photo's GPS location and
|
||||||
|
* timestamp don't go to every member of the room or space.
|
||||||
|
*/
|
||||||
|
export const useStrippedFilePicker = <M extends boolean | undefined = undefined>(
|
||||||
|
onSelect: (file: M extends true ? File[] : File) => void,
|
||||||
|
multiple?: M,
|
||||||
|
) => {
|
||||||
|
const [strip] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
|
const handle = useCallback(
|
||||||
|
(picked: File | File[]) => {
|
||||||
|
if (!strip) {
|
||||||
|
onSelect(picked as M extends true ? File[] : File);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clean = (f: File) => stripImageMetadata(f).then((r) => r.file);
|
||||||
|
if (Array.isArray(picked)) {
|
||||||
|
Promise.all(picked.map(clean)).then((files) =>
|
||||||
|
onSelect(files as M extends true ? File[] : File),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
clean(picked).then((file) => onSelect(file as M extends true ? File[] : File));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[strip, onSelect],
|
||||||
|
);
|
||||||
|
return useFilePicker(handle as (file: M extends true ? File[] : File) => void, multiple);
|
||||||
|
};
|
||||||
@@ -6,6 +6,8 @@ export enum Presence {
|
|||||||
Online = 'online',
|
Online = 'online',
|
||||||
Unavailable = 'unavailable',
|
Unavailable = 'unavailable',
|
||||||
Offline = 'offline',
|
Offline = 'offline',
|
||||||
|
// [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb.
|
||||||
|
Busy = 'org.matrix.msc3026.busy',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserPresence = {
|
export type UserPresence = {
|
||||||
@@ -129,6 +131,7 @@ export const usePresenceLabel = (): Record<Presence, string> =>
|
|||||||
[Presence.Online]: 'Online',
|
[Presence.Online]: 'Online',
|
||||||
[Presence.Unavailable]: 'Idle',
|
[Presence.Unavailable]: 'Idle',
|
||||||
[Presence.Offline]: 'Offline',
|
[Presence.Offline]: 'Offline',
|
||||||
|
[Presence.Busy]: 'Do Not Disturb',
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
getNotificationType,
|
getNotificationType,
|
||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
|
isFromFilteredTimelineSet,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType } from '../../../types/matrix/room';
|
import { NotificationType } from '../../../types/matrix/room';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
@@ -58,11 +59,11 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
import {
|
import {
|
||||||
MAX_MUTE_TIMEOUT_MS,
|
MAX_MUTE_TIMEOUT_MS,
|
||||||
MuteTimerEntry,
|
|
||||||
loadMuteTimers,
|
loadMuteTimers,
|
||||||
|
migrateLocalMuteTimers,
|
||||||
unmuteRoom,
|
unmuteRoom,
|
||||||
} from '../../features/room-nav/muteTimers';
|
} from '../../features/room-nav/muteTimers';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
|
||||||
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
|
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
|
||||||
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
@@ -91,6 +92,8 @@ import {
|
|||||||
shouldNotifyThreadReply,
|
shouldNotifyThreadReply,
|
||||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
} from '../../utils/threadNotifications';
|
} from '../../utils/threadNotifications';
|
||||||
|
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// Grace period after the initial sync settles before invite notifications arm, so
|
||||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||||
@@ -334,7 +337,9 @@ function InviteNotifications() {
|
|||||||
|
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
// [Gitea #324] The in-app toast (focused window) needs no browser
|
||||||
|
// permission; only the OS notification does.
|
||||||
|
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
|
||||||
notify(newCount);
|
notify(newCount);
|
||||||
}
|
}
|
||||||
if (notificationSound && inviteSoundId !== 'none') {
|
if (notificationSound && inviteSoundId !== 'none') {
|
||||||
@@ -369,30 +374,35 @@ function PresenceUpdater() {
|
|||||||
function MuteTimerRestore() {
|
function MuteTimerRestore() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
// [Gitea #333] Timers are in account data, so a mute set on another device
|
||||||
|
// is lifted here too, and changes there (a new mute, a manual unmute)
|
||||||
|
// reschedule. Moves any timers this browser kept locally (pre-#333) first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timers = loadMuteTimers();
|
migrateLocalMuteTimers(mx).catch(() => {});
|
||||||
if (timers.length === 0) return undefined;
|
}, [mx]);
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const pastDue: MuteTimerEntry[] = [];
|
|
||||||
const future: MuteTimerEntry[] = [];
|
|
||||||
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
|
|
||||||
|
|
||||||
pastDue.forEach((entry) => {
|
|
||||||
unmuteRoom(mx, entry.roomId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handles = future.map((entry) =>
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
unmuteRoom(mx, entry.roomId);
|
|
||||||
},
|
|
||||||
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let handles: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const schedule = () => {
|
||||||
|
handles.forEach(clearTimeout);
|
||||||
|
const now = Date.now();
|
||||||
|
handles = loadMuteTimers(mx).map((entry) =>
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
unmuteRoom(mx, entry.roomId);
|
||||||
|
},
|
||||||
|
Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onAccountData = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule();
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
mx.on(ClientEvent.AccountData, onAccountData);
|
||||||
return () => {
|
return () => {
|
||||||
handles.forEach(clearTimeout);
|
handles.forEach(clearTimeout);
|
||||||
|
mx.removeListener(ClientEvent.AccountData, onAccountData);
|
||||||
};
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
@@ -427,12 +437,7 @@ function StatusExpiryMonitor() {
|
|||||||
// permanently and the status stayed set forever. Retry the write (honouring
|
// permanently and the status stayed set forever. Retry the write (honouring
|
||||||
// Retry-After), and only forget the status once the server has taken it.
|
// Retry-After), and only forget the status once the server has taken it.
|
||||||
let clearing = false;
|
let clearing = false;
|
||||||
const check = () => {
|
const clearStatus = () => {
|
||||||
if (clearing) return;
|
|
||||||
const stored = localStorage.getItem(expiryKey);
|
|
||||||
if (!stored) return;
|
|
||||||
const ts = parseInt(stored, 10);
|
|
||||||
if (!ts || Date.now() < ts) return;
|
|
||||||
clearing = true;
|
clearing = true;
|
||||||
setPresenceWithRetry(mx, {
|
setPresenceWithRetry(mx, {
|
||||||
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
|
||||||
@@ -442,12 +447,31 @@ function StatusExpiryMonitor() {
|
|||||||
noteLocalStatusWrite();
|
noteLocalStatusWrite();
|
||||||
localStorage.removeItem(msgKey);
|
localStorage.removeItem(msgKey);
|
||||||
localStorage.removeItem(expiryKey);
|
localStorage.removeItem(expiryKey);
|
||||||
|
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
clearing = false;
|
clearing = false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const check = () => {
|
||||||
|
if (clearing) return;
|
||||||
|
const stored = localStorage.getItem(expiryKey);
|
||||||
|
const ts = stored ? parseInt(stored, 10) : 0;
|
||||||
|
if (ts && Date.now() >= ts) {
|
||||||
|
clearStatus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// [Gitea #300] An expiry set on another device (account data): clear the
|
||||||
|
// status if it is still that one, otherwise just forget the expiry.
|
||||||
|
const action = statusExpiryAction(
|
||||||
|
readStatusExpiry(mx),
|
||||||
|
mx.getUser(userId)?.presenceStatusMsg,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
if (action === 'clear') clearStatus();
|
||||||
|
else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
check();
|
check();
|
||||||
// Poll often enough that a rate-limited retry lands promptly, but not so
|
// Poll often enough that a rate-limited retry lands promptly, but not so
|
||||||
@@ -571,7 +595,8 @@ function MessageNotifications() {
|
|||||||
// NotificationOptions typing.
|
// NotificationOptions typing.
|
||||||
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
||||||
}),
|
}),
|
||||||
data: { path: roomPath, roomId, threadId: threadId || undefined },
|
// userId: [Gitea #299] the SW replies as this account only.
|
||||||
|
data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() },
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
window.focus();
|
window.focus();
|
||||||
@@ -579,7 +604,7 @@ function MessageNotifications() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[navigate, setToast, mDirects],
|
[navigate, setToast, mDirects, mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
// N105: when a service-worker-owned notification is clicked, the SW focuses
|
// N105: when a service-worker-owned notification is clicked, the SW focuses
|
||||||
@@ -641,7 +666,11 @@ function MessageNotifications() {
|
|||||||
|
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
// [Gitea #324] notify() shows an in-app toast while the window is focused,
|
||||||
|
// which needs no browser permission; only its OS-notification path does.
|
||||||
|
// Gating both on the permission left people who declined it (most web
|
||||||
|
// users) with a ping sound and nothing on screen.
|
||||||
|
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
|
||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
notify({
|
notify({
|
||||||
@@ -684,6 +713,7 @@ function MessageNotifications() {
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (mx.getSyncState() !== 'SYNCING') return;
|
if (mx.getSyncState() !== 'SYNCING') return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
|
||||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
||||||
if (
|
if (
|
||||||
!room ||
|
!room ||
|
||||||
@@ -1060,32 +1090,6 @@ function TauriUpdateFeature() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LotusDenoiseFeature() {
|
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (event.data?.type === 'lotus-denoise-status') {
|
|
||||||
const { active, error } = event.data;
|
|
||||||
if (!active) {
|
|
||||||
setToast({
|
|
||||||
id: `denoise-fail-${Date.now()}`,
|
|
||||||
displayName: 'Audio Quality',
|
|
||||||
body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`,
|
|
||||||
roomName: 'System',
|
|
||||||
roomId: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
|
||||||
}, [setToast]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registers the global `?` shortcut (ignored while typing) and renders the
|
// Registers the global `?` shortcut (ignored while typing) and renders the
|
||||||
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
||||||
function KeyboardShortcutsFeature() {
|
function KeyboardShortcutsFeature() {
|
||||||
@@ -1126,6 +1130,7 @@ function ClockSkewFeature() {
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (!data.liveEvent) return;
|
if (!data.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
// Only events our homeserver stamped: a federated event's
|
// Only events our homeserver stamped: a federated event's
|
||||||
// origin_server_ts is the other server's clock.
|
// origin_server_ts is the other server's clock.
|
||||||
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
||||||
@@ -1167,7 +1172,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<CloseBehaviorPrompt />
|
<CloseBehaviorPrompt />
|
||||||
<ConsentRequiredPrompt />
|
<ConsentRequiredPrompt />
|
||||||
<OutboxFeature />
|
<OutboxFeature />
|
||||||
<LotusDenoiseFeature />
|
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
<KeyboardShortcutsFeature />
|
<KeyboardShortcutsFeature />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState';
|
|||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom } from '../../state/serverStatus';
|
||||||
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
|
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
|
||||||
|
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
|
||||||
|
|
||||||
type StateData = {
|
type StateData = {
|
||||||
current: SyncState | null;
|
current: SyncState | null;
|
||||||
@@ -17,6 +18,7 @@ type SyncStatusProps = {
|
|||||||
};
|
};
|
||||||
export function SyncStatus({ mx }: SyncStatusProps) {
|
export function SyncStatus({ mx }: SyncStatusProps) {
|
||||||
const serverStatus = useAtomValue(serverStatusAtom);
|
const serverStatus = useAtomValue(serverStatusAtom);
|
||||||
|
const stalled = useAtomValue(homeserverStalledAtom);
|
||||||
const [stateData, setStateData] = useState<StateData>({
|
const [stateData, setStateData] = useState<StateData>({
|
||||||
current: null,
|
current: null,
|
||||||
previous: undefined,
|
previous: undefined,
|
||||||
@@ -34,6 +36,35 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
|||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
|
||||||
|
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
|
||||||
|
const serverDown = serverStatus.status?.homeserver === 'down';
|
||||||
|
// Only blame the user's connection when Kuma has checked the server since
|
||||||
|
// the drop and it was fine (a stale "up" from before proves nothing).
|
||||||
|
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
|
||||||
|
|
||||||
|
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
|
||||||
|
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" shrink="No">
|
||||||
|
<Box
|
||||||
|
className={ContainerColor({ variant: 'Warning' })}
|
||||||
|
style={{ padding: `${config.space.S100} 0` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Text size="L400">
|
||||||
|
Lotus Chat's server isn't responding. Messages will send when it's back.
|
||||||
|
Still trying…
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Line variant="Warning" size="300" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(stateData.current === SyncState.Prepared ||
|
(stateData.current === SyncState.Prepared ||
|
||||||
stateData.current === SyncState.Syncing ||
|
stateData.current === SyncState.Syncing ||
|
||||||
@@ -57,13 +88,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
|
|
||||||
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
|
|
||||||
const serverDown = serverStatus.status?.homeserver === 'down';
|
|
||||||
// Only blame the user's connection when Kuma has checked the server since
|
|
||||||
// the drop and it was fine (a stale "up" from before proves nothing).
|
|
||||||
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
|
|
||||||
|
|
||||||
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" shrink="No">
|
<Box direction="Column" shrink="No">
|
||||||
|
|||||||
+185
-134
@@ -255,6 +255,8 @@ function HomeCustomSection({
|
|||||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
});
|
});
|
||||||
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
||||||
|
// [Gitea #289] While filtering, a section with no matches is hidden.
|
||||||
|
if (filterQuery.trim() && items.length === 0) return null;
|
||||||
// Sections are user-curated and small, so they render plainly — no
|
// Sections are user-curated and small, so they render plainly — no
|
||||||
// virtualizer per section (a late-mounted one never measured its scroller).
|
// virtualizer per section (a late-mounted one never measured its scroller).
|
||||||
return (
|
return (
|
||||||
@@ -443,6 +445,26 @@ export function Home() {
|
|||||||
});
|
});
|
||||||
}, [mx, sortedRooms, filterQuery]);
|
}, [mx, sortedRooms, filterQuery]);
|
||||||
|
|
||||||
|
// [Gitea #289] While filtering, hide empty sections; with no match at all,
|
||||||
|
// say so instead of leaving bare headers.
|
||||||
|
const filtering = filterQuery.trim().length > 0;
|
||||||
|
const customSectionMatch = useMemo(() => {
|
||||||
|
if (!filtering) return true;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return customSections.some((section) =>
|
||||||
|
section.rooms.some((rId) =>
|
||||||
|
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [mx, customSections, filtering, filterQuery]);
|
||||||
|
const noFilterMatch =
|
||||||
|
filtering &&
|
||||||
|
!customSectionMatch &&
|
||||||
|
filteredFavoriteRooms.length === 0 &&
|
||||||
|
filteredRooms.length === 0 &&
|
||||||
|
filteredLowPriorityRooms.length === 0;
|
||||||
|
|
||||||
const favVirtualizer = useVirtualizer({
|
const favVirtualizer = useVirtualizer({
|
||||||
count: filteredFavoriteRooms.length,
|
count: filteredFavoriteRooms.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
@@ -580,7 +602,7 @@ export function Home() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
{favoriteRooms.length > 0 && (
|
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -629,142 +651,171 @@ export function Home() {
|
|||||||
notificationPreferences={notificationPreferences}
|
notificationPreferences={notificationPreferences}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<NavCategory>
|
{noFilterMatch && (
|
||||||
<NavCategoryHeader>
|
<NavCategory>
|
||||||
<RoomNavCategoryButton
|
<Box
|
||||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
direction="Column"
|
||||||
data-category-id={DEFAULT_CATEGORY_ID}
|
alignItems="Center"
|
||||||
onClick={handleCategoryClick}
|
gap="200"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
Rooms
|
<Text size="T300" priority="300" align="Center">
|
||||||
</RoomNavCategoryButton>
|
No rooms match “{filterQuery.trim()}”
|
||||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
</Text>
|
||||||
<>
|
<Button
|
||||||
<IconButton
|
size="300"
|
||||||
aria-expanded={!!sortMenuAnchor}
|
variant="Secondary"
|
||||||
aria-haspopup="menu"
|
fill="Soft"
|
||||||
variant="Background"
|
radii="300"
|
||||||
fill="None"
|
onClick={() => setFilterQuery('')}
|
||||||
size="300"
|
>
|
||||||
radii="300"
|
<Text size="B300">Clear filter</Text>
|
||||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
</Button>
|
||||||
const cords = evt.currentTarget.getBoundingClientRect();
|
</Box>
|
||||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
</NavCategory>
|
||||||
}}
|
)}
|
||||||
aria-label="Sort rooms"
|
{(!filtering || filteredRooms.length > 0) && (
|
||||||
>
|
<NavCategory>
|
||||||
<Icon src={Icons.Sort} size="100" />
|
<NavCategoryHeader>
|
||||||
</IconButton>
|
<RoomNavCategoryButton
|
||||||
<PopOut
|
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||||
anchor={sortMenuAnchor}
|
data-category-id={DEFAULT_CATEGORY_ID}
|
||||||
position="Bottom"
|
onClick={handleCategoryClick}
|
||||||
align="End"
|
>
|
||||||
offset={6}
|
Rooms
|
||||||
content={
|
</RoomNavCategoryButton>
|
||||||
<FocusTrap
|
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||||
focusTrapOptions={{
|
<>
|
||||||
initialFocus: false,
|
<IconButton
|
||||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
aria-expanded={!!sortMenuAnchor}
|
||||||
clickOutsideDeactivates: true,
|
aria-haspopup="menu"
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
variant="Background"
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
fill="None"
|
||||||
escapeDeactivates: stopPropagation,
|
size="300"
|
||||||
}}
|
radii="300"
|
||||||
>
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
const cords = evt.currentTarget.getBoundingClientRect();
|
||||||
<Box
|
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||||
direction="Column"
|
}}
|
||||||
gap="100"
|
aria-label="Sort rooms"
|
||||||
style={{ padding: config.space.S100 }}
|
>
|
||||||
>
|
<Icon src={Icons.Sort} size="100" />
|
||||||
<MenuItem
|
</IconButton>
|
||||||
onClick={() => {
|
<PopOut
|
||||||
setHomeRoomSort('recent');
|
anchor={sortMenuAnchor}
|
||||||
setSortMenuAnchor(undefined);
|
position="Bottom"
|
||||||
}}
|
align="End"
|
||||||
size="300"
|
offset={6}
|
||||||
after={
|
content={
|
||||||
homeRoomSort === 'recent' ? (
|
<FocusTrap
|
||||||
<Icon size="100" src={Icons.Check} />
|
focusTrapOptions={{
|
||||||
) : undefined
|
initialFocus: false,
|
||||||
}
|
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||||
radii="300"
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ padding: config.space.S100 }}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<MenuItem
|
||||||
Recent Activity
|
onClick={() => {
|
||||||
</Text>
|
setHomeRoomSort('recent');
|
||||||
</MenuItem>
|
setSortMenuAnchor(undefined);
|
||||||
<MenuItem
|
}}
|
||||||
onClick={() => {
|
size="300"
|
||||||
setHomeRoomSort('alpha');
|
after={
|
||||||
setSortMenuAnchor(undefined);
|
homeRoomSort === 'recent' ? (
|
||||||
}}
|
<Icon size="100" src={Icons.Check} />
|
||||||
size="300"
|
) : undefined
|
||||||
after={
|
}
|
||||||
homeRoomSort === 'alpha' ? (
|
radii="300"
|
||||||
<Icon size="100" src={Icons.Check} />
|
>
|
||||||
) : undefined
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
}
|
Recent Activity
|
||||||
radii="300"
|
</Text>
|
||||||
>
|
</MenuItem>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<MenuItem
|
||||||
A → Z
|
onClick={() => {
|
||||||
</Text>
|
setHomeRoomSort('alpha');
|
||||||
</MenuItem>
|
setSortMenuAnchor(undefined);
|
||||||
<MenuItem
|
}}
|
||||||
onClick={() => {
|
size="300"
|
||||||
setHomeRoomSort('unread');
|
after={
|
||||||
setSortMenuAnchor(undefined);
|
homeRoomSort === 'alpha' ? (
|
||||||
}}
|
<Icon size="100" src={Icons.Check} />
|
||||||
size="300"
|
) : undefined
|
||||||
after={
|
}
|
||||||
homeRoomSort === 'unread' ? (
|
radii="300"
|
||||||
<Icon size="100" src={Icons.Check} />
|
>
|
||||||
) : undefined
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
}
|
A → Z
|
||||||
radii="300"
|
</Text>
|
||||||
>
|
</MenuItem>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<MenuItem
|
||||||
Unread First
|
onClick={() => {
|
||||||
</Text>
|
setHomeRoomSort('unread');
|
||||||
</MenuItem>
|
setSortMenuAnchor(undefined);
|
||||||
</Box>
|
}}
|
||||||
</Menu>
|
size="300"
|
||||||
</FocusTrap>
|
after={
|
||||||
}
|
homeRoomSort === 'unread' ? (
|
||||||
/>
|
<Icon size="100" src={Icons.Check} />
|
||||||
</>
|
) : undefined
|
||||||
)}
|
}
|
||||||
</NavCategoryHeader>
|
radii="300"
|
||||||
<div
|
>
|
||||||
style={{
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
position: 'relative',
|
Unread First
|
||||||
height: virtualizer.getTotalSize(),
|
</Text>
|
||||||
}}
|
</MenuItem>
|
||||||
>
|
</Box>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
</Menu>
|
||||||
const roomId = filteredRooms[vItem.index];
|
</FocusTrap>
|
||||||
const room = mx.getRoom(roomId);
|
}
|
||||||
if (!room) return null;
|
|
||||||
const selected = selectedRoomId === roomId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
|
||||||
<RoomNavItem
|
|
||||||
room={room}
|
|
||||||
selected={selected}
|
|
||||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
|
||||||
notificationMode={getRoomNotificationMode(
|
|
||||||
notificationPreferences,
|
|
||||||
room.roomId,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</>
|
||||||
);
|
)}
|
||||||
})}
|
</NavCategoryHeader>
|
||||||
</div>
|
<div
|
||||||
</NavCategory>
|
style={{
|
||||||
{lowPriorityRooms.length > 0 && (
|
position: 'relative',
|
||||||
|
height: virtualizer.getTotalSize(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = filteredRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selected}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
|
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
|
|||||||
import { CallControlState } from './CallControlState';
|
import { CallControlState } from './CallControlState';
|
||||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
|
import { CallErrorState, parseCallError } from './callError';
|
||||||
|
|
||||||
export enum CallControlEvent {
|
export enum CallControlEvent {
|
||||||
StateUpdate = 'state_update',
|
StateUpdate = 'state_update',
|
||||||
@@ -54,6 +55,29 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const CALL_ERROR_CHANGED = 'call_error_changed';
|
||||||
|
|
||||||
|
export type AudioOutputOption = { id: string; label: string };
|
||||||
|
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
|
||||||
|
|
||||||
|
/** Pure: parse the fork's `io.lotus.audio_output_state` (#310). */
|
||||||
|
export function parseAudioOutputState(
|
||||||
|
data: unknown,
|
||||||
|
): { deviceId?: string; available?: AudioOutputOption[] } | undefined {
|
||||||
|
if (typeof data !== 'object' || data === null) return undefined;
|
||||||
|
const { deviceId, available } = data as { deviceId?: unknown; available?: unknown };
|
||||||
|
const list = Array.isArray(available)
|
||||||
|
? available.filter(
|
||||||
|
(o): o is AudioOutputOption =>
|
||||||
|
typeof o === 'object' &&
|
||||||
|
o !== null &&
|
||||||
|
typeof (o as AudioOutputOption).id === 'string' &&
|
||||||
|
typeof (o as AudioOutputOption).label === 'string',
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list };
|
||||||
|
}
|
||||||
|
|
||||||
export class CallControl extends EventEmitter implements CallControlState {
|
export class CallControl extends EventEmitter implements CallControlState {
|
||||||
private state: CallControlState;
|
private state: CallControlState;
|
||||||
|
|
||||||
@@ -91,6 +115,13 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||||
super();
|
super();
|
||||||
|
// [Gitea #316] More than 10 call features subscribe to state_update at once
|
||||||
|
// (call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
|
||||||
|
// announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC …).
|
||||||
|
// Hitting the default limit logged a MaxListenersExceededWarning, and the
|
||||||
|
// console then kept this CallControl — and through it the whole Element Call
|
||||||
|
// document — alive: ~11 MB leaked per call. These are not leaks, so lift it.
|
||||||
|
this.setMaxListeners(100);
|
||||||
|
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.call = call;
|
this.call = call;
|
||||||
@@ -205,11 +236,51 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
return this._audioOutputId;
|
return this._audioOutputId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #318] The fork's error page ("Connection lost") is up; the host
|
||||||
|
// kept showing the call as joined with controls that did nothing.
|
||||||
|
private _callError: CallErrorState = null;
|
||||||
|
|
||||||
|
public get callError(): CallErrorState {
|
||||||
|
return this._callError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fork's `io.lotus.call_error` report. */
|
||||||
|
public onCallError(data: unknown) {
|
||||||
|
const next = parseCallError(data);
|
||||||
|
if (next === undefined) return;
|
||||||
|
this._callError = next;
|
||||||
|
this.emit(CALL_ERROR_CHANGED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Press the fork's Reconnect / Try again. */
|
||||||
|
public reconnect(): void {
|
||||||
|
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
public setAudioOutput(deviceId: string): void {
|
public setAudioOutput(deviceId: string): void {
|
||||||
this._audioOutputId = deviceId;
|
this._audioOutputId = deviceId;
|
||||||
this.sendAudioOutput(deviceId);
|
this.sendAudioOutput(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #310] deviceIds are hashed per frame/origin, so ids from the
|
||||||
|
// host's own enumerateDevices() mean nothing inside the call iframe. The
|
||||||
|
// fork reports its outputs (ids valid in the frame) and its effective
|
||||||
|
// selection; the call bar's menu uses those when present.
|
||||||
|
private _audioOutputs: AudioOutputOption[] | undefined;
|
||||||
|
|
||||||
|
public get audioOutputs(): AudioOutputOption[] | undefined {
|
||||||
|
return this._audioOutputs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fork's `io.lotus.audio_output_state` report. */
|
||||||
|
public onAudioOutputState(data: unknown) {
|
||||||
|
const report = parseAudioOutputState(data);
|
||||||
|
if (!report) return;
|
||||||
|
this._audioOutputs = report.available;
|
||||||
|
if (report.deviceId !== undefined) this._audioOutputId = report.deviceId;
|
||||||
|
this.emit(AUDIO_OUTPUT_STATE);
|
||||||
|
}
|
||||||
|
|
||||||
private sendAudioOutput(deviceId: string): void {
|
private sendAudioOutput(deviceId: string): void {
|
||||||
if (!this.joined) return;
|
if (!this.joined) return;
|
||||||
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
|||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
import { getCallPageUrl } from './callPageUrl';
|
import { getCallPageUrl } from './callPageUrl';
|
||||||
import { restrictWidgetMessages } from '../widgetTransport';
|
import { restrictWidgetMessages } from '../widgetTransport';
|
||||||
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
|
|
||||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
@@ -217,6 +218,9 @@ export class CallEmbed {
|
|||||||
// - transparent background so the room wallpaper shows through natively
|
// - transparent background so the room wallpaper shows through natively
|
||||||
lotusCallState: 'true',
|
lotusCallState: 'true',
|
||||||
lotusTransparent: 'true',
|
lotusTransparent: 'true',
|
||||||
|
// [Gitea #293] the fork applies the theme with transparency anyway, and
|
||||||
|
// warned in every call that lotusTheme wasn't set
|
||||||
|
lotusTheme: 'true',
|
||||||
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
|
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
|
||||||
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
||||||
lotusHostControls: 'true',
|
lotusHostControls: 'true',
|
||||||
@@ -251,8 +255,18 @@ export class CallEmbed {
|
|||||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
|
||||||
|
// (DMs, invite-only groups outside a space). In a voice channel or space
|
||||||
|
// room the notification carries an @room mention, so every member got a
|
||||||
|
// highlighted ping (and phone push) whenever a moderator hopped in, and
|
||||||
|
// other clients showed it as a missed call.
|
||||||
|
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
|
||||||
if (CallEmbed.startingCall(intent)) {
|
if (CallEmbed.startingCall(intent)) {
|
||||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
let notify = 'none';
|
||||||
|
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
|
||||||
|
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
|
||||||
|
}
|
||||||
|
params.append('sendNotificationType', notify);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||||
@@ -439,6 +453,18 @@ export class CallEmbed {
|
|||||||
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
|
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
// [Gitea #318] The fork's error page ("Connection lost") is up / cleared.
|
||||||
|
this.disposables.push(
|
||||||
|
this.listenAction('io.lotus.call_error', (evt) => {
|
||||||
|
this.control.onCallError((evt.detail as { data?: unknown } | undefined)?.data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// [Gitea #310] The fork's own output list + selection (frame-valid ids).
|
||||||
|
this.disposables.push(
|
||||||
|
this.listenAction('io.lotus.audio_output_state', (evt) => {
|
||||||
|
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
||||||
this.disposables.push(
|
this.disposables.push(
|
||||||
this.listenAction('io.lotus.hotkey', (evt) => {
|
this.listenAction('io.lotus.hotkey', (evt) => {
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { getCallCapabilities } from './utils';
|
import { getCallCapabilities } from './utils';
|
||||||
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
|
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { dedupeInFlight } from '../../utils/inFlight';
|
||||||
|
|
||||||
|
/** A little longer than Element Call's own 2 s timeout for a restart. */
|
||||||
|
const RESTART_TIMEOUT_MS = 5000;
|
||||||
export class CallWidgetDriver extends WidgetDriver {
|
export class CallWidgetDriver extends WidgetDriver {
|
||||||
private allowedCapabilities: Set<Capability>;
|
private allowedCapabilities: Set<Capability>;
|
||||||
|
|
||||||
@@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver {
|
|||||||
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
|
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #318] While the homeserver is stalled, Element Call times out each
|
||||||
|
// restart locally after 2 s and asks again, so requests for the same delay
|
||||||
|
// id piled up and hit the server together on recovery (404s, then 429s).
|
||||||
|
// Share one in-flight restart per id, and give it a timeout of its own.
|
||||||
|
private readonly restartOnce = dedupeInFlight<unknown>();
|
||||||
|
|
||||||
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
|
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||||
await this.mx._unstable_restartScheduledDelayedEvent(delayId);
|
await this.restartOnce(delayId, () =>
|
||||||
|
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
|
||||||
|
localTimeoutMs: RESTART_TIMEOUT_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
|
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseAudioOutputState } from './CallControl';
|
||||||
|
|
||||||
|
test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
parseAudioOutputState({
|
||||||
|
deviceId: 'f2',
|
||||||
|
available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
deviceId: 'f2',
|
||||||
|
available: [
|
||||||
|
{ id: '', label: 'Default' },
|
||||||
|
{ id: 'f2', label: 'Headset' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.deepEqual(parseAudioOutputState({ deviceId: null }), {
|
||||||
|
deviceId: undefined,
|
||||||
|
available: undefined,
|
||||||
|
});
|
||||||
|
assert.equal(parseAudioOutputState('x'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseCallError } from './callError';
|
||||||
|
|
||||||
|
test('parseCallError (#318)', () => {
|
||||||
|
assert.deepEqual(parseCallError({ code: 'CONNECTION_LOST_ERROR', recoverable: true }), {
|
||||||
|
code: 'CONNECTION_LOST_ERROR',
|
||||||
|
recoverable: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(parseCallError({ code: 'E2EE_NOT_SUPPORTED' }), {
|
||||||
|
code: 'E2EE_NOT_SUPPORTED',
|
||||||
|
recoverable: false,
|
||||||
|
});
|
||||||
|
assert.equal(parseCallError({ code: null }), null);
|
||||||
|
assert.equal(parseCallError({}), undefined);
|
||||||
|
assert.equal(parseCallError('x'), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #318] The fork's `io.lotus.call_error`: its error page is showing
|
||||||
|
* (`{ code, recoverable }`) or was cleared (`{ code: null }`).
|
||||||
|
*/
|
||||||
|
export type CallErrorState = { code: string; recoverable: boolean } | null;
|
||||||
|
|
||||||
|
/** Pure: parse a report; undefined for garbage (ignored). */
|
||||||
|
export function parseCallError(data: unknown): CallErrorState | undefined {
|
||||||
|
if (typeof data !== 'object' || data === null) return undefined;
|
||||||
|
const { code, recoverable } = data as { code?: unknown; recoverable?: unknown };
|
||||||
|
if (code === null) return null;
|
||||||
|
if (typeof code !== 'string' || !code) return undefined;
|
||||||
|
return { code, recoverable: recoverable === true };
|
||||||
|
}
|
||||||
@@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => {
|
|||||||
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
||||||
* swept with the rest — the server-side presence status survives, so a
|
* swept with the rest — the server-side presence status survives, so a
|
||||||
* re-login loses nothing)
|
* re-login loses nothing)
|
||||||
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
|
||||||
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
|
||||||
* preferences, not swept here
|
* Timed mutes moved to account data in [Gitea #333]; a leftover local
|
||||||
|
* `io.lotus.mute_timers` is migrated there on the next start.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
|
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
getUnreadInfos,
|
getUnreadInfos,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
|
isFromFilteredTimelineSet,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { roomToParentsAtom } from './roomToParents';
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
@@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
data: IRoomTimelineData,
|
data: IRoomTimelineData,
|
||||||
) => {
|
) => {
|
||||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
// Single-owner rule: thread replies drive the room badge via
|
// Single-owner rule: thread replies drive the room badge via
|
||||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||||
// never double-driven / mis-attributed to the main timeline.
|
// never double-driven / mis-attributed to the main timeline.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
|
|||||||
/**
|
/**
|
||||||
* Global atom: Map<roomId, ScheduledMessage[]>
|
* Global atom: Map<roomId, ScheduledMessage[]>
|
||||||
* Stores all locally-tracked scheduled messages across rooms.
|
* Stores all locally-tracked scheduled messages across rooms.
|
||||||
* MSC4140 has no list endpoint, so we track them ourselves.
|
* Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts
|
||||||
* Backed by localStorage so scheduled messages survive page refreshes.
|
* (Gitea #322); localStorage is a cache for the first paint.
|
||||||
*/
|
*/
|
||||||
export const scheduledMessagesAtom = atom(
|
export const scheduledMessagesAtom = atom(
|
||||||
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
||||||
|
|||||||
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
|
|||||||
syncLost: false,
|
syncLost: false,
|
||||||
lostAt: null,
|
lostAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 baseDismissedAtom = atom<string[]>(readDismissed());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
|
||||||
|
* the security nudge knows a dismissed announcement isn't on screen.
|
||||||
|
* Persisted to localStorage; session-only if storage is unavailable.
|
||||||
|
*/
|
||||||
|
export const statusDismissedAtom = atom(
|
||||||
|
(get) => get(baseDismissedAtom),
|
||||||
|
(get, set, key: string) => {
|
||||||
|
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable — dismissed for this session only
|
||||||
|
}
|
||||||
|
set(baseDismissedAtom, next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { BUSY_PRESENCE, isDndPresence } from './busyPresence';
|
||||||
|
|
||||||
|
test('isDndPresence: busy, or the legacy unavailable + "dnd" text (#283)', () => {
|
||||||
|
assert.equal(isDndPresence(BUSY_PRESENCE, '🎮 Gaming'), true);
|
||||||
|
assert.equal(isDndPresence(BUSY_PRESENCE, undefined), true);
|
||||||
|
assert.equal(isDndPresence('unavailable', 'dnd'), true);
|
||||||
|
assert.equal(isDndPresence('unavailable', 'lunch'), false);
|
||||||
|
assert.equal(isDndPresence('online', 'dnd'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #283] Do Not Disturb as a real presence state: MSC3026 "busy"
|
||||||
|
* (Synapse `experimental_features.msc3026_enabled`). Before, DND was sent as
|
||||||
|
* presence `unavailable` with the literal status text "dnd", which replaced
|
||||||
|
* your status and showed as "dnd" in other clients.
|
||||||
|
*/
|
||||||
|
export const BUSY_PRESENCE = 'org.matrix.msc3026.busy';
|
||||||
|
export const BUSY_PRESENCE_FEATURE = 'org.matrix.msc3026.busy_presence';
|
||||||
|
|
||||||
|
let busySupported = false;
|
||||||
|
|
||||||
|
/** Recorded by the presence updater from the server's unstable_features. */
|
||||||
|
export const setBusyPresenceSupported = (supported: boolean): void => {
|
||||||
|
busySupported = supported;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isBusyPresenceSupported = (): boolean => busySupported;
|
||||||
|
|
||||||
|
/** Pure: whether a presence means Do Not Disturb (busy, or the legacy "dnd" text). */
|
||||||
|
export const isDndPresence = (presence: string | undefined, status: string | undefined): boolean =>
|
||||||
|
presence === BUSY_PRESENCE || (presence === 'unavailable' && status === 'dnd');
|
||||||
@@ -67,3 +67,18 @@ test('declinedRing finds our own decline of that ring only (#312)', () => {
|
|||||||
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
||||||
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('declinedRing matches a decline of the notification or of the membership (#313)', () => {
|
||||||
|
const decline = (rel: string) => ({
|
||||||
|
getType: () => 'org.matrix.msc4310.rtc.decline',
|
||||||
|
getSender: () => '@me:x',
|
||||||
|
getRelation: () => ({ event_id: rel }),
|
||||||
|
});
|
||||||
|
const room = stateRoom([], [decline('$notification')]);
|
||||||
|
assert.equal(declinedRing(room, ['$membership', '$notification'], '@me:x'), true);
|
||||||
|
assert.equal(declinedRing(room, ['$membership'], '@me:x'), false);
|
||||||
|
assert.equal(
|
||||||
|
declinedRing(stateRoom([], [decline('$membership')]), ['$membership', undefined], '@me:x'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,7 +40,18 @@ export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
|||||||
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
||||||
* the ring in the first sync after a restart, before any dismiss listener.
|
* the ring in the first sync after a restart, before any dismiss listener.
|
||||||
*/
|
*/
|
||||||
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
|
export const declinedRing = (
|
||||||
|
room: Room,
|
||||||
|
ringIds: string | Array<string | undefined>,
|
||||||
|
myUserId: string,
|
||||||
|
): boolean => {
|
||||||
|
// [Gitea #313] A decline references the notification event (MSC4310); older
|
||||||
|
// Lotus builds referenced the call membership instead. Match either.
|
||||||
|
const ids = new Set((Array.isArray(ringIds) ? ringIds : [ringIds]).filter(Boolean));
|
||||||
|
return declinedAny(room, ids, myUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string): boolean =>
|
||||||
room
|
room
|
||||||
.getLiveTimeline()
|
.getLiveTimeline()
|
||||||
.getEvents()
|
.getEvents()
|
||||||
@@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string):
|
|||||||
(ev) =>
|
(ev) =>
|
||||||
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
||||||
ev.getSender() === myUserId &&
|
ev.getSender() === myUserId &&
|
||||||
ev.getRelation()?.event_id === refEventId,
|
ids.has(ev.getRelation()?.event_id),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { denoiseNotice } from './denoiseNotice';
|
||||||
|
|
||||||
|
test('denoiseNotice: says when ML is off or fell back (#260)', () => {
|
||||||
|
assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined);
|
||||||
|
assert.match(
|
||||||
|
denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '',
|
||||||
|
/^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '',
|
||||||
|
/unfiltered audio/,
|
||||||
|
);
|
||||||
|
assert.equal(denoiseNotice('rnnoise', undefined), undefined);
|
||||||
|
assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #260] The fork reports what noise suppression actually runs as
|
||||||
|
* `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for
|
||||||
|
* a report, or undefined when what runs is what the user picked.
|
||||||
|
*/
|
||||||
|
export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown };
|
||||||
|
|
||||||
|
const MODEL_NAMES: Record<string, string> = {
|
||||||
|
rnnoise: 'RNNoise',
|
||||||
|
speex: 'Speex',
|
||||||
|
dtln: 'DTLN',
|
||||||
|
deepfilternet: 'DeepFilterNet 3',
|
||||||
|
};
|
||||||
|
const modelName = (m: unknown): string =>
|
||||||
|
typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model';
|
||||||
|
|
||||||
|
export function denoiseNotice(
|
||||||
|
requestedModel: string,
|
||||||
|
report: DenoiseStateReport | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!report || typeof report.active !== 'boolean') return undefined;
|
||||||
|
if (!report.active) {
|
||||||
|
return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`;
|
||||||
|
}
|
||||||
|
if (typeof report.model === 'string' && report.model !== requestedModel) {
|
||||||
|
return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { IRoomTimelineData } from 'matrix-js-sdk';
|
||||||
|
import { isFromFilteredTimelineSet } from './room';
|
||||||
|
|
||||||
|
const data = (filter: unknown): IRoomTimelineData =>
|
||||||
|
({
|
||||||
|
liveEvent: true,
|
||||||
|
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
|
||||||
|
}) as unknown as IRoomTimelineData;
|
||||||
|
|
||||||
|
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
|
||||||
|
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
|
||||||
|
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
|
||||||
|
assert.equal(isFromFilteredTimelineSet(undefined), false);
|
||||||
|
assert.equal(
|
||||||
|
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { formatFileSize, isCompressible } from './imageCompression';
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
hasTransparency,
|
||||||
|
isAnimatedWebp,
|
||||||
|
isCompressible,
|
||||||
|
} from './imageCompression';
|
||||||
|
|
||||||
test('formatFileSize formats bytes below 1KB as B', () => {
|
test('formatFileSize formats bytes below 1KB as B', () => {
|
||||||
assert.equal(formatFileSize(0), '0 B');
|
assert.equal(formatFileSize(0), '0 B');
|
||||||
@@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => {
|
|||||||
test('isCompressible accepts raster image types', () => {
|
test('isCompressible accepts raster image types', () => {
|
||||||
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
|
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
|
||||||
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
|
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
|
||||||
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
|
|
||||||
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
|
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isCompressible rejects svg, empty type, and non-images', () => {
|
test('isCompressible rejects svg, empty type, and non-images', () => {
|
||||||
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
|
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
|
||||||
|
// [Gitea #331] usually animated; a canvas keeps one frame
|
||||||
|
assert.equal(isCompressible({ type: 'image/gif' } as Blob), false);
|
||||||
|
assert.equal(isCompressible({ type: 'image/apng' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: '' } as Blob), false);
|
assert.equal(isCompressible({ type: '' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
|
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
|
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
|
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webpHeader = (chunk: string, flags = 0) => {
|
||||||
|
const b = new Uint8Array(32);
|
||||||
|
const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at);
|
||||||
|
put(0, 'RIFF');
|
||||||
|
put(8, 'WEBP');
|
||||||
|
put(12, chunk);
|
||||||
|
b[20] = flags;
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('isAnimatedWebp: VP8X with the animation flag (#331)', () => {
|
||||||
|
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true);
|
||||||
|
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only');
|
||||||
|
assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy');
|
||||||
|
assert.equal(isAnimatedWebp(new Uint8Array(8)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasTransparency (#331)', () => {
|
||||||
|
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false);
|
||||||
|
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true);
|
||||||
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user