diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 6cebd5378..0215a0d38 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -1,7 +1,7 @@ # Lotus Chat — Feature Reference Everything added to Lotus Chat beyond upstream Cinny v4.12.1. -Last updated: July 2026. +Last updated: October 2026. --- @@ -28,7 +28,8 @@ Last updated: July 2026. 19. [Infrastructure](#infrastructure) 20. [Localization](#localization) 21. [Desktop App Features](#desktop-app-features) -22. [Key Custom Files](#key-custom-files) +22. [June–October 2026 Additions](#juneoctober-2026-additions) +23. [Key Custom Files](#key-custom-files) --- @@ -1522,6 +1523,98 @@ Checks for a new desktop release every 12h and offers a one-click update. `Tauri --- +## June–October 2026 Additions + +Features shipped since the sections above were last updated (Gitea #294). Each +line names the commit that introduced it. + +### Calls + +- **Selectable ringtone + volume:** pick the incoming-call ringtone (`4a875884`). +- **In-call incoming-call banner:** a small banner for a second call instead of a full ring (`c67aed01`). +- **Denoise tester:** Settings → Calls lets you audition the ML models and calibrate the noise gate (`14cfa021`). +- **Mic level meter** on the mute button (`3449338b`, #146). +- **"You're muted" nudge** when you talk into a muted mic (`73b1d1e3`, #117). +- **Undeafen catch-up toast:** what you missed while deafened (`33e16e85`, #128). +- **Admin turned off screen share / camera:** a toast explains why your share or camera stopped (`34574178`, #223). +- **Voice-limit / audio-only chip** in the room header (`7f17940d`, #134). +- **"N Live" pill names** who is in the call on hover / focus (`a475531b`, #157). +- **Live-call dot on the space rail** (`4fe9c870`, #148). +- **Missed-call line** in the timeline for DMs and small groups (`c921f115`, #162). +- **Answer with camera or audio-only:** answering says when it turns the camera on (`d2f56817`, #135). +- **Audio output quick-switch** in the call bar (`98c80f36`, #119). +- **Call-ended summary toast:** "Call ended · 41 min · connection was good" (`af1c0ee1`, #143). +- **No WebRTC on desktop:** offers "Open in browser" instead of a dead end (`fff583fc`). +- **Call page on its own origin** (desktop, opt-in) (`7d7a379c`, #43). + +### Messaging & composer + +- **Reactions collapse** to one row with a +N chip (`bf05751e`, #138). +- **Consecutive photos / videos** render as one gallery grid (`bd8c79e0`, #137). +- **Inline audio playback** for `m.file` audio attachments (`eb34b047`). +- **Recently used GIFs** in the GIF picker (`5b58e5fe`). +- **Pasted image → caption field:** a single pasted or dropped image lands in its caption (`0c45bde8`, #129). +- **Offer to format pasted code** as a code block (`75d55e86`, #107). +- **Tap to preview stickers / GIFs** on touch, tap again to send (`8d11a62e`, #147). +- **Link-mismatch confirmation** before opening a link whose text names another site (`74d8e311`, #122). +- **Embed fallback** for hung embeds and deleted X posts (`ba5b1ffe`, #200). +- **"Back to where you were"** after jumping in the timeline (`7da91bd8`, #127). +- **Offline outbox:** unsent messages survive a reload and retry (`02d86cae`, #112). +- **Terms-consent explanation** when "Failed to send" is the server wanting its terms accepted (`c96c47dd`). + +### Search & navigation + +- **Quick switcher (Ctrl/⌘+K)**, with a configurable key (`9d4679d2`). +- **Search filters:** `has:image/file/video`, recent searches, pinned only (`1ee0f0b5`). +- **Search operators:** `in:`, `before:` / `after:`, `has:`, `is:pinned` (`75951f90`, #106). +- **Explore:** featured Lotus Guild space and the matrixrooms.info directory (`b1ecb0c4`). + +### Rooms & threads + +- **Disappearing messages** (MSC1763 `m.room.retention`) (`82e52e1b`). +- **Mark as Unread** (MSC2867) and **Low Priority** rooms (`d46b91b1`). +- **"Who can see this"** line under the room name in Room Settings (`ad1cbcf7`, #133). +- **Threads: Mark all read** in the threads list (`fdec3ed7`, #165). + +### Media viewer + +- **Swipe** between items in the full-screen viewer (`cf80729a`, #164). +- **Two-finger pinch zoom** (`39589b40`, #164). + +### Profile, privacy & safety + +- **Report User** (MSC4260) (`26f90087`). +- **Active now / Last active** from presence (`dea1f7af`, #150). +- **Delete all my messages** in a room (`edb46247`, #169). +- **Self-hosted VT323 font** instead of Google Fonts (`6f250353`, #214). +- **Device-security nudge:** verify this device / set up key backup (`6a7627fa`, #110, #123). +- **Homeserver status banner** from Uptime Kuma (`6ae00872`, #124). + +### Appearance + +- **Custom accent colour** for non-TDS themes (`3c4842df`, P5-1). +- **Theme presets:** Cyberpunk, Ocean, Blood Red, Matrix, Midnight (`65e24bd4`, P5-2). + +### Mobile & PWA + +- **Saved Messages** from the room header's More Options menu (`8c711f5f`). +- **Long-press bottom sheet** of message actions (`53a80adb`, #166). +- **Haptics** on PTT press / release and on reactions (`492b57c6`, #125). +- **Android share target:** share into Lotus Chat from other apps (`96a97a2f`, #155). + +### Notifications + +- **Inline reply** from a browser notification (unencrypted rooms) (`d4420905`, #203). + +### Desktop app + +- **Tray icon mirrors call state** (`25fa0f6e`, cinny-desktop #4). +- **Start minimized** under Launch on login (`4a982389`, cinny-desktop #3). +- **First-close choice:** asks once what closing the window should do (`359c79a4`, cinny-desktop #5). +- **Keychain mirror:** login tokens also kept in the OS keychain (`9b9f33b2`, #105). + +--- + ## Key Custom Files | File | Purpose | diff --git a/LOTUS_REFERENCE.md b/LOTUS_REFERENCE.md index 2ab8986c9..bead65518 100644 --- a/LOTUS_REFERENCE.md +++ b/LOTUS_REFERENCE.md @@ -34,7 +34,7 @@ This file keeps only what a contributor needs to have open while working: the tw ## 🚫 Blocked Features (server / upstream gated) -Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running). +Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.162.0 (2026-10-18, Gitea #294):** live location (`msc3489`/`msc3672`) and relation redaction (`msc3892`) are still unadvertised; thread subscriptions (`msc4306`), sticky events (`msc4354`) and QR login (`msc4108`) are still `false`. New: `org.matrix.msc3026.busy_presence` is advertised as `false`, and enabling it would give Do Not Disturb a real presence state (#283). `msc3266` and `msc4222` are no longer listed, but room preview still works (`im.nheko.summary` and `v1/room_summary` both answer 200). (Previous check: 1.157.1 on 2026-07-23, no change then.) - **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share. - **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable. diff --git a/e2e/local-homeserver.spec.ts b/e2e/local-homeserver.spec.ts index 0df5c0d1a..7dcbd6f63 100644 --- a/e2e/local-homeserver.spec.ts +++ b/e2e/local-homeserver.spec.ts @@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => { expect(console_.unexpected()).toEqual([]); }); + test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => { + const console_ = collectConsole(page); + const room = await createRoom(alice, 'Poll Room'); + await loginUI(page, alice); + await openRoom(page, room); + const question = uniq('Lunch? '); + await page.getByRole('button', { name: 'Create poll' }).first().click(); + await page.getByPlaceholder('Ask a question…').fill(question); + await page.getByLabel('Option 1', { exact: true }).fill('Pizza'); + await page.getByLabel('Option 2', { exact: true }).fill('Tacos'); + await page.getByRole('button', { name: 'Create Poll' }).last().click(); + const poll = page.locator('[data-message-id]', { hasText: question }).last(); + await expect(poll).toBeVisible(); + type Ev = { + type: string; + event_id: string; + content: { 'm.relates_to'?: { event_id?: string } }; + }; + const events = async () => + ( + await api<{ chunk: Ev[] }>( + 'GET', + `/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`, + alice.token, + ) + ).chunk; + // Vote once the poll has reached the server (its row may still carry the local id). + await expect + .poll(async () => (await events()).some((e) => /poll\.start/.test(e.type))) + .toBe(true); + await poll.getByText('Tacos', { exact: true }).first().click(); + await expect + .poll(async () => { + const chunk = await events(); + const start = chunk.find((e) => /poll\.start/.test(e.type)); + return chunk.some( + (e) => + /poll\.response/.test(e.type) && + e.content['m.relates_to']?.event_id === start?.event_id && + start?.event_id.startsWith('$'), + ); + }) + .toBe(true); + expect(console_.unexpected()).toEqual([]); + }); + test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => { const room = await createRoom(alice, 'Scroll Room'); await Array.from({ length: 40 }).reduce>( diff --git a/e2e/security-nudge.spec.ts b/e2e/security-nudge.spec.ts index f9dcd73c9..e664eb46b 100644 --- a/e2e/security-nudge.spec.ts +++ b/e2e/security-nudge.spec.ts @@ -29,6 +29,32 @@ async function settled(page: Page) { await page.waitForTimeout(2000); } +/** [Gitea #307] Serve a Kuma status page for `localhost` with one posted announcement. */ +async function mockAnnouncement(page: Page) { + await page.route(/\/config\.json(\?.*)?$/, async (route) => { + const res = await route.fetch(); + const cfg = await res.json(); + cfg.statusPages = { localhost: { url: 'https://isitup.lotusguild.org', slug: 'matrix' } }; + await route.fulfill({ response: res, json: cfg }); + }); + await page.route(/isitup\.lotusguild\.org\/api\/status-page\//, async (route) => { + const cors = { 'access-control-allow-origin': '*' }; + if (route.request().url().includes('/heartbeat/')) { + await route.fulfill({ headers: cors, json: { heartbeatList: {}, uptimeList: {} } }); + return; + } + await route.fulfill({ + headers: cors, + json: { + config: { slug: 'matrix' }, + incidents: [{ id: 7, title: 'Community call on Friday', content: '', style: 'info' }], + maintenanceList: [], + publicGroupList: [], + }, + }); + }); +} + test.describe('security nudge (#110, #123)', () => { let user: TestUser; @@ -60,6 +86,22 @@ test.describe('security nudge (#110, #123)', () => { await expect(page.getByText('Device Verification').first()).toBeVisible(); }); + test('a dismissed announcement no longer hides the nudge (#307)', async ({ page }) => { + test.setTimeout(200_000); + await mockAnnouncement(page); + await loginUI(page, user); + await pastGrace(page); + await settled(page); + // The announcement wins the one strip slot until it's dismissed. + const announcement = page.getByRole('status').filter({ hasText: 'Community call on Friday' }); + await expect(announcement).toBeVisible(); + await expect(strip(page)).toHaveCount(0); + await announcement.getByRole('button', { name: 'Dismiss' }).click(); + await page.reload(); + await settled(page); + await expect(strip(page)).toContainText("Your encryption keys aren't backed up"); + }); + test('"Not now" snoozes it across a reload', async ({ page }) => { test.setTimeout(200_000); await loginUI(page, user); diff --git a/public/manifest.json b/public/manifest.json index c179b848b..dc6efac83 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -5,7 +5,6 @@ "dir": "auto", "lang": "en-US", "display": "standalone", - "orientation": "portrait", "start_url": "./", "background_color": "#0a0a0a", "theme_color": "#980000", @@ -90,7 +89,7 @@ "name": "New Message", "short_name": "DM", "description": "Open a new direct message", - "url": "/", + "url": "./direct/create/", "icons": [ { "src": "public/res/android/android-chrome-96x96.png", diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index 17e054da5..6fa7b5b7d 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion'; import { useSetting } from '../state/hooks/settings'; import { useCallPreferences } from '../state/hooks/callPreferences'; 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 { StateEvent } from '../../types/matrix/room'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; @@ -86,9 +86,12 @@ import { CallAvatarAnimation } from '../styles/Animations.css'; import { webRTCSupported } from '../utils/rtc'; import { CallsUnavailableMessage } from '../features/call/CallsUnavailable'; import { zIndices } from '../styles/zIndex'; +import { useDenoiseNotices } from '../hooks/useDenoiseNotices'; const PIP_MIN_W = 200; const PIP_MIN_H = 112; +/** Default PiP top: just below the 64px-ish room header. */ +const PIP_DEFAULT_TOP = 72; type Corner = 'se' | 'sw' | 'ne' | 'nw'; @@ -111,6 +114,8 @@ type IncomingCallInfo = { intent?: string; notificationType: RTCNotificationType; refEventId: string; + /** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */ + notificationEventId?: string; }; type IncomingCallProps = { dm: boolean; @@ -283,7 +288,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr fill="Soft" size="400" radii="400" - onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} + onClick={() => + dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore() + } before={} > @@ -455,7 +462,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming size="300" radii="300" outlined - onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())} + onClick={() => + dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore() + } before={} > @@ -496,6 +505,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback( async (event, room, toStartOfTimeline, removed, data) => { + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] // only process rtc notification reference events. // we do not want to wait to decrypt all events. if (event.getRelation()?.rel_type !== RelationType.Reference) return; @@ -596,6 +606,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) : undefined, notificationType, refEventId, + notificationEventId: event.getId(), }; // [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 // ring, so an absent one gets a short grace period before giving up. const myUserId = mx.getSafeUserId(); - if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) { + if ( + declinedRing(room, [refEventId, event.getId()], myUserId) || + hasActiveCallMembership(room, myUserId) + ) { return; } if (hasActiveCallMembership(room, sender)) { @@ -613,7 +627,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) } setTimeout(() => { 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; } if (hasActiveCallMembership(room, sender)) setCallInfo(info); @@ -683,9 +700,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps) data, ) => { if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; + if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) { const related = event.getRelation()?.event_id; - if (!related || related === refEventId) dismiss(); // declined elsewhere + // [Gitea #313] Correct clients reference the notification; older Lotus + // builds referenced the call membership. Accept both. + if (!related || related === refEventId || related === callInfo.notificationEventId) + dismiss(); // declined elsewhere } }; session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships); @@ -793,6 +814,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { useCallPolicyRevokedToast(embed, joined); useCallEndedToast(embed); useScreenshareNotices(embed); + useDenoiseNotices(embed); // [Gitea #43] The in-frame screenshare button follows the same room policy // as the host bar's (hidden where the server would refuse the share). const { allowScreenshare } = useRoomCallPolicy(embed.room); @@ -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.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`; } else { - el.style.left = `${window.innerWidth - 280 - 16}px`; - el.style.top = `${window.innerHeight - 158 - 72}px`; + // [Gitea #323] First use: top-right under the room header, over the + // timeline. Bottom-right sat on the composer (Send / Emoji, and on + // phones the whole message box), so typing landed on the call. + el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`; + el.style.top = `${PIP_DEFAULT_TOP}px`; } el.style.width = '280px'; el.style.height = '158px'; diff --git a/src/app/components/CloseBehaviorPrompt.tsx b/src/app/components/CloseBehaviorPrompt.tsx index b1bc7bbf7..6983b2e06 100644 --- a/src/app/components/CloseBehaviorPrompt.tsx +++ b/src/app/components/CloseBehaviorPrompt.tsx @@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle'; export function CloseBehaviorPrompt() { const [open, setOpen] = useState(false); const [launchOnLogin, setLaunchOnLogin] = useState(false); + // [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the + // tray" would hide the window with no icon to bring it back. Older desktop + // builds lack the command; assume a tray then (the old behaviour). + const [hasTray, setHasTray] = useState(true); const modalStyle = useModalStyle(420); // Tells the native side we're listening (see get_close_behavior). @@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() { tauriInvoke()?.('get_close_behavior').catch(() => undefined); }, []); - useTauriEvent('lotus-close-requested', () => setOpen(true)); + useTauriEvent('lotus-close-requested', () => { + const ask = tauriInvoke()?.('has_tray_host'); + if (!ask) { + setOpen(true); + return; + } + ask + .then((v) => setHasTray(v !== false)) + .catch(() => setHasTray(true)) + .finally(() => setOpen(true)); + }); const choose = useCallback( (value: 'tray' | 'quit') => { @@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() { setOpen(false), @@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() { - Lotus Chat can keep running in the system tray when you close this window, so - messages, notifications and calls still reach you. Open it again from the tray icon. + {hasTray + ? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.' + : "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."} Start Lotus Chat when I sign in to my computer - - + {hasTray ? ( + <> + + + + ) : ( + <> + + + + )} You can change this in Settings → General. diff --git a/src/app/components/ConsentRequiredPrompt.tsx b/src/app/components/ConsentRequiredPrompt.tsx index d683c81b1..8ba637fe9 100644 --- a/src/app/components/ConsentRequiredPrompt.tsx +++ b/src/app/components/ConsentRequiredPrompt.tsx @@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() { const [opened, setOpened] = useState(false); const [blockedCount, setBlockedCount] = useState(0); const snoozedUntil = useRef(0); + // [Gitea #311] Closing via Retry also deactivates the focus trap; that must + // not snooze, or a retry that fails again (terms not accepted yet) is silent. + const closingForRetry = useRef(false); const blocked = useRef(new Map()); const modalStyle = useModalStyle(440); const server = mx.getDomain() ?? 'Your homeserver'; @@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() { }, [consentUri]); const retry = useCallback(() => { + closingForRetry.current = true; + snoozedUntil.current = 0; setOpen(false); setOpened(false); const pending = Array.from(blocked.current.entries()); @@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() { focusTrapOptions={{ initialFocus: '#consent-review', fallbackFocus: '#consent-later', - onDeactivate: later, + onDeactivate: () => { + if (closingForRetry.current) { + closingForRetry.current = false; + return; + } + later(); + }, clickOutsideDeactivates: true, escapeDeactivates: true, }} diff --git a/src/app/components/LogoutDialog.tsx b/src/app/components/LogoutDialog.tsx index 921e595ec..0dc15ae98 100644 --- a/src/app/components/LogoutDialog.tsx +++ b/src/app/components/LogoutDialog.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useCallback } from 'react'; +import React, { forwardRef, useCallback, useEffect, useState } from 'react'; import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds'; import { useAtom } from 'jotai'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; @@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient'; import { useModalStyle } from '../hooks/useModalStyle'; import { useCrossSigningActive } from '../hooks/useCrossSigning'; import { InfoCard } from './info-card'; +import { ScheduledMessage } from '../state/scheduledMessages'; +import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages'; import { useDeviceVerificationStatus, VerificationStatus, @@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef( const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom); - const [logoutState, logout] = useAsyncCallback( - useCallback(async () => { - // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC - // membership (expires: 4 h) stays behind as a ghost participant and - // everyone else sees you "in call" until it times out. - if (callEmbed && callEmbed.joined && !callEmbed.disposed) { - await hangupCallAndWait(mx, callEmbed); - setCallEmbed(undefined); - } - await logoutClient(mx); - }, [mx, callEmbed, setCallEmbed]), + // [Gitea #322] Scheduled messages live on the server and still send after + // logout, where no client would show them. Say so, and offer to cancel. + const [scheduled, setScheduled] = useState([]); + useEffect(() => { + let alive = true; + fetchScheduledMessages(mx) + .then((list) => alive && setScheduled(list)) + .catch(() => undefined); + return () => { + alive = false; + }; + }, [mx]); + + const [logoutState, logout] = useAsyncCallback( + useCallback( + async (cancelScheduled?: boolean) => { + if (cancelScheduled) { + await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId))); + } + // [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC + // membership (expires: 4 h) stays behind as a ghost participant and + // everyone else sees you "in call" until it times out. + if (callEmbed && callEmbed.joined && !callEmbed.disposed) { + await hangupCallAndWait(mx, callEmbed); + setCallEmbed(undefined); + } + await logoutClient(mx); + }, + [mx, callEmbed, setCallEmbed, scheduled], + ), ); const ongoingLogout = logoutState.status === AsyncStatus.Loading; @@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef( description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages." /> ))} + {scheduled.length > 0 && ( + + )} You’re about to log out. Are you sure? {logoutState.status === AsyncStatus.Error && ( @@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef( + {scheduled.length > 0 && ( + + )} diff --git a/src/app/components/emoji-board/EmojiBoard.tsx b/src/app/components/emoji-board/EmojiBoard.tsx index 91252a70f..da56c7fea 100644 --- a/src/app/components/emoji-board/EmojiBoard.tsx +++ b/src/app/components/emoji-board/EmojiBoard.tsx @@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji'; import { useEmojiGroupLabels } from './useEmojiGroupLabels'; import { useEmojiGroupIcons } from './useEmojiGroupIcons'; -import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard'; +import { preventScrollWithArrowKey } from '../../utils/keyboard'; import { useRelevantImagePacks } from '../../hooks/useImagePacks'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRecentEmoji } from '../../hooks/useRecentEmoji'; @@ -612,7 +612,13 @@ export function EmojiBoard({ !editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt), isKeyBackward: (evt: KeyboardEvent) => !editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt), - escapeDeactivates: stopPropagation, + // [Gitea #304] The board opens with focus in its search field, where + // the shared stopPropagation helper ignores Escape. Always close here, + // without letting the key also close the room/modal underneath. + escapeDeactivates: (evt: KeyboardEvent) => { + evt.stopPropagation(); + return true; + }, }} > ( [imagePack, savedImages, uploadedImages], ); - const pickFiles = useFilePicker( + const pickFiles = useStrippedFilePicker( useCallback( (pickedFiles: File[]) => { const uniqueFiles = pickedFiles.map((file) => { diff --git a/src/app/components/image-pack-view/PackMeta.tsx b/src/app/components/image-pack-view/PackMeta.tsx index e04169d4a..d79aa84b6 100644 --- a/src/app/components/image-pack-view/PackMeta.tsx +++ b/src/app/components/image-pack-view/PackMeta.tsx @@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common'; import { BreakWord } from '../../styles/Text.css'; import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser'; import { ContainerColor } from '../../styles/ContainerColor.css'; -import { useFilePicker } from '../../hooks/useFilePicker'; +import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker'; import { useObjectURL } from '../../hooks/useObjectURL'; import { createUploadAtom, UploadSuccess } from '../../state/upload'; import { CompactUploadCardRenderer } from '../upload-card'; @@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil return undefined; }, [imageFile]); - const pickFile = useFilePicker(setImageFile, false); + const pickFile = useStrippedFilePicker(setImageFile, false); const handleRemoveUpload = useCallback(() => { setImageFile(undefined); diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index 4e110be41..a0b76d10f 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useAlive } from '../../hooks/useAlive'; import { copyToClipboard } from '../../utils/dom'; -import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getViaServers } from '../../plugins/via-servers'; import { useModalStyle } from '../../hooks/useModalStyle'; +import { useLotusShareBase } from '../../hooks/useLotusLinkBase'; +import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { const [linkCopied, setLinkCopied] = useState(false); const [showQr, setShowQr] = useState(false); + // [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat. + const lotusBase = useLotusShareBase(); const inviteUrl = (() => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); - return getMatrixToRoom(roomIdOrAlias, viaServers); + return room.isSpaceRoom() + ? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers) + : getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers); })(); const handleCopyLink = () => { diff --git a/src/app/components/message/MsgTypeRenderers.tsx b/src/app/components/message/MsgTypeRenderers.tsx index 191d1895a..c69f9c2fd 100644 --- a/src/app/components/message/MsgTypeRenderers.tsx +++ b/src/app/components/message/MsgTypeRenderers.tsx @@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common'; import { formatAccuracy } from '../../utils/geoShare'; import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment'; import { FileHeader, FileDownloadButton } from './FileHeader'; +import { previewUrlsFor } from '../../utils/linkSafety'; // ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343). const COLLAPSE_MAX_HEIGHT_REM = 20; @@ -343,7 +344,13 @@ export function MText({ if (typeof body !== 'string') return ; const trimmedBody = trimReplyFromBody(body); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + // [Gitea #286] no genuine card for a site a deceptive link only pretends to be + const urls = urlsMatch + ? previewUrlsFor( + [...new Set(urlsMatch)], + typeof customBody === 'string' ? customBody : undefined, + ) + : undefined; return ( <> @@ -401,7 +408,13 @@ export function MEmote({ if (typeof body !== 'string') return ; const trimmedBody = trimReplyFromBody(body); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + // [Gitea #286] no genuine card for a site a deceptive link only pretends to be + const urls = urlsMatch + ? previewUrlsFor( + [...new Set(urlsMatch)], + typeof customBody === 'string' ? customBody : undefined, + ) + : undefined; return ( <> @@ -458,7 +471,13 @@ export function MNotice({ if (typeof body !== 'string') return ; const trimmedBody = trimReplyFromBody(body); const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG); - const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined; + // [Gitea #286] no genuine card for a site a deceptive link only pretends to be + const urls = urlsMatch + ? previewUrlsFor( + [...new Set(urlsMatch)], + typeof customBody === 'string' ? customBody : undefined, + ) + : undefined; return ( <> diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 87a194970..819ecc19e 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -1,7 +1,7 @@ import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds'; import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations'; -import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; +import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getMemberName, sendRoomEvent } from '../../../utils/room'; import { @@ -99,7 +99,21 @@ export function PollContent({ }) { const mx = useMatrixClient(); const roomId = room.roomId; - const eventId = mEvent.getId(); + // [Gitea #284] A poll you just created starts with its local-echo id + // (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but + // doesn't re-render us. Track the id so votes and the tally use the real one. + const [eventId, setEventId] = useState(() => mEvent.getId()); + useEffect(() => { + const sync = () => setEventId(mEvent.getId()); + sync(); + mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync); + mEvent.on(MatrixEventEvent.Status, sync); + return () => { + mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync); + mEvent.off(MatrixEventEvent.Status, sync); + }; + }, [mEvent]); + const isLocalEcho = !!eventId?.startsWith('~'); const senderId = mEvent.getSender(); const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]); @@ -192,8 +206,9 @@ export function PollContent({ const isMultiple = maxSelections > 1; const showResults = resultsVisible(isUndisclosed, isEnded); - const canVote = !!roomId && !!eventId && !isEnded; - const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact); + const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded; + const canEnd = + !!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact); const myVotes = pending ?? tally.myVotes; const { counts, total, voters } = tally; @@ -219,25 +234,34 @@ export function PollContent({ setPending(next); // Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader // accepts both namespaces). - sendRoomEvent(mx, roomId, null, 'm.poll.response', { - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.selections': Array.from(next), - }).catch(() => setPending(null)); + // The SDK can throw synchronously (e.g. a relation to a pending event). + try { + sendRoomEvent(mx, roomId, null, 'm.poll.response', { + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.selections': Array.from(next), + }).catch(() => setPending(null)); + } catch { + setPending(null); + } }; const handleEndPoll = () => { if (!roomId || !eventId || ending) return; setEnding(true); - sendRoomEvent(mx, roomId, null, 'm.poll.end', { - 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, - 'm.poll.end': {}, - 'm.text': 'The poll has ended.', - }) - .then(() => { - setConfirmEnd(false); - setEnding(false); + try { + sendRoomEvent(mx, roomId, null, 'm.poll.end', { + 'm.relates_to': { rel_type: 'm.reference', event_id: eventId }, + 'm.poll.end': {}, + 'm.text': 'The poll has ended.', }) - .catch(() => setEnding(false)); + .then(() => { + setConfirmEnd(false); + setEnding(false); + }) + .catch(() => setEnding(false)); + } catch { + setEnding(false); + } }; // Radiogroup keyboard model (single-choice only): arrows move focus + selection. diff --git a/src/app/components/setting-tile/SettingTile.tsx b/src/app/components/setting-tile/SettingTile.tsx index f22fcd995..36c1dcce0 100644 --- a/src/app/components/setting-tile/SettingTile.tsx +++ b/src/app/components/setting-tile/SettingTile.tsx @@ -43,7 +43,9 @@ export function SettingTile({ title, description, before, after, children }: Set )} {description && ( - + // [Gitea #291] A
, not a

