Compare commits

...
Author SHA1 Message Date
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 CI 0b940a6df7 Merge remote-tracking branch 'origin/lotus' into perf/lazy-settings
CI / Build & Quality Checks (pull_request) Successful in 2m0s
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 11m52s
# Conflicts:
#	src/app/features/settings/account/Profile.tsx
2026-10-19 01:07:39 -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
jared 46da76c21b Merge pull request 'feat(calls): minimise the call PiP to a small bar; phones start minimised (#323)' (#414) from fix/pip-minimise 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 01:00:16 -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
jared a20accd446 Merge pull request 'fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)' (#409) from fix/translate-unavailable-copy 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 00:47:26 -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
jared 080831d4ea Merge pull request 'fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)' (#394) from fix/status-expiry-account-data 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 00:35:05 -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
Lotus CIandClaude Opus 5.5 8b6464c8b9 perf: load the Settings dialog on demand; entry chunk 351.8 → 277.1 kB gzip
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
Settings (every page, pickers, the denoise tester) was in the entry
chunk though it only renders when opened. Load it lazily behind a
focusable spinner (it opens inside a FocusTrap), and warm the chunk
when the app goes idle. SettingsPages and the status localStorage keys
move to tiny modules so callers don't pull the dialog back in.
sanitize-html (+postcss, htmlparser2) and chroma-js get their own vendor
chunks; with Settings gone the chunk graph had dropped them into the
entry chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 00:00:19 -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 7ef24c14e0 Merge branch 'lotus' into fix/pip-minimise
CI / Build & Quality Checks (pull_request) Successful in 2m52s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 1m11s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m54s
2026-10-18 23:40:47 -04:00
jared ca284a75e3 Merge branch 'lotus' into fix/translate-unavailable-copy
CI / Build & Quality Checks (pull_request) Successful in 1m50s
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 12m19s
2026-10-18 23:40:46 -04:00
jared 179be38e2c Merge branch 'lotus' into fix/status-expiry-account-data
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 10s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m4s
2026-10-18 23:40:44 -04:00
Lotus CI 708e5c2c14 Merge remote-tracking branch 'origin/lotus' into fix/pip-minimise
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
# Conflicts:
#	src/app/components/CallEmbedProvider.tsx
2026-10-18 23:40:18 -04:00
Lotus CI b07eb35159 Merge remote-tracking branch 'origin/lotus' into fix/translate-unavailable-copy
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
CI / Secret scan (gitleaks) (pull_request) Canceled after 0s
CI / Docker image build & smoke test (pull_request) Canceled after 0s
CI / Playwright smoke (e2e) (pull_request) Canceled after 0s
# Conflicts:
#	src/app/utils/translation/langUtils.ts
2026-10-18 23:37:54 -04:00
Lotus CI e4b2405a19 Merge remote-tracking branch 'origin/lotus' into fix/status-expiry-account-data
CI / Build & Quality Checks (pull_request) Failing after 1m49s
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 9s
# Conflicts:
#	src/app/pages/client/ClientNonUIFeatures.tsx
#	src/types/matrix/accountData.ts
2026-10-18 23:20:44 -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 c4c54dad7a feat(calls): minimise the call PiP to a small bar; phones start minimised (#323)
CI / Build & Quality Checks (pull_request) Successful in 2m51s
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 15m22s
The PiP had no way to get out of the way short of dragging it. Add a
minimise / expand button: minimised it is a 220×40 bar with the call
room's name (still draggable, tap to return to the call). The choice is
remembered; with no saved choice, phones start minimised.

Fixes #323

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 01:08:36 -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 f41de57893 fix(translate): no "use the Lotus desktop app" advice inside the desktop app (#349)
CI / Build & Quality Checks (pull_request) Successful in 3m24s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 16s
CI / Playwright smoke (e2e) (pull_request) Failing after 18m15s
Where the on-device Translator/LanguageDetector APIs are missing,
Settings said to use Chrome/Edge "or the Lotus desktop app", but the
Linux and macOS apps (WebKit) lack them too, and showed the advice to
people already in the app. Word it by platform. (The per-message
Translate action was already hidden where unsupported.)

Fixes #349

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-18 00:19:48 -04:00
Lotus CIandClaude Opus 5.5 c8576cbb1f fix(status): any Lotus device clears an auto-expiring status, not just the one that set it (#300)
CI / Build & Quality Checks (pull_request) Successful in 3m2s
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 25m12s
"Clear after 1 hour" lived in that device's localStorage, so a status
set on a laptop that was then closed stayed on the server until the
laptop opened Lotus again. The expiry is now also kept in account data
(io.lotus.status_expiry: { status_msg, expires_at }). Every device's
StatusExpiryMonitor clears the status when due, but only if it is still
the one that was set to expire (otherwise it just forgets the expiry).

Fixes #300

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 20:37:21 -04:00
39 changed files with 726 additions and 93 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''',
+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.24",
"@lotusguild/element-call-embedded": "0.25.0-lotus.25",
"@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.24",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.24/element-call-embedded-0.25.0-lotus.24.tgz",
"integrity": "sha512-6cMjJ0WCGvZJridoF0YslvF3giYar/uLgc/JAlgLqBg+Irv+Mm/xWuC2bme60BT+f/wmEX30gr7WDeIc4JUL7w==",
"version": "0.25.0-lotus.25",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.25/element-call-embedded-0.25.0-lotus.25.tgz",
"integrity": "sha512-Vvw6B+QoG0/qFJcBzhlAbE+vnk83DikpeeF2xbygpoHfmG9r1H/mS47V91jAMH2vmNbGiALwFlLbJjEET43A2g==",
"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.24",
"@lotusguild/element-call-embedded": "0.25.0-lotus.25",
"@playwright/test": "1.63.0",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
+94 -7
View File
@@ -86,10 +86,14 @@ 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;
const PIP_MIN_H = 112;
/** [Gitea #323] Minimised PiP bar size. */
const PIP_MINI_W = 220;
const PIP_MINI_H = 40;
/** Default PiP top: just below the 64px-ish room header. */
const PIP_DEFAULT_TOP = 72;
@@ -814,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).
@@ -1003,6 +1008,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
);
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
// instead of the video. Remembered; phones start minimised.
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
try {
const v = localStorage.getItem('pip-minimized');
if (v !== null) return v === '1';
} catch {
/* storage unavailable */
}
return window.innerWidth < 600;
});
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
const togglePipMinimized = useCallback(() => {
setPipMinimized((m) => {
try {
localStorage.setItem('pip-minimized', m ? '0' : '1');
} catch {
/* storage unavailable */
}
return !m;
});
}, []);
useEffect(() => {
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFsChange);
@@ -1103,6 +1130,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
}
}, [pipMode, callVisible]);
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
// below has positioned and sized it). Keeps the bar on screen.
React.useEffect(() => {
const el = callEmbedRef.current;
if (!el || !pipMode) return;
if (pipMinimized) {
if (el.offsetHeight > PIP_MINI_H) {
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
}
el.style.width = `${PIP_MINI_W}px`;
el.style.height = `${PIP_MINI_H}px`;
} else {
el.style.width = `${pipFullSizeRef.current.width}px`;
el.style.height = `${pipFullSizeRef.current.height}px`;
}
const rect = el.getBoundingClientRect();
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
}, [pipMode, pipMinimized, callEmbedRef]);
React.useEffect(() => {
if (!pipMode) return;
const onPipWindowResize = (): void => {
@@ -1421,19 +1468,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
position: 'absolute',
inset: 0,
zIndex: 1,
background: 'transparent',
// Minimised: cover the frame so only the bar shows.
background: pipMinimized ? color.Surface.Container : 'transparent',
cursor: 'grab',
// [Gitea #43] The call's "Share your screen?" prompt is drawn
// inside the frame: let the click reach its Share button.
pointerEvents: pipPromptOpen ? 'none' : undefined,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'flex-end',
alignItems: pipMinimized ? 'center' : 'flex-start',
padding: '6px',
}}
>
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<div
style={{
display: 'flex',
gap: config.space.S100,
alignItems: 'center',
...(pipMinimized ? { width: '100%' } : {}),
}}
>
{/* Leftmost: the top-right corner is a resize handle. */}
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
aria-pressed={pipMinimized}
onClick={(e) => {
e.stopPropagation();
togglePipMinimized();
}}
onMouseDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
style={{
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
color: '#fff',
}}
>
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
</IconButton>
{!pipMinimized && document.fullscreenEnabled && (
<IconButton
type="button"
size="300"
@@ -1465,14 +1543,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
fontSize: '11px',
fontWeight: 600,
pointerEvents: 'none',
...(pipMinimized
? {
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
: {}),
}}
>
↗ Return to call
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
</div>
</div>
</div>
<PipMuteOverlay callEmbed={callEmbed} />
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
const s = corner.includes('s');
const e2 = corner.includes('e');
const dots = [
+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>
);
}
+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} />
+2 -9
View File
@@ -33,16 +33,9 @@ import { About } from './about';
import { UseStateProvider } from '../../components/UseStateProvider';
import { stopPropagation } from '../../utils/keyboard';
import { LogoutDialog } from '../../components/LogoutDialog';
import { SettingsPages } from './pages';
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
export { SettingsPages };
type SettingsMenuItem = {
page: SettingsPages;
@@ -0,0 +1,43 @@
import React, { lazy, Suspense } from 'react';
import { Box, Spinner, config } from 'folds';
import type { SettingsPages } from './pages';
// Settings is a large, rarely-open dialog (all its pages, pickers, the
// denoise tester…). Load it on first open instead of in the entry chunk, and
// warm the chunk once the app is idle so opening it rarely waits.
const loadSettings = () => import('./Settings');
const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings })));
if (typeof window !== 'undefined') {
const warm = () => {
loadSettings().catch(() => undefined);
};
if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 });
else setTimeout(warm, 5_000);
}
type SettingsProps = {
initialPage?: SettingsPages;
requestClose: () => void;
};
export function Settings(props: SettingsProps) {
return (
<Suspense
fallback={
// Settings opens inside a FocusTrap, which needs a tabbable node.
<Box
tabIndex={0}
role="status"
aria-label="Loading settings"
alignItems="Center"
justifyContent="Center"
style={{ padding: config.space.S700, outline: 'none' }}
>
<Spinner variant="Secondary" size="400" />
</Box>
}
>
<SettingsDialog {...props} />
</Suspense>
);
}
+16 -6
View File
@@ -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';
@@ -67,6 +71,8 @@ import {
makePresetId,
normalizeLabel,
} from '../../../utils/statusPresets';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
import { writeStatusExpiry } from '../../../utils/statusExpiry';
type ProfileProps = {
profile: UserProfile;
@@ -365,8 +371,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
);
}
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' },
@@ -508,12 +513,16 @@ function ProfileStatus() {
if (msg && delayMs > 0) {
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
const expiresAt = Date.now() + delayMs;
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(expiresAt));
// [Gitea #300] …and on every other Lotus device, via account data.
writeStatusExpiry(mx, msg, expiresAt).catch(() => undefined);
} else {
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
writeStatusExpiry(mx, msg).catch(() => undefined);
}
},
[saveStatus, userId],
[saveStatus, userId, mx],
);
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -553,8 +562,9 @@ function ProfileStatus() {
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
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);
@@ -108,6 +108,7 @@ import {
TRANSLATE_TARGET_LANGUAGES,
normalizeLang,
isSupportedTargetLang,
translationUnavailableText,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
@@ -2756,7 +2757,7 @@ function Messages() {
description={
translationSupported
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
: translationUnavailableText(isTauriEnv())
}
after={
<SettingsSelect
+2 -1
View File
@@ -1 +1,2 @@
export * from './Settings';
export { SettingsPages } from './pages';
export { Settings } from './SettingsLazy';
+11
View File
@@ -0,0 +1,11 @@
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
* page without pulling the dialog into the entry chunk. */
export enum SettingsPages {
GeneralPage,
AccountPage,
NotificationPage,
DevicesPage,
EmojisStickersPage,
DeveloperToolsPage,
AboutPage,
}
+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',
}),
[],
);
+22 -7
View File
@@ -63,7 +63,7 @@ import {
migrateLocalMuteTimers,
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
@@ -92,6 +92,7 @@ import {
shouldNotifyThreadReply,
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
import { readStatusExpiry, statusExpiryAction, writeStatusExpiry } from '../../utils/statusExpiry';
import { AccountDataEvent } from '../../../types/matrix/accountData';
// Grace period after the initial sync settles before invite notifications arm, so
@@ -436,12 +437,7 @@ function StatusExpiryMonitor() {
// permanently and the status stayed set forever. Retry the write (honouring
// Retry-After), and only forget the status once the server has taken it.
let clearing = false;
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
const clearStatus = () => {
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
@@ -451,12 +447,31 @@ function StatusExpiryMonitor() {
noteLocalStatusWrite();
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
writeStatusExpiry(mx, '').catch(() => undefined);
})
.catch(() => undefined)
.finally(() => {
clearing = false;
});
};
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
const ts = stored ? parseInt(stored, 10) : 0;
if (ts && Date.now() >= ts) {
clearStatus();
return;
}
// [Gitea #300] An expiry set on another device (account data): clear the
// status if it is still that one, otherwise just forget the expiry.
const action = statusExpiryAction(
readStatusExpiry(mx),
mx.getUser(userId)?.presenceStatusMsg,
Date.now(),
);
if (action === 'clear') clearStatus();
else if (action === 'forget') writeStatusExpiry(mx, '').catch(() => undefined);
};
check();
// Poll often enough that a rate-limited retry lands promptly, but not so
+24
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,8 @@ 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';
@@ -233,6 +236,27 @@ 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);
+6
View File
@@ -453,6 +453,12 @@ 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) => {
+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,
+22
View File
@@ -0,0 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseStatusExpiry, statusExpiryAction } from './statusExpiry';
test('parseStatusExpiry (#300)', () => {
assert.deepEqual(parseStatusExpiry({ status_msg: '🍔 Lunch', expires_at: 5 }), {
statusMsg: '🍔 Lunch',
expiresAt: 5,
});
assert.equal(parseStatusExpiry({}), undefined);
assert.equal(parseStatusExpiry({ status_msg: '', expires_at: 5 }), undefined);
assert.equal(parseStatusExpiry({ status_msg: 'x', expires_at: '5' }), undefined);
});
test('statusExpiryAction: clears only the status that was set to expire (#300)', () => {
const e = { statusMsg: '🍔 Lunch', expiresAt: 1000 };
assert.equal(statusExpiryAction(e, '🍔 Lunch', 999), 'none');
assert.equal(statusExpiryAction(e, '🍔 Lunch', 1000), 'clear');
assert.equal(statusExpiryAction(e, '🎮 Gaming', 2000), 'forget');
assert.equal(statusExpiryAction(e, '', 2000), 'forget');
assert.equal(statusExpiryAction(undefined, 'x', 2000), 'none');
});
+46
View File
@@ -0,0 +1,46 @@
import { MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData } from './room';
/**
* [Gitea #300] The status message's auto-clear time, in account data so every
* Lotus device knows it. Before, only the device that set "clear after 1 hour"
* (localStorage) ever cleared it, and only while it was open.
*/
export type StatusExpiry = { statusMsg: string; expiresAt: number };
/** Pure: parse `io.lotus.status_expiry` content. */
export function parseStatusExpiry(content: unknown): StatusExpiry | undefined {
const c = content as { status_msg?: unknown; expires_at?: unknown } | undefined;
if (!c || typeof c.status_msg !== 'string' || typeof c.expires_at !== 'number') return undefined;
if (!c.status_msg || !Number.isFinite(c.expires_at)) return undefined;
return { statusMsg: c.status_msg, expiresAt: c.expires_at };
}
/**
* Pure: what to do with a due expiry. Clear the server status only if it is
* still the one that was set to expire; if it has since changed (e.g. on
* another device), just forget the expiry.
*/
export function statusExpiryAction(
entry: StatusExpiry | undefined,
currentStatusMsg: string | undefined,
now: number,
): 'none' | 'clear' | 'forget' {
if (!entry || now < entry.expiresAt) return 'none';
return (currentStatusMsg ?? '') === entry.statusMsg ? 'clear' : 'forget';
}
export const readStatusExpiry = (mx: MatrixClient): StatusExpiry | undefined =>
parseStatusExpiry(getAccountData(mx, AccountDataEvent.LotusStatusExpiry)?.getContent());
/** Record (or with no `expiresAt`, remove) the shared auto-clear time. */
export async function writeStatusExpiry(
mx: MatrixClient,
statusMsg: string,
expiresAt?: number,
): Promise<void> {
const content =
statusMsg && expiresAt !== undefined ? { status_msg: statusMsg, expires_at: expiresAt } : {};
await mx.setAccountData(AccountDataEvent.LotusStatusExpiry as never, content as never);
}
+3
View File
@@ -0,0 +1,3 @@
/** localStorage keys for this device's copy of the status message and its expiry. */
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
@@ -1,6 +1,7 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
translationUnavailableText,
normalizeLang,
sameLanguage,
languageName,
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
assert.equal(new Set(codes).size, codes.length);
assert.ok(codes.includes('en'));
});
test('translationUnavailableText: no "use the desktop app" inside the desktop app (#349)', () => {
assert.doesNotMatch(translationUnavailableText(true), /desktop app\.$|Lotus desktop app/);
assert.match(translationUnavailableText(true), /this version of the desktop app/);
assert.match(translationUnavailableText(false), /this browser/);
});
+10
View File
@@ -86,6 +86,16 @@ export function isSupportedTargetLang(code: string): boolean {
return CURATED_NAME.has(normalizeLang(code));
}
/**
* [Gitea #349] Why translation is unavailable, by platform. The desktop app
* on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send
* people to "the Lotus desktop app" they're already using.
*/
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
+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',
};
}
+3
View File
@@ -26,6 +26,9 @@ export enum AccountDataEvent {
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
LotusSettings = 'io.lotus.settings',
// [Gitea #300] When the status message auto-clears: { status_msg, expires_at },
// so any Lotus device can clear it (not only the one that set it).
LotusStatusExpiry = 'io.lotus.status_expiry',
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
// device can lift a mute set on another.
LotusMuteTimers = 'io.lotus.mute_timers',
+19
View File
@@ -251,6 +251,25 @@ const vendorChunks = (id) => {
if (id.includes('node_modules/immer')) return 'immer';
if (id.includes('node_modules/folds')) return 'folds';
if (id.includes('node_modules/emojibase')) return 'emojibase';
// HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour
// maths are needed at startup but are big, stable libraries: keep them in
// their own cacheable chunks instead of wherever the chunk graph drops them
// (once Settings became lazy they landed in the entry chunk).
if (
id.includes('node_modules/sanitize-html') ||
id.includes('node_modules/postcss') ||
id.includes('node_modules/htmlparser2') ||
id.includes('node_modules/domhandler') ||
id.includes('node_modules/domutils') ||
id.includes('node_modules/dom-serializer') ||
id.includes('node_modules/entities') ||
id.includes('node_modules/parse-srcset') ||
id.includes('node_modules/nanoid') ||
id.includes('node_modules/picocolors') ||
id.includes('node_modules/source-map-js')
)
return 'sanitize-html';
if (id.includes('node_modules/chroma-js')) return 'chroma';
};
export default defineConfig({