Compare commits

...
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 ce1354ed93 fix(topic): keep URLs, aliases and user IDs intact in room topics (#262)
CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m19s
The topic editor's hand-rolled markdown treated any single _ or * as
emphasis, inside URLs and #alias:server / @user:server too, and built the
plain topic by stripping tags, so the underscores were lost for every
client. It also left " unescaped in link targets.

buildTopicContent() now uses the composer's inline markdown parser (links,
bare URLs and HTML escaping as for messages) and treats _ between two
letters/digits as literal (CommonMark), except inside code, link targets
and URLs. The plain topic drops the markers but keeps links as
"text (url)" and decodes entities.

Fixes #262

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:11:16 -04:00
jared 0eccf9bea9 Merge pull request 'feat(lotus): load avatar decorations from chat.lotusguild.org (matrix #22)' (#425) from feat/decorations-host into lotus
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 2s
CI / Playwright smoke (e2e) (push) Successful in 12m13s
2026-10-19 13:43:54 -04:00
Lotus CIandClaude Opus 5.5 62c9ac8436 feat(lotus): load avatar decorations from chat.lotusguild.org (matrix #22)
CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m27s
DECORATION_CDN moves from Nextcloud's public DAV (a PHP session and four
cookies per image, no cache headers) to https://chat.lotusguild.org/decorations,
static files from the versioned avatar-decorations package with a one-week
cache. Pin @lotusguild/element-call-embedded 0.25.0-lotus.26, whose call
tiles accept the new origin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 12:54:23 -04:00
jared aaf75fe74a Merge pull request 'fix(calls): the call bar shows "Call disconnected" and reconnects when sync is back (#318)' (#418) from fix/call-disconnected-bar into lotus
CI / Build & Quality Checks (push) Successful in 1m46s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 11m54s
2026-10-19 12:42:07 -04:00
Lotus CI 968507b4b1 Merge remote-tracking branch 'origin/fix/call-disconnected-bar' into fix/call-disconnected-bar
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m13s
2026-10-19 12:14:52 -04:00
Lotus CI 893bf0f6c4 Merge remote-tracking branch 'origin/lotus' into fix/call-disconnected-bar
# Conflicts:
#	package-lock.json
#	package.json
#	src/app/plugins/call/CallControl.ts
#	src/app/plugins/call/CallEmbed.ts
2026-10-19 12:12:28 -04:00
jared 8459427c1e Merge pull request 'fix(desktop): the Linux update command verifies the signed SHA256SUMS before installing (cinny-desktop #36)' (#422) from fix/linux-update-verify into lotus
CI / Build & Quality Checks (push) Successful in 1m45s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 14m30s
2026-10-19 04:40:01 -04:00
Lotus CIandClaude Opus 5.5 ec47d542dd ci(gitleaks): allowlist the desktop updater minisign public key
CI / Build & Quality Checks (pull_request) Successful in 2m9s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m38s
utils/updateErrors.ts embeds the public key that verifies Linux
downloads (cinny-desktop #36); gitleaks' generic-api-key rule matched
it. It is a published public key, not a secret.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 04:24:51 -04:00
jared ef3849eb2f Merge pull request 'perf: load the Settings dialog on demand; entry chunk 351.8 → 277.1 kB gzip' (#420) from perf/lazy-settings into lotus
CI / Build & Quality Checks (push) Successful in 2m10s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 21s
CI / Trigger Desktop Build (push) Successful in 17s
CI / Playwright smoke (e2e) (push) Successful in 14m42s
2026-10-19 02:56:11 -04:00
jared aaa915fe6b Merge pull request 'fix(presence): Do Not Disturb is real "busy" presence and keeps your status (#283)' (#424) from fix/dnd-busy-presence into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 02:44:20 -04:00
jared 029550c855 Merge pull request 'fix(threads): viewing a room no longer reads threads you don't follow (#314)' (#423) from fix/threads-stay-unread into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 02:33:25 -04:00
jared 5bc0942eea Merge pull request 'fix(profile): private notes say they are stored unencrypted on the homeserver (#329)' (#421) from fix/private-note-wording into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 02:07:07 -04:00
jared a937831e6b Merge pull request 'fix(calls): the call-bar output menu uses the call frame's devices (#310)' (#417) from fix/audio-output-frame-ids into lotus
CI / Build & Quality Checks (push) Successful in 1m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 01:13:19 -04:00
Lotus CIandClaude Opus 5.5 00f962a463 fix(presence): Do Not Disturb is real "busy" presence and keeps your status (#283)
CI / Build & Quality Checks (pull_request) Successful in 1m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m59s
DND was sent as presence unavailable with the literal status text
"dnd": it replaced your status, and other clients showed "dnd". Where
the server supports MSC3026 (busy_presence), DND now sends presence
org.matrix.msc3026.busy and leaves the status alone (Synapse keeps
busy across /sync set_presence=online). Busy renders as red "Do Not
Disturb" with the real status line; the legacy unavailable + "dnd"
encoding is still recognised and still used on servers without it.
Status saves / clears no longer downgrade busy. The SDK rejects
non-spec presences, so busy goes through a direct authed PUT.

Fixes #283

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 01:04:27 -04:00
Lotus CIandClaude Opus 5.5 fee3fbb7fc fix(threads): viewing a room no longer reads threads you don't follow (#314)
CI / Build & Quality Checks (pull_request) Successful in 1m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m31s
A passive read (just looking at the room) sent a threaded receipt for
every unread thread you don't follow, so the Threads panel's Unread
filter and Mark all read only ever worked for followed threads.
Decision (2026-10-19): leave them unread until opened, but stop
counting unfollowed threads toward the room's unread dot
(getUnreadInfo subtracts them like muted threads; a mention makes a
thread followed, so highlights are never hidden). isThreadFollowed
moves to utils/room. Adds a Playwright regression.

Fixes #314

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:52:04 -04:00
Lotus CIandClaude Opus 5.5 674aef94fb fix(desktop): the Linux update command verifies the signed SHA256SUMS before installing (cinny-desktop #36)
CI / Build & Quality Checks (pull_request) Successful in 1m54s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Failing after 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m47s
The pacman / apt update command installed the downloaded package with
no integrity check. Download it with the release's SHA256SUMS.txt and
.minisig into a fresh temp dir, verify the signature with the updater
key (minisign), check the hash, then install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:37:34 -04:00
Lotus CIandClaude Opus 5.5 2ac764cd45 fix(profile): private notes say they are stored unencrypted on the homeserver (#329)
CI / Build & Quality Checks (pull_request) Successful in 2m6s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m48s
"Notes only visible to you" overstated it: notes live in plain account
data that homeserver admins can read. Say so under the note box (and
link it as the box's description): "Only you can see this in Lotus Chat.
It's stored on your homeserver and isn't end-to-end encrypted."

Fixes #329

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:28:39 -04:00
jared 2a9b7f68b7 Merge branch 'lotus' into fix/call-disconnected-bar
CI / Build & Quality Checks (pull_request) Successful in 3m3s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 9s
CI / Playwright smoke (e2e) (pull_request) Successful in 20m15s
2026-10-18 23:40:50 -04:00
jared 2815767d47 Merge branch 'lotus' into fix/audio-output-frame-ids
CI / Build & Quality Checks (pull_request) Successful in 1m51s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m56s
2026-10-18 23:40:49 -04:00
Lotus CIandClaude Opus 5.5 6648ca928d chore(call): pin element-call-embedded 0.25.0-lotus.25 (call_error / reconnect, #318)
CI / Build & Quality Checks (pull_request) Failing after 2m51s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:42:35 -04:00
Lotus CI 258ffc88be Merge remote-tracking branch 'origin/lotus' into fix/call-disconnected-bar
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-18 15:39:59 -04:00
Lotus CIandClaude Opus 5.5 338b24c43f chore(call): pin element-call-embedded 0.25.0-lotus.24 (frame audio outputs, #310)
CI / Build & Quality Checks (pull_request) Failing after 2m49s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Playwright smoke (e2e) (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 14s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 15:26:44 -04:00
Lotus CI 562198860f Merge remote-tracking branch 'origin/lotus' into fix/audio-output-frame-ids 2026-10-18 15:24:58 -04:00
Lotus CIandClaude Opus 5.5 fe70616412 fix(calls): the call bar shows "Call disconnected" and reconnects when sync is back (#318)
After a homeserver stall Element Call showed "Connection lost" while the
call bar still looked joined. Handle the fork's io.lotus.call_error:
show "Call disconnected" with a Reconnect button (io.lotus.reconnect),
and reconnect automatically once sync is back.

Fixes #318

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:55:29 -04:00
Lotus CIandClaude Opus 5.5 ef7690135d fix(calls): the call-bar output menu uses the call frame's devices (#310)
deviceIds are hashed per frame/origin, so the ids the menu got from the
chat page's enumerateDevices() were unknown inside the call iframe: the
fork fell back to the default output while the menu showed a tick on
the chosen device. Handle io.lotus.audio_output_state (also silencing
the fork's "host did not ack" log) and build the menu from the fork's
list and effective selection; older forks fall back to the old list.

Fixes #310

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:48:47 -04:00
33 changed files with 667 additions and 98 deletions
+3
View File
@@ -12,6 +12,9 @@ regexes = [
'''https?://matrix\.lotusguild\.org''',
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
'''matrix\.lotusguild\.org''',
# Lotus Chat desktop updater's minisign PUBLIC key (published in the
# cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it.
'''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''',
]
paths = [
'''config\.json''',
+1 -1
View File
@@ -233,7 +233,7 @@ All decoration files are 256×256 APNGs. They animate natively in all modern bro
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
**CDN:**
Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png`. `<img>` elements load cross-origin freely — no CORS headers needed.
Static files at `https://chat.lotusguild.org/decorations/{slug}.png` (thumbnails: `thumbs/{slug}.webp`), cached for a week. They come from the versioned `avatar-decorations` package in Gitea's package registry, installed by the matrix repo's LXC 106 deploy (see its README, "Avatar decorations"). To add or replace decorations, publish a new package version there, then update the catalog in `avatarDecorations.ts`. They used to be served from Nextcloud (matrix #22). `<img>` elements load cross-origin freely, so no CORS headers are needed.
**Module-level cache with in-flight deduplication:**
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
+40
View File
@@ -265,6 +265,46 @@ test.describe('local homeserver regression', () => {
await expect.poll(threadUnread).toBe(false);
});
test("a thread I don't follow stays unread while I view the room (#314)", async ({ page }) => {
const room = await createRoom(alice, 'Unfollowed Thread Room', { invite: [bob.userId] });
await joinRoom(bob, room);
const inThread = (root: string) => ({
'm.relates_to': {
rel_type: 'm.thread',
event_id: root,
is_falling_back: true,
'm.in_reply_to': { event_id: root },
},
});
const root = await sendText(bob, room, "bob's thread root");
await loginUI(page, alice);
await openRoom(page, room);
await sendText(bob, room, 'reply in bob thread', inThread(root));
await sendText(bob, room, 'newer main message 314');
await expect(page.getByText('newer main message 314')).toBeVisible();
const threadUnread = async () => {
const filter = {
room: {
rooms: [room],
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
},
};
const sync = await api<{
rooms: {
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
};
}>(
'GET',
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
alice.token,
);
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
};
await page.waitForTimeout(2000); // let any receipt the room view would send go out
// Viewing the room used to read every thread you don't follow (#314).
expect(await threadUnread()).toBe(true);
});
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
const room = await createRoom(alice, 'Lightbox Room');
const png = Buffer.from(
+4 -4
View File
@@ -81,7 +81,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
@@ -2695,9 +2695,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
},
"node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.23",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
"version": "0.25.0-lotus.26",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
"dev": true
},
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
+1 -1
View File
@@ -108,7 +108,7 @@
},
"devDependencies": {
"@axe-core/playwright": "4.13.0",
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+2
View File
@@ -86,6 +86,7 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
import { webRTCSupported } from '../utils/rtc';
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
import { zIndices } from '../styles/zIndex';
import { useReconnectWhenSynced } from '../hooks/useCallError';
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
const PIP_MIN_W = 200;
@@ -817,6 +818,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
useCallPolicyRevokedToast(embed, joined);
useCallEndedToast(embed);
useScreenshareNotices(embed);
useReconnectWhenSynced(embed.control);
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).
+6 -2
View File
@@ -13,11 +13,13 @@ import {
import React, { ReactNode } from 'react';
import * as css from './styles.css';
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
import { isDndPresence } from '../../utils/busyPresence';
const PresenceToColor: Record<Presence, MainColor> = {
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
[Presence.Busy]: 'Critical',
};
type PresenceBadgeProps = {
@@ -31,9 +33,11 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
// Disturb" to match PresenceRingAvatar and the settings picker (which both
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
const isDnd = presence === Presence.Unavailable && status === 'dnd';
// [Gitea #283] or MSC3026 busy, which keeps a real status line.
const isDnd = isDndPresence(presence, status);
const legacyDnd = isDnd && status === 'dnd';
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
const displayStatus = isDnd ? undefined : status;
const displayStatus = legacyDnd ? undefined : status;
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
return (
@@ -4,6 +4,7 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence';
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
if (!presence || presence === Presence.Offline) return null;
if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND
if (presence === Presence.Unavailable) {
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
}
@@ -286,10 +286,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
</Box>
<textarea
aria-label="Private note about this user"
aria-describedby="private-note-storage"
value={draft}
onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH}
placeholder="Notes only visible to you…"
placeholder="Add a private note…"
rows={3}
style={{
width: '100%',
@@ -305,6 +306,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
outline: 'none',
}}
/>
{/* [Gitea #329] Account data isn't end-to-end encrypted: say so. */}
<Text id="private-note-storage" size="T200" priority="300">
Only you can see this in Lotus Chat. It&apos;s stored on your homeserver and isn&apos;t
end-to-end encrypted.
</Text>
</Box>
);
}
@@ -16,6 +16,7 @@ import {
toRem,
} from 'folds';
import { CallEmbed } from '../../plugins/call';
import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
import { MobileTouchTarget } from '../../styles/mobile.css';
import { stopPropagation } from '../../utils/keyboard';
@@ -39,6 +40,21 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
const [devices, setDevices] = useState<OutputDevice[]>([]);
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
// [Gitea #310] Prefer the fork's list: its ids are the ones valid inside
// the call iframe. Older forks don't report one; fall back to our own.
const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs);
useEffect(() => {
const onState = () => {
setForkDevices(embed.control.audioOutputs);
setSelected(embed.control.audioOutputId);
};
onState();
embed.control.on(AUDIO_OUTPUT_STATE, onState);
return () => {
embed.control.off(AUDIO_OUTPUT_STATE, onState);
};
}, [embed]);
const refresh = useCallback(async () => {
try {
const all = await navigator.mediaDevices.enumerateDevices();
@@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
}, []);
useEffect(() => {
if (!anchor) return undefined;
if (!anchor || forkDevices) return undefined;
refresh();
navigator.mediaDevices.addEventListener('devicechange', refresh);
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
}, [anchor, refresh]);
}, [anchor, refresh, forkDevices]);
const shown = forkDevices ?? devices;
const choose = (id: string) => {
embed.control.setAudioOutput(id);
@@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
aria-label="Audio output"
>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{devices.length === 0 && (
{shown.length === 0 && (
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
No audio outputs found.
</Text>
)}
{devices.map((d) => {
const isSelected = selected ? d.id === selected : d.id === 'default';
{shown.map((d) => {
const isSelected =
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
return (
<MenuItem
key={d.id}
+35 -2
View File
@@ -1,5 +1,5 @@
import React, { useMemo } from 'react';
import { Box, Spinner, Text, color } from 'folds';
import { Box, Button, Spinner, Text, color } from 'folds';
import { useAtomValue } from 'jotai';
import classNames from 'classnames';
import { LiveChip } from './LiveChip';
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MemberSpeaking } from './MemberSpeaking';
import { clockSkewAtom } from '../../state/clockSkew';
import { describeSkewVsServer } from '../../utils/clockSkew';
import { useCallError } from '../../hooks/useCallError';
type CallStatusProps = {
callEmbed: CallEmbed;
@@ -42,6 +43,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
const clockSkew = useAtomValue(clockSkewAtom);
const compact = screenSize === ScreenSize.Mobile;
// [Gitea #318] The call dropped ("Connection lost" in the frame).
const callError = useCallError(callEmbed.control);
const memberVisible = callJoined && callMembers.length > 0;
@@ -65,6 +68,25 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{!compact && (
<>
<CallRoomName room={room} />
{callError && (
<>
<StatusDivider />
<Text size="T200" truncate role="alert" style={{ color: color.Critical.Main }}>
Call disconnected
</Text>
{callError.recoverable && (
<Button
size="300"
radii="300"
variant="Critical"
fill="Soft"
onClick={() => callEmbed.control.reconnect()}
>
<Text size="B300">Reconnect</Text>
</Button>
)}
</>
)}
{clockSkew.warning && clockSkew.skewMs !== null && (
<>
<StatusDivider />
@@ -102,8 +124,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
{memberVisible && !compact && <StatusDivider />}
<Box shrink="No" alignItems="Center" gap="Inherit">
{compact && (
<Box grow="Yes">
<Box grow="Yes" alignItems="Center" gap="200">
<CallRoomName room={room} />
{callError?.recoverable && (
<Button
size="300"
radii="300"
variant="Critical"
fill="Soft"
onClick={() => callEmbed.control.reconnect()}
>
<Text size="B300">Reconnect</Text>
</Button>
)}
</Box>
)}
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
import { buildTopicContent } from '../../../utils/topicMarkdown';
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
const start = textarea.selectionStart;
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
textarea.setSelectionRange(cursorStart, cursorEnd);
}
function topicMarkdownToHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>');
}
function buildTopicContent(topic: string): Record<string, string> {
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
const formattedBody = topicMarkdownToHtml(topic);
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
}
type RoomProfileEditProps = {
canEditAvatar: boolean;
canEditName: boolean;
+4 -2
View File
@@ -3,8 +3,10 @@
// of this file at runtime (by regex) instead of re-declaring it, so the two can
// never drift. If you migrate the CDN, change it here ONLY — keep the
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
export const DECORATION_CDN =
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
// matrix #22: static files on chat.lotusguild.org (a versioned package installed
// by the matrix repo's LXC 106 deploy), not Nextcloud's public DAV. Absolute, so
// the desktop app and the Element Call fork's origin allowlist both work.
export const DECORATION_CDN = 'https://chat.lotusguild.org/decorations';
// Runtime base. A deployment can repoint the decorations at a different host
// without editing the catalog literal above (which scripts/syncDecorations.mjs
@@ -38,7 +38,11 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
import {
describePresenceError,
setPresenceWithRetry,
writePresence,
} from '../../../utils/presenceWrite';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
@@ -560,7 +564,7 @@ function ProfileStatus() {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, '').catch(() => undefined);
// Preserve the user's chosen presence when clearing the status message.
mx.setPresence({
writePresence(mx, {
presence: presenceStateFromSetting(presenceStatus, hidePresence),
status_msg: '',
}).catch(() => undefined);
+41
View File
@@ -0,0 +1,41 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { SyncState } from 'matrix-js-sdk';
import { CALL_ERROR_CHANGED, CallControl } from '../plugins/call/CallControl';
import { CallErrorState } from '../plugins/call/callError';
import { useMatrixClient } from './useMatrixClient';
import { useSyncState } from './useSyncState';
/** [Gitea #318] The fork's current call error (null while the call is fine). */
export function useCallError(control: CallControl): CallErrorState {
const [err, setErr] = useState(control.callError);
useEffect(() => {
const on = () => setErr(control.callError);
on();
control.on(CALL_ERROR_CHANGED, on);
return () => {
control.off(CALL_ERROR_CHANGED, on);
};
}, [control]);
return err;
}
/**
* [Gitea #318] After a homeserver stall the call drops with "Connection
* lost". Once sync is back, reconnect automatically (once per drop).
*/
export function useReconnectWhenSynced(control: CallControl): void {
const mx = useMatrixClient();
const err = useCallError(control);
const tried = useRef<CallErrorState>(null);
useSyncState(
mx,
useCallback(
(state: SyncState | null) => {
if (state !== SyncState.Syncing || !err?.recoverable || tried.current === err) return;
tried.current = err;
control.reconnect();
},
[err, control],
),
);
}
+31 -7
View File
@@ -1,20 +1,27 @@
import { useEffect, useRef } from 'react';
import { SetPresence } from 'matrix-js-sdk';
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
import { useMatrixClient } from './useMatrixClient';
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import {
BUSY_PRESENCE,
BUSY_PRESENCE_FEATURE,
isBusyPresenceSupported,
setBusyPresenceSupported,
} from '../utils/busyPresence';
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
const ACTIVITY_THROTTLE_MS = 1000;
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
export type PresenceState = 'online' | 'unavailable' | 'offline';
export type PresenceState = 'online' | 'unavailable' | 'offline' | typeof BUSY_PRESENCE;
/**
* Single source of truth for mapping the user's presence preference to the
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
* Matrix presence value: auto/online → 'online', idle → 'unavailable', dnd →
* MSC3026 busy (or 'unavailable' where the server lacks it),
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
* status writer so setting/clearing a status message never overrides the user's
* chosen presence (e.g. outing an Invisible user as online).
@@ -24,7 +31,10 @@ export function presenceStateFromSetting(
hidePresence: boolean,
): PresenceState {
if (hidePresence || presenceStatus === 'invisible') return 'offline';
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
// [Gitea #283] DND is MSC3026 busy where the server supports it, so status
// writes (save / clear / auto-clear) don't downgrade it to "unavailable".
if (presenceStatus === 'dnd') return isBusyPresenceSupported() ? BUSY_PRESENCE : 'unavailable';
if (presenceStatus === 'idle') return 'unavailable';
return 'online';
}
@@ -62,8 +72,7 @@ export function usePresenceUpdater() {
// by a heartbeat that changed nothing.
const send = (write: PresenceWrite, label: string) => {
if (!deduper.shouldSend(write)) return Promise.resolve();
return mx
.setPresence(write)
return writePresence(mx, write)
.then(() => deduper.sent(write))
.catch((err) => warnPresenceFailure(label, err));
};
@@ -134,7 +143,22 @@ export function usePresenceUpdater() {
return undefined;
}
if (presenceStatus === 'dnd') {
setUnavailable('dnd');
// [Gitea #283] Real "busy" presence where supported: the status text
// stays yours. /sync reporting "online" doesn't override busy on
// Synapse, so the sync loop can stay online. Older servers keep the
// legacy unavailable + "dnd" text encoding.
mx.doesServerSupportUnstableFeature(BUSY_PRESENCE_FEATURE)
.catch(() => false)
.then(async (busy) => {
setBusyPresenceSupported(busy);
if (!busy) {
setUnavailable('dnd');
return;
}
syncPresence(SetPresence.Online);
const status = await resolveStatus();
send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy');
});
return undefined;
}
+3
View File
@@ -6,6 +6,8 @@ export enum Presence {
Online = 'online',
Unavailable = 'unavailable',
Offline = 'offline',
// [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb.
Busy = 'org.matrix.msc3026.busy',
}
export type UserPresence = {
@@ -129,6 +131,7 @@ export const usePresenceLabel = (): Record<Presence, string> =>
[Presence.Online]: 'Online',
[Presence.Unavailable]: 'Idle',
[Presence.Offline]: 'Offline',
[Presence.Busy]: 'Do Not Disturb',
}),
[],
);
+64
View File
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
import { CallControlState } from './CallControlState';
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
import { canDelegateCapability } from './utils';
import { CallErrorState, parseCallError } from './callError';
export enum CallControlEvent {
StateUpdate = 'state_update',
@@ -54,6 +55,29 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
};
}
export const CALL_ERROR_CHANGED = 'call_error_changed';
export type AudioOutputOption = { id: string; label: string };
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
/** Pure: parse the fork's `io.lotus.audio_output_state` (#310). */
export function parseAudioOutputState(
data: unknown,
): { deviceId?: string; available?: AudioOutputOption[] } | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { deviceId, available } = data as { deviceId?: unknown; available?: unknown };
const list = Array.isArray(available)
? available.filter(
(o): o is AudioOutputOption =>
typeof o === 'object' &&
o !== null &&
typeof (o as AudioOutputOption).id === 'string' &&
typeof (o as AudioOutputOption).label === 'string',
)
: undefined;
return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list };
}
export class CallControl extends EventEmitter implements CallControlState {
private state: CallControlState;
@@ -212,11 +236,51 @@ export class CallControl extends EventEmitter implements CallControlState {
return this._audioOutputId;
}
// [Gitea #318] The fork's error page ("Connection lost") is up; the host
// kept showing the call as joined with controls that did nothing.
private _callError: CallErrorState = null;
public get callError(): CallErrorState {
return this._callError;
}
/** The fork's `io.lotus.call_error` report. */
public onCallError(data: unknown) {
const next = parseCallError(data);
if (next === undefined) return;
this._callError = next;
this.emit(CALL_ERROR_CHANGED);
}
/** Press the fork's Reconnect / Try again. */
public reconnect(): void {
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
}
public setAudioOutput(deviceId: string): void {
this._audioOutputId = deviceId;
this.sendAudioOutput(deviceId);
}
// [Gitea #310] deviceIds are hashed per frame/origin, so ids from the
// host's own enumerateDevices() mean nothing inside the call iframe. The
// fork reports its outputs (ids valid in the frame) and its effective
// selection; the call bar's menu uses those when present.
private _audioOutputs: AudioOutputOption[] | undefined;
public get audioOutputs(): AudioOutputOption[] | undefined {
return this._audioOutputs;
}
/** The fork's `io.lotus.audio_output_state` report. */
public onAudioOutputState(data: unknown) {
const report = parseAudioOutputState(data);
if (!report) return;
this._audioOutputs = report.available;
if (report.deviceId !== undefined) this._audioOutputId = report.deviceId;
this.emit(AUDIO_OUTPUT_STATE);
}
private sendAudioOutput(deviceId: string): void {
if (!this.joined) return;
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
+12
View File
@@ -453,6 +453,18 @@ export class CallEmbed {
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #318] The fork's error page ("Connection lost") is up / cleared.
this.disposables.push(
this.listenAction('io.lotus.call_error', (evt) => {
this.control.onCallError((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #310] The fork's own output list + selection (frame-valid ids).
this.disposables.push(
this.listenAction('io.lotus.audio_output_state', (evt) => {
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
}),
);
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
this.disposables.push(
this.listenAction('io.lotus.hotkey', (evt) => {
@@ -0,0 +1,24 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseAudioOutputState } from './CallControl';
test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => {
assert.deepEqual(
parseAudioOutputState({
deviceId: 'f2',
available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null],
}),
{
deviceId: 'f2',
available: [
{ id: '', label: 'Default' },
{ id: 'f2', label: 'Headset' },
],
},
);
assert.deepEqual(parseAudioOutputState({ deviceId: null }), {
deviceId: undefined,
available: undefined,
});
assert.equal(parseAudioOutputState('x'), undefined);
});
+17
View File
@@ -0,0 +1,17 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseCallError } from './callError';
test('parseCallError (#318)', () => {
assert.deepEqual(parseCallError({ code: 'CONNECTION_LOST_ERROR', recoverable: true }), {
code: 'CONNECTION_LOST_ERROR',
recoverable: true,
});
assert.deepEqual(parseCallError({ code: 'E2EE_NOT_SUPPORTED' }), {
code: 'E2EE_NOT_SUPPORTED',
recoverable: false,
});
assert.equal(parseCallError({ code: null }), null);
assert.equal(parseCallError({}), undefined);
assert.equal(parseCallError('x'), undefined);
});
+14
View File
@@ -0,0 +1,14 @@
/**
* [Gitea #318] The fork's `io.lotus.call_error`: its error page is showing
* (`{ code, recoverable }`) or was cleared (`{ code: null }`).
*/
export type CallErrorState = { code: string; recoverable: boolean } | null;
/** Pure: parse a report; undefined for garbage (ignored). */
export function parseCallError(data: unknown): CallErrorState | undefined {
if (typeof data !== 'object' || data === null) return undefined;
const { code, recoverable } = data as { code?: unknown; recoverable?: unknown };
if (code === null) return null;
if (typeof code !== 'string' || !code) return undefined;
return { code, recoverable: recoverable === true };
}
+11
View File
@@ -0,0 +1,11 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BUSY_PRESENCE, isDndPresence } from './busyPresence';
test('isDndPresence: busy, or the legacy unavailable + "dnd" text (#283)', () => {
assert.equal(isDndPresence(BUSY_PRESENCE, '🎮 Gaming'), true);
assert.equal(isDndPresence(BUSY_PRESENCE, undefined), true);
assert.equal(isDndPresence('unavailable', 'dnd'), true);
assert.equal(isDndPresence('unavailable', 'lunch'), false);
assert.equal(isDndPresence('online', 'dnd'), false);
});
+21
View File
@@ -0,0 +1,21 @@
/**
* [Gitea #283] Do Not Disturb as a real presence state: MSC3026 "busy"
* (Synapse `experimental_features.msc3026_enabled`). Before, DND was sent as
* presence `unavailable` with the literal status text "dnd", which replaced
* your status and showed as "dnd" in other clients.
*/
export const BUSY_PRESENCE = 'org.matrix.msc3026.busy';
export const BUSY_PRESENCE_FEATURE = 'org.matrix.msc3026.busy_presence';
let busySupported = false;
/** Recorded by the presence updater from the server's unstable_features. */
export const setBusyPresenceSupported = (supported: boolean): void => {
busySupported = supported;
};
export const isBusyPresenceSupported = (): boolean => busySupported;
/** Pure: whether a presence means Do Not Disturb (busy, or the legacy "dnd" text). */
export const isDndPresence = (presence: string | undefined, status: string | undefined): boolean =>
presence === BUSY_PRESENCE || (presence === 'unavailable' && status === 'dnd');
+17 -11
View File
@@ -169,7 +169,7 @@ test('private receipt flag uses ReadPrivate', async () => {
// follows unread; an explicit mark-as-read still clears everything.
const sender = (userId: string) => ({ getSender: () => userId });
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
test('passive: threads stay unread, followed or not; main receipt is scoped to main (#314)', async () => {
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
const other = thread('$other', evt('$r2'));
const { mx, calls } = setup({
@@ -181,10 +181,9 @@ test('passive: a thread I replied in stays unread; main receipt is scoped to mai
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', false], // main-scoped: unthreaded would also read $mine's reply
['$r2', false],
],
// main-scoped (unthreaded would read the threads' replies), and no thread
// receipts: [Gitea #314] unfollowed threads used to be read silently.
[['b', false]],
);
});
@@ -203,7 +202,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
assert.equal(calls.length, 0);
});
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
test('passive: an unfollowed unread thread is left unread too (#314)', async () => {
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
const { mx, calls } = setup({
timeline: [evt('a'), evt('b')],
@@ -214,10 +213,16 @@ test('passive: no followed thread unread → main receipt stays unthreaded', asy
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[
['b', true],
['$r2', false],
],
[['b', false]],
);
});
test('passive: no thread unread → main receipt stays unthreaded', async () => {
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [] });
await markAsRead(mx, '!r:server', false, { passive: true });
assert.deepEqual(
calls.map((c) => [c.eventId, c.unthreaded]),
[['b', true]],
);
});
@@ -249,9 +254,10 @@ test('passive: the thread open in the panel is left to the panel', async () => {
threadUnread: { $open: 1, $other: 1 },
});
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
// The open thread is left to the panel, and [#314] the other one stays unread.
assert.deepEqual(
calls.map((c) => c.eventId),
['$r2'],
[],
);
});
+12 -18
View File
@@ -1,27 +1,19 @@
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
import { MatrixClient, NotificationCountType, ReceiptType, Thread } from 'matrix-js-sdk';
import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
import { isThreadFollowed } from './room';
/**
* [Gitea #217] Threads the user is following: they started it, replied in it,
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
* thread bundle and lags a reply we just sent, so our own loaded events count too.
*/
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
if (thread.hasCurrentUserParticipated) return true;
if (userId && thread.rootEvent?.getSender() === userId) return true;
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
return (
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
);
}
// isThreadFollowed lives in utils/room (getUnreadInfo needs it too).
export { isThreadFollowed };
export type MarkAsReadOptions = {
/**
* [Gitea #217] The read comes from simply looking at the room (timeline at
* the bottom and focused), not an explicit "mark as read". Threads the user
* follows stay unread until their panel is opened, like Slack and Discord;
* the rest are cleared so they don't keep the room dot lit forever.
* the bottom and focused), not an explicit "mark as read". Threads stay
* unread until opened (or "Mark all read"), like Slack and Discord.
* [Gitea #314] That includes threads you don't follow; those no longer
* count toward the room's unread dot (see getUnreadInfo), so leaving them
* unread doesn't keep the room lit.
*/
passive?: boolean;
/**
@@ -66,7 +58,9 @@ export async function markAsRead(
const threads = room.getThreads();
const threadUnread = (thread: Thread) =>
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
// [Gitea #314] A passive read leaves every thread's state alone (it used to
// silently read every thread you don't follow).
const keepThread = (_thread: Thread) => passive;
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
// it, so a passive read while a followed thread is unread must stay scoped
// to the main timeline or the next main message would clear that thread.
+26 -3
View File
@@ -1,4 +1,5 @@
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
import { MatrixClient, MatrixError, IPresenceOpts, Method } from 'matrix-js-sdk';
import { BUSY_PRESENCE } from './busyPresence';
/**
* Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per
@@ -10,7 +11,29 @@ import { MatrixClient, MatrixError } from 'matrix-js-sdk';
* and `PresenceDeduper` lets the heartbeat skip writes that would not change
* anything, so it stops spending the budget in the first place.
*/
export type PresenceWrite = { presence: 'online' | 'offline' | 'unavailable'; status_msg?: string };
export type PresenceWrite = {
// [Gitea #283] or MSC3026 busy (Do Not Disturb)
presence: 'online' | 'offline' | 'unavailable' | typeof BUSY_PRESENCE;
status_msg?: string;
};
/**
* `mx.setPresence` for a PresenceWrite. The SDK refuses anything but the three
* spec presences ("Bad presence value"), so MSC3026 busy (#283) goes straight
* to the same endpoint.
*/
export const writePresence = async (
mx: Pick<MatrixClient, 'setPresence'> & Partial<Pick<MatrixClient, 'getSafeUserId' | 'http'>>,
write: PresenceWrite,
): Promise<void> => {
if (write.presence !== BUSY_PRESENCE) {
await mx.setPresence(write as IPresenceOpts);
return;
}
if (!mx.getSafeUserId || !mx.http) throw new Error('busy presence needs a full MatrixClient');
const path = `/presence/${encodeURIComponent(mx.getSafeUserId())}/status`;
await mx.http.authedRequest(Method.Put, path, undefined, write);
};
const sleep = (ms: number) =>
new Promise<void>((r) => {
@@ -28,7 +51,7 @@ export async function setPresenceWithRetry(
for (;;) {
try {
// eslint-disable-next-line no-await-in-loop
await mx.setPresence(write);
await writePresence(mx, write);
return;
} catch (e) {
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
+39
View File
@@ -928,3 +928,42 @@ test('getAllVersionsRoomCreator returns empty set when no create event', () => {
test('EventTimeline.FORWARDS constant is available', () => {
assert.equal(typeof EventTimeline.FORWARDS, 'string');
});
test('getUnreadInfo: unfollowed threads do not light the room; followed and mentioned do (#314)', () => {
const threads = [
{ id: '$followed', hasCurrentUserParticipated: true },
{ id: '$other' },
{ id: '$mention' },
];
const counts: Record<string, { total: number; highlight: number }> = {
$followed: { total: 2, highlight: 0 },
$other: { total: 3, highlight: 0 },
$mention: { total: 1, highlight: 1 },
};
const room = {
roomId: '!r:x',
// Server room totals include thread counts: 2 + 3 + 1 thread, 0 main.
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? 6 : 1,
getThreads: () => threads,
getThreadUnreadNotificationCount: (id: string, type: NotificationCountType) =>
type === NotificationCountType.Total ? counts[id].total : counts[id].highlight,
getEventReadUpTo: () => null,
getLiveTimeline: () => ({ getEvents: () => [] }),
} as unknown as Room;
const me = { getUserId: () => '@me:x' } as unknown as MatrixClient;
assert.deepEqual(getUnreadInfo(room, undefined, me), { roomId: '!r:x', highlight: 1, total: 3 });
// Only an unfollowed thread is unread: the room isn't lit.
counts.$followed.total = 0;
counts.$mention = { total: 0, highlight: 0 };
const onlyOther = {
...room,
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? 3 : 0,
} as unknown as Room;
assert.deepEqual(getUnreadInfo(onlyOther, undefined, me), {
roomId: '!r:x',
highlight: 0,
total: 0,
});
});
+27
View File
@@ -18,6 +18,7 @@ import {
NotificationCountType,
RelationType,
Room,
Thread,
RoomMember,
TimelineEvents,
IRoomTimelineData,
@@ -418,6 +419,20 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
/**
* [Gitea #217] Threads the user is following: they started it, replied in it,
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
* thread bundle and lags a reply we just sent, so our own loaded events count too.
*/
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
if (thread.hasCurrentUserParticipated) return true;
if (userId && thread.rootEvent?.getSender() === userId) return true;
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
return (
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
);
}
export const getUnreadInfo = (
room: Room,
mutedThreads?: Set<string>,
@@ -439,6 +454,18 @@ export const getUnreadInfo = (
if (highlight < 0) highlight = 0;
}
// [Gitea #314] Threads you don't follow stay unread in the Threads panel
// (viewing the room no longer reads them) but don't light the room's dot.
// A mention makes a thread followed, so highlights are never subtracted.
if (mx) {
room.getThreads().forEach((thread) => {
if (mutedThreads?.has(thread.id)) return;
if (isThreadFollowed(room, thread, mx.getUserId())) return;
total -= room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
});
if (total < 0) total = 0;
}
const resolvedTotal = highlight > total ? highlight : total;
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
+72
View File
@@ -0,0 +1,72 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildTopicContent } from './topicMarkdown';
test('a URL with underscores is sent verbatim (#262)', () => {
const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq';
assert.deepEqual(buildTopicContent(t), { topic: t });
});
test('a markdown link keeps its underscored URL in both fields (#262)', () => {
const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/);
assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)');
});
test('room aliases and user IDs with underscores are untouched (#262)', () => {
for (const t of [
'Join #my_room_name:matrix.org',
'Ask @some_user_x:matrix.org',
'see snake_case_word',
]) {
assert.deepEqual(buildTopicContent(t), { topic: t });
}
});
test('emphasis still works at word boundaries', () => {
const c = buildTopicContent('this is _really_ *great* and **bold**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<i data-md="_">really<\/i>/);
assert.match(c.formatted_body, /<i data-md="\*">great<\/i>/);
assert.match(c.formatted_body, /<strong data-md="\*\*">bold<\/strong>/);
assert.equal(c.topic, 'this is really great and bold');
});
test('emphasis around an alias keeps the alias intact', () => {
const c = buildTopicContent('**Join #my_room:matrix.org**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<strong data-md="\*\*">Join #my_room:matrix\.org<\/strong>/);
assert.equal(c.topic, 'Join #my_room:matrix.org');
});
test('inline code keeps its underscores and no backslashes appear', () => {
const c = buildTopicContent('run `a_b_c` now');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<code data-md="`">a_b_c<\/code>/);
assert.equal(c.topic, 'run a_b_c now');
});
test('a double quote in a link target cannot break out of the attribute (#262)', () => {
const c = buildTopicContent('[x](https://a" onclick="alert(1))');
assert.ok('formatted_body' in c);
assert.doesNotMatch(c.formatted_body, /" onclick="/);
});
test('HTML in the topic is escaped, and the plain topic shows it as typed', () => {
const c = buildTopicContent('**<b>hi</b> & bye**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /&lt;b&gt;hi&lt;\/b&gt; &amp; bye/);
assert.equal(c.topic, '<b>hi</b> & bye');
});
test('line breaks become <br> and survive in the plain topic', () => {
const c = buildTopicContent('**Rules**\nBe nice');
assert.ok('formatted_body' in c);
assert.equal(c.formatted_body, '<strong data-md="**">Rules</strong><br>Be nice');
assert.equal(c.topic, 'Rules\nBe nice');
});
test('plain text with no markdown is sent as typed', () => {
assert.deepEqual(buildTopicContent("It's <fun> & games"), { topic: "It's <fun> & games" });
});
+63
View File
@@ -0,0 +1,63 @@
import { parseInlineMD } from '../plugins/markdown';
import { sanitizeText } from './sanitize';
/**
* Room topic markdown → `m.room.topic` content (#262).
*
* Uses the composer's inline markdown parser (the same rules as messages: links,
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
* two letters/digits is literal, as in CommonMark. Topics are full of
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
* shared parser would otherwise turn into italics and, worse, drop from the
* plain `topic` that every other client shows.
*/
// Spans the guard must not touch: inline code, link targets, bare URLs.
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
const escapeIntrawordUnderscores = (line: string): string =>
line
.split(PROTECTED)
.map((part, i) =>
// split() with one capture group puts the protected spans at odd indexes.
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
)
.join('');
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
const decodeEntities = (s: string): string =>
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
const htmlToPlain = (html: string): string =>
decodeEntities(
html
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
const text = label.replace(/<[^>]+>/g, '');
return text === href ? href : `${text} (${href})`;
})
.replace(/<br>/g, '\n')
.replace(/<[^>]+>/g, ''),
);
export type TopicContent =
| { topic: string }
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
export function buildTopicContent(text: string): TopicContent {
const lines = text.split('\n');
const html = lines
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
.join('<br>');
const plainHtml = lines.map(sanitizeText).join('<br>');
// No markdown at all: send the text as typed, without a formatted body.
if (html === plainHtml) return { topic: text };
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
}
+14 -4
View File
@@ -69,13 +69,23 @@ test('install kinds: anything unknown keeps the in-app updater', async () => {
test('package installs get their package manager’s command, not an Install button', async () => {
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
const pacman = packageUpdateHelp('pacman')!;
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
assert.equal(
pacman.command,
'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst',
// [cinny-desktop #36] signature, then checksum, then install, in that order;
// a local `pacman -U ./file` (not `<url>`, which also wants `<url>.sig`).
const steps = pacman.command!.split(' && ');
assert.equal(steps[0], 'cd "$(mktemp -d)"');
assert.match(
steps[1],
/-fLO \S+\/LotusChat-x86_64\.pkg\.tar\.zst -fLO \S+\/SHA256SUMS\.txt -fLO \S+\/SHA256SUMS\.txt\.minisig$/,
);
assert.equal(
steps[2],
'minisign -Vm SHA256SUMS.txt -P RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l',
);
assert.equal(steps[3], 'sha256sum -c --ignore-missing SHA256SUMS.txt');
assert.equal(steps[4], 'sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst');
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
const deb = packageUpdateHelp('deb')!;
assert.match(deb.command!, /minisign -Vm SHA256SUMS\.txt/);
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
assert.equal(packageUpdateHelp('manual')?.command, undefined);
assert.equal(packageUpdateHelp('in-app'), undefined);
+24 -9
View File
@@ -64,23 +64,38 @@ export const toInstallKind = (value: unknown): InstallKind =>
export type PackageUpdateHelp = { command?: string; url: string; download: string };
/** The desktop updater's minisign public key (cinny-desktop tauri.conf.json). */
export const RELEASE_MINISIGN_KEY = 'RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l';
/**
* [cinny-desktop #36] Download a Linux package and install it only after
* checking the release's minisign-signed SHA256SUMS.txt. Runs in a fresh temp
* directory so a stale SHA256SUMS.txt can't be picked up.
*/
const verifiedInstall = (file: string, install: string): string =>
[
`cd "$(mktemp -d)"`,
`curl -fLO ${RELEASE_DOWNLOAD}/${file} -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt.minisig`,
`minisign -Vm SHA256SUMS.txt -P ${RELEASE_MINISIGN_KEY}`,
`sha256sum -c --ignore-missing SHA256SUMS.txt`,
`${install} ./${file}`,
].join(' && ');
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
if (kind === 'pacman') {
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`;
// Download first, then install the local file: `pacman -U <url>` also
// fetches `<url>.sig` and fails without it (we don't sign packages),
// while a local file falls under LocalFileSigLevel (signature optional).
// Install the local file: `pacman -U <url>` also fetches `<url>.sig` and
// fails without it (packages aren't signed for pacman), while a local file
// falls under LocalFileSigLevel. Integrity comes from SHA256SUMS.txt.
return {
command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`,
url,
command: verifiedInstall('LotusChat-x86_64.pkg.tar.zst', 'sudo pacman -U'),
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`,
download: 'Download package',
};
}
if (kind === 'deb') {
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`;
return {
command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`,
url,
command: verifiedInstall('LotusChat-x86_64.deb', 'sudo apt install'),
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`,
download: 'Download package',
};
}