Compare commits
76
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1511cb0b97 | ||
|
|
3e11a6fc0b | ||
|
|
5ce4692a7b | ||
|
|
4486f02bc6 | ||
|
|
a7c9d15824 | ||
|
|
7b3d1bad8f | ||
|
|
2c11bd3b8e | ||
|
|
6f7a8f64e2 | ||
|
|
e6e52cdc37 | ||
|
|
4969ef9b6d | ||
|
|
be71931a81 | ||
|
|
4ab5a8fd06 | ||
|
|
4e30ae763e | ||
|
|
f9265d2d90 | ||
|
|
82c5728a96 | ||
|
|
dd6cf156ba | ||
|
|
e94cdfe5dc | ||
|
|
bb2ace82eb | ||
|
|
3a74699fef | ||
|
|
7fc42b32d4 | ||
|
|
005fc9e768 | ||
|
|
33cc0de97c | ||
|
|
3bf040f17a | ||
|
|
69e05bbf8d | ||
|
|
15f2fcc4d1 | ||
|
|
8e7127a350 | ||
|
|
f30a65eb74 | ||
|
|
0a2f18e3d5 | ||
|
|
0d08ba0a55 | ||
|
|
6ae4d7adc0 | ||
|
|
f7bdd2855a | ||
|
|
26cf1204b5 | ||
|
|
e16c923bea | ||
|
|
f9172cc339 | ||
|
|
8bbb436ca7 | ||
|
|
4ddcda122a | ||
|
|
9c433d9d77 | ||
|
|
e8fd0a1112 | ||
|
|
00e27a24e6 | ||
|
|
46d6570df3 | ||
|
|
a425aa3572 | ||
|
|
d1270956ea | ||
|
|
5b5fbdb7b7 |
+95
-2
@@ -1,7 +1,7 @@
|
|||||||
# Lotus Chat — Feature Reference
|
# Lotus Chat — Feature Reference
|
||||||
|
|
||||||
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
||||||
Last updated: July 2026.
|
Last updated: October 2026.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -28,7 +28,8 @@ Last updated: July 2026.
|
|||||||
19. [Infrastructure](#infrastructure)
|
19. [Infrastructure](#infrastructure)
|
||||||
20. [Localization](#localization)
|
20. [Localization](#localization)
|
||||||
21. [Desktop App Features](#desktop-app-features)
|
21. [Desktop App Features](#desktop-app-features)
|
||||||
22. [Key Custom Files](#key-custom-files)
|
22. [June–October 2026 Additions](#juneoctober-2026-additions)
|
||||||
|
23. [Key Custom Files](#key-custom-files)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -1522,6 +1523,98 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## June–October 2026 Additions
|
||||||
|
|
||||||
|
Features shipped since the sections above were last updated (Gitea #294). Each
|
||||||
|
line names the commit that introduced it.
|
||||||
|
|
||||||
|
### Calls
|
||||||
|
|
||||||
|
- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`).
|
||||||
|
- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`).
|
||||||
|
- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`).
|
||||||
|
- **Mic level meter** on the mute button (`3449338b`, #146).
|
||||||
|
- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117).
|
||||||
|
- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128).
|
||||||
|
- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223).
|
||||||
|
- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134).
|
||||||
|
- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157).
|
||||||
|
- **Live-call dot on the space rail** (`4fe9c870`, #148).
|
||||||
|
- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162).
|
||||||
|
- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135).
|
||||||
|
- **Audio output quick-switch** in the call bar (`98c80f36`, #119).
|
||||||
|
- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143).
|
||||||
|
- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`).
|
||||||
|
- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43).
|
||||||
|
|
||||||
|
### Messaging & composer
|
||||||
|
|
||||||
|
- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138).
|
||||||
|
- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137).
|
||||||
|
- **Inline audio playback** for `m.file` audio attachments (`eb34b047`).
|
||||||
|
- **Recently used GIFs** in the GIF picker (`5b58e5fe`).
|
||||||
|
- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129).
|
||||||
|
- **Offer to format pasted code** as a code block (`75d55e86`, #107).
|
||||||
|
- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147).
|
||||||
|
- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122).
|
||||||
|
- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200).
|
||||||
|
- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127).
|
||||||
|
- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112).
|
||||||
|
- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`).
|
||||||
|
|
||||||
|
### Search & navigation
|
||||||
|
|
||||||
|
- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`).
|
||||||
|
- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`).
|
||||||
|
- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106).
|
||||||
|
- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`).
|
||||||
|
|
||||||
|
### Rooms & threads
|
||||||
|
|
||||||
|
- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`).
|
||||||
|
- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`).
|
||||||
|
- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133).
|
||||||
|
- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165).
|
||||||
|
|
||||||
|
### Media viewer
|
||||||
|
|
||||||
|
- **Swipe** between items in the full-screen viewer (`cf80729a`, #164).
|
||||||
|
- **Two-finger pinch zoom** (`39589b40`, #164).
|
||||||
|
|
||||||
|
### Profile, privacy & safety
|
||||||
|
|
||||||
|
- **Report User** (MSC4260) (`26f90087`).
|
||||||
|
- **Active now / Last active** from presence (`dea1f7af`, #150).
|
||||||
|
- **Delete all my messages** in a room (`edb46247`, #169).
|
||||||
|
- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214).
|
||||||
|
- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123).
|
||||||
|
- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124).
|
||||||
|
|
||||||
|
### Appearance
|
||||||
|
|
||||||
|
- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1).
|
||||||
|
- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2).
|
||||||
|
|
||||||
|
### Mobile & PWA
|
||||||
|
|
||||||
|
- **Saved Messages** from the room header's More Options menu (`8c711f5f`).
|
||||||
|
- **Long-press bottom sheet** of message actions (`53a80adb`, #166).
|
||||||
|
- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125).
|
||||||
|
- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155).
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203).
|
||||||
|
|
||||||
|
### Desktop app
|
||||||
|
|
||||||
|
- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4).
|
||||||
|
- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3).
|
||||||
|
- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5).
|
||||||
|
- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Key Custom Files
|
## Key Custom Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|||||||
+1
-1
@@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw
|
|||||||
|
|
||||||
## 🚫 Blocked Features (server / upstream gated)
|
## 🚫 Blocked Features (server / upstream gated)
|
||||||
|
|
||||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.)
|
||||||
|
|
||||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||||
|
|||||||
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
|
|||||||
expect(console_.unexpected()).toEqual([]);
|
expect(console_.unexpected()).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
|
||||||
|
const console_ = collectConsole(page);
|
||||||
|
const room = await createRoom(alice, 'Poll Room');
|
||||||
|
await loginUI(page, alice);
|
||||||
|
await openRoom(page, room);
|
||||||
|
const question = uniq('Lunch? ');
|
||||||
|
await page.getByRole('button', { name: 'Create poll' }).first().click();
|
||||||
|
await page.getByPlaceholder('Ask a question…').fill(question);
|
||||||
|
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
|
||||||
|
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
|
||||||
|
await page.getByRole('button', { name: 'Create Poll' }).last().click();
|
||||||
|
const poll = page.locator('[data-message-id]', { hasText: question }).last();
|
||||||
|
await expect(poll).toBeVisible();
|
||||||
|
type Ev = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
content: { 'm.relates_to'?: { event_id?: string } };
|
||||||
|
};
|
||||||
|
const events = async () =>
|
||||||
|
(
|
||||||
|
await api<{ chunk: Ev[] }>(
|
||||||
|
'GET',
|
||||||
|
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
|
||||||
|
alice.token,
|
||||||
|
)
|
||||||
|
).chunk;
|
||||||
|
// Vote once the poll has reached the server (its row may still carry the local id).
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
|
||||||
|
.toBe(true);
|
||||||
|
await poll.getByText('Tacos', { exact: true }).first().click();
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const chunk = await events();
|
||||||
|
const start = chunk.find((e) => /poll\.start/.test(e.type));
|
||||||
|
return chunk.some(
|
||||||
|
(e) =>
|
||||||
|
/poll\.response/.test(e.type) &&
|
||||||
|
e.content['m.relates_to']?.event_id === start?.event_id &&
|
||||||
|
start?.event_id.startsWith('$'),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
expect(console_.unexpected()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
|
||||||
const room = await createRoom(alice, 'Scroll Room');
|
const room = await createRoom(alice, 'Scroll Room');
|
||||||
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
|
||||||
|
|||||||
@@ -29,6 +29,32 @@ async function settled(page: Page) {
|
|||||||
await page.waitForTimeout(2000);
|
await page.waitForTimeout(2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */
|
||||||
|
async function mockAnnouncement(page: Page) {
|
||||||
|
await page.route(/\/config\.json(\?.*)?$/, async (route) => {
|
||||||
|
const res = await route.fetch();
|
||||||
|
const cfg = await res.json();
|
||||||
|
cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } };
|
||||||
|
await route.fulfill({ response: res, json: cfg });
|
||||||
|
});
|
||||||
|
await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => {
|
||||||
|
const cors = { 'access-control-allow-origin': '*' };
|
||||||
|
if (route.request().url().includes('/heartbeat/')) {
|
||||||
|
await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await route.fulfill({
|
||||||
|
headers: cors,
|
||||||
|
json: {
|
||||||
|
config: { slug: 'matrix' },
|
||||||
|
incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }],
|
||||||
|
maintenanceList: [],
|
||||||
|
publicGroupList: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test.describe('security nudge (#110, #123)', () => {
|
test.describe('security nudge (#110, #123)', () => {
|
||||||
let user: TestUser;
|
let user: TestUser;
|
||||||
|
|
||||||
@@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => {
|
|||||||
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
await expect(page.getByText('Device Verification').first()).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => {
|
||||||
|
test.setTimeout(200_000);
|
||||||
|
await mockAnnouncement(page);
|
||||||
|
await loginUI(page, user);
|
||||||
|
await pastGrace(page);
|
||||||
|
await settled(page);
|
||||||
|
// The announcement wins the one strip slot until it's dismissed.
|
||||||
|
const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' });
|
||||||
|
await expect(announcement).toBeVisible();
|
||||||
|
await expect(strip(page)).toHaveCount(0);
|
||||||
|
await announcement.getByRole('button', { name: 'Dismiss' }).click();
|
||||||
|
await page.reload();
|
||||||
|
await settled(page);
|
||||||
|
await expect(strip(page)).toContainText("Your encryption keys aren't backed up");
|
||||||
|
});
|
||||||
|
|
||||||
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
test('"Not now" snoozes it across a reload', async ({ page }) => {
|
||||||
test.setTimeout(200_000);
|
test.setTimeout(200_000);
|
||||||
await loginUI(page, user);
|
await loginUI(page, user);
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"dir": "auto",
|
"dir": "auto",
|
||||||
"lang": "en-US",
|
"lang": "en-US",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"orientation": "portrait",
|
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
"background_color": "#0a0a0a",
|
"background_color": "#0a0a0a",
|
||||||
"theme_color": "#980000",
|
"theme_color": "#980000",
|
||||||
@@ -90,7 +89,7 @@
|
|||||||
"name": "New Message",
|
"name": "New Message",
|
||||||
"short_name": "DM",
|
"short_name": "DM",
|
||||||
"description": "Open a new direct message",
|
"description": "Open a new direct message",
|
||||||
"url": "/",
|
"url": "./direct/create/",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "public/res/android/android-chrome-96x96.png",
|
"src": "public/res/android/android-chrome-96x96.png",
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getMemberName } from '../utils/room';
|
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
@@ -86,9 +86,12 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
|
|||||||
import { webRTCSupported } from '../utils/rtc';
|
import { webRTCSupported } from '../utils/rtc';
|
||||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||||
import { zIndices } from '../styles/zIndex';
|
import { zIndices } from '../styles/zIndex';
|
||||||
|
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||||
|
|
||||||
const PIP_MIN_W = 200;
|
const PIP_MIN_W = 200;
|
||||||
const PIP_MIN_H = 112;
|
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';
|
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||||
|
|
||||||
@@ -111,6 +114,8 @@ type IncomingCallInfo = {
|
|||||||
intent?: string;
|
intent?: string;
|
||||||
notificationType: RTCNotificationType;
|
notificationType: RTCNotificationType;
|
||||||
refEventId: string;
|
refEventId: string;
|
||||||
|
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
|
||||||
|
notificationEventId?: string;
|
||||||
};
|
};
|
||||||
type IncomingCallProps = {
|
type IncomingCallProps = {
|
||||||
dm: boolean;
|
dm: boolean;
|
||||||
@@ -283,7 +288,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
|||||||
fill="Soft"
|
fill="Soft"
|
||||||
size="400"
|
size="400"
|
||||||
radii="400"
|
radii="400"
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
onClick={() =>
|
||||||
|
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||||
|
}
|
||||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B400">
|
<Text as="span" size="B400">
|
||||||
@@ -455,7 +462,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
|||||||
size="300"
|
size="300"
|
||||||
radii="300"
|
radii="300"
|
||||||
outlined
|
outlined
|
||||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
onClick={() =>
|
||||||
|
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||||
|
}
|
||||||
before={<Icon size="100" src={Icons.Cross} filled />}
|
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||||
>
|
>
|
||||||
<Text as="span" size="B300">
|
<Text as="span" size="B300">
|
||||||
@@ -496,6 +505,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
|
|
||||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||||
async (event, room, toStartOfTimeline, removed, data) => {
|
async (event, room, toStartOfTimeline, removed, data) => {
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
// only process rtc notification reference events.
|
// only process rtc notification reference events.
|
||||||
// we do not want to wait to decrypt all events.
|
// we do not want to wait to decrypt all events.
|
||||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||||
@@ -596,6 +606,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
: undefined,
|
: undefined,
|
||||||
notificationType,
|
notificationType,
|
||||||
refEventId,
|
refEventId,
|
||||||
|
notificationEventId: event.getId(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||||
@@ -604,7 +615,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
// stale RTC session). The caller's membership can arrive just after the
|
// stale RTC session). The caller's membership can arrive just after the
|
||||||
// ring, so an absent one gets a short grace period before giving up.
|
// ring, so an absent one gets a short grace period before giving up.
|
||||||
const myUserId = mx.getSafeUserId();
|
const myUserId = mx.getSafeUserId();
|
||||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
if (
|
||||||
|
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||||
|
hasActiveCallMembership(room, myUserId)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasActiveCallMembership(room, sender)) {
|
if (hasActiveCallMembership(room, sender)) {
|
||||||
@@ -613,7 +627,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (Date.now() >= senderTs + lifetime) return;
|
if (Date.now() >= senderTs + lifetime) return;
|
||||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
if (
|
||||||
|
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||||
|
hasActiveCallMembership(room, myUserId)
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||||
@@ -683,9 +700,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||||
const related = event.getRelation()?.event_id;
|
const related = event.getRelation()?.event_id;
|
||||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
// [Gitea #313] Correct clients reference the notification; older Lotus
|
||||||
|
// builds referenced the call membership. Accept both.
|
||||||
|
if (!related || related === refEventId || related === callInfo.notificationEventId)
|
||||||
|
dismiss(); // declined elsewhere
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||||
@@ -793,6 +814,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
|||||||
useCallPolicyRevokedToast(embed, joined);
|
useCallPolicyRevokedToast(embed, joined);
|
||||||
useCallEndedToast(embed);
|
useCallEndedToast(embed);
|
||||||
useScreenshareNotices(embed);
|
useScreenshareNotices(embed);
|
||||||
|
useDenoiseNotices(embed);
|
||||||
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||||
// as the host bar's (hidden where the server would refuse the share).
|
// as the host bar's (hidden where the server would refuse the share).
|
||||||
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
@@ -1046,8 +1068,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
|
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
|
||||||
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
|
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
|
||||||
} else {
|
} else {
|
||||||
el.style.left = `${window.innerWidth - 280 - 16}px`;
|
// [Gitea #323] First use: top-right under the room header, over the
|
||||||
el.style.top = `${window.innerHeight - 158 - 72}px`;
|
// timeline. Bottom-right sat on the composer (Send / Emoji, and on
|
||||||
|
// phones the whole message box), so typing landed on the call.
|
||||||
|
el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`;
|
||||||
|
el.style.top = `${PIP_DEFAULT_TOP}px`;
|
||||||
}
|
}
|
||||||
el.style.width = '280px';
|
el.style.width = '280px';
|
||||||
el.style.height = '158px';
|
el.style.height = '158px';
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
|||||||
export function CloseBehaviorPrompt() {
|
export function CloseBehaviorPrompt() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
||||||
|
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
|
||||||
|
// tray" would hide the window with no icon to bring it back. Older desktop
|
||||||
|
// builds lack the command; assume a tray then (the old behaviour).
|
||||||
|
const [hasTray, setHasTray] = useState(true);
|
||||||
const modalStyle = useModalStyle(420);
|
const modalStyle = useModalStyle(420);
|
||||||
|
|
||||||
// Tells the native side we're listening (see get_close_behavior).
|
// Tells the native side we're listening (see get_close_behavior).
|
||||||
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
|
|||||||
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useTauriEvent('lotus-close-requested', () => setOpen(true));
|
useTauriEvent('lotus-close-requested', () => {
|
||||||
|
const ask = tauriInvoke()?.('has_tray_host');
|
||||||
|
if (!ask) {
|
||||||
|
setOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ask
|
||||||
|
.then((v) => setHasTray(v !== false))
|
||||||
|
.catch(() => setHasTray(true))
|
||||||
|
.finally(() => setOpen(true));
|
||||||
|
});
|
||||||
|
|
||||||
const choose = useCallback(
|
const choose = useCallback(
|
||||||
(value: 'tray' | 'quit') => {
|
(value: 'tray' | 'quit') => {
|
||||||
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
|
|||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
<FocusTrap
|
<FocusTrap
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: '#close-behavior-keep',
|
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
|
||||||
// Dismissing without choosing keeps the window open; the next close
|
// Dismissing without choosing keeps the window open; the next close
|
||||||
// asks again.
|
// asks again.
|
||||||
onDeactivate: () => setOpen(false),
|
onDeactivate: () => setOpen(false),
|
||||||
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
|
|||||||
</Header>
|
</Header>
|
||||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||||
<Text priority="400" id="close-behavior-body">
|
<Text priority="400" id="close-behavior-body">
|
||||||
Lotus Chat can keep running in the system tray when you close this window, so
|
{hasTray
|
||||||
messages, notifications and calls still reach you. Open it again from the tray icon.
|
? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.'
|
||||||
|
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
|
||||||
</Text>
|
</Text>
|
||||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
|
|||||||
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
|
{hasTray ? (
|
||||||
<Text size="B400">Keep running in the tray</Text>
|
<>
|
||||||
</Button>
|
<Button
|
||||||
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
id="close-behavior-keep"
|
||||||
<Text size="B400">Quit when I close the window</Text>
|
variant="Primary"
|
||||||
</Button>
|
onClick={() => choose('tray')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Keep running in the tray</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-quit"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => choose('quit')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Quit when I close the window</Text>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-quit"
|
||||||
|
variant="Primary"
|
||||||
|
onClick={() => choose('quit')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Quit when I close the window</Text>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
id="close-behavior-keep"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => choose('tray')}
|
||||||
|
>
|
||||||
|
<Text size="B400">Keep running in the background</Text>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="T200" priority="300">
|
<Text size="T200" priority="300">
|
||||||
You can change this in Settings → General.
|
You can change this in Settings → General.
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() {
|
|||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [blockedCount, setBlockedCount] = useState(0);
|
const [blockedCount, setBlockedCount] = useState(0);
|
||||||
const snoozedUntil = useRef(0);
|
const snoozedUntil = useRef(0);
|
||||||
|
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
|
||||||
|
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
|
||||||
|
const closingForRetry = useRef(false);
|
||||||
const blocked = useRef(new Map<MatrixEvent, Room>());
|
const blocked = useRef(new Map<MatrixEvent, Room>());
|
||||||
const modalStyle = useModalStyle(440);
|
const modalStyle = useModalStyle(440);
|
||||||
const server = mx.getDomain() ?? 'Your homeserver';
|
const server = mx.getDomain() ?? 'Your homeserver';
|
||||||
@@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() {
|
|||||||
}, [consentUri]);
|
}, [consentUri]);
|
||||||
|
|
||||||
const retry = useCallback(() => {
|
const retry = useCallback(() => {
|
||||||
|
closingForRetry.current = true;
|
||||||
|
snoozedUntil.current = 0;
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
setOpened(false);
|
setOpened(false);
|
||||||
const pending = Array.from(blocked.current.entries());
|
const pending = Array.from(blocked.current.entries());
|
||||||
@@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() {
|
|||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: '#consent-review',
|
initialFocus: '#consent-review',
|
||||||
fallbackFocus: '#consent-later',
|
fallbackFocus: '#consent-later',
|
||||||
onDeactivate: later,
|
onDeactivate: () => {
|
||||||
|
if (closingForRetry.current) {
|
||||||
|
closingForRetry.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
later();
|
||||||
|
},
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
escapeDeactivates: true,
|
escapeDeactivates: true,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { forwardRef, useCallback } from 'react';
|
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||||
import { useAtom } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||||
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
|||||||
import { useModalStyle } from '../hooks/useModalStyle';
|
import { useModalStyle } from '../hooks/useModalStyle';
|
||||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||||
import { InfoCard } from './info-card';
|
import { InfoCard } from './info-card';
|
||||||
|
import { ScheduledMessage } from '../state/scheduledMessages';
|
||||||
|
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
|
||||||
import {
|
import {
|
||||||
useDeviceVerificationStatus,
|
useDeviceVerificationStatus,
|
||||||
VerificationStatus,
|
VerificationStatus,
|
||||||
@@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
|
|
||||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||||
|
|
||||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
// [Gitea #322] Scheduled messages live on the server and still send after
|
||||||
useCallback(async () => {
|
// logout, where no client would show them. Say so, and offer to cancel.
|
||||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
useEffect(() => {
|
||||||
// everyone else sees you "in call" until it times out.
|
let alive = true;
|
||||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
fetchScheduledMessages(mx)
|
||||||
await hangupCallAndWait(mx, callEmbed);
|
.then((list) => alive && setScheduled(list))
|
||||||
setCallEmbed(undefined);
|
.catch(() => undefined);
|
||||||
}
|
return () => {
|
||||||
await logoutClient(mx);
|
alive = false;
|
||||||
}, [mx, callEmbed, setCallEmbed]),
|
};
|
||||||
|
}, [mx]);
|
||||||
|
|
||||||
|
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
|
||||||
|
useCallback(
|
||||||
|
async (cancelScheduled?: boolean) => {
|
||||||
|
if (cancelScheduled) {
|
||||||
|
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
|
||||||
|
}
|
||||||
|
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||||
|
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||||
|
// everyone else sees you "in call" until it times out.
|
||||||
|
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||||
|
await hangupCallAndWait(mx, callEmbed);
|
||||||
|
setCallEmbed(undefined);
|
||||||
|
}
|
||||||
|
await logoutClient(mx);
|
||||||
|
},
|
||||||
|
[mx, callEmbed, setCallEmbed, scheduled],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||||
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{scheduled.length > 0 && (
|
||||||
|
<InfoCard
|
||||||
|
variant="Warning"
|
||||||
|
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
|
||||||
|
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||||||
{logoutState.status === AsyncStatus.Error && (
|
{logoutState.status === AsyncStatus.Error && (
|
||||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||||
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
|||||||
<Box direction="Column" gap="200">
|
<Box direction="Column" gap="200">
|
||||||
<Button
|
<Button
|
||||||
variant="Critical"
|
variant="Critical"
|
||||||
onClick={logout}
|
onClick={() => logout()}
|
||||||
disabled={ongoingLogout}
|
disabled={ongoingLogout}
|
||||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
||||||
>
|
>
|
||||||
<Text size="B400">Logout</Text>
|
<Text size="B400">
|
||||||
|
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
|
||||||
|
</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
{scheduled.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="Critical"
|
||||||
|
fill="Soft"
|
||||||
|
onClick={() => logout(true)}
|
||||||
|
disabled={ongoingLogout}
|
||||||
|
>
|
||||||
|
<Text size="B400">Cancel scheduled messages and logout</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
||||||
<Text size="B400">Cancel</Text>
|
<Text size="B400">Cancel</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
|||||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||||
@@ -612,7 +612,13 @@ export function EmojiBoard({
|
|||||||
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
||||||
isKeyBackward: (evt: KeyboardEvent) =>
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
||||||
escapeDeactivates: stopPropagation,
|
// [Gitea #304] The board opens with focus in its search field, where
|
||||||
|
// the shared stopPropagation helper ignores Escape. Always close here,
|
||||||
|
// without letting the key also close the room/modal underneath.
|
||||||
|
escapeDeactivates: (evt: KeyboardEvent) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmojiBoardLayout
|
<EmojiBoardLayout
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { SettingTile } from '../setting-tile';
|
|||||||
import { UsageSwitcher } from './UsageSwitcher';
|
import { UsageSwitcher } from './UsageSwitcher';
|
||||||
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
|
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
|
||||||
import * as css from './style.css';
|
import * as css from './style.css';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||||
import { CompactUploadCardRenderer } from '../upload-card';
|
import { CompactUploadCardRenderer } from '../upload-card';
|
||||||
import { UploadSuccess } from '../../state/upload';
|
import { UploadSuccess } from '../../state/upload';
|
||||||
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
||||||
@@ -60,7 +60,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
|
|||||||
[imagePack, savedImages, uploadedImages],
|
[imagePack, savedImages, uploadedImages],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickFiles = useFilePicker(
|
const pickFiles = useStrippedFilePicker(
|
||||||
useCallback(
|
useCallback(
|
||||||
(pickedFiles: File[]) => {
|
(pickedFiles: File[]) => {
|
||||||
const uniqueFiles = pickedFiles.map((file) => {
|
const uniqueFiles = pickedFiles.map((file) => {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common';
|
|||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||||
import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
||||||
import { CompactUploadCardRenderer } from '../upload-card';
|
import { CompactUploadCardRenderer } from '../upload-card';
|
||||||
@@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
|||||||
return undefined;
|
return undefined;
|
||||||
}, [imageFile]);
|
}, [imageFile]);
|
||||||
|
|
||||||
const pickFile = useFilePicker(setImageFile, false);
|
const pickFile = useStrippedFilePicker(setImageFile, false);
|
||||||
|
|
||||||
const handleRemoveUpload = useCallback(() => {
|
const handleRemoveUpload = useCallback(() => {
|
||||||
setImageFile(undefined);
|
setImageFile(undefined);
|
||||||
|
|||||||
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|||||||
import { BreakWord } from '../../styles/Text.css';
|
import { BreakWord } from '../../styles/Text.css';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { copyToClipboard } from '../../utils/dom';
|
import { copyToClipboard } from '../../utils/dom';
|
||||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
|
||||||
import { getViaServers } from '../../plugins/via-servers';
|
import { getViaServers } from '../../plugins/via-servers';
|
||||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||||
|
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
|
||||||
|
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
|
||||||
|
|
||||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
|||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const [showQr, setShowQr] = useState(false);
|
const [showQr, setShowQr] = useState(false);
|
||||||
|
|
||||||
|
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
|
||||||
|
const lotusBase = useLotusShareBase();
|
||||||
const inviteUrl = (() => {
|
const inviteUrl = (() => {
|
||||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||||
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
return room.isSpaceRoom()
|
||||||
|
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
|
||||||
|
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
|||||||
import { formatAccuracy } from '../../utils/geoShare';
|
import { formatAccuracy } from '../../utils/geoShare';
|
||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
import { previewUrlsFor } from '../../utils/linkSafety';
|
||||||
|
|
||||||
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||||
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||||
@@ -343,7 +344,13 @@ export function MText({
|
|||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body);
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||||
|
const urls = urlsMatch
|
||||||
|
? previewUrlsFor(
|
||||||
|
[...new Set(urlsMatch)],
|
||||||
|
typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -401,7 +408,13 @@ export function MEmote({
|
|||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body);
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||||
|
const urls = urlsMatch
|
||||||
|
? previewUrlsFor(
|
||||||
|
[...new Set(urlsMatch)],
|
||||||
|
typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -458,7 +471,13 @@ export function MNotice({
|
|||||||
if (typeof body !== 'string') return <BrokenContent />;
|
if (typeof body !== 'string') return <BrokenContent />;
|
||||||
const trimmedBody = trimReplyFromBody(body);
|
const trimmedBody = trimReplyFromBody(body);
|
||||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||||
|
const urls = urlsMatch
|
||||||
|
? previewUrlsFor(
|
||||||
|
[...new Set(urlsMatch)],
|
||||||
|
typeof customBody === 'string' ? customBody : undefined,
|
||||||
|
)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
||||||
import {
|
import {
|
||||||
@@ -99,7 +99,21 @@ export function PollContent({
|
|||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const roomId = room.roomId;
|
const roomId = room.roomId;
|
||||||
const eventId = mEvent.getId();
|
// [Gitea #284] A poll you just created starts with its local-echo id
|
||||||
|
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
|
||||||
|
// doesn't re-render us. Track the id so votes and the tally use the real one.
|
||||||
|
const [eventId, setEventId] = useState(() => mEvent.getId());
|
||||||
|
useEffect(() => {
|
||||||
|
const sync = () => setEventId(mEvent.getId());
|
||||||
|
sync();
|
||||||
|
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||||
|
mEvent.on(MatrixEventEvent.Status, sync);
|
||||||
|
return () => {
|
||||||
|
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||||
|
mEvent.off(MatrixEventEvent.Status, sync);
|
||||||
|
};
|
||||||
|
}, [mEvent]);
|
||||||
|
const isLocalEcho = !!eventId?.startsWith('~');
|
||||||
const senderId = mEvent.getSender();
|
const senderId = mEvent.getSender();
|
||||||
|
|
||||||
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||||
@@ -192,8 +206,9 @@ export function PollContent({
|
|||||||
const isMultiple = maxSelections > 1;
|
const isMultiple = maxSelections > 1;
|
||||||
|
|
||||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||||
const canVote = !!roomId && !!eventId && !isEnded;
|
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
|
||||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
const canEnd =
|
||||||
|
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||||
|
|
||||||
const myVotes = pending ?? tally.myVotes;
|
const myVotes = pending ?? tally.myVotes;
|
||||||
const { counts, total, voters } = tally;
|
const { counts, total, voters } = tally;
|
||||||
@@ -219,25 +234,34 @@ export function PollContent({
|
|||||||
setPending(next);
|
setPending(next);
|
||||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||||
// accepts both namespaces).
|
// accepts both namespaces).
|
||||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
try {
|
||||||
'm.selections': Array.from(next),
|
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||||
}).catch(() => setPending(null));
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
|
'm.selections': Array.from(next),
|
||||||
|
}).catch(() => setPending(null));
|
||||||
|
} catch {
|
||||||
|
setPending(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndPoll = () => {
|
const handleEndPoll = () => {
|
||||||
if (!roomId || !eventId || ending) return;
|
if (!roomId || !eventId || ending) return;
|
||||||
setEnding(true);
|
setEnding(true);
|
||||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
try {
|
||||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||||
'm.poll.end': {},
|
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||||
'm.text': 'The poll has ended.',
|
'm.poll.end': {},
|
||||||
})
|
'm.text': 'The poll has ended.',
|
||||||
.then(() => {
|
|
||||||
setConfirmEnd(false);
|
|
||||||
setEnding(false);
|
|
||||||
})
|
})
|
||||||
.catch(() => setEnding(false));
|
.then(() => {
|
||||||
|
setConfirmEnd(false);
|
||||||
|
setEnding(false);
|
||||||
|
})
|
||||||
|
.catch(() => setEnding(false));
|
||||||
|
} catch {
|
||||||
|
setEnding(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export function SettingTile({ title, description, before, after, children }: Set
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{description && (
|
||||||
<Text className={BreakWord} size="T200" priority="300">
|
// [Gitea #291] A <div>, not a <p>: many tiles pass blocks (badges,
|
||||||
|
// <Box>, <Text>) as the description, which is invalid inside a <p>.
|
||||||
|
<Text as="div" className={BreakWord} size="T200" priority="300">
|
||||||
{description}
|
{description}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -310,9 +310,13 @@ export function UploadCardRenderer({
|
|||||||
const { file } = upload;
|
const { file } = upload;
|
||||||
const fileSizeExceeded = file.size >= allowSize;
|
const fileSizeExceeded = file.size >= allowSize;
|
||||||
|
|
||||||
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
// [Gitea #291] Start in an effect, not during render: the desktop taskbar
|
||||||
startUpload();
|
// progress hook subscribes to the upload atoms, so a render-time start
|
||||||
}
|
// updated another component mid-render (React warning on every image send).
|
||||||
|
const idle = upload.status === UploadStatus.Idle;
|
||||||
|
useEffect(() => {
|
||||||
|
if (idle && !fileSizeExceeded) startUpload();
|
||||||
|
}, [idle, fileSizeExceeded, startUpload]);
|
||||||
|
|
||||||
const handleSpoiler = (marked: boolean) => {
|
const handleSpoiler = (marked: boolean) => {
|
||||||
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
GIF_PREVIEW_MAX_WIDTH,
|
GIF_PREVIEW_MAX_WIDTH,
|
||||||
gifPreviewBox,
|
gifPreviewBox,
|
||||||
} from '../../utils/gifPreviewSize';
|
} from '../../utils/gifPreviewSize';
|
||||||
|
import { sameProvider } from './embedProvider';
|
||||||
|
|
||||||
const linkStyles = { color: color.Success.Main };
|
const linkStyles = { color: color.Success.Main };
|
||||||
|
|
||||||
@@ -2497,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
|||||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||||
if (embed) return embed;
|
if (embed) return embed;
|
||||||
const ogUrl = prev['og:url'];
|
const ogUrl = prev['og:url'];
|
||||||
return typeof ogUrl === 'string' && ogUrl !== url
|
// [Gitea #326] …but only from an og:url on the posted link's own
|
||||||
|
// provider: the page sets og:url, so any site could otherwise show up
|
||||||
|
// as a playable YouTube (etc.) card that links to itself.
|
||||||
|
return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl)
|
||||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||||
: null;
|
: null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { sameProvider } from './embedProvider';
|
||||||
|
|
||||||
|
test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => {
|
||||||
|
const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||||
|
assert.equal(sameProvider('https://evil.example/free-gift', yt), false);
|
||||||
|
assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false);
|
||||||
|
assert.equal(sameProvider('https://youtu.be/abc', yt), true);
|
||||||
|
assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true);
|
||||||
|
assert.equal(
|
||||||
|
sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true);
|
||||||
|
assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true);
|
||||||
|
assert.equal(
|
||||||
|
sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(sameProvider('not a url', yt), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #326] A page's og:url is chosen by the page itself, so a phishing
|
||||||
|
* link could serve `og:url = youtube.com/watch?v=…` and render as a playable
|
||||||
|
* YouTube card linking to the attacker. Only trust an og:url on the same
|
||||||
|
* provider as the link actually posted (including the provider's own
|
||||||
|
* short-link domains).
|
||||||
|
*/
|
||||||
|
const PROVIDER_ALIASES: Record<string, string> = {
|
||||||
|
'youtu.be': 'youtube.com',
|
||||||
|
'youtube-nocookie.com': 'youtube.com',
|
||||||
|
'spotify.link': 'spotify.com',
|
||||||
|
'redd.it': 'reddit.com',
|
||||||
|
'x.com': 'twitter.com',
|
||||||
|
'twitch.tv': 'twitch.tv',
|
||||||
|
'apple.co': 'apple.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: the provider a host belongs to (last two labels, aliases folded). Hosts
|
||||||
|
* under two-part public suffixes (`*.co.uk`) share a value, which is harmless
|
||||||
|
* here: no embeddable provider lives under one.
|
||||||
|
*/
|
||||||
|
export const providerOf = (host: string): string => {
|
||||||
|
const parts = host.toLowerCase().replace(/\.$/, '').split('.');
|
||||||
|
const base = parts.slice(-2).join('.');
|
||||||
|
return PROVIDER_ALIASES[base] ?? base;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */
|
||||||
|
export function sameProvider(url: string, ogUrl: string): boolean {
|
||||||
|
try {
|
||||||
|
return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,6 +50,14 @@ type FlatClip = {
|
|||||||
* A management toggle reveals the pack editors (personal + this room, if
|
* A management toggle reveals the pack editors (personal + this room, if
|
||||||
* permitted). Space-wide packs are managed from Space settings.
|
* permitted). Space-wide packs are managed from Space settings.
|
||||||
*/
|
*/
|
||||||
|
/** [EC#13, EC#47] Why the fork refused to play a clip, for the user. */
|
||||||
|
export const soundboardRefusalText = (reason: string | undefined): string => {
|
||||||
|
if (reason === 'muted') return 'Unmute your microphone to play a soundboard clip.';
|
||||||
|
if (reason === 'not_permitted')
|
||||||
|
return "The soundboard isn't available in this voice channel (only microphone audio is allowed here).";
|
||||||
|
return 'Could not play that clip.';
|
||||||
|
};
|
||||||
|
|
||||||
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const { room } = callEmbed;
|
const { room } = callEmbed;
|
||||||
@@ -122,13 +130,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
|||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
const result = await callEmbed.control.injectAudio(url, vol, clipBytes);
|
||||||
if (!result.played) {
|
if (!result.played) {
|
||||||
// [EC#13] Refused fork-side (only reason today: local mic muted) —
|
// [EC#13, EC#47] Refused fork-side (mic muted, or the room's token
|
||||||
|
// only allows the microphone source, as in audio-only rooms) —
|
||||||
// don't play it locally either, or the user would think it went out.
|
// don't play it locally either, or the user would think it went out.
|
||||||
setError(
|
setError(soundboardRefusalText(result.reason));
|
||||||
result.reason === 'muted'
|
|
||||||
? 'Unmute your microphone to play a soundboard clip.'
|
|
||||||
: 'Could not play that clip.',
|
|
||||||
);
|
|
||||||
done();
|
done();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ import {
|
|||||||
useCallJoined,
|
useCallJoined,
|
||||||
useCallEmbedPlacementSync,
|
useCallEmbedPlacementSync,
|
||||||
useCallLoadError,
|
useCallLoadError,
|
||||||
|
useCallStart,
|
||||||
} from '../../hooks/useCallEmbed';
|
} from '../../hooks/useCallEmbed';
|
||||||
import { callEmbedAtom } from '../../state/callEmbed';
|
import { callEmbedAtom } from '../../state/callEmbed';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { PrescreenControls } from './PrescreenControls';
|
import { PrescreenControls } from './PrescreenControls';
|
||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { useRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -160,6 +161,16 @@ function CallLoadErrorMessage() {
|
|||||||
// prescreen, where they can choose to join again.
|
// prescreen, where they can choose to join again.
|
||||||
const dismiss = () => setCallEmbed(undefined);
|
const dismiss = () => setCallEmbed(undefined);
|
||||||
|
|
||||||
|
// [Gitea #301] Try again: start a fresh embed for the same room with the
|
||||||
|
// same mic/camera/sound choices (starting one disposes the hung one).
|
||||||
|
const callEmbed = useCallEmbed();
|
||||||
|
const startCall = useCallStart(useIsDirectRoom());
|
||||||
|
const retry = () => {
|
||||||
|
if (!callEmbed) return;
|
||||||
|
const { room, control } = callEmbed;
|
||||||
|
startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
className={css.CallViewContent}
|
className={css.CallViewContent}
|
||||||
@@ -173,6 +184,9 @@ function CallLoadErrorMessage() {
|
|||||||
The call failed to load. Check your connection and try again.
|
The call failed to load. Check your connection and try again.
|
||||||
</Text>
|
</Text>
|
||||||
<Box gap="200" alignItems="Center">
|
<Box gap="200" alignItems="Center">
|
||||||
|
<Button variant="Primary" size="300" radii="400" onClick={retry} disabled={!callEmbed}>
|
||||||
|
<Text size="B400">Try again</Text>
|
||||||
|
</Button>
|
||||||
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
|
||||||
<Text size="B400">Back</Text>
|
<Text size="B400">Back</Text>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
Binary file not shown.
@@ -40,7 +40,7 @@ import { useImagePackRooms } from '../../../hooks/useImagePackRooms';
|
|||||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { useFilePicker } from '../../../hooks/useFilePicker';
|
import { useStrippedFilePicker } from '../../../hooks/useStrippedFilePicker';
|
||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
@@ -68,7 +68,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
|||||||
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
|
const imagePackRooms = useImagePackRooms(room.roomId, roomToParents);
|
||||||
|
|
||||||
const [iconFile, setIconFile] = useState<File>();
|
const [iconFile, setIconFile] = useState<File>();
|
||||||
const pickFile = useFilePicker(setIconFile, false);
|
const pickFile = useStrippedFilePicker(setIconFile, false);
|
||||||
|
|
||||||
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
|
const [tagColor, setTagColor] = useState<string | undefined>(tag?.color);
|
||||||
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
|
const [tagIcon, setTagIcon] = useState<MemberPowerTagIcon | undefined>(tag?.icon);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
foldEdits,
|
||||||
filterGroupsByDateRange,
|
filterGroupsByDateRange,
|
||||||
filterGroupsByMsgType,
|
filterGroupsByMsgType,
|
||||||
filterGroupsByPinned,
|
filterGroupsByPinned,
|
||||||
@@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => {
|
|||||||
['$2'],
|
['$2'],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const sr = (eventId: string, ts: number, content: Record<string, unknown>) =>
|
||||||
|
({
|
||||||
|
rank: 1,
|
||||||
|
result: {
|
||||||
|
event_id: eventId,
|
||||||
|
room_id: '!r:x',
|
||||||
|
origin_server_ts: ts,
|
||||||
|
type: 'm.room.message',
|
||||||
|
content,
|
||||||
|
},
|
||||||
|
}) as unknown as Parameters<typeof foldEdits>[0][number];
|
||||||
|
|
||||||
|
test('foldEdits: one row per message, showing the latest edit (#288)', () => {
|
||||||
|
const edit = (id: string, ts: number, body: string) =>
|
||||||
|
sr(id, ts, {
|
||||||
|
body: `* ${body}`,
|
||||||
|
'm.new_content': { body },
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||||
|
});
|
||||||
|
const out = foldEdits([
|
||||||
|
edit('$e2', 30, 'Tuesday 11am room B'),
|
||||||
|
sr('$orig', 10, { body: 'Monday 10am room A' }),
|
||||||
|
edit('$e1', 20, 'Tuesday 10am room A'),
|
||||||
|
sr('$other', 5, { body: 'meeting notes' }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((r) => r.result.event_id),
|
||||||
|
['$orig', '$other'],
|
||||||
|
);
|
||||||
|
assert.deepEqual(out[0].result.content['m.new_content'], { body: 'Tuesday 11am room B' });
|
||||||
|
assert.equal(out[0].result.content.body, 'Monday 10am room A');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('foldEdits: an edit whose original did not match stands in for it', () => {
|
||||||
|
const out = foldEdits([
|
||||||
|
sr('$e1', 20, {
|
||||||
|
'm.new_content': { body: 'x' },
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
assert.equal(out.length, 1);
|
||||||
|
assert.equal(out[0].result.event_id, '$orig');
|
||||||
|
});
|
||||||
|
|||||||
@@ -96,10 +96,58 @@ export const filterGroupsByDateRange = (
|
|||||||
.filter((group) => group.items.length > 0);
|
.filter((group) => group.items.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #288] Server search also returns `m.replace` events, so an edited
|
||||||
|
* message showed once per version. Fold each edit into its message: one row,
|
||||||
|
* at the first position any version ranked, showing the latest text and
|
||||||
|
* pointing at the original event (so "jump" lands on the real message).
|
||||||
|
*/
|
||||||
|
export const foldEdits = (results: ISearchResult[]): ISearchResult[] => {
|
||||||
|
const targetOf = (r: ISearchResult): string => {
|
||||||
|
const rel = r.result.content?.['m.relates_to'];
|
||||||
|
return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string'
|
||||||
|
? rel.event_id
|
||||||
|
: r.result.event_id;
|
||||||
|
};
|
||||||
|
const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id;
|
||||||
|
const order: string[] = [];
|
||||||
|
const originals = new Map<string, ISearchResult>();
|
||||||
|
const latestEdit = new Map<string, ISearchResult>();
|
||||||
|
results.forEach((r) => {
|
||||||
|
const target = targetOf(r);
|
||||||
|
if (!originals.has(target) && !latestEdit.has(target)) order.push(target);
|
||||||
|
if (!isEdit(r)) {
|
||||||
|
originals.set(target, r);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const prev = latestEdit.get(target);
|
||||||
|
if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts)
|
||||||
|
latestEdit.set(target, r);
|
||||||
|
});
|
||||||
|
return order.map((target) => {
|
||||||
|
const original = originals.get(target);
|
||||||
|
const edit = latestEdit.get(target);
|
||||||
|
const newContent = edit?.result.content?.['m.new_content'];
|
||||||
|
if (original && !newContent) return original;
|
||||||
|
if (original) {
|
||||||
|
return {
|
||||||
|
...original,
|
||||||
|
result: {
|
||||||
|
...original.result,
|
||||||
|
content: { ...original.result.content, 'm.new_content': newContent },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Only an edit matched: show it as its message.
|
||||||
|
const only = edit as ISearchResult;
|
||||||
|
return { ...only, result: { ...only.result, event_id: target } };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||||
const groups: ResultGroup[] = [];
|
const groups: ResultGroup[] = [];
|
||||||
|
|
||||||
results.forEach((item) => {
|
foldEdits(results).forEach((item) => {
|
||||||
const roomId = item.result.room_id;
|
const roomId = item.result.room_id;
|
||||||
const resultItem: ResultItem = {
|
const resultItem: ResultItem = {
|
||||||
rank: item.rank,
|
rank: item.rank,
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
setRoomNotificationPreference,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
|
||||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
@@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
prevMode,
|
prevMode,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
|
||||||
unmuteRoom(mx, room.roomId);
|
} else {
|
||||||
});
|
// An indefinite mute replaces any timed one.
|
||||||
|
await clearMuteTimer(mx, room.roomId);
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { shouldResetMuteOnUnmute } from './muteTimers';
|
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
|
||||||
|
|
||||||
test('resets to Unset when the room is still Mute at expiry', () => {
|
test('resets to Unset when the room is still Mute at expiry', () => {
|
||||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
||||||
@@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute
|
|||||||
test('does not reset when the mode is already Unset', () => {
|
test('does not reset when the mode is already Unset', () => {
|
||||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => {
|
||||||
|
const entries = [
|
||||||
|
{ roomId: '!a:x', unmuteAt: 1000 },
|
||||||
|
{ roomId: '!b:x', unmuteAt: 2000 },
|
||||||
|
];
|
||||||
|
assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries);
|
||||||
|
assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [
|
||||||
|
{ roomId: '!b:x', unmuteAt: 5 },
|
||||||
|
]);
|
||||||
|
assert.deepEqual(parseMuteTimers(undefined), []);
|
||||||
|
assert.deepEqual(parseMuteTimers({ timers: null }), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
setRoomNotificationPreference,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
|
||||||
// localStorage key for timed mute timers
|
// Legacy localStorage key (before #333); migrated into account data once.
|
||||||
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||||
|
|
||||||
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||||
@@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
|||||||
|
|
||||||
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||||
|
|
||||||
export function loadMuteTimers(): MuteTimerEntry[] {
|
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
|
||||||
try {
|
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
|
||||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
const timers = (content as { timers?: unknown } | undefined)?.timers;
|
||||||
return Array.isArray(parsed) ? parsed : [];
|
if (!timers || typeof timers !== 'object') return [];
|
||||||
} catch {
|
return Object.entries(timers as Record<string, unknown>)
|
||||||
return [];
|
.filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]))
|
||||||
}
|
.map(([roomId, unmuteAt]) => ({ roomId, unmuteAt }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
/** Pure: account data content for `entries`. */
|
||||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
export const muteTimersContent = (entries: MuteTimerEntry[]) => ({
|
||||||
|
timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #333] Timed mutes live in account data: the mute itself is a
|
||||||
|
* server-side push rule on every device, so the timer must be too — kept in one
|
||||||
|
* browser's localStorage, only that device ever unmuted (and never after a
|
||||||
|
* logout there).
|
||||||
|
*/
|
||||||
|
export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] {
|
||||||
|
return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise<void> {
|
||||||
|
await mx.setAccountData(
|
||||||
|
AccountDataEvent.LotusMuteTimers as never,
|
||||||
|
muteTimersContent(entries) as never,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Move timers kept in this browser's localStorage (pre-#333) into account data. */
|
||||||
|
export async function migrateLocalMuteTimers(mx: MatrixClient): Promise<void> {
|
||||||
|
let legacy: MuteTimerEntry[] = [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
|
legacy = Array.isArray(parsed)
|
||||||
|
? parsed.filter(
|
||||||
|
(e): e is MuteTimerEntry =>
|
||||||
|
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
legacy = [];
|
||||||
|
}
|
||||||
|
if (legacy.length === 0) return;
|
||||||
|
const current = loadMuteTimers(mx);
|
||||||
|
const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))];
|
||||||
|
await saveMuteTimers(mx, merged);
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(MUTE_TIMERS_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
||||||
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
|
|||||||
RoomNotificationMode.Mute,
|
RoomNotificationMode.Mute,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
await clearMuteTimer(mx, roomId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
|
||||||
const unmuteAt = Date.now() + durationMs;
|
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
|
||||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
const timers = loadMuteTimers(mx);
|
||||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
if (!timers.some((e) => e.roomId === roomId)) return;
|
||||||
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
await saveMuteTimers(
|
||||||
|
mx,
|
||||||
|
timers.filter((e) => e.roomId !== roomId),
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record when a timed mute ends. MuteTimerRestore (on every Lotus device)
|
||||||
|
* schedules the unmute from account data, so whichever is online at expiry
|
||||||
|
* lifts it.
|
||||||
|
*/
|
||||||
|
export async function scheduleMuteTimer(
|
||||||
|
mx: MatrixClient,
|
||||||
|
roomId: string,
|
||||||
|
durationMs: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const unmuteAt = Date.now() + durationMs;
|
||||||
|
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
|
||||||
|
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { SequenceCard } from '../../components/sequence-card';
|
import { SequenceCard } from '../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||||
|
import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries';
|
||||||
|
|
||||||
// ── Policy event types ────────────────────────────────────────────────────────
|
// ── Policy event types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -38,13 +39,6 @@ type PolicyRuleContent = {
|
|||||||
recommendation?: string;
|
recommendation?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PolicyEntry = {
|
|
||||||
entity: string;
|
|
||||||
reason: string;
|
|
||||||
recommendation: string;
|
|
||||||
stateKey: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PolicyTab = 'users' | 'rooms' | 'servers';
|
type PolicyTab = 'users' | 'rooms' | 'servers';
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -53,13 +47,6 @@ function isGlob(entity: string): boolean {
|
|||||||
return entity.includes('*') || entity.includes('?');
|
return entity.includes('*') || entity.includes('?');
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendationLabel(rec: string): string {
|
|
||||||
// `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
|
|
||||||
// (pre-stabilization) recommendation still emitted by older bots.
|
|
||||||
if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban';
|
|
||||||
return rec;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all state events of a given type from a room's live forward state.
|
* Fetch all state events of a given type from a room's live forward state.
|
||||||
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
* Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type.
|
||||||
@@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
|||||||
.filter((entry) => entry.entity !== '');
|
.filter((entry) => entry.entity !== '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge policy entries from the stable and legacy event types for a rule
|
|
||||||
* kind, de-duplicating by entity+recommendation so a room that emits both a
|
|
||||||
* stable and a legacy rule for the same target isn't double-listed.
|
|
||||||
*/
|
|
||||||
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const result: PolicyEntry[] = [];
|
|
||||||
entries.forEach((entry) => {
|
|
||||||
const key = `${entry.entity} ${entry.recommendation}`;
|
|
||||||
if (seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
result.push(entry);
|
|
||||||
});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Entry row ─────────────────────────────────────────────────────────────────
|
// ── Entry row ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { dedupePolicyEntries, recommendationLabel } from './policyEntries';
|
||||||
|
|
||||||
|
const e = (entity: string, recommendation: string, stateKey = entity) => ({
|
||||||
|
entity,
|
||||||
|
reason: 'spam waves',
|
||||||
|
recommendation,
|
||||||
|
stateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupePolicyEntries: a stable rule and its legacy twin show once (#321)', () => {
|
||||||
|
const out = dedupePolicyEntries([
|
||||||
|
e('@spammer:evil.example', 'm.ban'),
|
||||||
|
e('@spammer:evil.example', 'org.matrix.mjolnir.ban'),
|
||||||
|
e('@*:spam.example', 'm.ban'),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
out.map((x) => x.entity),
|
||||||
|
['@spammer:evil.example', '@*:spam.example'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupePolicyEntries: different recommendations stay separate', () => {
|
||||||
|
assert.equal(dedupePolicyEntries([e('@a:x', 'm.ban'), e('@a:x', 'org.example.mute')]).length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recommendationLabel', () => {
|
||||||
|
assert.equal(recommendationLabel('m.ban'), 'Ban');
|
||||||
|
assert.equal(recommendationLabel('org.matrix.mjolnir.ban'), 'Ban');
|
||||||
|
assert.equal(recommendationLabel('org.example.mute'), 'org.example.mute');
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type PolicyEntry = {
|
||||||
|
entity: string;
|
||||||
|
reason: string;
|
||||||
|
recommendation: string;
|
||||||
|
stateKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy
|
||||||
|
* (pre-stabilization) recommendation still emitted by older bots.
|
||||||
|
*/
|
||||||
|
export const normalizeRecommendation = (rec: string): string =>
|
||||||
|
rec === 'org.matrix.mjolnir.ban' ? 'm.ban' : rec;
|
||||||
|
|
||||||
|
export function recommendationLabel(rec: string): string {
|
||||||
|
if (normalizeRecommendation(rec) === 'm.ban') return 'Ban';
|
||||||
|
return rec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge stable and legacy rule lists, one entry per entity + recommendation.
|
||||||
|
* [Gitea #321] Keyed on the normalised recommendation: a bot publishing both
|
||||||
|
* `m.ban` and its `org.matrix.mjolnir.ban` twin showed the rule twice.
|
||||||
|
*/
|
||||||
|
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: PolicyEntry[] = [];
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const key = `${entry.entity} ${normalizeRecommendation(entry.recommendation)}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
result.push(entry);
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ import {
|
|||||||
isMembershipChanged,
|
isMembershipChanged,
|
||||||
reactionOrEditEvent,
|
reactionOrEditEvent,
|
||||||
sendRoomEvent,
|
sendRoomEvent,
|
||||||
|
isFromFilteredTimelineSet,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { getLastEditDiff } from '../../utils/editDiff';
|
import { getLastEditDiff } from '../../utils/editDiff';
|
||||||
import { tick } from '../../utils/haptics';
|
import { tick } from '../../utils/haptics';
|
||||||
@@ -138,7 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
import { isRingableRoom } from '../../utils/callRing';
|
import { declinedRing, isRingableRoom } from '../../utils/callRing';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
@@ -410,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||||
|
// [Gitea #259] The media gallery / activity log register filtered timeline
|
||||||
|
// sets whose events the room re-emits as live: don't count them twice.
|
||||||
|
if (isFromFilteredTimelineSet(data)) return;
|
||||||
onArrive(mEvent);
|
onArrive(mEvent);
|
||||||
};
|
};
|
||||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||||
@@ -2037,6 +2041,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// Same rule as ringing: no "Missed call" in voice rooms or space
|
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||||
// channels, where starting a call is just joining the channel.
|
// channels, where starting a call is just joining the channel.
|
||||||
if (!isRingableRoom(room, direct)) return null;
|
if (!isRingableRoom(room, direct)) return null;
|
||||||
|
// [Gitea #313] A call we declined isn't a missed call.
|
||||||
|
const ringRef = mEvent.getRelation()?.event_id;
|
||||||
|
if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null;
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
const mention =
|
const mention =
|
||||||
content['m.mentions']?.room ||
|
content['m.mentions']?.room ||
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk';
|
|||||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
import {
|
||||||
|
cancelScheduledMessage,
|
||||||
|
fetchScheduledMessages,
|
||||||
|
groupScheduledByRoom,
|
||||||
|
sendScheduledMessageNow,
|
||||||
|
} from '../../utils/scheduledMessages';
|
||||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||||
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
import { formatFriendlyDateTime } from '../../utils/datetimeInput';
|
||||||
@@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
|||||||
|
|
||||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||||
|
|
||||||
|
// [Gitea #322] The server is the source of truth: messages scheduled on
|
||||||
|
// another device (or before a logout) show up here and can be cancelled.
|
||||||
|
// localStorage is only a cache for the first paint; on error we keep it.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => {
|
||||||
|
fetchScheduledMessages(mx)
|
||||||
|
.then((list) => {
|
||||||
|
if (alive) setScheduledMessages(groupScheduledByRoom(list));
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
window.addEventListener('focus', load);
|
||||||
|
return () => {
|
||||||
|
alive = false;
|
||||||
|
window.removeEventListener('focus', load);
|
||||||
|
};
|
||||||
|
}, [mx, roomId, setScheduledMessages]);
|
||||||
|
|
||||||
// Remove scheduled messages whose time has passed
|
// Remove scheduled messages whose time has passed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return undefined;
|
if (messages.length === 0) return undefined;
|
||||||
|
|||||||
@@ -47,8 +47,12 @@ import {
|
|||||||
recentForwardTargetsAtom,
|
recentForwardTargetsAtom,
|
||||||
addRecentForwardTarget,
|
addRecentForwardTarget,
|
||||||
} from '../../../state/recentForwardTargets';
|
} from '../../../state/recentForwardTargets';
|
||||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
import {
|
||||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
buildForwardContent,
|
||||||
|
buildPlaintextAttachmentContent,
|
||||||
|
forwardEncryptionWarning,
|
||||||
|
} from './forwardContent';
|
||||||
|
import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -275,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
// Selection persists across query changes: a room selected then filtered out
|
// Selection persists across query changes: a room selected then filtered out
|
||||||
// of the rendered slice stays selected.
|
// of the rendered slice stays selected.
|
||||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||||
|
const encryptionWarning = forwardEncryptionWarning(
|
||||||
|
!!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(),
|
||||||
|
[...selectedRoomIds]
|
||||||
|
.map((id) => mx.getRoom(id))
|
||||||
|
.filter((r): r is Room => !!r && !r.hasEncryptionStateEvent())
|
||||||
|
// The name the user sees (their own "Rename for me" name, if set).
|
||||||
|
.map((r) => getLocalRoomName(mx, r.roomId) ?? r.name),
|
||||||
|
);
|
||||||
|
|
||||||
const toggleRoom = useCallback((roomId: string) => {
|
const toggleRoom = useCallback((roomId: string) => {
|
||||||
setSelectedRoomIds((prev) => {
|
setSelectedRoomIds((prev) => {
|
||||||
@@ -590,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
shrink="No"
|
shrink="No"
|
||||||
direction="Column"
|
direction="Column"
|
||||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||||
|
gap="200"
|
||||||
>
|
>
|
||||||
|
{encryptionWarning && (
|
||||||
|
<Text size="T200" role="note" style={{ color: color.Warning.Main }}>
|
||||||
|
{encryptionWarning}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="Primary"
|
variant="Primary"
|
||||||
size="400"
|
size="400"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
|
||||||
import { buildForwardContent, getForwardedMeta } from './forwardContent';
|
import { buildForwardContent, forwardEncryptionWarning, getForwardedMeta } from './forwardContent';
|
||||||
|
|
||||||
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
// Pure content builder buildForwardContent: refuses undecryptable events, forwards
|
||||||
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
// the latest edit (`m.new_content`), and strips reply fallbacks + `m.relates_to`.
|
||||||
@@ -171,3 +171,16 @@ test('getForwardedMeta rejects malformed metadata', () => {
|
|||||||
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
assert.equal(getForwardedMeta({ 'io.lotus.forwarded': { sender: 1 } }), undefined);
|
||||||
assert.equal(getForwardedMeta({}), undefined);
|
assert.equal(getForwardedMeta({}), undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('forwardEncryptionWarning: only from an encrypted room into unencrypted ones (#330)', () => {
|
||||||
|
assert.equal(forwardEncryptionWarning(false, ['general']), undefined);
|
||||||
|
assert.equal(forwardEncryptionWarning(true, []), undefined);
|
||||||
|
assert.match(
|
||||||
|
forwardEncryptionWarning(true, ['general']) ?? '',
|
||||||
|
/^general isn't encrypted\..*that room, along with who originally wrote it\.$/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
forwardEncryptionWarning(true, ['a', 'b']) ?? '',
|
||||||
|
/^2 of the rooms you picked aren't encrypted\..*those rooms/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,3 +122,23 @@ export async function buildPlaintextAttachmentContent(
|
|||||||
|
|
||||||
return plainContent;
|
return plainContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #330] The note shown when forwarding out of an encrypted room into
|
||||||
|
* rooms that aren't: the text (and a re-uploaded attachment) becomes readable
|
||||||
|
* by the server, and the original author is shown with it. Undefined when
|
||||||
|
* there's nothing to warn about.
|
||||||
|
*/
|
||||||
|
export function forwardEncryptionWarning(
|
||||||
|
sourceEncrypted: boolean,
|
||||||
|
unencryptedDestNames: string[],
|
||||||
|
): string | undefined {
|
||||||
|
if (!sourceEncrypted || unencryptedDestNames.length === 0) return undefined;
|
||||||
|
const where =
|
||||||
|
unencryptedDestNames.length === 1
|
||||||
|
? `${unencryptedDestNames[0]} isn't encrypted.`
|
||||||
|
: `${unencryptedDestNames.length} of the rooms you picked aren't encrypted.`;
|
||||||
|
return `${where} The forwarded message (and any attachment) will be readable by the server and everyone in ${
|
||||||
|
unencryptedDestNames.length === 1 ? 'that room' : 'those rooms'
|
||||||
|
}, along with who originally wrote it.`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
|||||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||||
|
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||||
|
|
||||||
type PendingAsk = {
|
type PendingAsk = {
|
||||||
requests: WidgetPermissionRequest[];
|
requests: WidgetPermissionRequest[];
|
||||||
@@ -111,6 +112,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
|||||||
};
|
};
|
||||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||||
if (toStart || removed || !data?.liveEvent) return;
|
if (toStart || removed || !data?.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||||
else feed(ev);
|
else feed(ev);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useKeyBackupInfo, useKeyBackupStatusChange } from '../../hooks/useKeyBa
|
|||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||||
import { pickBanner } from '../../utils/kumaStatus';
|
import { pickBanner } from '../../utils/kumaStatus';
|
||||||
import {
|
import {
|
||||||
dismissNudge,
|
dismissNudge,
|
||||||
@@ -66,6 +66,7 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
const backupInfo = useKeyBackupInfo(crypto);
|
const backupInfo = useKeyBackupInfo(crypto);
|
||||||
const backupActive = useBackupActive(crypto);
|
const backupActive = useBackupActive(crypto);
|
||||||
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
const { status: serverStatus, syncLost } = useAtomValue(serverStatusAtom);
|
||||||
|
const statusDismissed = useAtomValue(statusDismissedAtom);
|
||||||
const requestSettings = useSetAtom(settingsRequestAtom);
|
const requestSettings = useSetAtom(settingsRequestAtom);
|
||||||
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
// Not urgent: wait until sync has settled (two SYNCING in a row), so it
|
||||||
// never stacks under the "Connecting…" strip at startup.
|
// never stacks under the "Connecting…" strip at startup.
|
||||||
@@ -91,7 +92,11 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// One strip at a time: outages, maintenance and a lost connection win.
|
// One strip at a time: outages, maintenance and a lost connection win.
|
||||||
const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client' });
|
// [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't
|
||||||
|
// on screen and must not keep the nudge hidden for as long as it's posted.
|
||||||
|
const otherStrip =
|
||||||
|
syncLost ||
|
||||||
|
!!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) });
|
||||||
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now()))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useAtomValue, useSetAtom } from 'jotai';
|
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||||
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
import { MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
|
||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
@@ -7,20 +7,9 @@ import { useClientConfig } from '../../hooks/useClientConfig';
|
|||||||
import { useSyncState } from '../../hooks/useSyncState';
|
import { useSyncState } from '../../hooks/useSyncState';
|
||||||
import { useServerStatus } from '../../hooks/useServerStatus';
|
import { useServerStatus } from '../../hooks/useServerStatus';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom, statusDismissedAtom } from '../../state/serverStatus';
|
||||||
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
|
||||||
|
import { homeserverStalledAtom, useHomeserverStall } from '../../hooks/useHomeserverStall';
|
||||||
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
|
||||||
const DISMISS_MAX = 50;
|
|
||||||
|
|
||||||
const readDismissed = (): string[] => {
|
|
||||||
try {
|
|
||||||
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
|
||||||
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const bannerIcon = (b: StripContent): IconSrc => {
|
const bannerIcon = (b: StripContent): IconSrc => {
|
||||||
if (b.kind === 'maintenance') return Icons.Setting;
|
if (b.kind === 'maintenance') return Icons.Setting;
|
||||||
@@ -163,7 +152,12 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
|||||||
});
|
});
|
||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
const syncLost = lostAt !== null;
|
// [Gitea #317] A hung server keeps sync "SYNCING" for up to 110 s; the stall
|
||||||
|
// watchdog flags it sooner, and Kuma is asked at once.
|
||||||
|
const stalled = useHomeserverStall(mx);
|
||||||
|
const setStalled = useSetAtom(homeserverStalledAtom);
|
||||||
|
useEffect(() => setStalled(stalled), [setStalled, stalled]);
|
||||||
|
const syncLost = lostAt !== null || stalled;
|
||||||
const status = useServerStatus(page, syncLost);
|
const status = useServerStatus(page, syncLost);
|
||||||
const setAtom = useSetAtom(serverStatusAtom);
|
const setAtom = useSetAtom(serverStatusAtom);
|
||||||
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
|
||||||
@@ -173,22 +167,14 @@ export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
|
|||||||
/** The logged-in app's status strip. */
|
/** The logged-in app's status strip. */
|
||||||
export function ServerStatusBanner() {
|
export function ServerStatusBanner() {
|
||||||
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
const { status, syncLost } = useAtomValue(serverStatusAtom);
|
||||||
const [dismissed, setDismissed] = useState(readDismissed);
|
const [dismissed, dismissKey] = useAtom(statusDismissedAtom);
|
||||||
const banner = pickBanner(status, {
|
const banner = pickBanner(status, {
|
||||||
syncLost,
|
syncLost,
|
||||||
where: 'client',
|
where: 'client',
|
||||||
dismissed: new Set(dismissed),
|
dismissed: new Set(dismissed),
|
||||||
});
|
});
|
||||||
if (!banner) return null;
|
if (!banner) return null;
|
||||||
const dismiss = () => {
|
const dismiss = () => dismissKey(banner.key);
|
||||||
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
|
|
||||||
try {
|
|
||||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
|
||||||
} catch {
|
|
||||||
// storage unavailable — dismissed for this session only
|
|
||||||
}
|
|
||||||
setDismissed(next);
|
|
||||||
};
|
|
||||||
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
return <StatusStrip banner={banner} onDismiss={dismiss} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -596,10 +596,6 @@ function Appearance() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
|
||||||
<DesktopChromeSetting />
|
|
||||||
<AutostartSetting />
|
|
||||||
<KeychainMirrorSetting />
|
|
||||||
|
|
||||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Twitter Emoji"
|
title="Twitter Emoji"
|
||||||
@@ -2856,6 +2852,23 @@ function PackageUpdate({ help }: { help: NonNullable<ReturnType<typeof packageUp
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #290] Desktop-only settings in their own section (they used to sit
|
||||||
|
* inside Appearance, between Monochrome Mode and Twitter Emoji). Renders
|
||||||
|
* nothing outside the desktop app.
|
||||||
|
*/
|
||||||
|
function DesktopSettings() {
|
||||||
|
if (!isTauriEnv()) return null;
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Desktop</Text>
|
||||||
|
<AutostartSetting />
|
||||||
|
<KeychainMirrorSetting />
|
||||||
|
<DesktopChromeSetting />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function AppUpdates() {
|
function AppUpdates() {
|
||||||
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
const { isTauri, status, check, install, installKind } = useTauriUpdater();
|
||||||
if (!isTauri) return null;
|
if (!isTauri) return null;
|
||||||
@@ -2964,6 +2977,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<StorageUsage />
|
<StorageUsage />
|
||||||
<SettingsSyncSection />
|
<SettingsSyncSection />
|
||||||
<Calls />
|
<Calls />
|
||||||
|
<DesktopSettings />
|
||||||
<AppUpdates />
|
<AppUpdates />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ import { settingsAtom } from '../../state/settings';
|
|||||||
import { isMacOS } from '../../utils/user-agent';
|
import { isMacOS } from '../../utils/user-agent';
|
||||||
import { KeySymbol } from '../../utils/key-symbol';
|
import { KeySymbol } from '../../utils/key-symbol';
|
||||||
import * as css from './KeyboardShortcutsDialog.css';
|
import * as css from './KeyboardShortcutsDialog.css';
|
||||||
|
import { BLOCK_HOTKEYS, INLINE_HOTKEYS } from '../../components/editor/keyboard';
|
||||||
|
import { MarkType } from '../../components/editor/types';
|
||||||
|
import { BLOCK_LABELS, MARK_LABELS, hotkeyKeys, keyCodeLabel } from './shortcutLabels';
|
||||||
|
|
||||||
/** Global open-state for the keyboard shortcuts help dialog. */
|
/** Global open-state for the keyboard shortcuts help dialog. */
|
||||||
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
export const keyboardShortcutsDialogAtom = atom<boolean>(false);
|
||||||
@@ -98,6 +101,9 @@ export function KeyboardShortcutsDialog() {
|
|||||||
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
const { open, closeDialog } = useKeyboardShortcutsDialog();
|
||||||
const modalStyle = useModalStyle(480);
|
const modalStyle = useModalStyle(480);
|
||||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
|
const [pttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
|
const [pttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
|
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
|
||||||
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
const modKey = isMacOS() ? KeySymbol.Command : 'Ctrl';
|
||||||
|
|
||||||
@@ -109,6 +115,8 @@ export function KeyboardShortcutsDialog() {
|
|||||||
rows: [
|
rows: [
|
||||||
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
{ description: 'Show keyboard shortcuts', keys: ['?'] },
|
||||||
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
|
{ description: 'Close open panel, otherwise mark room as read', keys: [KeySymbol.Escape] },
|
||||||
|
// [Gitea #334] the quick switcher / search was missing
|
||||||
|
{ description: 'Quick switcher: jump to a room or person', keys: [modKey, 'K'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -124,6 +132,26 @@ export function KeyboardShortcutsDialog() {
|
|||||||
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
|
keys: enterForNewline ? ['Enter'] : [KeySymbol.Shift, 'Enter'],
|
||||||
},
|
},
|
||||||
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
|
{ description: 'Send message (always)', keys: [modKey, 'Enter'] },
|
||||||
|
{ description: 'Edit your last message (empty composer)', keys: ['↑'] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// [Gitea #334] generated from the editor's own bindings so they can't drift
|
||||||
|
title: 'Composer formatting',
|
||||||
|
rows: [
|
||||||
|
...Object.entries(INLINE_HOTKEYS).map(([hotkey, mark]) => ({
|
||||||
|
description:
|
||||||
|
mark === MarkType.StrikeThrough
|
||||||
|
? `${MARK_LABELS[mark]} (not the browser's Save)`
|
||||||
|
: MARK_LABELS[mark],
|
||||||
|
keys: hotkeyKeys(hotkey, modKey),
|
||||||
|
})),
|
||||||
|
{ description: 'Heading 1 / 2 / 3', keys: [modKey, '1 / 2 / 3'] },
|
||||||
|
...Object.entries(BLOCK_HOTKEYS).map(([hotkey, block]) => ({
|
||||||
|
description: BLOCK_LABELS[block] ?? block,
|
||||||
|
keys: hotkeyKeys(hotkey, modKey),
|
||||||
|
})),
|
||||||
|
{ description: 'Clear formatting', keys: [modKey, 'E'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -132,6 +160,19 @@ export function KeyboardShortcutsDialog() {
|
|||||||
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
|
{ description: 'Reveal message actions (react, reply, more)', keys: ['Hover / focus'] },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// [Gitea #334] the user's own bindings (Settings → Calls)
|
||||||
|
title: 'Calls',
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
description: pttMode
|
||||||
|
? 'Push to talk (hold)'
|
||||||
|
: 'Push to talk (hold; turn on in Settings → Calls)',
|
||||||
|
keys: [keyCodeLabel(pttKey)],
|
||||||
|
},
|
||||||
|
{ description: 'Deafen / undeafen', keys: [keyCodeLabel(deafenKey)] },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { hotkeyKeys, keyCodeLabel } from './shortcutLabels';
|
||||||
|
|
||||||
|
test('hotkeyKeys (#334)', () => {
|
||||||
|
assert.deepEqual(hotkeyKeys('mod+b', 'Ctrl'), ['Ctrl', 'B']);
|
||||||
|
assert.deepEqual(hotkeyKeys("mod+'", '⌘'), ['⌘', "'"]);
|
||||||
|
assert.deepEqual(hotkeyKeys('mod+[', 'Ctrl'), ['Ctrl', '[']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keyCodeLabel (#334)', () => {
|
||||||
|
assert.equal(keyCodeLabel('KeyM'), 'M');
|
||||||
|
assert.equal(keyCodeLabel('Digit1'), '1');
|
||||||
|
assert.equal(keyCodeLabel('Space'), 'Space');
|
||||||
|
assert.equal(keyCodeLabel(''), 'Not set');
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { BlockType, MarkType } from '../../components/editor/types';
|
||||||
|
|
||||||
|
/** Pure: an is-hotkey string ('mod+b', "mod+'") as keycap labels. */
|
||||||
|
export const hotkeyKeys = (hotkey: string, modKey: string): string[] =>
|
||||||
|
hotkey.split('+').map((k) => (k === 'mod' ? modKey : k.length === 1 ? k.toUpperCase() : k));
|
||||||
|
|
||||||
|
/** Pure: a KeyboardEvent.code ('KeyM', 'Digit1', 'Space') as a keycap label. */
|
||||||
|
export const keyCodeLabel = (code: string | undefined): string => {
|
||||||
|
if (!code) return 'Not set';
|
||||||
|
if (/^Key[A-Z]$/.test(code)) return code.slice(3);
|
||||||
|
if (/^Digit\d$/.test(code)) return code.slice(5);
|
||||||
|
return code;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MARK_LABELS: Record<MarkType, string> = {
|
||||||
|
[MarkType.Bold]: 'Bold',
|
||||||
|
[MarkType.Italic]: 'Italic',
|
||||||
|
[MarkType.Underline]: 'Underline',
|
||||||
|
[MarkType.StrikeThrough]: 'Strikethrough',
|
||||||
|
[MarkType.Code]: 'Inline code',
|
||||||
|
[MarkType.Spoiler]: 'Spoiler',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BLOCK_LABELS: Partial<Record<BlockType, string>> = {
|
||||||
|
[BlockType.OrderedList]: 'Numbered list',
|
||||||
|
[BlockType.UnorderedList]: 'Bulleted list',
|
||||||
|
[BlockType.BlockQuote]: 'Quote',
|
||||||
|
[BlockType.CodeBlock]: 'Code block',
|
||||||
|
};
|
||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
CALL_SESSION_HEARTBEAT_MS,
|
CALL_SESSION_HEARTBEAT_MS,
|
||||||
clearCallSession,
|
clearCallSession,
|
||||||
decideRejoin,
|
decideRejoin,
|
||||||
|
othersInCall,
|
||||||
|
rejoinToastBody,
|
||||||
readCallSession,
|
readCallSession,
|
||||||
writeCallSession,
|
writeCallSession,
|
||||||
} from '../utils/callRejoin';
|
} from '../utils/callRejoin';
|
||||||
@@ -112,6 +114,27 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
|||||||
};
|
};
|
||||||
dismissToast(TOAST_ID);
|
dismissToast(TOAST_ID);
|
||||||
clearCallSession();
|
clearCallSession();
|
||||||
|
// [Gitea #302] A DM call the other person has since left is over;
|
||||||
|
// starting it again would ring them, so just say so.
|
||||||
|
const dm = l.directs.has(room.roomId);
|
||||||
|
if (
|
||||||
|
dm &&
|
||||||
|
!othersInCall(
|
||||||
|
session.memberships.map((m) => m.userId ?? ''),
|
||||||
|
mx.getUserId() ?? '',
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Own id: clicking a toast removes it by id after its onClick runs.
|
||||||
|
setToast({
|
||||||
|
id: `${TOAST_ID}-ended`,
|
||||||
|
iconSrc: Icons.Phone,
|
||||||
|
displayName: 'Call ended',
|
||||||
|
body: `${room.name ?? 'They'} already left the call.`,
|
||||||
|
roomName: room.name ?? '',
|
||||||
|
roomId: room.roomId,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
(l.directs.has(room.roomId) ? l.startDmCall : l.startRoomCall)(room, pref);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -133,6 +156,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
|||||||
.filter((m) => m.userId === mx.getUserId())
|
.filter((m) => m.userId === mx.getUserId())
|
||||||
.map((m) => m.deviceId),
|
.map((m) => m.deviceId),
|
||||||
memberCount: memberships.length,
|
memberCount: memberships.length,
|
||||||
|
dm: latest.current.directs.has(room.roomId),
|
||||||
});
|
});
|
||||||
// The RTC session fills in a moment after sync; keep waiting while it is
|
// The RTC session fills in a moment after sync; keep waiting while it is
|
||||||
// empty unless this is the final (timed-out) look.
|
// empty unless this is the final (timed-out) look.
|
||||||
@@ -152,7 +176,7 @@ export function useCallRejoin(embed: CallEmbed | undefined, joined: boolean): vo
|
|||||||
id: TOAST_ID,
|
id: TOAST_ID,
|
||||||
iconSrc: Icons.Phone,
|
iconSrc: Icons.Phone,
|
||||||
displayName: 'Rejoin voice?',
|
displayName: 'Rejoin voice?',
|
||||||
body: `You were in the call in ${room.name ?? 'a room'} before the restart. Tap to rejoin.`,
|
body: rejoinToastBody(room.name, latest.current.directs.has(room.roomId)),
|
||||||
roomName: room.name ?? '',
|
roomName: room.name ?? '',
|
||||||
roomId: room.roomId,
|
roomId: room.roomId,
|
||||||
sticky: true,
|
sticky: true,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { Icons } from 'folds';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { denoiseNotice, DenoiseStateReport } from '../utils/denoiseNotice';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #260] Tell the user when ML noise suppression didn't attach (raw mic)
|
||||||
|
* or fell back to another model. The fork's `io.lotus.denoise_state` used to
|
||||||
|
* go unanswered ("unsupported action"), so failures were silent.
|
||||||
|
*/
|
||||||
|
export function useDenoiseNotices(embed: CallEmbed): void {
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
const [mode] = useSetting(settingsAtom, 'callNoiseSuppression');
|
||||||
|
const [model] = useSetting(settingsAtom, 'callDenoiseModel');
|
||||||
|
const latest = useRef({ mode, model });
|
||||||
|
latest.current = { mode, model };
|
||||||
|
useEffect(
|
||||||
|
() =>
|
||||||
|
embed.listenAction<{ data?: DenoiseStateReport }>('io.lotus.denoise_state', (evt) => {
|
||||||
|
if (latest.current.mode !== 'ml') return;
|
||||||
|
const body = denoiseNotice(latest.current.model, evt.detail?.data);
|
||||||
|
if (!body) return;
|
||||||
|
setToast({
|
||||||
|
id: `denoise-state-${Date.now()}`,
|
||||||
|
iconSrc: Icons.Mic,
|
||||||
|
displayName: 'Noise suppression',
|
||||||
|
body,
|
||||||
|
roomName: embed.room.name ?? 'Voice call',
|
||||||
|
roomId: embed.roomId,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
[embed, setToast],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { SyncState } from 'matrix-js-sdk';
|
||||||
|
import { STALL_AFTER_MS, syncOverdue } from './useHomeserverStall';
|
||||||
|
|
||||||
|
test('syncOverdue: only while SYNCING and past the stall threshold (#317)', () => {
|
||||||
|
const now = 1_000_000;
|
||||||
|
assert.equal(syncOverdue(now - STALL_AFTER_MS - 1, now, SyncState.Syncing), true);
|
||||||
|
assert.equal(syncOverdue(now - 20_000, now, SyncState.Syncing), false, 'a normal long-poll');
|
||||||
|
assert.equal(
|
||||||
|
syncOverdue(now - 120_000, now, SyncState.Reconnecting),
|
||||||
|
false,
|
||||||
|
'SyncStatus already shows that',
|
||||||
|
);
|
||||||
|
assert.equal(syncOverdue(now - 120_000, now, null), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
import { ClientEvent, MatrixClient, SyncState } from 'matrix-js-sdk';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #317] A hung homeserver (accepts connections, never answers) went
|
||||||
|
* unnoticed for up to 110 s: the SDK only gives up on a /sync long-poll after
|
||||||
|
* pollTimeout (30 s) + an 80 s buffer, and until then the sync state stays
|
||||||
|
* SYNCING, so no strip showed and Kuma wasn't asked. This watchdog notices a
|
||||||
|
* /sync that's overdue and confirms with a short probe of /versions.
|
||||||
|
*/
|
||||||
|
export const STALL_AFTER_MS = 45_000; // a healthy long-poll returns within ~30 s
|
||||||
|
const CHECK_EVERY_MS = 10_000;
|
||||||
|
const PROBE_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
export const homeserverStalledAtom = atom(false);
|
||||||
|
|
||||||
|
/** Pure: should we probe, given the last sync response time? */
|
||||||
|
export const syncOverdue = (lastSyncAt: number, now: number, state: SyncState | null): boolean =>
|
||||||
|
state === SyncState.Syncing && now - lastSyncAt > STALL_AFTER_MS;
|
||||||
|
|
||||||
|
export function useHomeserverStall(mx: MatrixClient): boolean {
|
||||||
|
const [stalled, setStalled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let lastSyncAt = Date.now();
|
||||||
|
let probing = false;
|
||||||
|
const onSync = () => {
|
||||||
|
lastSyncAt = Date.now();
|
||||||
|
setStalled(false);
|
||||||
|
};
|
||||||
|
const check = async () => {
|
||||||
|
if (probing || !syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) return;
|
||||||
|
probing = true;
|
||||||
|
let answered = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${mx.getHomeserverUrl()}/_matrix/client/versions`, {
|
||||||
|
cache: 'no-store',
|
||||||
|
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
answered = res.ok;
|
||||||
|
} catch {
|
||||||
|
answered = false;
|
||||||
|
}
|
||||||
|
probing = false;
|
||||||
|
// Still overdue and the server didn't answer a trivial request: stalled.
|
||||||
|
if (!answered && syncOverdue(lastSyncAt, Date.now(), mx.getSyncState())) setStalled(true);
|
||||||
|
if (answered) setStalled(false);
|
||||||
|
};
|
||||||
|
mx.on(ClientEvent.Sync, onSync);
|
||||||
|
const id = setInterval(check, CHECK_EVERY_MS);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(ClientEvent.Sync, onSync);
|
||||||
|
clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [mx]);
|
||||||
|
return stalled;
|
||||||
|
}
|
||||||
@@ -12,7 +12,8 @@ import {
|
|||||||
markAutoTranslateTried,
|
markAutoTranslateTried,
|
||||||
} from '../state/translation';
|
} from '../state/translation';
|
||||||
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
|
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
|
||||||
import { sameLanguage } from '../utils/translation/langUtils';
|
import { sameLanguage, translateSkipMessage } from '../utils/translation/langUtils';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
|
||||||
export type MsgTranslationStatus =
|
export type MsgTranslationStatus =
|
||||||
| 'idle' // not translating (original shown)
|
| 'idle' // not translating (original shown)
|
||||||
@@ -52,6 +53,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
|||||||
// Write-only: reading the whole cache array here (via useAtom) would
|
// Write-only: reading the whole cache array here (via useAtom) would
|
||||||
// resubscribe this message to every other message's writes. [Gitea #39]
|
// resubscribe this message to every other message's writes. [Gitea #39]
|
||||||
const setCache = useSetAtom(translationCacheAtom);
|
const setCache = useSetAtom(translationCacheAtom);
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
// The key includes a fingerprint of `text`, so an edit (same event id, new
|
// The key includes a fingerprint of `text`, so an edit (same event id, new
|
||||||
// body) misses the old entry and re-translates instead of showing stale
|
// body) misses the old entry and re-translates instead of showing stale
|
||||||
@@ -124,6 +126,16 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
|||||||
if (!from || sameLanguage(from, targetLang)) {
|
if (!from || sameLanguage(from, targetLang)) {
|
||||||
setStatus('skipped');
|
setStatus('skipped');
|
||||||
setActive(false);
|
setActive(false);
|
||||||
|
// [Gitea #287] Say why nothing happened instead of silently undoing
|
||||||
|
// the click (no chip shows once translation is switched back off).
|
||||||
|
setToast({
|
||||||
|
id: `translate-skipped-${eventId}-${Date.now()}`,
|
||||||
|
displayName: 'Translate',
|
||||||
|
body: translateSkipMessage(from, targetLang),
|
||||||
|
roomName: '',
|
||||||
|
roomId: '',
|
||||||
|
onClick: () => undefined,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFromLang(from);
|
setFromLang(from);
|
||||||
@@ -154,7 +166,7 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
|
|||||||
// Invalidate this run's writes if inputs change / unmount.
|
// Invalidate this run's writes if inputs change / unmount.
|
||||||
runIdRef.current += 1;
|
runIdRef.current += 1;
|
||||||
};
|
};
|
||||||
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
|
}, [active, cached, supported, text, targetLang, key, setActive, setCache, setToast, eventId]);
|
||||||
|
|
||||||
// When switched off, drop back to idle (keep the cached translation around so
|
// When switched off, drop back to idle (keep the cached translation around so
|
||||||
// toggling back on is instant).
|
// toggling back on is instant).
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { shouldShowScreenshareNotice } from './useScreenshareNotices';
|
||||||
|
|
||||||
|
test('shouldShowScreenshareNotice: no "window was closed" when an admin turned sharing off (#297)', () => {
|
||||||
|
assert.equal(shouldShowScreenshareNotice('ended', false), false);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('ended', true), true);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('no-frames', false), true);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('alone', true), true);
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Icons } from 'folds';
|
import { Icons } from 'folds';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useRoomCallPolicy } from './useRoomCallPolicy';
|
||||||
|
|
||||||
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
||||||
|
|
||||||
@@ -19,18 +20,32 @@ export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string |
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Pure: a policy-forced stop is explained by its own toast (#297). */
|
||||||
|
export const shouldShowScreenshareNotice = (
|
||||||
|
kind: ScreenshareNoticeKind,
|
||||||
|
allowScreenshare: boolean,
|
||||||
|
): boolean => !(kind === 'ended' && !allowScreenshare);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
||||||
* closed, no frames for a while, or a long share with nobody watching.
|
* closed, no frames for a while, or a long share with nobody watching.
|
||||||
*/
|
*/
|
||||||
export function useScreenshareNotices(embed: CallEmbed): void {
|
export function useScreenshareNotices(embed: CallEmbed): void {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
// [Gitea #297] When a room admin turns screen sharing off, the share ends
|
||||||
|
// and the fork reports `ended` ("the shared window was closed"), which is
|
||||||
|
// wrong; useCallPolicyRevokedToast already says what happened.
|
||||||
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
|
const allowRef = useRef(allowScreenshare);
|
||||||
|
allowRef.current = allowScreenshare;
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
||||||
'io.lotus.screenshare_notice',
|
'io.lotus.screenshare_notice',
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
|
const kind = evt.detail?.data?.kind as ScreenshareNoticeKind;
|
||||||
|
if (!shouldShowScreenshareNotice(kind, allowRef.current)) return;
|
||||||
|
const body = describeScreenshareNotice(kind);
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
setToast({
|
setToast({
|
||||||
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useFilePicker } from './useFilePicker';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { stripImageMetadata } from '../utils/stripImageMetadata';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #332] `useFilePicker` for images that get published (emoji / sticker
|
||||||
|
* packs, pack avatars, power-level badges): applies "Remove Photo Metadata"
|
||||||
|
* like message attachments and avatars, so a phone photo's GPS location and
|
||||||
|
* timestamp don't go to every member of the room or space.
|
||||||
|
*/
|
||||||
|
export const useStrippedFilePicker = <M extends boolean | undefined = undefined>(
|
||||||
|
onSelect: (file: M extends true ? File[] : File) => void,
|
||||||
|
multiple?: M,
|
||||||
|
) => {
|
||||||
|
const [strip] = useSetting(settingsAtom, 'stripImageMetadata');
|
||||||
|
const handle = useCallback(
|
||||||
|
(picked: File | File[]) => {
|
||||||
|
if (!strip) {
|
||||||
|
onSelect(picked as M extends true ? File[] : File);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const clean = (f: File) => stripImageMetadata(f).then((r) => r.file);
|
||||||
|
if (Array.isArray(picked)) {
|
||||||
|
Promise.all(picked.map(clean)).then((files) =>
|
||||||
|
onSelect(files as M extends true ? File[] : File),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
clean(picked).then((file) => onSelect(file as M extends true ? File[] : File));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[strip, onSelect],
|
||||||
|
);
|
||||||
|
return useFilePicker(handle as (file: M extends true ? File[] : File) => void, multiple);
|
||||||
|
};
|
||||||
@@ -49,6 +49,7 @@ import {
|
|||||||
getNotificationType,
|
getNotificationType,
|
||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
|
isFromFilteredTimelineSet,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { NotificationType } from '../../../types/matrix/room';
|
import { NotificationType } from '../../../types/matrix/room';
|
||||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||||
@@ -58,8 +59,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
import {
|
import {
|
||||||
MAX_MUTE_TIMEOUT_MS,
|
MAX_MUTE_TIMEOUT_MS,
|
||||||
MuteTimerEntry,
|
|
||||||
loadMuteTimers,
|
loadMuteTimers,
|
||||||
|
migrateLocalMuteTimers,
|
||||||
unmuteRoom,
|
unmuteRoom,
|
||||||
} from '../../features/room-nav/muteTimers';
|
} from '../../features/room-nav/muteTimers';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
@@ -91,6 +92,7 @@ import {
|
|||||||
shouldNotifyThreadReply,
|
shouldNotifyThreadReply,
|
||||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
} from '../../utils/threadNotifications';
|
} from '../../utils/threadNotifications';
|
||||||
|
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// Grace period after the initial sync settles before invite notifications arm, so
|
||||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||||
@@ -334,7 +336,9 @@ function InviteNotifications() {
|
|||||||
|
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
// [Gitea #324] The in-app toast (focused window) needs no browser
|
||||||
|
// permission; only the OS notification does.
|
||||||
|
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
|
||||||
notify(newCount);
|
notify(newCount);
|
||||||
}
|
}
|
||||||
if (notificationSound && inviteSoundId !== 'none') {
|
if (notificationSound && inviteSoundId !== 'none') {
|
||||||
@@ -369,30 +373,35 @@ function PresenceUpdater() {
|
|||||||
function MuteTimerRestore() {
|
function MuteTimerRestore() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
|
// [Gitea #333] Timers are in account data, so a mute set on another device
|
||||||
|
// is lifted here too, and changes there (a new mute, a manual unmute)
|
||||||
|
// reschedule. Moves any timers this browser kept locally (pre-#333) first.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timers = loadMuteTimers();
|
migrateLocalMuteTimers(mx).catch(() => {});
|
||||||
if (timers.length === 0) return undefined;
|
}, [mx]);
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const pastDue: MuteTimerEntry[] = [];
|
|
||||||
const future: MuteTimerEntry[] = [];
|
|
||||||
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
|
|
||||||
|
|
||||||
pastDue.forEach((entry) => {
|
|
||||||
unmuteRoom(mx, entry.roomId);
|
|
||||||
});
|
|
||||||
|
|
||||||
const handles = future.map((entry) =>
|
|
||||||
setTimeout(
|
|
||||||
() => {
|
|
||||||
unmuteRoom(mx, entry.roomId);
|
|
||||||
},
|
|
||||||
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let handles: ReturnType<typeof setTimeout>[] = [];
|
||||||
|
const schedule = () => {
|
||||||
|
handles.forEach(clearTimeout);
|
||||||
|
const now = Date.now();
|
||||||
|
handles = loadMuteTimers(mx).map((entry) =>
|
||||||
|
setTimeout(
|
||||||
|
() => {
|
||||||
|
unmuteRoom(mx, entry.roomId);
|
||||||
|
},
|
||||||
|
Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const onAccountData = (event: MatrixEvent) => {
|
||||||
|
if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule();
|
||||||
|
};
|
||||||
|
schedule();
|
||||||
|
mx.on(ClientEvent.AccountData, onAccountData);
|
||||||
return () => {
|
return () => {
|
||||||
handles.forEach(clearTimeout);
|
handles.forEach(clearTimeout);
|
||||||
|
mx.removeListener(ClientEvent.AccountData, onAccountData);
|
||||||
};
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
@@ -571,7 +580,8 @@ function MessageNotifications() {
|
|||||||
// NotificationOptions typing.
|
// NotificationOptions typing.
|
||||||
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
actions: [{ action: 'reply', title: 'Reply', type: 'text', placeholder: 'Reply…' }],
|
||||||
}),
|
}),
|
||||||
data: { path: roomPath, roomId, threadId: threadId || undefined },
|
// userId: [Gitea #299] the SW replies as this account only.
|
||||||
|
data: { path: roomPath, roomId, threadId: threadId || undefined, userId: mx.getUserId() },
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
window.focus();
|
window.focus();
|
||||||
@@ -579,7 +589,7 @@ function MessageNotifications() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[navigate, setToast, mDirects],
|
[navigate, setToast, mDirects, mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
// N105: when a service-worker-owned notification is clicked, the SW focuses
|
// N105: when a service-worker-owned notification is clicked, the SW focuses
|
||||||
@@ -641,7 +651,11 @@ function MessageNotifications() {
|
|||||||
|
|
||||||
if (quietActive) return;
|
if (quietActive) return;
|
||||||
|
|
||||||
if (showNotifications && notificationPermission('granted')) {
|
// [Gitea #324] notify() shows an in-app toast while the window is focused,
|
||||||
|
// which needs no browser permission; only its OS-notification path does.
|
||||||
|
// Gating both on the permission left people who declined it (most web
|
||||||
|
// users) with a ping sound and nothing on screen.
|
||||||
|
if (showNotifications && (document.hasFocus() || notificationPermission('granted'))) {
|
||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
notify({
|
notify({
|
||||||
@@ -684,6 +698,7 @@ function MessageNotifications() {
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (mx.getSyncState() !== 'SYNCING') return;
|
if (mx.getSyncState() !== 'SYNCING') return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
|
||||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
||||||
if (
|
if (
|
||||||
!room ||
|
!room ||
|
||||||
@@ -1060,32 +1075,6 @@ function TauriUpdateFeature() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LotusDenoiseFeature() {
|
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleMessage = (event: MessageEvent) => {
|
|
||||||
if (event.data?.type === 'lotus-denoise-status') {
|
|
||||||
const { active, error } = event.data;
|
|
||||||
if (!active) {
|
|
||||||
setToast({
|
|
||||||
id: `denoise-fail-${Date.now()}`,
|
|
||||||
displayName: 'Audio Quality',
|
|
||||||
body: `ML Noise Suppression failed: ${error || 'Unknown error'}. Falling back to raw mic.`,
|
|
||||||
roomName: 'System',
|
|
||||||
roomId: '',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('message', handleMessage);
|
|
||||||
return () => window.removeEventListener('message', handleMessage);
|
|
||||||
}, [setToast]);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Registers the global `?` shortcut (ignored while typing) and renders the
|
// Registers the global `?` shortcut (ignored while typing) and renders the
|
||||||
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
// keyboard-shortcuts help dialog. Headless — the dialog self-gates on its atom.
|
||||||
function KeyboardShortcutsFeature() {
|
function KeyboardShortcutsFeature() {
|
||||||
@@ -1126,6 +1115,7 @@ function ClockSkewFeature() {
|
|||||||
data,
|
data,
|
||||||
) => {
|
) => {
|
||||||
if (!data.liveEvent) return;
|
if (!data.liveEvent) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
// Only events our homeserver stamped: a federated event's
|
// Only events our homeserver stamped: a federated event's
|
||||||
// origin_server_ts is the other server's clock.
|
// origin_server_ts is the other server's clock.
|
||||||
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
||||||
@@ -1167,7 +1157,6 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<CloseBehaviorPrompt />
|
<CloseBehaviorPrompt />
|
||||||
<ConsentRequiredPrompt />
|
<ConsentRequiredPrompt />
|
||||||
<OutboxFeature />
|
<OutboxFeature />
|
||||||
<LotusDenoiseFeature />
|
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
<KeyboardShortcutsFeature />
|
<KeyboardShortcutsFeature />
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useSyncState } from '../../hooks/useSyncState';
|
|||||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||||
import { serverStatusAtom } from '../../state/serverStatus';
|
import { serverStatusAtom } from '../../state/serverStatus';
|
||||||
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
|
import { serverConfirmedUpSince } from '../../utils/kumaStatus';
|
||||||
|
import { homeserverStalledAtom } from '../../hooks/useHomeserverStall';
|
||||||
|
|
||||||
type StateData = {
|
type StateData = {
|
||||||
current: SyncState | null;
|
current: SyncState | null;
|
||||||
@@ -17,6 +18,7 @@ type SyncStatusProps = {
|
|||||||
};
|
};
|
||||||
export function SyncStatus({ mx }: SyncStatusProps) {
|
export function SyncStatus({ mx }: SyncStatusProps) {
|
||||||
const serverStatus = useAtomValue(serverStatusAtom);
|
const serverStatus = useAtomValue(serverStatusAtom);
|
||||||
|
const stalled = useAtomValue(homeserverStalledAtom);
|
||||||
const [stateData, setStateData] = useState<StateData>({
|
const [stateData, setStateData] = useState<StateData>({
|
||||||
current: null,
|
current: null,
|
||||||
previous: undefined,
|
previous: undefined,
|
||||||
@@ -34,6 +36,35 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
|||||||
}, []),
|
}, []),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
|
||||||
|
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
|
||||||
|
const serverDown = serverStatus.status?.homeserver === 'down';
|
||||||
|
// Only blame the user's connection when Kuma has checked the server since
|
||||||
|
// the drop and it was fine (a stale "up" from before proves nothing).
|
||||||
|
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
|
||||||
|
|
||||||
|
// [Gitea #317] Sync still says SYNCING, but the server has stopped answering.
|
||||||
|
if (stalled && stateData.current === SyncState.Syncing && !serverDown) {
|
||||||
|
return (
|
||||||
|
<Box direction="Column" shrink="No">
|
||||||
|
<Box
|
||||||
|
className={ContainerColor({ variant: 'Warning' })}
|
||||||
|
style={{ padding: `${config.space.S100} 0` }}
|
||||||
|
alignItems="Center"
|
||||||
|
justifyContent="Center"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
<Text size="L400">
|
||||||
|
Lotus Chat's server isn't responding. Messages will send when it's back.
|
||||||
|
Still trying…
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Line variant="Warning" size="300" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(stateData.current === SyncState.Prepared ||
|
(stateData.current === SyncState.Prepared ||
|
||||||
stateData.current === SyncState.Syncing ||
|
stateData.current === SyncState.Syncing ||
|
||||||
@@ -57,13 +88,6 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Gitea #124] When Kuma confirms the homeserver is down, the status banner
|
|
||||||
// says so ("Lotus Chat's server is down…") instead of "Connection Lost!".
|
|
||||||
const serverDown = serverStatus.status?.homeserver === 'down';
|
|
||||||
// Only blame the user's connection when Kuma has checked the server since
|
|
||||||
// the drop and it was fine (a stale "up" from before proves nothing).
|
|
||||||
const serverUp = serverConfirmedUpSince(serverStatus.status, serverStatus.lostAt);
|
|
||||||
|
|
||||||
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
if (stateData.current === SyncState.Reconnecting && !serverDown) {
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" shrink="No">
|
<Box direction="Column" shrink="No">
|
||||||
|
|||||||
+185
-134
@@ -255,6 +255,8 @@ function HomeCustomSection({
|
|||||||
return (localName ?? matrixName).toLowerCase().includes(query);
|
return (localName ?? matrixName).toLowerCase().includes(query);
|
||||||
});
|
});
|
||||||
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
|
||||||
|
// [Gitea #289] While filtering, a section with no matches is hidden.
|
||||||
|
if (filterQuery.trim() && items.length === 0) return null;
|
||||||
// Sections are user-curated and small, so they render plainly — no
|
// Sections are user-curated and small, so they render plainly — no
|
||||||
// virtualizer per section (a late-mounted one never measured its scroller).
|
// virtualizer per section (a late-mounted one never measured its scroller).
|
||||||
return (
|
return (
|
||||||
@@ -443,6 +445,26 @@ export function Home() {
|
|||||||
});
|
});
|
||||||
}, [mx, sortedRooms, filterQuery]);
|
}, [mx, sortedRooms, filterQuery]);
|
||||||
|
|
||||||
|
// [Gitea #289] While filtering, hide empty sections; with no match at all,
|
||||||
|
// say so instead of leaving bare headers.
|
||||||
|
const filtering = filterQuery.trim().length > 0;
|
||||||
|
const customSectionMatch = useMemo(() => {
|
||||||
|
if (!filtering) return true;
|
||||||
|
const query = filterQuery.toLowerCase();
|
||||||
|
const localNames = getLocalRoomNamesContent(mx);
|
||||||
|
return customSections.some((section) =>
|
||||||
|
section.rooms.some((rId) =>
|
||||||
|
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}, [mx, customSections, filtering, filterQuery]);
|
||||||
|
const noFilterMatch =
|
||||||
|
filtering &&
|
||||||
|
!customSectionMatch &&
|
||||||
|
filteredFavoriteRooms.length === 0 &&
|
||||||
|
filteredRooms.length === 0 &&
|
||||||
|
filteredLowPriorityRooms.length === 0;
|
||||||
|
|
||||||
const favVirtualizer = useVirtualizer({
|
const favVirtualizer = useVirtualizer({
|
||||||
count: filteredFavoriteRooms.length,
|
count: filteredFavoriteRooms.length,
|
||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
@@ -580,7 +602,7 @@ export function Home() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</NavCategory>
|
</NavCategory>
|
||||||
{favoriteRooms.length > 0 && (
|
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
@@ -629,142 +651,171 @@ export function Home() {
|
|||||||
notificationPreferences={notificationPreferences}
|
notificationPreferences={notificationPreferences}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<NavCategory>
|
{noFilterMatch && (
|
||||||
<NavCategoryHeader>
|
<NavCategory>
|
||||||
<RoomNavCategoryButton
|
<Box
|
||||||
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
direction="Column"
|
||||||
data-category-id={DEFAULT_CATEGORY_ID}
|
alignItems="Center"
|
||||||
onClick={handleCategoryClick}
|
gap="200"
|
||||||
|
style={{ padding: config.space.S400 }}
|
||||||
>
|
>
|
||||||
Rooms
|
<Text size="T300" priority="300" align="Center">
|
||||||
</RoomNavCategoryButton>
|
No rooms match “{filterQuery.trim()}”
|
||||||
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
</Text>
|
||||||
<>
|
<Button
|
||||||
<IconButton
|
size="300"
|
||||||
aria-expanded={!!sortMenuAnchor}
|
variant="Secondary"
|
||||||
aria-haspopup="menu"
|
fill="Soft"
|
||||||
variant="Background"
|
radii="300"
|
||||||
fill="None"
|
onClick={() => setFilterQuery('')}
|
||||||
size="300"
|
>
|
||||||
radii="300"
|
<Text size="B300">Clear filter</Text>
|
||||||
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
</Button>
|
||||||
const cords = evt.currentTarget.getBoundingClientRect();
|
</Box>
|
||||||
setSortMenuAnchor((current) => (current ? undefined : cords));
|
</NavCategory>
|
||||||
}}
|
)}
|
||||||
aria-label="Sort rooms"
|
{(!filtering || filteredRooms.length > 0) && (
|
||||||
>
|
<NavCategory>
|
||||||
<Icon src={Icons.Sort} size="100" />
|
<NavCategoryHeader>
|
||||||
</IconButton>
|
<RoomNavCategoryButton
|
||||||
<PopOut
|
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
|
||||||
anchor={sortMenuAnchor}
|
data-category-id={DEFAULT_CATEGORY_ID}
|
||||||
position="Bottom"
|
onClick={handleCategoryClick}
|
||||||
align="End"
|
>
|
||||||
offset={6}
|
Rooms
|
||||||
content={
|
</RoomNavCategoryButton>
|
||||||
<FocusTrap
|
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
|
||||||
focusTrapOptions={{
|
<>
|
||||||
initialFocus: false,
|
<IconButton
|
||||||
onDeactivate: () => setSortMenuAnchor(undefined),
|
aria-expanded={!!sortMenuAnchor}
|
||||||
clickOutsideDeactivates: true,
|
aria-haspopup="menu"
|
||||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
variant="Background"
|
||||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
fill="None"
|
||||||
escapeDeactivates: stopPropagation,
|
size="300"
|
||||||
}}
|
radii="300"
|
||||||
>
|
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
const cords = evt.currentTarget.getBoundingClientRect();
|
||||||
<Box
|
setSortMenuAnchor((current) => (current ? undefined : cords));
|
||||||
direction="Column"
|
}}
|
||||||
gap="100"
|
aria-label="Sort rooms"
|
||||||
style={{ padding: config.space.S100 }}
|
>
|
||||||
>
|
<Icon src={Icons.Sort} size="100" />
|
||||||
<MenuItem
|
</IconButton>
|
||||||
onClick={() => {
|
<PopOut
|
||||||
setHomeRoomSort('recent');
|
anchor={sortMenuAnchor}
|
||||||
setSortMenuAnchor(undefined);
|
position="Bottom"
|
||||||
}}
|
align="End"
|
||||||
size="300"
|
offset={6}
|
||||||
after={
|
content={
|
||||||
homeRoomSort === 'recent' ? (
|
<FocusTrap
|
||||||
<Icon size="100" src={Icons.Check} />
|
focusTrapOptions={{
|
||||||
) : undefined
|
initialFocus: false,
|
||||||
}
|
onDeactivate: () => setSortMenuAnchor(undefined),
|
||||||
radii="300"
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
|
||||||
|
<Box
|
||||||
|
direction="Column"
|
||||||
|
gap="100"
|
||||||
|
style={{ padding: config.space.S100 }}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<MenuItem
|
||||||
Recent Activity
|
onClick={() => {
|
||||||
</Text>
|
setHomeRoomSort('recent');
|
||||||
</MenuItem>
|
setSortMenuAnchor(undefined);
|
||||||
<MenuItem
|
}}
|
||||||
onClick={() => {
|
size="300"
|
||||||
setHomeRoomSort('alpha');
|
after={
|
||||||
setSortMenuAnchor(undefined);
|
homeRoomSort === 'recent' ? (
|
||||||
}}
|
<Icon size="100" src={Icons.Check} />
|
||||||
size="300"
|
) : undefined
|
||||||
after={
|
}
|
||||||
homeRoomSort === 'alpha' ? (
|
radii="300"
|
||||||
<Icon size="100" src={Icons.Check} />
|
>
|
||||||
) : undefined
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
}
|
Recent Activity
|
||||||
radii="300"
|
</Text>
|
||||||
>
|
</MenuItem>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<MenuItem
|
||||||
A → Z
|
onClick={() => {
|
||||||
</Text>
|
setHomeRoomSort('alpha');
|
||||||
</MenuItem>
|
setSortMenuAnchor(undefined);
|
||||||
<MenuItem
|
}}
|
||||||
onClick={() => {
|
size="300"
|
||||||
setHomeRoomSort('unread');
|
after={
|
||||||
setSortMenuAnchor(undefined);
|
homeRoomSort === 'alpha' ? (
|
||||||
}}
|
<Icon size="100" src={Icons.Check} />
|
||||||
size="300"
|
) : undefined
|
||||||
after={
|
}
|
||||||
homeRoomSort === 'unread' ? (
|
radii="300"
|
||||||
<Icon size="100" src={Icons.Check} />
|
>
|
||||||
) : undefined
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
}
|
A → Z
|
||||||
radii="300"
|
</Text>
|
||||||
>
|
</MenuItem>
|
||||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
<MenuItem
|
||||||
Unread First
|
onClick={() => {
|
||||||
</Text>
|
setHomeRoomSort('unread');
|
||||||
</MenuItem>
|
setSortMenuAnchor(undefined);
|
||||||
</Box>
|
}}
|
||||||
</Menu>
|
size="300"
|
||||||
</FocusTrap>
|
after={
|
||||||
}
|
homeRoomSort === 'unread' ? (
|
||||||
/>
|
<Icon size="100" src={Icons.Check} />
|
||||||
</>
|
) : undefined
|
||||||
)}
|
}
|
||||||
</NavCategoryHeader>
|
radii="300"
|
||||||
<div
|
>
|
||||||
style={{
|
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||||
position: 'relative',
|
Unread First
|
||||||
height: virtualizer.getTotalSize(),
|
</Text>
|
||||||
}}
|
</MenuItem>
|
||||||
>
|
</Box>
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
</Menu>
|
||||||
const roomId = filteredRooms[vItem.index];
|
</FocusTrap>
|
||||||
const room = mx.getRoom(roomId);
|
}
|
||||||
if (!room) return null;
|
|
||||||
const selected = selectedRoomId === roomId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
|
|
||||||
<RoomNavItem
|
|
||||||
room={room}
|
|
||||||
selected={selected}
|
|
||||||
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
|
||||||
notificationMode={getRoomNotificationMode(
|
|
||||||
notificationPreferences,
|
|
||||||
room.roomId,
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
</VirtualTile>
|
</>
|
||||||
);
|
)}
|
||||||
})}
|
</NavCategoryHeader>
|
||||||
</div>
|
<div
|
||||||
</NavCategory>
|
style={{
|
||||||
{lowPriorityRooms.length > 0 && (
|
position: 'relative',
|
||||||
|
height: virtualizer.getTotalSize(),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
|
const roomId = filteredRooms[vItem.index];
|
||||||
|
const room = mx.getRoom(roomId);
|
||||||
|
if (!room) return null;
|
||||||
|
const selected = selectedRoomId === roomId;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTile
|
||||||
|
virtualItem={vItem}
|
||||||
|
key={roomId}
|
||||||
|
ref={virtualizer.measureElement}
|
||||||
|
>
|
||||||
|
<RoomNavItem
|
||||||
|
room={room}
|
||||||
|
selected={selected}
|
||||||
|
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
|
||||||
|
notificationMode={getRoomNotificationMode(
|
||||||
|
notificationPreferences,
|
||||||
|
room.roomId,
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</VirtualTile>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</NavCategory>
|
||||||
|
)}
|
||||||
|
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<NavCategoryHeader>
|
<NavCategoryHeader>
|
||||||
<RoomNavCategoryButton
|
<RoomNavCategoryButton
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ export class CallControl extends EventEmitter implements CallControlState {
|
|||||||
|
|
||||||
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
constructor(state: CallControlState, call: ClientWidgetApi, iframe: HTMLIFrameElement) {
|
||||||
super();
|
super();
|
||||||
|
// [Gitea #316] More than 10 call features subscribe to state_update at once
|
||||||
|
// (call bar, controls, hotkeys, AFK, muted-talk, join/leave sounds,
|
||||||
|
// announcements, rejoin, policy toast, glance, desktop thumbbar/SMTC …).
|
||||||
|
// Hitting the default limit logged a MaxListenersExceededWarning, and the
|
||||||
|
// console then kept this CallControl — and through it the whole Element Call
|
||||||
|
// document — alive: ~11 MB leaked per call. These are not leaks, so lift it.
|
||||||
|
this.setMaxListeners(100);
|
||||||
|
|
||||||
this.state = state;
|
this.state = state;
|
||||||
this.call = call;
|
this.call = call;
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ import {
|
|||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { getCallCapabilities } from './utils';
|
import { getCallCapabilities } from './utils';
|
||||||
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
|
import { downloadMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||||
|
import { dedupeInFlight } from '../../utils/inFlight';
|
||||||
|
|
||||||
|
/** A little longer than Element Call's own 2 s timeout for a restart. */
|
||||||
|
const RESTART_TIMEOUT_MS = 5000;
|
||||||
export class CallWidgetDriver extends WidgetDriver {
|
export class CallWidgetDriver extends WidgetDriver {
|
||||||
private allowedCapabilities: Set<Capability>;
|
private allowedCapabilities: Set<Capability>;
|
||||||
|
|
||||||
@@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver {
|
|||||||
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
|
await this.mx._unstable_cancelScheduledDelayedEvent(delayId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #318] While the homeserver is stalled, Element Call times out each
|
||||||
|
// restart locally after 2 s and asks again, so requests for the same delay
|
||||||
|
// id piled up and hit the server together on recovery (404s, then 429s).
|
||||||
|
// Share one in-flight restart per id, and give it a timeout of its own.
|
||||||
|
private readonly restartOnce = dedupeInFlight<unknown>();
|
||||||
|
|
||||||
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
|
public async restartScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||||
await this.mx._unstable_restartScheduledDelayedEvent(delayId);
|
await this.restartOnce(delayId, () =>
|
||||||
|
this.mx._unstable_restartScheduledDelayedEvent(delayId, {
|
||||||
|
localTimeoutMs: RESTART_TIMEOUT_MS,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
|
public async sendScheduledDelayedEvent(delayId: string): Promise<void> {
|
||||||
|
|||||||
@@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => {
|
|||||||
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
||||||
* swept with the rest — the server-side presence status survives, so a
|
* swept with the rest — the server-side presence status survives, so a
|
||||||
* re-login loses nothing)
|
* re-login loses nothing)
|
||||||
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
|
||||||
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
|
||||||
* preferences, not swept here
|
* Timed mutes moved to account data in [Gitea #333]; a leftover local
|
||||||
|
* `io.lotus.mute_timers` is migrated there on the next start.
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
|
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
getUnreadInfo,
|
getUnreadInfo,
|
||||||
getUnreadInfos,
|
getUnreadInfos,
|
||||||
isNotificationEvent,
|
isNotificationEvent,
|
||||||
|
isFromFilteredTimelineSet,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { roomToParentsAtom } from './roomToParents';
|
import { roomToParentsAtom } from './roomToParents';
|
||||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||||
@@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
|||||||
data: IRoomTimelineData,
|
data: IRoomTimelineData,
|
||||||
) => {
|
) => {
|
||||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||||
|
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||||
// Single-owner rule: thread replies drive the room badge via
|
// Single-owner rule: thread replies drive the room badge via
|
||||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||||
// never double-driven / mis-attributed to the main timeline.
|
// never double-driven / mis-attributed to the main timeline.
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ const internalAtom = atomWithStorage<Record<string, ScheduledMessage[]>>(
|
|||||||
/**
|
/**
|
||||||
* Global atom: Map<roomId, ScheduledMessage[]>
|
* Global atom: Map<roomId, ScheduledMessage[]>
|
||||||
* Stores all locally-tracked scheduled messages across rooms.
|
* Stores all locally-tracked scheduled messages across rooms.
|
||||||
* MSC4140 has no list endpoint, so we track them ourselves.
|
* Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts
|
||||||
* Backed by localStorage so scheduled messages survive page refreshes.
|
* (Gitea #322); localStorage is a cache for the first paint.
|
||||||
*/
|
*/
|
||||||
export const scheduledMessagesAtom = atom(
|
export const scheduledMessagesAtom = atom(
|
||||||
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
(get): Map<string, ScheduledMessage[]> => new Map(Object.entries(get(internalAtom))),
|
||||||
|
|||||||
@@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{
|
|||||||
syncLost: false,
|
syncLost: false,
|
||||||
lostAt: null,
|
lostAt: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const DISMISS_KEY = 'lotus-status-banner-dismissed';
|
||||||
|
const DISMISS_MAX = 50;
|
||||||
|
|
||||||
|
const readDismissed = (): string[] => {
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
|
||||||
|
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseDismissedAtom = atom<string[]>(readDismissed());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #307] Status strips the user dismissed (by banner key), shared so
|
||||||
|
* the security nudge knows a dismissed announcement isn't on screen.
|
||||||
|
* Persisted to localStorage; session-only if storage is unavailable.
|
||||||
|
*/
|
||||||
|
export const statusDismissedAtom = atom(
|
||||||
|
(get) => get(baseDismissedAtom),
|
||||||
|
(get, set, key: string) => {
|
||||||
|
const next = [...get(baseDismissedAtom).filter((k) => k !== key), key].slice(-DISMISS_MAX);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
// storage unavailable — dismissed for this session only
|
||||||
|
}
|
||||||
|
set(baseDismissedAtom, next);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { describe, it } from 'node:test';
|
import { describe, it } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { CALL_REJOIN_MAX_AGE_MS, decideRejoin, RejoinContext } from './callRejoin';
|
import {
|
||||||
|
CALL_REJOIN_MAX_AGE_MS,
|
||||||
|
decideRejoin,
|
||||||
|
othersInCall,
|
||||||
|
rejoinToastBody,
|
||||||
|
RejoinContext,
|
||||||
|
} from './callRejoin';
|
||||||
|
|
||||||
const base: RejoinContext = {
|
const base: RejoinContext = {
|
||||||
record: {
|
record: {
|
||||||
@@ -54,3 +60,21 @@ describe('decideRejoin', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('decideRejoin: DM calls are never auto-rejoined, only offered (#302)', () => {
|
||||||
|
assert.deepEqual(decideRejoin({ ...base, mode: 'auto', dm: true }), {
|
||||||
|
action: 'ask',
|
||||||
|
roomId: '!r',
|
||||||
|
});
|
||||||
|
assert.deepEqual(decideRejoin({ ...base, mode: 'auto' }), { action: 'auto', roomId: '!r' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejoinToastBody and othersInCall (#302)', () => {
|
||||||
|
assert.equal(
|
||||||
|
rejoinToastBody('Bob Builder', true),
|
||||||
|
'You were in a call with Bob Builder before the restart. Tap to rejoin.',
|
||||||
|
);
|
||||||
|
assert.match(rejoinToastBody('Voice Lounge', false), /in the call in Voice Lounge/);
|
||||||
|
assert.equal(othersInCall(['@me:x'], '@me:x'), false);
|
||||||
|
assert.equal(othersInCall(['@me:x', '@bob:x'], '@me:x'), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ export type RejoinContext = {
|
|||||||
ownMemberDevices: string[];
|
ownMemberDevices: string[];
|
||||||
/** Number of call memberships in the room, ours included. */
|
/** Number of call memberships in the room, ours included. */
|
||||||
memberCount: number;
|
memberCount: number;
|
||||||
|
/** A DM call: rejoining one may ring the other person. */
|
||||||
|
dm?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
|
export type RejoinDecision = { action: 'none' } | { action: 'ask' | 'auto'; roomId: string };
|
||||||
@@ -83,5 +85,18 @@ export function decideRejoin(ctx: RejoinContext): RejoinDecision {
|
|||||||
// Nobody there any more (our own stale membership doesn't count).
|
// Nobody there any more (our own stale membership doesn't count).
|
||||||
const others = ctx.memberCount - ctx.ownMemberDevices.length;
|
const others = ctx.memberCount - ctx.ownMemberDevices.length;
|
||||||
if (others <= 0) return { action: 'none' };
|
if (others <= 0) return { action: 'none' };
|
||||||
|
// [Gitea #302] Never rejoin a DM call on our own: if the other person has
|
||||||
|
// just left, starting it again would ring them. Ask instead.
|
||||||
|
if (ctx.dm && mode === 'auto') return { action: 'ask', roomId: record.roomId };
|
||||||
return { action: mode, roomId: record.roomId };
|
return { action: mode, roomId: record.roomId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pure: whether anyone but us is still in the call (by call membership). */
|
||||||
|
export const othersInCall = (memberUserIds: string[], myUserId: string): boolean =>
|
||||||
|
memberUserIds.some((u) => u !== myUserId);
|
||||||
|
|
||||||
|
/** The rejoin toast's text; DMs name the person rather than "the call in <room>". */
|
||||||
|
export const rejoinToastBody = (roomName: string | undefined, dm: boolean): string =>
|
||||||
|
dm
|
||||||
|
? `You were in a call with ${roomName ?? 'them'} before the restart. Tap to rejoin.`
|
||||||
|
: `You were in the call in ${roomName ?? 'a room'} before the restart. Tap to rejoin.`;
|
||||||
|
|||||||
@@ -67,3 +67,18 @@ test('declinedRing finds our own decline of that ring only (#312)', () => {
|
|||||||
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
||||||
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('declinedRing matches a decline of the notification or of the membership (#313)', () => {
|
||||||
|
const decline = (rel: string) => ({
|
||||||
|
getType: () => 'org.matrix.msc4310.rtc.decline',
|
||||||
|
getSender: () => '@me:x',
|
||||||
|
getRelation: () => ({ event_id: rel }),
|
||||||
|
});
|
||||||
|
const room = stateRoom([], [decline('$notification')]);
|
||||||
|
assert.equal(declinedRing(room, ['$membership', '$notification'], '@me:x'), true);
|
||||||
|
assert.equal(declinedRing(room, ['$membership'], '@me:x'), false);
|
||||||
|
assert.equal(
|
||||||
|
declinedRing(stateRoom([], [decline('$membership')]), ['$membership', undefined], '@me:x'),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -40,7 +40,18 @@ export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
|||||||
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
||||||
* the ring in the first sync after a restart, before any dismiss listener.
|
* the ring in the first sync after a restart, before any dismiss listener.
|
||||||
*/
|
*/
|
||||||
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
|
export const declinedRing = (
|
||||||
|
room: Room,
|
||||||
|
ringIds: string | Array<string | undefined>,
|
||||||
|
myUserId: string,
|
||||||
|
): boolean => {
|
||||||
|
// [Gitea #313] A decline references the notification event (MSC4310); older
|
||||||
|
// Lotus builds referenced the call membership instead. Match either.
|
||||||
|
const ids = new Set((Array.isArray(ringIds) ? ringIds : [ringIds]).filter(Boolean));
|
||||||
|
return declinedAny(room, ids, myUserId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const declinedAny = (room: Room, ids: Set<string | undefined>, myUserId: string): boolean =>
|
||||||
room
|
room
|
||||||
.getLiveTimeline()
|
.getLiveTimeline()
|
||||||
.getEvents()
|
.getEvents()
|
||||||
@@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string):
|
|||||||
(ev) =>
|
(ev) =>
|
||||||
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
||||||
ev.getSender() === myUserId &&
|
ev.getSender() === myUserId &&
|
||||||
ev.getRelation()?.event_id === refEventId,
|
ids.has(ev.getRelation()?.event_id),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { denoiseNotice } from './denoiseNotice';
|
||||||
|
|
||||||
|
test('denoiseNotice: says when ML is off or fell back (#260)', () => {
|
||||||
|
assert.equal(denoiseNotice('deepfilternet', { active: true, model: 'deepfilternet' }), undefined);
|
||||||
|
assert.match(
|
||||||
|
denoiseNotice('deepfilternet', { active: true, model: 'rnnoise' }) ?? '',
|
||||||
|
/^DeepFilterNet 3 couldn't start, so noise suppression is using RNNoise instead\.$/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
denoiseNotice('rnnoise', { active: false, error: 'boom' }) ?? '',
|
||||||
|
/unfiltered audio/,
|
||||||
|
);
|
||||||
|
assert.equal(denoiseNotice('rnnoise', undefined), undefined);
|
||||||
|
assert.equal(denoiseNotice('rnnoise', { model: 'x' }), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* [Gitea #260] The fork reports what noise suppression actually runs as
|
||||||
|
* `io.lotus.denoise_state { active, model, error? }`. Pure: the toast text for
|
||||||
|
* a report, or undefined when what runs is what the user picked.
|
||||||
|
*/
|
||||||
|
export type DenoiseStateReport = { active?: unknown; model?: unknown; error?: unknown };
|
||||||
|
|
||||||
|
const MODEL_NAMES: Record<string, string> = {
|
||||||
|
rnnoise: 'RNNoise',
|
||||||
|
speex: 'Speex',
|
||||||
|
dtln: 'DTLN',
|
||||||
|
deepfilternet: 'DeepFilterNet 3',
|
||||||
|
};
|
||||||
|
const modelName = (m: unknown): string =>
|
||||||
|
typeof m === 'string' ? (MODEL_NAMES[m] ?? m) : 'the selected model';
|
||||||
|
|
||||||
|
export function denoiseNotice(
|
||||||
|
requestedModel: string,
|
||||||
|
report: DenoiseStateReport | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
if (!report || typeof report.active !== 'boolean') return undefined;
|
||||||
|
if (!report.active) {
|
||||||
|
return `ML noise suppression couldn't start, so your microphone is sending unfiltered audio. You can pick another model in Settings → Calls.`;
|
||||||
|
}
|
||||||
|
if (typeof report.model === 'string' && report.model !== requestedModel) {
|
||||||
|
return `${modelName(requestedModel)} couldn't start, so noise suppression is using ${modelName(report.model)} instead.`;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { IRoomTimelineData } from 'matrix-js-sdk';
|
||||||
|
import { isFromFilteredTimelineSet } from './room';
|
||||||
|
|
||||||
|
const data = (filter: unknown): IRoomTimelineData =>
|
||||||
|
({
|
||||||
|
liveEvent: true,
|
||||||
|
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
|
||||||
|
}) as unknown as IRoomTimelineData;
|
||||||
|
|
||||||
|
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
|
||||||
|
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
|
||||||
|
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
|
||||||
|
assert.equal(isFromFilteredTimelineSet(undefined), false);
|
||||||
|
assert.equal(
|
||||||
|
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { formatFileSize, isCompressible } from './imageCompression';
|
import {
|
||||||
|
formatFileSize,
|
||||||
|
hasTransparency,
|
||||||
|
isAnimatedWebp,
|
||||||
|
isCompressible,
|
||||||
|
} from './imageCompression';
|
||||||
|
|
||||||
test('formatFileSize formats bytes below 1KB as B', () => {
|
test('formatFileSize formats bytes below 1KB as B', () => {
|
||||||
assert.equal(formatFileSize(0), '0 B');
|
assert.equal(formatFileSize(0), '0 B');
|
||||||
@@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => {
|
|||||||
test('isCompressible accepts raster image types', () => {
|
test('isCompressible accepts raster image types', () => {
|
||||||
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
|
assert.equal(isCompressible({ type: 'image/png' } as Blob), true);
|
||||||
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
|
assert.equal(isCompressible({ type: 'image/jpeg' } as Blob), true);
|
||||||
assert.equal(isCompressible({ type: 'image/gif' } as Blob), true);
|
|
||||||
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
|
assert.equal(isCompressible({ type: 'image/webp' } as Blob), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('isCompressible rejects svg, empty type, and non-images', () => {
|
test('isCompressible rejects svg, empty type, and non-images', () => {
|
||||||
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
|
assert.equal(isCompressible({ type: 'image/svg+xml' } as Blob), false);
|
||||||
|
// [Gitea #331] usually animated; a canvas keeps one frame
|
||||||
|
assert.equal(isCompressible({ type: 'image/gif' } as Blob), false);
|
||||||
|
assert.equal(isCompressible({ type: 'image/apng' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: '' } as Blob), false);
|
assert.equal(isCompressible({ type: '' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
|
assert.equal(isCompressible({ type: 'application/pdf' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
|
assert.equal(isCompressible({ type: 'text/plain' } as Blob), false);
|
||||||
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
|
assert.equal(isCompressible({ type: 'video/mp4' } as Blob), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const webpHeader = (chunk: string, flags = 0) => {
|
||||||
|
const b = new Uint8Array(32);
|
||||||
|
const put = (at: number, str: string) => b.set(new TextEncoder().encode(str), at);
|
||||||
|
put(0, 'RIFF');
|
||||||
|
put(8, 'WEBP');
|
||||||
|
put(12, chunk);
|
||||||
|
b[20] = flags;
|
||||||
|
return b;
|
||||||
|
};
|
||||||
|
|
||||||
|
test('isAnimatedWebp: VP8X with the animation flag (#331)', () => {
|
||||||
|
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x02)), true);
|
||||||
|
assert.equal(isAnimatedWebp(webpHeader('VP8X', 0x10)), false, 'alpha flag only');
|
||||||
|
assert.equal(isAnimatedWebp(webpHeader('VP8 ')), false, 'simple lossy');
|
||||||
|
assert.equal(isAnimatedWebp(new Uint8Array(8)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hasTransparency (#331)', () => {
|
||||||
|
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 4, 5, 6, 255])), false);
|
||||||
|
assert.equal(hasTransparency(new Uint8ClampedArray([1, 2, 3, 255, 0, 0, 0, 0])), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -8,11 +8,32 @@ export type CompressionResult = {
|
|||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Any raster image the browser can render to canvas can be compressed to JPEG.
|
// Raster images the browser can render to canvas can be compressed to JPEG.
|
||||||
// SVG is vector and must stay as-is; GIF animation is lost on canvas but static
|
// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually
|
||||||
// frames compress fine. Empty type string (undetected MIME) is excluded.
|
// animated, and a canvas keeps only the first frame, so they aren't offered.
|
||||||
|
// Empty type string (undetected MIME) is excluded.
|
||||||
|
const NOT_COMPRESSIBLE = new Set(['image/svg+xml', 'image/gif', 'image/apng']);
|
||||||
const isCompressibleType = (type: string): boolean =>
|
const isCompressibleType = (type: string): boolean =>
|
||||||
type.startsWith('image/') && type !== 'image/svg+xml' && type !== '';
|
type.startsWith('image/') && !NOT_COMPRESSIBLE.has(type);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure: whether a WebP file is animated. An animated WebP is a VP8X file
|
||||||
|
* whose header has the animation flag (byte 20, bit 0x02) and an ANIM chunk.
|
||||||
|
*/
|
||||||
|
export function isAnimatedWebp(bytes: Uint8Array): boolean {
|
||||||
|
const ascii = (at: number, len: number) => String.fromCharCode(...bytes.subarray(at, at + len));
|
||||||
|
if (bytes.length < 21 || ascii(0, 4) !== 'RIFF' || ascii(8, 4) !== 'WEBP') return false;
|
||||||
|
// Bit 0x02 of the VP8X flags byte (no-bitwise: test it arithmetically).
|
||||||
|
return ascii(12, 4) === 'VP8X' && Math.floor(bytes[20] / 2) % 2 === 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pure: whether RGBA pixel data has any pixel that isn't fully opaque. */
|
||||||
|
export function hasTransparency(rgba: Uint8ClampedArray): boolean {
|
||||||
|
for (let i = 3; i < rgba.length; i += 4) {
|
||||||
|
if (rgba[i] !== 255) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns true if this file can be compressed via canvas (any raster image type). */
|
/** Returns true if this file can be compressed via canvas (any raster image type). */
|
||||||
export function isCompressible(file: File | Blob): boolean {
|
export function isCompressible(file: File | Blob): boolean {
|
||||||
@@ -28,7 +49,8 @@ const JPEG_OUTPUT_TYPE = 'image/jpeg';
|
|||||||
*
|
*
|
||||||
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
|
* PNG is skipped entirely: it may carry an alpha channel, and re-encoding to
|
||||||
* JPEG composites transparency onto an opaque (black) background, corrupting the
|
* JPEG composites transparency onto an opaque (black) background, corrupting the
|
||||||
* image. Returning null makes callers fall back to uploading the lossless
|
* image. Any other image with transparent pixels, and animated WebP, are left
|
||||||
|
* alone for the same reason (#331). Returning null makes callers fall back to uploading the lossless
|
||||||
* original. The image is decoded with `imageOrientation: 'from-image'` so any
|
* original. The image is decoded with `imageOrientation: 'from-image'` so any
|
||||||
* EXIF orientation is baked into the pixels instead of being silently dropped.
|
* EXIF orientation is baked into the pixels instead of being silently dropped.
|
||||||
*/
|
*/
|
||||||
@@ -39,6 +61,15 @@ export async function compressImage(
|
|||||||
if (!isCompressibleType(file.type)) return null;
|
if (!isCompressibleType(file.type)) return null;
|
||||||
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
|
// Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency.
|
||||||
if (file.type === 'image/png') return null;
|
if (file.type === 'image/png') return null;
|
||||||
|
// [Gitea #331] An animated WebP would become a single still frame.
|
||||||
|
if (file.type === 'image/webp') {
|
||||||
|
try {
|
||||||
|
const head = new Uint8Array(await file.slice(0, 32).arrayBuffer());
|
||||||
|
if (isAnimatedWebp(head)) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let bitmap: ImageBitmap;
|
let bitmap: ImageBitmap;
|
||||||
try {
|
try {
|
||||||
@@ -60,6 +91,13 @@ export async function compressImage(
|
|||||||
}
|
}
|
||||||
ctx.drawImage(bitmap, 0, 0);
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
bitmap.close();
|
bitmap.close();
|
||||||
|
// [Gitea #331] JPEG has no alpha: a transparent WebP (sticker, logo) came out
|
||||||
|
// with a black background. Keep any image with transparency as the original.
|
||||||
|
try {
|
||||||
|
if (hasTransparency(ctx.getImageData(0, 0, width, height).data)) return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { dedupeInFlight } from './inFlight';
|
||||||
|
|
||||||
|
test('dedupeInFlight: one request per key while pending, a new one after (#318)', async () => {
|
||||||
|
const once = dedupeInFlight<number>();
|
||||||
|
let calls = 0;
|
||||||
|
let release!: () => void;
|
||||||
|
const gate = new Promise<void>((r) => {
|
||||||
|
release = r;
|
||||||
|
});
|
||||||
|
const run = async () => {
|
||||||
|
calls += 1;
|
||||||
|
await gate;
|
||||||
|
return calls;
|
||||||
|
};
|
||||||
|
const a = once('d1', run);
|
||||||
|
const b = once('d1', run);
|
||||||
|
const c = once('d2', run);
|
||||||
|
assert.equal(a, b);
|
||||||
|
release();
|
||||||
|
assert.deepEqual(await Promise.all([a, b, c]), [2, 2, 2]);
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
await once('d1', run);
|
||||||
|
assert.equal(calls, 3, 'settled requests are not reused');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dedupeInFlight: a rejection clears the key', async () => {
|
||||||
|
const once = dedupeInFlight<void>();
|
||||||
|
await assert.rejects(once('k', () => Promise.reject(new Error('boom'))));
|
||||||
|
await once('k', () => Promise.resolve());
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Share one in-flight promise per key: callers that ask again while a request
|
||||||
|
* for the same key is still pending get that request instead of a new one.
|
||||||
|
*/
|
||||||
|
export function dedupeInFlight<T>(): (key: string, run: () => Promise<T>) => Promise<T> {
|
||||||
|
const pending = new Map<string, Promise<T>>();
|
||||||
|
return (key, run) => {
|
||||||
|
const existing = pending.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
const p = run().finally(() => pending.delete(key));
|
||||||
|
pending.set(key, p);
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import {
|
||||||
|
plain,
|
||||||
DEFAULT_GROUPS,
|
DEFAULT_GROUPS,
|
||||||
kumaBeatTime,
|
kumaBeatTime,
|
||||||
kumaUrls,
|
kumaUrls,
|
||||||
@@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve
|
|||||||
'server-down',
|
'server-down',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('plain: strips Markdown syntax only, keeps ordinary #, _, > and * (#308)', () => {
|
||||||
|
assert.equal(
|
||||||
|
plain('join #lotus-general or #announcements:lotusguild.org'),
|
||||||
|
'join #lotus-general or #announcements:lotusguild.org',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
plain('ping @some_user about snake_case and C#'),
|
||||||
|
'ping @some_user about snake_case and C#',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
plain('calls -> fixed, > 5 people, 5 * 3 = 15'),
|
||||||
|
'calls -> fixed, > 5 people, 5 * 3 = 15',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
plain('# Heading\n* item one\n- item two\n> quoted'),
|
||||||
|
'Heading\nitem one\nitem two\nquoted',
|
||||||
|
);
|
||||||
|
assert.equal(plain('**bold**, *em*, _em_, `/sfu/get`'), 'bold, em, em, /sfu/get');
|
||||||
|
assert.equal(
|
||||||
|
plain('Read the <b>guide</b> <img src=x onerror=alert(1)> [docs](https://x.y)'),
|
||||||
|
'Read the guide docs',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Markdown-ish incident text as plain text for the Details line. */
|
/** Markdown-ish incident text as plain text for the Details line. */
|
||||||
const plain = (s: string): string =>
|
/**
|
||||||
|
* [Gitea #308] Markdown → plain text for the Details line. Strips only the
|
||||||
|
* syntax: removing every `*_\`#>` also broke "#lotus-general", "snake_case",
|
||||||
|
* "->" and "5 * 3". HTML tags are dropped (React escapes the text anyway).
|
||||||
|
*/
|
||||||
|
export const plain = (s: string): string =>
|
||||||
s
|
s
|
||||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
.replace(/!\[[^\]]*\]\([^)]*\)/g, '')
|
||||||
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
|
||||||
.replace(/[*_`#>]/g, '')
|
.replace(/<\/?[a-z][^>]*>/gi, '')
|
||||||
.replace(/\s+\n/g, '\n')
|
.replace(/^[ \t]{0,3}#{1,6}[ \t]+/gm, '')
|
||||||
|
.replace(/^[ \t]*>[ \t]?/gm, '')
|
||||||
|
.replace(/^[ \t]*[*+-][ \t]+/gm, '')
|
||||||
|
.replace(/`([^`\n]+)`/g, '$1')
|
||||||
|
.replace(/\*\*(?=\S)([^*\n]*?\S)\*\*/g, '$1')
|
||||||
|
.replace(/__(?=\S)([^_\n]*?\S)__/g, '$1')
|
||||||
|
.replace(/(^|[^\w*])\*(?=\S)([^*\n]*?\S)\*(?![\w*])/g, '$1$2')
|
||||||
|
.replace(/(^|[^\w])_(?=\S)([^_\n]*?\S)_(?!\w)/g, '$1$2')
|
||||||
|
.replace(/[ \t]+\n/g, '\n')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { analyzeLink, hostFromText, registrableDomain } from './linkSafety';
|
import { analyzeLink, hostFromText, previewUrlsFor, registrableDomain } from './linkSafety';
|
||||||
|
|
||||||
test('visible text that names another site is a mismatch', () => {
|
test('visible text that names another site is a mismatch', () => {
|
||||||
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
|
const r = analyzeLink('https://matrix.lotusguild.org/login', 'https://evil.example/login');
|
||||||
@@ -73,3 +73,25 @@ test('shared hosting suffixes are separate sites (#335)', () => {
|
|||||||
);
|
);
|
||||||
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
|
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => {
|
||||||
|
const urls = ['https://paypal.com'];
|
||||||
|
assert.deepEqual(
|
||||||
|
previewUrlsFor(urls, '<a href="https://evil.example/login">https://paypal.com</a>'),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
// An honest link keeps its preview.
|
||||||
|
assert.deepEqual(
|
||||||
|
previewUrlsFor(urls, '<a href="https://www.paypal.com/x">https://paypal.com</a>'),
|
||||||
|
urls,
|
||||||
|
);
|
||||||
|
assert.deepEqual(previewUrlsFor(urls, undefined), urls);
|
||||||
|
// Other links in the message keep theirs.
|
||||||
|
assert.deepEqual(
|
||||||
|
previewUrlsFor(
|
||||||
|
['https://paypal.com', 'https://lotusguild.org'],
|
||||||
|
'see <a href="https://evil.example">paypal.com</a> and https://lotusguild.org',
|
||||||
|
),
|
||||||
|
['https://lotusguild.org'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
|
|||||||
const other = shown.find((h) => registrableDomain(h) !== realSite);
|
const other = shown.find((h) => registrableDomain(h) !== realSite);
|
||||||
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
|
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ANCHOR_REG = /<a\s[^>]*href\s*=\s*(["'])(.*?)\1[^>]*>([\s\S]*?)<\/a>/gi;
|
||||||
|
const decodeEntities = (s: string): string =>
|
||||||
|
s
|
||||||
|
.replace(/<[^>]+>/g, '')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/&/g, '&');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #286] The URLs to build link previews for. Previews come from the
|
||||||
|
* plain `body`, but clicks follow `formatted_body`'s hrefs, and a sender
|
||||||
|
* controls both: text reading "https://paypal.com" linked to evil.example got
|
||||||
|
* a genuine PayPal card. Drop any body URL whose site is only *claimed* by a
|
||||||
|
* deceptive link's text.
|
||||||
|
*/
|
||||||
|
export const previewUrlsFor = (urls: string[], formattedBody: string | undefined): string[] => {
|
||||||
|
if (!formattedBody || urls.length === 0) return urls;
|
||||||
|
const claimed = new Set<string>();
|
||||||
|
Array.from(formattedBody.matchAll(ANCHOR_REG)).forEach((m) => {
|
||||||
|
const safety = analyzeLink(decodeEntities(m[3]), decodeEntities(m[2]));
|
||||||
|
if (safety?.mismatch && safety.shownHost) claimed.add(registrableDomain(safety.shownHost));
|
||||||
|
});
|
||||||
|
if (claimed.size === 0) return urls;
|
||||||
|
return urls.filter((u) => {
|
||||||
|
try {
|
||||||
|
return !claimed.has(registrableDomain(new URL(u).hostname));
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
Room,
|
Room,
|
||||||
RoomMember,
|
RoomMember,
|
||||||
TimelineEvents,
|
TimelineEvents,
|
||||||
|
IRoomTimelineData,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
|
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
|
||||||
@@ -847,3 +848,14 @@ export const guessPerfectParent = (
|
|||||||
|
|
||||||
return perfectParent;
|
return perfectParent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
|
||||||
|
* set. The media gallery and activity log register filtered sets on the room
|
||||||
|
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
|
||||||
|
* liveEvent: true, so listeners that mean "a new event in the room" saw each
|
||||||
|
* matching event twice (the timeline window drifted, notifications doubled).
|
||||||
|
* The room's main and thread sets have no filter.
|
||||||
|
*/
|
||||||
|
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
|
||||||
|
!!data?.timeline?.getTimelineSet()?.getFilter();
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
scheduleMessage,
|
scheduleMessage,
|
||||||
cancelScheduledMessage,
|
cancelScheduledMessage,
|
||||||
restartScheduledMessage,
|
restartScheduledMessage,
|
||||||
|
parseDelayedEvents,
|
||||||
|
groupScheduledByRoom,
|
||||||
} from './scheduledMessages';
|
} from './scheduledMessages';
|
||||||
|
|
||||||
// Minimal MatrixClient stub: records every authedRequest call and returns a
|
// Minimal MatrixClient stub: records every authedRequest call and returns a
|
||||||
@@ -119,3 +121,61 @@ test('cancel/restart url-encode the delay id', async () => {
|
|||||||
await cancelScheduledMessage(mx, 'a/b c');
|
await cancelScheduledMessage(mx, 'a/b c');
|
||||||
assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c');
|
assert.equal(calls[0][1], '/delayed_events/a%2Fb%20c');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseDelayedEvents: Synapse shape, messages only, sendAt from running_since + delay (#322)', () => {
|
||||||
|
const res = {
|
||||||
|
delayed_events: [
|
||||||
|
{
|
||||||
|
delay_id: 'syd_a',
|
||||||
|
room_id: '!r:x',
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { msgtype: 'm.text', body: 'hi' },
|
||||||
|
delay: 60000,
|
||||||
|
running_since: 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
delay_id: 'syd_call',
|
||||||
|
room_id: '!r:x',
|
||||||
|
type: 'org.matrix.msc3401.call.member',
|
||||||
|
state_key: '_@a:x_D',
|
||||||
|
content: {},
|
||||||
|
delay: 8000,
|
||||||
|
running_since: 1000,
|
||||||
|
},
|
||||||
|
{ delay_id: 'syd_bad', room_id: '!r:x', type: 'm.room.message', content: {} },
|
||||||
|
null,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
assert.deepEqual(parseDelayedEvents(res), [
|
||||||
|
{ delayId: 'syd_a', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' }, sendAt: 61000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseDelayedEvents: MSC `scheduled` shape and garbage', () => {
|
||||||
|
const item = {
|
||||||
|
delay_id: 'd',
|
||||||
|
room_id: '!r:x',
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { body: 'b' },
|
||||||
|
delay_ms: 5,
|
||||||
|
delayed_since_ts: 10,
|
||||||
|
};
|
||||||
|
assert.equal(parseDelayedEvents({ scheduled: [item] })[0].sendAt, 15);
|
||||||
|
assert.deepEqual(parseDelayedEvents(undefined), []);
|
||||||
|
assert.deepEqual(parseDelayedEvents({ delayed_events: 'nope' }), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupScheduledByRoom: groups and sorts by sendAt', () => {
|
||||||
|
const m = (delayId: string, roomId: string, sendAt: number) => ({
|
||||||
|
delayId,
|
||||||
|
roomId,
|
||||||
|
content: {},
|
||||||
|
sendAt,
|
||||||
|
});
|
||||||
|
const g = groupScheduledByRoom([m('b', '!1', 20), m('a', '!1', 10), m('c', '!2', 5)]);
|
||||||
|
assert.deepEqual(
|
||||||
|
g.get('!1')?.map((x) => x.delayId),
|
||||||
|
['a', 'b'],
|
||||||
|
);
|
||||||
|
assert.equal(g.get('!2')?.length, 1);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||||
|
import type { ScheduledMessage } from '../state/scheduledMessages';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Schedule a message via MSC4140 (delayed messages).
|
* Schedule a message via MSC4140 (delayed messages).
|
||||||
@@ -76,3 +77,61 @@ export async function sendScheduledMessageNow(mx: MatrixClient, delayId: string)
|
|||||||
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
{ prefix: '/_matrix/client/unstable/org.matrix.msc4140' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DelayedEventItem = {
|
||||||
|
delay_id?: unknown;
|
||||||
|
room_id?: unknown;
|
||||||
|
type?: unknown;
|
||||||
|
state_key?: unknown;
|
||||||
|
content?: unknown;
|
||||||
|
delay?: unknown;
|
||||||
|
delay_ms?: unknown;
|
||||||
|
running_since?: unknown;
|
||||||
|
delayed_since_ts?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #322] Pure: the user's pending scheduled messages from an MSC4140
|
||||||
|
* `GET /delayed_events` response. Synapse answers `{ delayed_events: [...] }`;
|
||||||
|
* the current MSC shape is `{ scheduled: [...] }`. Call-membership delayed
|
||||||
|
* events share the endpoint, so only non-state `m.room.message`s are kept.
|
||||||
|
*/
|
||||||
|
export function parseDelayedEvents(res: unknown): ScheduledMessage[] {
|
||||||
|
if (!res || typeof res !== 'object') return [];
|
||||||
|
const r = res as { delayed_events?: unknown; scheduled?: unknown };
|
||||||
|
const list = Array.isArray(r.delayed_events) ? r.delayed_events : r.scheduled;
|
||||||
|
if (!Array.isArray(list)) return [];
|
||||||
|
const out: ScheduledMessage[] = [];
|
||||||
|
list.forEach((raw: DelayedEventItem) => {
|
||||||
|
if (!raw || typeof raw !== 'object') return;
|
||||||
|
if (raw.type !== 'm.room.message' || raw.state_key !== undefined) return;
|
||||||
|
if (typeof raw.delay_id !== 'string' || typeof raw.room_id !== 'string') return;
|
||||||
|
if (!raw.content || typeof raw.content !== 'object') return;
|
||||||
|
const delay = Number(raw.delay ?? raw.delay_ms);
|
||||||
|
const since = Number(raw.running_since ?? raw.delayed_since_ts);
|
||||||
|
if (!Number.isFinite(delay) || !Number.isFinite(since)) return;
|
||||||
|
out.push({
|
||||||
|
delayId: raw.delay_id,
|
||||||
|
roomId: raw.room_id,
|
||||||
|
content: raw.content as IContent,
|
||||||
|
sendAt: since + delay,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The user's pending scheduled messages, as the server has them. */
|
||||||
|
export async function fetchScheduledMessages(mx: MatrixClient): Promise<ScheduledMessage[]> {
|
||||||
|
const res = await mx.http.authedRequest(Method.Get, '/delayed_events', undefined, undefined, {
|
||||||
|
prefix: '/_matrix/client/unstable/org.matrix.msc4140',
|
||||||
|
});
|
||||||
|
return parseDelayedEvents(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group by room, the shape of `scheduledMessagesAtom`. */
|
||||||
|
export function groupScheduledByRoom(msgs: ScheduledMessage[]): Map<string, ScheduledMessage[]> {
|
||||||
|
const map = new Map<string, ScheduledMessage[]>();
|
||||||
|
msgs.forEach((m) => map.set(m.roomId, [...(map.get(m.roomId) ?? []), m]));
|
||||||
|
map.forEach((list) => list.sort((a, b) => a.sendAt - b.sendAt));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|||||||
@@ -85,3 +85,9 @@ export function languageName(code: string, uiLocale = 'en'): string {
|
|||||||
export function isSupportedTargetLang(code: string): boolean {
|
export function isSupportedTargetLang(code: string): boolean {
|
||||||
return CURATED_NAME.has(normalizeLang(code));
|
return CURATED_NAME.has(normalizeLang(code));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Pure: why a manual Translate did nothing (#287). */
|
||||||
|
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
||||||
|
from
|
||||||
|
? `This message is already in ${languageName(targetLang) || targetLang}.`
|
||||||
|
: "Couldn't tell what language this message is in, so it wasn't translated.";
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { translateSkipMessage } from './langUtils';
|
||||||
|
|
||||||
|
test('translateSkipMessage explains a Translate that did nothing (#287)', () => {
|
||||||
|
assert.equal(translateSkipMessage('en', 'en'), 'This message is already in English.');
|
||||||
|
assert.match(translateSkipMessage(undefined, 'en'), /^Couldn't tell what language/);
|
||||||
|
});
|
||||||
+1
-1
@@ -43,7 +43,7 @@ if ('serviceWorker' in navigator && swProtocolOk) {
|
|||||||
|
|
||||||
const sendSessionToSW = () => {
|
const sendSessionToSW = () => {
|
||||||
const session = getFallbackSession();
|
const session = getFallbackSession();
|
||||||
pushSessionToSW(session?.baseUrl, session?.accessToken);
|
pushSessionToSW(session?.baseUrl, session?.accessToken, session?.userId);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The dev worker is an ES module (it imports workbox), so it must be
|
// The dev worker is an ES module (it imports workbox), so it must be
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
|
export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) {
|
||||||
if (!('serviceWorker' in navigator)) return;
|
if (!('serviceWorker' in navigator)) return;
|
||||||
if (!navigator.serviceWorker.controller) return;
|
if (!navigator.serviceWorker.controller) return;
|
||||||
|
|
||||||
@@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) {
|
|||||||
type: 'setSession',
|
type: 'setSession',
|
||||||
accessToken,
|
accessToken,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
|
userId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching';
|
||||||
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
import { SHARE_TARGET_PATH, stashSharedForm } from './swShare';
|
||||||
import { sendNotificationReply } from './swReply';
|
import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply';
|
||||||
|
|
||||||
export type {};
|
export type {};
|
||||||
declare const self: ServiceWorkerGlobalScope & {
|
declare const self: ServiceWorkerGlobalScope & {
|
||||||
@@ -40,6 +40,8 @@ precacheAndRoute(self.__WB_MANIFEST);
|
|||||||
type SessionInfo = {
|
type SessionInfo = {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
|
/** [Gitea #299] whose session this is, so a notification reply uses its own account */
|
||||||
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -63,9 +65,13 @@ async function cleanupDeadClients() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSession(clientId: string, accessToken: string, baseUrl: string) {
|
function setSession(clientId: string, accessToken: string, baseUrl: string, userId?: unknown) {
|
||||||
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
if (typeof accessToken === 'string' && typeof baseUrl === 'string') {
|
||||||
sessions.set(clientId, { accessToken, baseUrl });
|
sessions.set(clientId, {
|
||||||
|
accessToken,
|
||||||
|
baseUrl,
|
||||||
|
userId: typeof userId === 'string' ? userId : undefined,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Logout or invalid session
|
// Logout or invalid session
|
||||||
sessions.delete(clientId);
|
sessions.delete(clientId);
|
||||||
@@ -130,10 +136,10 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|||||||
const client = event.source as Client | null;
|
const client = event.source as Client | null;
|
||||||
if (!client) return;
|
if (!client) return;
|
||||||
|
|
||||||
const { type, accessToken, baseUrl } = event.data || {};
|
const { type, accessToken, baseUrl, userId } = event.data || {};
|
||||||
|
|
||||||
if (type === 'setSession') {
|
if (type === 'setSession') {
|
||||||
setSession(client.id, accessToken, baseUrl);
|
setSession(client.id, accessToken, baseUrl, userId);
|
||||||
cleanupDeadClients();
|
cleanupDeadClients();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -155,15 +161,19 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => {
|
|||||||
if (text) {
|
if (text) {
|
||||||
event.waitUntil(
|
event.waitUntil(
|
||||||
sendNotificationReply(
|
sendNotificationReply(
|
||||||
Array.from(sessions.values()).pop(),
|
// [Gitea #299] The notification's own account, not the newest session.
|
||||||
|
pickReplySession(
|
||||||
|
Array.from(sessions.values()),
|
||||||
|
typeof data.userId === 'string' ? data.userId : undefined,
|
||||||
|
),
|
||||||
data.roomId,
|
data.roomId,
|
||||||
data.threadId,
|
data.threadId,
|
||||||
text,
|
text,
|
||||||
).then((ok) => {
|
).then((result) => {
|
||||||
if (!ok) {
|
if (result !== 'sent') {
|
||||||
// Let the user know it didn't go out, and keep their text reachable.
|
// Let the user know it didn't go out, and keep their text reachable.
|
||||||
return self.registration.showNotification('Reply not sent', {
|
return self.registration.showNotification('Reply not sent', {
|
||||||
body: `Couldn't send "${text.slice(0, 60)}" — open the app to retry.`,
|
body: replyFailureText(result, text),
|
||||||
tag: `swreply-fail-${data.roomId}`,
|
tag: `swreply-fail-${data.roomId}`,
|
||||||
data: { path },
|
data: { path },
|
||||||
});
|
});
|
||||||
|
|||||||
+60
-30
@@ -1,6 +1,12 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { buildReplyContent, replySendUrl, sendNotificationReply } from './swReply';
|
import {
|
||||||
|
buildReplyContent,
|
||||||
|
pickReplySession,
|
||||||
|
replyFailureText,
|
||||||
|
replySendUrl,
|
||||||
|
sendNotificationReply,
|
||||||
|
} from './swReply';
|
||||||
|
|
||||||
test('thread replies carry the m.thread relation, room replies do not', () => {
|
test('thread replies carry the m.thread relation, room replies do not', () => {
|
||||||
assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} });
|
assert.deepEqual(buildReplyContent('hi'), { msgtype: 'm.text', body: 'hi', 'm.mentions': {} });
|
||||||
@@ -19,40 +25,64 @@ test('send URL encodes the room id against the session base URL', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('sendNotificationReply PUTs with the bearer token and reports ok / failure', async () => {
|
test('sendNotificationReply checks encryption, then PUTs with the bearer token (#203, #299)', async () => {
|
||||||
const calls: { url: string; init: RequestInit }[] = [];
|
const calls: { url: string; init: RequestInit }[] = [];
|
||||||
const fetchOk = (async (url: string, init: RequestInit) => {
|
const fetchFor = (encStatus: number, sendOk = true) =>
|
||||||
calls.push({ url, init });
|
(async (url: string, init: RequestInit = {}) => {
|
||||||
return { ok: true } as Response;
|
calls.push({ url, init });
|
||||||
}) as unknown as typeof fetch;
|
if (url.includes('/state/m.room.encryption/')) {
|
||||||
assert.equal(
|
return { ok: encStatus === 200, status: encStatus } as Response;
|
||||||
await sendNotificationReply(
|
}
|
||||||
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
return { ok: sendOk, status: sendOk ? 200 : 500 } as Response;
|
||||||
'!r:hs',
|
}) as unknown as typeof fetch;
|
||||||
'$t',
|
const session = { accessToken: 'tok', baseUrl: 'https://hs' };
|
||||||
'yo',
|
|
||||||
fetchOk,
|
assert.equal(await sendNotificationReply(session, '!r:hs', '$t', 'yo', fetchFor(404)), 'sent');
|
||||||
),
|
assert.match(calls[0].url, /\/rooms\/!r%3Ahs\/state\/m\.room\.encryption\/$/);
|
||||||
true,
|
const put = calls[1];
|
||||||
);
|
assert.equal(put.init.method, 'PUT');
|
||||||
assert.equal(calls[0].init.method, 'PUT');
|
assert.equal((put.init.headers as Record<string, string>).Authorization, 'Bearer tok');
|
||||||
assert.equal((calls[0].init.headers as Record<string, string>).Authorization, 'Bearer tok');
|
|
||||||
assert.match(
|
assert.match(
|
||||||
calls[0].url,
|
put.url,
|
||||||
/^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/,
|
/^https:\/\/hs\/_matrix\/client\/v3\/rooms\/!r%3Ahs\/send\/m\.room\.message\/swreply-/,
|
||||||
);
|
);
|
||||||
assert.equal(JSON.parse(calls[0].init.body as string)['m.relates_to'].event_id, '$t');
|
assert.equal(JSON.parse(put.init.body as string)['m.relates_to'].event_id, '$t');
|
||||||
|
|
||||||
const fetchFail = (async () => ({ ok: false }) as Response) as unknown as typeof fetch;
|
// Encryption turned on since the notification was shown: nothing is sent.
|
||||||
|
calls.length = 0;
|
||||||
assert.equal(
|
assert.equal(
|
||||||
await sendNotificationReply(
|
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(200)),
|
||||||
{ accessToken: 'tok', baseUrl: 'https://hs' },
|
'encrypted',
|
||||||
'!r:hs',
|
);
|
||||||
undefined,
|
assert.equal(calls.length, 1);
|
||||||
'yo',
|
// Can't tell (server error): fail safe, nothing is sent.
|
||||||
fetchFail,
|
calls.length = 0;
|
||||||
),
|
assert.equal(
|
||||||
false,
|
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(500)),
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
assert.equal(calls.length, 1);
|
||||||
|
assert.equal(
|
||||||
|
await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(404, false)),
|
||||||
|
'failed',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchFor(404)),
|
||||||
|
'no-session',
|
||||||
);
|
);
|
||||||
assert.equal(await sendNotificationReply(undefined, '!r:hs', undefined, 'yo', fetchOk), false);
|
});
|
||||||
|
|
||||||
|
test('pickReplySession: the notification account only (#299)', () => {
|
||||||
|
const a = { accessToken: 'a', baseUrl: 'https://hs', userId: '@a:hs' };
|
||||||
|
const b = { accessToken: 'b', baseUrl: 'https://hs', userId: '@b:hs' };
|
||||||
|
assert.equal(pickReplySession([a, b], '@a:hs'), a);
|
||||||
|
assert.equal(pickReplySession([b], '@a:hs'), undefined, 'logged out of @a here');
|
||||||
|
assert.equal(pickReplySession([a], undefined), a, 'old notification, one session');
|
||||||
|
assert.equal(pickReplySession([a, b], undefined), undefined, 'old notification, ambiguous');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replyFailureText says why', () => {
|
||||||
|
assert.match(replyFailureText('encrypted', 'hi'), /now encrypted/);
|
||||||
|
assert.match(replyFailureText('no-session', 'hi'), /no longer signed in/);
|
||||||
|
assert.match(replyFailureText('failed', 'hi'), /retry/);
|
||||||
});
|
});
|
||||||
|
|||||||
+48
-11
@@ -1,9 +1,42 @@
|
|||||||
/**
|
/**
|
||||||
* [Gitea #203] Build and send an inline reply typed into a notification's
|
* [Gitea #203] Build and send an inline reply typed into a notification's
|
||||||
* text action. Lives outside sw.ts so it can be unit-tested; the SW passes in
|
* text action. Lives outside sw.ts so it can be unit-tested; the SW passes in
|
||||||
* its newest known session and the global fetch.
|
* the notification account's session and the global fetch.
|
||||||
*/
|
*/
|
||||||
export type SwReplySession = { accessToken: string; baseUrl: string };
|
export type SwReplySession = { accessToken: string; baseUrl: string; userId?: string };
|
||||||
|
|
||||||
|
export type SwReplyResult = 'sent' | 'failed' | 'encrypted' | 'no-session';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #299] The session to reply with: the notification's own account. A
|
||||||
|
* notification without a user id (shown before this change) may only use the
|
||||||
|
* session if it is the only one, so a reply never posts as another account.
|
||||||
|
*/
|
||||||
|
export const pickReplySession = (
|
||||||
|
sessions: SwReplySession[],
|
||||||
|
userId: string | undefined,
|
||||||
|
): SwReplySession | undefined => {
|
||||||
|
if (userId) return sessions.filter((x) => x.userId === userId).pop();
|
||||||
|
return sessions.length === 1 ? sessions[0] : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** What the "Reply not sent" notification says for each failure. */
|
||||||
|
export const replyFailureText = (result: SwReplyResult, text: string): string => {
|
||||||
|
const quoted = `"${text.slice(0, 60)}"`;
|
||||||
|
if (result === 'encrypted') {
|
||||||
|
return `This room is now encrypted, so ${quoted} wasn't sent. Open the app to reply.`;
|
||||||
|
}
|
||||||
|
if (result === 'no-session') {
|
||||||
|
return `You're no longer signed in to that account here, so ${quoted} wasn't sent.`;
|
||||||
|
}
|
||||||
|
return `Couldn't send ${quoted}. Open the app to retry.`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const encryptionStateUrl = (baseUrl: string, roomId: string): string =>
|
||||||
|
new URL(
|
||||||
|
`/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/state/m.room.encryption/`,
|
||||||
|
baseUrl,
|
||||||
|
).href;
|
||||||
|
|
||||||
export const buildReplyContent = (text: string, threadId?: string): Record<string, unknown> => {
|
export const buildReplyContent = (text: string, threadId?: string): Record<string, unknown> => {
|
||||||
const content: Record<string, unknown> = { msgtype: 'm.text', body: text, 'm.mentions': {} };
|
const content: Record<string, unknown> = { msgtype: 'm.text', body: text, 'm.mentions': {} };
|
||||||
@@ -30,20 +63,24 @@ export async function sendNotificationReply(
|
|||||||
threadId: string | undefined,
|
threadId: string | undefined,
|
||||||
text: string,
|
text: string,
|
||||||
fetchFn: typeof fetch = fetch,
|
fetchFn: typeof fetch = fetch,
|
||||||
): Promise<boolean> {
|
): Promise<SwReplyResult> {
|
||||||
if (!session?.accessToken || !session.baseUrl) return false;
|
if (!session?.accessToken || !session.baseUrl) return 'no-session';
|
||||||
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
const headers = { Authorization: `Bearer ${session.accessToken}` };
|
||||||
try {
|
try {
|
||||||
|
// [Gitea #299] The SW can't encrypt, and the reply action was offered when
|
||||||
|
// the notification was shown — possibly hours ago. Re-check now: only a
|
||||||
|
// definite "no encryption state" (404) lets the plain reply go out.
|
||||||
|
const enc = await fetchFn(encryptionStateUrl(session.baseUrl, roomId), { headers });
|
||||||
|
if (enc.ok) return 'encrypted';
|
||||||
|
if (enc.status !== 404) return 'failed';
|
||||||
|
const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
|
const res = await fetchFn(replySendUrl(session.baseUrl, roomId, txn), {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
Authorization: `Bearer ${session.accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(buildReplyContent(text, threadId)),
|
body: JSON.stringify(buildReplyContent(text, threadId)),
|
||||||
});
|
});
|
||||||
return res.ok;
|
return res.ok ? 'sent' : 'failed';
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return 'failed';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export enum AccountDataEvent {
|
|||||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||||
LotusSettings = 'io.lotus.settings',
|
LotusSettings = 'io.lotus.settings',
|
||||||
|
|
||||||
|
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
||||||
|
// device can lift a mute set on another.
|
||||||
|
LotusMuteTimers = 'io.lotus.mute_timers',
|
||||||
|
|
||||||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||||
|
|
||||||
CrossSigningMaster = 'm.cross_signing.master',
|
CrossSigningMaster = 'm.cross_signing.master',
|
||||||
|
|||||||
Reference in New Issue
Block a user