: many tiles pass blocks (badges, + // , ) as the description, which is invalid inside a

. + {description} )} diff --git a/src/app/components/upload-card/UploadCardRenderer.tsx b/src/app/components/upload-card/UploadCardRenderer.tsx index c399d08c3..2b0dd7d0a 100644 --- a/src/app/components/upload-card/UploadCardRenderer.tsx +++ b/src/app/components/upload-card/UploadCardRenderer.tsx @@ -310,9 +310,13 @@ export function UploadCardRenderer({ const { file } = upload; const fileSizeExceeded = file.size >= allowSize; - if (upload.status === UploadStatus.Idle && !fileSizeExceeded) { - startUpload(); - } + // [Gitea #291] Start in an effect, not during render: the desktop taskbar + // progress hook subscribes to the upload atoms, so a render-time start + // updated another component mid-render (React warning on every image send). + const idle = upload.status === UploadStatus.Idle; + useEffect(() => { + if (idle && !fileSizeExceeded) startUpload(); + }, [idle, fileSizeExceeded, startUpload]); const handleSpoiler = (marked: boolean) => { setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked }); diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 24a6881b6..067fc68c8 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -54,6 +54,7 @@ import { GIF_PREVIEW_MAX_WIDTH, gifPreviewBox, } from '../../utils/gifPreviewSize'; +import { sameProvider } from './embedProvider'; 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 => { if (embed) return embed; const ogUrl = prev['og:url']; - return typeof ogUrl === 'string' && ogUrl !== url + // [Gitea #326] …but only from an og:url on the posted link's own + // provider: the page sets og:url, so any site could otherwise show up + // as a playable YouTube (etc.) card that links to itself. + return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl) ? parseMediaEmbed(ogUrl, window.location.hostname) : null; }; diff --git a/src/app/components/url-preview/embedProvider.test.ts b/src/app/components/url-preview/embedProvider.test.ts new file mode 100644 index 000000000..0f28b4013 --- /dev/null +++ b/src/app/components/url-preview/embedProvider.test.ts @@ -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); +}); diff --git a/src/app/components/url-preview/embedProvider.ts b/src/app/components/url-preview/embedProvider.ts new file mode 100644 index 000000000..48255f772 --- /dev/null +++ b/src/app/components/url-preview/embedProvider.ts @@ -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 = { + '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; + } +} diff --git a/src/app/features/call/CallSoundboard.tsx b/src/app/features/call/CallSoundboard.tsx index beadcc115..7c30203e0 100644 --- a/src/app/features/call/CallSoundboard.tsx +++ b/src/app/features/call/CallSoundboard.tsx @@ -50,6 +50,14 @@ type FlatClip = { * A management toggle reveals the pack editors (personal + this room, if * permitted). Space-wide packs are managed from Space settings. */ +/** [EC#13, EC#47] Why the fork refused to play a clip, for the user. */ +export const soundboardRefusalText = (reason: string | undefined): string => { + if (reason === 'muted') return 'Unmute your microphone to play a soundboard clip.'; + if (reason === 'not_permitted') + return "The soundboard isn't available in this voice channel (only microphone audio is allowed here)."; + return 'Could not play that clip.'; +}; + export function CallSoundboard({ callEmbed }: CallSoundboardProps) { const mx = useMatrixClient(); const { room } = callEmbed; @@ -122,13 +130,10 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) { .catch(() => undefined); const result = await callEmbed.control.injectAudio(url, vol, clipBytes); if (!result.played) { - // [EC#13] Refused fork-side (only reason today: local mic muted) — + // [EC#13, EC#47] Refused fork-side (mic muted, or the room's token + // only allows the microphone source, as in audio-only rooms) — // don't play it locally either, or the user would think it went out. - setError( - result.reason === 'muted' - ? 'Unmute your microphone to play a soundboard clip.' - : 'Could not play that clip.', - ); + setError(soundboardRefusalText(result.reason)); done(); return; } diff --git a/src/app/features/call/CallView.tsx b/src/app/features/call/CallView.tsx index f4e21a5ab..5973d9bf6 100644 --- a/src/app/features/call/CallView.tsx +++ b/src/app/features/call/CallView.tsx @@ -6,12 +6,13 @@ import { useCallJoined, useCallEmbedPlacementSync, useCallLoadError, + useCallStart, } from '../../hooks/useCallEmbed'; import { callEmbedAtom } from '../../state/callEmbed'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { PrescreenControls } from './PrescreenControls'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; -import { useRoom } from '../../hooks/useRoom'; +import { useIsDirectRoom, useRoom } from '../../hooks/useRoom'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -160,6 +161,16 @@ function CallLoadErrorMessage() { // prescreen, where they can choose to join again. const dismiss = () => setCallEmbed(undefined); + // [Gitea #301] Try again: start a fresh embed for the same room with the + // same mic/camera/sound choices (starting one disposes the hung one). + const callEmbed = useCallEmbed(); + const startCall = useCallStart(useIsDirectRoom()); + const retry = () => { + if (!callEmbed) return; + const { room, control } = callEmbed; + startCall(room, { microphone: control.microphone, video: control.video, sound: control.sound }); + }; + return ( + diff --git a/src/app/features/common-settings/general/RoomShareInvite.tsx b/src/app/features/common-settings/general/RoomShareInvite.tsx index f37bdf100..f3d0c3aa9 100644 Binary files a/src/app/features/common-settings/general/RoomShareInvite.tsx and b/src/app/features/common-settings/general/RoomShareInvite.tsx differ diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index 88d2655ff..76a424cf5 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -40,7 +40,7 @@ import { useImagePackRooms } from '../../../hooks/useImagePackRooms'; import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; -import { useFilePicker } from '../../../hooks/useFilePicker'; +import { useStrippedFilePicker } from '../../../hooks/useStrippedFilePicker'; import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; @@ -68,7 +68,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) { const imagePackRooms = useImagePackRooms(room.roomId, roomToParents); const [iconFile, setIconFile] = useState(); - const pickFile = useFilePicker(setIconFile, false); + const pickFile = useStrippedFilePicker(setIconFile, false); const [tagColor, setTagColor] = useState(tag?.color); const [tagIcon, setTagIcon] = useState(tag?.icon); diff --git a/src/app/features/message-search/useMessageSearch.test.ts b/src/app/features/message-search/useMessageSearch.test.ts index c57b6a484..2b02b7245 100644 --- a/src/app/features/message-search/useMessageSearch.test.ts +++ b/src/app/features/message-search/useMessageSearch.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + foldEdits, filterGroupsByDateRange, filterGroupsByMsgType, filterGroupsByPinned, @@ -109,3 +110,47 @@ test('filterGroupsByPinned: keeps pinned items and drops empty groups', () => { ['$2'], ); }); + +const sr = (eventId: string, ts: number, content: Record) => + ({ + rank: 1, + result: { + event_id: eventId, + room_id: '!r:x', + origin_server_ts: ts, + type: 'm.room.message', + content, + }, + }) as unknown as Parameters[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'); +}); diff --git a/src/app/features/message-search/useMessageSearch.ts b/src/app/features/message-search/useMessageSearch.ts index dbf5cc545..2ce6cfb5b 100644 --- a/src/app/features/message-search/useMessageSearch.ts +++ b/src/app/features/message-search/useMessageSearch.ts @@ -96,10 +96,58 @@ export const filterGroupsByDateRange = ( .filter((group) => group.items.length > 0); }; +/** + * [Gitea #288] Server search also returns `m.replace` events, so an edited + * message showed once per version. Fold each edit into its message: one row, + * at the first position any version ranked, showing the latest text and + * pointing at the original event (so "jump" lands on the real message). + */ +export const foldEdits = (results: ISearchResult[]): ISearchResult[] => { + const targetOf = (r: ISearchResult): string => { + const rel = r.result.content?.['m.relates_to']; + return rel?.rel_type === 'm.replace' && typeof rel.event_id === 'string' + ? rel.event_id + : r.result.event_id; + }; + const isEdit = (r: ISearchResult) => targetOf(r) !== r.result.event_id; + const order: string[] = []; + const originals = new Map(); + const latestEdit = new Map(); + results.forEach((r) => { + const target = targetOf(r); + if (!originals.has(target) && !latestEdit.has(target)) order.push(target); + if (!isEdit(r)) { + originals.set(target, r); + return; + } + const prev = latestEdit.get(target); + if (!prev || r.result.origin_server_ts > prev.result.origin_server_ts) + latestEdit.set(target, r); + }); + return order.map((target) => { + const original = originals.get(target); + const edit = latestEdit.get(target); + const newContent = edit?.result.content?.['m.new_content']; + if (original && !newContent) return original; + if (original) { + return { + ...original, + result: { + ...original.result, + content: { ...original.result.content, 'm.new_content': newContent }, + }, + }; + } + // Only an edit matched: show it as its message. + const only = edit as ISearchResult; + return { ...only, result: { ...only.result, event_id: target } }; + }); +}; + const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groups: ResultGroup[] = []; - results.forEach((item) => { + foldEdits(results).forEach((item) => { const roomId = item.result.room_id; const resultItem: ResultItem = { rank: item.rank, diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 711dd0122..a3f4da929 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -80,7 +80,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; -import { scheduleMuteTimer, unmuteRoom } from './muteTimers'; +import { clearMuteTimer, scheduleMuteTimer } from './muteTimers'; import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; @@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef( prevMode, ).catch(() => {}); if (durationMs !== null) { - scheduleMuteTimer(room.roomId, durationMs, () => { - unmuteRoom(mx, room.roomId); - }); + await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {}); + } else { + // An indefinite mute replaces any timed one. + await clearMuteTimer(mx, room.roomId); } requestClose(); }, diff --git a/src/app/features/room-nav/muteTimers.test.ts b/src/app/features/room-nav/muteTimers.test.ts index 809ee152c..094c9b01c 100644 --- a/src/app/features/room-nav/muteTimers.test.ts +++ b/src/app/features/room-nav/muteTimers.test.ts @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences'; -import { shouldResetMuteOnUnmute } from './muteTimers'; +import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers'; test('resets to Unset when the room is still Mute at expiry', () => { assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true); @@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute test('does not reset when the mode is already Unset', () => { assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false); }); + +test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => { + const entries = [ + { roomId: '!a:x', unmuteAt: 1000 }, + { roomId: '!b:x', unmuteAt: 2000 }, + ]; + assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries); + assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [ + { roomId: '!b:x', unmuteAt: 5 }, + ]); + assert.deepEqual(parseMuteTimers(undefined), []); + assert.deepEqual(parseMuteTimers({ timers: null }), []); +}); diff --git a/src/app/features/room-nav/muteTimers.ts b/src/app/features/room-nav/muteTimers.ts index d1eb3e66f..06286e64d 100644 --- a/src/app/features/room-nav/muteTimers.ts +++ b/src/app/features/room-nav/muteTimers.ts @@ -7,7 +7,7 @@ import { setRoomNotificationPreference, } from '../../hooks/useRoomsNotificationPreferences'; -// localStorage key for timed mute timers +// Legacy localStorage key (before #333); migrated into account data once. export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers'; // setTimeout's delay is a signed 32-bit int; larger values overflow and fire @@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647; export type MuteTimerEntry = { roomId: string; unmuteAt: number }; -export function loadMuteTimers(): MuteTimerEntry[] { - try { - const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } +/** Pure: the timers in an `io.lotus.mute_timers` account data content. */ +export function parseMuteTimers(content: unknown): MuteTimerEntry[] { + const timers = (content as { timers?: unknown } | undefined)?.timers; + if (!timers || typeof timers !== 'object') return []; + return Object.entries(timers as Record) + .filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1])) + .map(([roomId, unmuteAt]) => ({ roomId, unmuteAt })); } -export function saveMuteTimers(timers: MuteTimerEntry[]): void { - localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers)); +/** Pure: account data content for `entries`. */ +export const muteTimersContent = (entries: MuteTimerEntry[]) => ({ + timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])), +}); + +/** + * [Gitea #333] Timed mutes live in account data: the mute itself is a + * server-side push rule on every device, so the timer must be too — kept in one + * browser's localStorage, only that device ever unmuted (and never after a + * logout there). + */ +export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] { + return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent()); +} + +async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise { + 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 { + let legacy: MuteTimerEntry[] = []; + try { + const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]'); + legacy = Array.isArray(parsed) + ? parsed.filter( + (e): e is MuteTimerEntry => + !!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number', + ) + : []; + } catch { + legacy = []; + } + if (legacy.length === 0) return; + const current = loadMuteTimers(mx); + const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))]; + await saveMuteTimers(mx, merged); + try { + localStorage.removeItem(MUTE_TIMERS_KEY); + } catch { + /* ignore */ + } } // Pure decision for the unmute guard: a timed mute should only be reset back to @@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise {}); } - saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId)); + await clearMuteTimer(mx, roomId); } -export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void { - const unmuteAt = Date.now() + durationMs; - const existing = loadMuteTimers().filter((e) => e.roomId !== roomId); - saveMuteTimers([...existing, { roomId, unmuteAt }]); - setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS)); +/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */ +export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise { + const timers = loadMuteTimers(mx); + if (!timers.some((e) => e.roomId === roomId)) return; + await saveMuteTimers( + mx, + timers.filter((e) => e.roomId !== roomId), + ).catch(() => {}); +} + +/** + * Record when a timed mute ends. MuteTimerRestore (on every Lotus device) + * schedules the unmute from account data, so whichever is online at expiry + * lifts it. + */ +export async function scheduleMuteTimer( + mx: MatrixClient, + roomId: string, + durationMs: number, +): Promise { + const unmuteAt = Date.now() + durationMs; + const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId); + await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]); } diff --git a/src/app/features/room-settings/PolicyListViewer.tsx b/src/app/features/room-settings/PolicyListViewer.tsx index 5a120c8a3..453016a5c 100644 --- a/src/app/features/room-settings/PolicyListViewer.tsx +++ b/src/app/features/room-settings/PolicyListViewer.tsx @@ -17,6 +17,7 @@ import { Page, PageContent, PageHeader } from '../../components/page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { SequenceCard } from '../../components/sequence-card'; import { SequenceCardStyle } from '../common-settings/styles.css'; +import { dedupePolicyEntries, PolicyEntry, recommendationLabel } from './policyEntries'; // ── Policy event types ──────────────────────────────────────────────────────── @@ -38,13 +39,6 @@ type PolicyRuleContent = { recommendation?: string; }; -type PolicyEntry = { - entity: string; - reason: string; - recommendation: string; - stateKey: string; -}; - type PolicyTab = 'users' | 'rooms' | 'servers'; // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -53,13 +47,6 @@ function isGlob(entity: string): boolean { return entity.includes('*') || entity.includes('?'); } -function recommendationLabel(rec: string): string { - // `m.ban` is the stable value; `org.matrix.mjolnir.ban` is the legacy - // (pre-stabilization) recommendation still emitted by older bots. - if (rec === 'm.ban' || rec === 'org.matrix.mjolnir.ban') return 'Ban'; - return rec; -} - /** * Fetch all state events of a given type from a room's live forward state. * Uses the raw matrix-js-sdk `getStateEvents` API which accepts any string type. @@ -84,23 +71,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] { .filter((entry) => entry.entity !== ''); } -/** - * Merge policy entries from the stable and legacy event types for a rule - * kind, de-duplicating by entity+recommendation so a room that emits both a - * stable and a legacy rule for the same target isn't double-listed. - */ -export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] { - const seen = new Set(); - const result: PolicyEntry[] = []; - entries.forEach((entry) => { - const key = `${entry.entity} ${entry.recommendation}`; - if (seen.has(key)) return; - seen.add(key); - result.push(entry); - }); - return result; -} - // ── Entry row ───────────────────────────────────────────────────────────────── function PolicyEntryRow({ entry }: { entry: PolicyEntry }) { diff --git a/src/app/features/room-settings/policyEntries.test.ts b/src/app/features/room-settings/policyEntries.test.ts new file mode 100644 index 000000000..0e88eba30 --- /dev/null +++ b/src/app/features/room-settings/policyEntries.test.ts @@ -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'); +}); diff --git a/src/app/features/room-settings/policyEntries.ts b/src/app/features/room-settings/policyEntries.ts new file mode 100644 index 000000000..3954731b6 --- /dev/null +++ b/src/app/features/room-settings/policyEntries.ts @@ -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(); + 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; +} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2500feda6..f75277358 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -90,6 +90,7 @@ import { isMembershipChanged, reactionOrEditEvent, sendRoomEvent, + isFromFilteredTimelineSet, } from '../../utils/room'; import { getLastEditDiff } from '../../utils/editDiff'; import { tick } from '../../utils/haptics'; @@ -138,7 +139,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useIgnoredUsers } from '../../hooks/useIgnoredUsers'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useIsDirectRoom } from '../../hooks/useRoom'; -import { isRingableRoom } from '../../utils/callRing'; +import { declinedRing, isRingableRoom } from '../../utils/callRing'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { useRoomCreators } from '../../hooks/useRoomCreators'; @@ -410,6 +411,9 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) data, ) => { if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return; + // [Gitea #259] The media gallery / activity log register filtered timeline + // sets whose events the room re-emits as live: don't count them twice. + if (isFromFilteredTimelineSet(data)) return; onArrive(mEvent); }; const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => { @@ -2037,6 +2041,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli // Same rule as ringing: no "Missed call" in voice rooms or space // channels, where starting a call is just joining the channel. if (!isRingableRoom(room, direct)) return null; + // [Gitea #313] A call we declined isn't a missed call. + const ringRef = mEvent.getRelation()?.event_id; + if (declinedRing(room, [mEventId, ringRef], mx.getSafeUserId())) return null; const content = mEvent.getContent(); const mention = content['m.mentions']?.room || diff --git a/src/app/features/room/ScheduledMessagesTray.tsx b/src/app/features/room/ScheduledMessagesTray.tsx index 46866bd22..c66563a1b 100644 --- a/src/app/features/room/ScheduledMessagesTray.tsx +++ b/src/app/features/room/ScheduledMessagesTray.tsx @@ -4,7 +4,12 @@ import { IContent } from 'matrix-js-sdk'; import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages'; -import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages'; +import { + cancelScheduledMessage, + fetchScheduledMessages, + groupScheduledByRoom, + sendScheduledMessageNow, +} from '../../utils/scheduledMessages'; import { ScheduleMessageModal } from './ScheduleMessageModal'; import { useTimestampFormatter } from '../../hooks/useTimestampFormatter'; import { formatFriendlyDateTime } from '../../utils/datetimeInput'; @@ -26,6 +31,26 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) { const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]); + // [Gitea #322] The server is the source of truth: messages scheduled on + // another device (or before a logout) show up here and can be cancelled. + // localStorage is only a cache for the first paint; on error we keep it. + useEffect(() => { + let alive = true; + const load = () => { + fetchScheduledMessages(mx) + .then((list) => { + if (alive) setScheduledMessages(groupScheduledByRoom(list)); + }) + .catch(() => undefined); + }; + load(); + window.addEventListener('focus', load); + return () => { + alive = false; + window.removeEventListener('focus', load); + }; + }, [mx, roomId, setScheduledMessages]); + // Remove scheduled messages whose time has passed useEffect(() => { if (messages.length === 0) return undefined; diff --git a/src/app/features/room/message/ForwardMessageDialog.tsx b/src/app/features/room/message/ForwardMessageDialog.tsx index ef6f745e2..892a92ec2 100644 --- a/src/app/features/room/message/ForwardMessageDialog.tsx +++ b/src/app/features/room/message/ForwardMessageDialog.tsx @@ -47,8 +47,12 @@ import { recentForwardTargetsAtom, addRecentForwardTarget, } from '../../../state/recentForwardTargets'; -import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent'; -import { useLocalRoomNames } from '../../../hooks/useRoomMeta'; +import { + buildForwardContent, + buildPlaintextAttachmentContent, + forwardEncryptionWarning, +} from './forwardContent'; +import { useLocalRoomNames, getLocalRoomName } from '../../../hooks/useRoomMeta'; type RoomRowProps = { room: Room; @@ -275,6 +279,14 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { // Selection persists across query changes: a room selected then filtered out // of the rendered slice stays selected. const [selectedRoomIds, setSelectedRoomIds] = useState>(new Set()); + const encryptionWarning = forwardEncryptionWarning( + !!mx.getRoom(mEvent.getRoomId() ?? '')?.hasEncryptionStateEvent(), + [...selectedRoomIds] + .map((id) => mx.getRoom(id)) + .filter((r): r is Room => !!r && !r.hasEncryptionStateEvent()) + // The name the user sees (their own "Rename for me" name, if set). + .map((r) => getLocalRoomName(mx, r.roomId) ?? r.name), + ); const toggleRoom = useCallback((roomId: string) => { setSelectedRoomIds((prev) => { @@ -590,7 +602,13 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) { shrink="No" direction="Column" style={{ padding: `${config.space.S200} ${config.space.S400}` }} + gap="200" > + {encryptionWarning && ( + + {encryptionWarning} + + )} + + + )} + {(!filtering || filteredRooms.length > 0) && ( + + + + Rooms + + {!closedCategories.has(DEFAULT_CATEGORY_ID) && ( + <> + ) => { + const cords = evt.currentTarget.getBoundingClientRect(); + setSortMenuAnchor((current) => (current ? undefined : cords)); + }} + aria-label="Sort rooms" + > + + + setSortMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > +

+ - - Recent Activity - - - { - setHomeRoomSort('alpha'); - setSortMenuAnchor(undefined); - }} - size="300" - after={ - homeRoomSort === 'alpha' ? ( - - ) : undefined - } - radii="300" - > - - A → Z - - - { - setHomeRoomSort('unread'); - setSortMenuAnchor(undefined); - }} - size="300" - after={ - homeRoomSort === 'unread' ? ( - - ) : undefined - } - radii="300" - > - - Unread First - - - - - - } - /> - - )} - -
- {virtualizer.getVirtualItems().map((vItem) => { - const roomId = filteredRooms[vItem.index]; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - - return ( - - { + setHomeRoomSort('recent'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'recent' ? ( + + ) : undefined + } + radii="300" + > + + Recent Activity + + + { + setHomeRoomSort('alpha'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'alpha' ? ( + + ) : undefined + } + radii="300" + > + + A → Z + + + { + setHomeRoomSort('unread'); + setSortMenuAnchor(undefined); + }} + size="300" + after={ + homeRoomSort === 'unread' ? ( + + ) : undefined + } + radii="300" + > + + Unread First + + + + + + } /> - - ); - })} -
- - {lowPriorityRooms.length > 0 && ( + + )} + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const roomId = filteredRooms[vItem.index]; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; + + return ( + + + + ); + })} +
+ + )} + {lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && ( ; @@ -129,8 +132,18 @@ export class CallWidgetDriver extends WidgetDriver { 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(); + public async restartScheduledDelayedEvent(delayId: string): Promise { - 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 { diff --git a/src/app/state/plaintextCaches.ts b/src/app/state/plaintextCaches.ts index 6e66bc864..ca4c9e8a0 100644 --- a/src/app/state/plaintextCaches.ts +++ b/src/app/state/plaintextCaches.ts @@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => { * `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are * swept with the rest — the server-side presence status survives, so a * re-login loses nothing) - * - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed - * nav/space categories, `cinny_oidc_dynamic_clients`) is treated as - * preferences, not swept here + * - low-sensitivity UI/metadata residue (collapsed nav/space categories, + * `cinny_oidc_dynamic_clients`) is treated as 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 diff --git a/src/app/state/room/roomToUnread.ts b/src/app/state/room/roomToUnread.ts index 535574af8..c3b31c5d9 100644 --- a/src/app/state/room/roomToUnread.ts +++ b/src/app/state/room/roomToUnread.ts @@ -24,6 +24,7 @@ import { getUnreadInfo, getUnreadInfos, isNotificationEvent, + isFromFilteredTimelineSet, } from '../../utils/room'; import { roomToParentsAtom } from './roomToParents'; import { useStateEventCallback } from '../../hooks/useStateEventCallback'; @@ -236,6 +237,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo data: IRoomTimelineData, ) => { 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 // RoomEvent.UnreadNotifications below — ignore them here so the count is // never double-driven / mis-attributed to the main timeline. diff --git a/src/app/state/scheduledMessages.ts b/src/app/state/scheduledMessages.ts index ddceb704b..6eaf0eb5b 100644 --- a/src/app/state/scheduledMessages.ts +++ b/src/app/state/scheduledMessages.ts @@ -21,8 +21,8 @@ const internalAtom = atomWithStorage>( /** * Global atom: Map * Stores all locally-tracked scheduled messages across rooms. - * MSC4140 has no list endpoint, so we track them ourselves. - * Backed by localStorage so scheduled messages survive page refreshes. + * Loaded from the server's MSC4140 `GET /delayed_events` when the tray mounts + * (Gitea #322); localStorage is a cache for the first paint. */ export const scheduledMessagesAtom = atom( (get): Map => new Map(Object.entries(get(internalAtom))), diff --git a/src/app/state/serverStatus.ts b/src/app/state/serverStatus.ts index 9d4e94250..cd29358e2 100644 --- a/src/app/state/serverStatus.ts +++ b/src/app/state/serverStatus.ts @@ -16,3 +16,35 @@ export const serverStatusAtom = atom<{ syncLost: false, 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(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); + }, +); diff --git a/src/app/utils/callRejoin.test.ts b/src/app/utils/callRejoin.test.ts index 9f5eb62fd..ad887d65d 100644 --- a/src/app/utils/callRejoin.test.ts +++ b/src/app/utils/callRejoin.test.ts @@ -1,6 +1,12 @@ import { describe, it } from 'node:test'; 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 = { 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); +}); diff --git a/src/app/utils/callRejoin.ts b/src/app/utils/callRejoin.ts index 93cfb365b..fc53cd6fc 100644 --- a/src/app/utils/callRejoin.ts +++ b/src/app/utils/callRejoin.ts @@ -67,6 +67,8 @@ export type RejoinContext = { ownMemberDevices: string[]; /** Number of call memberships in the room, ours included. */ memberCount: number; + /** A DM call: rejoining one may ring the other person. */ + dm?: boolean; }; 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). const others = ctx.memberCount - ctx.ownMemberDevices.length; 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 }; } + +/** 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 ". */ +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.`; diff --git a/src/app/utils/callRing.test.ts b/src/app/utils/callRing.test.ts index ac96ae3ee..bf94215a9 100644 --- a/src/app/utils/callRing.test.ts +++ b/src/app/utils/callRing.test.ts @@ -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, '$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, + ); +}); diff --git a/src/app/utils/callRing.ts b/src/app/utils/callRing.ts index adbdd5e42..370ca1bcb 100644 --- a/src/app/utils/callRing.ts +++ b/src/app/utils/callRing.ts @@ -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 * 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, + 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, myUserId: string): boolean => room .getLiveTimeline() .getEvents() @@ -48,5 +59,5 @@ export const declinedRing = (room: Room, refEventId: string, myUserId: string): (ev) => ev.getType() === 'org.matrix.msc4310.rtc.decline' && ev.getSender() === myUserId && - ev.getRelation()?.event_id === refEventId, + ids.has(ev.getRelation()?.event_id), ); diff --git a/src/app/utils/denoiseNotice.test.ts b/src/app/utils/denoiseNotice.test.ts new file mode 100644 index 000000000..779be6879 --- /dev/null +++ b/src/app/utils/denoiseNotice.test.ts @@ -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); +}); diff --git a/src/app/utils/denoiseNotice.ts b/src/app/utils/denoiseNotice.ts new file mode 100644 index 000000000..8d16645bc --- /dev/null +++ b/src/app/utils/denoiseNotice.ts @@ -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 = { + 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; +} diff --git a/src/app/utils/filteredTimeline.test.ts b/src/app/utils/filteredTimeline.test.ts new file mode 100644 index 000000000..12405c508 --- /dev/null +++ b/src/app/utils/filteredTimeline.test.ts @@ -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, + ); +}); diff --git a/src/app/utils/imageCompression.test.ts b/src/app/utils/imageCompression.test.ts index 4bcf11415..a41c1c1a9 100644 --- a/src/app/utils/imageCompression.test.ts +++ b/src/app/utils/imageCompression.test.ts @@ -1,6 +1,11 @@ import { test } from 'node:test'; 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', () => { assert.equal(formatFileSize(0), '0 B'); @@ -25,14 +30,38 @@ test('formatFileSize formats values in the MB range', () => { test('isCompressible accepts raster image types', () => { assert.equal(isCompressible({ type: 'image/png' } 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); }); test('isCompressible rejects svg, empty type, and non-images', () => { 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: 'application/pdf' } as Blob), false); assert.equal(isCompressible({ type: 'text/plain' } 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); +}); diff --git a/src/app/utils/imageCompression.ts b/src/app/utils/imageCompression.ts index 63b277036..6eb5e14ad 100644 --- a/src/app/utils/imageCompression.ts +++ b/src/app/utils/imageCompression.ts @@ -8,11 +8,32 @@ export type CompressionResult = { height: number; }; -// Any raster image 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 -// frames compress fine. Empty type string (undetected MIME) is excluded. +// Raster images the browser can render to canvas can be compressed to JPEG. +// SVG is vector and must stay as-is. [Gitea #331] GIF and APNG are usually +// 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 => - 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). */ 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 * 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 * 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; // Skip PNG (potential alpha) — re-encoding to JPEG would flatten transparency. 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; try { @@ -60,6 +91,13 @@ export async function compressImage( } ctx.drawImage(bitmap, 0, 0); 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) => { canvas.toBlob( diff --git a/src/app/utils/inFlight.test.ts b/src/app/utils/inFlight.test.ts new file mode 100644 index 000000000..832366613 --- /dev/null +++ b/src/app/utils/inFlight.test.ts @@ -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(); + let calls = 0; + let release!: () => void; + const gate = new Promise((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(); + await assert.rejects(once('k', () => Promise.reject(new Error('boom')))); + await once('k', () => Promise.resolve()); +}); diff --git a/src/app/utils/inFlight.ts b/src/app/utils/inFlight.ts new file mode 100644 index 000000000..a1fd10080 --- /dev/null +++ b/src/app/utils/inFlight.ts @@ -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(): (key: string, run: () => Promise) => Promise { + const pending = new Map>(); + 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; + }; +} diff --git a/src/app/utils/kumaStatus.test.ts b/src/app/utils/kumaStatus.test.ts index ad08f850c..93088a411 100644 --- a/src/app/utils/kumaStatus.test.ts +++ b/src/app/utils/kumaStatus.test.ts @@ -1,6 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { + plain, DEFAULT_GROUPS, kumaBeatTime, kumaUrls, @@ -260,3 +261,27 @@ test('a dismissed calls-down banner lets the next one through, never hides serve '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 guide [docs](https://x.y)'), + 'Read the guide docs', + ); +}); diff --git a/src/app/utils/kumaStatus.ts b/src/app/utils/kumaStatus.ts index 4b4b71837..7e23274c2 100644 --- a/src/app/utils/kumaStatus.ts +++ b/src/app/utils/kumaStatus.ts @@ -240,12 +240,25 @@ const incidentTone = (style: string): BannerTone => { }; /** 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 .replace(/!\[[^\]]*\]\([^)]*\)/g, '') .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') - .replace(/[*_`#>]/g, '') - .replace(/\s+\n/g, '\n') + .replace(/<\/?[a-z][^>]*>/gi, '') + .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(); /** diff --git a/src/app/utils/linkSafety.test.ts b/src/app/utils/linkSafety.test.ts index 7bd2f8a72..5d288e840 100644 --- a/src/app/utils/linkSafety.test.ts +++ b/src/app/utils/linkSafety.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; 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', () => { 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'); }); + +test('previewUrlsFor: no preview for a site a deceptive link only claims (#286)', () => { + const urls = ['https://paypal.com']; + assert.deepEqual( + previewUrlsFor(urls, 'https://paypal.com'), + [], + ); + // An honest link keeps its preview. + assert.deepEqual( + previewUrlsFor(urls, 'https://paypal.com'), + urls, + ); + assert.deepEqual(previewUrlsFor(urls, undefined), urls); + // Other links in the message keep theirs. + assert.deepEqual( + previewUrlsFor( + ['https://paypal.com', 'https://lotusguild.org'], + 'see paypal.com and https://lotusguild.org', + ), + ['https://lotusguild.org'], + ); +}); diff --git a/src/app/utils/linkSafety.ts b/src/app/utils/linkSafety.ts index a6026a935..73a793a4e 100644 --- a/src/app/utils/linkSafety.ts +++ b/src/app/utils/linkSafety.ts @@ -221,3 +221,37 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => { const other = shown.find((h) => registrableDomain(h) !== realSite); return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode }; }; + +const ANCHOR_REG = /]*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(); + 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; + } + }); +}; diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index a86a2bf10..41071fe52 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -20,6 +20,7 @@ import { Room, RoomMember, TimelineEvents, + IRoomTimelineData, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events'; @@ -847,3 +848,14 @@ export const guessPerfectParent = ( 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(); diff --git a/src/app/utils/scheduledMessages.test.ts b/src/app/utils/scheduledMessages.test.ts index 69869ba31..c3328fd5f 100644 --- a/src/app/utils/scheduledMessages.test.ts +++ b/src/app/utils/scheduledMessages.test.ts @@ -6,6 +6,8 @@ import { scheduleMessage, cancelScheduledMessage, restartScheduledMessage, + parseDelayedEvents, + groupScheduledByRoom, } from './scheduledMessages'; // 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'); 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); +}); diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index 3f157ab1a..a8acde312 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -1,4 +1,5 @@ import { IContent, MatrixClient, Method } from 'matrix-js-sdk'; +import type { ScheduledMessage } from '../state/scheduledMessages'; /** * 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' }, ); } + +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 { + 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 { + const map = new Map(); + 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; +} diff --git a/src/app/utils/translation/langUtils.ts b/src/app/utils/translation/langUtils.ts index 6b4effedb..e260aff95 100644 --- a/src/app/utils/translation/langUtils.ts +++ b/src/app/utils/translation/langUtils.ts @@ -95,3 +95,9 @@ export const translationUnavailableText = (desktopApp: boolean): string => desktopApp ? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.' : 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.'; + +/** 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."; diff --git a/src/app/utils/translation/translateSkip.test.ts b/src/app/utils/translation/translateSkip.test.ts new file mode 100644 index 000000000..2caf652bf --- /dev/null +++ b/src/app/utils/translation/translateSkip.test.ts @@ -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/); +}); diff --git a/src/index.tsx b/src/index.tsx index 528fd59c9..a2c853b44 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -43,7 +43,7 @@ if ('serviceWorker' in navigator && swProtocolOk) { const sendSessionToSW = () => { 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 diff --git a/src/sw-session.ts b/src/sw-session.ts index 4b2ec055d..e4d2672da 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -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 (!navigator.serviceWorker.controller) return; @@ -6,5 +6,6 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string) { type: 'setSession', accessToken, baseUrl, + userId, }); } diff --git a/src/sw.ts b/src/sw.ts index 158773965..33d9a8e64 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -2,7 +2,7 @@ import { precacheAndRoute, type PrecacheEntry } from 'workbox-precaching'; import { SHARE_TARGET_PATH, stashSharedForm } from './swShare'; -import { sendNotificationReply } from './swReply'; +import { pickReplySession, replyFailureText, sendNotificationReply } from './swReply'; export type {}; declare const self: ServiceWorkerGlobalScope & { @@ -40,6 +40,8 @@ precacheAndRoute(self.__WB_MANIFEST); type SessionInfo = { accessToken: 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') { - sessions.set(clientId, { accessToken, baseUrl }); + sessions.set(clientId, { + accessToken, + baseUrl, + userId: typeof userId === 'string' ? userId : undefined, + }); } else { // Logout or invalid session sessions.delete(clientId); @@ -130,10 +136,10 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const client = event.source as Client | null; if (!client) return; - const { type, accessToken, baseUrl } = event.data || {}; + const { type, accessToken, baseUrl, userId } = event.data || {}; if (type === 'setSession') { - setSession(client.id, accessToken, baseUrl); + setSession(client.id, accessToken, baseUrl, userId); cleanupDeadClients(); } }); @@ -155,15 +161,19 @@ self.addEventListener('notificationclick', (event: NotificationEvent) => { if (text) { event.waitUntil( 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.threadId, text, - ).then((ok) => { - if (!ok) { + ).then((result) => { + if (result !== 'sent') { // Let the user know it didn't go out, and keep their text reachable. 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}`, data: { path }, }); diff --git a/src/swReply.test.ts b/src/swReply.test.ts index c7763f5a7..66c7f2ffd 100644 --- a/src/swReply.test.ts +++ b/src/swReply.test.ts @@ -1,6 +1,12 @@ import { test } from 'node:test'; 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', () => { 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 fetchOk = (async (url: string, init: RequestInit) => { - calls.push({ url, init }); - return { ok: true } as Response; - }) as unknown as typeof fetch; - assert.equal( - await sendNotificationReply( - { accessToken: 'tok', baseUrl: 'https://hs' }, - '!r:hs', - '$t', - 'yo', - fetchOk, - ), - true, - ); - assert.equal(calls[0].init.method, 'PUT'); - assert.equal((calls[0].init.headers as Record).Authorization, 'Bearer tok'); + const fetchFor = (encStatus: number, sendOk = true) => + (async (url: string, init: RequestInit = {}) => { + calls.push({ url, init }); + if (url.includes('/state/m.room.encryption/')) { + return { ok: encStatus === 200, status: encStatus } as Response; + } + return { ok: sendOk, status: sendOk ? 200 : 500 } as Response; + }) as unknown as typeof fetch; + const session = { accessToken: 'tok', baseUrl: 'https://hs' }; + + assert.equal(await sendNotificationReply(session, '!r:hs', '$t', 'yo', fetchFor(404)), 'sent'); + assert.match(calls[0].url, /\/rooms\/!r%3Ahs\/state\/m\.room\.encryption\/$/); + const put = calls[1]; + assert.equal(put.init.method, 'PUT'); + assert.equal((put.init.headers as Record).Authorization, 'Bearer tok'); assert.match( - calls[0].url, + put.url, /^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( - await sendNotificationReply( - { accessToken: 'tok', baseUrl: 'https://hs' }, - '!r:hs', - undefined, - 'yo', - fetchFail, - ), - false, + await sendNotificationReply(session, '!r:hs', undefined, 'yo', fetchFor(200)), + 'encrypted', + ); + assert.equal(calls.length, 1); + // Can't tell (server error): fail safe, nothing is sent. + calls.length = 0; + assert.equal( + 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/); }); diff --git a/src/swReply.ts b/src/swReply.ts index 968918567..987aab4a3 100644 --- a/src/swReply.ts +++ b/src/swReply.ts @@ -1,9 +1,42 @@ /** * [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 - * 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 => { const content: Record = { msgtype: 'm.text', body: text, 'm.mentions': {} }; @@ -30,20 +63,24 @@ export async function sendNotificationReply( threadId: string | undefined, text: string, fetchFn: typeof fetch = fetch, -): Promise { - if (!session?.accessToken || !session.baseUrl) return false; - const txn = `swreply-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +): Promise { + if (!session?.accessToken || !session.baseUrl) return 'no-session'; + const headers = { Authorization: `Bearer ${session.accessToken}` }; 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), { method: 'PUT', - headers: { - Authorization: `Bearer ${session.accessToken}`, - 'Content-Type': 'application/json', - }, + headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify(buildReplyContent(text, threadId)), }); - return res.ok; + return res.ok ? 'sent' : 'failed'; } catch { - return false; + return 'failed'; } } diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 472d0bd40..74e8dee4a 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -26,6 +26,10 @@ export enum AccountDataEvent { // [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync). 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', CrossSigningMaster = 'm.cross_signing.master', diff --git a/vite.config.js b/vite.config.js index faa37921d..0d6b2ef7b 100644 --- a/vite.config.js +++ b/vite.config.js @@ -87,97 +87,106 @@ function copyPdfWorker() { }; } -// Lotus ML noise suppression: ship the RNNoise worklet/wasm + our getUserMedia -// shim alongside the vendored Element Call bundle, and inject the shim