Compare commits
175
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
d412f9c56c | ||
|
|
acc17f422b | ||
|
|
ad5631819d | ||
|
|
5feb2184be | ||
|
|
86b77ac9f3 | ||
|
|
83933fb5ac | ||
|
|
cd760ebc3c | ||
|
|
1511cb0b97 | ||
|
|
3e11a6fc0b | ||
|
|
5ce4692a7b | ||
|
|
4486f02bc6 | ||
|
|
a7c9d15824 | ||
|
|
7b3d1bad8f | ||
|
|
2c11bd3b8e | ||
|
|
6f7a8f64e2 | ||
|
|
e6e52cdc37 | ||
|
|
4969ef9b6d | ||
|
|
be71931a81 | ||
|
|
4ab5a8fd06 | ||
|
|
4e30ae763e | ||
|
|
f9265d2d90 | ||
|
|
82c5728a96 | ||
|
|
dd6cf156ba | ||
|
|
e94cdfe5dc | ||
|
|
bb2ace82eb | ||
|
|
3a74699fef | ||
|
|
7fc42b32d4 | ||
|
|
005fc9e768 | ||
|
|
33cc0de97c | ||
|
|
3bf040f17a | ||
|
|
906a034e26 | ||
|
|
69e05bbf8d | ||
|
|
15f2fcc4d1 | ||
|
|
8e7127a350 | ||
|
|
f30a65eb74 | ||
|
|
0a2f18e3d5 | ||
|
|
0d08ba0a55 | ||
|
|
6ae4d7adc0 | ||
|
|
b855eb505d | ||
|
|
f7bdd2855a | ||
|
|
26cf1204b5 | ||
|
|
e16c923bea | ||
|
|
cc8e7444f6 | ||
|
|
f9172cc339 | ||
|
|
ef7690135d | ||
|
|
8bbb436ca7 | ||
|
|
4ddcda122a | ||
|
|
9c433d9d77 | ||
|
|
e8fd0a1112 | ||
|
|
00e27a24e6 | ||
|
|
46d6570df3 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
a425aa3572 | ||
|
|
f0b7400d1b | ||
|
|
d1270956ea | ||
|
|
3878a85339 | ||
|
|
9edb76835c | ||
|
|
1784d40ba1 | ||
|
|
76ef8a52c7 | ||
|
|
a3ef2b4098 | ||
|
|
0c5cf4109a | ||
|
|
39f74d51e6 | ||
|
|
36e697613b | ||
|
|
2d31312a73 | ||
|
|
57036bb6a4 | ||
|
|
c6419044cb | ||
|
|
29f6a35bfd | ||
|
|
78691f1fff | ||
|
|
a86947be48 | ||
|
|
37372b215a | ||
|
|
b6f9af3f58 | ||
|
|
5b5fbdb7b7 | ||
|
|
8b2322f751 | ||
|
|
02950499ea | ||
|
|
05d7e87d26 | ||
|
|
bbb2034080 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
4650796120 | ||
|
|
d55a8ba8a5 | ||
|
|
4b8c3219e9 | ||
|
|
47328d6fee | ||
|
|
b0eb3da830 | ||
|
|
f22f9f07a3 | ||
|
|
2700aad209 | ||
|
|
a8a7e639a9 | ||
|
|
508a123150 | ||
|
|
fe61352d88 | ||
|
|
0bec28b651 | ||
|
|
75089ecd3f | ||
|
|
a4a163df3d | ||
|
|
70f1d640ed | ||
|
|
85a2abc46a | ||
|
|
5130dfc9c8 | ||
|
|
e57af60466 | ||
|
|
1f10866b60 | ||
|
|
d9c797f3f5 | ||
|
|
9b2ec91dd6 | ||
|
|
1618d71e53 | ||
|
|
4736e23400 | ||
|
|
06294f5269 | ||
|
|
fea9484a03 | ||
|
|
cf22fd8501 | ||
|
|
ddf9da1a55 | ||
|
|
861293243c | ||
|
|
e50bd677d6 | ||
|
|
022e5e6987 | ||
|
|
5c765925d2 | ||
|
|
747714b2f3 | ||
|
|
c9d276d2a5 | ||
|
|
68f44f0f1c | ||
|
|
30e2dbcde9 | ||
|
|
d1993f2084 | ||
|
|
6a7627fa26 | ||
|
|
8e30c73e2f | ||
|
|
6acdd439d5 | ||
|
|
9aec3691ac | ||
|
|
4c36c03066 | ||
|
|
fbdac30d18 | ||
|
|
27d659118f | ||
|
|
747400ea25 | ||
|
|
6ae0087213 | ||
|
|
d6548c56fd | ||
|
|
23f059d9a4 | ||
|
|
9b9f33b270 | ||
|
|
7d7a379ce0 | ||
|
|
91f82d60e3 | ||
|
|
f0865115a4 | ||
|
|
899e160aed | ||
|
|
3e5fdd0dab | ||
|
|
bb569d69a2 | ||
|
|
02d86caeb0 | ||
|
|
c0c93213c1 | ||
|
|
1ea6987083 | ||
|
|
be8e49a2bb | ||
|
|
1e217592fc | ||
|
|
ffe1d5a81e | ||
|
|
fff583fca5 | ||
|
|
e2b23397bd | ||
|
|
b929cbf74b | ||
|
|
8520de232d | ||
|
|
773e41311e |
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": [
|
||||
"matrix.lotusguild.org"
|
||||
],
|
||||
"allowCustomHomeservers": false,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
}
|
||||
}
|
||||
@@ -14,3 +14,6 @@ test-results/
|
||||
# local dev homeserver (scripts/dev-homeserver.sh)
|
||||
.dev-homeserver/
|
||||
__pycache__/
|
||||
|
||||
# Local config backups
|
||||
*.bak
|
||||
|
||||
+95
-2
@@ -1,7 +1,7 @@
|
||||
# Lotus Chat — Feature Reference
|
||||
|
||||
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)
|
||||
20. [Localization](#localization)
|
||||
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)
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
| 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)
|
||||
|
||||
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] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||
|
||||
+12
-1
@@ -12,5 +12,16 @@
|
||||
"enabled": false,
|
||||
"basename": "/"
|
||||
},
|
||||
"gifApiKey": ""
|
||||
"gifApiKey": "",
|
||||
"statusPages": {
|
||||
"matrix.lotusguild.org": {
|
||||
"url": "https://isitup.lotusguild.org",
|
||||
"slug": "matrix",
|
||||
"groups": {
|
||||
"homeserver": "Homeserver",
|
||||
"calls": "Voice calls",
|
||||
"login": "Login"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-1
@@ -42,7 +42,18 @@ export function collectConsole(page: Page): ConsoleCollector {
|
||||
const errors: string[] = [];
|
||||
const pageErrors: string[] = [];
|
||||
page.on('console', (msg) => {
|
||||
if (msg.type() === 'error') errors.push(msg.text());
|
||||
if (msg.type() !== 'error') return;
|
||||
// The login page's registration probe (POST /_matrix/client/v3/register)
|
||||
// answers 403 "Registration has been disabled" on a homeserver with sign-up
|
||||
// closed (matrix.lotusguild.org since 2026-10-16: accounts come from SSO).
|
||||
// Allowed only for that URL so any other 403 still fails the test.
|
||||
if (
|
||||
/status of 403/.test(msg.text()) &&
|
||||
/\/_matrix\/client\/v3\/register/.test(msg.location().url)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
errors.push(msg.text());
|
||||
});
|
||||
page.on('pageerror', (err) => {
|
||||
pageErrors.push(err.message);
|
||||
|
||||
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
|
||||
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 }) => {
|
||||
const room = await createRoom(alice, 'Scroll Room');
|
||||
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||
@@ -257,12 +303,21 @@ test.describe('local homeserver regression', () => {
|
||||
await page.clock.install({ time: Date.now() + 14 * 60 * 1000 });
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sendText(bob, room, `tick ${i}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
const ticks = async (from: number, n: number) => {
|
||||
for (let i = from; i < from + n; i += 1) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await sendText(bob, room, `tick ${i}`);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await page.waitForTimeout(500);
|
||||
}
|
||||
};
|
||||
await ticks(0, 4);
|
||||
// "Ahead" could be a late delivery (a stalled server), so it is only
|
||||
// reported once it has held for a minute of fresh samples.
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
|
||||
await page.clock.fastForward('01:05');
|
||||
await ticks(4, 2);
|
||||
await expect(page.getByText(/clock is .*14 minutes ahead of the server/)).toBeVisible();
|
||||
await page.getByRole('button', { name: 'Dismiss for 24 h' }).click();
|
||||
await expect(page.getByText(/clock is .*ahead of the server/)).toHaveCount(0);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import { HS, ensureUser, hsReachable, loginUI, TestUser, uniq } from './localHs';
|
||||
|
||||
// [Gitea #110, #123] The device-security nudge. A fresh account has no key
|
||||
// backup and no cross-signing → "Set up key backup", but only after the 24 h
|
||||
// grace period on this device; "Not now" snoozes it.
|
||||
const strip = (page: Page) => page.getByRole('status').filter({ hasText: /encryption keys/ });
|
||||
|
||||
/** Back-date this device's first-seen record past the 24 h grace period, then reload. */
|
||||
async function pastGrace(page: Page) {
|
||||
await page.evaluate(() => {
|
||||
const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}');
|
||||
localStorage.setItem(
|
||||
`lotus-security-nudge-${deviceId}`,
|
||||
JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }),
|
||||
);
|
||||
});
|
||||
await page.reload();
|
||||
}
|
||||
|
||||
/** The nudge waits until sync has settled (after "Connecting…"). */
|
||||
async function settled(page: Page) {
|
||||
await page
|
||||
.getByText('Connecting...')
|
||||
.first()
|
||||
.waitFor({ timeout: 30_000 })
|
||||
.catch(() => undefined);
|
||||
await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 });
|
||||
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)', () => {
|
||||
let user: TestUser;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
user = await ensureUser(uniq('e2e_nudge_'));
|
||||
});
|
||||
|
||||
test('nothing during the first 24 h on a device', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await loginUI(page, user);
|
||||
await settled(page);
|
||||
// Past the point where the nudge shows once the grace period is over (~5 s).
|
||||
await page.waitForTimeout(8000);
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('no key backup: "Set up key backup" opens Settings → Devices', async ({ page }) => {
|
||||
test.setTimeout(150_000);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
const nudge = strip(page);
|
||||
await expect(nudge).toContainText("Your encryption keys aren't backed up");
|
||||
await nudge.getByRole('button', { name: 'Set up' }).click();
|
||||
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.setTimeout(200_000);
|
||||
await loginUI(page, user);
|
||||
await pastGrace(page);
|
||||
await settled(page);
|
||||
await strip(page).getByRole('button', { name: 'Not now' }).click();
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
await page.reload();
|
||||
await settled(page);
|
||||
await expect(strip(page)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
import { test, expect, Page } from '@playwright/test';
|
||||
import {
|
||||
HS,
|
||||
createRoom,
|
||||
ensureUser,
|
||||
hsReachable,
|
||||
loginUI,
|
||||
openRoom,
|
||||
uniq,
|
||||
TestUser,
|
||||
} from './localHs';
|
||||
|
||||
// [Gitea #124] Status banner from the homeserver's Uptime Kuma page. Kuma is
|
||||
// answered by fixtures here; nothing contacts a real Kuma.
|
||||
const KUMA = 'https://isitup.lotusguild.org';
|
||||
const PAGE_CFG = { url: KUMA, slug: 'matrix' };
|
||||
const UP = 1;
|
||||
const DOWN = 0;
|
||||
|
||||
type Fixture = { hs?: number; calls?: number; staleMinutes?: number; fail?: boolean };
|
||||
|
||||
const kumaTime = (ms: number) => new Date(ms).toISOString().replace('T', ' ').slice(0, 23);
|
||||
|
||||
/** Serve config.json with a status page for `serverName` and answer Kuma from `fx()`. */
|
||||
async function mockKuma(page: Page, serverName: string | null, fx: () => Fixture) {
|
||||
const kumaRequests: string[] = [];
|
||||
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||
const res = await route.fetch();
|
||||
const cfg = await res.json();
|
||||
cfg.statusPages = serverName ? { [serverName]: PAGE_CFG } : {};
|
||||
await route.fulfill({ response: res, json: cfg });
|
||||
});
|
||||
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||
const url = route.request().url();
|
||||
kumaRequests.push(url);
|
||||
const f = fx();
|
||||
if (f.fail) {
|
||||
await route.fulfill({ status: 500, body: 'oops' });
|
||||
return;
|
||||
}
|
||||
const cors = { 'access-control-allow-origin': '*' };
|
||||
if (url.includes('/heartbeat/')) {
|
||||
// Two checks, the latest `staleMinutes` ago (0 = just now).
|
||||
const last = Date.now() - (f.staleMinutes ?? 0) * 60_000;
|
||||
const beats = (s: number) => [
|
||||
{ status: s, time: kumaTime(last - 60_000) },
|
||||
{ status: s, time: kumaTime(last) },
|
||||
];
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: { heartbeatList: { 1: beats(f.hs ?? UP), 2: beats(f.calls ?? UP) }, uptimeList: {} },
|
||||
});
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
headers: cors,
|
||||
json: {
|
||||
config: { slug: 'matrix' },
|
||||
incidents: [],
|
||||
maintenanceList: [],
|
||||
publicGroupList: [
|
||||
{ name: 'Homeserver', monitorList: [{ id: 1 }] },
|
||||
{ name: 'Voice calls', monitorList: [{ id: 2 }] },
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
return kumaRequests;
|
||||
}
|
||||
|
||||
const serverOf = (u: TestUser) => u.userId.split(':').slice(1).join(':');
|
||||
const strip = (page: Page, text: RegExp) =>
|
||||
page.locator('[role="status"], [role="alert"]').filter({ hasText: text });
|
||||
const cutSync = (page: Page) =>
|
||||
page.route(/\/_matrix\/client\/v3\/sync/, (route) => route.abort('connectionfailed'));
|
||||
|
||||
test.describe('server status banner (#124)', () => {
|
||||
let alice: TestUser;
|
||||
let room: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(!(await hsReachable()), `no local homeserver at ${HS} (set E2E_LOCAL_HS)`);
|
||||
alice = await ensureUser(uniq('e2e_status_'));
|
||||
room = await createRoom(alice, 'Status Room');
|
||||
});
|
||||
|
||||
test('calls down: amber strip, dismiss sticks across a reload', async ({ page }) => {
|
||||
await mockKuma(page, serverOf(alice), () => ({ calls: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
const calls = strip(page, /Voice calls are down right now\. Messages still work\./);
|
||||
await expect(calls).toBeVisible();
|
||||
await calls.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(calls).toHaveCount(0);
|
||||
await page.reload();
|
||||
await openRoom(page, room);
|
||||
await page.waitForTimeout(2000);
|
||||
await expect(calls).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('other homeservers never contact Kuma', async ({ page }) => {
|
||||
const requests = await mockKuma(page, 'some.other.server', () => ({ hs: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await page.waitForTimeout(3000);
|
||||
expect(requests).toEqual([]);
|
||||
});
|
||||
|
||||
test('Kuma failing shows nothing (fail quiet)', async ({ page }) => {
|
||||
const requests = await mockKuma(page, serverOf(alice), () => ({ fail: true }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await expect.poll(() => requests.length).toBeGreaterThan(0);
|
||||
await page.waitForTimeout(1500);
|
||||
await expect(strip(page, /server|Voice calls|Maintenance/i)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('connection lost + Kuma says the server is down: critical strip, not "Connection Lost"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: DOWN }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
// Connected: the server is "having problems" for others, not for us.
|
||||
await expect(strip(page, /server is having problems/)).toBeVisible();
|
||||
await cutSync(page);
|
||||
const down = page.getByRole('alert').filter({ hasText: /server is down\. We're on it/ });
|
||||
await expect(down).toBeVisible({ timeout: 120_000 });
|
||||
await expect(page.getByText('Connection Lost! Reconnecting...')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('connection lost + Kuma checked since and the server is up: "may be your connection"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: UP }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await cutSync(page);
|
||||
await expect(page.getByText(/server is up, so it may be your internet connection/)).toBeVisible(
|
||||
{
|
||||
timeout: 120_000,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('connection lost + only a stale "up" from Kuma: plain "Connection Lost"', async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(150_000);
|
||||
await mockKuma(page, serverOf(alice), () => ({ hs: UP, staleMinutes: 10 }));
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await cutSync(page);
|
||||
await expect(page.getByText('Connection Lost! Reconnecting...')).toBeVisible({
|
||||
timeout: 120_000,
|
||||
});
|
||||
await page.waitForTimeout(3000);
|
||||
await expect(page.getByText(/may be your internet connection/)).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
Generated
+25
-23
@@ -45,12 +45,12 @@
|
||||
"html-react-parser": "6.1.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "4.0.0",
|
||||
"i18next-http-backend": "4.0.2",
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.47",
|
||||
"katex": "0.18.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.24",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.21",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.21/element-call-embedded-0.25.0-lotus.21.tgz",
|
||||
"integrity": "sha512-Phtjop2qdKRrCLqAhzipRaJaVnU3MjK9CV4QwHkwU/Xlj8oguZBfjOlV7zTqOTzAr57OteOfLcyxLsmv5+u9tg==",
|
||||
"version": "0.25.0-lotus.24",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz",
|
||||
"integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
@@ -5026,9 +5026,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
@@ -8168,9 +8168,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-http-backend": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.0.tgz",
|
||||
"integrity": "sha512-EgSjO3Q1G6f2Q5oy7u9mmxuesE0oSfzAD97NFBjC8EmkK4guBSYLljM0Fng3DarMWIIkU70jfo4+mUzmyVISTA==",
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next-http-backend/-/i18next-http-backend-4.0.2.tgz",
|
||||
"integrity": "sha512-oay62dIB2kL7+WHzoUXBjWfL3+mwijD3pQkQkIEaRLhy6kjXxUhrRenV0gInwlCASAaJaDy94+XvYizK+cAGdA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
@@ -9162,28 +9162,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.47",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz",
|
||||
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==",
|
||||
"version": "0.18.11",
|
||||
"resolved": "https://registry.npmjs.org/katex/-/katex-0.18.11.tgz",
|
||||
"integrity": "sha512-yvyz/2VMKqoho/35Hat5w37wIEdWJ5ElVMidTAlWrxr5uW4aBGsEpveTmKh18/zw08v/7lGswCPon4OhqmqrTA==",
|
||||
"deprecated": "Accidentally published with breaking changes. Use 0.19.0 instead.",
|
||||
"funding": [
|
||||
"https://opencollective.com/katex",
|
||||
"https://github.com/sponsors/katex"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^8.3.0"
|
||||
"commander": "^15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"katex": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/katex/node_modules/commander": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz",
|
||||
"integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
@@ -12164,9 +12165,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
|
||||
+3
-3
@@ -72,12 +72,12 @@
|
||||
"html-react-parser": "6.1.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-http-backend": "4.0.0",
|
||||
"i18next-http-backend": "4.0.2",
|
||||
"immer": "11.1.8",
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.47",
|
||||
"katex": "0.18.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.21",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.24",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
"dir": "auto",
|
||||
"lang": "en-US",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"start_url": "./",
|
||||
"background_color": "#0a0a0a",
|
||||
"theme_color": "#980000",
|
||||
@@ -90,7 +89,7 @@
|
||||
"name": "New Message",
|
||||
"short_name": "DM",
|
||||
"description": "Open a new direct message",
|
||||
"url": "/",
|
||||
"url": "./direct/create/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "public/res/android/android-chrome-96x96.png",
|
||||
|
||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
||||
</Chip>
|
||||
</Box>
|
||||
<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} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -18,9 +18,10 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import {
|
||||
ClientEvent,
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
JoinRule,
|
||||
SyncState,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
} from '../hooks/useCallEmbed';
|
||||
import { callChatAtom, callEmbedAtom } from '../state/callEmbed';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { CallEmbed, useCallControlState, useScreensharePromptOpen } from '../plugins/call';
|
||||
import { hangupCallAndWait } from '../plugins/call/hangup';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
@@ -72,7 +73,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
||||
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -82,10 +84,14 @@ import { useRoomCallPolicy } from '../hooks/useRoomCallPolicy';
|
||||
import { useNotificationsQuiet } from '../hooks/useNotificationsQuiet';
|
||||
import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
/** Default PiP top: just below the 64px-ish room header. */
|
||||
const PIP_DEFAULT_TOP = 72;
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
@@ -108,6 +114,8 @@ type IncomingCallInfo = {
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
|
||||
notificationEventId?: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
@@ -231,13 +239,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
</Text>
|
||||
)}
|
||||
{!webRTCSupported() && (
|
||||
<Text
|
||||
style={{ margin: 'auto', color: color.Critical.Main }}
|
||||
size="L400"
|
||||
align="Center"
|
||||
>
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
<CallsUnavailableMessage roomId={room.roomId} actionLabel="Answer in browser" />
|
||||
)}
|
||||
<Box direction="Column" gap="300">
|
||||
{willAnswerWithCamera && (
|
||||
@@ -286,7 +288,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
fill="Soft"
|
||||
size="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 />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
@@ -458,7 +462,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
size="300"
|
||||
radii="300"
|
||||
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 />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -499,6 +505,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
@@ -586,12 +593,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||
// restricted rooms, and public rooms must never trigger ringing.
|
||||
if (room.isCallRoom()) return;
|
||||
const isDirect = directs.has(room.roomId);
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
const joinRule = room.getJoinRule();
|
||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||
if (!isDirect && !isPrivateInviteGroup) return;
|
||||
if (!isRingableRoom(room, directs.has(room.roomId))) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
@@ -604,13 +606,59 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
notificationEventId: event.getId(),
|
||||
};
|
||||
|
||||
setCallInfo(info);
|
||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||
// a reload the first sync replays the ring together with the caller's
|
||||
// hang-up and our own decline. Check the room STATE (not the possibly
|
||||
// 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.
|
||||
const myUserId = mx.getSafeUserId();
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) {
|
||||
setCallInfo(info);
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (Date.now() >= senderTs + lifetime) return;
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||
}, 4000);
|
||||
},
|
||||
[mx, directs, callEmbed, setToast],
|
||||
);
|
||||
|
||||
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
|
||||
// call's join and leave in one batch, and the RTC session may keep the join
|
||||
// (stale "1 Live", call screen instead of chat). Once sync is live again,
|
||||
// recompute every active session from the room's current state.
|
||||
useEffect(() => {
|
||||
const onSync = (state: SyncState, prev: SyncState | null) => {
|
||||
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
|
||||
mx.getRooms().forEach((r) => {
|
||||
mx.matrixRTC
|
||||
.getActiveRoomSession(r)
|
||||
?._onRTCSessionMemberUpdate()
|
||||
.catch(() => undefined);
|
||||
});
|
||||
};
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||
return () => {
|
||||
@@ -639,7 +687,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
const { memberships } = session;
|
||||
if (memberships.some((m) => m.sender === myUserId))
|
||||
dismiss(); // answered elsewhere
|
||||
else if (memberships.length === 0) dismiss(); // caller gone
|
||||
else if (memberships.length === 0)
|
||||
dismiss(); // caller gone
|
||||
// [Gitea #312] The session can be stale after a reload; the room state isn't.
|
||||
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
|
||||
};
|
||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||
event,
|
||||
@@ -649,9 +700,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
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);
|
||||
@@ -759,6 +814,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallEndedToast(embed);
|
||||
useScreenshareNotices(embed);
|
||||
useDenoiseNotices(embed);
|
||||
// [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).
|
||||
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||
@@ -882,6 +938,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
const callActive = callEmbed && joined;
|
||||
const callVisible = inCallRoom && callActive && !chatOnlyView;
|
||||
const pipMode = callActive && !inCallRoom;
|
||||
const pipPromptOpen = useScreensharePromptOpen(callEmbed?.control);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
||||
@@ -1011,8 +1068,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
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`;
|
||||
} else {
|
||||
el.style.left = `${window.innerWidth - 280 - 16}px`;
|
||||
el.style.top = `${window.innerHeight - 158 - 72}px`;
|
||||
// [Gitea #323] First use: top-right under the room header, over the
|
||||
// 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.height = '158px';
|
||||
@@ -1363,6 +1423,9 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
cursor: 'grab',
|
||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||
// inside the frame: let the click reach its Share button.
|
||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
|
||||
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
||||
export function CloseBehaviorPrompt() {
|
||||
const [open, setOpen] = 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);
|
||||
|
||||
// Tells the native side we're listening (see get_close_behavior).
|
||||
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
|
||||
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(
|
||||
(value: 'tray' | 'quit') => {
|
||||
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#close-behavior-keep',
|
||||
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
|
||||
// Dismissing without choosing keeps the window open; the next close
|
||||
// asks again.
|
||||
onDeactivate: () => setOpen(false),
|
||||
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text priority="400" id="close-behavior-body">
|
||||
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.
|
||||
{hasTray
|
||||
? '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>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Checkbox
|
||||
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
|
||||
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
|
||||
<Text size="B400">Keep running in the tray</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
{hasTray ? (
|
||||
<>
|
||||
<Button
|
||||
id="close-behavior-keep"
|
||||
variant="Primary"
|
||||
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>
|
||||
<Text size="T200" priority="300">
|
||||
You can change this in Settings → General.
|
||||
|
||||
@@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [blockedCount, setBlockedCount] = useState(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 modalStyle = useModalStyle(440);
|
||||
const server = mx.getDomain() ?? 'Your homeserver';
|
||||
@@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() {
|
||||
}, [consentUri]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
closingForRetry.current = true;
|
||||
snoozedUntil.current = 0;
|
||||
setOpen(false);
|
||||
setOpened(false);
|
||||
const pending = Array.from(blocked.current.entries());
|
||||
@@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() {
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#consent-review',
|
||||
fallbackFocus: '#consent-later',
|
||||
onDeactivate: later,
|
||||
onDeactivate: () => {
|
||||
if (closingForRetry.current) {
|
||||
closingForRetry.current = false;
|
||||
return;
|
||||
}
|
||||
later();
|
||||
},
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: true,
|
||||
}}
|
||||
|
||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Setup Device Verification
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Reset Device Verification
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</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 { useAtom } from 'jotai';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../hooks/useModalStyle';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
import { InfoCard } from './info-card';
|
||||
import { ScheduledMessage } from '../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
@@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
|
||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
// [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]),
|
||||
// [Gitea #322] Scheduled messages live on the server and still send after
|
||||
// logout, where no client would show them. Say so, and offer to cancel.
|
||||
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchScheduledMessages(mx)
|
||||
.then((list) => alive && setScheduled(list))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
|
||||
useCallback(
|
||||
async (cancelScheduled?: boolean) => {
|
||||
if (cancelScheduled) {
|
||||
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
|
||||
}
|
||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||
// 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;
|
||||
@@ -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."
|
||||
/>
|
||||
))}
|
||||
{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>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
variant="Critical"
|
||||
onClick={logout}
|
||||
onClick={() => logout()}
|
||||
disabled={ongoingLogout}
|
||||
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>
|
||||
{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}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
|
||||
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
data.allowFederation,
|
||||
data.additionalCreators,
|
||||
),
|
||||
power_level_content_override:
|
||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
||||
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||
// 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,
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
@@ -612,7 +612,13 @@ export function EmojiBoard({
|
||||
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
!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
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SettingTile } from '../setting-tile';
|
||||
import { UsageSwitcher } from './UsageSwitcher';
|
||||
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
|
||||
import * as css from './style.css';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
import { UploadSuccess } from '../../state/upload';
|
||||
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
||||
@@ -60,7 +60,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
|
||||
[imagePack, savedImages, uploadedImages],
|
||||
);
|
||||
|
||||
const pickFiles = useFilePicker(
|
||||
const pickFiles = useStrippedFilePicker(
|
||||
useCallback(
|
||||
(pickedFiles: File[]) => {
|
||||
const uniqueFiles = pickedFiles.map((file) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
@@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
const pickFile = useStrippedFilePicker(setImageFile, false);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
|
||||
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
|
||||
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
const [linkCopied, setLinkCopied] = 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 roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
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 = () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import React, { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||
import { IContent, MatrixEvent } from 'matrix-js-sdk';
|
||||
@@ -37,10 +37,15 @@ import {
|
||||
} from '../../../types/matrix/common';
|
||||
import { FALLBACK_MIMETYPE, getBlobSafeMimeType } from '../../utils/mimeTypes';
|
||||
import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { formatAccuracy } from '../../utils/geoShare';
|
||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||
import { previewUrlsFor } from '../../utils/linkSafety';
|
||||
|
||||
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
|
||||
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||
const collapseMaxHeightPx = (): number =>
|
||||
COLLAPSE_MAX_HEIGHT_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
|
||||
|
||||
type CollapsibleBodyProps = {
|
||||
eventId?: string;
|
||||
@@ -78,7 +83,7 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return undefined;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
|
||||
setNeedsCollapse(el.scrollHeight > collapseMaxHeightPx());
|
||||
});
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
@@ -88,16 +93,36 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
// (Gitea #85); useReducedMotion subscribes to the change event instead.
|
||||
const prefersReducedMotion = useReducedMotion();
|
||||
|
||||
const bodyId = useId();
|
||||
const isClipped = needsCollapse && collapsed;
|
||||
// [Gitea #343] The clipped part stays in the DOM, so Tab can reach a link in
|
||||
// it; the browser would then scroll the overflow:hidden box itself (top cut
|
||||
// off, and it stayed that way). Expand instead when focus lands inside.
|
||||
const handleFocus = () => {
|
||||
if (isClipped) setCollapsed(false);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (isClipped && bodyRef.current) bodyRef.current.scrollTop = 0;
|
||||
}, [isClipped]);
|
||||
|
||||
// Fade the content itself rather than painting a fixed-colour gradient over
|
||||
// it, so it blends on any background (bubbles, highlights, chat backgrounds).
|
||||
const fadeMask = 'linear-gradient(#000 calc(100% - 3rem), transparent)';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
ref={bodyRef}
|
||||
id={bodyId}
|
||||
onFocus={handleFocus}
|
||||
style={{
|
||||
position: 'relative',
|
||||
...(needsCollapse && collapsed
|
||||
...(isClipped
|
||||
? {
|
||||
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
|
||||
maxHeight: `${COLLAPSE_MAX_HEIGHT_REM}rem`,
|
||||
overflow: 'hidden',
|
||||
maskImage: fadeMask,
|
||||
WebkitMaskImage: fadeMask,
|
||||
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
|
||||
}
|
||||
: {
|
||||
@@ -106,23 +131,12 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{needsCollapse && collapsed && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '3rem',
|
||||
background: `linear-gradient(transparent, ${color.Surface.Container})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{needsCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={bodyId}
|
||||
onClick={() => setCollapsed((c) => !c)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
@@ -133,7 +147,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
||||
}}
|
||||
>
|
||||
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
|
||||
{collapsed ? 'Read more ↓' : 'Show less ↑'}
|
||||
{collapsed ? 'Read more' : 'Show less'}
|
||||
<span aria-hidden="true">{collapsed ? ' ↓' : ' ↑'}</span>
|
||||
</Text>
|
||||
</button>
|
||||
)}
|
||||
@@ -329,7 +344,13 @@ export function MText({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
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 (
|
||||
<>
|
||||
@@ -387,7 +408,13 @@ export function MEmote({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
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 (
|
||||
<>
|
||||
@@ -444,7 +471,13 @@ export function MNotice({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
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 (
|
||||
<>
|
||||
@@ -732,6 +765,8 @@ export function MLocation({ content }: MLocationProps) {
|
||||
const lon = parseFloat(location.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
|
||||
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
|
||||
// RFC 5870 `;u=` uncertainty (meters), sent by Lotus since Gitea #340.
|
||||
const accuracyLabel = formatAccuracy(Number(/;u=([\d.]+)/.exec(geoUri)?.[1]));
|
||||
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
|
||||
lat - 0.004
|
||||
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
|
||||
@@ -772,6 +807,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
)}
|
||||
<Text size="T300" priority="300">
|
||||
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
||||
{accuracyLabel && ` (${accuracyLabel})`}
|
||||
</Text>
|
||||
<Button
|
||||
as="a"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
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 { getMemberName, sendRoomEvent } from '../../../utils/room';
|
||||
import {
|
||||
@@ -99,7 +99,21 @@ export function PollContent({
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
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 parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||
@@ -192,8 +206,9 @@ export function PollContent({
|
||||
const isMultiple = maxSelections > 1;
|
||||
|
||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||
const canVote = !!roomId && !!eventId && !isEnded;
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
|
||||
const canEnd =
|
||||
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total, voters } = tally;
|
||||
@@ -219,25 +234,34 @@ export function PollContent({
|
||||
setPending(next);
|
||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||
// accepts both namespaces).
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
} catch {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndPoll = () => {
|
||||
if (!roomId || !eventId || ending) return;
|
||||
setEnding(true);
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
} catch {
|
||||
setEnding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||
@@ -261,52 +285,69 @@ export function PollContent({
|
||||
handleVote(answers[idx].id);
|
||||
};
|
||||
|
||||
const headerLabel = isEnded
|
||||
? 'Poll closed · Final results'
|
||||
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
|
||||
// [Gitea #246] Type line under the "Poll" label.
|
||||
const typeLabel = (() => {
|
||||
if (isEnded) return 'Final results';
|
||||
if (isUndisclosed) return 'Results hidden until the end';
|
||||
return isMultiple ? `Pick up to ${maxSelections}` : 'Single choice';
|
||||
})();
|
||||
|
||||
// Multiple choice counts people, not answers ("1 voter" who picked two).
|
||||
const unit = isMultiple ? 'voter' : 'vote';
|
||||
const footerNote = (() => {
|
||||
const totalPart = total > 0 ? `${total} ${unit}${total === 1 ? '' : 's'}` : `No ${unit}s yet`;
|
||||
if (isUndisclosed && !isEnded) {
|
||||
return canVote
|
||||
? 'Voting open · Results hidden until the poll ends'
|
||||
: 'Results hidden until the poll ends';
|
||||
? `${myVotes.size > 0 ? 'You voted' : 'Voting open'} · results shown when the poll ends`
|
||||
: 'Results shown when the poll ends';
|
||||
}
|
||||
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
|
||||
if (isEnded) return `${votesPart} · Poll closed`;
|
||||
if (!canVote) return votesPart;
|
||||
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
|
||||
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
|
||||
if (isEnded) return `${totalPart} · Poll closed`;
|
||||
if (!canVote) return totalPart;
|
||||
return `${totalPart} · ${myVotes.size > 0 ? 'Select another answer to change your vote' : 'Select an answer to vote'}`;
|
||||
})();
|
||||
|
||||
// Neutral tones derived from the theme's text colour, so they have contrast
|
||||
// in every theme (the Container/ContainerLine tokens were near-invisible).
|
||||
const muted = (pct: number) =>
|
||||
`color-mix(in srgb, ${color.SurfaceVariant.OnContainer} ${pct}%, transparent)`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
data-poll-content
|
||||
direction="Column"
|
||||
gap="200"
|
||||
style={{ maxWidth: '340px', paddingTop: '2px', paddingBottom: '4px' }}
|
||||
gap="300"
|
||||
style={{
|
||||
// Same width for every poll (the message body shrink-wraps its content).
|
||||
width: toRem(460),
|
||||
maxWidth: '100%',
|
||||
margin: `${config.space.S100} 0`,
|
||||
padding: config.space.S300,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `${config.borderWidth.B300} solid ${muted(18)}`,
|
||||
background: muted(4),
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
as="div"
|
||||
size="T200"
|
||||
priority="300"
|
||||
data-poll-content-label
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden>◉ </span>
|
||||
{headerLabel}
|
||||
</Text>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{question}
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap" data-poll-content-label>
|
||||
<Icon size="100" src={Icons.OrderList} style={{ opacity: 0.8 }} />
|
||||
<Text as="span" size="L400" style={{ fontWeight: 600 }}>
|
||||
Poll
|
||||
</Text>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
·
|
||||
</Text>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{typeLabel}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text size="T400" style={{ fontWeight: 600, overflowWrap: 'anywhere' }}>
|
||||
{question}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ marginTop: '2px' }}
|
||||
gap="200"
|
||||
role={isMultiple ? 'group' : 'radiogroup'}
|
||||
aria-label={question}
|
||||
onKeyDown={handleRadioKeyDown}
|
||||
@@ -316,20 +357,32 @@ export function PollContent({
|
||||
const text = answer.text;
|
||||
const selected = myVotes.has(id);
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
const showBar = showResults && total > 0;
|
||||
const pct = showBar ? Math.round((voteCount / total) * 100) : 0;
|
||||
const isWinner = winners.has(id);
|
||||
const resultText = `${voteCount} ${voteCount === 1 ? 'vote' : 'votes'} · ${pct}%`;
|
||||
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
|
||||
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
|
||||
let barColor = muted(40);
|
||||
if (isWinner) barColor = color.Success.Main;
|
||||
else if (selected) barColor = color.Primary.Main;
|
||||
let rowBorder = muted(22);
|
||||
if (isWinner) rowBorder = color.Success.Main;
|
||||
else if (selected) rowBorder = color.Primary.Main;
|
||||
const labelParts = [text];
|
||||
if (showBar) labelParts.push(resultText);
|
||||
if (isWinner) labelParts.push('winning answer');
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
<button
|
||||
type="button"
|
||||
data-poll-answer
|
||||
data-selected={selected}
|
||||
data-winner={isWinner || undefined}
|
||||
role={isMultiple ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-disabled={!canVote}
|
||||
aria-label={isWinner ? `${text}, winning answer` : undefined}
|
||||
aria-label={labelParts.join(', ')}
|
||||
aria-describedby={
|
||||
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
|
||||
? `poll-voters-${eventId}-${id}`
|
||||
@@ -340,60 +393,36 @@ export function PollContent({
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
isWinner
|
||||
? color.Success.Main
|
||||
: selected
|
||||
? color.Primary.Main
|
||||
: color.SurfaceVariant.ContainerLine
|
||||
}`,
|
||||
background: selected ? muted(8) : 'transparent',
|
||||
border: `${config.borderWidth.B300} solid ${rowBorder}`,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'inherit',
|
||||
font: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
gap: config.space.S200,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{showResults && total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? color.Primary.ContainerActive
|
||||
: color.SurfaceVariant.ContainerActive,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: toRem(14),
|
||||
height: toRem(14),
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||
}`,
|
||||
boxSizing: 'border-box',
|
||||
width: toRem(18),
|
||||
height: toRem(18),
|
||||
border: `2px solid ${selected ? color.Primary.Main : muted(55)}`,
|
||||
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||
background: selected ? color.Primary.Main : 'transparent',
|
||||
display: 'flex',
|
||||
@@ -405,29 +434,72 @@ export function PollContent({
|
||||
>
|
||||
{selected ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
</span>
|
||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||
<Text
|
||||
as="span"
|
||||
size="T300"
|
||||
style={{ flexGrow: 1, minWidth: 0, overflowWrap: 'anywhere' }}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
{isWinner && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Check}
|
||||
style={{ flexShrink: 0, color: color.Success.Main }}
|
||||
/>
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S100,
|
||||
color: color.Success.Main,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Icon size="50" src={Icons.Star} filled />
|
||||
Winner
|
||||
</Text>
|
||||
)}
|
||||
{showResults && total > 0 && (
|
||||
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{pct}%
|
||||
{showBar && (
|
||||
<Text
|
||||
as="span"
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ flexShrink: 0, fontVariantNumeric: 'tabular-nums' }}
|
||||
>
|
||||
{resultText}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
{showBar && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: toRem(6),
|
||||
borderRadius: config.radii.Pill,
|
||||
background: muted(12),
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'block',
|
||||
height: '100%',
|
||||
width: `${pct}%`,
|
||||
borderRadius: config.radii.Pill,
|
||||
background: barColor,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
|
||||
<Text
|
||||
id={`poll-voters-${eventId}-${id}`}
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
|
||||
style={{ padding: `0 ${config.space.S300}`, marginTop: `-${config.space.S100}` }}
|
||||
>
|
||||
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
|
||||
</Text>
|
||||
@@ -436,48 +508,56 @@ export function PollContent({
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{canShowVoters && (
|
||||
<Box>
|
||||
<Chip
|
||||
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
aria-pressed={showVoters}
|
||||
onClick={() => setShowVoters((v) => !v)}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
||||
<i>{footerNote}</i>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200" wrap="Wrap">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0, flex: '1 1 12rem' }}>
|
||||
{footerNote}
|
||||
</Text>
|
||||
{canEnd &&
|
||||
(confirmEnd ? (
|
||||
<Box gap="100" shrink="No" alignItems="Center">
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
aria-disabled={ending}
|
||||
onClick={ending ? undefined : handleEndPoll}
|
||||
>
|
||||
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
|
||||
</Chip>
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
||||
<Text size="T200">Cancel</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
) : (
|
||||
<Box gap="100" shrink="No" alignItems="Center" wrap="Wrap">
|
||||
{canShowVoters && (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
before={<Icon size="50" src={Icons.Cross} />}
|
||||
aria-pressed={showVoters}
|
||||
onClick={() => setShowVoters((v) => !v)}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">End poll</Text>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
{showVoters ? 'Hide voters' : 'Who voted'}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
)}
|
||||
{canEnd &&
|
||||
(confirmEnd ? (
|
||||
<>
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
aria-disabled={ending}
|
||||
onClick={ending ? undefined : handleEndPoll}
|
||||
>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
{ending ? 'Ending…' : 'End poll'}
|
||||
</Text>
|
||||
</Chip>
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
Cancel
|
||||
</Text>
|
||||
</Chip>
|
||||
</>
|
||||
) : (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
before={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200" style={{ whiteSpace: 'nowrap' }}>
|
||||
End poll
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -43,7 +43,9 @@ export function SettingTile({ title, description, before, after, children }: Set
|
||||
</Text>
|
||||
)}
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -310,9 +310,13 @@ export function UploadCardRenderer({
|
||||
const { file } = upload;
|
||||
const fileSizeExceeded = file.size >= allowSize;
|
||||
|
||||
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
||||
startUpload();
|
||||
}
|
||||
// [Gitea #291] Start in an effect, not during render: the desktop taskbar
|
||||
// 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) => {
|
||||
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { DefaultReset, color, config, toRem } from 'folds';
|
||||
import { GIF_PREVIEW_MAX_HEIGHT } from '../../utils/gifPreviewSize';
|
||||
|
||||
export const UrlPreview = style([
|
||||
DefaultReset,
|
||||
@@ -955,12 +956,14 @@ export const PortraitSideLayout = style([
|
||||
// GIF card (Giphy / Tenor)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Full card width; a GIF narrower than the card (portrait, square, small) is
|
||||
// centred on the surface colour instead of being stretched and cropped.
|
||||
export const GifThumbnailWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: color.Surface.Container,
|
||||
@@ -972,14 +975,26 @@ export const GifThumbnailWrapper = style([
|
||||
},
|
||||
]);
|
||||
|
||||
// Wraps just the GIF so the "GIF" badge sits on the GIF's own corner.
|
||||
export const GifFrame = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
},
|
||||
]);
|
||||
|
||||
// The inline width/aspect-ratio (from og:image:width/height) sets the size;
|
||||
// max-width lets a phone's narrower card shrink it, keeping the ratio.
|
||||
export const GifThumbnailImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
maxHeight: toRem(200),
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: toRem(GIF_PREVIEW_MAX_HEIGHT),
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
},
|
||||
]);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { ImageOverlay } from '../ImageOverlay';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
@@ -48,6 +49,12 @@ import {
|
||||
tiktokOembedUrl,
|
||||
tiktokPlayerEmbedUrl,
|
||||
} from '../../utils/videoEmbed';
|
||||
import {
|
||||
GIF_PREVIEW_MAX_HEIGHT,
|
||||
GIF_PREVIEW_MAX_WIDTH,
|
||||
gifPreviewBox,
|
||||
} from '../../utils/gifPreviewSize';
|
||||
import { sameProvider } from './embedProvider';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
@@ -2277,6 +2284,9 @@ function GifCard({
|
||||
}) {
|
||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
// Size the GIF from its own dimensions (Giphy and Tenor publish them) rather
|
||||
// than forcing every GIF into one cropped band.
|
||||
const box = gifPreviewBox(prev['og:image:width'], prev['og:image:height']);
|
||||
|
||||
// A GIF card exists to show a moving GIF, so request the original rather than
|
||||
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
|
||||
@@ -2284,7 +2294,15 @@ function GifCard({
|
||||
const thumbSrc = mxcImage
|
||||
? shouldServeGifOriginal(url, prev)
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
: mxcUrlToHttp(
|
||||
mx,
|
||||
mxcImage,
|
||||
useAuthentication,
|
||||
GIF_PREVIEW_MAX_WIDTH * 2,
|
||||
GIF_PREVIEW_MAX_HEIGHT * 2,
|
||||
'scale',
|
||||
false,
|
||||
)
|
||||
: null;
|
||||
|
||||
// If there's no image, fall back to a generic-style layout
|
||||
@@ -2316,7 +2334,9 @@ function GifCard({
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: '100%' }}>
|
||||
{/* GIF thumbnail — full width */}
|
||||
{/* The whole GIF at its own aspect ratio, centred; never cropped. With a
|
||||
known size the box is reserved before it loads (no layout jump);
|
||||
without one it shows at its natural size, capped by the card. */}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
@@ -2324,8 +2344,22 @@ function GifCard({
|
||||
className={previewCss.GifThumbnailWrapper}
|
||||
aria-label={`View GIF on ${siteBadgeLabel}: ${title}`}
|
||||
>
|
||||
<img className={previewCss.GifThumbnailImg} src={thumbSrc} alt={title} loading="lazy" />
|
||||
<span className={previewCss.GifBadge}>GIF</span>
|
||||
<span className={previewCss.GifFrame}>
|
||||
<img
|
||||
className={previewCss.GifThumbnailImg}
|
||||
src={thumbSrc}
|
||||
alt={title}
|
||||
loading="lazy"
|
||||
width={box?.width}
|
||||
height={box?.height}
|
||||
style={
|
||||
box
|
||||
? { width: toRem(box.width), aspectRatio: `${box.width} / ${box.height}` }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<span className={previewCss.GifBadge}>GIF</span>
|
||||
</span>
|
||||
</a>
|
||||
{/* Footer row */}
|
||||
<UrlPreviewContent>
|
||||
@@ -2464,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||
if (embed) return embed;
|
||||
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)
|
||||
: 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;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
@@ -39,6 +40,21 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
const [devices, setDevices] = useState<OutputDevice[]>([]);
|
||||
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 () => {
|
||||
try {
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
@@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return undefined;
|
||||
if (!anchor || forkDevices) return undefined;
|
||||
refresh();
|
||||
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
||||
}, [anchor, refresh]);
|
||||
}, [anchor, refresh, forkDevices]);
|
||||
|
||||
const shown = forkDevices ?? devices;
|
||||
|
||||
const choose = (id: string) => {
|
||||
embed.control.setAudioOutput(id);
|
||||
@@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
aria-label="Audio output"
|
||||
>
|
||||
<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 }}>
|
||||
No audio outputs found.
|
||||
</Text>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const isSelected = selected ? d.id === selected : d.id === 'default';
|
||||
{shown.map((d) => {
|
||||
const isSelected =
|
||||
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
|
||||
return (
|
||||
<MenuItem
|
||||
key={d.id}
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CallEmbed,
|
||||
useCallControlState,
|
||||
useCallMicLevel,
|
||||
useFrameScreenshare,
|
||||
useScreenshareMode,
|
||||
} from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
@@ -197,10 +197,16 @@ export function CallControl({
|
||||
// Keep a forbidden control visible while its track is still live (so the user
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame.
|
||||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||||
const showScreenshare = !frameScreenshare && (allowScreenshare || screenshare);
|
||||
// [Gitea #43] Hidden where EC shows its own screenshare button in the frame;
|
||||
// where the click can't be delegated, starting asks for the in-frame prompt.
|
||||
const screenshareMode = useScreenshareMode(callEmbed.control);
|
||||
const showScreenshare = screenshareMode !== 'hidden' && (allowScreenshare || screenshare);
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
const handleScreenshareToggle = () => {
|
||||
if (screenshare) callEmbed.control.toggleScreenshare();
|
||||
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
|
||||
else setShareConfirm(true);
|
||||
};
|
||||
|
||||
const handleMicrophoneToggle = useCallback(
|
||||
() => callEmbed.control.toggleMicrophone(),
|
||||
@@ -255,9 +261,7 @@ export function CallControl({
|
||||
{!compact && showScreenshare && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
disabled={!callJoined}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -72,9 +72,9 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ color: color.Warning.Main }}
|
||||
title="Fix your computer's clock — calls and encryption depend on it"
|
||||
title="This device's clock is off. Calls and encryption depend on it: turn on automatic time in your system settings."
|
||||
>
|
||||
Clock {describeSkewVsServer(clockSkew.skewMs)} — calls will fail
|
||||
Device clock {describeSkewVsServer(clockSkew.skewMs)}: calls may fail
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
CallEmbed,
|
||||
useCallControlState,
|
||||
useCallMicLevel,
|
||||
useFrameScreenshare,
|
||||
useScreenshareMode,
|
||||
} from '../../plugins/call';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -112,9 +112,14 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
// can stop it); otherwise hide it entirely.
|
||||
const showCamera = allowCamera || video;
|
||||
// [Gitea #43] Where EC shows its own screenshare button in the frame, ours
|
||||
// is hidden (this engine can't start a share from the host); the
|
||||
// screenshare-audio mute stays.
|
||||
const frameScreenshare = useFrameScreenshare(callEmbed.control);
|
||||
// is hidden (the screenshare-audio mute stays); where the click can't be
|
||||
// delegated, starting asks for the fork's in-frame "Share your screen?".
|
||||
const screenshareMode = useScreenshareMode(callEmbed.control);
|
||||
const handleScreenshareToggle = () => {
|
||||
if (screenshare) callEmbed.control.toggleScreenshare();
|
||||
else if (screenshareMode === 'prompt') callEmbed.control.promptScreenshare();
|
||||
else setShareConfirm(true);
|
||||
};
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
@@ -225,12 +230,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
{!frameScreenshare && (
|
||||
{screenshareMode !== 'hidden' && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() =>
|
||||
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
|
||||
}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
/>
|
||||
)}
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
|
||||
@@ -50,6 +50,14 @@ type FlatClip = {
|
||||
* A management toggle reveals the pack editors (personal + this room, if
|
||||
* 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) {
|
||||
const mx = useMatrixClient();
|
||||
const { room } = callEmbed;
|
||||
@@ -122,13 +130,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
.catch(() => undefined);
|
||||
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
||||
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.
|
||||
setError(
|
||||
result.reason === 'muted'
|
||||
? 'Unmute your microphone to play a soundboard clip.'
|
||||
: 'Could not play that clip.',
|
||||
);
|
||||
setError(soundboardRefusalText(result.reason));
|
||||
done();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
useCallJoined,
|
||||
useCallEmbedPlacementSync,
|
||||
useCallLoadError,
|
||||
useCallStart,
|
||||
} from '../../hooks/useCallEmbed';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { PrescreenControls } from './PrescreenControls';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
@@ -23,6 +24,7 @@ import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { CallsUnavailableMessage } from './CallsUnavailable';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
|
||||
function LivekitServerMissingMessage() {
|
||||
@@ -33,25 +35,19 @@ function LivekitServerMissingMessage() {
|
||||
);
|
||||
}
|
||||
|
||||
function WebRTCMissingError() {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function JoinMessage({
|
||||
roomId,
|
||||
hasParticipant,
|
||||
livekitSupported,
|
||||
rtcSupported,
|
||||
}: {
|
||||
roomId: string;
|
||||
hasParticipant?: boolean;
|
||||
livekitSupported?: boolean;
|
||||
rtcSupported?: boolean;
|
||||
}) {
|
||||
if (rtcSupported === false) {
|
||||
return <WebRTCMissingError />;
|
||||
return <CallsUnavailableMessage roomId={roomId} />;
|
||||
}
|
||||
|
||||
if (livekitSupported === false) {
|
||||
@@ -144,6 +140,7 @@ function CallPrescreen() {
|
||||
)}
|
||||
{!inOtherCall && hasPermission && !channelFull && (
|
||||
<JoinMessage
|
||||
roomId={room.roomId}
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
@@ -164,6 +161,16 @@ function CallLoadErrorMessage() {
|
||||
// prescreen, where they can choose to join again.
|
||||
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 (
|
||||
<Box
|
||||
className={css.CallViewContent}
|
||||
@@ -177,6 +184,9 @@ function CallLoadErrorMessage() {
|
||||
The call failed to load. Check your connection and try again.
|
||||
</Text>
|
||||
<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}>
|
||||
<Text size="B400">Back</Text>
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Button, Icon, Icons, Text, color } from 'folds';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { isTauri } from '../../hooks/useTauri';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { getCallInBrowserUrl, resolveWebAppUrl } from '../../utils/callInBrowser';
|
||||
|
||||
/**
|
||||
* [cinny-desktop: Linux calls] The desktop app on Linux runs on WebKitGTK,
|
||||
* which ships without WebRTC (disabled in 2.54; a libwebrtc backend is due
|
||||
* around 2.56). Where calls can't work here, send the user to the room in the
|
||||
* web app instead of a dead end. Only in the desktop app, and only when
|
||||
* config.json sets `webAppUrl`.
|
||||
*/
|
||||
export const useCallInBrowserUrl = (roomId: string): string | undefined => {
|
||||
const { webAppUrl } = useClientConfig();
|
||||
return useMemo(
|
||||
() =>
|
||||
isTauri() && !webRTCSupported()
|
||||
? getCallInBrowserUrl(resolveWebAppUrl(webAppUrl), roomId)
|
||||
: undefined,
|
||||
[webAppUrl, roomId],
|
||||
);
|
||||
};
|
||||
|
||||
/** Opens through the desktop's new-window handler → the system browser. */
|
||||
export const openCallInBrowser = (url: string): void => {
|
||||
window.open(url, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const desktopUnavailableText = (): string =>
|
||||
typeof navigator !== 'undefined' && /Linux/i.test(navigator.userAgent)
|
||||
? 'Calls aren’t available in the desktop app on Linux yet: its web engine has no WebRTC.'
|
||||
: 'Calls aren’t available in this desktop app: its web engine has no WebRTC.';
|
||||
|
||||
type CallsUnavailableMessageProps = {
|
||||
roomId: string;
|
||||
/** Button label, e.g. "Answer in browser" for an incoming call. */
|
||||
actionLabel?: string;
|
||||
};
|
||||
|
||||
export function CallsUnavailableMessage({
|
||||
roomId,
|
||||
actionLabel = 'Open in browser',
|
||||
}: CallsUnavailableMessageProps) {
|
||||
const url = useCallInBrowserUrl(roomId);
|
||||
|
||||
if (!isTauri()) {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Your browser does not support WebRTC, which is required for calling.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box direction="Column" alignItems="Center" gap="200" style={{ margin: 'auto' }}>
|
||||
<Text style={{ color: color.Warning.Main }} size="L400" align="Center">
|
||||
{desktopUnavailableText()}
|
||||
{url ? ' You can join from the web app in your browser.' : ''}
|
||||
</Text>
|
||||
{url && (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
before={<Icon size="100" src={Icons.External} />}
|
||||
onClick={() => openCallInBrowser(url)}
|
||||
>
|
||||
<Text size="B300">{actionLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useCallEmbed, useCallJoined, useCallStart } from '../../hooks/useCallEm
|
||||
import { useCallPreferences } from '../../state/hooks/callPreferences';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useCallsDown } from '../server-status/ServerStatusBanner';
|
||||
|
||||
type MediaPermState = 'granted' | 'denied' | 'prompt' | 'unknown';
|
||||
|
||||
@@ -61,6 +62,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
|
||||
const micPermission = useMediaPermissions();
|
||||
const micDenied = micPermission === 'denied';
|
||||
const callsDown = useCallsDown();
|
||||
|
||||
const disabled = inOtherCall || !canJoin || micDenied;
|
||||
|
||||
@@ -91,6 +93,11 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
|
||||
<ChatButton />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
{callsDown && (
|
||||
<Text size="T200" style={{ color: color.Warning.Main, textAlign: 'center' }}>
|
||||
Voice calls are down right now, so joining may fail.
|
||||
</Text>
|
||||
)}
|
||||
{micDenied && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
|
||||
Microphone access is blocked. Enable it in your browser settings to join.
|
||||
|
||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
||||
</Chip>
|
||||
</Box>
|
||||
<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} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
Binary file not shown.
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { stateToCarry } from '../../../utils/roomUpgradeState';
|
||||
|
||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const [upgradeState, upgrade] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (version: string, newAdditionalCreators?: string[]) => {
|
||||
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
});
|
||||
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
|
||||
// policy, soundboard, retention, role tags, emoji packs) into the
|
||||
// replacement room: read it first, re-create it after the upgrade.
|
||||
const carried = stateToCarry(
|
||||
Array.from(room.currentState.events.values()).flatMap((byKey) =>
|
||||
Array.from(byKey.values()),
|
||||
),
|
||||
);
|
||||
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
|
||||
Method.Post,
|
||||
`/rooms/${room.roomId}/upgrade`,
|
||||
undefined,
|
||||
{
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
},
|
||||
);
|
||||
const newRoomId = res?.replacement_room;
|
||||
if (!newRoomId || carried.length === 0) return;
|
||||
const results = await Promise.allSettled(
|
||||
carried.map((st) =>
|
||||
mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
|
||||
undefined,
|
||||
st.content,
|
||||
),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
if (failed > 0) {
|
||||
throw new Error(
|
||||
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[mx, room],
|
||||
),
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
||||
import { useStrippedFilePicker } from '../../../hooks/useStrippedFilePicker';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
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 [iconFile, setIconFile] = useState<File>();
|
||||
const pickFile = useFilePicker(setIconFile, false);
|
||||
const pickFile = useStrippedFilePicker(setIconFile, false);
|
||||
|
||||
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
|
||||
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
|
||||
|
||||
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
const light: CSSProperties = {
|
||||
@@ -75,7 +75,7 @@ const light: CSSProperties = {
|
||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
||||
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||
};
|
||||
|
||||
export const animAurora: ChatBgVariants = { dark, light };
|
||||
|
||||
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
|
||||
'0 0', // wash A (static)
|
||||
'0 0', // wash B (static)
|
||||
].join(','),
|
||||
animation: `${firefliesDrift} 44s linear infinite`,
|
||||
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
|
||||
'0 0', // base wash
|
||||
'0 0', // vignette
|
||||
].join(','),
|
||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
||||
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
|
||||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
|
||||
].join(','),
|
||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||
animation: `${rainFall} 12s linear infinite`,
|
||||
animation: `${rainFall} 12s steps(288) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // nebula A
|
||||
'0 0', // nebula B
|
||||
].join(','),
|
||||
animation: `${starDrift} 90s linear infinite`,
|
||||
animation: `${starDrift} 90s steps(1080) infinite`,
|
||||
},
|
||||
|
||||
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
|
||||
'0 0', // wash A
|
||||
'0 0', // wash B
|
||||
].join(','),
|
||||
animation: `${starDrift} 100s linear infinite`,
|
||||
animation: `${starDrift} 100s steps(1200) infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -149,6 +149,11 @@ export const getChatBg = (
|
||||
}
|
||||
// For animated backgrounds, promote the element to its own compositor layer so
|
||||
// background-position keyframes don't trigger repaints on descendant elements.
|
||||
// [Gitea #337] background-position can't run on the compositor, so every
|
||||
// animation frame repaints the whole chat area (45 to 70% main-thread busy
|
||||
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
|
||||
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
|
||||
// same, a fraction of the repaints.
|
||||
if (style.animation) {
|
||||
return { ...style, willChange: 'background-position', contain: 'paint' };
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { MsgTypeFilter } from './useMessageSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
|
||||
onChange: (rooms?: string[]) => void;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx],
|
||||
(rId) => {
|
||||
const name = mx.getRoom(rId)?.name ?? rId;
|
||||
// [Gitea #336] match the personal "Rename for me" name too
|
||||
return localNames[rId] ? [localNames[rId], name] : name;
|
||||
},
|
||||
[mx, localNames],
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
||||
pinnedOnly,
|
||||
onTogglePinnedOnly,
|
||||
}: SearchFiltersProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
@@ -73,6 +74,7 @@ export function SearchResultGroup({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SearchResultGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
foldEdits,
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
||||
['$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);
|
||||
};
|
||||
|
||||
/**
|
||||
* [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 groups: ResultGroup[] = [];
|
||||
|
||||
results.forEach((item) => {
|
||||
foldEdits(results).forEach((item) => {
|
||||
const roomId = item.result.room_id;
|
||||
const resultItem: ResultItem = {
|
||||
rank: item.rank,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
ConnectionError,
|
||||
EventStatus,
|
||||
KnownMembership,
|
||||
MatrixClient,
|
||||
MatrixError,
|
||||
MatrixEvent,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
SyncState,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { sendOfflineAtom } from '../../state/sendOffline';
|
||||
import {
|
||||
OUTBOX_MAX_AUTO_RETRIES,
|
||||
OutboxEntry,
|
||||
isRetryableSendError,
|
||||
loadOutbox,
|
||||
outboxRetryDelayMs,
|
||||
planRestore,
|
||||
saveOutbox,
|
||||
shouldKeepInOutbox,
|
||||
withEntry,
|
||||
withoutEntry,
|
||||
} from '../../utils/outbox';
|
||||
|
||||
const PENDING: ReadonlySet<EventStatus | null> = new Set([
|
||||
EventStatus.SENDING,
|
||||
EventStatus.ENCRYPTING,
|
||||
EventStatus.QUEUED,
|
||||
EventStatus.NOT_SENT,
|
||||
]);
|
||||
const ONLINE: ReadonlySet<SyncState | null> = new Set([
|
||||
SyncState.Prepared,
|
||||
SyncState.Syncing,
|
||||
SyncState.Catchup,
|
||||
]);
|
||||
const OFFLINE: ReadonlySet<SyncState | null> = new Set([SyncState.Reconnecting, SyncState.Error]);
|
||||
|
||||
/** The server already has it: its transaction id came back down /sync. */
|
||||
const isDelivered = (room: Room, txnId: string): boolean => {
|
||||
const hasTxn = (events: MatrixEvent[]) =>
|
||||
events.some((e) => e.getUnsigned().transaction_id === txnId);
|
||||
return (
|
||||
hasTxn(room.getLiveTimeline().getEvents()) ||
|
||||
room.getThreads().some((t) => hasTxn(t.liveTimeline.getEvents()))
|
||||
);
|
||||
};
|
||||
|
||||
type OutboxSession = {
|
||||
/** Own pending events of this session, by txnId. */
|
||||
live: Map<string, { event: MatrixEvent; room: Room }>;
|
||||
autoRetries: Map<string, number>;
|
||||
restored: boolean;
|
||||
retrying: boolean;
|
||||
};
|
||||
|
||||
// Per client, not per mount: a remount must neither restore twice nor forget
|
||||
// the events it is tracking.
|
||||
const sessions = new WeakMap<MatrixClient, OutboxSession>();
|
||||
const getSession = (mx: MatrixClient): OutboxSession => {
|
||||
let session = sessions.get(mx);
|
||||
if (!session) {
|
||||
session = { live: new Map(), autoRetries: new Map(), restored: false, retrying: false };
|
||||
sessions.set(mx, session);
|
||||
}
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #112] Offline outbox (see utils/outbox.ts): mirrors own message sends
|
||||
* into localStorage until the server confirms them, puts unsent ones back
|
||||
* after a reload, and re-sends network failures when the connection returns.
|
||||
*/
|
||||
export function OutboxFeature() {
|
||||
const mx = useMatrixClient();
|
||||
const setOffline = useSetAtom(sendOfflineAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = mx.getSafeUserId();
|
||||
let entries = loadOutbox(userId);
|
||||
const session = getSession(mx);
|
||||
const { live, autoRetries } = session;
|
||||
let disposed = false;
|
||||
let offlineNow = false;
|
||||
const timers = new Set<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const isOffline = () =>
|
||||
OFFLINE.has(mx.getSyncState()) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||
|
||||
const persist = (next: OutboxEntry[]) => {
|
||||
if (next === entries) return;
|
||||
entries = next;
|
||||
saveOutbox(userId, entries);
|
||||
};
|
||||
|
||||
/**
|
||||
* A network failure while we think we're online (a blip neither sync nor
|
||||
* the browser noticed): try again shortly, backing off. Real outages are
|
||||
* handled by the reconnect / back-online triggers below.
|
||||
*/
|
||||
const scheduleBlipRetry = (event: MatrixEvent) => {
|
||||
const attempts = autoRetries.get(event.getTxnId() ?? '') ?? 0;
|
||||
if (!isRetryableSendError(event.error) || attempts >= OUTBOX_MAX_AUTO_RETRIES || isOffline())
|
||||
return;
|
||||
const timer = setTimeout(() => {
|
||||
timers.delete(timer);
|
||||
retryQueued();
|
||||
}, outboxRetryDelayMs(attempts));
|
||||
timers.add(timer);
|
||||
};
|
||||
|
||||
const onLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, room) => {
|
||||
const txnId = event.getTxnId();
|
||||
if (!txnId || event.getSender() !== userId) return;
|
||||
if (PENDING.has(event.status)) {
|
||||
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
||||
live.set(txnId, { event, room });
|
||||
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
||||
persist(
|
||||
withEntry(entries, {
|
||||
txnId,
|
||||
roomId: room.roomId,
|
||||
threadId: event.threadRootId ?? null,
|
||||
type: event.getType(),
|
||||
content: event.getContent(),
|
||||
ts: event.getTs(),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
||||
live.delete(txnId);
|
||||
autoRetries.delete(txnId);
|
||||
persist(withoutEntry(entries, txnId));
|
||||
};
|
||||
|
||||
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
||||
const retryQueued = async () => {
|
||||
if (session.retrying || disposed) return;
|
||||
session.retrying = true;
|
||||
try {
|
||||
const byRoom = new Map<Room, MatrixEvent[]>();
|
||||
Array.from(live.values())
|
||||
.filter(
|
||||
({ event }) =>
|
||||
event.status === EventStatus.NOT_SENT &&
|
||||
isRetryableSendError(event.error) &&
|
||||
(autoRetries.get(event.getTxnId() ?? '') ?? 0) < OUTBOX_MAX_AUTO_RETRIES,
|
||||
)
|
||||
.sort((a, b) => a.event.getTs() - b.event.getTs())
|
||||
.forEach(({ event, room }) => {
|
||||
byRoom.set(room, [...(byRoom.get(room) ?? []), event]);
|
||||
});
|
||||
await Promise.all(
|
||||
Array.from(byRoom.entries()).map(async ([room, events]) => {
|
||||
for (const event of events) {
|
||||
if (disposed || event.status !== EventStatus.NOT_SENT) continue;
|
||||
const txnId = event.getTxnId() ?? '';
|
||||
autoRetries.set(txnId, (autoRetries.get(txnId) ?? 0) + 1);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mx.resendEvent(event, room);
|
||||
} catch (e) {
|
||||
// Still offline: keep the rest of this room in order for the next try.
|
||||
if (isRetryableSendError(e)) break;
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
session.retrying = false;
|
||||
}
|
||||
};
|
||||
|
||||
const restore = () => {
|
||||
if (session.restored) return;
|
||||
session.restored = true;
|
||||
const plan = planRestore(
|
||||
entries,
|
||||
Date.now(),
|
||||
(roomId) => mx.getRoom(roomId)?.getMyMembership() === KnownMembership.Join,
|
||||
(entry) => {
|
||||
const room = mx.getRoom(entry.roomId);
|
||||
return !!room && isDelivered(room, entry.txnId);
|
||||
},
|
||||
);
|
||||
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
|
||||
plan.restore.forEach((entry) => {
|
||||
const room = mx.getRoom(entry.roomId);
|
||||
if (!room || live.has(entry.txnId)) return;
|
||||
// Same shape as the SDK's own local echo (client.sendCompleteEvent).
|
||||
const event = new MatrixEvent({
|
||||
type: entry.type,
|
||||
content: entry.content,
|
||||
event_id: `~${entry.roomId}:${entry.txnId}`,
|
||||
sender: userId,
|
||||
room_id: entry.roomId,
|
||||
origin_server_ts: entry.ts,
|
||||
});
|
||||
const thread = entry.threadId ? room.getThread(entry.threadId) : undefined;
|
||||
if (thread) event.setThread(thread);
|
||||
event.setTxnId(entry.txnId);
|
||||
event.setStatus(EventStatus.NOT_SENT);
|
||||
if (plan.autoSend.has(entry.txnId)) {
|
||||
// Recent: treat like a send that lost the network (shown as Queued
|
||||
// while offline, re-sent below and on reconnect).
|
||||
// (The SDK types `error` as MatrixError but stores any send error there.)
|
||||
event.error = new ConnectionError(
|
||||
'not sent before the app was closed',
|
||||
) as unknown as MatrixError;
|
||||
} else {
|
||||
// Old: back as "Failed to send"; the user decides (Retry / Cancel).
|
||||
autoRetries.set(entry.txnId, OUTBOX_MAX_AUTO_RETRIES);
|
||||
}
|
||||
try {
|
||||
room.addPendingEvent(event, entry.txnId); // emits LocalEchoUpdated → `live`
|
||||
} catch {
|
||||
// Already pending under this txnId: nothing to restore.
|
||||
}
|
||||
});
|
||||
if (!isOffline()) retryQueued();
|
||||
};
|
||||
|
||||
const updateOffline = () => {
|
||||
const offline = isOffline();
|
||||
// Back online (sync recovered or the browser says so): send what's queued.
|
||||
if (offlineNow && !offline && session.restored) retryQueued();
|
||||
offlineNow = offline;
|
||||
setOffline(offline);
|
||||
};
|
||||
|
||||
const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state, prevState) => {
|
||||
updateOffline();
|
||||
if (!ONLINE.has(state)) return;
|
||||
if (!session.restored) {
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
if (OFFLINE.has(prevState) || prevState === SyncState.Catchup) retryQueued();
|
||||
};
|
||||
const onBrowserOnline = () => updateOffline();
|
||||
|
||||
mx.on(RoomEvent.LocalEchoUpdated, onLocalEcho);
|
||||
mx.on(ClientEvent.Sync, onSync);
|
||||
window.addEventListener('online', onBrowserOnline);
|
||||
window.addEventListener('offline', onBrowserOnline);
|
||||
updateOffline();
|
||||
if (ONLINE.has(mx.getSyncState())) restore();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
timers.forEach((t) => clearTimeout(t));
|
||||
mx.off(RoomEvent.LocalEchoUpdated, onLocalEcho);
|
||||
mx.off(ClientEvent.Sync, onSync);
|
||||
window.removeEventListener('online', onBrowserOnline);
|
||||
window.removeEventListener('offline', onBrowserOnline);
|
||||
};
|
||||
}, [mx, setOffline]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
setRoomNotificationPreference,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
||||
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
|
||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
@@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
prevMode,
|
||||
).catch(() => {});
|
||||
if (durationMs !== null) {
|
||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
||||
unmuteRoom(mx, room.roomId);
|
||||
});
|
||||
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
|
||||
} else {
|
||||
// An indefinite mute replaces any timed one.
|
||||
await clearMuteTimer(mx, room.roomId);
|
||||
}
|
||||
requestClose();
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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', () => {
|
||||
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', () => {
|
||||
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,
|
||||
} 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';
|
||||
|
||||
// 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 function loadMuteTimers(): MuteTimerEntry[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
|
||||
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
|
||||
const timers = (content as { timers?: unknown } | undefined)?.timers;
|
||||
if (!timers || typeof timers !== 'object') return [];
|
||||
return Object.entries(timers as Record<string, unknown>)
|
||||
.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 {
|
||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||
/** Pure: account data content for `entries`. */
|
||||
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
|
||||
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
|
||||
RoomNotificationMode.Mute,
|
||||
).catch(() => {});
|
||||
}
|
||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
||||
await clearMuteTimer(mx, roomId);
|
||||
}
|
||||
|
||||
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||
const unmuteAt = Date.now() + durationMs;
|
||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
||||
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
|
||||
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
|
||||
const timers = loadMuteTimers(mx);
|
||||
if (!timers.some((e) => e.roomId === roomId)) return;
|
||||
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 { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
|
||||
|
||||
// ── Policy event types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -38,13 +39,6 @@ type PolicyRuleContent = {
|
||||
recommendation?: string;
|
||||
};
|
||||
|
||||
type PolicyEntry = {
|
||||
entity: string;
|
||||
reason: string;
|
||||
recommendation: string;
|
||||
stateKey: string;
|
||||
};
|
||||
|
||||
type PolicyTab = 'users' | 'rooms' | 'servers';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -53,13 +47,6 @@ function isGlob(entity: string): boolean {
|
||||
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.
|
||||
* 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 !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
||||
Report Room
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
||||
Report User
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -153,10 +153,14 @@ import { useRoomUnverifiedDeviceCount } from '../../hooks/useDeviceVerificationS
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||
import { DraftIndicator } from './DraftIndicator';
|
||||
import { PendingLocation, ShareLocationDialog } from './ShareLocationDialog';
|
||||
import { buildGeoUri } from '../../utils/geoShare';
|
||||
import { useLocalRoomName } from '../../hooks/useRoomMeta';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||
import { plaintextCachesWiped } from '../../state/plaintextCaches';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -356,6 +360,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
);
|
||||
const displayRoomName = useLocalRoomName(room);
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||
const handleShareLocation = useCallback(() => {
|
||||
@@ -368,21 +373,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => {
|
||||
setLocating(false);
|
||||
const { latitude, longitude } = pos.coords;
|
||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||
const ts = Date.now();
|
||||
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
|
||||
// m.location/m.asset/m.ts blocks so Element and other clients render a
|
||||
// proper "shared location" pin instead of falling back to plain text.
|
||||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||||
msgtype: 'm.location',
|
||||
body: `Shared a location: ${geoUri}`,
|
||||
geo_uri: geoUri,
|
||||
'org.matrix.msc3488.location': { uri: geoUri },
|
||||
'org.matrix.msc3488.asset': { type: 'm.self' },
|
||||
'org.matrix.msc3488.ts': ts,
|
||||
'm.ts': ts,
|
||||
});
|
||||
// [Gitea #340] Don't send straight away: confirm first (ShareLocationDialog).
|
||||
const { latitude, longitude, accuracy } = pos.coords;
|
||||
setPendingLocation({ latitude, longitude, accuracy });
|
||||
},
|
||||
(err) => {
|
||||
setLocating(false);
|
||||
@@ -397,7 +390,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}, [mx, roomId, threadRootId]);
|
||||
}, []);
|
||||
const [pendingLocation, setPendingLocation] = React.useState<PendingLocation | null>(null);
|
||||
const handleSendLocation = useCallback(
|
||||
(approximate: boolean) => {
|
||||
if (!pendingLocation) return;
|
||||
const { latitude, longitude, accuracy } = pendingLocation;
|
||||
setPendingLocation(null);
|
||||
const geoUri = buildGeoUri(latitude, longitude, accuracy, approximate);
|
||||
const ts = Date.now();
|
||||
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
|
||||
// m.location/m.asset/m.ts blocks so Element and other clients render a
|
||||
// proper "shared location" pin instead of falling back to plain text.
|
||||
sendRoomMessage(mx, roomId, threadRootId ?? null, {
|
||||
msgtype: 'm.location',
|
||||
body: `Shared ${approximate ? 'an approximate' : 'a'} location: ${geoUri}`,
|
||||
geo_uri: geoUri,
|
||||
'org.matrix.msc3488.location': { uri: geoUri },
|
||||
'org.matrix.msc3488.asset': { type: 'm.self' },
|
||||
'org.matrix.msc3488.ts': ts,
|
||||
'm.ts': ts,
|
||||
});
|
||||
},
|
||||
[pendingLocation, mx, roomId, threadRootId],
|
||||
);
|
||||
|
||||
const handleVoiceSend = useCallback(
|
||||
async (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => {
|
||||
@@ -547,6 +563,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// Persist the current editor state to the draft atom + localStorage; an empty
|
||||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||||
const persistDraft = useCallback(() => {
|
||||
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
|
||||
if (plaintextCachesWiped()) return;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
@@ -1538,11 +1556,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
style={touchTarget}
|
||||
className={MobileTouchTarget}
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} />
|
||||
)}
|
||||
{locating ? <Spinner variant="Secondary" size="400" /> : <Icon src={Icons.Pin} />}
|
||||
</IconButton>
|
||||
) : null;
|
||||
|
||||
@@ -1751,6 +1765,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
onClose={() => setPollOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{pendingLocation && (
|
||||
<ShareLocationDialog
|
||||
location={pendingLocation}
|
||||
roomName={displayRoomName}
|
||||
onSend={handleSendLocation}
|
||||
requestClose={() => setPendingLocation(null)}
|
||||
/>
|
||||
)}
|
||||
{scheduleOpen && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
|
||||
@@ -90,6 +90,7 @@ import {
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
sendRoomEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
@@ -121,7 +122,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -138,6 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||
import { declinedRing, isRingableRoom } from '../../utils/callRing';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
@@ -409,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
||||
data,
|
||||
) => {
|
||||
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);
|
||||
};
|
||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||
@@ -493,7 +498,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||
// re-renders. `undefined` = no policy.
|
||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||
const retentionMs = effectiveRetentionMs(
|
||||
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||
);
|
||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
@@ -2030,6 +2038,12 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
if (!senderId || senderId === mx.getUserId()) return null;
|
||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||
// channels, where starting a call is just joining the channel.
|
||||
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 mention =
|
||||
content['m.mentions']?.room ||
|
||||
|
||||
@@ -80,6 +80,7 @@ import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||
import { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
|
||||
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
||||
|
||||
type RoomMenuProps = {
|
||||
@@ -465,6 +466,36 @@ function CallRulesChip({ room }: { room: Room }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't
|
||||
* make calls (desktop on WebKitGTK): opens the room in the web app instead.
|
||||
*/
|
||||
function CallInBrowserButton({ url }: { url: string }) {
|
||||
return (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Call in your browser (this desktop app can’t make calls here)</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
aria-label="Start call in browser"
|
||||
onClick={() => openCallInBrowser(url)}
|
||||
>
|
||||
<Icon size="400" src={Icons.VideoCamera} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function CallButton() {
|
||||
const room = useRoom();
|
||||
const direct = useIsDirectRoom();
|
||||
@@ -555,6 +586,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
);
|
||||
const livekitSupported = useLivekitSupport();
|
||||
const rtcSupported = webRTCSupported();
|
||||
const callInBrowserUrl = useCallInBrowserUrl(room.roomId);
|
||||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
@@ -782,11 +814,16 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
|
||||
{!room.isCallRoom() &&
|
||||
livekitSupported &&
|
||||
rtcSupported &&
|
||||
(rtcSupported || callInBrowserUrl) &&
|
||||
hasCallPermission &&
|
||||
(direct ||
|
||||
(room.getJoinRule() === 'invite' &&
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
|
||||
(rtcSupported ? (
|
||||
<CallButton />
|
||||
) : (
|
||||
callInBrowserUrl && <CallInBrowserButton url={callInBrowserUrl} />
|
||||
))}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
||||
{title}
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
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 { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
|
||||
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
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return undefined;
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import React from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
config,
|
||||
Dialog,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
Text,
|
||||
} from 'folds';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { formatAccuracy } from '../../utils/geoShare';
|
||||
|
||||
export type PendingLocation = { latitude: number; longitude: number; accuracy?: number };
|
||||
|
||||
type ShareLocationDialogProps = {
|
||||
location: PendingLocation;
|
||||
roomName: string;
|
||||
onSend: (approximate: boolean) => void;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #340] Confirm step before an m.location is posted: the toolbar button
|
||||
* used to send the exact position from a single click. Shows what will be
|
||||
* shared (coordinates + the fix's accuracy) and offers an approximate share.
|
||||
*/
|
||||
export function ShareLocationDialog({
|
||||
location,
|
||||
roomName,
|
||||
onSend,
|
||||
requestClose,
|
||||
}: ShareLocationDialogProps) {
|
||||
const modalStyle = useModalStyle(420);
|
||||
const { latitude, longitude, accuracy } = location;
|
||||
const accuracyLabel = formatAccuracy(accuracy);
|
||||
const mapUrl = `https://www.openstreetmap.org/?mlat=${latitude}&mlon=${longitude}#map=16/${latitude}/${longitude}`;
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#share-location-cancel',
|
||||
onDeactivate: requestClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="share-location-title"
|
||||
variant="Surface"
|
||||
style={modalStyle}
|
||||
>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
}}
|
||||
variant="Surface"
|
||||
size="500"
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Text id="share-location-title" as="h2" size="H4">
|
||||
Share your location?
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" onClick={requestClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text>
|
||||
Everyone in <b>{roomName}</b> will see this position, and it can't be taken
|
||||
back from their devices once sent.
|
||||
</Text>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T400">
|
||||
<b>
|
||||
{latitude.toFixed(5)}, {longitude.toFixed(5)}
|
||||
</b>
|
||||
{accuracyLabel && ` (${accuracyLabel})`}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{accuracyLabel
|
||||
? 'The ± is how sure your device is of this position.'
|
||||
: 'Your device did not say how accurate this position is.'}{' '}
|
||||
<a href={mapUrl} target="_blank" rel="noreferrer noopener">
|
||||
Check on OpenStreetMap
|
||||
</a>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box gap="200" justifyContent="End" wrap="Wrap">
|
||||
<Button
|
||||
id="share-location-cancel"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={requestClose}
|
||||
>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => onSend(true)}>
|
||||
<Text size="B400">Send approximate (~1 km)</Text>
|
||||
</Button>
|
||||
<Button variant="Primary" onClick={() => onSend(false)}>
|
||||
<Text size="B400">Send</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Dialog>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
@@ -47,7 +47,12 @@ import {
|
||||
recentForwardTargetsAtom,
|
||||
addRecentForwardTarget,
|
||||
} from '../../../state/recentForwardTargets';
|
||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||
import {
|
||||
buildForwardContent,
|
||||
buildPlaintextAttachmentContent,
|
||||
forwardEncryptionWarning,
|
||||
} from './forwardContent';
|
||||
import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type RoomRowProps = {
|
||||
room: Room;
|
||||
@@ -58,6 +63,7 @@ type RoomRowProps = {
|
||||
sending: boolean;
|
||||
};
|
||||
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -97,7 +103,7 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
|
||||
>
|
||||
<Box direction="Column">
|
||||
<Text size="T300" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
{dm && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
@@ -212,6 +218,7 @@ function RecentChip({
|
||||
onToggle: () => void;
|
||||
sending: boolean;
|
||||
}) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
@@ -240,7 +247,7 @@ function RecentChip({
|
||||
}
|
||||
>
|
||||
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
@@ -252,6 +259,7 @@ type Props = {
|
||||
};
|
||||
|
||||
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(400);
|
||||
const directs = useAtomValue(mDirectAtom);
|
||||
@@ -271,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
// Selection persists across query changes: a room selected then filtered out
|
||||
// of the rendered slice stays selected.
|
||||
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) => {
|
||||
setSelectedRoomIds((prev) => {
|
||||
@@ -296,8 +312,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const filtered = useMemo(() => {
|
||||
if (!query) return allRooms;
|
||||
const q = query.toLowerCase();
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
return allRooms.filter((r) =>
|
||||
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
|
||||
);
|
||||
}, [allRooms, query, localNames]);
|
||||
|
||||
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
|
||||
const recentRooms = useMemo(
|
||||
@@ -584,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||
gap="200"
|
||||
>
|
||||
{encryptionWarning && (
|
||||
<Text size="T200" role="note" style={{ color: color.Warning.Main }}>
|
||||
{encryptionWarning}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
variant="Primary"
|
||||
size="400"
|
||||
|
||||
@@ -26,19 +26,21 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import React, {
|
||||
CSSProperties,
|
||||
FormEventHandler,
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useHover, useFocusWithin } from 'react-aria';
|
||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import classNames from 'classnames';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
AvatarBase,
|
||||
@@ -83,8 +85,6 @@ import { copyToClipboard } from '../../../utils/dom';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { getMatrixToRoomEvent } from '../../../plugins/matrix-to';
|
||||
import { getLotusRoomPermalink } from '../../../plugins/lotus-permalink';
|
||||
import { getOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
import { useClientConfig } from '../../../hooks/useClientConfig';
|
||||
import { getViaServers } from '../../../plugins/via-servers';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
|
||||
@@ -98,28 +98,55 @@ import { useLongPress } from '../../../hooks/useLongPress';
|
||||
import { ActionSheet } from '../../../components/action-sheet';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { useLotusShareBase } from '../../../hooks/useLotusLinkBase';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
import { sendOfflineAtom } from '../../../state/sendOffline';
|
||||
import { isRetryableSendError } from '../../../utils/outbox';
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({
|
||||
status,
|
||||
mEvent,
|
||||
room,
|
||||
lotusTerminal,
|
||||
}: {
|
||||
status: string | null;
|
||||
mEvent: MatrixEvent;
|
||||
room: Room;
|
||||
lotusTerminal: boolean;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const offline = useAtomValue(sendOfflineAtom);
|
||||
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
|
||||
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
|
||||
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
|
||||
// Re-render on the event's own status updates.
|
||||
const [, setStatusTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const bump = () => setStatusTick((t) => t + 1);
|
||||
mEvent.on(MatrixEventEvent.Status, bump);
|
||||
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||
return () => {
|
||||
mEvent.off(MatrixEventEvent.Status, bump);
|
||||
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||
};
|
||||
}, [mEvent]);
|
||||
const { status } = mEvent;
|
||||
if (status === null) return null; // confirmed by server — read receipts take over
|
||||
let iconSrc: IconSrc;
|
||||
let label: string;
|
||||
let colorStyle: string;
|
||||
const isSending = status === EventStatus.SENDING || status === EventStatus.ENCRYPTING;
|
||||
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
|
||||
// [Gitea #112] A network failure while offline is queued, not failed: the
|
||||
// outbox sends it again when the connection is back.
|
||||
const queued = status === EventStatus.NOT_SENT && offline && isRetryableSendError(mEvent.error);
|
||||
const failed = !queued && (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED);
|
||||
if (failed) {
|
||||
iconSrc = Icons.Cross;
|
||||
label = 'Failed to send';
|
||||
label = status === EventStatus.NOT_SENT ? 'Failed to send. Click to retry' : 'Failed to send';
|
||||
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
|
||||
} else if (status === EventStatus.QUEUED || isSending) {
|
||||
iconSrc = Icons.Send;
|
||||
label = isSending ? 'Sending...' : 'Queued';
|
||||
} else if (queued || status === EventStatus.QUEUED || isSending) {
|
||||
iconSrc = queued ? Icons.RecentClock : Icons.Send;
|
||||
if (queued) label = "Queued. Will send when you're back online";
|
||||
else label = isSending ? 'Sending...' : 'Queued';
|
||||
colorStyle = lotusTerminal
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
|
||||
: color.Secondary.Main;
|
||||
@@ -130,27 +157,49 @@ function DeliveryStatus({
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
|
||||
: color.Secondary.Main;
|
||||
}
|
||||
const retryable = failed && status === EventStatus.NOT_SENT;
|
||||
const handleRetry: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
evt.stopPropagation();
|
||||
if (mEvent.status === EventStatus.NOT_SENT) mx.resendEvent(mEvent, room).catch(() => undefined);
|
||||
};
|
||||
const style: CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
lineHeight: 1,
|
||||
color: colorStyle,
|
||||
opacity: 0.85,
|
||||
userSelect: 'none',
|
||||
...(lotusTerminal && failed ? { textShadow: 'var(--lt-glow-red)' } : {}),
|
||||
};
|
||||
const icon = (
|
||||
<span className={isSending ? SendingSpinClass : undefined}>
|
||||
<Icon size="100" src={iconSrc} />
|
||||
</span>
|
||||
);
|
||||
if (retryable) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRetry}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
...style,
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
font: 'inherit',
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
as="span"
|
||||
aria-label={label}
|
||||
title={label}
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '2px',
|
||||
lineHeight: 1,
|
||||
color: colorStyle,
|
||||
opacity: 0.85,
|
||||
userSelect: 'none',
|
||||
...(lotusTerminal && status === EventStatus.NOT_SENT
|
||||
? { textShadow: 'var(--lt-glow-red)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<span className={isSending ? SendingSpinClass : undefined}>
|
||||
<Icon size="100" src={iconSrc} />
|
||||
</span>
|
||||
<Box as="span" aria-label={label} title={label} style={style}>
|
||||
{icon}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -460,18 +509,11 @@ export const MessageCopyLotusLinkItem = as<
|
||||
onClose?: () => void;
|
||||
}
|
||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||
const { hashRouter } = useClientConfig();
|
||||
const lotusBase = useLotusShareBase();
|
||||
const handleCopy = () => {
|
||||
const eventId = mEvent.getId();
|
||||
if (!eventId) return;
|
||||
copyToClipboard(
|
||||
getLotusRoomPermalink(
|
||||
getOriginBaseUrl(hashRouter),
|
||||
room.roomId,
|
||||
eventId,
|
||||
getViaServers(room),
|
||||
),
|
||||
);
|
||||
copyToClipboard(getLotusRoomPermalink(lotusBase, room.roomId, eventId, getViaServers(room)));
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -683,7 +725,13 @@ export const MessageDeleteItem = as<
|
||||
Delete Message
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -823,7 +871,13 @@ export const MessageReportItem = as<
|
||||
Report Message
|
||||
</Text>
|
||||
</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} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -1110,7 +1164,7 @@ export const Message = React.memo(
|
||||
/>
|
||||
)}
|
||||
{isMine && !mEvent.isState() && readReceiptUsers.length === 0 && (
|
||||
<DeliveryStatus status={mEvent.status} lotusTerminal={!!lotusTerminal} />
|
||||
<DeliveryStatus mEvent={mEvent} room={room} lotusTerminal={!!lotusTerminal} />
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
const measure = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const rowWidth = container.getBoundingClientRect().width;
|
||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
||||
// [Gitea #305] The width the chips actually get (content box). The border
|
||||
// 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 widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
||||
style={{
|
||||
...props.style,
|
||||
...(collapsed && rowHeight
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
||||
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||
// 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) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
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
|
||||
// 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({}), 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* [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.`;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type ThreadPanelHeaderProps = {
|
||||
room: Room;
|
||||
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||
const { format } = useTimestampFormatter();
|
||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
||||
Thread
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -33,6 +33,8 @@ import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, conf
|
||||
import classNames from 'classnames';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { isRetryableSendError } from '../../../utils/outbox';
|
||||
import { sendOfflineAtom } from '../../../state/sendOffline';
|
||||
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||
@@ -253,6 +255,7 @@ export type ThreadTimelineProps = {
|
||||
|
||||
export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const mx = useMatrixClient();
|
||||
const sendOffline = useAtomValue(sendOfflineAtom);
|
||||
const alive = useAlive();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
@@ -992,8 +995,12 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
const showEmptyReplies = ready && thread.length === 0;
|
||||
|
||||
const renderPendingEvent = (mEvent: MatrixEvent) => {
|
||||
// [Gitea #112] Network failures while offline are queued, not failed.
|
||||
const queued =
|
||||
mEvent.status === EventStatus.NOT_SENT && sendOffline && isRetryableSendError(mEvent.error);
|
||||
const failed =
|
||||
mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED;
|
||||
!queued &&
|
||||
(mEvent.status === EventStatus.NOT_SENT || mEvent.status === EventStatus.CANCELLED);
|
||||
return (
|
||||
<div
|
||||
key={mEvent.getId() ?? mEvent.getTxnId()}
|
||||
@@ -1007,6 +1014,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{queued && (
|
||||
<Box style={{ padding: `0 ${config.space.S400}` }}>
|
||||
<Text size="T200" priority="300">
|
||||
Queued. Will send when you're back online
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
|
||||
onOpenThread: (threadId: string) => void;
|
||||
};
|
||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const handleJump = useCallback(
|
||||
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{totalUnread > 0 && (
|
||||
|
||||
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
|
||||
type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
};
|
||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||
if (toStart || removed || !data?.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||
else feed(ev);
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
useAsyncSearch,
|
||||
UseAsyncSearchOptions,
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import {
|
||||
@@ -157,17 +158,20 @@ export function Search({ requestClose }: SearchProps) {
|
||||
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
|
||||
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
|
||||
|
||||
const localNames = useLocalRoomNames();
|
||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||
(roomId: string) => {
|
||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
||||
// [Gitea #336] Match the user's own "Rename for me" name as well.
|
||||
const names = localNames[roomId] ? [localNames[roomId], roomName] : [roomName];
|
||||
if (mDirects.has(roomId)) {
|
||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
||||
if (targetUsername) return [roomName, targetUsername];
|
||||
if (targetUsername) return [...names, targetUsername];
|
||||
}
|
||||
return roomName;
|
||||
return names.length === 1 ? names[0] : names;
|
||||
},
|
||||
[getRoom, mDirects, mx],
|
||||
[getRoom, mDirects, mx, localNames],
|
||||
);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
|
||||
@@ -388,8 +392,10 @@ export function Search({ requestClose }: SearchProps) {
|
||||
<Box grow="Yes" alignItems="Center" gap="100">
|
||||
<Text size="T400" truncate>
|
||||
{queryHighlighRegex
|
||||
? highlightText(queryHighlighRegex, [room.name])
|
||||
: room.name}
|
||||
? highlightText(queryHighlighRegex, [
|
||||
localNames[room.roomId] ?? room.name,
|
||||
])
|
||||
: (localNames[room.roomId] ?? room.name)}
|
||||
</Text>
|
||||
{dmUsername && (
|
||||
<Text as="span" size="T200" priority="300" truncate>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { SyncState } from 'matrix-js-sdk';
|
||||
import { CryptoApi } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { Icons } from 'folds';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
} from '../../hooks/useDeviceVerificationStatus';
|
||||
import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBackup';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||
import { pickBanner } from '../../utils/kumaStatus';
|
||||
import {
|
||||
dismissNudge,
|
||||
NUDGE_COPY,
|
||||
NUDGE_KEY_PREFIX,
|
||||
nudgeFor,
|
||||
NudgeRecord,
|
||||
parseNudgeRecord,
|
||||
shouldShowNudge,
|
||||
} from '../../utils/securityNudge';
|
||||
import { StatusStrip } from '../server-status/ServerStatusBanner';
|
||||
|
||||
/** This device's backup connection: undefined until known, then true/false. */
|
||||
const useBackupActive = (crypto: CryptoApi): boolean | undefined => {
|
||||
const alive = useAlive();
|
||||
const [active, setActive] = useState<boolean>();
|
||||
useEffect(() => {
|
||||
crypto.getActiveSessionBackupVersion().then((v) => {
|
||||
if (alive()) setActive(typeof v === 'string');
|
||||
});
|
||||
}, [crypto, alive]);
|
||||
useKeyBackupStatusChange(useCallback((enabled: boolean) => setActive(enabled), []));
|
||||
return active;
|
||||
};
|
||||
|
||||
const readRecord = (key: string): NudgeRecord => {
|
||||
let raw: string | null = null;
|
||||
try {
|
||||
raw = localStorage.getItem(key);
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
const record = parseNudgeRecord(raw, Date.now());
|
||||
if (!raw) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(record));
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
}
|
||||
return record;
|
||||
};
|
||||
|
||||
function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
||||
const mx = useMatrixClient();
|
||||
const deviceId = mx.getDeviceId() ?? undefined;
|
||||
const crossSigning = useCrossSigningActive();
|
||||
const status = useDeviceVerificationStatus(crypto, mx.getSafeUserId(), deviceId);
|
||||
const backupInfo = useKeyBackupInfo(crypto);
|
||||
const backupActive = useBackupActive(crypto);
|
||||
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
||||
const statusDismissed = useAtomValue(statusDismissedAtom);
|
||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
||||
// never stacks under the "Connecting…" strip at startup.
|
||||
const [settled, setSettled] = useState(false);
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback((state: SyncState | null, prev?: SyncState | null) => {
|
||||
setSettled(state === SyncState.Syncing && prev === SyncState.Syncing);
|
||||
}, []),
|
||||
);
|
||||
|
||||
const key = `${NUDGE_KEY_PREFIX}${deviceId ?? 'unknown'}`;
|
||||
const [record, setRecord] = useState(() => readRecord(key));
|
||||
|
||||
let deviceVerified: boolean | undefined;
|
||||
if (status === VerificationStatus.Verified) deviceVerified = true;
|
||||
else if (status === VerificationStatus.Unverified) deviceVerified = false;
|
||||
const kind = nudgeFor({
|
||||
crossSigning,
|
||||
deviceVerified,
|
||||
backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null,
|
||||
backupActive,
|
||||
});
|
||||
|
||||
// One strip at a time: outages, maintenance and a lost connection win.
|
||||
// [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()))
|
||||
return null;
|
||||
|
||||
const copy = NUDGE_COPY[kind];
|
||||
const dismiss = () => {
|
||||
const next = dismissNudge(kind, record, Date.now());
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(next));
|
||||
} catch {
|
||||
/* dismissed for this session only */
|
||||
}
|
||||
setRecord(next);
|
||||
};
|
||||
return (
|
||||
<StatusStrip
|
||||
banner={{ key: kind, tone: 'Primary', text: copy.text, dismissable: true }}
|
||||
icon={Icons.ShieldUser}
|
||||
action={{ label: copy.action, onClick: () => requestSettings('devices') }}
|
||||
dismissLabel="Not now"
|
||||
onDismiss={dismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #110, #123] The device-security nudge: verify this device, connect
|
||||
* it to the key backup, or set up key backup. See utils/securityNudge.ts.
|
||||
*/
|
||||
function SecurityBannerInner() {
|
||||
const mx = useMatrixClient();
|
||||
const crypto = mx.getCrypto();
|
||||
if (!crypto) return null;
|
||||
return <SecurityBannerFor crypto={crypto} />;
|
||||
}
|
||||
|
||||
// A nudge must never take the app down: any error just hides it.
|
||||
export function SecurityBanner() {
|
||||
return (
|
||||
<ErrorBoundary fallback={null}>
|
||||
<SecurityBannerInner />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||
import { useSyncState } from '../../hooks/useSyncState';
|
||||
import { useServerStatus } from '../../hooks/useServerStatus';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
||||
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
||||
|
||||
const bannerIcon = (b: StripContent): IconSrc => {
|
||||
if (b.kind === 'maintenance') return Icons.Setting;
|
||||
if (b.tone === 'Primary') return Icons.Info;
|
||||
return Icons.Warning;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #124] One status strip, in the same slot and style as the sync and
|
||||
* clock banners. Presentational: the caller picks the banner.
|
||||
*/
|
||||
export type StripContent = Pick<
|
||||
StatusBanner,
|
||||
'key' | 'tone' | 'text' | 'detail' | 'dismissable'
|
||||
> & {
|
||||
kind?: StatusBanner['kind'];
|
||||
};
|
||||
|
||||
export function StatusStrip({
|
||||
banner,
|
||||
onDismiss,
|
||||
icon,
|
||||
action,
|
||||
dismissLabel,
|
||||
}: {
|
||||
banner: StripContent;
|
||||
onDismiss?: () => void;
|
||||
/** Overrides the icon picked from the banner's tone/kind. */
|
||||
icon?: IconSrc;
|
||||
/** A primary action button (e.g. "Verify"). */
|
||||
action?: { label: string; onClick: () => void };
|
||||
/** A text button instead of the ✕ for dismissing (e.g. "Not now"). */
|
||||
dismissLabel?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
|
||||
useEffect(() => setOpen(false), [banner.key]);
|
||||
const critical = banner.tone === 'Critical';
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
className={ContainerColor({ variant: banner.tone })}
|
||||
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
role={critical ? 'alert' : 'status'}
|
||||
aria-live={critical ? 'assertive' : 'polite'}
|
||||
>
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent={mobile ? 'Start' : 'Center'}
|
||||
gap="200"
|
||||
style={{ minHeight: config.size.X300 }}
|
||||
>
|
||||
<Icon size="100" src={icon ?? bannerIcon(banner)} />
|
||||
<Text size="L400" truncate={mobile && !!banner.detail && !open} style={{ minWidth: 0 }}>
|
||||
{banner.text}
|
||||
</Text>
|
||||
{banner.detail && (
|
||||
<Button
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Text size="B300" style={{ textDecoration: 'underline' }}>
|
||||
{open ? 'Hide' : 'Details'}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{action && (
|
||||
<Button
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
onClick={action.onClick}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Text size="B300">{action.label}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{banner.dismissable && onDismiss && dismissLabel && (
|
||||
<Button
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="None"
|
||||
radii="300"
|
||||
onClick={onDismiss}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Text size="B300">{dismissLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
{banner.dismissable && onDismiss && !dismissLabel && (
|
||||
<IconButton
|
||||
size="300"
|
||||
variant={banner.tone}
|
||||
fill="None"
|
||||
radii="300"
|
||||
aria-label="Dismiss"
|
||||
onClick={onDismiss}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
{banner.detail && open && (
|
||||
<Box justifyContent={mobile ? 'Start' : 'Center'}>
|
||||
<Text size="T200" style={{ maxWidth: 680, whiteSpace: 'pre-line' }}>
|
||||
{banner.detail}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Line variant={banner.tone} size="300" />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/** Runs the Kuma poller for the logged-in homeserver and publishes it. */
|
||||
export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
||||
const { statusPages } = useClientConfig();
|
||||
const page = useMemo(
|
||||
() => resolveStatusPage(statusPages, mx.getDomain() ?? undefined),
|
||||
[statusPages, mx],
|
||||
);
|
||||
const [lostAt, setLostAt] = useState<number | null>(null);
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback((state: SyncState | null) => {
|
||||
const lost = state === SyncState.Reconnecting || state === SyncState.Error;
|
||||
setLostAt((prev) => {
|
||||
if (!lost) return null;
|
||||
return prev ?? Date.now();
|
||||
});
|
||||
}, []),
|
||||
);
|
||||
// [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 setAtom = useSetAtom(serverStatusAtom);
|
||||
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The logged-in app's status strip. */
|
||||
export function ServerStatusBanner() {
|
||||
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
||||
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
|
||||
const banner = pickBanner(status, {
|
||||
syncLost,
|
||||
where: 'client',
|
||||
dismissed: new Set(dismissed),
|
||||
});
|
||||
if (!banner) return null;
|
||||
const dismiss = () => dismissKey(banner.key);
|
||||
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
||||
}
|
||||
|
||||
/** The sign-in screen's strip, for the homeserver being signed in to. */
|
||||
export function LoginStatusBanner({ serverName }: { serverName: string }) {
|
||||
const { statusPages } = useClientConfig();
|
||||
const page = useMemo(() => resolveStatusPage(statusPages, serverName), [statusPages, serverName]);
|
||||
const status = useServerStatus(page);
|
||||
const banner = pickBanner(status, { syncLost: false, where: 'login' });
|
||||
return banner ? <StatusStrip banner={banner} /> : null;
|
||||
}
|
||||
|
||||
/** Calls-down note for the call Join controls (null when calls are fine). */
|
||||
export function useCallsDown(): boolean {
|
||||
const { status } = useAtomValue(serverStatusAtom);
|
||||
return status?.calls === 'down' && status.homeserver !== 'down';
|
||||
}
|
||||
@@ -57,7 +57,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { noteLocalStatusWrite } from '../../../utils/ownStatusSync';
|
||||
import { DND_STATUS_SENTINEL, noteLocalStatusWrite } from '../../../utils/ownStatusSync';
|
||||
import { ProfileDecoration } from './ProfileDecoration';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { useStatusPresets } from '../../../hooks/useStatusPresets';
|
||||
@@ -396,7 +396,8 @@ function ProfileStatus() {
|
||||
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||
|
||||
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
|
||||
const serverStatus = presence?.status === DND_STATUS_SENTINEL ? undefined : presence?.status;
|
||||
const initialStatus = serverStatus ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
|
||||
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
|
||||
// True while the user has unsaved local edits — prevents a server presence
|
||||
// echo from overwriting what the user is currently typing/inserting.
|
||||
@@ -442,6 +443,9 @@ function ProfileStatus() {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
}
|
||||
}
|
||||
// [Gitea #283] "dnd" is the Do Not Disturb sentinel, not something the user
|
||||
// typed: don't show it in the status field or keep it as their status.
|
||||
if (remoteStatus === DND_STATUS_SENTINEL) return;
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
|
||||
@@ -112,8 +112,15 @@ import {
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { describeUpdateError, manualDownloadUrl } from '../../../utils/updateErrors';
|
||||
import {
|
||||
describeUpdateError,
|
||||
isPackageManagedError,
|
||||
manualDownloadUrl,
|
||||
packageUpdateHelp,
|
||||
} from '../../../utils/updateErrors';
|
||||
import { copyToClipboard } from '../../../utils/dom';
|
||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||
import { useKeychainMirrorStatus } from '../../../state/keychainMirror';
|
||||
import { isSafeGlobalToggleKey } from '../../../hooks/useCallHotkeys';
|
||||
import { customWindowChromeAtom } from '../../../state/customWindowChrome';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
@@ -238,6 +245,27 @@ function AutostartSetting() {
|
||||
);
|
||||
}
|
||||
|
||||
// [Gitea #105] Desktop: whether the login is also kept in the OS keychain.
|
||||
function KeychainMirrorSetting() {
|
||||
const status = useKeychainMirrorStatus();
|
||||
if (!isTauriEnv() || status.state === 'idle' || status.state === 'cleared') return null;
|
||||
let description: string;
|
||||
if (status.state === 'ok') {
|
||||
description =
|
||||
"A copy of your login is kept in the system keychain (Windows Credential Manager). A later update will keep it only there, out of the app's data folder.";
|
||||
} else if (status.state === 'unsupported') {
|
||||
description =
|
||||
"Not available on this system yet. Your login is saved in the app's data folder, as before.";
|
||||
} else {
|
||||
description = `Couldn't save a copy to the system keychain (${status.error}). You stay logged in; your login is saved in the app's data folder, as before.`;
|
||||
}
|
||||
return (
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Login in the system keychain" description={description} />
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
themes: Theme[];
|
||||
@@ -568,9 +596,6 @@ function Appearance() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<DesktopChromeSetting />
|
||||
<AutostartSetting />
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Twitter Emoji"
|
||||
@@ -2771,12 +2796,93 @@ function updateProgressText(progress: UpdateProgress | undefined): string {
|
||||
return `Downloading update… ${pct}% (${formatMb(downloaded)} of ${formatMb(total)})${attempt}`;
|
||||
}
|
||||
|
||||
function AppUpdates() {
|
||||
const { isTauri, status, check, install } = useTauriUpdater();
|
||||
if (!isTauri) return null;
|
||||
/**
|
||||
* A Linux package install is updated with its package manager (the in-app
|
||||
* updater can't write to /usr/bin): the command, Copy and a download link.
|
||||
*/
|
||||
function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUpdateHelp>> }) {
|
||||
const [copied, setCopied] = useState<'no' | 'yes' | 'failed'>('no');
|
||||
const copy = () => {
|
||||
const text = help.command ?? '';
|
||||
if (!navigator.clipboard) {
|
||||
copyToClipboard(text);
|
||||
setCopied('yes');
|
||||
return;
|
||||
}
|
||||
navigator.clipboard.writeText(text).then(
|
||||
() => setCopied('yes'),
|
||||
() => setCopied('failed'),
|
||||
);
|
||||
};
|
||||
let copyLabel = 'Copy command';
|
||||
if (copied === 'yes') copyLabel = 'Copied';
|
||||
else if (copied === 'failed') copyLabel = 'Copy failed: select it above';
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="T200">
|
||||
Lotus Chat was installed as a system package, so update it the same way
|
||||
{help.command ? ' (your chats and settings are kept):' : '.'}
|
||||
</Text>
|
||||
{help.command && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ fontFamily: 'monospace', wordBreak: 'break-all', userSelect: 'all' }}
|
||||
>
|
||||
{help.command}
|
||||
</Text>
|
||||
)}
|
||||
<Box gap="200" wrap="Wrap">
|
||||
{help.command && (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={copy}>
|
||||
<Text size="B300">{copyLabel}</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
outlined
|
||||
onClick={() => window.open(help.url, '_blank')}
|
||||
>
|
||||
<Text size="B300">{help.download}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const description =
|
||||
status.state === 'checking'
|
||||
/**
|
||||
* [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() {
|
||||
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
||||
if (!isTauri) return null;
|
||||
const packageHelp = packageUpdateHelp(installKind);
|
||||
const packageUpdate =
|
||||
!!packageHelp &&
|
||||
(status.state === 'available' ||
|
||||
(status.state === 'error' && isPackageManagedError(status.message)));
|
||||
|
||||
const description = packageUpdate
|
||||
? status.state === 'available'
|
||||
? `Update available: v${status.version}`
|
||||
: 'An update is available.'
|
||||
: status.state === 'checking'
|
||||
? 'Checking for updates...'
|
||||
: status.state === 'up-to-date'
|
||||
? 'Lotus Chat is up to date.'
|
||||
@@ -2793,43 +2899,43 @@ function AppUpdates() {
|
||||
else check();
|
||||
};
|
||||
|
||||
const after =
|
||||
status.state === 'available' ? (
|
||||
<Button size="300" radii="300" onClick={() => install()}>
|
||||
<Text size="B300">Install & Restart</Text>
|
||||
const after = packageUpdate ? undefined : status.state === 'available' ? (
|
||||
<Button size="300" radii="300" onClick={() => install()}>
|
||||
<Text size="B300">Install & Restart</Text>
|
||||
</Button>
|
||||
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : status.state === 'error' ? (
|
||||
<Box gap="200" wrap="Wrap" justifyContent="End">
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
|
||||
<Text size="B300">Try again</Text>
|
||||
</Button>
|
||||
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : status.state === 'error' ? (
|
||||
<Box gap="200" wrap="Wrap" justifyContent="End">
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={retry}>
|
||||
<Text size="B300">Try again</Text>
|
||||
{status.phase !== 'check' && (
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
outlined
|
||||
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
|
||||
>
|
||||
<Text size="B300">Download installer</Text>
|
||||
</Button>
|
||||
{status.phase !== 'check' && (
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
outlined
|
||||
onClick={() => window.open(manualDownloadUrl(navigator.userAgent), '_blank')}
|
||||
>
|
||||
<Text size="B300">Download installer</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||
<Text size="B300">Check</Text>
|
||||
</Button>
|
||||
);
|
||||
)}
|
||||
</Box>
|
||||
) : (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||
<Text size="B300">Check</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">App Updates</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||
{status.state === 'error' && (
|
||||
{packageUpdate && packageHelp && <PackageUpdate help={packageHelp} />}
|
||||
{status.state === 'error' && !packageUpdate && (
|
||||
<Text size="T200" priority="300" style={{ wordBreak: 'break-word' }}>
|
||||
Details: {status.message}
|
||||
</Text>
|
||||
@@ -2871,6 +2977,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<StorageUsage />
|
||||
<SettingsSyncSection />
|
||||
<Calls />
|
||||
<DesktopSettings />
|
||||
<AppUpdates />
|
||||
</Box>
|
||||
</PageContent>
|
||||
|
||||
@@ -25,6 +25,9 @@ import { settingsAtom } from '../../state/settings';
|
||||
import { isMacOS } from '../../utils/user-agent';
|
||||
import { KeySymbol } from '../../utils/key-symbol';
|
||||
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. */
|
||||
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
||||
@@ -98,6 +101,9 @@ export function KeyboardShortcutsDialog() {
|
||||
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
||||
const modalStyle = useModalStyle(480);
|
||||
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';
|
||||
|
||||
@@ -109,6 +115,8 @@ export function KeyboardShortcutsDialog() {
|
||||
rows: [
|
||||
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
||||
{ 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'],
|
||||
},
|
||||
{ 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'] },
|
||||
],
|
||||
},
|
||||
{
|
||||
// [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 (
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -17,6 +17,8 @@ import {
|
||||
CALL_SESSION_HEARTBEAT_MS,
|
||||
clearCallSession,
|
||||
decideRejoin,
|
||||
othersInCall,
|
||||
rejoinToastBody,
|
||||
readCallSession,
|
||||
writeCallSession,
|
||||
} from '../utils/callRejoin';
|
||||
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
};
|
||||
dismissToast(TOAST_ID);
|
||||
clearCallSession();
|
||||
// [Gitea #302] A DM call the other person has since left is over;
|
||||
// starting it again would ring them, so just say so.
|
||||
const dm = l.directs.has(room.roomId);
|
||||
if (
|
||||
dm &&
|
||||
!othersInCall(
|
||||
session.memberships.map((m) => m.userId ?? ''),
|
||||
mx.getUserId() ?? '',
|
||||
)
|
||||
) {
|
||||
// Own id: clicking a toast removes it by id after its onClick runs.
|
||||
setToast({
|
||||
id: `${TOAST_ID}-ended`,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Call ended',
|
||||
body: `${room.name ?? 'They'} already left the call.`,
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||
} catch {
|
||||
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
.filter((m) => m.userId === mx.getUserId())
|
||||
.map((m) => m.deviceId),
|
||||
memberCount: memberships.length,
|
||||
dm: latest.current.directs.has(room.roomId),
|
||||
});
|
||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||
// empty unless this is the final (timed-out) look.
|
||||
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
||||
id: TOAST_ID,
|
||||
iconSrc: Icons.Phone,
|
||||
displayName: 'Rejoin voice?',
|
||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
||||
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
|
||||
roomName: room.name ?? '',
|
||||
roomId: room.roomId,
|
||||
sticky: true,
|
||||
|
||||
@@ -25,6 +25,28 @@ export type ClientConfig = {
|
||||
* Unset: the bundled copy on this origin. Ignored in the desktop app.
|
||||
*/
|
||||
elementCallUrl?: string;
|
||||
|
||||
/**
|
||||
* [Gitea #43] Desktop only: the loopback origin the desktop app's local
|
||||
* server also answers on (e.g. "http://127.0.0.1:44548"), to load the
|
||||
* bundled call page from a different origin than the app
|
||||
* ("http://localhost:44548"). Set by cinny-desktop together with the server
|
||||
* and CSP changes it needs; unset keeps the same-origin call page.
|
||||
*/
|
||||
desktopCallOrigin?: string;
|
||||
|
||||
/**
|
||||
* [Gitea #124] Uptime Kuma status page per homeserver, for the status banner:
|
||||
* { "<server name>": { url, slug, groups?: { homeserver, calls, login } } }.
|
||||
* Homeservers not listed never contact Kuma.
|
||||
*/
|
||||
statusPages?: Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Absolute https URL of the public web app (e.g. https://chat.lotusguild.org).
|
||||
* The desktop app sets it so it can hand calls it can't make to the browser.
|
||||
*/
|
||||
webAppUrl?: string;
|
||||
};
|
||||
|
||||
const ClientConfigContext = createContext<ClientConfig | null>(null);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { lotusLinkBases, lotusShareBase } from './useLotusLinkBase';
|
||||
|
||||
const LOCAL = 'http://localhost:44548/#/';
|
||||
const WEB = 'https://chat.lotusguild.org';
|
||||
|
||||
test('web: shared links use the page origin', () => {
|
||||
assert.equal(lotusShareBase(WEB, false, WEB), WEB);
|
||||
assert.deepEqual(lotusLinkBases(WEB, false, undefined), [WEB]);
|
||||
});
|
||||
|
||||
test('desktop with webAppUrl: shared links use the public web app', () => {
|
||||
assert.equal(lotusShareBase(LOCAL, true, `${WEB}/`), WEB);
|
||||
assert.deepEqual(lotusLinkBases(LOCAL, true, WEB), [LOCAL, WEB]);
|
||||
});
|
||||
|
||||
test('desktop without a valid webAppUrl falls back to the origin', () => {
|
||||
[undefined, '', 'http://chat.lotusguild.org', 'nonsense'].forEach((v) => {
|
||||
assert.equal(lotusShareBase(LOCAL, true, v), LOCAL, String(v));
|
||||
assert.deepEqual(lotusLinkBases(LOCAL, true, v), [LOCAL]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useClientConfig } from './useClientConfig';
|
||||
import { isTauri } from './useTauri';
|
||||
import { getOriginBaseUrl } from '../pages/pathUtils';
|
||||
import { resolveWebAppUrl } from '../utils/callInBrowser';
|
||||
|
||||
/**
|
||||
* [Gitea #248] Base URL for Lotus links meant for other people.
|
||||
*
|
||||
* On the web that's this page's origin. In the desktop app the page is served
|
||||
* from a local address (and hash-routed), which nobody else can open, so use
|
||||
* the public web app from config `webAppUrl` (web path routing) instead.
|
||||
* Without a valid `webAppUrl` it falls back to the origin, as before.
|
||||
*/
|
||||
export const lotusShareBase = (originBase: string, desktop: boolean, webAppUrl: unknown): string =>
|
||||
(desktop && resolveWebAppUrl(webAppUrl)) || originBase;
|
||||
|
||||
/**
|
||||
* Every base a Lotus link to this deployment may start with: the origin, plus
|
||||
* the public web app in the desktop app, so a shared https link opens in-app.
|
||||
*/
|
||||
export const lotusLinkBases = (
|
||||
originBase: string,
|
||||
desktop: boolean,
|
||||
webAppUrl: unknown,
|
||||
): string[] => {
|
||||
const shared = lotusShareBase(originBase, desktop, webAppUrl);
|
||||
return shared === originBase ? [originBase] : [originBase, shared];
|
||||
};
|
||||
|
||||
export const useLotusShareBase = (): string => {
|
||||
const { hashRouter, webAppUrl } = useClientConfig();
|
||||
return useMemo(
|
||||
() => lotusShareBase(getOriginBaseUrl(hashRouter), isTauri(), webAppUrl),
|
||||
[hashRouter, webAppUrl],
|
||||
);
|
||||
};
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
markAutoTranslateTried,
|
||||
} from '../state/translation';
|
||||
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 =
|
||||
| '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
|
||||
// resubscribe this message to every other message's writes. [Gitea #39]
|
||||
const setCache = useSetAtom(translationCacheAtom);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
// 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
|
||||
@@ -124,6 +126,16 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
||||
if (!from || sameLanguage(from, targetLang)) {
|
||||
setStatus('skipped');
|
||||
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;
|
||||
}
|
||||
setFromLang(from);
|
||||
@@ -154,7 +166,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
||||
// Invalidate this run's writes if inputs change / unmount.
|
||||
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
|
||||
// toggling back on is instant).
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||
import { manualDndAtom } from '../state/manualDnd';
|
||||
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const QUIET_TICK_MS = 30_000;
|
||||
|
||||
export type NotificationsQuietInputs = {
|
||||
focusAssistActive: boolean;
|
||||
manualDnd: boolean;
|
||||
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
|
||||
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
||||
// components.
|
||||
export function useNotificationsQuiet(): boolean {
|
||||
// [Gitea #325] The quiet-hours window and the snooze expiry depend on the
|
||||
// clock, but the long-lived notifiers using this hook rarely re-render, so a
|
||||
// window starting (or a 30-minute pause ending) while the app stayed open was
|
||||
// never noticed. Re-evaluate on a tick, like the night-light overlay.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const manualDnd = useAtomValue(manualDndAtom);
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { SetPresence } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||
@@ -90,11 +91,21 @@ export function usePresenceUpdater() {
|
||||
return status;
|
||||
};
|
||||
|
||||
// [Gitea #282] A one-off PUT /presence doesn't stick: the SDK long-polls
|
||||
// /sync with set_presence=online by default and Synapse treats every sync
|
||||
// as "online", so Invisible / Idle / DND flipped back within seconds. Tell
|
||||
// the sync loop which presence to report too (as Element does).
|
||||
const syncPresence = (presence: SetPresence) => {
|
||||
mx.setSyncPresence(presence).catch(() => undefined);
|
||||
};
|
||||
|
||||
const setOnline = async () => {
|
||||
syncPresence(SetPresence.Online);
|
||||
const status = await resolveStatus();
|
||||
return send({ presence: 'online', ...(status ? { status_msg: status } : {}) }, 'online');
|
||||
};
|
||||
const setUnavailable = async (statusMsg?: string) => {
|
||||
syncPresence(SetPresence.Unavailable);
|
||||
const status = await resolveStatus();
|
||||
return send(
|
||||
{
|
||||
@@ -104,7 +115,10 @@ export function usePresenceUpdater() {
|
||||
'unavailable',
|
||||
);
|
||||
};
|
||||
const setOffline = () => send({ presence: 'offline', status_msg: '' }, 'offline');
|
||||
const setOffline = () => {
|
||||
syncPresence(SetPresence.Offline);
|
||||
return send({ presence: 'offline', status_msg: '' }, 'offline');
|
||||
};
|
||||
|
||||
// Manual presence overrides — no activity tracking needed.
|
||||
if (hidePresence || presenceStatus === 'invisible') {
|
||||
@@ -167,13 +181,17 @@ export function usePresenceUpdater() {
|
||||
// it isn't usable here. fetch(..., { keepalive: true }) lets the request outlive the
|
||||
// page and is the correct mechanism for an authed endpoint. (keepalive bodies are
|
||||
// capped at 64KB, which this tiny payload is well under.)
|
||||
// [Gitea #339] Synapse treats a missing status_msg as "clear it", so a bare
|
||||
// { presence: 'offline' } wiped the user's custom status for everyone whenever
|
||||
// any tab closed or reloaded. Send the current status along with it.
|
||||
const status = readStatus();
|
||||
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ presence: 'offline' }),
|
||||
body: JSON.stringify({ presence: 'offline', ...(status ? { status_msg: status } : {}) }),
|
||||
keepalive: true,
|
||||
}).catch((err) => warnPresenceFailure('offline (pagehide)', err));
|
||||
};
|
||||
|
||||
@@ -102,6 +102,22 @@ export const useLocalRoomName = (room: Room): string => {
|
||||
return localNames[room.roomId] ?? name;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
|
||||
* (quick switcher, forward/share pickers) so they use the name the user sees in
|
||||
* the sidebar, not the server name.
|
||||
*/
|
||||
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
|
||||
const mx = useMatrixClient();
|
||||
return roomNamesStore.useValue(mx);
|
||||
};
|
||||
|
||||
/** Non-hook read for notification paths (toast / OS notification titles). */
|
||||
export const getLocalRoomName = (
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
|
||||
|
||||
export const useHasLocalRoomName = (roomId: string): boolean => {
|
||||
const mx = useMatrixClient();
|
||||
const localNames = roomNamesStore.useValue(mx);
|
||||
|
||||
@@ -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 { Icons } from 'folds';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
import { useRoomCallPolicy } from './useRoomCallPolicy';
|
||||
|
||||
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
|
||||
* closed, no frames for a while, or a long share with nobody watching.
|
||||
*/
|
||||
export function useScreenshareNotices(embed: CallEmbed): void {
|
||||
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(
|
||||
() =>
|
||||
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
||||
'io.lotus.screenshare_notice',
|
||||
(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;
|
||||
setToast({
|
||||
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { kumaUrls, parseKumaStatus, ServerStatus, StatusPageConfig } from '../utils/kumaStatus';
|
||||
|
||||
/** Normal poll, and while a problem is showing (so recovery clears quickly). */
|
||||
export const STATUS_POLL_MS = 5 * 60 * 1000;
|
||||
export const STATUS_POLL_PROBLEM_MS = 60 * 1000;
|
||||
const STATUS_FETCH_TIMEOUT_MS = 10 * 1000;
|
||||
const STATUS_BACKOFF_MAX_MS = 15 * 60 * 1000;
|
||||
|
||||
const fetchJson = async (url: string, signal: AbortSignal): Promise<unknown> => {
|
||||
const res = await fetch(url, { cache: 'no-store', credentials: 'omit', signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json();
|
||||
};
|
||||
|
||||
export const hasProblem = (s: ServerStatus | null): boolean =>
|
||||
!!s &&
|
||||
(s.homeserver === 'down' || s.calls === 'down' || s.login === 'down' || s.maintenance.length > 0);
|
||||
|
||||
/**
|
||||
* [Gitea #124] Poll the homeserver's Kuma status page (none configured → no
|
||||
* requests, null). Only while the page is visible; every 5 min, every 60 s
|
||||
* while something is wrong, and at once when `urgent` turns true (this
|
||||
* client's connection dropped). Any failure → null ("no banner"), with
|
||||
* backoff: Kuma being unreachable must never look like Matrix being down.
|
||||
*/
|
||||
export const useServerStatus = (
|
||||
page: StatusPageConfig | undefined,
|
||||
urgent = false,
|
||||
): ServerStatus | null => {
|
||||
const [status, setStatus] = useState<ServerStatus | null>(null);
|
||||
const pageKey = page ? `${page.url}/${page.slug}` : '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!page) {
|
||||
setStatus(null);
|
||||
return undefined;
|
||||
}
|
||||
const urls = kumaUrls(page);
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let controller: AbortController | undefined;
|
||||
let failures = 0;
|
||||
let stopped = false;
|
||||
let last: ServerStatus | null = null;
|
||||
|
||||
const schedule = (ms: number) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(poll, ms);
|
||||
};
|
||||
|
||||
async function poll() {
|
||||
if (stopped) return;
|
||||
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const abort = controller;
|
||||
const timeout = setTimeout(() => abort.abort(), STATUS_FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const [pageJson, heartbeatJson] = await Promise.all([
|
||||
fetchJson(urls.page, abort.signal),
|
||||
fetchJson(urls.heartbeat, abort.signal),
|
||||
]);
|
||||
if (stopped) return;
|
||||
failures = 0;
|
||||
last = parseKumaStatus(pageJson, heartbeatJson, page!.groups);
|
||||
setStatus(last);
|
||||
// While this client's connection is lost, keep asking every minute: that
|
||||
// is how it learns Kuma has checked the server since the drop.
|
||||
schedule(hasProblem(last) || urgent ? STATUS_POLL_PROBLEM_MS : STATUS_POLL_MS);
|
||||
} catch {
|
||||
if (stopped) return;
|
||||
failures += 1;
|
||||
last = null;
|
||||
setStatus(null);
|
||||
schedule(Math.min(STATUS_POLL_PROBLEM_MS * 2 ** failures, STATUS_BACKOFF_MAX_MS));
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
const onVisible = () => {
|
||||
if (document.visibilityState === 'visible') poll();
|
||||
};
|
||||
document.addEventListener('visibilitychange', onVisible);
|
||||
poll();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
controller?.abort();
|
||||
document.removeEventListener('visibilitychange', onVisible);
|
||||
};
|
||||
// pageKey identifies the page; the object itself is rebuilt from config.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageKey, urgent]);
|
||||
|
||||
return status;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user