From 7a61b82479b542e6dd1f86fe3b0155dff1031abb Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:46:35 -0400 Subject: [PATCH 001/185] feat(dev-tools): add rotate-sessions developer tool --- .../settings/developer-tools/DevelopTools.tsx | 82 ++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 100119726..aa0dd8a80 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; -import { Box, Text, Scroll, Switch, Button } from 'folds'; +import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; +import { KnownMembership } from 'matrix-js-sdk/lib/types'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -11,6 +12,7 @@ import { AccountDataEditor } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; import { SettingsSectionPage } from '../SettingsSectionPage'; +import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; @@ -26,6 +28,33 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const [expand, setExpend] = useState(false); const [accountDataType, setAccountDataType] = useState(); + const [rotateState, rotateAllSessions] = useAsyncCallback< + { rotated: number; total: number }, + Error, + [] + >( + useCallback(async () => { + const crypto = mx.getCrypto(); + if (!crypto) throw new Error('Crypto module not available'); + + const encryptedRooms = mx + .getRooms() + .filter( + (room) => + room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId) + ); + + await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId))); + const rotated = encryptedRooms.length; + + // Proactively start session creation + key sharing with all devices + // (including bridge bots). fire-and-forget per room. + encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); + + return { rotated, total: encryptedRooms.length }; + }, [mx]) + ); + const submitAccountData: AccountDataSubmitCallback = useCallback( async (type, content) => { // TODO: remove cast once account data typing is unified. @@ -110,6 +139,57 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && ( + + Encryption + + + ) + } + > + + {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} + + + } + > + {rotateState.status === AsyncStatus.Success && ( + + Sessions discarded for {rotateState.data.rotated} of{' '} + {rotateState.data.total} encrypted rooms. Key sharing is starting in the + background — send a message in an affected room to confirm delivery to + bridges. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} {developerTools && ( Date: Tue, 31 Mar 2026 12:06:31 -0400 Subject: [PATCH 002/185] chore: add changeset for devtool-rotate-sessions --- .changeset/devtool-rotate-sessions.md | 5 +++++ src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 .changeset/devtool-rotate-sessions.md diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md new file mode 100644 index 000000000..2730ad8e6 --- /dev/null +++ b/.changeset/devtool-rotate-sessions.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add rotate-sessions developer tool to force session rotation for testing diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index aa0dd8a80..3bba68aac 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -11,8 +11,8 @@ import type { AccountDataSubmitCallback } from '$components/AccountDataEditor'; import { AccountDataEditor } from '$components/AccountDataEditor'; import { copyToClipboard } from '$utils/dom'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { SettingsSectionPage } from '../SettingsSectionPage'; import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; +import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; import { DebugLogViewer } from './DebugLogViewer'; From fe1018ca17064046501aa796703caf8c4f4be4af Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 22:55:00 -0400 Subject: [PATCH 003/185] fix(dev-tools): address review feedback for rotate-sessions - Import KnownMembership from $types/matrix-sdk instead of matrix-js-sdk/lib/types - Use Promise.allSettled to handle partial failures and report accurate count - Add window.confirm confirmation before discarding sessions - Clarify changeset: 'encryption sessions' not 'sessions' --- .changeset/devtool-rotate-sessions.md | 2 +- build.log | 114 ++++++++++++++++++ full_output.txt | 0 lint_output.txt | 62 ++++++++++ .../settings/developer-tools/DevelopTools.tsx | 16 ++- test_file.txt | 1 + 6 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 build.log create mode 100644 full_output.txt create mode 100644 lint_output.txt create mode 100644 test_file.txt diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md index 2730ad8e6..cae1bda0c 100644 --- a/.changeset/devtool-rotate-sessions.md +++ b/.changeset/devtool-rotate-sessions.md @@ -2,4 +2,4 @@ default: patch --- -Add rotate-sessions developer tool to force session rotation for testing +Add rotate-encryption-sessions developer tool to force Megolm session rotation for testing diff --git a/build.log b/build.log new file mode 100644 index 000000000..1f5016d8d --- /dev/null +++ b/build.log @@ -0,0 +1,114 @@ + +> sable@1.14.0 build /Users/evie/git/Sable +> vite build + +vite v7.3.1 building client environment for production... +transforming... +✓ 8032 modules transformed. +rendering chunks... +computing gzip size... +[vite-plugin-static-copy] Copied 10 items. +dist/.assetsignore 0.02 kB +dist/assets/index-Ds5yF1gS.js.br 0.07 kB +dist/assets/index-Ds5yF1gS.js.map.br 0.41 kB +dist/wrangler.json.br 0.42 kB +dist/assets/cinny-logo-maskable-36x36-CT1Bq-jJ.png 0.79 kB +dist/index.html.br 0.88 kB +dist/assets/cinny-logo-maskable-DXG8PKa6.svg.br 0.96 kB +dist/wrangler.json 1.00 kB │ gzip: 0.54 kB +dist/assets/cinny-logo-maskable-48x48-DfxQ4FEO.png 1.08 kB +dist/assets/cinny-logo-maskable-57x57-B_a6RKlt.png 1.28 kB +dist/assets/cinny-logo-maskable-60x60-uAvO2kwU.png 1.35 kB +dist/assets/cinny-logo-maskable-72x72-ChMts4zW.png 1.59 kB +dist/assets/cinny-logo-maskable-96x96-DeladQJ2.png 2.08 kB +dist/assets/cinny-logo-maskable-114x114-BRz0LILn.png 2.45 kB +dist/assets/cinny-logo-maskable-DXG8PKa6.svg 2.56 kB │ gzip: 1.12 kB +dist/assets/cinny-logo-maskable-120x120-BBcwUWQZ.png 2.59 kB +dist/assets/cinny-logo-maskable-144x144-CitAVeb0.png 3.08 kB +dist/assets/cinny-logo-maskable-152x152-Di3nKfIW.png 3.26 kB +dist/assets/cinny-logo-maskable-167x167-Dk9KG9Yi.png 3.59 kB +dist/assets/cinny-logo-maskable-180x180-RAgvvHf4.png 3.79 kB +dist/assets/cinny-logo-maskable-192x192-BHxZuLYc.png 4.06 kB +dist/assets/favicon-CemZgig7.png 4.15 kB +dist/index.html 4.28 kB │ gzip: 1.17 kB +dist/assets/arborium-4uGfF3G6.js.br 4.54 kB +dist/assets/arborium-4uGfF3G6.js.map.br 4.98 kB +dist/assets/space-mono-vietnamese-700-italic-BwPKnf-l.woff 5.24 kB +dist/assets/space-mono-vietnamese-400-normal-B0PMp_xB.woff 5.41 kB +dist/assets/space-mono-vietnamese-700-normal-D-KrLuLr.woff 5.45 kB +dist/assets/cinny-logo-maskable-256x256-B_icl17M.png 5.58 kB +dist/assets/space-mono-vietnamese-400-italic-DvlTUS1j.woff 5.88 kB +dist/assets/space-mono-vietnamese-400-normal-BNOj0Qhp.woff2 7.27 kB +dist/assets/space-mono-vietnamese-700-normal-DWQgDHuA.woff2 7.33 kB +dist/assets/space-mono-vietnamese-700-italic-i2bR4MHS.woff2 7.39 kB +dist/assets/cinny-logo-maskable-384x384-DA-2uwBp.png 8.52 kB +dist/assets/space-mono-vietnamese-400-italic-CyQIvI4V.woff2 9.69 kB +dist/assets/notification-EtLMRd0T.ogg 11.30 kB +dist/assets/cinny-logo-maskable-512x512-dB91iLyU.png 11.87 kB +dist/assets/space-mono-latin-400-normal-_3DlpgIW.woff 12.31 kB +dist/assets/space-mono-latin-700-normal-D7A851RN.woff 12.42 kB +dist/assets/space-mono-latin-ext-700-normal-B_E7P90g.woff 12.48 kB +dist/assets/space-mono-latin-ext-400-normal-D4cJI_B-.woff 12.61 kB +dist/assets/space-mono-latin-ext-400-italic-DYA_DB_l.woff 12.89 kB +dist/assets/nunito-vietnamese-wght-normal-U01xdrZh.woff2 13.10 kB +dist/assets/space-mono-latin-ext-700-italic-CbHMtIk0.woff 13.14 kB +dist/assets/space-mono-latin-700-italic-B8C1HgwN.woff 13.65 kB +dist/assets/space-mono-latin-400-italic-zmx7Qf09.woff 13.82 kB +dist/assets/nunito-vietnamese-wght-italic-5K55R7rt.woff2 14.77 kB +dist/assets/space-mono-latin-ext-700-normal-B2s3bDs2.woff2 15.81 kB +dist/assets/space-mono-latin-ext-400-normal-DTLbW2xa.woff2 15.83 kB +dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 16.52 kB +dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 16.72 kB +dist/assets/space-mono-latin-ext-400-italic-x3PrlAeq.woff2 17.20 kB +dist/assets/space-mono-latin-ext-700-italic-CkCrmjWu.woff2 17.74 kB +dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 18.30 kB +dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 18.64 kB +dist/assets/index-C5lozGWL.css.br 20.47 kB +dist/assets/nunito-cyrillic-wght-normal-CY6AOgYE.woff2 20.78 kB +dist/assets/nunito-cyrillic-wght-italic-AGUkry7S.woff2 22.90 kB +dist/assets/nunito-cyrillic-ext-wght-normal-D4X5GqEv.woff2 28.93 kB +dist/assets/nunito-cyrillic-ext-wght-italic-C7FdRbwB.woff2 31.51 kB +dist/assets/invite-DROg5x7-.ogg 32.67 kB +dist/assets/nunito-latin-ext-wght-normal-CXYtwYOx.woff2 35.59 kB +dist/assets/nunito-latin-ext-wght-italic-CmZo11nB.woff2 39.07 kB +dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 39.13 kB +dist/assets/index-ByL9e3j1.js.br 39.36 kB +dist/assets/nunito-latin-wght-italic-ZB3Aladm.woff2 41.76 kB +dist/assets/pdf-DEJ3BJS6.js.br 130.24 kB +dist/assets/ringtone-4rwYiCEg.webm 139.95 kB +dist/assets/index-ByL9e3j1.js.map.br 140.69 kB +dist/assets/pdf-DEJ3BJS6.js.map.br 290.20 kB +dist/assets/index-BTNFNN8W.js.map.br 302.21 kB +dist/assets/Twemoji.Mozilla.v15.1.0-CM1RS90w.woff2 491.74 kB +dist/assets/index-BTNFNN8W.js.br 951.42 kB +dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm.br 1,333.88 kB +dist/assets/Twemoji.Mozilla.v15.1.0-DHQZm25T.ttf 1,451.58 kB +dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm 5,513.88 kB │ gzip: 1,831.16 kB +dist/assets/index-C5lozGWL.css 156.09 kB │ gzip: 25.73 kB +dist/assets/index-Ds5yF1gS.js 0.08 kB │ gzip: 0.09 kB │ map: 0.96 kB +dist/assets/arborium-4uGfF3G6.js 20.65 kB │ gzip: 5.16 kB │ map: 21.44 kB +dist/assets/index-ByL9e3j1.js 272.42 kB │ gzip: 47.99 kB │ map: 950.66 kB +dist/assets/pdf-DEJ3BJS6.js 755.04 kB │ gzip: 165.12 kB │ map: 1,505.59 kB +dist/assets/index-BTNFNN8W.js 6,603.34 kB │ gzip: 1,316.26 kB │ map: 1,618.30 kB + +(!) Some chunks are larger than 500 kB after minification. Consider: +- Using dynamic import() to code-split the application +- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks +- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. + +PWA v1.2.0 +Building src/sw.ts service worker ("es" format)... +vite v7.3.1 building client environment for production... +transforming... +✓ 61 modules transformed. +rendering chunks... +computing gzip size... +dist/sw.mjs 37.52 kB │ gzip: 11.63 kB │ map: 232.66 kB + +PWA v1.2.0 +mode injectManifest +format: es +precache 9 entries (14223.46 KiB) +files generated + dist/sw.js + dist/sw.js.map diff --git a/full_output.txt b/full_output.txt new file mode 100644 index 000000000..e69de29bb diff --git a/lint_output.txt b/lint_output.txt new file mode 100644 index 000000000..d0e6288a3 --- /dev/null +++ b/lint_output.txt @@ -0,0 +1,62 @@ + +> sable@1.14.0 lint /Users/evie/git/Sable +> eslint . + + +/Users/evie/git/Sable/src/app/components/url-preview/UrlPreviewCard.tsx + 36:5 warning Unexpected console statement no-console + 97:9 warning Unexpected console statement no-console + 104:11 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/app/features/room/RoomInput.tsx + 1678:19 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/app/features/settings/Persona/PerMessageProfileEditor.tsx + 47:3 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/app/pages/client/ClientNonUIFeatures.tsx + 833:9 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/app/plugins/call/CallEmbed.ts + 317:7 warning Unexpected console statement no-console + 423:11 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/app/utils/debugLogger.ts + 87:9 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/index.tsx + 54:9 warning Unexpected confirm no-alert + +/Users/evie/git/Sable/src/sw.ts + 110:9 warning Unexpected console statement no-console + 117:9 warning Unexpected console statement no-console + 182:5 warning Unexpected console statement no-console + 189:5 warning Unexpected console statement no-console + 222:5 warning Unexpected console statement no-console + 230:7 warning Unexpected console statement no-console + 276:7 warning Unexpected console statement no-console + 281:5 warning Unexpected console statement no-console + 412:11 warning Unexpected console statement no-console + 421:9 warning Unexpected console statement no-console + 448:5 warning Unexpected console statement no-console + 458:5 warning Unexpected console statement no-console + 872:7 warning Unexpected console statement no-console + 914:3 warning Unexpected console statement no-console + 920:3 warning Unexpected console statement no-console + 922:5 warning Unexpected console statement no-console + 927:3 warning Unexpected console statement no-console + 954:5 warning Unexpected console statement no-console + 979:3 warning Unexpected console statement no-console + 980:3 warning Unexpected console statement no-console + 1015:3 warning Unexpected console statement no-console + 1024:7 warning Unexpected console statement no-console + 1031:9 warning Unexpected console statement no-console + 1049:11 warning Unexpected console statement no-console + 1055:7 warning Unexpected console statement no-console + +/Users/evie/git/Sable/src/sw/pushNotification.ts + 48:5 warning Unexpected console statement no-console + 169:7 warning Unexpected console statement no-console + +✖ 37 problems (0 errors, 37 warnings) + diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 3bba68aac..99103a802 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -1,6 +1,6 @@ import { useCallback, useState } from 'react'; import { Box, Text, Scroll, Switch, Button, Spinner, color } from 'folds'; -import { KnownMembership } from 'matrix-js-sdk/lib/types'; +import { KnownMembership } from '$types/matrix-sdk'; import { PageContent } from '$components/page'; import { SequenceCard } from '$components/sequence-card'; import { SettingTile } from '$components/setting-tile'; @@ -34,6 +34,14 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp [] >( useCallback(async () => { + if ( + !window.confirm( + 'This will discard all current Megolm encryption sessions and start new ones. Continue?' + ) + ) { + throw new Error('Cancelled'); + } + const crypto = mx.getCrypto(); if (!crypto) throw new Error('Crypto module not available'); @@ -44,8 +52,10 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp room.getMyMembership() === KnownMembership.Join && mx.isRoomEncrypted(room.roomId) ); - await Promise.all(encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId))); - const rotated = encryptedRooms.length; + const results = await Promise.allSettled( + encryptedRooms.map((room) => crypto.forceDiscardSession(room.roomId)) + ); + const rotated = results.filter((r) => r.status === 'fulfilled').length; // Proactively start session creation + key sharing with all devices // (including bridge bots). fire-and-forget per room. diff --git a/test_file.txt b/test_file.txt new file mode 100644 index 000000000..9daeafb98 --- /dev/null +++ b/test_file.txt @@ -0,0 +1 @@ +test From db6f398d63b9b8a271d4095a78a96a9bc39b4c40 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 22:55:09 -0400 Subject: [PATCH 004/185] chore: remove accidentally committed scratch files --- build.log | 114 ------------------------------------------------ full_output.txt | 0 lint_output.txt | 62 -------------------------- test_file.txt | 1 - 4 files changed, 177 deletions(-) delete mode 100644 build.log delete mode 100644 full_output.txt delete mode 100644 lint_output.txt delete mode 100644 test_file.txt diff --git a/build.log b/build.log deleted file mode 100644 index 1f5016d8d..000000000 --- a/build.log +++ /dev/null @@ -1,114 +0,0 @@ - -> sable@1.14.0 build /Users/evie/git/Sable -> vite build - -vite v7.3.1 building client environment for production... -transforming... -✓ 8032 modules transformed. -rendering chunks... -computing gzip size... -[vite-plugin-static-copy] Copied 10 items. -dist/.assetsignore 0.02 kB -dist/assets/index-Ds5yF1gS.js.br 0.07 kB -dist/assets/index-Ds5yF1gS.js.map.br 0.41 kB -dist/wrangler.json.br 0.42 kB -dist/assets/cinny-logo-maskable-36x36-CT1Bq-jJ.png 0.79 kB -dist/index.html.br 0.88 kB -dist/assets/cinny-logo-maskable-DXG8PKa6.svg.br 0.96 kB -dist/wrangler.json 1.00 kB │ gzip: 0.54 kB -dist/assets/cinny-logo-maskable-48x48-DfxQ4FEO.png 1.08 kB -dist/assets/cinny-logo-maskable-57x57-B_a6RKlt.png 1.28 kB -dist/assets/cinny-logo-maskable-60x60-uAvO2kwU.png 1.35 kB -dist/assets/cinny-logo-maskable-72x72-ChMts4zW.png 1.59 kB -dist/assets/cinny-logo-maskable-96x96-DeladQJ2.png 2.08 kB -dist/assets/cinny-logo-maskable-114x114-BRz0LILn.png 2.45 kB -dist/assets/cinny-logo-maskable-DXG8PKa6.svg 2.56 kB │ gzip: 1.12 kB -dist/assets/cinny-logo-maskable-120x120-BBcwUWQZ.png 2.59 kB -dist/assets/cinny-logo-maskable-144x144-CitAVeb0.png 3.08 kB -dist/assets/cinny-logo-maskable-152x152-Di3nKfIW.png 3.26 kB -dist/assets/cinny-logo-maskable-167x167-Dk9KG9Yi.png 3.59 kB -dist/assets/cinny-logo-maskable-180x180-RAgvvHf4.png 3.79 kB -dist/assets/cinny-logo-maskable-192x192-BHxZuLYc.png 4.06 kB -dist/assets/favicon-CemZgig7.png 4.15 kB -dist/index.html 4.28 kB │ gzip: 1.17 kB -dist/assets/arborium-4uGfF3G6.js.br 4.54 kB -dist/assets/arborium-4uGfF3G6.js.map.br 4.98 kB -dist/assets/space-mono-vietnamese-700-italic-BwPKnf-l.woff 5.24 kB -dist/assets/space-mono-vietnamese-400-normal-B0PMp_xB.woff 5.41 kB -dist/assets/space-mono-vietnamese-700-normal-D-KrLuLr.woff 5.45 kB -dist/assets/cinny-logo-maskable-256x256-B_icl17M.png 5.58 kB -dist/assets/space-mono-vietnamese-400-italic-DvlTUS1j.woff 5.88 kB -dist/assets/space-mono-vietnamese-400-normal-BNOj0Qhp.woff2 7.27 kB -dist/assets/space-mono-vietnamese-700-normal-DWQgDHuA.woff2 7.33 kB -dist/assets/space-mono-vietnamese-700-italic-i2bR4MHS.woff2 7.39 kB -dist/assets/cinny-logo-maskable-384x384-DA-2uwBp.png 8.52 kB -dist/assets/space-mono-vietnamese-400-italic-CyQIvI4V.woff2 9.69 kB -dist/assets/notification-EtLMRd0T.ogg 11.30 kB -dist/assets/cinny-logo-maskable-512x512-dB91iLyU.png 11.87 kB -dist/assets/space-mono-latin-400-normal-_3DlpgIW.woff 12.31 kB -dist/assets/space-mono-latin-700-normal-D7A851RN.woff 12.42 kB -dist/assets/space-mono-latin-ext-700-normal-B_E7P90g.woff 12.48 kB -dist/assets/space-mono-latin-ext-400-normal-D4cJI_B-.woff 12.61 kB -dist/assets/space-mono-latin-ext-400-italic-DYA_DB_l.woff 12.89 kB -dist/assets/nunito-vietnamese-wght-normal-U01xdrZh.woff2 13.10 kB -dist/assets/space-mono-latin-ext-700-italic-CbHMtIk0.woff 13.14 kB -dist/assets/space-mono-latin-700-italic-B8C1HgwN.woff 13.65 kB -dist/assets/space-mono-latin-400-italic-zmx7Qf09.woff 13.82 kB -dist/assets/nunito-vietnamese-wght-italic-5K55R7rt.woff2 14.77 kB -dist/assets/space-mono-latin-ext-700-normal-B2s3bDs2.woff2 15.81 kB -dist/assets/space-mono-latin-ext-400-normal-DTLbW2xa.woff2 15.83 kB -dist/assets/space-mono-latin-400-normal-Rg4St2Dn.woff2 16.52 kB -dist/assets/space-mono-latin-700-normal-mWgeinG7.woff2 16.72 kB -dist/assets/space-mono-latin-ext-400-italic-x3PrlAeq.woff2 17.20 kB -dist/assets/space-mono-latin-ext-700-italic-CkCrmjWu.woff2 17.74 kB -dist/assets/space-mono-latin-400-italic-YylcN9Ay.woff2 18.30 kB -dist/assets/space-mono-latin-700-italic-vNvENeTh.woff2 18.64 kB -dist/assets/index-C5lozGWL.css.br 20.47 kB -dist/assets/nunito-cyrillic-wght-normal-CY6AOgYE.woff2 20.78 kB -dist/assets/nunito-cyrillic-wght-italic-AGUkry7S.woff2 22.90 kB -dist/assets/nunito-cyrillic-ext-wght-normal-D4X5GqEv.woff2 28.93 kB -dist/assets/nunito-cyrillic-ext-wght-italic-C7FdRbwB.woff2 31.51 kB -dist/assets/invite-DROg5x7-.ogg 32.67 kB -dist/assets/nunito-latin-ext-wght-normal-CXYtwYOx.woff2 35.59 kB -dist/assets/nunito-latin-ext-wght-italic-CmZo11nB.woff2 39.07 kB -dist/assets/nunito-latin-wght-normal-BzFMHfZw.woff2 39.13 kB -dist/assets/index-ByL9e3j1.js.br 39.36 kB -dist/assets/nunito-latin-wght-italic-ZB3Aladm.woff2 41.76 kB -dist/assets/pdf-DEJ3BJS6.js.br 130.24 kB -dist/assets/ringtone-4rwYiCEg.webm 139.95 kB -dist/assets/index-ByL9e3j1.js.map.br 140.69 kB -dist/assets/pdf-DEJ3BJS6.js.map.br 290.20 kB -dist/assets/index-BTNFNN8W.js.map.br 302.21 kB -dist/assets/Twemoji.Mozilla.v15.1.0-CM1RS90w.woff2 491.74 kB -dist/assets/index-BTNFNN8W.js.br 951.42 kB -dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm.br 1,333.88 kB -dist/assets/Twemoji.Mozilla.v15.1.0-DHQZm25T.ttf 1,451.58 kB -dist/assets/matrix_sdk_crypto_wasm_bg-dMeGppz-.wasm 5,513.88 kB │ gzip: 1,831.16 kB -dist/assets/index-C5lozGWL.css 156.09 kB │ gzip: 25.73 kB -dist/assets/index-Ds5yF1gS.js 0.08 kB │ gzip: 0.09 kB │ map: 0.96 kB -dist/assets/arborium-4uGfF3G6.js 20.65 kB │ gzip: 5.16 kB │ map: 21.44 kB -dist/assets/index-ByL9e3j1.js 272.42 kB │ gzip: 47.99 kB │ map: 950.66 kB -dist/assets/pdf-DEJ3BJS6.js 755.04 kB │ gzip: 165.12 kB │ map: 1,505.59 kB -dist/assets/index-BTNFNN8W.js 6,603.34 kB │ gzip: 1,316.26 kB │ map: 1,618.30 kB - -(!) Some chunks are larger than 500 kB after minification. Consider: -- Using dynamic import() to code-split the application -- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks -- Adjust chunk size limit for this warning via build.chunkSizeWarningLimit. - -PWA v1.2.0 -Building src/sw.ts service worker ("es" format)... -vite v7.3.1 building client environment for production... -transforming... -✓ 61 modules transformed. -rendering chunks... -computing gzip size... -dist/sw.mjs 37.52 kB │ gzip: 11.63 kB │ map: 232.66 kB - -PWA v1.2.0 -mode injectManifest -format: es -precache 9 entries (14223.46 KiB) -files generated - dist/sw.js - dist/sw.js.map diff --git a/full_output.txt b/full_output.txt deleted file mode 100644 index e69de29bb..000000000 diff --git a/lint_output.txt b/lint_output.txt deleted file mode 100644 index d0e6288a3..000000000 --- a/lint_output.txt +++ /dev/null @@ -1,62 +0,0 @@ - -> sable@1.14.0 lint /Users/evie/git/Sable -> eslint . - - -/Users/evie/git/Sable/src/app/components/url-preview/UrlPreviewCard.tsx - 36:5 warning Unexpected console statement no-console - 97:9 warning Unexpected console statement no-console - 104:11 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/app/features/room/RoomInput.tsx - 1678:19 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/app/features/settings/Persona/PerMessageProfileEditor.tsx - 47:3 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/app/pages/client/ClientNonUIFeatures.tsx - 833:9 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/app/plugins/call/CallEmbed.ts - 317:7 warning Unexpected console statement no-console - 423:11 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/app/utils/debugLogger.ts - 87:9 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/index.tsx - 54:9 warning Unexpected confirm no-alert - -/Users/evie/git/Sable/src/sw.ts - 110:9 warning Unexpected console statement no-console - 117:9 warning Unexpected console statement no-console - 182:5 warning Unexpected console statement no-console - 189:5 warning Unexpected console statement no-console - 222:5 warning Unexpected console statement no-console - 230:7 warning Unexpected console statement no-console - 276:7 warning Unexpected console statement no-console - 281:5 warning Unexpected console statement no-console - 412:11 warning Unexpected console statement no-console - 421:9 warning Unexpected console statement no-console - 448:5 warning Unexpected console statement no-console - 458:5 warning Unexpected console statement no-console - 872:7 warning Unexpected console statement no-console - 914:3 warning Unexpected console statement no-console - 920:3 warning Unexpected console statement no-console - 922:5 warning Unexpected console statement no-console - 927:3 warning Unexpected console statement no-console - 954:5 warning Unexpected console statement no-console - 979:3 warning Unexpected console statement no-console - 980:3 warning Unexpected console statement no-console - 1015:3 warning Unexpected console statement no-console - 1024:7 warning Unexpected console statement no-console - 1031:9 warning Unexpected console statement no-console - 1049:11 warning Unexpected console statement no-console - 1055:7 warning Unexpected console statement no-console - -/Users/evie/git/Sable/src/sw/pushNotification.ts - 48:5 warning Unexpected console statement no-console - 169:7 warning Unexpected console statement no-console - -✖ 37 problems (0 errors, 37 warnings) - diff --git a/test_file.txt b/test_file.txt deleted file mode 100644 index 9daeafb98..000000000 --- a/test_file.txt +++ /dev/null @@ -1 +0,0 @@ -test From b7d74ef5e3dc098f93a127b9dc9927d07c571bd6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 12:02:12 -0400 Subject: [PATCH 005/185] fix(dev-tools): add error handling for prepareToEncrypt --- .../features/settings/developer-tools/DevelopTools.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 99103a802..a65285565 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -58,8 +58,14 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp const rotated = results.filter((r) => r.status === 'fulfilled').length; // Proactively start session creation + key sharing with all devices - // (including bridge bots). fire-and-forget per room. - encryptedRooms.forEach((room) => crypto.prepareToEncrypt(room)); + // (including bridge bots). fire-and-forget per room, but surface failures. + encryptedRooms.forEach((room) => { + void Promise.resolve() + .then(() => crypto.prepareToEncrypt(room)) + .catch((error) => { + console.error('Failed to prepare room encryption', room.roomId, error); + }); + }); return { rotated, total: encryptedRooms.length }; }, [mx]) From e2ebd4c7b08ccf40685228068a98217e528b08f1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:18:03 -0400 Subject: [PATCH 006/185] chore: fix lint and format issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/settings/developer-tools/DevelopTools.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a65285565..4dfda9a97 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -60,7 +60,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp // Proactively start session creation + key sharing with all devices // (including bridge bots). fire-and-forget per room, but surface failures. encryptedRooms.forEach((room) => { - void Promise.resolve() + Promise.resolve() .then(() => crypto.prepareToEncrypt(room)) .catch((error) => { console.error('Failed to prepare room encryption', room.roomId, error); From 9fe62d66c1c2e9b37b8602846cb551bc108ba334 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:22:49 -0400 Subject: [PATCH 007/185] docs(changeset): clarify Megolm session rotation description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/devtool-rotate-sessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/devtool-rotate-sessions.md b/.changeset/devtool-rotate-sessions.md index cae1bda0c..686fb292f 100644 --- a/.changeset/devtool-rotate-sessions.md +++ b/.changeset/devtool-rotate-sessions.md @@ -2,4 +2,4 @@ default: patch --- -Add rotate-encryption-sessions developer tool to force Megolm session rotation for testing +Add developer tool to force-rotate outbound Megolm encryption sessions per room, useful for testing key rotation and bridge session recovery From 95a802ec0dffbb2e6485021110e18068f9af50c2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:14:26 -0400 Subject: [PATCH 008/185] feat(flags): inject client config from GH environment variables at build Add scripts/inject-client-config.js which reads HOMESERVER_LIST, ELEMENT_CALL_URL, EXPERIMENTS and other config keys from the GH Actions environment and merges them into config.json at build time. CI workflows pass these through via env; the setup action prints an injected-config summary in the job summary. knip.json updated to include the new script as an entry point. --- .github/actions/setup/action.yml | 30 +++++++++ .github/workflows/cloudflare-web-deploy.yml | 8 +++ .github/workflows/cloudflare-web-preview.yml | 4 ++ knip.json | 2 +- scripts/inject-client-config.js | 71 ++++++++++++++++++++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 scripts/inject-client-config.js diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9b4c9acbb..d9a365eeb 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -34,6 +34,36 @@ runs: env: INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }} + - name: Inject runtime config overrides + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: node scripts/inject-client-config.js + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }} + + - name: Display injected config + if: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON != '' }} + shell: bash + working-directory: ${{ github.workspace }} + run: | + summary_file="${GITHUB_STEP_SUMMARY:-}" + echo "::group::Injected Client Config" + experiments_json="$(jq -c '.experiments // "No experiments configured"' config.json 2>/dev/null || echo 'config.json not readable')" + echo "$experiments_json" + echo "::endgroup::" + + if [[ -n "$summary_file" ]]; then + { + echo "### Injected client config" + echo + echo "\`\`\`json" + echo "$experiments_json" + echo "\`\`\`" + } >> "$summary_file" + fi + - name: Build app if: ${{ inputs.build == 'true' }} shell: bash diff --git a/.github/workflows/cloudflare-web-deploy.yml b/.github/workflows/cloudflare-web-deploy.yml index d3d2c4461..e32dbf68e 100644 --- a/.github/workflows/cloudflare-web-deploy.yml +++ b/.github/workflows/cloudflare-web-deploy.yml @@ -40,6 +40,10 @@ jobs: plan: if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest + environment: preview + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read pull-requests: write @@ -73,6 +77,10 @@ jobs: apply: if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest + environment: production + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} permissions: contents: read defaults: diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 8b93a4bb9..82046559c 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -32,9 +32,13 @@ jobs: deploy: if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push' runs-on: ubuntu-latest + environment: preview permissions: contents: read pull-requests: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 diff --git a/knip.json b/knip.json index 6cc8c8581..83f45fc19 100644 --- a/knip.json +++ b/knip.json @@ -1,6 +1,6 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", - "entry": ["src/sw.ts", "scripts/normalize-imports.js"], + "entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"], "ignore": ["oxlint.config.ts", "oxfmt.config.ts"], "ignoreExportsUsedInFile": { "interface": true, diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js new file mode 100644 index 000000000..b7c62c096 --- /dev/null +++ b/scripts/inject-client-config.js @@ -0,0 +1,71 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import process from 'node:process'; +import { PrefixedLogger } from './utils/console-style.js'; + +const CONFIG_PATH = 'config.json'; +const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON'; +const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT'; +const logger = new PrefixedLogger('[config-inject]'); + +const formatError = (error) => { + if (error instanceof Error) return error.stack ?? error.message; + return String(error); +}; + +const isPlainObject = (value) => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const deepMerge = (target, source) => { + if (!isPlainObject(target) || !isPlainObject(source)) return source; + + const merged = { ...target }; + Object.entries(source).forEach(([key, value]) => { + const targetValue = merged[key]; + merged[key] = + isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value; + }); + return merged; +}; + +const failOnError = process.env[STRICT_ENV] === 'true'; +const overridesRaw = process.env[OVERRIDES_ENV]; + +if (!overridesRaw) { + logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`); + process.exit(0); +} + +let fileConfig; +let overrides; + +try { + const file = await readFile(CONFIG_PATH, 'utf8'); + fileConfig = JSON.parse(file); +} catch (error) { + logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`); + process.exit(1); +} + +try { + overrides = JSON.parse(overridesRaw); + if (!isPlainObject(overrides)) { + throw new Error(`${OVERRIDES_ENV} must be a JSON object.`); + } +} catch (error) { + const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${ + failOnError ? 'failing build' : 'skipping overrides' + }.`; + if (failOnError) { + logger.error(`${message} ${formatError(error)}`); + process.exit(1); + } + logger.info(`[warning] ${message} ${formatError(error)}`); + process.exit(0); +} + +const mergedConfig = deepMerge(fileConfig, overrides); + +await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8'); +logger.info( + `Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}` +); From 291cecd13fbaf6ad950661b5771b255e2fe1329c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:14:51 -0400 Subject: [PATCH 009/185] feat(flags): add typed experiment bucketing helper with rollout percentages useClientConfig.ts gains getExperimentVariant() which deterministically buckets a userId into a variant using a hash of userId+experimentName, then checks it against rolloutPercentage. Experiment defaults shape is typed so all callers get compile-time checking of known experiment names. --- src/app/hooks/useClientConfig.ts | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 6cb2a9ad3..540e053a3 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -7,6 +7,21 @@ export type HashRouterConfig = { basename?: string; }; +export type ExperimentConfig = { + enabled?: boolean; + rolloutPercentage?: number; + variants?: string[]; + controlVariant?: string; +}; + +export type ExperimentSelection = { + key: string; + enabled: boolean; + rolloutPercentage: number; + variant: string; + inExperiment: boolean; +}; + export type ClientConfig = { defaultHomeserver?: number; homeserverList?: string[]; @@ -16,6 +31,8 @@ export type ClientConfig = { disableAccountSwitcher?: boolean; hideUsernamePasswordFields?: boolean; + experiments?: Record; + pushNotificationDetails?: { pushNotifyUrl?: string; vapidPublicKey?: string; @@ -66,6 +83,74 @@ export function useOptionalClientConfig(): ClientConfig | null { return useContext(ClientConfigContext); } +const DEFAULT_CONTROL_VARIANT = 'control'; + +const normalizeRolloutPercentage = (value?: number): number => { + if (typeof value !== 'number' || Number.isNaN(value)) return 100; + if (value < 0) return 0; + if (value > 100) return 100; + return value; +}; + +const hashToUInt32 = (input: string): number => { + let hash = 0; + for (let index = 0; index < input.length; index += 1) { + hash = (hash * 131 + input.charCodeAt(index)) % 4294967291; + } + return hash; +}; + +export const selectExperimentVariant = ( + key: string, + experiment: ExperimentConfig | undefined, + subjectId: string | undefined +): ExperimentSelection => { + const controlVariant = experiment?.controlVariant ?? DEFAULT_CONTROL_VARIANT; + const variants = (experiment?.variants?.filter((variant) => variant.length > 0) ?? []).filter( + (variant) => variant !== controlVariant + ); + + const enabled = Boolean(experiment?.enabled); + const rolloutPercentage = normalizeRolloutPercentage(experiment?.rolloutPercentage); + + if (!enabled || !subjectId || variants.length === 0 || rolloutPercentage === 0) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + // Two independent hashes keep rollout and variant assignment stable but decorrelated. + const rolloutBucket = hashToUInt32(`${key}:rollout:${subjectId}`) % 10000; + const rolloutCutoff = Math.floor(rolloutPercentage * 100); + if (rolloutBucket >= rolloutCutoff) { + return { + key, + enabled, + rolloutPercentage, + variant: controlVariant, + inExperiment: false, + }; + } + + const variantIndex = hashToUInt32(`${key}:variant:${subjectId}`) % variants.length; + return { + key, + enabled, + rolloutPercentage, + variant: variants[variantIndex], + inExperiment: true, + }; +}; + +export const useExperimentVariant = (key: string, subjectId?: string): ExperimentSelection => { + const clientConfig = useClientConfig(); + return selectExperimentVariant(key, clientConfig.experiments?.[key], subjectId); +}; + export const clientDefaultServer = (clientConfig: ClientConfig): string => clientConfig.homeserverList?.[clientConfig.defaultHomeserver ?? 0] ?? 'matrix.org'; From de7e9807c77122b625db3a5ba67abd53c682c328 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:14:59 -0400 Subject: [PATCH 010/185] feat(devtools): add Experiments panel to developer tools settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ExperimentsPanel shows every experiment name, current variant, rollout percentage, and whether the user is enrolled — readable without opening the console. DevelopTools.tsx wires it into the developer settings tab. --- .../settings/developer-tools/DevelopTools.tsx | 2 + .../developer-tools/ExperimentsPanel.tsx | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/app/features/settings/developer-tools/ExperimentsPanel.tsx diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index 4dfda9a97..ae5602173 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,6 +15,7 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; +import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; @@ -155,6 +156,7 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } + {developerTools && } {developerTools && ( Encryption diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx new file mode 100644 index 000000000..4c4689c03 --- /dev/null +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -0,0 +1,102 @@ +import { useMemo } from 'react'; +import { Box, Text, color } from 'folds'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useClientConfig, selectExperimentVariant } from '$hooks/useClientConfig'; +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCardStyle } from '$features/settings/styles.css'; + +export function ExperimentsPanel() { + const mx = useMatrixClient(); + const config = useClientConfig(); + const userId = mx.getUserId() ?? undefined; + + const experiments = useMemo(() => { + if (!config.experiments) return []; + return Object.entries(config.experiments).map(([key, experimentConfig]) => ({ + key, + config: experimentConfig, + selection: selectExperimentVariant(key, experimentConfig, userId), + })); + }, [config.experiments, userId]); + + if (experiments.length === 0) { + return ( + + Features & Experiments + + No experiments configured + + + ); + } + + return ( + + Features & Experiments + + {experiments.map(({ key, config: experimentConfig, selection }) => ( + + + + + Enabled: + + + {selection.enabled ? 'Yes' : 'No'} + + + {selection.enabled && ( + <> + + + Rollout: + + {selection.rolloutPercentage}% + + + + Your Variant: + + + {selection.variant} + {selection.inExperiment && ' (in experiment)'} + {!selection.inExperiment && ' (control)'} + + + {experimentConfig.variants && experimentConfig.variants.length > 0 && ( + + + Treatment Variants: + + + {experimentConfig.variants + .filter((v) => v !== experimentConfig.controlVariant) + .join(', ')} + + + )} + + )} + + + ))} + + + ); +} From e967bd3c4cd331b7ffd9ff721b8c5b3480108069 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 14:15:24 -0400 Subject: [PATCH 011/185] test(flags): cover experiment bucketing and add changeset --- .changeset/feature-flag-env-vars.md | 5 ++ src/app/hooks/useClientConfig.test.ts | 101 ++++++++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .changeset/feature-flag-env-vars.md create mode 100644 src/app/hooks/useClientConfig.test.ts diff --git a/.changeset/feature-flag-env-vars.md b/.changeset/feature-flag-env-vars.md new file mode 100644 index 000000000..25d7d7d01 --- /dev/null +++ b/.changeset/feature-flag-env-vars.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add build-time client config overrides via environment variables, with typed deterministic experiment bucketing helpers for progressive feature rollout and A/B testing. diff --git a/src/app/hooks/useClientConfig.test.ts b/src/app/hooks/useClientConfig.test.ts new file mode 100644 index 000000000..5071c5f7c --- /dev/null +++ b/src/app/hooks/useClientConfig.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { selectExperimentVariant, type ExperimentConfig } from './useClientConfig'; + +const baseExperiment: ExperimentConfig = { + enabled: true, + rolloutPercentage: 100, + controlVariant: 'control', + variants: ['alpha', 'beta'], +}; + +describe('selectExperimentVariant', () => { + it('returns control when experiment is disabled', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, enabled: false }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when subject id is missing', () => { + const result = selectExperimentVariant('threadUI', baseExperiment, undefined); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('returns control when rollout is 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 0 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout less than 0 to 0', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: -10 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + expect(result.rolloutPercentage).toBe(0); + }); + + it('normalizes rollout greater than 100 to 100', () => { + const result = selectExperimentVariant( + 'threadUI', + { ...baseExperiment, rolloutPercentage: 999 }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.rolloutPercentage).toBe(100); + expect(['alpha', 'beta']).toContain(result.variant); + }); + + it('falls back to control when variants are missing after filtering', () => { + const result = selectExperimentVariant( + 'threadUI', + { + ...baseExperiment, + variants: ['', 'control'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(false); + expect(result.variant).toBe('control'); + }); + + it('is deterministic for the same key and subject', () => { + const first = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + const second = selectExperimentVariant('threadUI', baseExperiment, '@alice:example.org'); + + expect(second).toEqual(first); + }); + + it('uses default control variant when none is provided', () => { + const result = selectExperimentVariant( + 'threadUI', + { + enabled: true, + rolloutPercentage: 100, + variants: ['alpha'], + }, + '@alice:example.org' + ); + + expect(result.inExperiment).toBe(true); + expect(result.variant).toBe('alpha'); + }); +}); From 1911cae648ead90be266e0e7f83a4ca00308761d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Apr 2026 17:51:06 -0400 Subject: [PATCH 012/185] fix(devtools): add focusId to ExperimentsPanel SettingTile --- src/app/features/settings/developer-tools/ExperimentsPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx index 4c4689c03..0308d6932 100644 --- a/src/app/features/settings/developer-tools/ExperimentsPanel.tsx +++ b/src/app/features/settings/developer-tools/ExperimentsPanel.tsx @@ -41,7 +41,7 @@ export function ExperimentsPanel() { gap="400" > {experiments.map(({ key, config: experimentConfig, selection }) => ( - + From f33284dc6961a6d7d694fd8d5acaf86b253698c9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:33:26 -0400 Subject: [PATCH 013/185] fix(security): block prototype-polluting keys in deepMerge --- scripts/inject-client-config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/scripts/inject-client-config.js b/scripts/inject-client-config.js index b7c62c096..0b5fcd3ad 100644 --- a/scripts/inject-client-config.js +++ b/scripts/inject-client-config.js @@ -15,11 +15,15 @@ const formatError = (error) => { const isPlainObject = (value) => typeof value === 'object' && value !== null && !Array.isArray(value); +// Keys that could trigger prototype pollution via bracket assignment. +const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']); + const deepMerge = (target, source) => { if (!isPlainObject(target) || !isPlainObject(source)) return source; const merged = { ...target }; Object.entries(source).forEach(([key, value]) => { + if (UNSAFE_KEYS.has(key)) return; const targetValue = merged[key]; merged[key] = isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value; From ad2ef39d3239214c4adb216a14d935142e29056d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:46:46 -0400 Subject: [PATCH 014/185] feat(bookmarks): add message bookmarks (MSC4438) --- config.json | 9 + src/app/features/bookmarks/bookmarkDomain.ts | 171 ++++++ .../features/bookmarks/bookmarkRepository.ts | 147 +++++ src/app/features/bookmarks/useBookmarks.ts | 78 +++ .../features/bookmarks/useInitBookmarks.ts | 70 +++ src/app/features/room/message/Message.tsx | 55 ++ .../settings/experimental/Experimental.tsx | 2 + .../experimental/MSC4438MessageBookmarks.tsx | 56 ++ src/app/hooks/router/useHomeSelected.ts | 11 + src/app/hooks/router/useInbox.ts | 17 +- src/app/pages/Router.tsx | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 7 + .../pages/client/bookmarks/BookmarksList.tsx | 560 ++++++++++++++++++ src/app/pages/client/bookmarks/index.ts | 1 + src/app/pages/client/home/Home.tsx | 96 +-- src/app/pages/client/inbox/Inbox.tsx | 44 +- src/app/pages/pathUtils.ts | 4 + src/app/pages/paths.ts | 3 + src/app/state/bookmarks.ts | 20 + src/app/state/settings.ts | 6 + src/types/matrix/accountData.ts | 3 + 21 files changed, 1318 insertions(+), 46 deletions(-) create mode 100644 src/app/features/bookmarks/bookmarkDomain.ts create mode 100644 src/app/features/bookmarks/bookmarkRepository.ts create mode 100644 src/app/features/bookmarks/useBookmarks.ts create mode 100644 src/app/features/bookmarks/useInitBookmarks.ts create mode 100644 src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx create mode 100644 src/app/pages/client/bookmarks/BookmarksList.tsx create mode 100644 src/app/pages/client/bookmarks/index.ts create mode 100644 src/app/state/bookmarks.ts diff --git a/config.json b/config.json index 2809e4f68..4f98f3f51 100644 --- a/config.json +++ b/config.json @@ -20,6 +20,15 @@ "enabled": true }, + "experiments": { + "messageBookmarks": { + "enabled": false, + "rolloutPercentage": 0, + "controlVariant": "control", + "variants": ["enabled"] + } + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/features/bookmarks/bookmarkDomain.ts b/src/app/features/bookmarks/bookmarkDomain.ts new file mode 100644 index 000000000..15da412c9 --- /dev/null +++ b/src/app/features/bookmarks/bookmarkDomain.ts @@ -0,0 +1,171 @@ +/** + * MSC4438: Message bookmarks via account data + * https://github.com/matrix-org/matrix-spec-proposals/pull/4438 + * + * Unstable event type names in use (will migrate to stable names once MSC is accepted): + * m.bookmarks.index → org.matrix.msc4438.bookmarks.index + * m.bookmark. → org.matrix.msc4438.bookmark. + * + * Bookmark ID algorithm: djb2-like 32-bit hash over "|", prefixed with "bmk_". + * This matches the reference implementation in smokku/cinny commit 6363e441 and is used here for + * cross-client interoperability. If the algorithm ever changes, a migration must be provided so + * that existing bookmarks can have their IDs recomputed (the ID is stored in the item event, so + * old items remain accessible). + */ + +import { MatrixEvent, Room } from '$types/matrix-sdk'; +import { AccountDataEvent } from '$types/matrix/accountData'; + +export type BookmarkIndexContent = { + version: 1; + revision: number; + updated_ts: number; + bookmark_ids: string[]; +}; + +export type BookmarkItemContent = { + version: 1; + bookmark_id: string; + uri: string; + room_id: string; + event_id: string; + event_ts: number; + bookmarked_ts: number; + sender?: string; + room_name?: string; + body_preview?: string; + msgtype?: string; + deleted?: boolean; +}; + +/** + * Compute a bookmark ID for a (roomId, eventId) pair using the reference + * djb2-style algorithm agreed upon with the Cinny proof-of-concept. + * + * Input string: "|" + * Algorithm: For each UTF-16 code unit ch, hash = ((hash << 5) - hash + ch) | 0 + * Output: "bmk_" + unsigned 32-bit hex, zero-padded to 8 chars + * + * NOTE: If this algorithm is ever changed, a migration helper must be written + * so that existing bookmarked items (whose IDs are stored on the server as + * account data event-type suffixes) can still be resolved. The bookmark_id + * field inside each item event is the canonical reference. + */ +export function computeBookmarkId(roomId: string, eventId: string): string { + const input = `${roomId}|${eventId}`; + let hash = 0; + for (let i = 0; i < input.length; i += 1) { + const ch = input.charCodeAt(i); + // eslint-disable-next-line no-bitwise + hash = ((hash << 5) - hash + ch) | 0; + } + // Convert to unsigned 32-bit integer and encode as 8-char lowercase hex + // eslint-disable-next-line no-bitwise + const hex = (hash >>> 0).toString(16).padStart(8, '0'); + return `bmk_${hex}`; +} + +/** Construct the account data event type for a bookmark item. */ +export function bookmarkItemEventType(bookmarkId: string): string { + return `${AccountDataEvent.BookmarkItemPrefix}${bookmarkId}`; +} + +/** + * Build a matrix: URI for a room event. + * Canonical form: matrix:roomid//e/ + * (MSC4438 §Matrix URI) + */ +export function buildMatrixURI(roomId: string, eventId: string): string { + return `matrix:roomid/${encodeURIComponent(roomId)}/e/${encodeURIComponent(eventId)}`; +} + +const BODY_PREVIEW_MAX_LENGTH = 120; + +/** + * Extract a short preview of the event body for display in the bookmark list. + * Truncated to 120 chars with an ellipsis (MSC4438 §Body preview). + * + * Security: preview is only used as plain text in the UI, never parsed as HTML. + * Encrypted-room callers may choose to pass an empty string to avoid leaking + * plaintext into unencrypted account data (MSC4438 §Security considerations). + */ +export function extractBodyPreview( + mEvent: MatrixEvent, + maxLength = BODY_PREVIEW_MAX_LENGTH +): string { + const content = mEvent.getContent(); + const body = content?.body; + if (typeof body !== 'string' || body.length === 0) return ''; + if (body.length <= maxLength) return body; + return `${body.slice(0, maxLength)}\u2026`; +} + +/** + * Build a BookmarkItemContent from a room and event. + * + * Security: optional metadata (sender, room_name, body_preview) is copied into + * unencrypted account data. For encrypted rooms the caller may choose to omit + * these fields, storing only the required fields (room_id, event_id, uri). + * Currently we always populate them for usability; future work could honour a + * "privacy mode" setting. + */ +export function createBookmarkItem( + room: Room, + mEvent: MatrixEvent +): BookmarkItemContent | undefined { + const eventId = mEvent.getId(); + const { roomId } = room; + if (!eventId) return undefined; + + const bookmarkId = computeBookmarkId(roomId, eventId); + + return { + version: 1, + bookmark_id: bookmarkId, + uri: buildMatrixURI(roomId, eventId), + room_id: roomId, + event_id: eventId, + event_ts: mEvent.getTs(), + bookmarked_ts: Date.now(), + sender: mEvent.getSender() ?? undefined, + room_name: room.name, + body_preview: extractBodyPreview(mEvent), + msgtype: mEvent.getContent()?.msgtype, + }; +} + +// Validators (MSC4438: clients must validate before use) +export function isValidIndexContent(content: unknown): content is BookmarkIndexContent { + if (typeof content !== 'object' || content === null) return false; + const c = content as Record; + return ( + c.version === 1 && + typeof c.revision === 'number' && + typeof c.updated_ts === 'number' && + Array.isArray(c.bookmark_ids) && + (c.bookmark_ids as unknown[]).every((id) => typeof id === 'string') + ); +} + +export function isValidBookmarkItem(content: unknown): content is BookmarkItemContent { + if (typeof content !== 'object' || content === null) return false; + const c = content as Record; + return ( + c.version === 1 && + typeof c.bookmark_id === 'string' && + typeof c.uri === 'string' && + typeof c.room_id === 'string' && + typeof c.event_id === 'string' && + typeof c.event_ts === 'number' && + typeof c.bookmarked_ts === 'number' + ); +} + +export function emptyIndex(): BookmarkIndexContent { + return { + version: 1, + revision: 0, + updated_ts: Date.now(), + bookmark_ids: [], + }; +} diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts new file mode 100644 index 000000000..d6703777f --- /dev/null +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -0,0 +1,147 @@ +/** + * Bookmark repository: low-level read/write operations against Matrix account data. + * + * All writes follow the MSC4438 ordering guarantee: + * item is written first → index is updated second + * This ensures that when other devices receive the updated index via /sync, the + * referenced item event is already available. + */ + +import { MatrixClient } from '$types/matrix-sdk'; +import { AccountDataEvent } from '$types/matrix/accountData'; +import { + BookmarkIndexContent, + BookmarkItemContent, + bookmarkItemEventType, + emptyIndex, + isValidBookmarkItem, + isValidIndexContent, +} from './bookmarkDomain'; + +// Internal helpers +function readIndex(mx: MatrixClient): BookmarkIndexContent { + const evt = mx.getAccountData(AccountDataEvent.BookmarksIndex as any); + const content = evt?.getContent(); + if (isValidIndexContent(content)) return content; + return emptyIndex(); +} + +function readItem(mx: MatrixClient, bookmarkId: string): BookmarkItemContent | undefined { + const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any); + const content = evt?.getContent(); + // Must be valid and not tombstoned (MSC4438 §Listing bookmarks) + if (isValidBookmarkItem(content) && !content.deleted) return content; + return undefined; +} + +async function writeIndex(mx: MatrixClient, index: BookmarkIndexContent): Promise { + await mx.setAccountData(AccountDataEvent.BookmarksIndex as any, index as any); +} + +async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise { + await mx.setAccountData(bookmarkItemEventType(item.bookmark_id) as any, item as any); +} + +// Public API +/** + * Add a bookmark. + * + * MSC4438 §Adding a bookmark: + * 1. Write the item event first. + * 2. Prepend the ID to bookmark_ids (if not already present). + * 3. Increment revision and update timestamp. + * 4. Write the updated index. + */ +export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): Promise { + // Write item before updating index (cross-device consistency) + await writeItem(mx, item); + + const index = readIndex(mx); + if (!index.bookmark_ids.includes(item.bookmark_id)) { + index.bookmark_ids.unshift(item.bookmark_id); + } + index.revision += 1; + index.updated_ts = Date.now(); + await writeIndex(mx, index); +} + +/** + * Remove a bookmark. + * + * MSC4438 §Removing a bookmark: + * 1. Remove the ID from the index. + * 2. Soft-delete the item (set deleted: true). + * + * Account data events cannot be deleted from the server, so soft-deletion is + * used. Other clients that encounter the item event can see it is explicitly + * removed. + */ +export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise { + const index = readIndex(mx); + index.bookmark_ids = index.bookmark_ids.filter((id) => id !== bookmarkId); + index.revision += 1; + index.updated_ts = Date.now(); + await writeIndex(mx, index); + + // Soft-delete the item event + const existing = readItem(mx, bookmarkId); + if (existing) { + await writeItem(mx, { ...existing, deleted: true }); + } +} + +/** + * List all active bookmarks in index order, with orphan recovery. + * + * MSC4438 §Listing bookmarks: + * - Iterates bookmark_ids in order. + * - Skips missing, malformed, or tombstoned items. + * - Deduplicates by first occurrence. + * + * Orphan recovery: also scans the in-memory account data store for bookmark + * item events that exist but are absent from the index. These arise when two + * devices concurrently write the index (last-write-wins drops the other + * device's new bookmark_id while the item event itself persists). Orphaned + * items are appended after the index-ordered items. + */ +export function listBookmarks(mx: MatrixClient): BookmarkItemContent[] { + const index = readIndex(mx); + const seen = new Set(); + + const items = index.bookmark_ids + .filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }) + .map((id) => readItem(mx, id)) + .filter((item): item is BookmarkItemContent => item != null); + + // Walk the in-memory account data store for orphaned item events. + const prefix = AccountDataEvent.BookmarkItemPrefix as string; + Array.from(mx.store.accountData.keys()).forEach((key) => { + if (!key.startsWith(prefix)) return; + const bookmarkId = key.slice(prefix.length); + if (seen.has(bookmarkId)) return; + const item = readItem(mx, bookmarkId); + if (item) { + seen.add(bookmarkId); + items.push(item); + } + }); + + return items; +} + +/** + * Check whether a specific bookmark ID is in the index. + * + * NOTE: Do not rely on the bookmark ID being deterministically derivable from + * (roomId, eventId) for this check — different clients may use different + * algorithms. Use the bookmarkIdSet atom (derived from the live list) for + * O(1) per-message checks instead. + */ +export function isBookmarked(mx: MatrixClient, bookmarkId: string): boolean { + const index = readIndex(mx); + return index.bookmark_ids.includes(bookmarkId); +} diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts new file mode 100644 index 000000000..aadd0bb8f --- /dev/null +++ b/src/app/features/bookmarks/useBookmarks.ts @@ -0,0 +1,78 @@ +import { useAtomValue, useSetAtom } from 'jotai'; +import { useCallback } from 'react'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { bookmarkIdSetAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks'; +import { BookmarkItemContent, computeBookmarkId } from './bookmarkDomain'; +import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository'; + +/** Returns the current ordered bookmark list. */ +export function useBookmarkList(): BookmarkItemContent[] { + return useAtomValue(bookmarkListAtom); +} + +/** Returns true while a bookmark refresh is in progress. */ +export function useBookmarkLoading(): boolean { + return useAtomValue(bookmarkLoadingAtom); +} + +/** + * Returns true if the given (roomId, eventId) is currently bookmarked. + * + * Uses the locally cached bookmarkIdSetAtom for O(1) lookup. + * MSC4438 §Checking if a message is bookmarked. + */ +export function useIsBookmarked(roomId: string, eventId: string): boolean { + const idSet = useAtomValue(bookmarkIdSetAtom); + return idSet.has(computeBookmarkId(roomId, eventId)); +} + +/** + * Returns bookmark action callbacks: refresh, add, remove, checkIsBookmarked. + * + * `refresh` re-reads all bookmark items from the locally cached account data. + * `add` / `remove` optimistically update the local atom before writing to the server. + */ +export function useBookmarkActions() { + const mx = useMatrixClient(); + const setList = useSetAtom(bookmarkListAtom); + const setLoading = useSetAtom(bookmarkLoadingAtom); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const items = listBookmarks(mx); + setList(items); + } finally { + setLoading(false); + } + }, [mx, setList, setLoading]); + + const add = useCallback( + async (item: BookmarkItemContent) => { + // Optimistic update + setList((prev) => { + if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev; + return [item, ...prev]; + }); + await addBookmark(mx, item); + }, + [mx, setList] + ); + + const remove = useCallback( + async (bookmarkId: string) => { + // Optimistic update + setList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId)); + await removeBookmark(mx, bookmarkId); + }, + [mx, setList] + ); + + const checkIsBookmarked = useCallback( + (roomId: string, eventId: string): boolean => + isBookmarked(mx, computeBookmarkId(roomId, eventId)), + [mx] + ); + + return { refresh, add, remove, checkIsBookmarked }; +} diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts new file mode 100644 index 000000000..40175ce58 --- /dev/null +++ b/src/app/features/bookmarks/useInitBookmarks.ts @@ -0,0 +1,70 @@ +import { MatrixEvent, SyncState } from '$types/matrix-sdk'; +import { useCallback, useEffect } from 'react'; +import { useSetAtom } from 'jotai'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useSyncState } from '$hooks/useSyncState'; +import { useAccountDataCallback } from '$hooks/useAccountDataCallback'; +import { bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks'; +import { AccountDataEvent } from '$types/matrix/accountData'; +import { listBookmarks } from './bookmarkRepository'; + +/** + * Top-level hook that keeps `bookmarkListAtom` in sync with account data. + * + * Must be called from an always-mounted component (e.g. ClientNonUIFeatures), + * NOT from a page component. Page components should simply read from the atom. + * + * Three triggers keep the atom current: + * 1. `useEffect` on mount — covers the case where `ClientNonUIFeatures` mounts + * after the initial sync transition has already fired (the common case). + * 2. `SyncState.Syncing` transition — refreshes on every reconnect. + * 3. `ClientEvent.AccountData` for the index event type — reacts immediately + * to index updates pushed by other devices mid-session. + */ +export function useInitBookmarks(): void { + const mx = useMatrixClient(); + const setList = useSetAtom(bookmarkListAtom); + const setLoading = useSetAtom(bookmarkLoadingAtom); + + const loadBookmarks = useCallback(() => { + setLoading(true); + try { + setList(listBookmarks(mx)); + } finally { + setLoading(false); + } + }, [mx, setList, setLoading]); + + // Immediate load: fires once on mount to cover the case where ClientNonUIFeatures + // mounts after the initial SyncState.Syncing transition has already fired. + // loadBookmarks is stable (memoized with stable deps), so this fires exactly once. + useEffect(() => { + loadBookmarks(); + }, [loadBookmarks]); + + // Trigger on reconnect (SyncState.Syncing transition after a disconnect). + useSyncState( + mx, + useCallback( + (state, prevState) => { + if (state === SyncState.Syncing && prevState !== SyncState.Syncing) { + loadBookmarks(); + } + }, + [loadBookmarks] + ) + ); + + // React to index updates pushed by other devices mid-session. + useAccountDataCallback( + mx, + useCallback( + (event: MatrixEvent) => { + if (event.getType() === (AccountDataEvent.BookmarksIndex as string)) { + loadBookmarks(); + } + }, + [loadBookmarks] + ) + ); +} diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 3224480c6..ffc0fec64 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -71,6 +71,9 @@ import { MessageEditHistoryItem } from '$components/message/modals/MessageEditHi import { MessageSourceCodeItem } from '$components/message/modals/MessageSource'; import { MessageForwardItem } from '$components/message/modals/MessageForward'; import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; +import { computeBookmarkId, createBookmarkItem } from '$features/bookmarks/bookmarkDomain'; +import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks'; +import { useExperimentVariant } from '$hooks/useClientConfig'; import { MessageReportItem } from '$components/message/modals/MessageReport'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; import type { PronounSet } from '$utils/pronouns'; @@ -199,6 +202,50 @@ export const MessagePinItem = as< ); }); +// message bookmarking +export const MessageBookmarkItem = as< + 'button', + { + room: Room; + mEvent: MatrixEvent; + onClose?: () => void; + } +>(({ room, mEvent, onClose, ...props }, ref) => { + const mx = useMatrixClient(); + const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); + const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); + const eventId = mEvent.getId() ?? ''; + const isBookmarked = useIsBookmarked(room.roomId, eventId); + const { add, remove } = useBookmarkActions(); + + if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null; + + const handleClick = async () => { + if (isBookmarked) { + await remove(computeBookmarkId(room.roomId, eventId)); + } else { + const item = createBookmarkItem(room, mEvent); + if (item) await add(item); + } + onClose?.(); + }; + + return ( + } + radii="300" + onClick={handleClick} + {...props} + ref={ref} + > + + {isBookmarked ? 'Remove Bookmark' : 'Bookmark Message'} + + + ); +}); + export type ForwardedMessageProps = { originalTimestamp: number; isForwarded: boolean; @@ -1107,6 +1154,7 @@ function MessageInternal( )} + {canPinEvent && ( )} @@ -1445,6 +1493,13 @@ export const Event = as<'div', EventProps>( )} + {!stateEvent && ( + + )} {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index 330412185..e048ed281 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -10,6 +10,7 @@ import { Sync } from '../general'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; +import { MSC4438MessageBookmarks } from './MSC4438MessageBookmarks'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -59,6 +60,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx new file mode 100644 index 000000000..45e429b18 --- /dev/null +++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx @@ -0,0 +1,56 @@ +import { SequenceCard } from '$components/sequence-card'; +import { SettingTile } from '$components/setting-tile'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { Box, Switch, Text } from 'folds'; +import { SequenceCardStyle } from '../styles.css'; + +export function MSC4438MessageBookmarks() { + const [enableMessageBookmarks, setEnableMessageBookmarks] = useSetting( + settingsAtom, + 'enableMessageBookmarks' + ); + + return ( + + Message Bookmarks + + + Save individual messages for later. Bookmarks are synced across all your devices via + account data.{' '} + + MSC4438 + + .{' '} + + Known issues (Sable GitHub) + + . + + } + after={ + + } + /> + + + ); +} diff --git a/src/app/hooks/router/useHomeSelected.ts b/src/app/hooks/router/useHomeSelected.ts index 2a16511aa..fcc439196 100644 --- a/src/app/hooks/router/useHomeSelected.ts +++ b/src/app/hooks/router/useHomeSelected.ts @@ -4,6 +4,7 @@ import { getHomeJoinPath, getHomePath, getHomeSearchPath, + getHomeBookmarksPath, } from '$pages/pathUtils'; export const useHomeSelected = (): boolean => { @@ -45,3 +46,13 @@ export const useHomeSearchSelected = (): boolean => { return !!match; }; + +export const useHomeBookmarksSelected = (): boolean => { + const match = useMatch({ + path: getHomeBookmarksPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/hooks/router/useInbox.ts b/src/app/hooks/router/useInbox.ts index 639e16dd4..c19c0cc4b 100644 --- a/src/app/hooks/router/useInbox.ts +++ b/src/app/hooks/router/useInbox.ts @@ -1,5 +1,10 @@ import { useMatch } from 'react-router-dom'; -import { getInboxInvitesPath, getInboxNotificationsPath, getInboxPath } from '$pages/pathUtils'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, + getInboxPath, +} from '$pages/pathUtils'; export const useInboxSelected = (): boolean => { const match = useMatch({ @@ -30,3 +35,13 @@ export const useInboxInvitesSelected = (): boolean => { return !!match; }; + +export const useInboxBookmarksSelected = (): boolean => { + const match = useMatch({ + path: getInboxBookmarksPath(), + caseSensitive: true, + end: false, + }); + + return !!match; +}; diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index aec62cc86..a6914028b 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -49,6 +49,7 @@ import { NOTIFICATIONS_PATH_SEGMENT, ROOM_PATH_SEGMENT, SEARCH_PATH_SEGMENT, + BOOKMARKS_PATH_SEGMENT, SERVER_PATH_SEGMENT, CREATE_PATH, TO_ROOM_EVENT_PATH, @@ -66,6 +67,7 @@ import { import { ClientBindAtoms, ClientLayout, ClientRoot, ClientRouteOutlet } from './client'; import { HandleNotificationClick, ClientNonUIFeatures } from './client/ClientNonUIFeatures'; import { Home, HomeRouteRoomProvider, HomeSearch } from './client/home'; +import { BookmarksList } from './client/bookmarks'; import { Direct, DirectCreate, DirectRouteRoomProvider } from './client/direct'; import { RouteSpaceProvider, Space, SpaceRouteRoomProvider, SpaceSearch } from './client/space'; import { Explore, FeaturedRooms, PublicRooms } from './client/explore'; @@ -243,6 +245,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> join

} /> } /> + } /> } /> } /> + } /> } /> diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b089cf8f8..833f5e211 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -60,6 +60,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -861,11 +862,17 @@ function SettingsSyncFeature() { return null; } +function BookmarksFeature() { + useInitBookmarks(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> + diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx new file mode 100644 index 000000000..6dae4fb5c --- /dev/null +++ b/src/app/pages/client/bookmarks/BookmarksList.tsx @@ -0,0 +1,560 @@ +import { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react'; +import { + Avatar, + Box, + Button, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Line, + Overlay, + OverlayBackdrop, + OverlayCenter, + Scroll, + Spinner, + Text, + Chip, + config, + color, +} from 'folds'; +import FocusTrap from 'focus-trap-react'; +import { JoinRule } from '$types/matrix-sdk'; +import { + Page, + PageContent, + PageContentCenter, + PageHeader, + PageHero, + PageHeroSection, +} from '$components/page'; +import { SequenceCard } from '$components/sequence-card'; +import { AvatarBase, ModernLayout, Time, Username, UsernameBold } from '$components/message'; +import { RoomAvatar, RoomIcon } from '$components/room-avatar'; +import { UserAvatar } from '$components/user-avatar'; +import { BackRouteHandler } from '$components/BackRouteHandler'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useRoomNavigate } from '$hooks/useRoomNavigate'; +import { ScreenSize, useScreenSizeContext } from '$hooks/useScreenSize'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '$utils/room'; +import { getMxIdLocalPart, mxcUrlToHttp } from '$utils/matrix'; +import colorMXID from '$utils/colorMXID'; +import { stopPropagation } from '$utils/keyboard'; +import { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain'; +import { + useBookmarkActions, + useBookmarkList, + useBookmarkLoading, +} from '$features/bookmarks/useBookmarks'; + +// --------------------------------------------------------------------------- +// RemoveBookmarkDialog +// --------------------------------------------------------------------------- + +type RemoveBookmarkDialogProps = { + item: BookmarkItemContent; + onConfirm: () => void; + onClose: () => void; +}; + +function RemoveBookmarkDialog({ item, onConfirm, onClose }: RemoveBookmarkDialogProps) { + return ( + +
+ + Remove Bookmark + + + + +
+ + {item.body_preview && ( + + + {item.body_preview} + + + )} + Remove this bookmark? You can always re-bookmark the message. + + + + + +
+ ); +} + +// --------------------------------------------------------------------------- +// BookmarkItemRow +// --------------------------------------------------------------------------- + +type BookmarkItemRowProps = { + item: BookmarkItemContent; + highlight?: string; + onJump: (roomId: string, eventId: string) => void; + onRemove: (item: BookmarkItemContent) => void; + hour24Clock: boolean; + dateFormatString: string; +}; + +function BookmarkItemRow({ + item, + highlight, + onJump, + onRemove, + hour24Clock, + dateFormatString, +}: BookmarkItemRowProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + + // Try to resolve live room/member data; fall back to stored metadata + const room = mx.getRoom(item.room_id) ?? undefined; + const senderId = item.sender ?? ''; + + const displayName = room + ? (getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId) + : (getMxIdLocalPart(senderId) ?? senderId); + + const senderAvatarMxc = room ? getMemberAvatarMxc(room, senderId) : undefined; + const avatarUrl = senderAvatarMxc + ? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined) + : undefined; + + const usernameColor = colorMXID(senderId); + + // Highlight matching substring in body_preview + const preview = item.body_preview ?? ''; + const highlightedPreview = useMemo(() => { + if (!highlight || !preview) return <>{preview}; + const idx = preview.toLowerCase().indexOf(highlight.toLowerCase()); + if (idx === -1) return <>{preview}; + return ( + <> + {preview.slice(0, idx)} + + {preview.slice(idx, idx + highlight.length)} + + {preview.slice(idx + highlight.length)} + + ); + }, [preview, highlight]); + + return ( + + + + } + /> + + + } + > + + + + + {displayName} + + + + + onJump(item.room_id, item.event_id)} + variant="Secondary" + radii="400" + > + Jump + + onRemove(item)} + aria-label="Remove bookmark" + > + + + + + {preview && ( + + {highlightedPreview} + + )} + + + ); +} + +// --------------------------------------------------------------------------- +// BookmarkResultGroup +// --------------------------------------------------------------------------- + +type BookmarkResultGroupProps = { + roomId: string; + roomName: string; + items: BookmarkItemContent[]; + highlight?: string; + onJump: (roomId: string, eventId: string) => void; + onRemove: (item: BookmarkItemContent) => void; + hour24Clock: boolean; + dateFormatString: string; +}; + +function BookmarkResultGroup({ + roomId, + roomName, + items, + highlight, + onJump, + onRemove, + hour24Clock, + dateFormatString, +}: BookmarkResultGroupProps) { + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const room = mx.getRoom(roomId) ?? undefined; + const avatarUrl = room ? getRoomAvatarUrl(mx, room, 96, useAuthentication) : undefined; + const displayRoomName = room?.name ?? roomName; + + return ( + +
+ + + ( + + )} + /> + + + {displayRoomName} + + +
+ + {items.map((item) => ( + + ))} + +
+ ); +} + +// --------------------------------------------------------------------------- +// BookmarkFilterInput +// --------------------------------------------------------------------------- + +type BookmarkFilterInputProps = { + inputRef: React.RefObject; + active?: boolean; + loading?: boolean; + onFilter: (term: string) => void; + onReset: () => void; +}; + +function BookmarkFilterInput({ + inputRef, + active, + loading, + onFilter, + onReset, +}: BookmarkFilterInputProps) { + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + const { filterInput } = evt.target as HTMLFormElement & { + filterInput: HTMLInputElement; + }; + const term = filterInput.value.trim(); + if (term) onFilter(term); + }; + + return ( + + + Filter + + ) : ( + + {active && ( + + + Clear + + )} + + Filter + + + ) + } + /> + + ); +} + +// --------------------------------------------------------------------------- +// BookmarksList (main export) +// --------------------------------------------------------------------------- + +export function BookmarksList() { + const mx = useMatrixClient(); + const screenSize = useScreenSizeContext(); + const scrollRef = useRef(null); + const filterInputRef = useRef(null); + const { navigateRoom } = useRoomNavigate(); + + const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); + const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); + + const bookmarks = useBookmarkList(); + const loading = useBookmarkLoading(); + const { remove } = useBookmarkActions(); + + const [filterTerm, setFilterTerm] = useState(); + const [removingItem, setRemovingItem] = useState(); + + // Filter and group bookmarks + const filteredBookmarks = useMemo(() => { + if (!filterTerm) return bookmarks; + const lower = filterTerm.toLowerCase(); + return bookmarks.filter( + (b) => + b.body_preview?.toLowerCase().includes(lower) || + b.room_name?.toLowerCase().includes(lower) || + (b.sender && getMxIdLocalPart(b.sender)?.toLowerCase().includes(lower)) + ); + }, [bookmarks, filterTerm]); + + // Group by room_id, preserving order + const groupedByRoom = useMemo(() => { + const map = new Map< + string, + { roomId: string; roomName: string; items: BookmarkItemContent[] } + >(); + filteredBookmarks.forEach((item) => { + let group = map.get(item.room_id); + if (!group) { + const room = mx.getRoom(item.room_id); + group = { + roomId: item.room_id, + roomName: room?.name ?? item.room_name ?? item.room_id, + items: [], + }; + map.set(item.room_id, group); + } + group.items.push(item); + }); + return Array.from(map.values()); + }, [filteredBookmarks, mx]); + + const handleJump = useCallback( + (roomId: string, eventId: string) => { + navigateRoom(roomId, eventId); + }, + [navigateRoom] + ); + + const handleRemoveConfirm = useCallback(async () => { + if (!removingItem) return; + await remove(removingItem.bookmark_id); + setRemovingItem(undefined); + }, [removingItem, remove]); + + const handleFilter = useCallback((term: string) => { + setFilterTerm(term); + }, []); + + const handleReset = useCallback(() => { + setFilterTerm(undefined); + if (filterInputRef.current) { + filterInputRef.current.value = ''; + } + }, []); + + return ( + + + + + {screenSize === ScreenSize.Mobile && ( + + {(onBack) => ( + + + + )} + + )} + + + {screenSize !== ScreenSize.Mobile && } + + Bookmarks + + + + + + + + + + + + + {loading && bookmarks.length === 0 && ( + + + + )} + + {!loading && bookmarks.length === 0 && ( + + } + title="No Bookmarks Yet" + subTitle="Bookmark messages to find them again easily. Right-click a message and choose Bookmark." + /> + + )} + + {!loading && bookmarks.length > 0 && filteredBookmarks.length === 0 && ( + + + + No bookmarks match your filter. + + + )} + + {groupedByRoom.length > 0 && ( + + {groupedByRoom.map((group, i) => ( + <> + {i > 0 && } + + + ))} + + )} + + + + + + {removingItem && ( + }> + + setRemovingItem(undefined), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + setRemovingItem(undefined)} + /> + + + + )} + + ); +} diff --git a/src/app/pages/client/bookmarks/index.ts b/src/app/pages/client/bookmarks/index.ts new file mode 100644 index 000000000..cdd211f71 --- /dev/null +++ b/src/app/pages/client/bookmarks/index.ts @@ -0,0 +1 @@ +export * from './BookmarksList'; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index afd4de936..22934b1df 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -36,11 +36,16 @@ import { getHomeCreatePath, getHomeRoomPath, getHomeSearchPath, + getHomeBookmarksPath, withSearchParam, } from '$pages/pathUtils'; import { getCanonicalAliasOrRoomId } from '$utils/matrix'; import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; -import { useHomeCreateSelected, useHomeSearchSelected } from '$hooks/router/useHomeSelected'; +import { + useHomeCreateSelected, + useHomeSearchSelected, + useHomeBookmarksSelected, +} from '$hooks/router/useHomeSelected'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { VirtualTile } from '$components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '$features/room-nav'; @@ -55,6 +60,7 @@ import { useClosedNavCategoriesAtom } from '$state/hooks/closedNavCategories'; import { stopPropagation } from '$utils/keyboard'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; +import { useExperimentVariant } from '$hooks/useClientConfig'; import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, @@ -203,6 +209,10 @@ export function Home() { const selectedRoomId = useSelectedRoom(); const createRoomSelected = useHomeCreateSelected(); const searchSelected = useHomeSearchSelected(); + const bookmarksSelected = useHomeBookmarksSelected(); + const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); + const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); + const showBookmarks = bookmarksExperiment.inExperiment || enableMessageBookmarks; const noRoomToDisplay = rooms.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); @@ -236,43 +246,39 @@ export function Home() { return ( - {noRoomToDisplay ? ( - - ) : ( - - - - - navigate(getHomeCreatePath())}> - - - - - - - - Create Room - - + + + + + navigate(getHomeCreatePath())}> + + + + + + + + Create Room + - - - - - {(open, setOpen) => ( - <> - - setOpen(true)}> - - - - - - - - Join with Address - - + + + + + + {(open, setOpen) => ( + <> + + setOpen(true)}> + + + + + + + + Join with Address + @@ -301,18 +307,22 @@ export function Home() { - + - Message Search + Bookmarks - + )} + + {noRoomToDisplay ? ( + + ) : ( - - - )} + )} +
+ ); } diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index 661435513..d594a928e 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -1,12 +1,24 @@ import { Avatar, Box, Icon, Icons, Text } from 'folds'; import { useAtomValue } from 'jotai'; import { NavCategory, NavItem, NavItemContent, NavLink } from '$components/nav'; -import { getInboxInvitesPath, getInboxNotificationsPath } from '$pages/pathUtils'; -import { useInboxInvitesSelected, useInboxNotificationsSelected } from '$hooks/router/useInbox'; +import { + getInboxBookmarksPath, + getInboxInvitesPath, + getInboxNotificationsPath, +} from '$pages/pathUtils'; +import { + useInboxBookmarksSelected, + useInboxInvitesSelected, + useInboxNotificationsSelected, +} from '$hooks/router/useInbox'; import { UnreadBadge } from '$components/unread-badge'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; import { PageNav, PageNavContent, PageNavHeader } from '$components/page'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { useSetting } from '$state/hooks/settings'; +import { settingsAtom } from '$state/settings'; +import { useExperimentVariant } from '$hooks/useClientConfig'; function InvitesNavItem() { const invitesSelected = useInboxInvitesSelected(); @@ -39,9 +51,36 @@ function InvitesNavItem() { ); } +function BookmarksNavItem() { + const bookmarksSelected = useInboxBookmarksSelected(); + + return ( + + + + + + + + + + Bookmarks + + + + + + + ); +} + export function Inbox() { useNavToActivePathMapper('inbox'); + const mx = useMatrixClient(); const notificationsSelected = useInboxNotificationsSelected(); + const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); + const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); + const showBookmarks = bookmarksExperiment.inExperiment || enableMessageBookmarks; return ( @@ -75,6 +114,7 @@ export function Inbox() { + {showBookmarks && }
diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4a95f47fc..2d2d63219 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -15,7 +15,9 @@ import { HOME_PATH, HOME_ROOM_PATH, HOME_SEARCH_PATH, + HOME_BOOKMARKS_PATH, LOGIN_PATH, + INBOX_BOOKMARKS_PATH, INBOX_INVITES_PATH, INBOX_NOTIFICATIONS_PATH, INBOX_PATH, @@ -91,6 +93,7 @@ export const getHomePath = (): string => HOME_PATH; export const getHomeCreatePath = (): string => HOME_CREATE_PATH; export const getHomeJoinPath = (): string => HOME_JOIN_PATH; export const getHomeSearchPath = (): string => HOME_SEARCH_PATH; +export const getHomeBookmarksPath = (): string => HOME_BOOKMARKS_PATH; export const getHomeRoomPath = (roomIdOrAlias: string, eventId?: string): string => { const params = { roomIdOrAlias: encodeURIComponent(roomIdOrAlias), @@ -158,6 +161,7 @@ export const getCreatePath = (): string => CREATE_PATH; export const getInboxPath = (): string => INBOX_PATH; export const getInboxNotificationsPath = (): string => INBOX_NOTIFICATIONS_PATH; export const getInboxInvitesPath = (): string => INBOX_INVITES_PATH; +export const getInboxBookmarksPath = (): string => INBOX_BOOKMARKS_PATH; export const getSettingsPath = (section?: string, focus?: string): string => { const path = trimTrailingSlash(generatePath(SETTINGS_PATH, { section: section ?? null })); diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 1ac57b756..2e686d109 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -39,6 +39,7 @@ export type SearchPathSearchParams = { senders?: string; }; export const SEARCH_PATH_SEGMENT = 'search/'; +export const BOOKMARKS_PATH_SEGMENT = 'bookmarks/'; export type RoomSearchParams = { /* comma separated string of servers */ @@ -50,6 +51,7 @@ export const HOME_PATH = '/home/'; export const HOME_CREATE_PATH = `/home/${CREATE_PATH_SEGMENT}`; export const HOME_JOIN_PATH = `/home/${JOIN_PATH_SEGMENT}`; export const HOME_SEARCH_PATH = `/home/${SEARCH_PATH_SEGMENT}`; +export const HOME_BOOKMARKS_PATH = `/home/${BOOKMARKS_PATH_SEGMENT}`; export const HOME_ROOM_PATH = `/home/${ROOM_PATH_SEGMENT}`; export const DIRECT_PATH = '/direct/'; @@ -88,6 +90,7 @@ export type InboxNotificationsPathSearchParams = { }; export const INBOX_NOTIFICATIONS_PATH = `/inbox/${NOTIFICATIONS_PATH_SEGMENT}`; export const INBOX_INVITES_PATH = `/inbox/${INVITES_PATH_SEGMENT}`; +export const INBOX_BOOKMARKS_PATH = `/inbox/${BOOKMARKS_PATH_SEGMENT}`; export const TO_PATH = '/to'; // Deep-link route used by push notification click-back URLs. diff --git a/src/app/state/bookmarks.ts b/src/app/state/bookmarks.ts new file mode 100644 index 000000000..7a2375691 --- /dev/null +++ b/src/app/state/bookmarks.ts @@ -0,0 +1,20 @@ +import { atom } from 'jotai'; +import { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain'; + +/** Ordered list of active bookmark items (mirrors the server index order). */ +export const bookmarkListAtom = atom([]); + +/** True while a refresh from account data is in progress. */ +export const bookmarkLoadingAtom = atom(false); + +/** + * Derived set of active bookmark IDs — used for O(1) per-message + * "is this message bookmarked?" checks. + * + * MSC4438 §Checking if a message is bookmarked: use a local reverse lookup + * rather than issuing server requests. + */ +export const bookmarkIdSetAtom = atom>((get) => { + const list = get(bookmarkListAtom); + return new Set(list.map((b) => b.bookmark_id)); +}); diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 401963f49..34659196e 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -154,6 +154,9 @@ export interface Settings { showPersonaSetting: boolean; closeFoldersByDefault: boolean; + // experimental + enableMessageBookmarks: boolean; + // furry stuff renderAnimals: boolean; @@ -276,6 +279,9 @@ export const defaultSettings: Settings = { showPersonaSetting: false, closeFoldersByDefault: false, + // experimental + enableMessageBookmarks: false, + // furry stuff renderAnimals: true, diff --git a/src/types/matrix/accountData.ts b/src/types/matrix/accountData.ts index 670effb19..074dc3c84 100644 --- a/src/types/matrix/accountData.ts +++ b/src/types/matrix/accountData.ts @@ -10,6 +10,9 @@ export const CustomAccountDataEvent = { SablePerProfileMessageProfiles: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_PER_MESSAGE_PROFILES_PROPERTY_NAME, SableSettings: prefix.MATRIX_SABLE_UNSTABLE_ACCOUNT_SETTINGS_PROPERTY_NAME, + // MSC4438 bookmarks — individual bookmark events + an index + MSC4438BookmarkPrefix: 'org.matrix.msc4438.bookmark.', + MSC4438BookmarksIndex: 'org.matrix.msc4438.bookmarks.index', } as const; export type CustomAccountDataEvent = (typeof CustomAccountDataEvent)[keyof typeof CustomAccountDataEvent]; From a64e8c3505255c5316c08bcef4b61b0ed8fd710e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:46:47 -0400 Subject: [PATCH 015/185] test(bookmarks): add unit tests for MSC4438 bookmark domain and repository --- .../features/bookmarks/bookmarkDomain.test.ts | 375 ++++++++++++++++++ .../bookmarks/bookmarkRepository.test.ts | 334 ++++++++++++++++ src/app/state/bookmarks.test.ts | 69 ++++ 3 files changed, 778 insertions(+) create mode 100644 src/app/features/bookmarks/bookmarkDomain.test.ts create mode 100644 src/app/features/bookmarks/bookmarkRepository.test.ts create mode 100644 src/app/state/bookmarks.test.ts diff --git a/src/app/features/bookmarks/bookmarkDomain.test.ts b/src/app/features/bookmarks/bookmarkDomain.test.ts new file mode 100644 index 000000000..2f70879da --- /dev/null +++ b/src/app/features/bookmarks/bookmarkDomain.test.ts @@ -0,0 +1,375 @@ +/** + * Unit tests for MSC4438 bookmark domain logic. + * All functions in bookmarkDomain.ts are pure / side-effect-free. + */ +import { describe, it, expect } from 'vitest'; +import type { MatrixEvent, Room } from '$types/matrix-sdk'; +import { AccountDataEvent } from '$types/matrix/accountData'; +import { + bookmarkItemEventType, + buildMatrixURI, + computeBookmarkId, + createBookmarkItem, + emptyIndex, + extractBodyPreview, + isValidBookmarkItem, + isValidIndexContent, +} from './bookmarkDomain'; + +// --------------------------------------------------------------------------- +// Helpers: minimal Matrix object stubs +// --------------------------------------------------------------------------- + +function makeEvent( + opts: { + id?: string | null; + body?: unknown; + msgtype?: string; + sender?: string; + ts?: number; + } = {} +): MatrixEvent { + return { + getId: () => (opts.id === null ? undefined : (opts.id ?? '$event:server.tld')), + getTs: () => opts.ts ?? 1_000_000, + getSender: () => opts.sender ?? '@alice:server.tld', + getContent: () => ({ + body: opts.body, + msgtype: opts.msgtype ?? 'm.text', + }), + } as unknown as MatrixEvent; +} + +function makeRoom(opts: { roomId?: string; name?: string } = {}): Room { + return { + roomId: opts.roomId ?? '!room:server.tld', + name: opts.name ?? 'Test Room', + } as unknown as Room; +} + +// --------------------------------------------------------------------------- +// computeBookmarkId +// --------------------------------------------------------------------------- + +describe('computeBookmarkId', () => { + it('returns a string prefixed with "bmk_"', () => { + expect(computeBookmarkId('!room:s', '$event:s')).toMatch(/^bmk_/); + }); + + it('is exactly 12 characters long ("bmk_" + 8 hex digits)', () => { + expect(computeBookmarkId('!room:s', '$event:s')).toHaveLength(12); + }); + + it('only contains hex digits after the prefix', () => { + const id = computeBookmarkId('!room:server.tld', '$event:server.tld'); + expect(id.slice(4)).toMatch(/^[0-9a-f]{8}$/); + }); + + it('is deterministic — same inputs always yield the same ID', () => { + const a = computeBookmarkId('!room:server.tld', '$event:server.tld'); + const b = computeBookmarkId('!room:server.tld', '$event:server.tld'); + expect(a).toBe(b); + }); + + it('differs when roomId changes', () => { + const a = computeBookmarkId('!roomA:s', '$event:s'); + const b = computeBookmarkId('!roomB:s', '$event:s'); + expect(a).not.toBe(b); + }); + + it('differs when eventId changes', () => { + const a = computeBookmarkId('!room:s', '$eventA:s'); + const b = computeBookmarkId('!room:s', '$eventB:s'); + expect(a).not.toBe(b); + }); + + it('separator prevents (roomId + eventId) collisions', () => { + // Without "|" separator, ("ab", "c") and ("a", "bc") would hash the same + const a = computeBookmarkId('ab', 'c'); + const b = computeBookmarkId('a', 'bc'); + expect(a).not.toBe(b); + }); + + // Known vector — computed from the reference djb2-like algorithm: + // input = "a|b", each char's code units: 97, 124, 98 + // hash trace: 0 → 97 → 3131 → 97159 (0x17b87) + it('produces the known reference vector for ("a", "b")', () => { + expect(computeBookmarkId('a', 'b')).toBe('bmk_00017b87'); + }); +}); + +// --------------------------------------------------------------------------- +// bookmarkItemEventType +// --------------------------------------------------------------------------- + +describe('bookmarkItemEventType', () => { + it('returns the MSC4438 unstable event type for a given bookmark ID', () => { + expect(bookmarkItemEventType('bmk_abcd1234')).toBe( + `${AccountDataEvent.BookmarkItemPrefix}bmk_abcd1234` + ); + }); + + it('uses BookmarkItemPrefix as the base', () => { + const id = 'bmk_00000001'; + expect(bookmarkItemEventType(id)).toContain(AccountDataEvent.BookmarkItemPrefix); + }); + + it('has BookmarksIndex enum value defined correctly', () => { + expect(AccountDataEvent.BookmarksIndex).toBe('org.matrix.msc4438.bookmarks.index'); + }); +}); + +// --------------------------------------------------------------------------- +// buildMatrixURI +// --------------------------------------------------------------------------- + +describe('buildMatrixURI', () => { + it.each([ + [ + '!room:server.tld', + '$event:server.tld', + // encodeURIComponent does not encode '!' — only ':' and '$' are encoded here + 'matrix:roomid/!room%3Aserver.tld/e/%24event%3Aserver.tld', + ], + ['simple', 'id', 'matrix:roomid/simple/e/id'], + ['a b', 'c d', 'matrix:roomid/a%20b/e/c%20d'], + ])('buildMatrixURI(%s, %s) → %s', (roomId, eventId, expected) => { + expect(buildMatrixURI(roomId, eventId)).toBe(expected); + }); + + it('starts with "matrix:roomid/"', () => { + expect(buildMatrixURI('!r:s', '$e:s')).toMatch(/^matrix:roomid\//); + }); + + it('contains "/e/" separator between roomId and eventId', () => { + expect(buildMatrixURI('!r:s', '$e:s')).toContain('/e/'); + }); +}); + +// --------------------------------------------------------------------------- +// extractBodyPreview +// --------------------------------------------------------------------------- + +describe('extractBodyPreview', () => { + it('returns the body unchanged when it is within the default limit', () => { + const event = makeEvent({ body: 'Hello, world!' }); + expect(extractBodyPreview(event)).toBe('Hello, world!'); + }); + + it('returns an empty string when body is undefined', () => { + const event = makeEvent({ body: undefined }); + expect(extractBodyPreview(event)).toBe(''); + }); + + it('returns an empty string when body is a non-string type', () => { + const event = makeEvent({ body: 42 }); + expect(extractBodyPreview(event)).toBe(''); + }); + + it('returns an empty string when body is an empty string', () => { + const event = makeEvent({ body: '' }); + expect(extractBodyPreview(event)).toBe(''); + }); + + it('truncates to 120 chars and appends "…" when body exceeds the default limit', () => { + const long = 'x'.repeat(200); + const result = extractBodyPreview(makeEvent({ body: long })); + expect(result).toHaveLength(121); // 120 + ellipsis char + expect(result.endsWith('\u2026')).toBe(true); + expect(result.slice(0, 120)).toBe('x'.repeat(120)); + }); + + it('does not truncate when body is exactly 120 chars', () => { + const exact = 'y'.repeat(120); + expect(extractBodyPreview(makeEvent({ body: exact }))).toBe(exact); + }); + + it('respects a custom maxLength', () => { + const event = makeEvent({ body: 'abcdefghij' }); + const result = extractBodyPreview(event, 5); + expect(result).toBe('abcde\u2026'); + }); +}); + +// --------------------------------------------------------------------------- +// isValidIndexContent +// --------------------------------------------------------------------------- + +describe('isValidIndexContent', () => { + const valid = { + version: 1 as const, + revision: 0, + updated_ts: Date.now(), + bookmark_ids: [], + }; + + it('accepts a well-formed index', () => { + expect(isValidIndexContent(valid)).toBe(true); + }); + + it('accepts an index with string IDs in bookmark_ids', () => { + expect(isValidIndexContent({ ...valid, bookmark_ids: ['bmk_aabbccdd'] })).toBe(true); + }); + + it('rejects null', () => { + expect(isValidIndexContent(null)).toBe(false); + }); + + it('rejects a non-object', () => { + expect(isValidIndexContent('string')).toBe(false); + expect(isValidIndexContent(42)).toBe(false); + }); + + it('rejects version !== 1', () => { + expect(isValidIndexContent({ ...valid, version: 2 })).toBe(false); + }); + + it('rejects missing revision', () => { + const { revision, ...rest } = valid; + expect(isValidIndexContent(rest)).toBe(false); + }); + + it('rejects missing updated_ts', () => { + const { updated_ts: updatedTs, ...rest } = valid; + expect(isValidIndexContent(rest)).toBe(false); + }); + + it('rejects missing bookmark_ids', () => { + const { bookmark_ids: bookmarkIds, ...rest } = valid; + expect(isValidIndexContent(rest)).toBe(false); + }); + + it('rejects bookmark_ids containing a non-string', () => { + expect(isValidIndexContent({ ...valid, bookmark_ids: [1, 2, 3] })).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// isValidBookmarkItem +// --------------------------------------------------------------------------- + +describe('isValidBookmarkItem', () => { + const valid = { + version: 1 as const, + bookmark_id: 'bmk_abcd1234', + uri: 'matrix:roomid/foo/e/bar', + room_id: '!room:s', + event_id: '$event:s', + event_ts: 1_000_000, + bookmarked_ts: 2_000_000, + }; + + it('accepts a complete, well-formed item', () => { + expect(isValidBookmarkItem(valid)).toBe(true); + }); + + it('accepts an item with optional fields set', () => { + expect( + isValidBookmarkItem({ ...valid, sender: '@alice:s', room_name: 'Room', deleted: false }) + ).toBe(true); + }); + + it('rejects null', () => { + expect(isValidBookmarkItem(null)).toBe(false); + }); + + it('rejects a non-object', () => { + expect(isValidBookmarkItem('string')).toBe(false); + }); + + it('rejects version !== 1', () => { + expect(isValidBookmarkItem({ ...valid, version: 2 })).toBe(false); + }); + + it.each(['bookmark_id', 'uri', 'room_id', 'event_id'] as const)( + 'rejects item missing string field "%s"', + (field) => { + const copy = { ...valid } as Record; + delete copy[field]; + expect(isValidBookmarkItem(copy)).toBe(false); + } + ); + + it.each(['event_ts', 'bookmarked_ts'] as const)( + 'rejects item missing numeric field "%s"', + (field) => { + const copy = { ...valid } as Record; + delete copy[field]; + expect(isValidBookmarkItem(copy)).toBe(false); + } + ); +}); + +// --------------------------------------------------------------------------- +// createBookmarkItem +// --------------------------------------------------------------------------- + +describe('createBookmarkItem', () => { + it('returns undefined when the event has no ID', () => { + const room = makeRoom(); + const event = makeEvent({ id: null }); + expect(createBookmarkItem(room, event)).toBeUndefined(); + }); + + it('returns a valid BookmarkItemContent for a normal event', () => { + const room = makeRoom({ roomId: '!r:s', name: 'My Room' }); + const event = makeEvent({ + id: '$e:s', + body: 'Hello', + msgtype: 'm.text', + sender: '@bob:s', + ts: 123456, + }); + const item = createBookmarkItem(room, event); + expect(item).toBeDefined(); + expect(item!.version).toBe(1); + expect(item!.room_id).toBe('!r:s'); + expect(item!.event_id).toBe('$e:s'); + expect(item!.bookmark_id).toBe(computeBookmarkId('!r:s', '$e:s')); + expect(item!.uri).toBe(buildMatrixURI('!r:s', '$e:s')); + expect(item!.event_ts).toBe(123456); + expect(item!.sender).toBe('@bob:s'); + expect(item!.room_name).toBe('My Room'); + expect(item!.body_preview).toBe('Hello'); + expect(item!.msgtype).toBe('m.text'); + }); + + it('omits body_preview when body is missing', () => { + const room = makeRoom(); + const event = makeEvent({ body: undefined }); + const item = createBookmarkItem(room, event); + expect(item!.body_preview).toBe(''); + }); + + it('passes isValidBookmarkItem on the returned content', () => { + const room = makeRoom(); + const event = makeEvent(); + const item = createBookmarkItem(room, event); + expect(isValidBookmarkItem(item)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// emptyIndex +// --------------------------------------------------------------------------- + +describe('emptyIndex', () => { + it('returns a valid index with version 1', () => { + const idx = emptyIndex(); + expect(isValidIndexContent(idx)).toBe(true); + expect(idx.version).toBe(1); + }); + + it('starts with revision 0 and empty bookmark_ids', () => { + const idx = emptyIndex(); + expect(idx.revision).toBe(0); + expect(idx.bookmark_ids).toEqual([]); + }); + + it('returns a fresh object on each call (no shared reference)', () => { + const a = emptyIndex(); + const b = emptyIndex(); + a.bookmark_ids.push('bmk_aabbccdd'); + expect(b.bookmark_ids).toHaveLength(0); + }); +}); diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts new file mode 100644 index 000000000..7b928b50e --- /dev/null +++ b/src/app/features/bookmarks/bookmarkRepository.test.ts @@ -0,0 +1,334 @@ +/** + * Unit tests for MSC4438 bookmark repository layer. + * + * The repository functions are pure in the sense that they read and write + * synchronously from a MatrixClient mock that returns predictable account data. + * No network calls are made. + */ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import type { MatrixClient } from '$types/matrix-sdk'; +import { AccountDataEvent } from '$types/matrix/accountData'; +import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository'; +import { + bookmarkItemEventType, + emptyIndex, + type BookmarkIndexContent, + type BookmarkItemContent, +} from './bookmarkDomain'; + +// --------------------------------------------------------------------------- +// Stub MatrixClient +// --------------------------------------------------------------------------- + +/** + * Build a minimal MatrixClient stub backed by an in-memory store. + * `getAccountData` returns a fake MatrixEvent whose `getContent()` reads + * from the store; `setAccountData` writes to the store. + */ +function makeClient(initialData: Record = {}): MatrixClient { + const store: Record = { ...initialData }; + const accountData = new Map(Object.entries(store)); + + return { + getAccountData: vi.fn((eventType: string) => { + const content = store[eventType]; + if (content === undefined) return undefined; + return { getContent: () => content }; + }), + setAccountData: vi.fn(async (eventType: string, content: unknown) => { + store[eventType] = content; + accountData.set(eventType, content); + }), + store: { accountData }, + _store: store, // exposed for inspection in tests + } as unknown as MatrixClient; +} + +// --------------------------------------------------------------------------- +// Test data helpers +// --------------------------------------------------------------------------- + +function makeItem(overrides: Partial = {}): BookmarkItemContent { + return { + version: 1, + bookmark_id: 'bmk_aabbccdd', + uri: 'matrix:roomid/foo/e/bar', + room_id: '!room:s', + event_id: '$event:s', + event_ts: 1_000_000, + bookmarked_ts: 2_000_000, + ...overrides, + }; +} + +function makeIndex(overrides: Partial = {}): BookmarkIndexContent { + return { + ...emptyIndex(), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// addBookmark +// --------------------------------------------------------------------------- + +describe('addBookmark', () => { + let mx: MatrixClient; + + beforeEach(() => { + mx = makeClient(); + }); + + it('writes the item event before writing the index', async () => { + const item = makeItem(); + const callOrder: string[] = []; + + (mx.setAccountData as ReturnType).mockImplementation( + async (type: string, content: unknown) => { + callOrder.push(type); + // keep default in-memory behaviour + (mx as any)._store[type] = content; + } + ); + + await addBookmark(mx, item); + + expect(callOrder[0]).toBe(bookmarkItemEventType(item.bookmark_id)); + expect(callOrder[1]).toBe(AccountDataEvent.BookmarksIndex); + }); + + it('prepends the bookmark ID to bookmark_ids in the index', async () => { + const existing = makeItem({ bookmark_id: 'bmk_11111111' }); + const mx2 = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [existing.bookmark_id] }), + [bookmarkItemEventType(existing.bookmark_id)]: existing, + }); + + const newItem = makeItem({ bookmark_id: 'bmk_22222222' }); + await addBookmark(mx2, newItem); + + const store = (mx2 as any)._store; + const idx = store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.bookmark_ids[0]).toBe('bmk_22222222'); + expect(idx.bookmark_ids[1]).toBe('bmk_11111111'); + }); + + it('does not duplicate an ID already in the index', async () => { + const item = makeItem(); + const mx2 = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + await addBookmark(mx2, item); + + const idx = (mx2 as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.bookmark_ids.filter((id) => id === item.bookmark_id)).toHaveLength(1); + }); + + it('increments the index revision', async () => { + const item = makeItem(); + await addBookmark(mx, item); + + const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.revision).toBe(1); + }); + + it('works when no index exists yet (creates an empty one)', async () => { + const item = makeItem(); + await addBookmark(mx, item); + + const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.bookmark_ids).toContain(item.bookmark_id); + }); +}); + +// --------------------------------------------------------------------------- +// removeBookmark +// --------------------------------------------------------------------------- + +describe('removeBookmark', () => { + it('removes the bookmark ID from the index', async () => { + const item = makeItem(); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + await removeBookmark(mx, item.bookmark_id); + + const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.bookmark_ids).not.toContain(item.bookmark_id); + }); + + it('soft-deletes the item event (sets deleted: true)', async () => { + const item = makeItem(); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + await removeBookmark(mx, item.bookmark_id); + + const stored = (mx as any)._store[ + bookmarkItemEventType(item.bookmark_id) + ] as BookmarkItemContent; + expect(stored.deleted).toBe(true); + }); + + it('increments the index revision', async () => { + const item = makeItem(); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ + bookmark_ids: [item.bookmark_id], + revision: 3, + }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + await removeBookmark(mx, item.bookmark_id); + + const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.revision).toBe(4); + }); + + it('succeeds without error when the item event does not exist', async () => { + const item = makeItem(); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + // No item event stored + }); + + await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow(); + }); + + it('leaves the index unchanged when the ID was not present', async () => { + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aaaabbbb'] }), + }); + + await removeBookmark(mx, 'bmk_nonexistent'); + + const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.bookmark_ids).toEqual(['bmk_aaaabbbb']); + }); +}); + +// --------------------------------------------------------------------------- +// listBookmarks +// --------------------------------------------------------------------------- + +describe('listBookmarks', () => { + it('returns an empty array when there is no index', () => { + const mx = makeClient(); + expect(listBookmarks(mx)).toEqual([]); + }); + + it('returns active items in index order', () => { + const a = makeItem({ bookmark_id: 'bmk_aaaaaaaa' }); + const b = makeItem({ bookmark_id: 'bmk_bbbbbbbb' }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ + bookmark_ids: [a.bookmark_id, b.bookmark_id], + }), + [bookmarkItemEventType(a.bookmark_id)]: a, + [bookmarkItemEventType(b.bookmark_id)]: b, + }); + + const result = listBookmarks(mx); + expect(result.map((i) => i.bookmark_id)).toEqual([a.bookmark_id, b.bookmark_id]); + }); + + it('skips items that are soft-deleted (deleted: true)', () => { + const item = makeItem({ deleted: true }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + expect(listBookmarks(mx)).toEqual([]); + }); + + it('skips item IDs whose event is missing from account data', () => { + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_orphaned'] }), + // No item event + }); + + expect(listBookmarks(mx)).toEqual([]); + }); + + it('deduplicates IDs that appear more than once in bookmark_ids', () => { + const item = makeItem(); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ + bookmark_ids: [item.bookmark_id, item.bookmark_id], + }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + expect(listBookmarks(mx)).toHaveLength(1); + }); + + it('skips malformed item events', () => { + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_bad'] }), + [bookmarkItemEventType('bmk_bad')]: { not_a_valid: 'item' }, + }); + + expect(listBookmarks(mx)).toEqual([]); + }); + + it('recovers orphaned items whose event exists but ID is absent from the index', () => { + // Simulate a concurrent-write race: device A's bookmark_id was dropped from the + // index by a last-write-wins overwrite, but the item event still exists. + const orphan = makeItem({ bookmark_id: 'bmk_orphan1' }); + const indexed = makeItem({ bookmark_id: 'bmk_indexed' }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [indexed.bookmark_id] }), + [bookmarkItemEventType(indexed.bookmark_id)]: indexed, + [bookmarkItemEventType(orphan.bookmark_id)]: orphan, + }); + + const result = listBookmarks(mx); + expect(result.map((i) => i.bookmark_id)).toContain(orphan.bookmark_id); + expect(result.map((i) => i.bookmark_id)).toContain(indexed.bookmark_id); + // Indexed item should appear before the orphan + expect(result[0].bookmark_id).toBe(indexed.bookmark_id); + }); + + it('does not return soft-deleted orphaned items', () => { + const orphan = makeItem({ bookmark_id: 'bmk_orphan2', deleted: true }); + const mx = makeClient({ + // No index entry for the orphan — deleted orphan should still be skipped + [bookmarkItemEventType(orphan.bookmark_id)]: orphan, + }); + + expect(listBookmarks(mx)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// isBookmarked +// --------------------------------------------------------------------------- + +describe('isBookmarked', () => { + it('returns true when the ID is in the index', () => { + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aabbccdd'] }), + }); + expect(isBookmarked(mx, 'bmk_aabbccdd')).toBe(true); + }); + + it('returns false when the ID is not in the index', () => { + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aabbccdd'] }), + }); + expect(isBookmarked(mx, 'bmk_ffffffff')).toBe(false); + }); + + it('returns false when there is no index', () => { + const mx = makeClient(); + expect(isBookmarked(mx, 'bmk_aabbccdd')).toBe(false); + }); +}); diff --git a/src/app/state/bookmarks.test.ts b/src/app/state/bookmarks.test.ts new file mode 100644 index 000000000..1145b9d1e --- /dev/null +++ b/src/app/state/bookmarks.test.ts @@ -0,0 +1,69 @@ +/** + * Unit tests for the Jotai bookmark atoms in src/app/state/bookmarks.ts. + * + * The derived `bookmarkIdSetAtom` is the only atom with non-trivial logic — + * it builds an O(1) lookup Set from the bookmark list. The primitive atoms + * (`bookmarkListAtom`, `bookmarkLoadingAtom`) are default Jotai atoms whose + * read/write semantics are provided by the library itself and do not need + * additional testing. + */ +import { describe, it, expect } from 'vitest'; +import { createStore } from 'jotai'; +import { bookmarkIdSetAtom, bookmarkListAtom } from './bookmarks'; +import type { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain'; + +// Helper: minimal valid bookmark item +function makeItem(id: string): BookmarkItemContent { + return { + version: 1, + bookmark_id: id, + uri: `matrix:roomid/foo/e/${id}`, + room_id: '!room:s', + event_id: `$${id}:s`, + event_ts: 1_000_000, + bookmarked_ts: 2_000_000, + }; +} + +describe('bookmarkIdSetAtom (derived)', () => { + it('returns an empty Set when the list is empty', () => { + const store = createStore(); + const set = store.get(bookmarkIdSetAtom); + expect(set.size).toBe(0); + }); + + it('contains the IDs of every item in bookmarkListAtom', () => { + const store = createStore(); + store.set(bookmarkListAtom, [makeItem('bmk_aaaaaaaa'), makeItem('bmk_bbbbbbbb')]); + + const set = store.get(bookmarkIdSetAtom); + expect(set.has('bmk_aaaaaaaa')).toBe(true); + expect(set.has('bmk_bbbbbbbb')).toBe(true); + }); + + it('does not contain IDs not in the list', () => { + const store = createStore(); + store.set(bookmarkListAtom, [makeItem('bmk_aaaaaaaa')]); + + const set = store.get(bookmarkIdSetAtom); + expect(set.has('bmk_ffffffff')).toBe(false); + }); + + it('updates reactively when the list changes', () => { + const store = createStore(); + store.set(bookmarkListAtom, [makeItem('bmk_11111111')]); + + expect(store.get(bookmarkIdSetAtom).has('bmk_11111111')).toBe(true); + + store.set(bookmarkListAtom, []); + expect(store.get(bookmarkIdSetAtom).has('bmk_11111111')).toBe(false); + }); + + it('returns a Set whose size equals the number of unique items', () => { + const store = createStore(); + const items = [makeItem('bmk_aaaaaaaa'), makeItem('bmk_bbbbbbbb'), makeItem('bmk_cccccccc')]; + store.set(bookmarkListAtom, items); + + expect(store.get(bookmarkIdSetAtom).size).toBe(3); + }); +}); From 9e5deeca1f44ce09cb1ad33fb7bc78e21cc240af Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:46:48 -0400 Subject: [PATCH 016/185] chore: add changeset for message-bookmarks --- .changeset/message-bookmarks.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/message-bookmarks.md diff --git a/.changeset/message-bookmarks.md b/.changeset/message-bookmarks.md new file mode 100644 index 000000000..9ca1cf6c3 --- /dev/null +++ b/.changeset/message-bookmarks.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add message bookmarks (MSC4438). Users can bookmark messages for easy retrieval via a new Bookmarks section in the home sidebar. Gated by an operator `config.json` experiment flag (`experiments.messageBookmarks`) and a per-user experimental settings toggle. From bc9d597d0d036c6eae0b950e1544d38294a7be0e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Apr 2026 16:42:01 -0400 Subject: [PATCH 017/185] fix(bookmarks): add missing focusId to MSC4438 settings SettingTile --- .../features/settings/experimental/MSC4438MessageBookmarks.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx index 45e429b18..0751a5578 100644 --- a/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx +++ b/src/app/features/settings/experimental/MSC4438MessageBookmarks.tsx @@ -16,6 +16,7 @@ export function MSC4438MessageBookmarks() { Message Bookmarks From 297ffa8c4c94a448e74992cc246b707696078750 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 09:23:25 -0400 Subject: [PATCH 018/185] fix(bookmarks): soft-delete item before updating index in removeBookmark --- src/app/features/bookmarks/bookmarkRepository.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts index d6703777f..e6d712dbd 100644 --- a/src/app/features/bookmarks/bookmarkRepository.ts +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -77,17 +77,19 @@ export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): * removed. */ export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise { + // Soft-delete the item FIRST — mirrors the item-before-index ordering of addBookmark. + // If writeIndex ran first, orphan recovery in listBookmarks() would transiently resurface the + // bookmark (item not yet deleted, but ID also not in index) between the two writes. + const existing = readItem(mx, bookmarkId); + if (existing) { + await writeItem(mx, { ...existing, deleted: true }); + } + const index = readIndex(mx); index.bookmark_ids = index.bookmark_ids.filter((id) => id !== bookmarkId); index.revision += 1; index.updated_ts = Date.now(); await writeIndex(mx, index); - - // Soft-delete the item event - const existing = readItem(mx, bookmarkId); - if (existing) { - await writeItem(mx, { ...existing, deleted: true }); - } } /** From fed3554ed6be910792d2b83f32a4e60dd85b0ae3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 10:53:57 -0400 Subject: [PATCH 019/185] fix(bookmarks): wire useInitBookmarks and fix orphan tombstoning - Mount BookmarksFeature in ClientNonUIFeatures so useInitBookmarks() is called; bookmarkListAtom was never populated, causing the viewer to always show "No Bookmarks Yet" even with data on the server. - Fix removeBookmark to tombstone any existing item event regardless of whether it passes validation, so malformed/orphan bmk_ items cannot be resurrected by orphan recovery in listBookmarks. - Add regression tests: tombstoning malformed item, idempotent tombstoning. --- .../bookmarks/bookmarkRepository.test.ts | 29 +++++++++++++++++++ .../features/bookmarks/bookmarkRepository.ts | 18 ++++++++---- src/app/pages/client/ClientNonUIFeatures.tsx | 8 +++-- 3 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts index 7b928b50e..3eeaaca65 100644 --- a/src/app/features/bookmarks/bookmarkRepository.test.ts +++ b/src/app/features/bookmarks/bookmarkRepository.test.ts @@ -202,6 +202,35 @@ describe('removeBookmark', () => { await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow(); }); + it('tombstones a malformed item event (sets deleted: true even when validation fails)', async () => { + // A malformed item exists in account data (e.g. written by a buggy client). + // removeBookmark must still tombstone it so orphan recovery does not resurrect it. + const badContent = { not_a_valid: 'item' }; + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_bad'] }), + [bookmarkItemEventType('bmk_bad')]: badContent, + }); + + await removeBookmark(mx, 'bmk_bad'); + + const stored = (mx as any)._store[bookmarkItemEventType('bmk_bad')]; + expect(stored.deleted).toBe(true); + }); + + it('tombstones an already-deleted item event (idempotent)', async () => { + // If for any reason the same bookmark is removed twice, the tombstone write + // should still succeed and the item should remain deleted. + const item = makeItem({ deleted: true }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + + await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow(); + const stored = (mx as any)._store[bookmarkItemEventType(item.bookmark_id)] as BookmarkItemContent; + expect(stored.deleted).toBe(true); + }); + it('leaves the index unchanged when the ID was not present', async () => { const mx = makeClient({ [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: ['bmk_aaaabbbb'] }), diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts index e6d712dbd..d1a30370d 100644 --- a/src/app/features/bookmarks/bookmarkRepository.ts +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -77,12 +77,18 @@ export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): * removed. */ export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise { - // Soft-delete the item FIRST — mirrors the item-before-index ordering of addBookmark. - // If writeIndex ran first, orphan recovery in listBookmarks() would transiently resurface the - // bookmark (item not yet deleted, but ID also not in index) between the two writes. - const existing = readItem(mx, bookmarkId); - if (existing) { - await writeItem(mx, { ...existing, deleted: true }); + // Tombstone the item event directly — bypass readItem()'s validation so that + // malformed or already-deleted items still get marked deleted: true. Without + // this, orphan recovery can resurrect items whose deletion write failed halfway. + const evt = mx.getAccountData(bookmarkItemEventType(bookmarkId) as any); + const raw = evt?.getContent(); + if (raw != null) { + // Write using the bookmarkId param as the canonical type key, not item.bookmark_id, + // so malformed items (missing bookmark_id field) still get the right event type. + await mx.setAccountData( + bookmarkItemEventType(bookmarkId) as any, + { ...(raw as object), deleted: true } as any + ); } const index = readIndex(mx); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 833f5e211..efba2feb5 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -531,8 +531,12 @@ function MessageNotifications() { }); } - // In-app audio: play when notification sounds are enabled AND this notification is loud. - if (notificationSound && isLoud) { + // In-app audio: play when the app is in the foreground (has focus) and + // notification sounds are enabled for this notification type. + // Gating on hasFocus() rather than just visibilityState prevents a race + // where the page is still 'visible' for a brief window after the user + // backgrounds the app on mobile — hasFocus() flips false first. + if (notificationSound && isLoud && document.hasFocus()) { playSound(); } }; From 914019aff4f1e63db948839d0fd22535dcfdb0ff Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 14:52:58 -0400 Subject: [PATCH 020/185] fix(bookmarks): strip deleted flag on re-add to guarantee re-activation --- .../bookmarks/bookmarkRepository.test.ts | 29 +++++++++++++++++++ .../features/bookmarks/bookmarkRepository.ts | 11 +++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts index 3eeaaca65..54e3d85b8 100644 --- a/src/app/features/bookmarks/bookmarkRepository.test.ts +++ b/src/app/features/bookmarks/bookmarkRepository.test.ts @@ -141,6 +141,35 @@ describe('addBookmark', () => { const idx = (mx as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; expect(idx.bookmark_ids).toContain(item.bookmark_id); }); + + it('re-activates a tombstoned bookmark (strips deleted: true)', async () => { + const tombstoned = makeItem({ deleted: true }); + const mx2 = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }), + [bookmarkItemEventType(tombstoned.bookmark_id)]: tombstoned, + }); + + // Re-add with a fresh item (same bookmark_id, no deleted flag) + const freshItem = makeItem(); + await addBookmark(mx2, freshItem); + + const stored = (mx2 as any)._store[ + bookmarkItemEventType(freshItem.bookmark_id) + ] as BookmarkItemContent; + expect(stored.deleted).toBeUndefined(); + const idx = (mx2 as any)._store[AccountDataEvent.BookmarksIndex] as BookmarkIndexContent; + expect(idx.bookmark_ids).toContain(freshItem.bookmark_id); + }); + + it('strips deleted: true even when the item passed in carries the flag', async () => { + const item = makeItem({ deleted: true }); + await addBookmark(mx, item); + + const stored = (mx as any)._store[ + bookmarkItemEventType(item.bookmark_id) + ] as BookmarkItemContent; + expect(stored.deleted).toBeUndefined(); + }); }); // --------------------------------------------------------------------------- diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts index d1a30370d..241430e76 100644 --- a/src/app/features/bookmarks/bookmarkRepository.ts +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -44,17 +44,22 @@ async function writeItem(mx: MatrixClient, item: BookmarkItemContent): Promise { + // Strip deleted so that re-bookmarking a previously removed message always + // produces an active item, even if a stale tombstoned item is passed in. + const { deleted, ...activeItem } = item; // Write item before updating index (cross-device consistency) - await writeItem(mx, item); + await writeItem(mx, activeItem as BookmarkItemContent); const index = readIndex(mx); if (!index.bookmark_ids.includes(item.bookmark_id)) { From 459f3a1e2f690183db5cd676aad3df39eb3024bc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 15:15:01 -0400 Subject: [PATCH 021/185] feat(bookmarks): show Recently Removed section with Restore button --- .../bookmarks/bookmarkRepository.test.ts | 77 ++++++++++++++++++- .../features/bookmarks/bookmarkRepository.ts | 39 ++++++++++ src/app/features/bookmarks/useBookmarks.ts | 47 +++++++++-- .../features/bookmarks/useInitBookmarks.ts | 8 +- .../pages/client/bookmarks/BookmarksList.tsx | 70 ++++++++++++++++- src/app/state/bookmarks.ts | 7 ++ 6 files changed, 235 insertions(+), 13 deletions(-) diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts index 54e3d85b8..d9724cf67 100644 --- a/src/app/features/bookmarks/bookmarkRepository.test.ts +++ b/src/app/features/bookmarks/bookmarkRepository.test.ts @@ -8,7 +8,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import type { MatrixClient } from '$types/matrix-sdk'; import { AccountDataEvent } from '$types/matrix/accountData'; -import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository'; +import { + addBookmark, + removeBookmark, + listBookmarks, + listDeletedBookmarks, + isBookmarked, +} from './bookmarkRepository'; import { bookmarkItemEventType, emptyIndex, @@ -366,6 +372,75 @@ describe('listBookmarks', () => { }); }); +// --------------------------------------------------------------------------- +// listDeletedBookmarks +// --------------------------------------------------------------------------- + +describe('listDeletedBookmarks', () => { + it('returns an empty array when there are no tombstoned items', () => { + const item = makeItem(); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + expect(listDeletedBookmarks(mx)).toEqual([]); + }); + + it('returns index-referenced items that are tombstoned (partial remove failure)', () => { + const item = makeItem({ deleted: true }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + const result = listDeletedBookmarks(mx); + expect(result).toHaveLength(1); + expect(result[0].bookmark_id).toBe(item.bookmark_id); + }); + + it('returns orphan tombstones not in the index (normal remove path)', () => { + const item = makeItem({ bookmark_id: 'bmk_orphan99', deleted: true }); + const mx = makeClient({ + // ID intentionally absent from the index + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + const result = listDeletedBookmarks(mx); + expect(result).toHaveLength(1); + expect(result[0].bookmark_id).toBe(item.bookmark_id); + }); + + it('does not return active (non-deleted) items', () => { + const active = makeItem(); + const deleted = makeItem({ bookmark_id: 'bmk_deleted1', deleted: true }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [active.bookmark_id] }), + [bookmarkItemEventType(active.bookmark_id)]: active, + [bookmarkItemEventType(deleted.bookmark_id)]: deleted, + }); + const result = listDeletedBookmarks(mx); + expect(result.map((i) => i.bookmark_id)).not.toContain(active.bookmark_id); + expect(result.map((i) => i.bookmark_id)).toContain(deleted.bookmark_id); + }); + + it('deduplicates when the same ID appears in both index and orphan scan', () => { + const item = makeItem({ deleted: true }); + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [item.bookmark_id] }), + [bookmarkItemEventType(item.bookmark_id)]: item, + }); + const result = listDeletedBookmarks(mx); + expect(result).toHaveLength(1); + }); + + it('skips malformed item events even if deleted: true', () => { + const mx = makeClient({ + [AccountDataEvent.BookmarksIndex]: makeIndex({ bookmark_ids: [] }), + [bookmarkItemEventType('bmk_bad')]: { deleted: true, not_valid: 'junk' }, + }); + expect(listDeletedBookmarks(mx)).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // isBookmarked // --------------------------------------------------------------------------- diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts index 241430e76..bd9cda928 100644 --- a/src/app/features/bookmarks/bookmarkRepository.ts +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -146,6 +146,45 @@ export function listBookmarks(mx: MatrixClient): BookmarkItemContent[] { return items; } +/** + * List all deleted (tombstoned) bookmark items. + * + * Includes both: + * - Items still referenced in the index whose item event carries deleted: true + * (arises when the index write fails after a soft-delete). + * - Orphaned tombstones whose ID has already been removed from the index + * (the normal case after a successful remove). + * + * Results are deduplicated and include only items that pass isValidBookmarkItem + * (ensuring enough stored metadata is available to display and restore them). + */ +export function listDeletedBookmarks(mx: MatrixClient): BookmarkItemContent[] { + const index = readIndex(mx); + const results: BookmarkItemContent[] = []; + const seen = new Set(); + + // 1. Index-referenced items that are tombstoned (partial remove failure) + index.bookmark_ids.forEach((id) => { + if (seen.has(id)) return; + seen.add(id); + const content = mx.getAccountData(bookmarkItemEventType(id) as any)?.getContent(); + if (isValidBookmarkItem(content) && content.deleted === true) results.push(content); + }); + + // 2. Orphan tombstones (properly removed from index but item event persists) + const prefix = AccountDataEvent.BookmarkItemPrefix as string; + Array.from(mx.store.accountData.keys()).forEach((key) => { + if (!key.startsWith(prefix)) return; + const bookmarkId = key.slice(prefix.length); + if (seen.has(bookmarkId)) return; + seen.add(bookmarkId); + const content = mx.getAccountData(key as any)?.getContent(); + if (isValidBookmarkItem(content) && content.deleted === true) results.push(content); + }); + + return results; +} + /** * Check whether a specific bookmark ID is in the index. * diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts index aadd0bb8f..df9346dfd 100644 --- a/src/app/features/bookmarks/useBookmarks.ts +++ b/src/app/features/bookmarks/useBookmarks.ts @@ -1,15 +1,31 @@ import { useAtomValue, useSetAtom } from 'jotai'; import { useCallback } from 'react'; import { useMatrixClient } from '$hooks/useMatrixClient'; -import { bookmarkIdSetAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks'; +import { + bookmarkDeletedListAtom, + bookmarkIdSetAtom, + bookmarkListAtom, + bookmarkLoadingAtom, +} from '$state/bookmarks'; import { BookmarkItemContent, computeBookmarkId } from './bookmarkDomain'; -import { addBookmark, removeBookmark, listBookmarks, isBookmarked } from './bookmarkRepository'; +import { + addBookmark, + listBookmarks, + listDeletedBookmarks, + removeBookmark, + isBookmarked, +} from './bookmarkRepository'; /** Returns the current ordered bookmark list. */ export function useBookmarkList(): BookmarkItemContent[] { return useAtomValue(bookmarkListAtom); } +/** Returns deleted (tombstoned) bookmarks that can be restored. */ +export function useBookmarkDeletedList(): BookmarkItemContent[] { + return useAtomValue(bookmarkDeletedListAtom); +} + /** Returns true while a bookmark refresh is in progress. */ export function useBookmarkLoading(): boolean { return useAtomValue(bookmarkLoadingAtom); @@ -35,28 +51,30 @@ export function useIsBookmarked(roomId: string, eventId: string): boolean { export function useBookmarkActions() { const mx = useMatrixClient(); const setList = useSetAtom(bookmarkListAtom); + const setDeletedList = useSetAtom(bookmarkDeletedListAtom); const setLoading = useSetAtom(bookmarkLoadingAtom); const refresh = useCallback(async () => { setLoading(true); try { - const items = listBookmarks(mx); - setList(items); + setList(listBookmarks(mx)); + setDeletedList(listDeletedBookmarks(mx)); } finally { setLoading(false); } - }, [mx, setList, setLoading]); + }, [mx, setList, setDeletedList, setLoading]); const add = useCallback( async (item: BookmarkItemContent) => { - // Optimistic update + // Optimistic update: add to active list, remove from deleted list setList((prev) => { if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev; return [item, ...prev]; }); + setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== item.bookmark_id)); await addBookmark(mx, item); }, - [mx, setList] + [mx, setList, setDeletedList] ); const remove = useCallback( @@ -68,11 +86,24 @@ export function useBookmarkActions() { [mx, setList] ); + const restore = useCallback( + async (item: BookmarkItemContent) => { + // Optimistic update: move from deleted list to active list + setDeletedList((prev) => prev.filter((b) => b.bookmark_id !== item.bookmark_id)); + setList((prev) => { + if (prev.some((b) => b.bookmark_id === item.bookmark_id)) return prev; + return [item, ...prev]; + }); + await addBookmark(mx, item); // strips deleted flag + }, + [mx, setList, setDeletedList] + ); + const checkIsBookmarked = useCallback( (roomId: string, eventId: string): boolean => isBookmarked(mx, computeBookmarkId(roomId, eventId)), [mx] ); - return { refresh, add, remove, checkIsBookmarked }; + return { refresh, add, remove, restore, checkIsBookmarked }; } diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts index 40175ce58..3b6cb2247 100644 --- a/src/app/features/bookmarks/useInitBookmarks.ts +++ b/src/app/features/bookmarks/useInitBookmarks.ts @@ -4,9 +4,9 @@ import { useSetAtom } from 'jotai'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSyncState } from '$hooks/useSyncState'; import { useAccountDataCallback } from '$hooks/useAccountDataCallback'; -import { bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks'; +import { bookmarkDeletedListAtom, bookmarkListAtom, bookmarkLoadingAtom } from '$state/bookmarks'; import { AccountDataEvent } from '$types/matrix/accountData'; -import { listBookmarks } from './bookmarkRepository'; +import { listBookmarks, listDeletedBookmarks } from './bookmarkRepository'; /** * Top-level hook that keeps `bookmarkListAtom` in sync with account data. @@ -24,16 +24,18 @@ import { listBookmarks } from './bookmarkRepository'; export function useInitBookmarks(): void { const mx = useMatrixClient(); const setList = useSetAtom(bookmarkListAtom); + const setDeletedList = useSetAtom(bookmarkDeletedListAtom); const setLoading = useSetAtom(bookmarkLoadingAtom); const loadBookmarks = useCallback(() => { setLoading(true); try { setList(listBookmarks(mx)); + setDeletedList(listDeletedBookmarks(mx)); } finally { setLoading(false); } - }, [mx, setList, setLoading]); + }, [mx, setList, setDeletedList, setLoading]); // Immediate load: fires once on mount to cover the case where ClientNonUIFeatures // mounts after the initial SyncState.Syncing transition has already fired. diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx index 6dae4fb5c..562a4920c 100644 --- a/src/app/pages/client/bookmarks/BookmarksList.tsx +++ b/src/app/pages/client/bookmarks/BookmarksList.tsx @@ -48,6 +48,7 @@ import { stopPropagation } from '$utils/keyboard'; import { BookmarkItemContent } from '$features/bookmarks/bookmarkDomain'; import { useBookmarkActions, + useBookmarkDeletedList, useBookmarkList, useBookmarkLoading, } from '$features/bookmarks/useBookmarks'; @@ -298,6 +299,45 @@ function BookmarkResultGroup({ ); } +// --------------------------------------------------------------------------- +// RemovedBookmarkRow +// --------------------------------------------------------------------------- + +type RemovedBookmarkRowProps = { + item: BookmarkItemContent; + onRestore: (item: BookmarkItemContent) => void; +}; + +function RemovedBookmarkRow({ item, onRestore }: RemovedBookmarkRowProps) { + const mx = useMatrixClient(); + const room = mx.getRoom(item.room_id) ?? undefined; + const roomName = room?.name ?? item.room_name ?? item.room_id; + + return ( + + + + + {roomName} + + {item.body_preview && ( + + {item.body_preview} + + )} + + onRestore(item)} variant="Secondary" radii="400"> + Restore + + + + ); +} + // --------------------------------------------------------------------------- // BookmarkFilterInput // --------------------------------------------------------------------------- @@ -379,8 +419,9 @@ export function BookmarksList() { const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString'); const bookmarks = useBookmarkList(); + const deletedBookmarks = useBookmarkDeletedList(); const loading = useBookmarkLoading(); - const { remove } = useBookmarkActions(); + const { remove, restore } = useBookmarkActions(); const [filterTerm, setFilterTerm] = useState(); const [removingItem, setRemovingItem] = useState(); @@ -432,6 +473,13 @@ export function BookmarksList() { setRemovingItem(undefined); }, [removingItem, remove]); + const handleRestore = useCallback( + async (item: BookmarkItemContent) => { + await restore(item); + }, + [restore] + ); + const handleFilter = useCallback((term: string) => { setFilterTerm(term); }, []); @@ -530,6 +578,26 @@ export function BookmarksList() { ))}
)} + + {deletedBookmarks.length > 0 && !filterTerm && ( + + +
+ + Recently Removed + +
+ + {deletedBookmarks.map((item) => ( + + ))} + +
+ )} diff --git a/src/app/state/bookmarks.ts b/src/app/state/bookmarks.ts index 7a2375691..4d6c2b19f 100644 --- a/src/app/state/bookmarks.ts +++ b/src/app/state/bookmarks.ts @@ -4,6 +4,13 @@ import { BookmarkItemContent } from '../features/bookmarks/bookmarkDomain'; /** Ordered list of active bookmark items (mirrors the server index order). */ export const bookmarkListAtom = atom([]); +/** + * Ordered list of deleted (tombstoned) bookmark items that are recoverable. + * Populated alongside bookmarkListAtom so the UI can show a "Recently Removed" + * section with a Restore button for each entry. + */ +export const bookmarkDeletedListAtom = atom([]); + /** True while a refresh from account data is in progress. */ export const bookmarkLoadingAtom = atom(false); From 5b6612698276044f300478d53ecc0aa7972a009e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 01:48:18 -0400 Subject: [PATCH 022/185] test(bookmarks): format repository tests for prettier compliance --- src/app/features/bookmarks/bookmarkRepository.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/features/bookmarks/bookmarkRepository.test.ts b/src/app/features/bookmarks/bookmarkRepository.test.ts index d9724cf67..0489fe692 100644 --- a/src/app/features/bookmarks/bookmarkRepository.test.ts +++ b/src/app/features/bookmarks/bookmarkRepository.test.ts @@ -262,7 +262,9 @@ describe('removeBookmark', () => { }); await expect(removeBookmark(mx, item.bookmark_id)).resolves.not.toThrow(); - const stored = (mx as any)._store[bookmarkItemEventType(item.bookmark_id)] as BookmarkItemContent; + const stored = (mx as any)._store[ + bookmarkItemEventType(item.bookmark_id) + ] as BookmarkItemContent; expect(stored.deleted).toBe(true); }); From 5ce7093fd11a17ba034e17589a25feb344e5749a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 16:27:43 -0400 Subject: [PATCH 023/185] fix(bookmarks): react to item-level account data events and fix remove() optimistic update - useInitBookmarks: also trigger loadBookmarks on bookmark item events (prefix org.matrix.msc4438.bookmark.*), not just the index event. Fixes cross-device sync not updating the UI when sliding sync delivers individual item events. - useBookmarks: remove() now moves the item to bookmarkDeletedListAtom optimistically so the Recently Removed section updates immediately. - Added regression tests for both fixes. --- .../features/bookmarks/useBookmarks.test.tsx | 132 +++++++++++++++ src/app/features/bookmarks/useBookmarks.ts | 15 +- .../bookmarks/useInitBookmarks.test.tsx | 155 ++++++++++++++++++ .../features/bookmarks/useInitBookmarks.ts | 10 +- 4 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 src/app/features/bookmarks/useBookmarks.test.tsx create mode 100644 src/app/features/bookmarks/useInitBookmarks.test.tsx diff --git a/src/app/features/bookmarks/useBookmarks.test.tsx b/src/app/features/bookmarks/useBookmarks.test.tsx new file mode 100644 index 000000000..e09f446c2 --- /dev/null +++ b/src/app/features/bookmarks/useBookmarks.test.tsx @@ -0,0 +1,132 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { createStore, Provider } from 'jotai'; +import { createElement, type ReactNode } from 'react'; +import { bookmarkListAtom, bookmarkDeletedListAtom } from '$state/bookmarks'; +import { useBookmarkActions } from './useBookmarks'; +import type { BookmarkItemContent } from './bookmarkDomain'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const { mockMx } = vi.hoisted(() => { + const store: Record = {}; + return { + mockMx: { + getAccountData: vi.fn((type: string) => { + const content = store[type]; + if (!content) return undefined; + return { getContent: () => content }; + }), + setAccountData: vi.fn(async (type: string, content: unknown) => { + store[type] = content; + }), + store: { accountData: new Map() }, + }, + }; +}); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + +// Mock the repository so removeBookmark doesn't try to read real account data +vi.mock('./bookmarkRepository', async (importOriginal) => { + const orig = await importOriginal(); + return { + ...orig, + removeBookmark: vi.fn(async () => {}), + addBookmark: vi.fn(async () => {}), + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeItem(id: string): BookmarkItemContent { + return { + version: 1, + bookmark_id: id, + uri: `matrix:roomid/foo/e/${id}`, + room_id: '!room:s', + event_id: `$${id}:s`, + event_ts: 1_000, + bookmarked_ts: 2_000, + }; +} + +function makeWrapper(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(Provider, { store }, children); + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useBookmarkActions.remove', () => { + let store: ReturnType; + + beforeEach(() => { + store = createStore(); + }); + + it('moves item from active list to deleted list optimistically', async () => { + const item = makeItem('bmk_1111'); + store.set(bookmarkListAtom, [item]); + store.set(bookmarkDeletedListAtom, []); + + const { result } = renderHook(() => useBookmarkActions(), { + wrapper: makeWrapper(store), + }); + + await act(async () => { + await result.current.remove('bmk_1111'); + }); + + expect(store.get(bookmarkListAtom)).toHaveLength(0); + + const deleted = store.get(bookmarkDeletedListAtom); + expect(deleted).toHaveLength(1); + expect(deleted[0].bookmark_id).toBe('bmk_1111'); + expect(deleted[0].deleted).toBe(true); + }); + + it('does not duplicate item in deleted list if already present', async () => { + const item = makeItem('bmk_2222'); + const deletedItem = { ...item, deleted: true as const }; + store.set(bookmarkListAtom, [item]); + store.set(bookmarkDeletedListAtom, [deletedItem]); + + const { result } = renderHook(() => useBookmarkActions(), { + wrapper: makeWrapper(store), + }); + + await act(async () => { + await result.current.remove('bmk_2222'); + }); + + expect(store.get(bookmarkDeletedListAtom)).toHaveLength(1); + }); + + it('handles removing a non-existent item gracefully', async () => { + store.set(bookmarkListAtom, [makeItem('bmk_3333')]); + store.set(bookmarkDeletedListAtom, []); + + const { result } = renderHook(() => useBookmarkActions(), { + wrapper: makeWrapper(store), + }); + + await act(async () => { + await result.current.remove('bmk_nonexistent'); + }); + + // Original item untouched + expect(store.get(bookmarkListAtom)).toHaveLength(1); + // Nothing added to deleted list since the item wasn't found + expect(store.get(bookmarkDeletedListAtom)).toHaveLength(0); + }); +}); diff --git a/src/app/features/bookmarks/useBookmarks.ts b/src/app/features/bookmarks/useBookmarks.ts index df9346dfd..c6fac5746 100644 --- a/src/app/features/bookmarks/useBookmarks.ts +++ b/src/app/features/bookmarks/useBookmarks.ts @@ -79,11 +79,20 @@ export function useBookmarkActions() { const remove = useCallback( async (bookmarkId: string) => { - // Optimistic update - setList((prev) => prev.filter((b) => b.bookmark_id !== bookmarkId)); + // Optimistic update: move from active list to deleted list + setList((prev) => { + const removed = prev.find((b) => b.bookmark_id === bookmarkId); + if (removed) { + setDeletedList((del) => { + if (del.some((b) => b.bookmark_id === bookmarkId)) return del; + return [{ ...removed, deleted: true }, ...del]; + }); + } + return prev.filter((b) => b.bookmark_id !== bookmarkId); + }); await removeBookmark(mx, bookmarkId); }, - [mx, setList] + [mx, setList, setDeletedList] ); const restore = useCallback( diff --git a/src/app/features/bookmarks/useInitBookmarks.test.tsx b/src/app/features/bookmarks/useInitBookmarks.test.tsx new file mode 100644 index 000000000..893b5d64c --- /dev/null +++ b/src/app/features/bookmarks/useInitBookmarks.test.tsx @@ -0,0 +1,155 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { createStore, Provider } from 'jotai'; +import { createElement, type ReactNode } from 'react'; +import { bookmarkListAtom, bookmarkDeletedListAtom } from '$state/bookmarks'; +import { useInitBookmarks } from './useInitBookmarks'; +import type { BookmarkItemContent, BookmarkIndexContent } from './bookmarkDomain'; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const BOOKMARKS_INDEX = 'org.matrix.msc4438.bookmarks.index'; +const BOOKMARK_PREFIX = 'org.matrix.msc4438.bookmark.'; + +const { accountDataCB, syncStateCB, mockMx } = vi.hoisted(() => { + const adCB: { current: ((event: { getType: () => string }) => void) | null } = { current: null }; + const ssCB: { current: ((state: string, prev: string) => void) | null } = { current: null }; + + const item: BookmarkItemContent = { + version: 1, + bookmark_id: 'bmk_aabb', + uri: 'matrix:roomid/foo/e/bar', + room_id: '!room:s', + event_id: '$ev:s', + event_ts: 1_000, + bookmarked_ts: 2_000, + }; + const deletedItem: BookmarkItemContent = { + version: 1, + bookmark_id: 'bmk_ccdd', + uri: 'matrix:roomid/baz/e/qux', + room_id: '!room2:s', + event_id: '$ev2:s', + event_ts: 3_000, + bookmarked_ts: 4_000, + deleted: true, + }; + const index: BookmarkIndexContent = { + version: 1, + revision: 1, + updated_ts: 5_000, + bookmark_ids: ['bmk_aabb', 'bmk_ccdd'], + }; + + const store: Record = { + ['org.matrix.msc4438.bookmarks.index']: index, + ['org.matrix.msc4438.bookmark.bmk_aabb']: item, + ['org.matrix.msc4438.bookmark.bmk_ccdd']: deletedItem, + }; + + const mx = { + getAccountData: vi.fn((type: string) => { + const content = store[type]; + if (!content) return undefined; + return { getContent: () => content }; + }), + setAccountData: vi.fn(), + store: { accountData: new Map(Object.entries(store)) }, + }; + + return { accountDataCB: adCB, syncStateCB: ssCB, mockMx: mx }; +}); + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + +vi.mock('$hooks/useAccountDataCallback', () => ({ + useAccountDataCallback: ( + _mx: unknown, + cb: (event: { getType: () => string }) => void + ) => { + accountDataCB.current = cb; + }, +})); + +vi.mock('$hooks/useSyncState', () => ({ + useSyncState: (_mx: unknown, cb: (state: string, prev: string) => void) => { + syncStateCB.current = cb; + }, +})); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeStore() { + return createStore(); +} + +function makeWrapper(store: ReturnType) { + return function Wrapper({ children }: { children: ReactNode }) { + return createElement(Provider, { store }, children); + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('useInitBookmarks', () => { + let store: ReturnType; + + beforeEach(() => { + store = makeStore(); + accountDataCB.current = null; + syncStateCB.current = null; + }); + + it('loads bookmarks on mount', () => { + renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) }); + + const list = store.get(bookmarkListAtom); + const deleted = store.get(bookmarkDeletedListAtom); + expect(list).toHaveLength(1); + expect(list[0].bookmark_id).toBe('bmk_aabb'); + expect(deleted).toHaveLength(1); + expect(deleted[0].bookmark_id).toBe('bmk_ccdd'); + }); + + it('reloads when BookmarksIndex account data event fires', () => { + renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) }); + + // Clear the atom to prove the callback re-populates it + store.set(bookmarkListAtom, []); + + accountDataCB.current!({ getType: () => BOOKMARKS_INDEX }); + + expect(store.get(bookmarkListAtom)).toHaveLength(1); + }); + + it('reloads when a bookmark item account data event fires', () => { + renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) }); + + store.set(bookmarkListAtom, []); + + accountDataCB.current!({ + getType: () => `${BOOKMARK_PREFIX}bmk_aabb`, + }); + + expect(store.get(bookmarkListAtom)).toHaveLength(1); + }); + + it('ignores unrelated account data events', () => { + renderHook(() => useInitBookmarks(), { wrapper: makeWrapper(store) }); + + store.set(bookmarkListAtom, []); + + accountDataCB.current!({ getType: () => 'm.room.message' }); + + // Should still be empty — callback should not have triggered a reload + expect(store.get(bookmarkListAtom)).toHaveLength(0); + }); +}); diff --git a/src/app/features/bookmarks/useInitBookmarks.ts b/src/app/features/bookmarks/useInitBookmarks.ts index 3b6cb2247..480e20083 100644 --- a/src/app/features/bookmarks/useInitBookmarks.ts +++ b/src/app/features/bookmarks/useInitBookmarks.ts @@ -57,12 +57,18 @@ export function useInitBookmarks(): void { ) ); - // React to index updates pushed by other devices mid-session. + // React to bookmark account data changes pushed by other devices mid-session. + // The index event fires when the bookmark list changes; individual item events + // fire when a bookmark is added, removed, or soft-deleted. useAccountDataCallback( mx, useCallback( (event: MatrixEvent) => { - if (event.getType() === (AccountDataEvent.BookmarksIndex as string)) { + const type = event.getType(); + if ( + type === (AccountDataEvent.BookmarksIndex as string) || + type.startsWith(AccountDataEvent.BookmarkItemPrefix as string) + ) { loadBookmarks(); } }, From faa490f2bd7cdc317af78672b43d6cc75afb60f8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 22:53:32 -0400 Subject: [PATCH 024/185] fix(bookmarks): add Fragment key, guard missing eventId, fix removeBookmark doc comment - Wrap grouped bookmark list items in instead of un-keyed <> - Return null from MessageBookmarkItem when mEvent.getId() is undefined - Update removeBookmark doc comment to match item-first deletion ordering --- src/app/features/bookmarks/bookmarkRepository.ts | 11 +++++++---- src/app/features/room/message/Message.tsx | 5 +++-- src/app/pages/client/bookmarks/BookmarksList.tsx | 7 +++---- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/app/features/bookmarks/bookmarkRepository.ts b/src/app/features/bookmarks/bookmarkRepository.ts index bd9cda928..1b6cf0208 100644 --- a/src/app/features/bookmarks/bookmarkRepository.ts +++ b/src/app/features/bookmarks/bookmarkRepository.ts @@ -74,12 +74,15 @@ export async function addBookmark(mx: MatrixClient, item: BookmarkItemContent): * Remove a bookmark. * * MSC4438 §Removing a bookmark: - * 1. Remove the ID from the index. - * 2. Soft-delete the item (set deleted: true). + * 1. Soft-delete the item first (set deleted: true). + * 2. Remove the ID from the index. + * 3. Increment revision and update timestamp. + * 4. Write the updated index. * * Account data events cannot be deleted from the server, so soft-deletion is - * used. Other clients that encounter the item event can see it is explicitly - * removed. + * used. This implementation intentionally tombstones the item before updating + * the index to mirror addBookmark()'s item-first ordering and avoid transient + * orphan recovery/resurrection if a removal only partially completes. */ export async function removeBookmark(mx: MatrixClient, bookmarkId: string): Promise { // Tombstone the item event directly — bypass readItem()'s validation so that diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index ffc0fec64..7d65c4065 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -214,10 +214,11 @@ export const MessageBookmarkItem = as< const mx = useMatrixClient(); const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); - const eventId = mEvent.getId() ?? ''; - const isBookmarked = useIsBookmarked(room.roomId, eventId); + const eventId = mEvent.getId(); + const isBookmarked = useIsBookmarked(room.roomId, eventId ?? ''); const { add, remove } = useBookmarkActions(); + if (!eventId) return null; if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null; const handleClick = async () => { diff --git a/src/app/pages/client/bookmarks/BookmarksList.tsx b/src/app/pages/client/bookmarks/BookmarksList.tsx index 562a4920c..a24b661c8 100644 --- a/src/app/pages/client/bookmarks/BookmarksList.tsx +++ b/src/app/pages/client/bookmarks/BookmarksList.tsx @@ -1,4 +1,4 @@ -import { FormEventHandler, useCallback, useMemo, useRef, useState } from 'react'; +import { FormEventHandler, Fragment, useCallback, useMemo, useRef, useState } from 'react'; import { Avatar, Box, @@ -561,10 +561,9 @@ export function BookmarksList() { {groupedByRoom.length > 0 && ( {groupedByRoom.map((group, i) => ( - <> + {i > 0 && } - + ))} )} From 148d9eb2ffc238f9cd96b6c4446dbabf118b6006 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 17:52:48 -0400 Subject: [PATCH 025/185] fix(nav): check DM membership before space parents in useRoomNavigate Mirrors the fix already applied in useNotificationJumper: when a room belongs to both the direct-message list and a space, prefer the /direct route over the space route. Previously useRoomNavigate checked orphan space parents first, which caused bookmark jumps and room-nav clicks on DMs-in-spaces to open the room via the space path instead of the direct path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomNavigate.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 8e4abb172..900110610 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -38,7 +38,20 @@ export const useRoomNavigate = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); + // Developer-mode: view the space's own timeline (must be checked first). + if (openSpaceTimeline) { + navigate(getSpaceRoomPath(roomIdOrAlias, roomId, eventId), opts); + return; + } + + // DMs take priority over space membership so direct chats always open + // via the direct route, even when the room also belongs to a space. + if (mDirects.has(roomId)) { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + return; + } + + const orphanParents = getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -49,15 +62,7 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); - return; - } - - if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } From 04a72cdb3b168d25e7d64efcbd8f4c448eaaca7d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 026/185] fix(timeline): restore useLayoutEffect auto-scroll, fix new-message scroll, fix eventId drag-to-bottom, increase list timeline limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTimelineSync: change auto-scroll recovery useEffect → useLayoutEffect to prevent one-frame flash after timeline reset - useTimelineSync: remove premature scrollToBottom from useLiveTimelineRefresh (operated on pre-commit DOM with stale scrollSize) - useTimelineSync: remove scrollToBottom + eventsLengthRef suppression from useLiveEventArrive; let useLayoutEffect handle scroll after React commits - RoomTimeline: init atBottomState to false when eventId is set, and reset it in the eventId useEffect, so auto-scroll doesn't drag to bottom on bookmark nav - RoomTimeline: change instant scrollToBottom to use scrollToIndex instead of scrollTo(scrollSize) — works correctly regardless of VList measurement state - slidingSync: increase DEFAULT_LIST_TIMELINE_LIMIT 1→3 to reduce empty previews when recent events are reactions/edits/state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 23 ++++++++++++++++++++--- src/app/hooks/timeline/useTimelineSync.ts | 22 +++++++++------------- src/client/slidingSync.ts | 6 +++--- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..44f1b7c07 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,7 +213,14 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(true); + // Load any cached scroll state for this room on mount. A fresh RoomTimeline is + // mounted per room (via key={roomId} in RoomView) so this is the only place we + // need to read the cache — the render-phase room-change block below only fires + // in the (hypothetical) case where the room prop changes without a remount. + const scrollCacheForRoomRef = useRef( + roomScrollCache.load(mxUserId, room.roomId) + ); + const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -257,7 +264,14 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - vListRef.current.scrollTo(vListRef.current.scrollSize); + if (behavior === 'smooth') { + vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); + } else { + // scrollToIndex works reliably regardless of VList measurement state. + // The auto-scroll useLayoutEffect fires after React commits new items, + // so lastIndex is always valid when this is called. + vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + } }, []); const timelineSync = useTimelineSync({ @@ -420,8 +434,11 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // Ensure auto-scroll to bottom doesn't fire while we're navigating to a + // specific event — atBottom will be updated correctly once the user scrolls. + setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + }, [eventId, room.roomId, setAtBottom]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index c10b762b8..4a7f82192 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; -import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import type { @@ -467,9 +467,6 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( @@ -489,9 +486,6 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(mEvt.getSender() === mx.getUserId() ? 'instant' : 'smooth'); - lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; - setTimeline((ct) => ({ ...ct })); return; } @@ -501,7 +495,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] ) ); @@ -526,10 +520,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - if (wasAtBottom) { - scrollToBottom('instant'); - } - }, [room, isAtBottomRef, scrollToBottom]) + // Scroll is handled by the useLayoutEffect auto-scroll recovery which + // fires after React commits the new timeline state — scrolling here + // would operate on the pre-commit DOM with a stale scrollSize. + }, [room, isAtBottomRef]) ); useRelationUpdate( @@ -546,7 +540,9 @@ export function useTimelineSync({ }, []) ); - useEffect(() => { + // useLayoutEffect so scroll fires before paint — prevents the one-frame flash + // where new VList content is briefly visible at the wrong position. + useLayoutEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index cea5b1f78..bb526291b 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -43,9 +43,9 @@ export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; // Dynamic list key used for space-scoped room views. export const LIST_SPACE = 'space'; -// One event of timeline per list room is enough to compute unread counts; -// the full history is loaded when the user opens the room. -const LIST_TIMELINE_LIMIT = 1; +// Higher limit avoids empty previews when the most-recent events are +// reactions/edits/state that useRoomLatestRenderedEvent skips over. +const LIST_TIMELINE_LIMIT = 3; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; From 14d4745ea5056c4e7db70ebfe8d5423efc3f2e52 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 19:33:47 -0400 Subject: [PATCH 027/185] fix(timeline): restore upstream scroll pattern for new messages Restore scrollToBottom call in useLiveEventArrive with instant/smooth based on sender, add back eventsLengthRef and lastScrolledAt suppression, restore scrollToBottom in useLiveTimelineRefresh when wasAtBottom, and revert instant scrollToBottom to scrollTo(scrollSize) matching upstream. The previous changes removed all scroll calls from event arrival handlers and relied solely on the useLayoutEffect auto-scroll recovery, which has timing issues with VList measurement. Upstream's pattern of scrolling in the event handler and suppressing the effect works reliably. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 5 +---- src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 44f1b7c07..aacc9a6b5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -267,10 +267,7 @@ export function RoomTimeline({ if (behavior === 'smooth') { vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); } else { - // scrollToIndex works reliably regardless of VList measurement state. - // The auto-scroll useLayoutEffect fires after React commits new items, - // so lastIndex is always valid when this is called. - vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + vListRef.current.scrollTo(vListRef.current.scrollSize); } }, []); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 4a7f82192..e19851b1a 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -467,6 +467,9 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + useLiveEventArrive( room, useCallback( @@ -486,6 +489,9 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; + setTimeline((ct) => ({ ...ct })); return; } @@ -495,7 +501,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] ) ); @@ -520,10 +526,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - // Scroll is handled by the useLayoutEffect auto-scroll recovery which - // fires after React commits the new timeline state — scrolling here - // would operate on the pre-commit DOM with a stale scrollSize. - }, [room, isAtBottomRef]) + if (wasAtBottom) { + scrollToBottom('instant'); + } + }, [room, isAtBottomRef, scrollToBottom]) ); useRelationUpdate( From b6b0b7b1a454e8f791bb603e0a4954cfe4b04b56 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 028/185] fix(timeline): align scrollToBottom with upstream, fix eventId race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove behavior parameter from scrollToBottom — always use scrollTo(scrollSize) matching upstream. The smooth scrollToIndex was scrolling to stale lastIndex (before new item measured), leaving new messages below the fold. - Revert auto-scroll recovery from useLayoutEffect back to useEffect (matches upstream). useLayoutEffect fires before VList measures new items and before setAtBottom(false) in eventId effect. - Remove stale scrollCacheForRoomRef that referenced missing imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 15 +-------------- src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++--------- 3 files changed, 9 insertions(+), 24 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index aacc9a6b5..2022cc280 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,13 +213,6 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - // Load any cached scroll state for this room on mount. A fresh RoomTimeline is - // mounted per room (via key={roomId} in RoomView) so this is the only place we - // need to read the cache — the render-phase room-change block below only fires - // in the (hypothetical) case where the room prop changes without a remount. - const scrollCacheForRoomRef = useRef( - roomScrollCache.load(mxUserId, room.roomId) - ); const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -264,11 +257,7 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - if (behavior === 'smooth') { - vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); - } else { - vListRef.current.scrollTo(vListRef.current.scrollSize); - } + vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); const timelineSync = useTimelineSync({ @@ -431,8 +420,6 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - // Ensure auto-scroll to bottom doesn't fire while we're navigating to a - // specific event — atBottom will be updated correctly once the user scrolls. setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId, setAtBottom]); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..5dfc0c9ed 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -130,7 +130,7 @@ describe('useTimelineSync', () => { await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalledWith('instant'); + expect(scrollToBottom).toHaveBeenCalled(); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index e19851b1a..fb7976462 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; -import { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import type { @@ -352,7 +352,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: (behavior?: 'instant' | 'smooth') => void; + scrollToBottom: () => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -461,7 +461,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -546,9 +546,7 @@ export function useTimelineSync({ }, []) ); - // useLayoutEffect so scroll fires before paint — prevents the one-frame flash - // where new VList content is briefly visible at the wrong position. - useLayoutEffect(() => { + useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; @@ -566,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { From c6fd535c1a21b972d812d441aa85d819362cc658 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:49:00 -0400 Subject: [PATCH 029/185] fix(bookmarks): remove unrelated branch spillover --- config.json | 9 ------- src/app/features/room/RoomTimeline.tsx | 5 ++-- src/app/features/room/message/Message.tsx | 4 +-- .../hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 10 ++++---- src/app/hooks/useRoomNavigate.ts | 25 ++++++++----------- src/app/pages/client/ClientNonUIFeatures.tsx | 8 ++---- src/app/pages/client/home/Home.tsx | 4 +-- src/app/pages/client/inbox/Inbox.tsx | 6 +---- src/client/slidingSync.ts | 6 ++--- 10 files changed, 26 insertions(+), 53 deletions(-) diff --git a/config.json b/config.json index 4f98f3f51..2809e4f68 100644 --- a/config.json +++ b/config.json @@ -20,15 +20,6 @@ "enabled": true }, - "experiments": { - "messageBookmarks": { - "enabled": false, - "rolloutPercentage": 0, - "controlVariant": "control", - "variants": ["enabled"] - } - }, - "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2022cc280..d63faa989 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,7 +213,7 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(!eventId); + const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -420,9 +420,8 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId, setAtBottom]); + }, [eventId, room.roomId]); useEffect(() => { if (eventId) return; diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 7d65c4065..cbd6da05a 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -73,7 +73,6 @@ import { MessageForwardItem } from '$components/message/modals/MessageForward'; import { MessageDeleteItem } from '$components/message/modals/MessageDelete'; import { computeBookmarkId, createBookmarkItem } from '$features/bookmarks/bookmarkDomain'; import { useIsBookmarked, useBookmarkActions } from '$features/bookmarks/useBookmarks'; -import { useExperimentVariant } from '$hooks/useClientConfig'; import { MessageReportItem } from '$components/message/modals/MessageReport'; import { filterPronounsByLanguage, getParsedPronouns } from '$utils/pronouns'; import type { PronounSet } from '$utils/pronouns'; @@ -212,14 +211,13 @@ export const MessageBookmarkItem = as< } >(({ room, mEvent, onClose, ...props }, ref) => { const mx = useMatrixClient(); - const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); const eventId = mEvent.getId(); const isBookmarked = useIsBookmarked(room.roomId, eventId ?? ''); const { add, remove } = useBookmarkActions(); if (!eventId) return null; - if (!bookmarksExperiment.inExperiment && !enableMessageBookmarks) return null; + if (!enableMessageBookmarks) return null; const handleClick = async () => { if (isBookmarked) { diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 5dfc0c9ed..b9d253c6a 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -130,7 +130,7 @@ describe('useTimelineSync', () => { await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalled(); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index fb7976462..0abfc8a91 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -352,7 +352,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: () => void; + scrollToBottom: (behavior?: 'instant' | 'smooth') => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -461,7 +461,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom(); + scrollToBottom('instant'); }, [alive, room, scrollToBottom]) ); @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(); + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom(); + scrollToBottom('instant'); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -564,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom(); + scrollToBottom('instant'); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 900110610..8e4abb172 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -38,20 +38,7 @@ export const useRoomNavigate = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - // Developer-mode: view the space's own timeline (must be checked first). - if (openSpaceTimeline) { - navigate(getSpaceRoomPath(roomIdOrAlias, roomId, eventId), opts); - return; - } - - // DMs take priority over space membership so direct chats always open - // via the direct route, even when the room also belongs to a space. - if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); - return; - } - - const orphanParents = getOrphanParents(roomToParents, roomId); + const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -62,7 +49,15 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); + navigate( + getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), + opts + ); + return; + } + + if (mDirects.has(roomId)) { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); return; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index efba2feb5..833f5e211 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -531,12 +531,8 @@ function MessageNotifications() { }); } - // In-app audio: play when the app is in the foreground (has focus) and - // notification sounds are enabled for this notification type. - // Gating on hasFocus() rather than just visibilityState prevents a race - // where the page is still 'visible' for a brief window after the user - // backgrounds the app on mobile — hasFocus() flips false first. - if (notificationSound && isLoud && document.hasFocus()) { + // In-app audio: play when notification sounds are enabled AND this notification is loud. + if (notificationSound && isLoud) { playSound(); } }; diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 22934b1df..8e16b5c79 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -60,7 +60,6 @@ import { useClosedNavCategoriesAtom } from '$state/hooks/closedNavCategories'; import { stopPropagation } from '$utils/keyboard'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { useExperimentVariant } from '$hooks/useClientConfig'; import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, @@ -210,9 +209,8 @@ export function Home() { const createRoomSelected = useHomeCreateSelected(); const searchSelected = useHomeSearchSelected(); const bookmarksSelected = useHomeBookmarksSelected(); - const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); - const showBookmarks = bookmarksExperiment.inExperiment || enableMessageBookmarks; + const showBookmarks = enableMessageBookmarks; const noRoomToDisplay = rooms.length === 0; const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); diff --git a/src/app/pages/client/inbox/Inbox.tsx b/src/app/pages/client/inbox/Inbox.tsx index d594a928e..ab42f7dcb 100644 --- a/src/app/pages/client/inbox/Inbox.tsx +++ b/src/app/pages/client/inbox/Inbox.tsx @@ -15,10 +15,8 @@ import { UnreadBadge } from '$components/unread-badge'; import { allInvitesAtom } from '$state/room-list/inviteList'; import { useNavToActivePathMapper } from '$hooks/useNavToActivePathMapper'; import { PageNav, PageNavContent, PageNavHeader } from '$components/page'; -import { useMatrixClient } from '$hooks/useMatrixClient'; import { useSetting } from '$state/hooks/settings'; import { settingsAtom } from '$state/settings'; -import { useExperimentVariant } from '$hooks/useClientConfig'; function InvitesNavItem() { const invitesSelected = useInboxInvitesSelected(); @@ -76,11 +74,9 @@ function BookmarksNavItem() { export function Inbox() { useNavToActivePathMapper('inbox'); - const mx = useMatrixClient(); const notificationsSelected = useInboxNotificationsSelected(); - const bookmarksExperiment = useExperimentVariant('messageBookmarks', mx.getUserId() ?? undefined); const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); - const showBookmarks = bookmarksExperiment.inExperiment || enableMessageBookmarks; + const showBookmarks = enableMessageBookmarks; return ( diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index bb526291b..cea5b1f78 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -43,9 +43,9 @@ export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; // Dynamic list key used for space-scoped room views. export const LIST_SPACE = 'space'; -// Higher limit avoids empty previews when the most-recent events are -// reactions/edits/state that useRoomLatestRenderedEvent skips over. -const LIST_TIMELINE_LIMIT = 3; +// One event of timeline per list room is enough to compute unread counts; +// the full history is loaded when the user opens the room. +const LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; From 1fd8b9cddd47657a4355f830d9e24c0abba6cb05 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:50:42 -0400 Subject: [PATCH 030/185] chore(bookmarks): fix branch validation issues --- src/app/features/bookmarks/useInitBookmarks.test.tsx | 11 ++++------- src/app/features/room/message/Message.tsx | 1 - 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/app/features/bookmarks/useInitBookmarks.test.tsx b/src/app/features/bookmarks/useInitBookmarks.test.tsx index 893b5d64c..afb24f953 100644 --- a/src/app/features/bookmarks/useInitBookmarks.test.tsx +++ b/src/app/features/bookmarks/useInitBookmarks.test.tsx @@ -44,9 +44,9 @@ const { accountDataCB, syncStateCB, mockMx } = vi.hoisted(() => { }; const store: Record = { - ['org.matrix.msc4438.bookmarks.index']: index, - ['org.matrix.msc4438.bookmark.bmk_aabb']: item, - ['org.matrix.msc4438.bookmark.bmk_ccdd']: deletedItem, + 'org.matrix.msc4438.bookmarks.index': index, + 'org.matrix.msc4438.bookmark.bmk_aabb': item, + 'org.matrix.msc4438.bookmark.bmk_ccdd': deletedItem, }; const mx = { @@ -67,10 +67,7 @@ vi.mock('$hooks/useMatrixClient', () => ({ })); vi.mock('$hooks/useAccountDataCallback', () => ({ - useAccountDataCallback: ( - _mx: unknown, - cb: (event: { getType: () => string }) => void - ) => { + useAccountDataCallback: (_mx: unknown, cb: (event: { getType: () => string }) => void) => { accountDataCB.current = cb; }, })); diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index cbd6da05a..529e07f86 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -210,7 +210,6 @@ export const MessageBookmarkItem = as< onClose?: () => void; } >(({ room, mEvent, onClose, ...props }, ref) => { - const mx = useMatrixClient(); const [enableMessageBookmarks] = useSetting(settingsAtom, 'enableMessageBookmarks'); const eventId = mEvent.getId(); const isBookmarked = useIsBookmarked(room.roomId, eventId ?? ''); From 4020c0823732c384421e407474d35a17e21a61e5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:43:30 -0400 Subject: [PATCH 031/185] feat(polls): implement MSC3381 polls with creator dialog and timeline renderer --- config.json | 7 +- src/app/features/room/RoomInput.tsx | 67 ++- .../room/poll/PollCreatorDialog.css.ts | 49 ++ .../features/room/poll/PollCreatorDialog.tsx | 383 ++++++++++++++ src/app/features/room/poll/PollEvent.css.ts | 40 ++ src/app/features/room/poll/PollEvent.tsx | 479 ++++++++++++++++++ src/app/features/room/poll/index.ts | 3 + .../hooks/timeline/useProcessedTimeline.ts | 10 + .../timeline/useTimelineEventRenderer.tsx | 76 +++ src/app/hooks/useClientConfig.ts | 4 + src/app/hooks/useCommands.ts | 7 + src/types/matrix-sdk.ts | 15 + 12 files changed, 1126 insertions(+), 14 deletions(-) create mode 100644 src/app/features/room/poll/PollCreatorDialog.css.ts create mode 100644 src/app/features/room/poll/PollCreatorDialog.tsx create mode 100644 src/app/features/room/poll/PollEvent.css.ts create mode 100644 src/app/features/room/poll/PollEvent.tsx create mode 100644 src/app/features/room/poll/index.ts diff --git a/config.json b/config.json index 2809e4f68..b5870a5b1 100644 --- a/config.json +++ b/config.json @@ -3,10 +3,8 @@ "homeserverList": ["matrix.org", "mozilla.org", "unredacted.org", "sable.moe", "kendama.moe"], "allowCustomHomeservers": true, "elementCallUrl": null, - "disableAccountSwitcher": false, "hideUsernamePasswordFields": false, - "pushNotificationDetails": { "pushNotifyUrl": "https://sygnal.sable.moe/_matrix/push/v1/notify", "vapidPublicKey": "BCnS4SbHjeOaqVFW4wjt5xDt_pYIL62qMzKePfYF9fl9PQU14RieIaObh7nLR_9dQf4sykZa-CTrcjkgMIE1mcg", @@ -19,7 +17,6 @@ "slidingSync": { "enabled": true }, - "featuredCommunities": { "openAsDefault": false, "spaces": [ @@ -40,9 +37,11 @@ ], "servers": ["matrixrooms.info", "mozilla.org", "unredacted.org"] }, - "hashRouter": { "enabled": false, "basename": "/" + }, + "features": { + "polls": false } } diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 850cffb50..5c4a21d14 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -121,6 +121,7 @@ import { stopPropagation } from '$utils/keyboard'; import { usePowerLevelsContext } from '$hooks/usePowerLevels'; import { useRoomCreators } from '$hooks/useRoomCreators'; import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { useClientConfig } from '$hooks/useClientConfig'; import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { convertPerMessageProfileToBeeperFormat, @@ -141,6 +142,8 @@ import { SerializableMap } from '$types/wrapper/SerializableMap'; import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; +import { PollCreatorDialog } from './poll'; +import type { PollCreatorContent } from './poll'; import { getAudioMsgContent, getFileMsgContent, @@ -379,6 +382,9 @@ export const RoomInput = forwardRef( ); const [scheduleMenuAnchor, setScheduleMenuAnchor] = useState(); const [showSchedulePicker, setShowSchedulePicker] = useState(false); + const [showPollCreator, setShowPollCreator] = useState(false); + const clientConfig = useClientConfig(); + const pollsEnabled = clientConfig.features?.polls ?? false; const [silentReply, setSilentReply] = useState(!mentionInReplies); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const isEncrypted = room.hasEncryptionStateEvent(); @@ -806,6 +812,12 @@ export const RoomInput = forwardRef( } else if (commandName === Command.UnFlip) { plainText = `${UNFLIP} ${plainText}`; customHtml = `${UNFLIP} ${customHtml}`; + } else if (commandName === Command.Poll) { + if (pollsEnabled) setShowPollCreator(true); + resetEditor(editor); + resetEditorHistory(editor); + sendTypingStatus(false); + return; } else if (commandName) { const commandContent = commands[commandName as Command]; if (commandContent) { @@ -1055,6 +1067,8 @@ export const RoomInput = forwardRef( isEncrypted, setEditingScheduledDelayId, setScheduledTime, + pollsEnabled, + setShowPollCreator, ]); const handleKeyDown: KeyboardEventHandler = useCallback( @@ -1478,16 +1492,18 @@ export const RoomInput = forwardRef( } before={ - pickFile('*')} - variant="SurfaceVariant" - size="300" - radii="300" - title="Upload File" - aria-label="Upload and attach a File" - > - - + + pickFile('*')} + variant="SurfaceVariant" + size="300" + radii="300" + title="Upload File" + aria-label="Upload and attach a File" + > + + + } after={ <> @@ -1729,6 +1745,37 @@ export const RoomInput = forwardRef( }} /> )} + {showPollCreator && ( + setShowPollCreator(false)} + onSubmit={(content: PollCreatorContent) => { + setShowPollCreator(false); + const pollKindKey = content.kind; + const eventContent: Record = { + 'org.matrix.msc1767.text': content.question, + 'org.matrix.msc3381.poll.start': { + question: { + 'org.matrix.msc1767.text': content.question, + }, + kind: pollKindKey, + max_selections: content.maxSelections, + answers: content.answers.map((a) => ({ + id: a.id, + 'org.matrix.msc1767.text': a.text, + })), + show_voter_names: content.showVoterNames, + ...(content.closesAt !== undefined ? { closes_at: content.closesAt } : {}), + }, + }; + (mx as any).sendEvent(roomId, 'org.matrix.msc3381.poll.start', eventContent).catch( + // unstable MSC3381 type + (err: unknown) => { + console.error('Failed to send poll:', err); + } + ); + }} + /> + )} ); } diff --git a/src/app/features/room/poll/PollCreatorDialog.css.ts b/src/app/features/room/poll/PollCreatorDialog.css.ts new file mode 100644 index 000000000..7bae11054 --- /dev/null +++ b/src/app/features/room/poll/PollCreatorDialog.css.ts @@ -0,0 +1,49 @@ +import { style } from '@vanilla-extract/css'; +import { color, config, toRem } from 'folds'; + +export const DialogContent = style({ + padding: config.space.S400, + minWidth: toRem(340), + maxWidth: toRem(500), + display: 'flex', + flexDirection: 'column', + gap: config.space.S300, + maxHeight: `min(80vh, ${toRem(600)})`, + overflowY: 'auto', +}); + +export const AnswerRow = style({ + display: 'flex', + alignItems: 'center', + gap: config.space.S200, +}); + +export const AnswerInput = style({ + flex: 1, +}); + +export const KindSelector = style({ + display: 'flex', + gap: config.space.S200, +}); + +export const ExpirySelector = style({ + display: 'flex', + flexWrap: 'wrap', + gap: config.space.S100, +}); + +export const DatetimeInput = style({ + padding: `${config.space.S100} ${config.space.S200}`, + borderRadius: config.radii.R300, + border: `1px solid ${color.SurfaceVariant.ContainerLine}`, + background: color.SurfaceVariant.Container, + color: 'inherit', + fontSize: config.fontSize.T300, + outline: 'none', + selectors: { + '&:focus': { + borderColor: color.Primary.Main, + }, + }, +}); diff --git a/src/app/features/room/poll/PollCreatorDialog.tsx b/src/app/features/room/poll/PollCreatorDialog.tsx new file mode 100644 index 000000000..e9a1b8ba8 --- /dev/null +++ b/src/app/features/room/poll/PollCreatorDialog.tsx @@ -0,0 +1,383 @@ +import { FormEventHandler, useId, useMemo, useRef, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Chip, + Dialog, + Header, + Icon, + IconButton, + Icons, + Input, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, + config, +} from 'folds'; +import { stopPropagation } from '$utils/keyboard'; +import { M_POLL_KIND_DISCLOSED, M_POLL_KIND_UNDISCLOSED } from '$types/matrix-sdk'; +import * as css from './PollCreatorDialog.css'; + +const MAX_ANSWERS = 20; +const MIN_ANSWERS = 2; + +type ExpiryPreset = 'none' | '1h' | '12h' | '24h' | '48h' | '1w' | 'custom'; + +const EXPIRY_PRESETS: { value: ExpiryPreset; label: string }[] = [ + { value: 'none', label: 'No limit' }, + { value: '1h', label: '1 hour' }, + { value: '12h', label: '12 hours' }, + { value: '24h', label: '24 hours' }, + { value: '48h', label: '48 hours' }, + { value: '1w', label: '1 week' }, + { value: 'custom', label: 'Custom…' }, +]; + +const HOUR_MS = 3_600_000; + +export type PollCreatorContent = { + question: string; + answers: Array<{ id: string; text: string }>; + kind: string; + maxSelections: number; + showVoterNames: boolean; + closesAt?: number; +}; + +type PollCreatorDialogProps = { + onCancel: () => void; + onSubmit: (content: PollCreatorContent) => void; +}; + +export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps) { + const questionId = useId(); + const [question, setQuestion] = useState(''); + const [answers, setAnswers] = useState<{ id: string; text: string }[]>(() => [ + { id: crypto.randomUUID(), text: '' }, + { id: crypto.randomUUID(), text: '' }, + ]); + const [kind, setKind] = useState( + M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed' + ); + const [showVoterNames, setShowVoterNames] = useState(true); + const [expiryPreset, setExpiryPreset] = useState('none'); + const [customExpiry, setCustomExpiry] = useState(''); + const [error, setError] = useState(); + const lastInputRef = useRef(null); + + const minDatetime = useMemo( + () => new Date(Date.now() + 60_000).toISOString().slice(0, 16), + // eslint-disable-next-line react-hooks/exhaustive-deps + [expiryPreset] + ); + + const computeClosesAt = (): number | undefined => { + const now = Date.now(); + switch (expiryPreset) { + case '1h': + return now + HOUR_MS; + case '12h': + return now + 12 * HOUR_MS; + case '24h': + return now + 24 * HOUR_MS; + case '48h': + return now + 48 * HOUR_MS; + case '1w': + return now + 7 * 24 * HOUR_MS; + case 'custom': { + const ts = customExpiry ? new Date(customExpiry).getTime() : NaN; + return Number.isFinite(ts) && ts > Date.now() ? ts : undefined; + } + default: + return undefined; + } + }; + + const handleAddAnswer = () => { + if (answers.length >= MAX_ANSWERS) return; + setAnswers((prev) => [...prev, { id: crypto.randomUUID(), text: '' }]); + // Focus the new answer field on next render + setTimeout(() => lastInputRef.current?.focus(), 0); + }; + + const handleRemoveAnswer = (id: string) => { + setAnswers((prev) => prev.filter((a) => a.id !== id)); + }; + + const handleAnswerChange = (id: string, value: string) => { + setAnswers((prev) => prev.map((a) => (a.id === id ? { ...a, text: value } : a))); + }; + + const handleSubmit: FormEventHandler = (evt) => { + evt.preventDefault(); + const trimmedQuestion = question.trim(); + if (!trimmedQuestion) { + setError('Please enter a question.'); + return; + } + const validAnswers = answers.map((a) => ({ ...a, text: a.text.trim() })).filter((a) => a.text); + if (validAnswers.length < MIN_ANSWERS) { + setError(`Please add at least ${MIN_ANSWERS} answers.`); + return; + } + if (expiryPreset === 'custom') { + const ts = customExpiry ? new Date(customExpiry).getTime() : NaN; + if (!Number.isFinite(ts) || ts <= Date.now()) { + setError('Please choose a future date and time for the custom expiry.'); + return; + } + } + setError(undefined); + onSubmit({ + question: trimmedQuestion, + answers: validAnswers, + kind, + maxSelections: 1, + showVoterNames, + closesAt: computeClosesAt(), + }); + }; + + return ( + }> + + + +
+ + Create Poll + + + + +
+ +
+
+ {/* Question */} + + + Question + + setQuestion((e.target as HTMLInputElement).value)} + placeholder="Ask a question…" + required + maxLength={340} + autoComplete="off" + /> + + + {/* Answers */} + + + Answers + + {answers.map((answer, index) => ( +
+ + handleAnswerChange(answer.id, (e.target as HTMLInputElement).value) + } + placeholder={`Option ${index + 1}`} + maxLength={340} + autoComplete="off" + /> + {answers.length > MIN_ANSWERS && ( + handleRemoveAnswer(answer.id)} + > + + + )} +
+ ))} + {answers.length < MAX_ANSWERS && ( + + )} +
+ + {/* Poll kind */} + + + Results visibility + +
+ + setKind( + M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed' + ) + } + aria-pressed={ + kind === + (M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed') + } + > + Show live results + + + setKind( + M_POLL_KIND_UNDISCLOSED.altName ?? 'org.matrix.msc3381.poll.undisclosed' + ) + } + aria-pressed={ + kind === + (M_POLL_KIND_UNDISCLOSED.altName ?? 'org.matrix.msc3381.poll.undisclosed') + } + > + Hide until closed + +
+
+ + {/* Voter visibility */} + + + Voter visibility + +
+ setShowVoterNames(true)} + aria-pressed={showVoterNames} + > + Show voters + + setShowVoterNames(false)} + aria-pressed={!showVoterNames} + > + Hide voters + +
+
+ + {/* Poll duration */} + + + Poll duration + +
+ {EXPIRY_PRESETS.map((p) => ( + setExpiryPreset(p.value)} + aria-pressed={expiryPreset === p.value} + > + {p.label} + + ))} +
+ {expiryPreset === 'custom' && ( + setCustomExpiry((e.target as HTMLInputElement).value)} + /> + )} +
+ + {error && ( + + {error} + + )} + + {/* Actions */} + + + + +
+
+
+
+
+
+ ); +} diff --git a/src/app/features/room/poll/PollEvent.css.ts b/src/app/features/room/poll/PollEvent.css.ts new file mode 100644 index 000000000..26e5433f9 --- /dev/null +++ b/src/app/features/room/poll/PollEvent.css.ts @@ -0,0 +1,40 @@ +import { style } from '@vanilla-extract/css'; +import { config } from 'folds'; + +// Vote button wrapping just the radio circle - minimal touch target +export const RadioZone = style({ + all: 'unset', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: `${config.space.S100} 0`, + selectors: { + '&:disabled': { + cursor: 'default', + }, + }, +}); + +// Text + percent area - clickable to reveal voters +export const AnswerTextButton = style({ + all: 'unset', + cursor: 'pointer', + display: 'flex', + flex: 1, + alignItems: 'center', + gap: config.space.S200, + minWidth: 0, + padding: `${config.space.S100} 0`, +}); + +// Non-interactive version of the text area +export const AnswerTextRow = style({ + display: 'flex', + flex: 1, + alignItems: 'center', + gap: config.space.S200, + minWidth: 0, + padding: `${config.space.S100} 0`, +}); diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx new file mode 100644 index 000000000..0ebfe58b1 --- /dev/null +++ b/src/app/features/room/poll/PollEvent.tsx @@ -0,0 +1,479 @@ +import { type ReactNode, useCallback, useEffect, useMemo, useReducer, useState } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + config, + Icon, + Icons, + Line, + Menu, + PopOut, + ProgressBar, + RadioButton, + Scroll, + Text, + toRem, +} from 'folds'; +import { + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_RESPONSE, + M_POLL_START, + MatrixEvent, + Room, + RoomEvent, +} from '$types/matrix-sdk'; +import { useMatrixClient } from '$hooks/useMatrixClient'; +import { stopPropagation } from '$utils/keyboard'; +import { + Attachment, + AttachmentBox, + AttachmentContent, + AttachmentHeader, +} from '$components/message/attachment/Attachment'; +import { MessageEvent } from '$types/matrix/room'; +import * as css from './PollEvent.css'; + +type PollAnswer = { id: string; text: string }; + +function extractPollData(mEvent: MatrixEvent): { + question: string; + answers: PollAnswer[]; + maxSelections: number; + isDisclosed: boolean; + showVoterNames: boolean; + closesAt: number | undefined; +} | null { + const content = mEvent.getContent(); + const pollStartKey = M_POLL_START.altName ?? 'org.matrix.msc3381.poll.start'; + const pollData = content[M_POLL_START.name] ?? content[pollStartKey]; + if (!pollData) return null; + + const questionText = + (pollData.question?.['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? + (pollData.question?.['org.matrix.msc1767.text'] as string | undefined) ?? + ''; + const rawAnswers: { + id?: string; + 'm.id'?: string; + 'org.matrix.msc1767.text'?: string; + 'm.text'?: { body: string }[]; + }[] = pollData.answers ?? []; + const answers: PollAnswer[] = rawAnswers.slice(0, 20).map((a) => ({ + id: String(a['m.id'] ?? a.id ?? ''), + text: + (a['m.text'] as { body: string }[] | undefined)?.[0]?.body ?? + a['org.matrix.msc1767.text'] ?? + '', + })); + const maxSelections = + typeof pollData.max_selections === 'number' && pollData.max_selections >= 1 + ? pollData.max_selections + : 1; + const kind = pollData.kind ?? ''; + const isDisclosed = + kind === M_POLL_KIND_DISCLOSED.name || + kind === (M_POLL_KIND_DISCLOSED.altName ?? 'org.matrix.msc3381.poll.disclosed'); + const showVoterNames = pollData.show_voter_names !== false; + const rawClosesAt = pollData.closes_at; + const closesAt = typeof rawClosesAt === 'number' && rawClosesAt > 0 ? rawClosesAt : undefined; + return { question: questionText, answers, maxSelections, isDisclosed, showVoterNames, closesAt }; +} + +function extractVoteSelections(responseEvent: MatrixEvent): string[] { + const content = responseEvent.getContent(); + const unstablePayload = content['org.matrix.msc3381.poll.response']; + const selections: unknown = + content['m.selections'] ?? + (typeof unstablePayload === 'object' && unstablePayload !== null + ? (unstablePayload as { answers?: unknown }).answers + : undefined); + if (!Array.isArray(selections)) return []; + return selections.filter((s): s is string => typeof s === 'string'); +} + +type TallyResult = { + tally: Map>; + myVote: string[]; + isEnded: boolean; +}; + +function computeTally( + room: Room, + pollEventId: string, + pollStartEvent: MatrixEvent, + answers: PollAnswer[], + maxSelections: number, + myUserId: string +): TallyResult { + const childEvents = room + .getUnfilteredTimelineSet() + .relations.getAllChildEventsForEvent(pollEventId); + + const userVotes = new Map(); + const validAnswerIds = new Set(answers.map((a) => a.id)); + const pollCreator = pollStartEvent.getSender(); + let isEnded = false; + let endTs: number | undefined; + + childEvents.forEach((event) => { + if (M_POLL_END.matches(event.getType())) { + const sender = event.getSender(); + if (!sender) return; + const ts = event.getTs(); + if ( + sender !== pollCreator && + !room.currentState.maySendRedactionForEvent(pollStartEvent, sender) + ) + return; + if (endTs !== undefined && endTs <= ts) return; + endTs = ts; + isEnded = true; + } + if (M_POLL_RESPONSE.matches(event.getType())) { + if (event.isDecryptionFailure()) return; + const sender = event.getSender(); + if (!sender) return; + const ts = event.getTs(); + const existing = userVotes.get(sender); + if (existing && existing.ts >= ts) return; + userVotes.set(sender, { ts, selections: extractVoteSelections(event) }); + } + }); + + const cutoff = endTs ?? Number.MAX_SAFE_INTEGER; + const tally = new Map>(answers.map((a) => [a.id, new Set()])); + userVotes.forEach(({ ts, selections }, userId) => { + if (ts > cutoff) return; + const valid = selections.slice(0, maxSelections); + if (!valid.every((s) => validAnswerIds.has(s))) return; + valid.forEach((sel) => tally.get(sel)?.add(userId)); + }); + + const myEntry = userVotes.get(myUserId); + let myVote: string[] = []; + if (myEntry && myEntry.ts <= cutoff) { + const myValid = myEntry.selections.slice(0, maxSelections); + if (myValid.every((s) => validAnswerIds.has(s))) myVote = myValid; + } + + return { tally, myVote, isEnded }; +} + +function formatExpiry(ts: number): string { + const diff = ts - Date.now(); + if (diff <= 0) return 'now'; + const hours = diff / 3_600_000; + if (hours < 1) return `in ${Math.round(diff / 60_000)} min`; + if (hours < 24) return `in ${Math.round(hours)} hr`; + const days = hours / 24; + if (days < 7) return `in ${Math.round(days)} day${Math.round(days) === 1 ? '' : 's'}`; + return new Date(ts).toLocaleDateString(); +} + +type PollEventProps = { + room: Room; + mEvent: MatrixEvent; + canEnd: boolean; + outlined?: boolean; +}; + +export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) { + const mx = useMatrixClient(); + const myUserId = mx.getUserId() ?? ''; + const pollEventId = mEvent.getId() ?? ''; + const [tick, incrementTick] = useReducer((n: number) => n + 1, 0); + const [, forceExpiry] = useReducer((n: number) => n + 1, 0); + + const pollData = useMemo(() => extractPollData(mEvent), [mEvent]); + + // Re-compute tally whenever a new response/end event lands + useEffect(() => { + const onTimeline = (event: MatrixEvent) => { + const relTo = event.getContent()?.['m.relates_to']?.event_id; + if (relTo === pollEventId) incrementTick(); + }; + room.on(RoomEvent.Timeline, onTimeline); + return () => { + room.off(RoomEvent.Timeline, onTimeline); + }; + }, [room, pollEventId]); + + // Re-render when the expiry countdown reaches zero + useEffect(() => { + if (!pollData?.closesAt) return undefined; + const remaining = pollData.closesAt - Date.now(); + if (remaining <= 0) return undefined; + const timer = setTimeout(forceExpiry, remaining); + return () => clearTimeout(timer); + }, [pollData?.closesAt]); + + const { tally, myVote, isEnded } = useMemo( + () => + pollData + ? computeTally( + room, + pollEventId, + mEvent, + pollData.answers, + pollData.maxSelections, + myUserId + ) + : { tally: new Map>(), myVote: [] as string[], isEnded: false }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [room, pollEventId, mEvent, pollData, myUserId, tick] + ); + + const isExpiredByTime = pollData?.closesAt !== undefined && Date.now() >= pollData.closesAt; + const effectivelyEnded = isEnded || isExpiredByTime; + const showResults = effectivelyEnded || (pollData?.isDisclosed ?? false); + + const totalVoters = useMemo( + () => new Set([...tally.values()].flatMap((s) => [...s])).size, + [tally] + ); + + const handleAnswerClick = useCallback( + (answerId: string) => { + if (effectivelyEnded || !pollData) return; + const { maxSelections } = pollData; + let next: string[]; + if (maxSelections === 1) { + next = myVote[0] === answerId ? [] : [answerId]; + } else if (myVote.includes(answerId)) { + next = myVote.filter((id) => id !== answerId); + } else { + next = [...myVote, answerId].slice(0, maxSelections); + } + const selections: Record = { 'm.selections': next }; + mx.sendEvent(room.roomId, MessageEvent.PollResponse as any, { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + ...selections, + 'org.matrix.msc3381.poll.response': { answers: next }, + }).catch(() => undefined); + }, + [effectivelyEnded, pollData, myVote, mx, room.roomId, pollEventId] + ); + + const endPoll = useCallback(() => { + mx.sendEvent(room.roomId, MessageEvent.PollEnd as any, { + 'm.relates_to': { rel_type: 'm.reference', event_id: pollEventId }, + 'org.matrix.msc3381.poll.end': {}, + body: 'The poll has ended', + }).catch(() => undefined); + }, [mx, room.roomId, pollEventId]); + + const [expandedVoters, setExpandedVoters] = useState<{ id: string; anchor: DOMRect } | null>( + null + ); + const toggleVoters = useCallback( + (id: string, anchor: DOMRect) => + setExpandedVoters((prev) => (prev?.id === id ? null : { id, anchor })), + [] + ); + const canShowVoters = (pollData?.showVoterNames ?? false) && showResults; + + if (!pollData) return null; + + const { question, answers, isDisclosed, closesAt } = pollData; + const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`; + + let statusText: string; + if (isEnded) statusText = `Poll ended · ${voterLabel}`; + else if (isExpiredByTime) statusText = `Poll expired · ${voterLabel}`; + else if (closesAt !== undefined && !isDisclosed) + statusText = `${voterLabel} · Results hidden until closed · Closes ${formatExpiry(closesAt)}`; + else if (closesAt !== undefined) statusText = `${voterLabel} · Closes ${formatExpiry(closesAt)}`; + else if (!isDisclosed) statusText = `${voterLabel} · Results hidden until closed`; + else statusText = voterLabel; + + return ( + + + + + {isDisclosed ? 'Poll' : 'Undisclosed Poll'} + + + + {voterLabel} + + + + + + {question || '(no question)'} + + + {answers.map((answer) => { + const voteCount = tally.get(answer.id)?.size ?? 0; + const percent = totalVoters > 0 ? Math.round((voteCount / totalVoters) * 100) : 0; + const isSelected = myVote.includes(answer.id); + + let textZone: ReactNode; + if (canShowVoters && voteCount > 0) { + textZone = ( + + ); + } else if (!effectivelyEnded) { + textZone = ( + + ); + } else { + textZone = ( + + + {answer.text} + + {showResults && ( + + {percent}% + + )} + + ); + } + + return ( + + + + {textZone} + + {showResults && ( + + )} + + ); + })} + + + + + {statusText} + + {!effectivelyEnded && canEnd && ( + + )} + + + + + {expandedVoters && canShowVoters && ( + setExpandedVoters(null), + clickOutsideDeactivates: true, + escapeDeactivates: stopPropagation, + }} + > + + + + Voters + + + + {[...(tally.get(expandedVoters.id) ?? [])].map((userId) => ( + + + {room.getMember(userId)?.name ?? userId} + + + ))} + + + + + + } + /> + )} + + ); +} diff --git a/src/app/features/room/poll/index.ts b/src/app/features/room/poll/index.ts new file mode 100644 index 000000000..3bcd8f5df --- /dev/null +++ b/src/app/features/room/poll/index.ts @@ -0,0 +1,3 @@ +export { PollEvent } from './PollEvent'; +export { PollCreatorDialog } from './PollCreatorDialog'; +export type { PollCreatorContent } from './PollCreatorDialog'; diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 9609dafc0..18d3d7ab6 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -113,6 +113,16 @@ export function useProcessedTimeline({ if (!membershipChanged && hideNickAvatarEvents) return acc; } + // Poll response and end events are always filtered — they update the poll tally + // via RoomEvent.Timeline listeners in PollEvent and must never render as timeline items. + if ( + type === 'org.matrix.msc3381.poll.response' || + type === 'org.matrix.msc3381.poll.end' || + type === 'm.poll.response' || + type === 'm.poll.end' + ) + return acc; + if (!showHiddenEvents) { const isStandardRendered = [ 'm.room.message', diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index b4d54d825..517be08d1 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -56,6 +56,7 @@ import * as customHtmlCss from '$styles/CustomHtml.css'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import type { ForwardedMessageProps } from '$features/room/message'; import { EncryptedContent, Event, Message, Reactions } from '$features/room/message'; +import { PollEvent } from '$features/room/poll'; import { useSableCosmetics } from '$hooks/useSableCosmetics'; @@ -1132,6 +1133,81 @@ export function useTimelineEventRenderer({ ); }, + [MessageEvent.PollStart]: (mEventId, mEvent, item, timelineSet, collapse) => { + const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent; + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = getSender.call(mEvent) ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const myUserId = mx.getUserId() ?? ''; + const canEnd = myUserId === senderId || canRedact; + + return ( + + ) : undefined + } + hideReadReceipts={hideReads} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {isRedacted.call(mEvent) ? ( + + ) : ( + + )} + + ); + }, + // Poll response and end events are not rendered individually — + // they update the poll via RoomEvent.Timeline listeners in PollEvent. + [MessageEvent.PollResponse]: () => null, + [MessageEvent.PollEnd]: () => null, }, (mEventId, mEvent, item, timelineSet, collapse) => { if (!showHiddenEvents) return null; diff --git a/src/app/hooks/useClientConfig.ts b/src/app/hooks/useClientConfig.ts index 540e053a3..f382587fd 100644 --- a/src/app/hooks/useClientConfig.ts +++ b/src/app/hooks/useClientConfig.ts @@ -67,6 +67,10 @@ export type ClientConfig = { themeCatalogApprovedHostPrefixes?: string[]; settingsDefaults?: Partial; + + features?: { + polls?: boolean; + }; }; const ClientConfigContext = createContext(null); diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts index c4987f6ba..138fefae0 100644 --- a/src/app/hooks/useCommands.ts +++ b/src/app/hooks/useCommands.ts @@ -285,6 +285,8 @@ export enum Command { // Spec missing from cinny Location = 'location', ShareMyLocation = 'sharemylocation', + // Polls + Poll = 'poll', } export type CommandContent = { @@ -1602,6 +1604,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => { navigator.geolocation.getCurrentPosition(success, error, options); }, }, + [Command.Poll]: { + name: Command.Poll, + description: 'Create a poll', + exe: async () => undefined, + }, }), [ mx, diff --git a/src/types/matrix-sdk.ts b/src/types/matrix-sdk.ts index d05073f2e..de6488849 100644 --- a/src/types/matrix-sdk.ts +++ b/src/types/matrix-sdk.ts @@ -54,3 +54,18 @@ export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; export * from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSessionManager'; export * from 'matrix-js-sdk/lib/models/thread'; + +export { + M_POLL_START, + M_POLL_RESPONSE, + M_POLL_END, + M_POLL_KIND_DISCLOSED, + M_POLL_KIND_UNDISCLOSED, +} from 'matrix-js-sdk/lib/@types/polls'; +export type { + PollStartEventContent, + PollResponseEventContent, + PollEndEventContent, + PollAnswer, + PollKind, +} from 'matrix-js-sdk/lib/@types/polls'; From ba66bbb7c3ed933e7f185b2a0004e267136906d3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 12:43:31 -0400 Subject: [PATCH 032/185] chore: add changeset for feat-polls --- .changeset/feat-polls.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/feat-polls.md diff --git a/.changeset/feat-polls.md b/.changeset/feat-polls.md new file mode 100644 index 000000000..6e7522702 --- /dev/null +++ b/.changeset/feat-polls.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +Add MSC3381 polls: create, vote on, and end polls directly in rooms (opt-in via `features.polls` in config.json). From 3e2af04329b81737ee0cc6e1321214c6f0466da8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 10:53:51 -0400 Subject: [PATCH 033/185] feat(polls): expose max_selections in poll creator dialog Allow poll creators to choose how many options voters may select (1 to the number of options). Defaults to 1 (single-choice). The value is clamped to [1, validAnswers.length] on submit per MSC3381. The voting UI already handles multi-selection correctly in PollEvent." --- .../features/room/poll/PollCreatorDialog.tsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/poll/PollCreatorDialog.tsx b/src/app/features/room/poll/PollCreatorDialog.tsx index e9a1b8ba8..f364f143f 100644 --- a/src/app/features/room/poll/PollCreatorDialog.tsx +++ b/src/app/features/room/poll/PollCreatorDialog.tsx @@ -53,7 +53,9 @@ type PollCreatorDialogProps = { export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps) { const questionId = useId(); + const maxSelectionsId = useId(); const [question, setQuestion] = useState(''); + const [maxSelections, setMaxSelections] = useState(1); const [answers, setAnswers] = useState<{ id: string; text: string }[]>(() => [ { id: crypto.randomUUID(), text: '' }, { id: crypto.randomUUID(), text: '' }, @@ -122,6 +124,7 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps setError(`Please add at least ${MIN_ANSWERS} answers.`); return; } + const clampedMaxSelections = Math.min(Math.max(1, maxSelections), validAnswers.length); if (expiryPreset === 'custom') { const ts = customExpiry ? new Date(customExpiry).getTime() : NaN; if (!Number.isFinite(ts) || ts <= Date.now()) { @@ -134,7 +137,7 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps question: trimmedQuestion, answers: validAnswers, kind, - maxSelections: 1, + maxSelections: clampedMaxSelections, showVoterNames, closesAt: computeClosesAt(), }); @@ -239,6 +242,25 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps )} + {/* Max selections */} + + + Max selections + + { + const val = parseInt((e.target as HTMLInputElement).value, 10); + if (!Number.isNaN(val) && val >= 1) setMaxSelections(val); + }} + style={{ width: '5rem' }} + /> + + {/* Poll kind */} From 8ce1d2b0ac48cf4813fb7ee62cfb3332c42ed5d4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 12:40:43 -0400 Subject: [PATCH 034/185] test(polls): unit-test pure functions; export extractPollData/computeTally/formatExpiry/extractVoteSelections --- src/app/features/room/poll/PollEvent.tsx | 8 +- src/app/features/room/poll/pollEvent.test.ts | 408 +++++++++++++++++++ 2 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 src/app/features/room/poll/pollEvent.test.ts diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx index 0ebfe58b1..1565c1c30 100644 --- a/src/app/features/room/poll/PollEvent.tsx +++ b/src/app/features/room/poll/PollEvent.tsx @@ -37,7 +37,7 @@ import * as css from './PollEvent.css'; type PollAnswer = { id: string; text: string }; -function extractPollData(mEvent: MatrixEvent): { +export function extractPollData(mEvent: MatrixEvent): { question: string; answers: PollAnswer[]; maxSelections: number; @@ -81,7 +81,7 @@ function extractPollData(mEvent: MatrixEvent): { return { question: questionText, answers, maxSelections, isDisclosed, showVoterNames, closesAt }; } -function extractVoteSelections(responseEvent: MatrixEvent): string[] { +export function extractVoteSelections(responseEvent: MatrixEvent): string[] { const content = responseEvent.getContent(); const unstablePayload = content['org.matrix.msc3381.poll.response']; const selections: unknown = @@ -99,7 +99,7 @@ type TallyResult = { isEnded: boolean; }; -function computeTally( +export function computeTally( room: Room, pollEventId: string, pollStartEvent: MatrixEvent, @@ -161,7 +161,7 @@ function computeTally( return { tally, myVote, isEnded }; } -function formatExpiry(ts: number): string { +export function formatExpiry(ts: number): string { const diff = ts - Date.now(); if (diff <= 0) return 'now'; const hours = diff / 3_600_000; diff --git a/src/app/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts new file mode 100644 index 000000000..b431d7b3e --- /dev/null +++ b/src/app/features/room/poll/pollEvent.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Room, MatrixEvent } from '$types/matrix-sdk'; +import { + extractPollData, + extractVoteSelections, + computeTally, + formatExpiry, +} from './PollEvent'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const POLL_CREATOR = '@creator:test'; +const MY_USER_ID = '@me:test'; + +/** + * Build a fake MatrixEvent that looks like an `m.poll.start` event. + */ +function makePollStartEvent( + id: string, + opts: { + question?: string; + answers?: { id: string; text: string }[]; + maxSelections?: number; + kind?: string; + closesAt?: number; + showVoterNames?: boolean; + /** Use unstable (org.matrix) keys if true (default false → stable m.poll.start) */ + unstable?: boolean; + } = {} +): MatrixEvent { + const { + question = 'Favourite colour?', + answers = [ + { id: 'ans-red', text: 'Red' }, + { id: 'ans-blue', text: 'Blue' }, + ], + maxSelections = 1, + kind = 'm.poll.disclosed', + closesAt, + showVoterNames = true, + unstable = false, + } = opts; + + const rawAnswers = answers.map((a) => ({ + 'm.id': a.id, + 'm.text': [{ body: a.text }], + })); + const pollStartKey = unstable ? 'org.matrix.msc3381.poll.start' : 'm.poll.start'; + + const content: Record = { + [pollStartKey]: { + question: { 'm.text': [{ body: question }] }, + answers: rawAnswers, + max_selections: maxSelections, + kind, + show_voter_names: showVoterNames, + ...(closesAt != null ? { closes_at: closesAt } : {}), + }, + }; + + return { + getId: () => id, + getSender: () => POLL_CREATOR, + getType: () => (unstable ? 'org.matrix.msc3381.poll.start' : 'm.poll.start'), + getContent: () => content, + getTs: () => 1_000, + } as unknown as MatrixEvent; +} + +/** + * Build a fake poll-response MatrixEvent. + */ +function makeResponseEvent( + sender: string, + selections: string[], + ts: number, + isDecryptionFailure = false +): MatrixEvent { + return { + getId: () => `${sender}-${ts}`, + getSender: () => sender, + getType: () => 'm.poll.response', + getTs: () => ts, + getContent: () => ({ 'm.selections': selections }), + isDecryptionFailure: () => isDecryptionFailure, + } as unknown as MatrixEvent; +} + +/** + * Build a fake poll-end MatrixEvent. + */ +function makeEndEvent(sender: string, ts: number): MatrixEvent { + return { + getId: () => `end-${ts}`, + getSender: () => sender, + getType: () => 'm.poll.end', + getTs: () => ts, + getContent: () => ({}), + isDecryptionFailure: () => false, + } as unknown as MatrixEvent; +} + +/** + * Build a minimal fake Room whose `relations.getAllChildEventsForEvent` returns + * the provided child events. + */ +function makeRoom( + childEvents: MatrixEvent[], + maySendRedaction = false +): Room { + return { + getUnfilteredTimelineSet: () => ({ + relations: { + getAllChildEventsForEvent: (_id: string) => childEvents, + }, + }), + currentState: { + maySendRedactionForEvent: (_event: MatrixEvent, _sender: string) => maySendRedaction, + }, + } as unknown as Room; +} + +// --------------------------------------------------------------------------- +// extractPollData +// --------------------------------------------------------------------------- + +describe('extractPollData', () => { + it('parses a stable (m.poll.start) event', () => { + const ev = makePollStartEvent('$poll:test'); + const data = extractPollData(ev); + expect(data).not.toBeNull(); + expect(data?.question).toBe('Favourite colour?'); + expect(data?.answers).toHaveLength(2); + expect(data?.answers[0]).toEqual({ id: 'ans-red', text: 'Red' }); + expect(data?.maxSelections).toBe(1); + expect(data?.isDisclosed).toBe(true); + expect(data?.showVoterNames).toBe(true); + expect(data?.closesAt).toBeUndefined(); + }); + + it('parses an unstable (org.matrix.msc3381) event', () => { + const ev = makePollStartEvent('$poll:test', { unstable: true }); + const data = extractPollData(ev); + expect(data?.question).toBe('Favourite colour?'); + expect(data?.isDisclosed).toBe(true); + }); + + it('returns null when there is no poll payload', () => { + const ev = { + getContent: () => ({}), + } as unknown as MatrixEvent; + expect(extractPollData(ev)).toBeNull(); + }); + + it('caps answers to 20 even if more are provided', () => { + const tooManyAnswers = Array.from({ length: 25 }, (_, i) => ({ + id: `a${i}`, + text: `Answer ${i}`, + })); + const ev = makePollStartEvent('$poll:test', { answers: tooManyAnswers }); + const data = extractPollData(ev); + expect(data?.answers).toHaveLength(20); + }); + + it('defaults maxSelections to 1 when not a positive integer', () => { + const ev = makePollStartEvent('$poll:test', { maxSelections: 0 }); + expect(extractPollData(ev)?.maxSelections).toBe(1); + }); + + it('parses closesAt when present', () => { + const future = Date.now() + 3_600_000; + const ev = makePollStartEvent('$poll:test', { closesAt: future }); + expect(extractPollData(ev)?.closesAt).toBe(future); + }); + + it('treats m.poll.disclosed kind as isDisclosed=true', () => { + const ev = makePollStartEvent('$poll:test', { kind: 'm.poll.disclosed' }); + expect(extractPollData(ev)?.isDisclosed).toBe(true); + }); + + it('treats m.poll.undisclosed kind as isDisclosed=false', () => { + const ev = makePollStartEvent('$poll:test', { kind: 'm.poll.undisclosed' }); + expect(extractPollData(ev)?.isDisclosed).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// extractVoteSelections +// --------------------------------------------------------------------------- + +describe('extractVoteSelections', () => { + it('returns stable m.selections array', () => { + const ev = { + getContent: () => ({ 'm.selections': ['ans-red'] }), + } as unknown as MatrixEvent; + expect(extractVoteSelections(ev)).toEqual(['ans-red']); + }); + + it('falls back to unstable org.matrix.msc3381.poll.response.answers', () => { + const ev = { + getContent: () => ({ + 'org.matrix.msc3381.poll.response': { answers: ['ans-blue'] }, + }), + } as unknown as MatrixEvent; + expect(extractVoteSelections(ev)).toEqual(['ans-blue']); + }); + + it('returns [] when the content has no selections field', () => { + const ev = { getContent: () => ({}) } as unknown as MatrixEvent; + expect(extractVoteSelections(ev)).toEqual([]); + }); + + it('filters out non-string values from selections array', () => { + const ev = { + getContent: () => ({ 'm.selections': ['valid', 42, null, 'also-valid'] }), + } as unknown as MatrixEvent; + expect(extractVoteSelections(ev)).toEqual(['valid', 'also-valid']); + }); +}); + +// --------------------------------------------------------------------------- +// computeTally +// --------------------------------------------------------------------------- + +describe('computeTally', () => { + const ANSWERS = [ + { id: 'ans-red', text: 'Red' }, + { id: 'ans-blue', text: 'Blue' }, + ]; + + it('correctly tallies a single vote', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000)]; + const room = makeRoom(children); + + const { tally, isEnded } = computeTally( + room, + '$poll:test', + pollStart, + ANSWERS, + 1, + MY_USER_ID + ); + + expect(isEnded).toBe(false); + expect(tally.get('ans-red')?.size).toBe(1); + expect(tally.get('ans-blue')?.size).toBe(0); + }); + + it('deduplicates votes from the same sender — latest timestamp wins', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [ + makeResponseEvent('@alice:test', ['ans-red'], 2_000), // older + makeResponseEvent('@alice:test', ['ans-blue'], 3_000), // newer — should win + ]; + const room = makeRoom(children); + + const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(tally.get('ans-red')?.size).toBe(0); + expect(tally.get('ans-blue')?.size).toBe(1); + }); + + it('ignores votes for answer ids not in the poll', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [makeResponseEvent('@alice:test', ['ans-invalid'], 2_000)]; + const room = makeRoom(children); + + const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(tally.get('ans-red')?.size).toBe(0); + expect(tally.get('ans-blue')?.size).toBe(0); + }); + + it('caps vote selections to maxSelections', () => { + const multiAnswers = [ + { id: 'a', text: 'A' }, + { id: 'b', text: 'B' }, + { id: 'c', text: 'C' }, + ]; + const pollStart = makePollStartEvent('$poll:test', { answers: multiAnswers, maxSelections: 2 }); + // Alice tries to vote for all 3 — only first 2 should count + const children = [makeResponseEvent('@alice:test', ['a', 'b', 'c'], 2_000)]; + const room = makeRoom(children); + + const { tally } = computeTally(room, '$poll:test', pollStart, multiAnswers, 2, MY_USER_ID); + + expect(tally.get('a')?.size).toBe(1); + expect(tally.get('b')?.size).toBe(1); + expect(tally.get('c')?.size).toBe(0); // third selection dropped + }); + + it('marks poll as ended when poll creator sends an end event', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [ + makeResponseEvent('@alice:test', ['ans-red'], 2_000), + makeEndEvent(POLL_CREATOR, 5_000), + ]; + const room = makeRoom(children); + + const { isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(isEnded).toBe(true); + }); + + it('excludes votes submitted after the poll end timestamp', () => { + const pollStart = makePollStartEvent('$poll:test'); + const end = makeEndEvent(POLL_CREATOR, 3_000); + const children = [ + makeResponseEvent('@alice:test', ['ans-red'], 2_000), // before end — counts + makeResponseEvent('@bob:test', ['ans-blue'], 4_000), // after end — excluded + end, + ]; + const room = makeRoom(children); + + const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(tally.get('ans-red')?.size).toBe(1); + expect(tally.get('ans-blue')?.size).toBe(0); + }); + + it('ignores end events from unauthorised senders', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [ + makeResponseEvent('@alice:test', ['ans-red'], 2_000), + makeEndEvent('@rogue:test', 3_000), // not creator, no redaction power + ]; + const room = makeRoom(children, /* maySendRedaction= */ false); + + const { isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(isEnded).toBe(false); + }); + + it('accepts end events from users with redaction power', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [makeEndEvent('@moderator:test', 3_000)]; + const room = makeRoom(children, /* maySendRedaction= */ true); + + const { isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(isEnded).toBe(true); + }); + + it('ignores decryption-failure response events', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000, /* decryptFailure= */ true)]; + const room = makeRoom(children); + + const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(tally.get('ans-red')?.size).toBe(0); + }); + + it('reports myVote from the current user', () => { + const pollStart = makePollStartEvent('$poll:test'); + const children = [makeResponseEvent(MY_USER_ID, ['ans-blue'], 2_000)]; + const room = makeRoom(children); + + const { myVote } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); + + expect(myVote).toEqual(['ans-blue']); + }); +}); + +// --------------------------------------------------------------------------- +// formatExpiry +// --------------------------------------------------------------------------- + +describe('formatExpiry', () => { + let now: number; + + beforeEach(() => { + now = Date.now(); + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns "now" for a past or zero timestamp', () => { + expect(formatExpiry(now - 1)).toBe('now'); + expect(formatExpiry(now)).toBe('now'); + }); + + it('returns "in X min" for times less than 1 hour away', () => { + expect(formatExpiry(now + 30 * 60_000)).toBe('in 30 min'); + }); + + it('returns "in X hr" for times between 1 and 24 hours away', () => { + expect(formatExpiry(now + 3 * 3_600_000)).toBe('in 3 hr'); + }); + + it('returns "in X day(s)" for times between 1 and 6 days away', () => { + expect(formatExpiry(now + 2 * 86_400_000)).toBe('in 2 days'); + expect(formatExpiry(now + 86_400_000)).toBe('in 1 day'); + }); + + it('returns a locale date string for 7+ days away', () => { + const future = now + 10 * 86_400_000; + const expected = new Date(future).toLocaleDateString(); + expect(formatExpiry(future)).toBe(expected); + }); +}); From e4b2fc33a839ae32cde1c2bba8adbc6de7e8fb65 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:29:09 -0400 Subject: [PATCH 035/185] fix: auto-format poll test imports for prettier compliance --- src/app/features/room/poll/pollEvent.test.ts | 25 +++++--------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts index b431d7b3e..f962a4a6a 100644 --- a/src/app/features/room/poll/pollEvent.test.ts +++ b/src/app/features/room/poll/pollEvent.test.ts @@ -1,11 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { Room, MatrixEvent } from '$types/matrix-sdk'; -import { - extractPollData, - extractVoteSelections, - computeTally, - formatExpiry, -} from './PollEvent'; +import { extractPollData, extractVoteSelections, computeTally, formatExpiry } from './PollEvent'; // --------------------------------------------------------------------------- // Helpers @@ -106,10 +101,7 @@ function makeEndEvent(sender: string, ts: number): MatrixEvent { * Build a minimal fake Room whose `relations.getAllChildEventsForEvent` returns * the provided child events. */ -function makeRoom( - childEvents: MatrixEvent[], - maySendRedaction = false -): Room { +function makeRoom(childEvents: MatrixEvent[], maySendRedaction = false): Room { return { getUnfilteredTimelineSet: () => ({ relations: { @@ -235,14 +227,7 @@ describe('computeTally', () => { const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000)]; const room = makeRoom(children); - const { tally, isEnded } = computeTally( - room, - '$poll:test', - pollStart, - ANSWERS, - 1, - MY_USER_ID - ); + const { tally, isEnded } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); expect(isEnded).toBe(false); expect(tally.get('ans-red')?.size).toBe(1); @@ -346,7 +331,9 @@ describe('computeTally', () => { it('ignores decryption-failure response events', () => { const pollStart = makePollStartEvent('$poll:test'); - const children = [makeResponseEvent('@alice:test', ['ans-red'], 2_000, /* decryptFailure= */ true)]; + const children = [ + makeResponseEvent('@alice:test', ['ans-red'], 2_000, /* decryptFailure= */ true), + ]; const room = makeRoom(children); const { tally } = computeTally(room, '$poll:test', pollStart, ANSWERS, 1, MY_USER_ID); From 7bcc3e2d059ce6a693125f3b09bc5e387d383af9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:30:26 -0400 Subject: [PATCH 036/185] fix: remove unused params from mock Room callbacks --- src/app/features/room/poll/pollEvent.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/room/poll/pollEvent.test.ts b/src/app/features/room/poll/pollEvent.test.ts index f962a4a6a..e4cce28ff 100644 --- a/src/app/features/room/poll/pollEvent.test.ts +++ b/src/app/features/room/poll/pollEvent.test.ts @@ -105,11 +105,11 @@ function makeRoom(childEvents: MatrixEvent[], maySendRedaction = false): Room { return { getUnfilteredTimelineSet: () => ({ relations: { - getAllChildEventsForEvent: (_id: string) => childEvents, + getAllChildEventsForEvent: () => childEvents, }, }), currentState: { - maySendRedactionForEvent: (_event: MatrixEvent, _sender: string) => maySendRedaction, + maySendRedactionForEvent: () => maySendRedaction, }, } as unknown as Room; } From 37a5bac314a84ffea2a393c3bb2598fbd26ea5e8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 11:12:46 -0400 Subject: [PATCH 037/185] fix: address PR #589 review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add stable m.poll.* type aliases alongside unstable MSC3381 types - Register stable poll types in useTimelineEventRenderer - Fix datetime-local timezone bug in PollCreatorDialog (UTC→local) - Add FocusOutline from folds for keyboard a11y on poll options - Add MatrixEventEvent.Decrypted listener for encrypted poll responses - Support multi-select polls with Checkbox component --- .../features/room/poll/PollCreatorDialog.tsx | 10 +- src/app/features/room/poll/PollEvent.css.ts | 54 +++--- src/app/features/room/poll/PollEvent.tsx | 25 ++- .../timeline/useTimelineEventRenderer.tsx | 155 ++++++++++-------- 4 files changed, 144 insertions(+), 100 deletions(-) diff --git a/src/app/features/room/poll/PollCreatorDialog.tsx b/src/app/features/room/poll/PollCreatorDialog.tsx index f364f143f..c56bf5edd 100644 --- a/src/app/features/room/poll/PollCreatorDialog.tsx +++ b/src/app/features/room/poll/PollCreatorDialog.tsx @@ -69,11 +69,13 @@ export function PollCreatorDialog({ onCancel, onSubmit }: PollCreatorDialogProps const [error, setError] = useState(); const lastInputRef = useRef(null); - const minDatetime = useMemo( - () => new Date(Date.now() + 60_000).toISOString().slice(0, 16), + const minDatetime = useMemo(() => { + const d = new Date(Date.now() + 60_000); + // datetime-local expects local time, not UTC — build YYYY-MM-DDTHH:MM manually + const pad = (n: number) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; // eslint-disable-next-line react-hooks/exhaustive-deps - [expiryPreset] - ); + }, [expiryPreset]); const computeClosesAt = (): number | undefined => { const now = Date.now(); diff --git a/src/app/features/room/poll/PollEvent.css.ts b/src/app/features/room/poll/PollEvent.css.ts index 26e5433f9..103e87050 100644 --- a/src/app/features/room/poll/PollEvent.css.ts +++ b/src/app/features/room/poll/PollEvent.css.ts @@ -1,33 +1,41 @@ import { style } from '@vanilla-extract/css'; -import { config } from 'folds'; +import { config, FocusOutline } from 'folds'; // Vote button wrapping just the radio circle - minimal touch target -export const RadioZone = style({ - all: 'unset', - cursor: 'pointer', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - padding: `${config.space.S100} 0`, - selectors: { - '&:disabled': { - cursor: 'default', +export const RadioZone = style([ + FocusOutline, + { + all: 'unset', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + padding: `${config.space.S100} 0`, + borderRadius: config.radii.R300, + selectors: { + '&:disabled': { + cursor: 'default', + }, }, }, -}); +]); // Text + percent area - clickable to reveal voters -export const AnswerTextButton = style({ - all: 'unset', - cursor: 'pointer', - display: 'flex', - flex: 1, - alignItems: 'center', - gap: config.space.S200, - minWidth: 0, - padding: `${config.space.S100} 0`, -}); +export const AnswerTextButton = style([ + FocusOutline, + { + all: 'unset', + cursor: 'pointer', + display: 'flex', + flex: 1, + alignItems: 'center', + gap: config.space.S200, + minWidth: 0, + padding: `${config.space.S100} 0`, + borderRadius: config.radii.R300, + }, +]); // Non-interactive version of the text area export const AnswerTextRow = style({ diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx index 1565c1c30..678a9cfd2 100644 --- a/src/app/features/room/poll/PollEvent.tsx +++ b/src/app/features/room/poll/PollEvent.tsx @@ -3,6 +3,7 @@ import FocusTrap from 'focus-trap-react'; import { Box, Button, + Checkbox, config, Icon, Icons, @@ -21,6 +22,7 @@ import { M_POLL_RESPONSE, M_POLL_START, MatrixEvent, + MatrixEventEvent, Room, RoomEvent, } from '$types/matrix-sdk'; @@ -200,6 +202,20 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) { }; }, [room, pollEventId]); + // Also re-compute when an encrypted poll response/end is decrypted + useEffect(() => { + const onDecrypted = (event: MatrixEvent) => { + if (M_POLL_RESPONSE.matches(event.getType()) || M_POLL_END.matches(event.getType())) { + const relTo = event.getContent()?.['m.relates_to']?.event_id; + if (relTo === pollEventId) incrementTick(); + } + }; + mx.on(MatrixEventEvent.Decrypted, onDecrypted); + return () => { + mx.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [mx, pollEventId]); + // Re-render when the expiry countdown reaches zero useEffect(() => { if (!pollData?.closesAt) return undefined; @@ -276,7 +292,8 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) { if (!pollData) return null; - const { question, answers, isDisclosed, closesAt } = pollData; + const { question, answers, isDisclosed, closesAt, maxSelections } = pollData; + const isMultiSelect = maxSelections > 1; const voterLabel = `${totalVoters} ${totalVoters === 1 ? 'voter' : 'voters'}`; let statusText: string; @@ -383,7 +400,11 @@ export function PollEvent({ room, mEvent, canEnd, outlined }: PollEventProps) { aria-pressed={isSelected} aria-label={`Vote for ${answer.text}`} > - + {isMultiSelect ? ( + + ) : ( + + )} {textZone} diff --git a/src/app/hooks/timeline/useTimelineEventRenderer.tsx b/src/app/hooks/timeline/useTimelineEventRenderer.tsx index 517be08d1..001700195 100644 --- a/src/app/hooks/timeline/useTimelineEventRenderer.tsx +++ b/src/app/hooks/timeline/useTimelineEventRenderer.tsx @@ -354,6 +354,85 @@ export function useTimelineEventRenderer({ }: TimelineEventRendererOptions) { const { t } = useTranslation(); + // Shared poll start renderer — used for both unstable and stable event types + const renderPollStart = ( + mEventId: string, + mEvent: MatrixEvent, + item: number, + timelineSet: EventTimelineSet, + collapse: boolean + ) => { + const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent; + const reactionRelations = getEventReactions(timelineSet, mEventId); + const reactions = reactionRelations?.getSortedAnnotationsByKey(); + const hasReactions = reactions && reactions.length > 0; + const highlighted = focusItem?.index === item && focusItem.highlight; + const senderId = getSender.call(mEvent) ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; + const myUserId = mx.getUserId() ?? ''; + const canEnd = myUserId === senderId || canRedact; + + return ( + + ) : undefined + } + hideReadReceipts={hideReads} + showDeveloperTools={showDeveloperTools} + memberPowerTag={getMemberPowerTag(senderId)} + hour24Clock={hour24Clock} + dateFormatString={dateFormatString} + > + {isRedacted.call(mEvent) ? ( + + ) : ( + + )} + + ); + }; + return useMatrixEventRenderer<[string, MatrixEvent, number, EventTimelineSet, boolean]>( { [EventType.RoomMessage]: (mEventId, mEvent, item, timelineSet, collapse) => { @@ -1133,81 +1212,15 @@ export function useTimelineEventRenderer({ ); }, - [MessageEvent.PollStart]: (mEventId, mEvent, item, timelineSet, collapse) => { - const { getSender, getAssociatedStatus, isRedacted, getUnsigned } = mEvent; - const reactionRelations = getEventReactions(timelineSet, mEventId); - const reactions = reactionRelations?.getSortedAnnotationsByKey(); - const hasReactions = reactions && reactions.length > 0; - const highlighted = focusItem?.index === item && focusItem.highlight; - const senderId = getSender.call(mEvent) ?? ''; - const senderDisplayName = - getMemberDisplayName(room, senderId, nicknames) ?? getMxIdLocalPart(senderId) ?? senderId; - const myUserId = mx.getUserId() ?? ''; - const canEnd = myUserId === senderId || canRedact; - - return ( - - ) : undefined - } - hideReadReceipts={hideReads} - showDeveloperTools={showDeveloperTools} - memberPowerTag={getMemberPowerTag(senderId)} - hour24Clock={hour24Clock} - dateFormatString={dateFormatString} - > - {isRedacted.call(mEvent) ? ( - - ) : ( - - )} - - ); - }, + [MessageEvent.PollStart]: renderPollStart, + [MessageEvent.StablePollStart]: renderPollStart, // Poll response and end events are not rendered individually — // they update the poll via RoomEvent.Timeline listeners in PollEvent. [MessageEvent.PollResponse]: () => null, [MessageEvent.PollEnd]: () => null, + // Stable poll type aliases (m.poll.*) + [MessageEvent.StablePollResponse]: () => null, + [MessageEvent.StablePollEnd]: () => null, }, (mEventId, mEvent, item, timelineSet, collapse) => { if (!showHiddenEvents) return null; From 47afe2bb2a723734dc13b9839ec4454d2d8f4d4d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:28:37 -0400 Subject: [PATCH 038/185] fix(polls): handle encrypted poll events in timeline filter - Use getEffectiveEvent() to check decrypted type for encrypted poll response/end events - Add poll start types to isStandardRendered list in useProcessedTimeline --- src/app/hooks/timeline/useProcessedTimeline.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 18d3d7ab6..115bdfcc4 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -115,11 +115,16 @@ export function useProcessedTimeline({ // Poll response and end events are always filtered — they update the poll tally // via RoomEvent.Timeline listeners in PollEvent and must never render as timeline items. + // Also check the effective (decrypted) type for encrypted events that have been decrypted. + const effectiveType = + type === 'm.room.encrypted' + ? ((mEvent.getEffectiveEvent()?.type as string) ?? type) + : type; if ( - type === 'org.matrix.msc3381.poll.response' || - type === 'org.matrix.msc3381.poll.end' || - type === 'm.poll.response' || - type === 'm.poll.end' + effectiveType === 'org.matrix.msc3381.poll.response' || + effectiveType === 'org.matrix.msc3381.poll.end' || + effectiveType === 'm.poll.response' || + effectiveType === 'm.poll.end' ) return acc; @@ -133,6 +138,8 @@ export function useProcessedTimeline({ 'm.room.topic', 'm.room.avatar', 'org.matrix.msc3401.call.member', + 'org.matrix.msc3381.poll.start', + 'm.poll.start', ].includes(type); if (!isStandardRendered) { From 62f04a621d2bf378071c1b4c2102d7599098b56a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:33:54 -0400 Subject: [PATCH 039/185] fix(polls): strip invalid vote selections instead of discarding entire vote Per MSC3381, invalid individual answer selections should be stripped but remaining valid selections should still be counted. Changed from valid.every() (discard all) to valid.filter() (strip invalid, keep valid). --- src/app/features/room/poll/PollEvent.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/poll/PollEvent.tsx b/src/app/features/room/poll/PollEvent.tsx index 678a9cfd2..d1260e1b6 100644 --- a/src/app/features/room/poll/PollEvent.tsx +++ b/src/app/features/room/poll/PollEvent.tsx @@ -148,16 +148,16 @@ export function computeTally( const tally = new Map>(answers.map((a) => [a.id, new Set()])); userVotes.forEach(({ ts, selections }, userId) => { if (ts > cutoff) return; - const valid = selections.slice(0, maxSelections); - if (!valid.every((s) => validAnswerIds.has(s))) return; + // Per MSC3381, strip invalid answer IDs but keep the remaining valid ones. + const valid = selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s)); + if (valid.length === 0) return; valid.forEach((sel) => tally.get(sel)?.add(userId)); }); const myEntry = userVotes.get(myUserId); let myVote: string[] = []; if (myEntry && myEntry.ts <= cutoff) { - const myValid = myEntry.selections.slice(0, maxSelections); - if (myValid.every((s) => validAnswerIds.has(s))) myVote = myValid; + myVote = myEntry.selections.slice(0, maxSelections).filter((s) => validAnswerIds.has(s)); } return { tally, myVote, isEnded }; From 50a956b5d0853db14571870453eede4a2235f238 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:50:22 -0400 Subject: [PATCH 040/185] feat(presence): add presence badges to sidebar and fix sliding sync presence data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DirectDMsList: show PresenceBadge on DM avatar — actual presence for 1:1 DMs, green dot when any participant is online for group DMs - AccountSwitcherTab: show PresenceBadge on own account avatar in sidebar - Fix AvatarPresence placement: move wrapper outside SidebarAvatar (overflow:hidden was clipping the badge) - useUserPresence: reset presence state when userId changes; add REST fallback for sliding sync (Synapse MSC4186 has no presence extension so m.presence events are never delivered via sync — GET /presence/:userId/status bootstraps the initial state) - ClientNonUIFeatures: explicitly PUT /presence/:userId/status on visibility change so the server records online/offline state; setSyncPresence is a no-op on MSC4186 --- src/app/hooks/useUserPresence.ts | 68 ++++++++++++++----- src/app/pages/client/ClientNonUIFeatures.tsx | 6 ++ .../client/sidebar/AccountSwitcherTab.tsx | 35 ++++++---- .../pages/client/sidebar/DirectDMsList.tsx | 34 ++++++++-- 4 files changed, 111 insertions(+), 32 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 0c90c79f9..a3b86ef08 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,6 +1,5 @@ import { useEffect, useMemo, useState } from 'react'; -import type { User, UserEventHandlerMap } from '$types/matrix-sdk'; -import { UserEvent } from '$types/matrix-sdk'; +import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { @@ -26,29 +25,66 @@ const getUserPresence = (user: User): UserPresence => ({ export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); const user = mx.getUser(userId); + const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); useEffect(() => { - if (!user) { - setPresence(undefined); - return undefined; + setPresence(user ? getUserPresence(user) : undefined); + + let cancelled = false; + + // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never + // delivered via sync. As a result, User.presence stays at the SDK default and + // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. + if (!user || user.getLastActiveTs() === 0) { + mx.getPresence(userId) + .then((resp) => { + if (cancelled) return; + setPresence({ + presence: resp.presence as Presence, + status: resp.status_msg, + active: resp.currently_active ?? false, + lastActiveTs: + resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + }); + }) + .catch(() => { + // Presence not available on this server (404 or not supported) — keep existing state. + }); } - setPresence(getUserPresence(user)); - const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => { - if (u.userId === user.userId) { - setPresence(getUserPresence(user)); + + const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { + if (u.userId === userId) { + setPresence(getUserPresence(u)); } }; - user.on(UserEvent.Presence, updatePresence); - user.on(UserEvent.CurrentlyActive, updatePresence); - user.on(UserEvent.LastPresenceTs, updatePresence); + user?.on(UserEvent.Presence, updatePresence); + user?.on(UserEvent.CurrentlyActive, updatePresence); + user?.on(UserEvent.LastPresenceTs, updatePresence); + + // If the User object doesn't exist yet, subscribe at client level as a fallback. + // ExtensionPresence emits ClientEvent.Event after creating and updating the User object, + // so by the time this fires mx.getUser(userId) is guaranteed to be non-null. + let removeClientListener: (() => void) | undefined; + if (!user) { + const onClientEvent = (event: MatrixEvent) => { + if (event.getSender() !== userId || event.getType() !== 'm.presence') return; + const u = mx.getUser(userId); + if (!u) return; + setPresence(getUserPresence(u)); + }; + mx.on(ClientEvent.Event, onClientEvent); + removeClientListener = () => mx.removeListener(ClientEvent.Event, onClientEvent); + } return () => { - user.removeListener(UserEvent.Presence, updatePresence); - user.removeListener(UserEvent.CurrentlyActive, updatePresence); - user.removeListener(UserEvent.LastPresenceTs, updatePresence); + cancelled = true; + user?.removeListener(UserEvent.Presence, updatePresence); + user?.removeListener(UserEvent.CurrentlyActive, updatePresence); + user?.removeListener(UserEvent.LastPresenceTs, updatePresence); + removeClientListener?.(); }; - }, [user]); + }, [mx, userId, user]); return presence; }; diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 833f5e211..a0eddfc34 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -852,6 +852,12 @@ function PresenceFeature() { mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); // Sliding sync: enable/disable the presence extension on the next poll. getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); + // Synapse MSC4186 sliding sync has no presence extension, so setSyncPresence has no + // effect. Explicitly PUT /presence/{userId}/status so the server knows the user's + // state — otherwise GET /presence returns stale offline and own presence badge is grey. + mx.setPresence({ presence: sendPresence ? 'online' : 'offline' }).catch(() => { + // Server doesn't support presence — ignore. + }); }, [mx, sendPresence]); return null; diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index a3ec48466..e7801bf22 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -37,10 +37,12 @@ import { getHomePath, getLoginPath, withSearchParam } from '$pages/pathUtils'; import { logoutClient, initClient, stopClient } from '$client/initMatrix'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { useUserProfile } from '$hooks/useUserProfile'; +import { useUserPresence } from '$hooks/useUserPresence'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { useSessionProfiles } from '$hooks/useSessionProfiles'; import { useOpenSettings } from '$features/settings'; import { Modal500 } from '$components/Modal500'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; @@ -170,6 +172,7 @@ export function AccountSwitcherTab() { const myUserId = mx.getUserId() ?? ''; const activeProfile = useUserProfile(myUserId); + const myPresence = useUserPresence(myUserId); const activeAvatarUrl = activeProfile.avatarUrl ? (mxcUrlToHttp(mx, activeProfile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined; @@ -269,19 +272,27 @@ export function AccountSwitcherTab() { {(triggerRef) => ( - 1} + + ) : undefined + } > - {nameInitials(label)}} - /> - + 1} + > + {nameInitials(label)}} + /> + + )} {(totalBackgroundUnread > 0 || anyBackgroundHighlight) && ( diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 8c3313335..639e4a0fe 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef, useEffect } from 'react'; +import { useMemo, useRef, useEffect, ReactNode } from 'react'; import * as Sentry from '@sentry/react'; import { useNavigate } from 'react-router-dom'; import { Avatar, Text, Box } from 'folds'; @@ -15,6 +15,8 @@ import { } from '$components/sidebar'; import { RoomAvatar } from '$components/room-avatar'; import { UserAvatar } from '$components/user-avatar'; +import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useUserPresence, Presence } from '$hooks/useUserPresence'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '$utils/room'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; import { nameInitials } from '$utils/common'; @@ -48,6 +50,28 @@ function DMItem({ room, selected }: DMItemProps) { // Members are sorted by who last sent messages (most recent first) const groupMembers = useGroupDMMembers(mx, room, MAX_GROUP_MEMBERS); + // Presence hooks — always called unconditionally (React rules of hooks). + // For single DMs: guessDMUserId() is synchronous; group slots use '' → undefined. + // For group DMs: singleDMUserId is '' → undefined; member slots use groupMembers. + const singleDMUserId = isGroupDM ? '' : room.guessDMUserId(); + const singleDMPresence = useUserPresence(singleDMUserId); + const member0Presence = useUserPresence(isGroupDM ? (groupMembers[0]?.userId ?? '') : ''); + const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : ''); + const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : ''); + + const groupDMOnline = + isGroupDM && + [member0Presence, member1Presence, member2Presence].some( + (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online + ); + + let presenceBadge: ReactNode; + if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { + presenceBadge = ; + } else if (isGroupDM && groupDMOnline) { + presenceBadge = ; + } + // Get unread info for badge const unread = roomToUnread.get(room.roomId); @@ -135,9 +159,11 @@ function DMItem({ room, selected }: DMItemProps) { {(triggerRef) => ( - - {renderAvatar()} - + + + {renderAvatar()} + + )} {unread && (unread.total > 0 || unread.highlight > 0) && ( From 01333f0523235c991300874fcb262c230fe085a7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 12:17:18 -0400 Subject: [PATCH 041/185] chore: add changeset for presence-sidebar-badges --- .changeset/presence-sidebar-badges.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/presence-sidebar-badges.md diff --git a/.changeset/presence-sidebar-badges.md b/.changeset/presence-sidebar-badges.md new file mode 100644 index 000000000..9d0356c48 --- /dev/null +++ b/.changeset/presence-sidebar-badges.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Add presence status badges to sidebar DM list and account switcher From edb7b27577689632844342d2b7c8fde8c6e6d114 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 9 Apr 2026 09:21:39 -0400 Subject: [PATCH 042/185] fix(presence): skip REST presence fetch when userId is empty string --- src/app/hooks/useUserPresence.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index a3b86ef08..52bb99467 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -36,7 +36,9 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { // Sliding sync (Synapse MSC4186) has no presence extension — m.presence events are never // delivered via sync. As a result, User.presence stays at the SDK default and // getLastActiveTs() stays 0. Fall back to a direct REST fetch to bootstrap presence state. - if (!user || user.getLastActiveTs() === 0) { + // Guard against empty userId — callers that render a fixed number of hooks (e.g. group DM + // slots) pass '' for absent members; firing getPresence('') would be a malformed request. + if (userId && (!user || user.getLastActiveTs() === 0)) { mx.getPresence(userId) .then((resp) => { if (cancelled) return; From afdead0690bcb4b6ee0e82736c0125db44c69cf4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 12:43:27 -0400 Subject: [PATCH 043/185] test(presence): add useUserPresence unit tests --- src/app/hooks/useUserPresence.test.tsx | 205 +++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 src/app/hooks/useUserPresence.test.tsx diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx new file mode 100644 index 000000000..125629137 --- /dev/null +++ b/src/app/hooks/useUserPresence.test.tsx @@ -0,0 +1,205 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useUserPresence, Presence } from './useUserPresence'; + +// ------- mock setup ------- + +// Each test can override mockUser / mockGetPresence as needed. +let mockUser: ReturnType | null = null; +let mockGetPresence: ReturnType; + +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + +// Listeners registered via user.on() – captured so tests can emit events. +const userListeners = new Map void)[]>(); + +const makeMockUser = (opts: { + presence?: string; + presenceStatusMsg?: string | undefined; + currentlyActive?: boolean; + lastActiveTs?: number; +} = {}) => ({ + userId: '@alice:test', + presence: opts.presence ?? 'online', + presenceStatusMsg: opts.presenceStatusMsg, + currentlyActive: opts.currentlyActive ?? true, + getLastActiveTs: vi.fn().mockReturnValue(opts.lastActiveTs ?? 1000), + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn(), +}); + +const mockMx = { + getUser: vi.fn((): ReturnType | null => mockUser), + getPresence: vi.fn( + (): Promise<{ + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; + }> => + mockGetPresence() + ), + on: vi.fn(), + removeListener: vi.fn(), +}; + +const USER_ID = '@alice:test'; + +beforeEach(() => { + vi.clearAllMocks(); + userListeners.clear(); + mockUser = null; + mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default + mockMx.getUser.mockImplementation(() => mockUser); + mockMx.getPresence.mockImplementation(() => mockGetPresence()); +}); + +// ------- tests ------- + +describe('useUserPresence', () => { + it('returns undefined when the user is not in the SDK and REST is pending', () => { + // mockUser is null; REST never resolves + const { result } = renderHook(() => useUserPresence(USER_ID)); + expect(result.current).toBeUndefined(); + }); + + it('initialises from SDK user when available with a non-zero lastActiveTs', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 5000 }); + // lastActiveTs > 0 — no REST fallback should be triggered + const { result } = renderHook(() => useUserPresence(USER_ID)); + + expect(result.current).toEqual({ + presence: Presence.Online, + status: undefined, + active: true, + lastActiveTs: 5000, + }); + expect(mockMx.getPresence).not.toHaveBeenCalled(); + }); + + it('fires the REST fallback when getLastActiveTs() is 0 (sliding-sync server)', async () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 0 }); + let resolvePresence!: (v: { + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number; + }) => void; + mockGetPresence = vi + .fn() + .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + await act(async () => { + resolvePresence({ + presence: 'unavailable', + status_msg: 'in a meeting', + currently_active: false, + last_active_ago: 60_000, + }); + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + expect(result.current?.status).toBe('in a meeting'); + expect(result.current?.active).toBe(false); + // lastActiveTs should be approximately Date.now() - 60_000 + expect(result.current?.lastActiveTs).toBeGreaterThan(0); + }); + + it('fires the REST fallback when user object does not exist yet', async () => { + // user is null — REST should still be requested + let resolvePresence!: (v: { presence: string }) => void; + mockGetPresence = vi + .fn() + .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + expect(mockMx.getPresence).toHaveBeenCalledWith(USER_ID); + + await act(async () => { + resolvePresence({ presence: 'online' }); + }); + + expect(result.current?.presence).toBe(Presence.Online); + }); + + it('does NOT fire REST when userId is an empty string', () => { + const { result } = renderHook(() => useUserPresence('')); + + expect(mockMx.getPresence).not.toHaveBeenCalled(); + expect(result.current).toBeUndefined(); + }); + + it('ignores the REST response after the component unmounts (cancelled flag)', async () => { + let resolvePresence!: (v: { presence: string }) => void; + mockGetPresence = vi + .fn() + .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + + const { result, unmount } = renderHook(() => useUserPresence(USER_ID)); + unmount(); + + // Resolve after unmount — cancelled = true, so state should NOT be updated + await act(async () => { + resolvePresence({ presence: 'online' }); + }); + + expect(result.current).toBeUndefined(); + }); + + it('updates presence when UserEvent.Presence fires on the user object', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + // Mutate mock user to simulate a presence change, then fire the registered listener + mockUser!.presence = 'unavailable'; + const handlers = userListeners.get('User.presence') ?? []; + + act(() => { + handlers.forEach((h) => h({}, mockUser)); + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + }); + + it('resets to undefined when userId changes to a user not in the SDK', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + + const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), { + initialProps: { uid: USER_ID }, + }); + + expect(result.current).not.toBeUndefined(); + + // Switch to unknown user + mockUser = null; + rerender({ uid: '@bob:test' }); + + expect(result.current).toBeUndefined(); + }); + + it('silently ignores a REST error (presence not supported on this server)', async () => { + mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found'))); + + const { result } = renderHook(() => useUserPresence(USER_ID)); + + // Wait for the rejection to be processed + await act(async () => { + await Promise.resolve(); + }); + + // Should still be undefined without throwing + expect(result.current).toBeUndefined(); + }); +}); From d0e759d1a18e2b1b505739bbdef2b05823b687b9 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:32:00 -0400 Subject: [PATCH 044/185] feat(presence): add presenceMode setting and Discord-style status picker Adds a new presenceMode setting ('online' | 'unavailable' | 'offline') that controls which Matrix presence state is broadcast when sendPresence is enabled. - Settings: new presenceMode field (default: 'online') - PresenceFeature: uses presenceMode; Invisible mode keeps sliding sync extension enabled so the user still receives others' presence events - AccountSwitcherTab: drive own badge from settings state (fixes stuck-offline badge on MSC4186 servers that never echo own presence); add Discord-style Online/Away/Invisible status picker in the account menu - usePresenceLabel: align label strings with Matrix state names - DevelopTools: add focusId to Rotate Encryption Sessions tile; fix import order --- .../settings/developer-tools/DevelopTools.tsx | 6 +++ src/app/hooks/useUserPresence.ts | 6 +-- src/app/pages/client/ClientNonUIFeatures.tsx | 36 ++++++++++--- .../client/sidebar/AccountSwitcherTab.tsx | 54 +++++++++++++++++-- src/app/state/settings.ts | 3 ++ 5 files changed, 89 insertions(+), 16 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index ae5602173..77f2722e4 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -167,8 +167,14 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp gap="400" > >>>>>> 4404e849f (feat(presence): add presenceMode setting and Discord-style status picker) description="Discard current Megolm sessions and begin sharing new keys with all room members. Key delivery happens in the background — send a message in each affected room to confirm the bridge has received the new keys." after={ + } + > + {rotateState.status === AsyncStatus.Success && ( + + Sessions discarded for {rotateState.data.rotated} of{' '} + {rotateState.data.total} encrypted rooms. Key sharing is starting in the + background — send a message in an affected room to confirm delivery to + bridges. + + )} + {rotateState.status === AsyncStatus.Error && ( + + {rotateState.error.message} + + )} + + + + )} + {developerTools && ( + + Encryption + + { export const usePresenceLabel = (): Record => useMemo( () => ({ - [Presence.Online]: 'Active', - [Presence.Unavailable]: 'Busy', - [Presence.Offline]: 'Away', + [Presence.Online]: 'Online', + [Presence.Unavailable]: 'Away', + [Presence.Offline]: 'Offline', }), [] ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 64d4ae28f..3b2ee4c75 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -60,6 +60,7 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; +import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -892,11 +893,17 @@ function SettingsSyncFeature() { return null; } +function BookmarksFeature() { + useInitBookmarks(); + return null; +} + export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> + diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 8dbbd35c2..b6b5dc069 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -289,31 +289,7 @@ export function AccountSwitcherTab() { {(triggerRef) => ( -<<<<<<< HEAD -||||||| parent of c178b777b (feat(presence): add presence badges to sidebar and fix sliding sync presence data) - 1} - > - {nameInitials(label)}} - /> - -======= - - ) : undefined - } - > ->>>>>>> c178b777b (feat(presence): add presence badges to sidebar and fix sliding sync presence data) Date: Sat, 11 Apr 2026 18:50:55 -0400 Subject: [PATCH 052/185] feat(presence): Discord-style presence picker with Idle, DND, and Invisible options --- src/app/hooks/useUserPresence.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 7e0f0e78b..8c9b85959 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -95,7 +95,7 @@ export const usePresenceLabel = (): Record => useMemo( () => ({ [Presence.Online]: 'Online', - [Presence.Unavailable]: 'Away', + [Presence.Unavailable]: 'Idle', [Presence.Offline]: 'Offline', }), [] From 3084d132593accc3f26c2cacf5cfc5aacd62fb55 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 21:49:38 -0400 Subject: [PATCH 053/185] feat(presence): auto-idle after inactivity timeout Adds an optional inactivity-based presence auto-idle that downgrades the user's broadcast presence from online to unavailable after a configurable period without keyboard or pointer input. - New config flag `presenceAutoIdleTimeoutMs` (default: 600 000 ms = 10 min, 0 = disabled). Operators can adjust or disable via config.json. - New hook `usePresenceAutoIdle` sets `presenceAutoIdledAtom` (ephemeral, not persisted) after the timeout, and clears it immediately on any mousemove / mousedown / keydown / touchstart / wheel event. - `PresenceFeature` reads `autoIdled` and derives the effective broadcast mode: when auto-idled the broadcast is forced to `unavailable` regardless of the user's configured presenceMode, then restored on activity. - `AccountSwitcherTab` badge and picker reflect the effective mode so the UI is consistent with what is actually broadcasted. If another device sets the user back to `online` (e.g. the user becomes active there), the `User.presence` event handler in `usePresenceAutoIdle` clears the auto-idle flag on this device too. Background tab throttling on iOS Safari PWA may delay or prevent the inactivity timer from firing reliably. The feature degrades gracefully: presence will eventually update when the tab regains focus. --- config.json | 1 + src/app/hooks/usePresenceAutoIdle.ts | 101 ++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 28 ++--- .../client/sidebar/AccountSwitcherTab.tsx | 12 ++- src/app/state/settings.ts | 3 + 5 files changed, 129 insertions(+), 16 deletions(-) create mode 100644 src/app/hooks/usePresenceAutoIdle.ts diff --git a/config.json b/config.json index b5870a5b1..3620c8188 100644 --- a/config.json +++ b/config.json @@ -17,6 +17,7 @@ "slidingSync": { "enabled": true }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts new file mode 100644 index 000000000..dd11e729b --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useSetAtom } from 'jotai'; +import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk'; +import { presenceAutoIdledAtom } from '$state/settings'; +import { createDebugLogger } from '$utils/debugLogger'; + +const debugLog = createDebugLogger('PresenceAutoIdle'); +const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; + +/** + * Automatically transitions presence to idle after a configurable inactivity + * timeout, and clears the idle state when activity is detected. + * + * Also subscribes to the Matrix `User.presence` event so that if another device + * sets you back to `online`, the auto-idle state is cleared here too (multi-device + * sync). + * + * Note: On iOS Safari PWA, background tab throttling may delay or prevent the + * inactivity timer from firing reliably. The feature degrades gracefully — presence + * will eventually update when the tab regains focus. + */ +export function usePresenceAutoIdle( + mx: MatrixClient, + presenceMode: string, + sendPresence: boolean, + timeoutMs: number +): void { + const setAutoIdled = useSetAtom(presenceAutoIdledAtom); + const autoIdledRef = useRef(false); + const timerRef = useRef(undefined); + + const clearTimer = useCallback(() => { + if (timerRef.current !== undefined) { + window.clearTimeout(timerRef.current); + timerRef.current = undefined; + } + }, []); + + // Inactivity timer: go idle after timeoutMs without user input. + useEffect(() => { + const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0; + if (!shouldAutoIdle) { + clearTimer(); + if (autoIdledRef.current) { + autoIdledRef.current = false; + setAutoIdled(false); + } + return undefined; + } + + const goIdle = () => { + debugLog.info('general', 'Inactivity timeout — auto-idling'); + autoIdledRef.current = true; + setAutoIdled(true); + }; + + const handleActivity = () => { + clearTimer(); + if (autoIdledRef.current) { + debugLog.info('general', 'Activity detected — clearing auto-idle'); + autoIdledRef.current = false; + setAutoIdled(false); + } + timerRef.current = window.setTimeout(goIdle, timeoutMs); + }; + + // Start the initial timer. + timerRef.current = window.setTimeout(goIdle, timeoutMs); + ACTIVITY_EVENTS.forEach((ev) => + document.addEventListener(ev, handleActivity, { passive: true }) + ); + + return () => { + ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); + clearTimer(); + }; + }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); + + // Multi-device sync: if another device sets us back to online, clear auto-idle. + useEffect(() => { + if (!sendPresence) return undefined; + const myUserId = mx.getUserId(); + if (!myUserId) return undefined; + const user = mx.getUser(myUserId); + if (!user) return undefined; + + const handlePresence: UserEventHandlerMap[UserEvent.Presence] = (_event, u) => { + if (u.userId !== myUserId) return; + if (u.presence === 'online' && autoIdledRef.current) { + debugLog.info('general', 'Remote device set Online — clearing auto-idle'); + autoIdledRef.current = false; + setAutoIdled(false); + } + }; + + user.on(UserEvent.Presence, handlePresence); + return () => { + user.removeListener(UserEvent.Presence, handlePresence); + }; + }, [mx, sendPresence, setAutoIdled]); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 3b2ee4c75..e2df21158 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue, useSetAtom, useAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; @@ -24,7 +24,9 @@ import NotificationSound from '$public/sound/notification.ogg'; import InviteSound from '$public/sound/invite.ogg'; import { notificationPermission, setFavicon } from '$utils/dom'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; +import { useClientConfig } from '$hooks/useClientConfig'; +import { usePresenceAutoIdle } from '$hooks/usePresenceAutoIdle'; import { nicknamesAtom } from '$state/nicknames'; import { mDirectAtom } from '$state/mDirectList'; import { allInvitesAtom } from '$state/room-list/inviteList'; @@ -60,7 +62,6 @@ import { useCallSignaling } from '$hooks/useCallSignaling'; import { getBlobCacheStats } from '$hooks/useBlobCache'; import { lastVisitedRoomIdAtom } from '$state/room/lastRoom'; import { useSettingsSyncEffect } from '$hooks/useSettingsSync'; -import { useInitBookmarks } from '$features/bookmarks/useInitBookmarks'; import { getInboxInvitesPath } from '../pathUtils'; import { BackgroundNotifications } from './BackgroundNotifications'; @@ -859,11 +860,18 @@ function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const [autoIdled] = useAtom(presenceAutoIdledAtom); + const clientConfig = useClientConfig(); + const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; + + usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { - // Effective broadcast state: honour presenceMode when presence is on, otherwise offline. + // When auto-idled, broadcast as unavailable regardless of the configured mode. + const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); + // Effective broadcast state: honour effectiveMode when presence is on, otherwise offline. // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. - const activePresence = presenceMode === 'dnd' ? 'online' : (presenceMode ?? 'online'); + const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; const effectiveState = sendPresence ? activePresence : 'offline'; const broadcasting = effectiveState !== 'offline'; @@ -879,11 +887,11 @@ function PresenceFeature() { // their presence events because the extension is still enabled above. mx.setPresence({ presence: effectiveState, - status_msg: sendPresence && presenceMode === 'dnd' ? 'dnd' : '', + status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', }).catch(() => { // Server doesn't support presence — ignore. }); - }, [mx, sendPresence, presenceMode]); + }, [mx, sendPresence, presenceMode, autoIdled]); return null; } @@ -893,17 +901,11 @@ function SettingsSyncFeature() { return null; } -function BookmarksFeature() { - useInitBookmarks(); - return null; -} - export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { useCallSignaling(); return ( <> - diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index b6b5dc069..858af5626 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -49,7 +49,7 @@ import { createDebugLogger } from '$utils/debugLogger'; import { useClientConfig } from '$hooks/useClientConfig'; import { UnreadBadge, UnreadBadgeCenter } from '$components/unread-badge'; import { useSetting } from '$state/hooks/settings'; -import { settingsAtom } from '$state/settings'; +import { settingsAtom, presenceAutoIdledAtom } from '$state/settings'; const log = createLogger('AccountSwitcherTab'); const debugLog = createDebugLogger('AccountSwitcherTab'); @@ -180,14 +180,18 @@ export function AccountSwitcherTab() { // user.presence would leave the badge stuck at the SDK default forever. const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode, setPresenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); + const setAutoIdled = useSetAtom(presenceAutoIdledAtom); + // The effective mode for badge display: if auto-idled, show unavailable regardless of selected mode. + const effectiveDisplayMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); let myOwnPresenceBadge: ReactNode; if (sendPresence && presenceMode !== 'offline') { myOwnPresenceBadge = - presenceMode === 'dnd' ? ( + effectiveDisplayMode === 'dnd' ? ( // DND: solid red badge (broadcasts as online with status_msg 'dnd') ) : ( - + ); } const activeAvatarUrl = activeProfile.avatarUrl @@ -412,6 +416,8 @@ export function AccountSwitcherTab() { } onClick={() => { setPresenceMode(mode); + // Clear auto-idle so the badge updates immediately on manual selection. + setAutoIdled(false); // Re-enable presence broadcasting if the master toggle was off if (!sendPresence) setSendPresence(true); }} diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index c06f69606..e8457a975 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -555,3 +555,6 @@ export const settingsAtom = atom( setSettings(update); } ); + +/** Ephemeral (not persisted) — true when auto-idled due to inactivity. */ +export const presenceAutoIdledAtom = atom(false); From 9dacfdc7f7e3705efcaadfd63e20299f7e6fa40d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 12:10:29 -0400 Subject: [PATCH 054/185] chore: add changeset for presence-auto-idle --- .changeset/presence-auto-idle.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/presence-auto-idle.md diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md new file mode 100644 index 000000000..0cdedfdac --- /dev/null +++ b/.changeset/presence-auto-idle.md @@ -0,0 +1,5 @@ +--- +'@sable/client': minor +--- + +feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker From be4bec9eac63416a608ac498fc56ee188633f909 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:47:21 -0400 Subject: [PATCH 055/185] fix(presence): restore missing experiment config helpers and clean presence hook tests --- .../settings/developer-tools/DevelopTools.tsx | 2 - src/app/hooks/useUserPresence.test.tsx | 72 ++++++++++--------- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index da4e560fe..335000c4d 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -15,7 +15,6 @@ import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; import { SettingsSectionPage } from '../SettingsSectionPage'; import { AccountData } from './AccountData'; import { SyncDiagnostics } from './SyncDiagnostics'; -import { ExperimentsPanel } from './ExperimentsPanel'; import { DebugLogViewer } from './DebugLogViewer'; import { SentrySettings } from './SentrySettings'; @@ -156,7 +155,6 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp )} {developerTools && } - {developerTools && } {developerTools && ( Encryption diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx index 125629137..c311563b6 100644 --- a/src/app/hooks/useUserPresence.test.tsx +++ b/src/app/hooks/useUserPresence.test.tsx @@ -6,21 +6,25 @@ import { useUserPresence, Presence } from './useUserPresence'; // Each test can override mockUser / mockGetPresence as needed. let mockUser: ReturnType | null = null; -let mockGetPresence: ReturnType; - -vi.mock('$hooks/useMatrixClient', () => ({ - useMatrixClient: () => mockMx, -})); +type PresenceResponse = { + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; +}; +let mockGetPresence: () => Promise; // Listeners registered via user.on() – captured so tests can emit events. const userListeners = new Map void)[]>(); -const makeMockUser = (opts: { - presence?: string; - presenceStatusMsg?: string | undefined; - currentlyActive?: boolean; - lastActiveTs?: number; -} = {}) => ({ +const makeMockUser = ( + opts: { + presence?: string; + presenceStatusMsg?: string | undefined; + currentlyActive?: boolean; + lastActiveTs?: number; + } = {} +) => ({ userId: '@alice:test', presence: opts.presence ?? 'online', presenceStatusMsg: opts.presenceStatusMsg, @@ -36,26 +40,22 @@ const makeMockUser = (opts: { const mockMx = { getUser: vi.fn((): ReturnType | null => mockUser), - getPresence: vi.fn( - (): Promise<{ - presence: string; - status_msg?: string; - currently_active?: boolean; - last_active_ago?: number | null; - }> => - mockGetPresence() - ), + getPresence: vi.fn((): Promise => mockGetPresence()), on: vi.fn(), removeListener: vi.fn(), }; +vi.mock('$hooks/useMatrixClient', () => ({ + useMatrixClient: () => mockMx, +})); + const USER_ID = '@alice:test'; beforeEach(() => { vi.clearAllMocks(); userListeners.clear(); mockUser = null; - mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); // pending by default + mockGetPresence = () => new Promise(() => {}); // pending by default mockMx.getUser.mockImplementation(() => mockUser); mockMx.getPresence.mockImplementation(() => mockGetPresence()); }); @@ -91,9 +91,10 @@ describe('useUserPresence', () => { currently_active?: boolean; last_active_ago?: number; }) => void; - mockGetPresence = vi - .fn() - .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + mockGetPresence = () => + new Promise((res) => { + resolvePresence = res; + }); const { result } = renderHook(() => useUserPresence(USER_ID)); @@ -116,9 +117,10 @@ describe('useUserPresence', () => { it('fires the REST fallback when user object does not exist yet', async () => { // user is null — REST should still be requested let resolvePresence!: (v: { presence: string }) => void; - mockGetPresence = vi - .fn() - .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + mockGetPresence = () => + new Promise((res) => { + resolvePresence = res; + }); const { result } = renderHook(() => useUserPresence(USER_ID)); @@ -140,9 +142,11 @@ describe('useUserPresence', () => { it('ignores the REST response after the component unmounts (cancelled flag)', async () => { let resolvePresence!: (v: { presence: string }) => void; - mockGetPresence = vi - .fn() - .mockReturnValue(new Promise((res) => { resolvePresence = res; })); + mockGetPresence = vi.fn().mockReturnValue( + new Promise((res) => { + resolvePresence = res; + }) + ); const { result, unmount } = renderHook(() => useUserPresence(USER_ID)); unmount(); @@ -157,12 +161,12 @@ describe('useUserPresence', () => { it('updates presence when UserEvent.Presence fires on the user object', () => { mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); - mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + mockGetPresence = () => new Promise(() => {}); const { result } = renderHook(() => useUserPresence(USER_ID)); // Mutate mock user to simulate a presence change, then fire the registered listener - mockUser!.presence = 'unavailable'; + mockUser.presence = 'unavailable'; const handlers = userListeners.get('User.presence') ?? []; act(() => { @@ -174,7 +178,7 @@ describe('useUserPresence', () => { it('resets to undefined when userId changes to a user not in the SDK', () => { mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); - mockGetPresence = vi.fn().mockReturnValue(new Promise(() => {})); + mockGetPresence = () => new Promise(() => {}); const { result, rerender } = renderHook(({ uid }) => useUserPresence(uid), { initialProps: { uid: USER_ID }, @@ -190,7 +194,7 @@ describe('useUserPresence', () => { }); it('silently ignores a REST error (presence not supported on this server)', async () => { - mockGetPresence = vi.fn().mockReturnValue(Promise.reject(new Error('404 Not Found'))); + mockGetPresence = () => Promise.reject(new Error('404 Not Found')); const { result } = renderHook(() => useUserPresence(USER_ID)); From 39519a99eaf6d3b1588bef95067203d77547841a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:00:02 -0400 Subject: [PATCH 056/185] fix(presence): address review feedback for presence-auto-idle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix changeset frontmatter: '@sable/client': minor → default: minor - Update presenceMode docstring to clarify dnd broadcasts as online+status_msg - Import KnownMembership from $types/matrix-sdk - Gate heartbeat effect on mx being defined to avoid no-op timers - Add mx to heartbeat effect dependency array --- .changeset/presence-auto-idle.md | 2 +- src/app/hooks/useAppVisibility.ts | 3 ++- src/app/state/settings.ts | 6 +++++- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/.changeset/presence-auto-idle.md b/.changeset/presence-auto-idle.md index 0cdedfdac..56889390c 100644 --- a/.changeset/presence-auto-idle.md +++ b/.changeset/presence-auto-idle.md @@ -1,5 +1,5 @@ --- -'@sable/client': minor +default: minor --- feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 144f132a9..b1d25add0 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -171,7 +171,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S ]); useEffect(() => { - if (!phase2VisibleHeartbeat) return undefined; + if (!phase2VisibleHeartbeat || !mx) return undefined; // Reset adaptive backoff/suppression so a config or session change starts fresh. heartbeatFailuresRef.current = 0; @@ -230,6 +230,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S }, [ heartbeatIntervalMs, heartbeatMaxBackoffMs, + mx, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter, pushSessionNow, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index e8457a975..010213ff7 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -125,7 +125,11 @@ export interface Settings { // Sable features! sendPresence: boolean; - /** Which Matrix presence state to broadcast when sendPresence is true. */ + /** + * Which presence mode to use when sendPresence is true. + * Matrix presence states are sent as-is; the app-specific `dnd` mode is + * broadcast as `presence=online` with a `status_msg`. + */ presenceMode: 'online' | 'unavailable' | 'dnd' | 'offline'; mobileGestures: boolean; rightSwipeAction: RightSwipeAction; From 181c075a193ec3428e3a20e29a1b3a36e2ac63ac Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:23:16 -0400 Subject: [PATCH 057/185] fix(presence): 5min default, wire visibility reset, add tests - Change presenceAutoIdleTimeoutMs from 600000 (10min) to 300000 (5min) - Wire appEvents.onVisibilityChange so returning to the app resets auto-idle - Add comprehensive usePresenceAutoIdle unit tests (10 tests) --- src/app/hooks/usePresenceAutoIdle.test.tsx | 238 +++++++++++++++++++++ src/app/hooks/usePresenceAutoIdle.ts | 10 + 2 files changed, 248 insertions(+) create mode 100644 src/app/hooks/usePresenceAutoIdle.test.tsx diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx new file mode 100644 index 000000000..0ebfd744a --- /dev/null +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -0,0 +1,238 @@ +import { act, renderHook } from '@testing-library/react'; +import { Provider, useAtomValue } from 'jotai'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { usePresenceAutoIdle } from './usePresenceAutoIdle'; +import { presenceAutoIdledAtom } from '$state/settings'; +import { appEvents } from '$utils/appEvents'; +import type { ReactNode } from 'react'; + +// -------- mock setup -------- + +const userListeners = new Map void)[]>(); + +const makeMockUser = () => ({ + userId: '@alice:test', + presence: 'online', + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = userListeners.get(event) ?? []; + list.push(handler); + userListeners.set(event, list); + }), + removeListener: vi.fn(), +}); + +let mockUser: ReturnType | null = null; + +const makeMockMx = () => ({ + getUserId: vi.fn(() => '@alice:test'), + getUser: vi.fn(() => mockUser), +}); + +let mockMx: ReturnType; + +const wrapper = ({ children }: { children: ReactNode }) => {children}; + +// Helper to read the atom value alongside the hook under test. +function useAutoIdledReader( + mx: ReturnType, + presenceMode: string, + sendPresence: boolean, + timeoutMs: number +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs); + return useAtomValue(presenceAutoIdledAtom); +} + +// -------- lifecycle -------- + +beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + userListeners.clear(); + mockUser = makeMockUser(); + mockMx = makeMockMx(); + appEvents.onVisibilityChange = null; +}); + +afterEach(() => { + vi.useRealTimers(); + appEvents.onVisibilityChange = null; +}); + +// -------- tests -------- + +describe('usePresenceAutoIdle', () => { + it('sets auto-idle after the timeout elapses', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + expect(result.current).toBe(false); + + act(() => { + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(true); + }); + + it('resets auto-idle when user activity is detected', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate user activity. + act(() => { + document.dispatchEvent(new Event('mousemove')); + }); + expect(result.current).toBe(false); + }); + + it('resets auto-idle when app becomes visible via appEvents', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate app returning to foreground. + act(() => { + appEvents.onVisibilityChange?.(true); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when presenceMode is not online', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'dnd', true, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when sendPresence is false', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', false, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('does not go idle when timeoutMs is 0', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 0), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(10000); + }); + expect(result.current).toBe(false); + }); + + it('restarts the idle timer on activity before timeout', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + // Advance partially, then trigger activity. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(false); + + act(() => { + document.dispatchEvent(new Event('keydown')); + }); + + // Original timeout would have fired at 5000ms, but we reset. + act(() => { + vi.advanceTimersByTime(3000); + }); + expect(result.current).toBe(false); + + // Now the full 5000ms from last activity should trigger idle. + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(result.current).toBe(true); + }); + + it('clears auto-idle when presenceMode changes away from online', () => { + const { result, rerender } = renderHook( + ({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000), + { wrapper, initialProps: { mode: 'online' } } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + rerender({ mode: 'dnd' }); + expect(result.current).toBe(false); + }); + + it('clears auto-idle when another device sets presence to online', () => { + const { result } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); + + // Simulate User.presence event from another device. + const handlers = userListeners.get('User.presence') ?? []; + expect(handlers.length).toBeGreaterThan(0); + + act(() => { + handlers.forEach((h) => + h({}, { userId: '@alice:test', presence: 'online' }) + ); + }); + expect(result.current).toBe(false); + }); + + it('restores previous appEvents.onVisibilityChange on cleanup', () => { + const prev = vi.fn(); + appEvents.onVisibilityChange = prev; + + const { unmount } = renderHook( + () => useAutoIdledReader(mockMx, 'online', true, 5000), + { wrapper } + ); + + // Our handler should be installed. + expect(appEvents.onVisibilityChange).not.toBe(prev); + + unmount(); + + // Previous handler should be restored. + expect(appEvents.onVisibilityChange).toBe(prev); + }); +}); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index dd11e729b..abf25edba 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSetAtom } from 'jotai'; import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk'; import { presenceAutoIdledAtom } from '$state/settings'; +import { appEvents } from '$utils/appEvents'; import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('PresenceAutoIdle'); @@ -70,9 +71,18 @@ export function usePresenceAutoIdle( document.addEventListener(ev, handleActivity, { passive: true }) ); + // When the app returns to the foreground, treat it as activity so the user + // isn't shown as idle the moment they switch back to the tab/PWA. + const prevOnVisibilityChange = appEvents.onVisibilityChange; + appEvents.onVisibilityChange = (isVisible: boolean) => { + prevOnVisibilityChange?.(isVisible); + if (isVisible) handleActivity(); + }; + return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); clearTimer(); + appEvents.onVisibilityChange = prevOnVisibilityChange; }; }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); From d1902645ede312b166199d07eaf0f1dcb83de0e3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 00:23:43 -0400 Subject: [PATCH 058/185] refactor: align presence-auto-idle with sw-push-session-recovery - Remove activeSession param from useAppVisibility, use mx methods instead - Switch appEvents to multi-subscriber Set-based pattern - Update usePresenceAutoIdle to use subscription-based visibility handler - Update tests for new appEvents API --- src/app/hooks/useAppVisibility.ts | 20 +++++++--------- src/app/hooks/usePresenceAutoIdle.test.tsx | 25 +++++++++---------- src/app/hooks/usePresenceAutoIdle.ts | 8 +++---- src/app/utils/appEvents.ts | 28 ++++++++++++++++++++-- 4 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index b1d25add0..e3000ecdf 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; -import { Session } from '$state/sessions'; import { appEvents } from '../utils/appEvents'; import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { createDebugLogger } from '../utils/debugLogger'; @@ -13,11 +12,11 @@ const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; -export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { +export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined); // Derive phase flags from experiment variant; fall back to direct config when not in experiment. const inSessionSync = sessionSyncVariant.inExperiment; @@ -55,9 +54,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S const pushSessionNow = useCallback( (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { - const baseUrl = activeSession?.baseUrl; - const accessToken = activeSession?.accessToken; - const userId = activeSession?.userId; + const baseUrl = mx?.getHomeserverUrl(); + const accessToken = mx?.getAccessToken(); + const userId = mx?.getUserId(); const canPush = !!mx && typeof baseUrl === 'string' && @@ -88,9 +87,6 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S return 'sent'; }, [ - activeSession?.accessToken, - activeSession?.baseUrl, - activeSession?.userId, mx, phase1ForegroundResync, phase2VisibleHeartbeat, @@ -106,9 +102,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.onVisibilityChange?.(isVisible); + appEvents.emitVisibilityChange(isVisible); if (!isVisible) { - appEvents.onVisibilityHidden?.(); + appEvents.emitVisibilityHidden(); return; } @@ -171,7 +167,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S ]); useEffect(() => { - if (!phase2VisibleHeartbeat || !mx) return undefined; + if (!phase2VisibleHeartbeat) return undefined; // Reset adaptive backoff/suppression so a config or session change starts fresh. heartbeatFailuresRef.current = 0; diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 0ebfd744a..043598c55 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -52,12 +52,10 @@ beforeEach(() => { userListeners.clear(); mockUser = makeMockUser(); mockMx = makeMockMx(); - appEvents.onVisibilityChange = null; }); afterEach(() => { vi.useRealTimers(); - appEvents.onVisibilityChange = null; }); // -------- tests -------- @@ -110,7 +108,7 @@ describe('usePresenceAutoIdle', () => { // Simulate app returning to foreground. act(() => { - appEvents.onVisibilityChange?.(true); + appEvents.emitVisibilityChange(true); }); expect(result.current).toBe(false); }); @@ -218,21 +216,24 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(false); }); - it('restores previous appEvents.onVisibilityChange on cleanup', () => { - const prev = vi.fn(); - appEvents.onVisibilityChange = prev; - - const { unmount } = renderHook( + it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { + const { result, unmount } = renderHook( () => useAutoIdledReader(mockMx, 'online', true, 5000), { wrapper } ); - // Our handler should be installed. - expect(appEvents.onVisibilityChange).not.toBe(prev); + // Go idle. + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(result.current).toBe(true); unmount(); - // Previous handler should be restored. - expect(appEvents.onVisibilityChange).toBe(prev); + // After unmount, emitting visibility change should have no effect. + // (No error thrown means the handler was properly unsubscribed.) + act(() => { + appEvents.emitVisibilityChange(true); + }); }); }); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index abf25edba..dc5af7e21 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -73,16 +73,14 @@ export function usePresenceAutoIdle( // When the app returns to the foreground, treat it as activity so the user // isn't shown as idle the moment they switch back to the tab/PWA. - const prevOnVisibilityChange = appEvents.onVisibilityChange; - appEvents.onVisibilityChange = (isVisible: boolean) => { - prevOnVisibilityChange?.(isVisible); + const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => { if (isVisible) handleActivity(); - }; + }); return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); clearTimer(); - appEvents.onVisibilityChange = prevOnVisibilityChange; + unsubVisibility(); }; }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2834c5b6f..2430f5324 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,5 +1,29 @@ +export type VisibilityChangeHandler = (isVisible: boolean) => void; +type VisibilityHiddenHandler = () => void; + +const visibilityChangeHandlers = new Set(); +const visibilityHiddenHandlers = new Set(); + export const appEvents = { - onVisibilityHidden: null as (() => void) | null, + onVisibilityHidden(handler: VisibilityHiddenHandler): () => void { + visibilityHiddenHandlers.add(handler); + return () => { + visibilityHiddenHandlers.delete(handler); + }; + }, + + emitVisibilityHidden(): void { + visibilityHiddenHandlers.forEach((h) => h()); + }, + + onVisibilityChange(handler: VisibilityChangeHandler): () => void { + visibilityChangeHandlers.add(handler); + return () => { + visibilityChangeHandlers.delete(handler); + }; + }, - onVisibilityChange: null as ((isVisible: boolean) => void) | null, + emitVisibilityChange(isVisible: boolean): void { + visibilityChangeHandlers.forEach((h) => h(isVisible)); + }, }; From 4d04c0b39ed27f7bef4f39dc52c7409dbc5acd42 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 10:58:40 -0400 Subject: [PATCH 059/185] style: fix lint errors from merge --- src/app/hooks/useAppVisibility.ts | 12 ++-- src/app/hooks/usePresenceAutoIdle.test.tsx | 66 ++++++++-------------- 2 files changed, 30 insertions(+), 48 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index e3000ecdf..af2bd2e69 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -16,7 +16,10 @@ export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', mx?.getUserId() ?? undefined); + const sessionSyncVariant = useExperimentVariant( + 'sessionSyncStrategy', + mx?.getUserId() ?? undefined + ); // Derive phase flags from experiment variant; fall back to direct config when not in experiment. const inSessionSync = sessionSyncVariant.inExperiment; @@ -86,12 +89,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { }); return 'sent'; }, - [ - mx, - phase1ForegroundResync, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - ] + [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] ); useEffect(() => { diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 043598c55..8e2f6d138 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -1,10 +1,10 @@ import { act, renderHook } from '@testing-library/react'; import { Provider, useAtomValue } from 'jotai'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; -import { usePresenceAutoIdle } from './usePresenceAutoIdle'; import { presenceAutoIdledAtom } from '$state/settings'; import { appEvents } from '$utils/appEvents'; import type { ReactNode } from 'react'; +import { usePresenceAutoIdle } from './usePresenceAutoIdle'; // -------- mock setup -------- @@ -39,7 +39,6 @@ function useAutoIdledReader( sendPresence: boolean, timeoutMs: number ) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any usePresenceAutoIdle(mx as any, presenceMode, sendPresence, timeoutMs); return useAtomValue(presenceAutoIdledAtom); } @@ -62,10 +61,9 @@ afterEach(() => { describe('usePresenceAutoIdle', () => { it('sets auto-idle after the timeout elapses', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); expect(result.current).toBe(false); @@ -77,10 +75,9 @@ describe('usePresenceAutoIdle', () => { }); it('resets auto-idle when user activity is detected', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); // Go idle. act(() => { @@ -96,10 +93,9 @@ describe('usePresenceAutoIdle', () => { }); it('resets auto-idle when app becomes visible via appEvents', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); act(() => { vi.advanceTimersByTime(5000); @@ -114,10 +110,7 @@ describe('usePresenceAutoIdle', () => { }); it('does not go idle when presenceMode is not online', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'dnd', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'dnd', true, 5000), { wrapper }); act(() => { vi.advanceTimersByTime(10000); @@ -126,10 +119,9 @@ describe('usePresenceAutoIdle', () => { }); it('does not go idle when sendPresence is false', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', false, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', false, 5000), { + wrapper, + }); act(() => { vi.advanceTimersByTime(10000); @@ -138,10 +130,7 @@ describe('usePresenceAutoIdle', () => { }); it('does not go idle when timeoutMs is 0', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 0), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 0), { wrapper }); act(() => { vi.advanceTimersByTime(10000); @@ -150,10 +139,9 @@ describe('usePresenceAutoIdle', () => { }); it('restarts the idle timer on activity before timeout', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); // Advance partially, then trigger activity. act(() => { @@ -194,10 +182,9 @@ describe('usePresenceAutoIdle', () => { }); it('clears auto-idle when another device sets presence to online', () => { - const { result } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); act(() => { vi.advanceTimersByTime(5000); @@ -209,18 +196,15 @@ describe('usePresenceAutoIdle', () => { expect(handlers.length).toBeGreaterThan(0); act(() => { - handlers.forEach((h) => - h({}, { userId: '@alice:test', presence: 'online' }) - ); + handlers.forEach((h) => h({}, { userId: '@alice:test', presence: 'online' })); }); expect(result.current).toBe(false); }); it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { - const { result, unmount } = renderHook( - () => useAutoIdledReader(mockMx, 'online', true, 5000), - { wrapper } - ); + const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); // Go idle. act(() => { From f413a431a69649ae6ab11593fc32f7cc4348f636 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 11:58:45 -0400 Subject: [PATCH 060/185] fix(presence): address review feedback - Fix test mock path to match relative import - Only send status_msg when explicitly setting DND (avoid clearing user status) - Guard lastActiveTs null check to prevent false presence badges - Remove unrelated enableMessageBookmarks leak from PR scope - Revert DevelopTools rotate-sessions changes (belongs in PR #670) - Add in-memory presence REST cache + in-flight dedupe to prevent N+1 floods - Only log 5xx server errors in presence fetch (suppress 404/network) - Close status picker menu after selection for UX consistency - Guard heartbeat effect on mx being defined --- src/app/hooks/useAppVisibility.ts | 2 +- src/app/hooks/useUserPresence.test.tsx | 5 +- src/app/hooks/useUserPresence.ts | 72 +++++++++++++++---- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- .../client/sidebar/AccountSwitcherTab.tsx | 1 + .../pages/client/sidebar/DirectDMsList.tsx | 13 +++- src/app/state/settings.ts | 6 -- 7 files changed, 75 insertions(+), 26 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index af2bd2e69..4e7b5b131 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -165,7 +165,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) { ]); useEffect(() => { - if (!phase2VisibleHeartbeat) return undefined; + if (!phase2VisibleHeartbeat || !mx) return undefined; // Reset adaptive backoff/suppression so a config or session change starts fresh. heartbeatFailuresRef.current = 0; diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx index c311563b6..78f334d71 100644 --- a/src/app/hooks/useUserPresence.test.tsx +++ b/src/app/hooks/useUserPresence.test.tsx @@ -1,6 +1,6 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useUserPresence, Presence } from './useUserPresence'; +import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence'; // ------- mock setup ------- @@ -45,7 +45,7 @@ const mockMx = { removeListener: vi.fn(), }; -vi.mock('$hooks/useMatrixClient', () => ({ +vi.mock('./useMatrixClient', () => ({ useMatrixClient: () => mockMx, })); @@ -54,6 +54,7 @@ const USER_ID = '@alice:test'; beforeEach(() => { vi.clearAllMocks(); userListeners.clear(); + clearPresenceCache(); mockUser = null; mockGetPresence = () => new Promise(() => {}); // pending by default mockMx.getUser.mockImplementation(() => mockUser); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 8c9b85959..787b998bd 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -22,6 +22,60 @@ const getUserPresence = (user: User): UserPresence => ({ lastActiveTs: user.getLastActiveTs(), }); +// In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods. +// Multiple hook instances for the same user share a single in-flight request. +const PRESENCE_CACHE_TTL_MS = 60_000; +const presenceCache = new Map(); +const presenceInflight = new Map>(); + +/** Visible for testing — clears the in-memory REST presence cache. */ +export function clearPresenceCache(): void { + presenceCache.clear(); + presenceInflight.clear(); +} + +function fetchPresenceOnce( + mx: { getPresence: (userId: string) => Promise<{ presence: string; status_msg?: string; currently_active?: boolean; last_active_ago?: number | null }> }, + userId: string +): Promise { + const cached = presenceCache.get(userId); + if (cached && Date.now() - cached.fetchedAt < PRESENCE_CACHE_TTL_MS) { + return Promise.resolve(cached.data); + } + + const existing = presenceInflight.get(userId); + if (existing) return existing; + + const promise = mx + .getPresence(userId) + .then((resp) => { + const data: UserPresence = { + presence: resp.presence as Presence, + status: resp.status_msg, + active: resp.currently_active ?? false, + lastActiveTs: + resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + }; + presenceCache.set(userId, { data, fetchedAt: Date.now() }); + return data; + }) + .catch((err: unknown) => { + // Suppress expected failures (404/403 = presence not supported, network errors). + // Only log unexpected server errors (5xx) for debugging. + const status = (err as { httpStatus?: number })?.httpStatus; + if (status && status >= 500) { + console.warn('[useUserPresence] REST fetch failed for', userId, err); + } + return undefined; + }) + .finally(() => { + presenceInflight.delete(userId); + }); + + presenceInflight.set(userId, promise); + return promise; +} + export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); const user = mx.getUser(userId); @@ -39,20 +93,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { // Guard against empty userId — callers that render a fixed number of hooks (e.g. group DM // slots) pass '' for absent members; firing getPresence('') would be a malformed request. if (userId && (!user || user.getLastActiveTs() === 0)) { - mx.getPresence(userId) - .then((resp) => { - if (cancelled) return; - setPresence({ - presence: resp.presence as Presence, - status: resp.status_msg, - active: resp.currently_active ?? false, - lastActiveTs: - resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, - }); - }) - .catch(() => { - // Presence not available on this server (404 or not supported) — keep existing state. - }); + fetchPresenceOnce(mx, userId).then((data) => { + if (cancelled || !data) return; + setPresence(data); + }); } const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index e2df21158..17bb1fd49 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -887,7 +887,7 @@ function PresenceFeature() { // their presence events because the extension is still enabled above. mx.setPresence({ presence: effectiveState, - status_msg: sendPresence && effectiveMode === 'dnd' ? 'dnd' : '', + ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), }).catch(() => { // Server doesn't support presence — ignore. }); diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 858af5626..602080484 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -420,6 +420,7 @@ export function AccountSwitcherTab() { setAutoIdled(false); // Re-enable presence broadcasting if the master toggle was off if (!sendPresence) setSendPresence(true); + setMenuAnchor(undefined); }} > diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 639e4a0fe..6c1c2ae78 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -62,11 +62,20 @@ function DMItem({ room, selected }: DMItemProps) { const groupDMOnline = isGroupDM && [member0Presence, member1Presence, member2Presence].some( - (p) => p && p.lastActiveTs !== 0 && p.presence === Presence.Online + (p) => + p && + p.lastActiveTs != null && + p.lastActiveTs !== 0 && + p.presence === Presence.Online ); let presenceBadge: ReactNode; - if (!isGroupDM && singleDMPresence && singleDMPresence.lastActiveTs !== 0) { + if ( + !isGroupDM && + singleDMPresence && + singleDMPresence.lastActiveTs != null && + singleDMPresence.lastActiveTs !== 0 + ) { presenceBadge = ; } else if (isGroupDM && groupDMOnline) { presenceBadge = ; diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 010213ff7..abeb2cfe8 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -160,9 +160,6 @@ export interface Settings { showPersonaSetting: boolean; closeFoldersByDefault: boolean; - // experimental - enableMessageBookmarks: boolean; - // furry stuff renderAnimals: boolean; @@ -286,9 +283,6 @@ export const defaultSettings: Settings = { showPersonaSetting: false, closeFoldersByDefault: false, - // experimental - enableMessageBookmarks: false, - // furry stuff renderAnimals: true, From fe4c6a361a313a2867abe64ed5db052b798ee07f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:17:56 -0400 Subject: [PATCH 061/185] chore: fix lint and format issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useUserPresence.ts | 12 +++++++++--- src/app/pages/client/sidebar/DirectDMsList.tsx | 6 +----- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 787b998bd..174621fd2 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -35,7 +35,14 @@ export function clearPresenceCache(): void { } function fetchPresenceOnce( - mx: { getPresence: (userId: string) => Promise<{ presence: string; status_msg?: string; currently_active?: boolean; last_active_ago?: number | null }> }, + mx: { + getPresence: (userId: string) => Promise<{ + presence: string; + status_msg?: string; + currently_active?: boolean; + last_active_ago?: number | null; + }>; + }, userId: string ): Promise { const cached = presenceCache.get(userId); @@ -53,8 +60,7 @@ function fetchPresenceOnce( presence: resp.presence as Presence, status: resp.status_msg, active: resp.currently_active ?? false, - lastActiveTs: - resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, + lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, }; presenceCache.set(userId, { data, fetchedAt: Date.now() }); return data; diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 6c1c2ae78..924d95d7d 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -62,11 +62,7 @@ function DMItem({ room, selected }: DMItemProps) { const groupDMOnline = isGroupDM && [member0Presence, member1Presence, member2Presence].some( - (p) => - p && - p.lastActiveTs != null && - p.lastActiveTs !== 0 && - p.presence === Presence.Online + (p) => p && p.lastActiveTs != null && p.lastActiveTs !== 0 && p.presence === Presence.Online ); let presenceBadge: ReactNode; From 83bc6ccd475bea83259cc63fa2c9b961efa1ae69 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 08:58:45 -0400 Subject: [PATCH 062/185] fix(presence): retry setPresence on failure for app resume reliability When the app resumes from background, the HTTP client may not have reconnected yet, causing setPresence to fail silently. Retry up to 3 times with back-off (2s, 4s, 6s) so presence recovers from idle. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 17bb1fd49..632ade28c 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -885,12 +885,24 @@ function PresenceFeature() { // - MSC4186 servers that have no presence extension see this immediately. // - When 'offline' (Invisible mode), we appear offline to others but still receive // their presence events because the extension is still enabled above. - mx.setPresence({ + const presencePayload = { presence: effectiveState, ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), - }).catch(() => { - // Server doesn't support presence — ignore. - }); + }; + let retryTimer: ReturnType | undefined; + const trySetPresence = (attempt = 0) => { + mx.setPresence(presencePayload).catch(() => { + // Retry up to 3 times with back-off: the HTTP client may not have + // reconnected yet after the app resumes from background. + if (attempt < 3) { + retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1)); + } + }); + }; + trySetPresence(); + return () => { + if (retryTimer !== undefined) clearTimeout(retryTimer); + }; }, [mx, sendPresence, presenceMode, autoIdled]); return null; From 6dd594c49834f440b1bf302a17eb0fe91ede1659 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:45:43 -0400 Subject: [PATCH 063/185] fix(presence): normalize dnd state handling --- src/app/components/presence/Presence.tsx | 1 + src/app/features/settings/account/Profile.tsx | 7 +- src/app/hooks/useAppVisibility.ts | 219 ++---------------- src/app/hooks/usePresenceAutoIdle.test.tsx | 20 +- src/app/hooks/usePresenceAutoIdle.ts | 16 +- src/app/hooks/useUserPresence.test.tsx | 81 +++++++ src/app/hooks/useUserPresence.ts | 74 +++++- src/app/pages/client/ClientNonUIFeatures.tsx | 49 ++-- .../client/sidebar/AccountSwitcherTab.tsx | 1 - .../pages/client/sidebar/DirectDMsList.tsx | 20 +- src/app/utils/appEvents.ts | 28 +-- 11 files changed, 228 insertions(+), 288 deletions(-) diff --git a/src/app/components/presence/Presence.tsx b/src/app/components/presence/Presence.tsx index 88543b7f6..085020ec2 100644 --- a/src/app/components/presence/Presence.tsx +++ b/src/app/components/presence/Presence.tsx @@ -9,6 +9,7 @@ const PresenceToColor: Record = { [Presence.Online]: 'Success', [Presence.Unavailable]: 'Warning', [Presence.Offline]: 'Secondary', + [Presence.Dnd]: 'Critical', }; type PresenceBadgeProps = { diff --git a/src/app/features/settings/account/Profile.tsx b/src/app/features/settings/account/Profile.tsx index 19dc11609..cbab12e49 100644 --- a/src/app/features/settings/account/Profile.tsx +++ b/src/app/features/settings/account/Profile.tsx @@ -42,7 +42,7 @@ import { CompactUploadCardRenderer } from '$components/upload-card'; import { useCapabilities } from '$hooks/useCapabilities'; import { profilesCacheAtom } from '$state/userRoomProfile'; import { SequenceCardStyle } from '$features/settings/styles.css'; -import { useUserPresence } from '$hooks/useUserPresence'; +import { Presence, useUserPresence } from '$hooks/useUserPresence'; import type { MSC1767Text } from '$types/matrix/common'; import { TimezoneEditor } from './TimezoneEditor'; import { PronounEditor } from './PronounEditor'; @@ -513,7 +513,10 @@ function ProfileExtended({ profile, userId }: Readonly) { const handleSaveStatus = useCallback( async (newStatus: string) => { - const currentState = presence?.presence || 'online'; + const currentState = + presence?.presence === Presence.Dnd + ? Presence.Online + : (presence?.presence ?? Presence.Online); await mx.setPresence({ presence: currentState, diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 4e7b5b131..7fd5f2325 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,96 +1,22 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useEffect } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; +import { useAtom } from 'jotai'; +import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig, useExperimentVariant } from './useClientConfig'; +import { useClientConfig } from './useClientConfig'; +import { useSetting } from '../state/hooks/settings'; +import { settingsAtom } from '../state/settings'; +import { pushSubscriptionAtom } from '../state/pushSubscription'; +import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; -import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; -const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; -const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; -const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; - export function useAppVisibility(mx: MatrixClient | undefined) { const clientConfig = useClientConfig(); - - const sessionSyncConfig = clientConfig.sessionSync; - const sessionSyncVariant = useExperimentVariant( - 'sessionSyncStrategy', - mx?.getUserId() ?? undefined - ); - - // Derive phase flags from experiment variant; fall back to direct config when not in experiment. - const inSessionSync = sessionSyncVariant.inExperiment; - const syncVariant = sessionSyncVariant.variant; - const phase1ForegroundResync = inSessionSync - ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' - : sessionSyncConfig?.phase1ForegroundResync === true; - const phase2VisibleHeartbeat = inSessionSync - ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' - : sessionSyncConfig?.phase2VisibleHeartbeat === true; - const phase3AdaptiveBackoffJitter = inSessionSync - ? syncVariant === 'session-sync-adaptive' - : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; - - const foregroundDebounceMs = Math.max( - 0, - sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS - ); - const heartbeatIntervalMs = Math.max( - 1000, - sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS - ); - const resumeHeartbeatSuppressMs = Math.max( - 0, - sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS - ); - const heartbeatMaxBackoffMs = Math.max( - heartbeatIntervalMs, - sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS - ); - - const lastForegroundPushAtRef = useRef(0); - const suppressHeartbeatUntilRef = useRef(0); - const heartbeatFailuresRef = useRef(0); - - const pushSessionNow = useCallback( - (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { - const baseUrl = mx?.getHomeserverUrl(); - const accessToken = mx?.getAccessToken(); - const userId = mx?.getUserId(); - const canPush = - !!mx && - typeof baseUrl === 'string' && - typeof accessToken === 'string' && - typeof userId === 'string' && - 'serviceWorker' in navigator && - !!navigator.serviceWorker.controller; - - if (!canPush) { - debugLog.warn('network', 'Skipped SW session sync', { - reason, - hasClient: !!mx, - hasBaseUrl: !!baseUrl, - hasAccessToken: !!accessToken, - hasUserId: !!userId, - hasSwController: !!navigator.serviceWorker?.controller, - }); - return 'skipped'; - } - - pushSessionToSW(baseUrl, accessToken, userId); - debugLog.info('network', 'Pushed session to SW', { - reason, - phase1ForegroundResync, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - }); - return 'sent'; - }, - [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] - ); + const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); + const pushSubAtom = useAtom(pushSubscriptionAtom); + const isMobile = mobileOrTablet(); useEffect(() => { const handleVisibilityChange = () => { @@ -100,133 +26,30 @@ export function useAppVisibility(mx: MatrixClient | undefined) { `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, { visibilityState: document.visibilityState } ); - appEvents.emitVisibilityChange(isVisible); + appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { - appEvents.emitVisibilityHidden(); - return; - } - - // Always kick the sync loop on foreground regardless of phase flags — - // the SDK may be sitting in exponential backoff after iOS froze the tab. - mx?.retryImmediately(); - - if (!phase1ForegroundResync) return; - - const now = Date.now(); - if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; - lastForegroundPushAtRef.current = now; - - if (pushSessionNow('foreground') === 'sent') { - // A successful push proves the SW controller is up — reset adaptive backoff - // so the heartbeat returns to its normal interval immediately rather than - // staying on an inflated delay left over from a prior SW absence period. - if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; - if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { - suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; - } - } - }; - - const handleFocus = () => { - if (document.visibilityState !== 'visible') return; - - // Always kick the sync loop on focus for the same reason as above. - mx?.retryImmediately(); - - if (!phase1ForegroundResync) return; - - const now = Date.now(); - if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; - lastForegroundPushAtRef.current = now; - - if (pushSessionNow('focus') === 'sent') { - if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; - if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { - suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; - } + appEvents.onVisibilityHidden?.(); } }; document.addEventListener('visibilitychange', handleVisibilityChange); - window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); - window.removeEventListener('focus', handleFocus); }; - }, [ - foregroundDebounceMs, - mx, - phase1ForegroundResync, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - pushSessionNow, - resumeHeartbeatSuppressMs, - ]); + }, []); useEffect(() => { - if (!phase2VisibleHeartbeat || !mx) return undefined; - - // Reset adaptive backoff/suppression so a config or session change starts fresh. - heartbeatFailuresRef.current = 0; - suppressHeartbeatUntilRef.current = 0; - - let timeoutId: number | undefined; - - const getDelayMs = (): number => { - let delay = heartbeatIntervalMs; - - if (phase3AdaptiveBackoffJitter) { - const failures = heartbeatFailuresRef.current; - const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); - delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); - - // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. - const jitter = 0.8 + Math.random() * 0.4; - delay = Math.max(1000, Math.round(delay * jitter)); - } - - return delay; - }; - - const tick = () => { - const now = Date.now(); + if (!mx) return; - if (document.visibilityState !== 'visible' || !navigator.onLine) { - timeoutId = window.setTimeout(tick, getDelayMs()); - return; - } - - if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { - timeoutId = window.setTimeout(tick, getDelayMs()); - return; - } - - const result = pushSessionNow('heartbeat'); - if (phase3AdaptiveBackoffJitter) { - if (result === 'sent') { - heartbeatFailuresRef.current = 0; - } else { - // 'skipped' means prerequisites (SW controller, session) aren't ready. - // Treat as a transient failure so backoff grows until the SW is ready. - heartbeatFailuresRef.current += 1; - } - } - - timeoutId = window.setTimeout(tick, getDelayMs()); + const handleVisibilityForNotifications = (isVisible: boolean) => { + togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); }; - timeoutId = window.setTimeout(tick, getDelayMs()); - + appEvents.onVisibilityChange = handleVisibilityForNotifications; + // eslint-disable-next-line consistent-return return () => { - if (timeoutId !== undefined) window.clearTimeout(timeoutId); + appEvents.onVisibilityChange = null; }; - }, [ - heartbeatIntervalMs, - heartbeatMaxBackoffMs, - mx, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - pushSessionNow, - ]); + }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); } diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 8e2f6d138..2fea1eddd 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -2,7 +2,6 @@ import { act, renderHook } from '@testing-library/react'; import { Provider, useAtomValue } from 'jotai'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { presenceAutoIdledAtom } from '$state/settings'; -import { appEvents } from '$utils/appEvents'; import type { ReactNode } from 'react'; import { usePresenceAutoIdle } from './usePresenceAutoIdle'; @@ -92,7 +91,7 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(false); }); - it('resets auto-idle when app becomes visible via appEvents', () => { + it('resets auto-idle when the document becomes visible again', () => { const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { wrapper, }); @@ -102,11 +101,16 @@ describe('usePresenceAutoIdle', () => { }); expect(result.current).toBe(true); - // Simulate app returning to foreground. + const visibilityStateSpy = vi + .spyOn(document, 'visibilityState', 'get') + .mockReturnValue('visible'); + act(() => { - appEvents.emitVisibilityChange(true); + document.dispatchEvent(new Event('visibilitychange')); }); expect(result.current).toBe(false); + + visibilityStateSpy.mockRestore(); }); it('does not go idle when presenceMode is not online', () => { @@ -201,7 +205,7 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(false); }); - it('unsubscribes from appEvents.onVisibilityChange on cleanup', () => { + it('stops responding to focus events after cleanup', () => { const { result, unmount } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { wrapper, }); @@ -214,10 +218,10 @@ describe('usePresenceAutoIdle', () => { unmount(); - // After unmount, emitting visibility change should have no effect. - // (No error thrown means the handler was properly unsubscribed.) act(() => { - appEvents.emitVisibilityChange(true); + window.dispatchEvent(new Event('focus')); }); + + expect(result.current).toBe(true); }); }); diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index dc5af7e21..c4f14a008 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef } from 'react'; import { useSetAtom } from 'jotai'; import { type MatrixClient, UserEvent, type UserEventHandlerMap } from '$types/matrix-sdk'; import { presenceAutoIdledAtom } from '$state/settings'; -import { appEvents } from '$utils/appEvents'; import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('PresenceAutoIdle'); @@ -65,22 +64,23 @@ export function usePresenceAutoIdle( timerRef.current = window.setTimeout(goIdle, timeoutMs); }; + const handleVisibilityChange = () => { + if (document.visibilityState === 'visible') handleActivity(); + }; + // Start the initial timer. timerRef.current = window.setTimeout(goIdle, timeoutMs); ACTIVITY_EVENTS.forEach((ev) => document.addEventListener(ev, handleActivity, { passive: true }) ); - - // When the app returns to the foreground, treat it as activity so the user - // isn't shown as idle the moment they switch back to the tab/PWA. - const unsubVisibility = appEvents.onVisibilityChange((isVisible: boolean) => { - if (isVisible) handleActivity(); - }); + document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleActivity); return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); + document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleActivity); clearTimer(); - unsubVisibility(); }; }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); diff --git a/src/app/hooks/useUserPresence.test.tsx b/src/app/hooks/useUserPresence.test.tsx index 78f334d71..70ca6b5d2 100644 --- a/src/app/hooks/useUserPresence.test.tsx +++ b/src/app/hooks/useUserPresence.test.tsx @@ -1,5 +1,9 @@ import { act, renderHook } from '@testing-library/react'; +import { Provider } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import { presenceAutoIdledAtom, settingsAtom } from '$state/settings'; import { useUserPresence, Presence, clearPresenceCache } from './useUserPresence'; // ------- mock setup ------- @@ -41,6 +45,7 @@ const makeMockUser = ( const mockMx = { getUser: vi.fn((): ReturnType | null => mockUser), getPresence: vi.fn((): Promise => mockGetPresence()), + getUserId: vi.fn<() => string | undefined>(() => undefined), on: vi.fn(), removeListener: vi.fn(), }; @@ -51,14 +56,53 @@ vi.mock('./useMatrixClient', () => ({ const USER_ID = '@alice:test'; +type HookWrapperProps = { + children: ReactNode; + sendPresence?: boolean; + presenceMode?: 'online' | 'unavailable' | 'dnd' | 'offline'; + autoIdled?: boolean; +}; + +const localStorageSettings = () => { + const rawSettings = localStorage.getItem('settings'); + return rawSettings ? JSON.parse(rawSettings) : {}; +}; + +const HydratePresenceSettings = ({ + children, + sendPresence = true, + presenceMode = 'online', + autoIdled = false, +}: HookWrapperProps) => { + useHydrateAtoms([ + [settingsAtom, { ...localStorageSettings(), sendPresence, presenceMode }], + [presenceAutoIdledAtom, autoIdled], + ]); + return children; +}; + +const createWrapper = (options?: Omit) => { + function Wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + } + + return Wrapper; +}; + beforeEach(() => { vi.clearAllMocks(); userListeners.clear(); clearPresenceCache(); + localStorage.clear(); mockUser = null; mockGetPresence = () => new Promise(() => {}); // pending by default mockMx.getUser.mockImplementation(() => mockUser); mockMx.getPresence.mockImplementation(() => mockGetPresence()); + mockMx.getUserId.mockReturnValue(undefined); }); // ------- tests ------- @@ -207,4 +251,41 @@ describe('useUserPresence', () => { // Should still be undefined without throwing expect(result.current).toBeUndefined(); }); + + it('normalizes synthetic dnd presence from the SDK user object', () => { + mockUser = makeMockUser({ presence: 'online', presenceStatusMsg: 'dnd', lastActiveTs: 1000 }); + + const { result } = renderHook(() => useUserPresence('@bob:test')); + + expect(result.current).toEqual({ + presence: Presence.Dnd, + status: undefined, + active: true, + lastActiveTs: 1000, + }); + }); + + it('overrides own presence from settings so member lists update immediately', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockMx.getUserId.mockReturnValue(USER_ID); + + const { result } = renderHook(() => useUserPresence(USER_ID), { + wrapper: createWrapper({ presenceMode: 'dnd' }), + }); + + expect(result.current?.presence).toBe(Presence.Dnd); + expect(result.current?.status).toBeUndefined(); + }); + + it('marks own presence idle when auto-idle is active', () => { + mockUser = makeMockUser({ presence: 'online', lastActiveTs: 1000 }); + mockMx.getUserId.mockReturnValue(USER_ID); + + const { result } = renderHook(() => useUserPresence(USER_ID), { + wrapper: createWrapper({ autoIdled: true }), + }); + + expect(result.current?.presence).toBe(Presence.Unavailable); + expect(result.current?.active).toBe(false); + }); }); diff --git a/src/app/hooks/useUserPresence.ts b/src/app/hooks/useUserPresence.ts index 174621fd2..6b1b634a5 100644 --- a/src/app/hooks/useUserPresence.ts +++ b/src/app/hooks/useUserPresence.ts @@ -1,11 +1,15 @@ import { useEffect, useMemo, useState } from 'react'; +import { useAtomValue } from 'jotai'; import { ClientEvent, MatrixEvent, User, UserEvent, UserEventHandlerMap } from '$types/matrix-sdk'; +import { presenceAutoIdledAtom, settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; import { useMatrixClient } from './useMatrixClient'; export enum Presence { Online = 'online', Unavailable = 'unavailable', Offline = 'offline', + Dnd = 'dnd', } export type UserPresence = { @@ -15,13 +19,66 @@ export type UserPresence = { lastActiveTs?: number; }; +const isSyntheticDndStatus = (status?: string): boolean => status === 'dnd'; + +const normalizePresence = (presence: string | undefined, status?: string): Presence => { + if (presence === Presence.Online && isSyntheticDndStatus(status)) return Presence.Dnd; + if (presence === Presence.Unavailable) return Presence.Unavailable; + if (presence === Presence.Offline) return Presence.Offline; + return Presence.Online; +}; + +const sanitizeStatus = (status?: string): string | undefined => + isSyntheticDndStatus(status) ? undefined : status; + const getUserPresence = (user: User): UserPresence => ({ - presence: user.presence as Presence, - status: user.presenceStatusMsg, + presence: normalizePresence(user.presence, user.presenceStatusMsg), + status: sanitizeStatus(user.presenceStatusMsg), active: user.currentlyActive, lastActiveTs: user.getLastActiveTs(), }); +const getOwnEffectivePresence = ( + sendPresence: boolean, + presenceMode: string | undefined, + autoIdled: boolean +): Presence => { + if (!sendPresence) return Presence.Offline; + if (autoIdled) return Presence.Unavailable; + if (presenceMode === Presence.Unavailable) return Presence.Unavailable; + if (presenceMode === Presence.Offline) return Presence.Offline; + if (presenceMode === Presence.Dnd) return Presence.Dnd; + return Presence.Online; +}; + +const applyOwnPresenceOverride = ( + rawPresence: UserPresence | undefined, + sendPresence: boolean, + presenceMode: string | undefined, + autoIdled: boolean +): UserPresence | undefined => { + const effectivePresence = getOwnEffectivePresence(sendPresence, presenceMode, autoIdled); + const sanitizedStatus = sanitizeStatus(rawPresence?.status); + + if (!rawPresence) { + return { + presence: effectivePresence, + status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus, + active: effectivePresence === Presence.Online || effectivePresence === Presence.Dnd, + }; + } + + return { + ...rawPresence, + presence: effectivePresence, + status: effectivePresence === Presence.Dnd ? undefined : sanitizedStatus, + active: + effectivePresence === Presence.Online || effectivePresence === Presence.Dnd + ? rawPresence.active + : false, + }; +}; + // In-memory presence REST cache to avoid N+1 /presence/{userId}/status floods. // Multiple hook instances for the same user share a single in-flight request. const PRESENCE_CACHE_TTL_MS = 60_000; @@ -57,8 +114,8 @@ function fetchPresenceOnce( .getPresence(userId) .then((resp) => { const data: UserPresence = { - presence: resp.presence as Presence, - status: resp.status_msg, + presence: normalizePresence(resp.presence, resp.status_msg), + status: sanitizeStatus(resp.status_msg), active: resp.currently_active ?? false, lastActiveTs: resp.last_active_ago != null ? Date.now() - resp.last_active_ago : undefined, }; @@ -84,6 +141,9 @@ function fetchPresenceOnce( export const useUserPresence = (userId: string): UserPresence | undefined => { const mx = useMatrixClient(); + const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); + const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); + const autoIdled = useAtomValue(presenceAutoIdledAtom); const user = mx.getUser(userId); const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined)); @@ -138,7 +198,10 @@ export const useUserPresence = (userId: string): UserPresence | undefined => { }; }, [mx, userId, user]); - return presence; + return useMemo(() => { + if (userId !== mx.getUserId()) return presence; + return applyOwnPresenceOverride(presence, sendPresence, presenceMode, autoIdled); + }, [autoIdled, mx, presence, presenceMode, sendPresence, userId]); }; export const usePresenceLabel = (): Record => @@ -147,6 +210,7 @@ export const usePresenceLabel = (): Record => [Presence.Online]: 'Online', [Presence.Unavailable]: 'Idle', [Presence.Offline]: 'Offline', + [Presence.Dnd]: 'Do Not Disturb', }), [] ); diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 632ade28c..9035d10dc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -1,4 +1,4 @@ -import { useAtomValue, useSetAtom, useAtom } from 'jotai'; +import { useAtomValue, useSetAtom } from 'jotai'; import * as Sentry from '@sentry/react'; import type { ReactNode } from 'react'; import { useCallback, useEffect, useRef } from 'react'; @@ -663,23 +663,10 @@ function SyncNotificationSettingsWithServiceWorker() { navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); }; - const postHidden = () => { - // pagehide fires more reliably than visibilitychange on iOS Safari PWA - // when the user locks the screen or backgrounds the app quickly, making - // it less likely that the SW is left with a stale appIsVisible=true. - const msg = { type: 'setAppVisible', visible: false }; - navigator.serviceWorker.controller?.postMessage(msg); - navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(msg)); - }; - // Report initial visibility immediately, then track changes. postVisibility(); document.addEventListener('visibilitychange', postVisibility); - window.addEventListener('pagehide', postHidden); - return () => { - document.removeEventListener('visibilitychange', postVisibility); - window.removeEventListener('pagehide', postHidden); - }; + return () => document.removeEventListener('visibilitychange', postVisibility); }, []); useEffect(() => { @@ -860,40 +847,36 @@ function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); const [presenceMode] = useSetting(settingsAtom, 'presenceMode'); - const [autoIdled] = useAtom(presenceAutoIdledAtom); + const autoIdled = useAtomValue(presenceAutoIdledAtom); const clientConfig = useClientConfig(); const timeoutMs = clientConfig.presenceAutoIdleTimeoutMs ?? 0; usePresenceAutoIdle(mx, presenceMode ?? 'online', sendPresence, timeoutMs); useEffect(() => { - // When auto-idled, broadcast as unavailable regardless of the configured mode. const effectiveMode = autoIdled ? 'unavailable' : (presenceMode ?? 'online'); - // Effective broadcast state: honour effectiveMode when presence is on, otherwise offline. - // DND broadcasts as online (you're active but don't want to be disturbed) with a status_msg. const activePresence = effectiveMode === 'dnd' ? 'online' : effectiveMode; const effectiveState = sendPresence ? activePresence : 'offline'; - const broadcasting = effectiveState !== 'offline'; + const ownUser = mx.getUser(mx.getUserId() ?? ''); + const shouldClearSyntheticDndStatus = + ownUser?.presenceStatusMsg === 'dnd' && (!sendPresence || effectiveMode !== 'dnd'); + let statusPayload: { status_msg: string } | undefined; + + if (sendPresence && effectiveMode === 'dnd') { + statusPayload = { status_msg: 'dnd' }; + } else if (shouldClearSyntheticDndStatus) { + statusPayload = { status_msg: '' }; + } - // Classic sync: set_presence query param on every /sync poll. - // Passing undefined restores the default (online); Offline suppresses broadcasting. - mx.setSyncPresence(broadcasting ? undefined : SetPresence.Offline); - // Sliding sync: keep the extension enabled so we always receive others' presence. - // Only disable it when the master sendPresence toggle is off (full privacy mode). + mx.setSyncPresence(sendPresence ? undefined : SetPresence.Offline); getSlidingSyncManager(mx)?.setPresenceEnabled(sendPresence); - // Explicitly PUT /presence/{userId}/status so the server knows the exact state: - // - MSC4186 servers that have no presence extension see this immediately. - // - When 'offline' (Invisible mode), we appear offline to others but still receive - // their presence events because the extension is still enabled above. const presencePayload = { presence: effectiveState, - ...(sendPresence && effectiveMode === 'dnd' ? { status_msg: 'dnd' } : {}), + ...statusPayload, }; let retryTimer: ReturnType | undefined; const trySetPresence = (attempt = 0) => { mx.setPresence(presencePayload).catch(() => { - // Retry up to 3 times with back-off: the HTTP client may not have - // reconnected yet after the app resumes from background. if (attempt < 3) { retryTimer = setTimeout(() => trySetPresence(attempt + 1), 2000 * (attempt + 1)); } @@ -903,7 +886,7 @@ function PresenceFeature() { return () => { if (retryTimer !== undefined) clearTimeout(retryTimer); }; - }, [mx, sendPresence, presenceMode, autoIdled]); + }, [autoIdled, mx, presenceMode, sendPresence]); return null; } diff --git a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx index 602080484..9516e299d 100644 --- a/src/app/pages/client/sidebar/AccountSwitcherTab.tsx +++ b/src/app/pages/client/sidebar/AccountSwitcherTab.tsx @@ -2,7 +2,6 @@ import type { MouseEvent, MouseEventHandler } from 'react'; import { useCallback, useState, type ReactNode } from 'react'; import type { RectCords } from 'folds'; import { - Badge, Box, Button, Dialog, diff --git a/src/app/pages/client/sidebar/DirectDMsList.tsx b/src/app/pages/client/sidebar/DirectDMsList.tsx index 924d95d7d..eccf06f8d 100644 --- a/src/app/pages/client/sidebar/DirectDMsList.tsx +++ b/src/app/pages/client/sidebar/DirectDMsList.tsx @@ -59,11 +59,17 @@ function DMItem({ room, selected }: DMItemProps) { const member1Presence = useUserPresence(isGroupDM ? (groupMembers[1]?.userId ?? '') : ''); const member2Presence = useUserPresence(isGroupDM ? (groupMembers[2]?.userId ?? '') : ''); - const groupDMOnline = - isGroupDM && - [member0Presence, member1Presence, member2Presence].some( - (p) => p && p.lastActiveTs != null && p.lastActiveTs !== 0 && p.presence === Presence.Online - ); + const groupDMPresence = isGroupDM + ? [member0Presence, member1Presence, member2Presence].reduce( + (acc, current) => { + if (!current || current.lastActiveTs == null || current.lastActiveTs === 0) return acc; + if (current.presence === Presence.Dnd) return Presence.Dnd; + if (!acc && current.presence === Presence.Online) return Presence.Online; + return acc; + }, + undefined + ) + : undefined; let presenceBadge: ReactNode; if ( @@ -73,8 +79,8 @@ function DMItem({ room, selected }: DMItemProps) { singleDMPresence.lastActiveTs !== 0 ) { presenceBadge = ; - } else if (isGroupDM && groupDMOnline) { - presenceBadge = ; + } else if (groupDMPresence) { + presenceBadge = ; } // Get unread info for badge diff --git a/src/app/utils/appEvents.ts b/src/app/utils/appEvents.ts index 2430f5324..2834c5b6f 100644 --- a/src/app/utils/appEvents.ts +++ b/src/app/utils/appEvents.ts @@ -1,29 +1,5 @@ -export type VisibilityChangeHandler = (isVisible: boolean) => void; -type VisibilityHiddenHandler = () => void; - -const visibilityChangeHandlers = new Set(); -const visibilityHiddenHandlers = new Set(); - export const appEvents = { - onVisibilityHidden(handler: VisibilityHiddenHandler): () => void { - visibilityHiddenHandlers.add(handler); - return () => { - visibilityHiddenHandlers.delete(handler); - }; - }, - - emitVisibilityHidden(): void { - visibilityHiddenHandlers.forEach((h) => h()); - }, - - onVisibilityChange(handler: VisibilityChangeHandler): () => void { - visibilityChangeHandlers.add(handler); - return () => { - visibilityChangeHandlers.delete(handler); - }; - }, + onVisibilityHidden: null as (() => void) | null, - emitVisibilityChange(isVisible: boolean): void { - visibilityChangeHandlers.forEach((h) => h(isVisible)); - }, + onVisibilityChange: null as ((isVisible: boolean) => void) | null, }; From 3d2d5756a927b8efad1df07cef4e619faa8c8c89 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 13:38:15 -0400 Subject: [PATCH 064/185] fix(presence): harden desktop auto-idle detection --- src/app/hooks/usePresenceAutoIdle.test.tsx | 13 +++++++ src/app/hooks/usePresenceAutoIdle.ts | 42 ++++++++++++++++++++-- 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/usePresenceAutoIdle.test.tsx b/src/app/hooks/usePresenceAutoIdle.test.tsx index 2fea1eddd..407e7f69c 100644 --- a/src/app/hooks/usePresenceAutoIdle.test.tsx +++ b/src/app/hooks/usePresenceAutoIdle.test.tsx @@ -170,6 +170,19 @@ describe('usePresenceAutoIdle', () => { expect(result.current).toBe(true); }); + it('still goes idle after the window loses focus', () => { + const { result } = renderHook(() => useAutoIdledReader(mockMx, 'online', true, 5000), { + wrapper, + }); + + act(() => { + window.dispatchEvent(new Event('blur')); + vi.advanceTimersByTime(5000); + }); + + expect(result.current).toBe(true); + }); + it('clears auto-idle when presenceMode changes away from online', () => { const { result, rerender } = renderHook( ({ mode }) => useAutoIdledReader(mockMx, mode, true, 5000), diff --git a/src/app/hooks/usePresenceAutoIdle.ts b/src/app/hooks/usePresenceAutoIdle.ts index c4f14a008..6dfad4968 100644 --- a/src/app/hooks/usePresenceAutoIdle.ts +++ b/src/app/hooks/usePresenceAutoIdle.ts @@ -6,6 +6,7 @@ import { createDebugLogger } from '$utils/debugLogger'; const debugLog = createDebugLogger('PresenceAutoIdle'); const ACTIVITY_EVENTS = ['mousemove', 'mousedown', 'keydown', 'touchstart', 'wheel'] as const; +const IDLE_CHECK_INTERVAL_MS = 30_000; /** * Automatically transitions presence to idle after a configurable inactivity @@ -28,6 +29,8 @@ export function usePresenceAutoIdle( const setAutoIdled = useSetAtom(presenceAutoIdledAtom); const autoIdledRef = useRef(false); const timerRef = useRef(undefined); + const intervalRef = useRef(undefined); + const lastActivityAtRef = useRef(Date.now()); const clearTimer = useCallback(() => { if (timerRef.current !== undefined) { @@ -36,11 +39,19 @@ export function usePresenceAutoIdle( } }, []); + const clearIntervalTimer = useCallback(() => { + if (intervalRef.current !== undefined) { + window.clearInterval(intervalRef.current); + intervalRef.current = undefined; + } + }, []); + // Inactivity timer: go idle after timeoutMs without user input. useEffect(() => { const shouldAutoIdle = presenceMode === 'online' && sendPresence && timeoutMs > 0; if (!shouldAutoIdle) { clearTimer(); + clearIntervalTimer(); if (autoIdledRef.current) { autoIdledRef.current = false; setAutoIdled(false); @@ -49,19 +60,36 @@ export function usePresenceAutoIdle( } const goIdle = () => { + if (autoIdledRef.current) return; debugLog.info('general', 'Inactivity timeout — auto-idling'); autoIdledRef.current = true; setAutoIdled(true); }; + const checkIdleDeadline = () => { + const elapsedMs = Date.now() - lastActivityAtRef.current; + if (elapsedMs >= timeoutMs) { + goIdle(); + return; + } + clearTimer(); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs - elapsedMs); + }; + const handleActivity = () => { + lastActivityAtRef.current = Date.now(); clearTimer(); if (autoIdledRef.current) { debugLog.info('general', 'Activity detected — clearing auto-idle'); autoIdledRef.current = false; setAutoIdled(false); } - timerRef.current = window.setTimeout(goIdle, timeoutMs); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs); + }; + + const handleBlur = () => { + debugLog.info('general', 'Window blurred — keeping idle deadline active'); + checkIdleDeadline(); }; const handleVisibilityChange = () => { @@ -69,20 +97,28 @@ export function usePresenceAutoIdle( }; // Start the initial timer. - timerRef.current = window.setTimeout(goIdle, timeoutMs); + lastActivityAtRef.current = Date.now(); + timerRef.current = window.setTimeout(checkIdleDeadline, timeoutMs); + intervalRef.current = window.setInterval( + checkIdleDeadline, + Math.min(timeoutMs, IDLE_CHECK_INTERVAL_MS) + ); ACTIVITY_EVENTS.forEach((ev) => document.addEventListener(ev, handleActivity, { passive: true }) ); document.addEventListener('visibilitychange', handleVisibilityChange); window.addEventListener('focus', handleActivity); + window.addEventListener('blur', handleBlur); return () => { ACTIVITY_EVENTS.forEach((ev) => document.removeEventListener(ev, handleActivity)); document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('focus', handleActivity); + window.removeEventListener('blur', handleBlur); clearTimer(); + clearIntervalTimer(); }; - }, [clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); + }, [clearIntervalTimer, clearTimer, presenceMode, sendPresence, setAutoIdled, timeoutMs]); // Multi-device sync: if another device sets us back to online, clear auto-idle. useEffect(() => { From fdbac9dd5e17bb641ad70a774c7d4d1c62a7d900 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 10:01:36 -0400 Subject: [PATCH 065/185] feat(room-nav): show topic/last-message preview for space and home rooms --- src/app/features/room-nav/RoomNavItem.tsx | 10 +- .../features/settings/cosmetics/Themes.tsx | 31 ++++++ src/app/hooks/useRoomLastMessage.ts | 95 +++++++++++++++++++ src/app/pages/client/home/Home.tsx | 4 + src/app/pages/client/space/Space.tsx | 4 + src/app/state/settings.ts | 4 + 6 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 src/app/hooks/useRoomLastMessage.ts diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index a372b975a..51a0fc4ee 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -72,6 +72,7 @@ import { useAutoDiscoveryInfo } from '$hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '$hooks/useLivekitSupport'; import { Presence, useUserPresence } from '$hooks/useUserPresence'; import { AvatarPresence, PresenceBadge } from '$components/presence'; +import { useRoomLastMessage } from '$hooks/useRoomLastMessage'; import { RoomNavUser } from './RoomNavUser'; /** @@ -260,6 +261,8 @@ type RoomNavItemProps = { showAvatar?: boolean; direct?: boolean; customDMCards?: boolean; + roomTopicPreview?: boolean; + roomMessagePreview?: boolean; }; export function RoomNavItem({ @@ -268,6 +271,8 @@ export function RoomNavItem({ showAvatar, direct, customDMCards, + roomTopicPreview = false, + roomMessagePreview = false, notificationMode, linkPath, }: RoomNavItemProps) { @@ -289,8 +294,11 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); + const lastMessage = useRoomLastMessage(!direct && roomMessagePreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); - const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? presence?.status) : undefined; + const roomTopic = direct + ? ((customDMCards && getRoomTopic) ?? presence?.status) + : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined); const { navigateRoom } = useRoomNavigate(); const navigate = useNavigate(); diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 460ab1f96..91bbccd98 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -521,6 +521,11 @@ export function Appearance({ settingsAtom, 'closeFoldersByDefault' ); + const [roomTopicPreview, setRoomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview, setRoomMessagePreview] = useSetting( + settingsAtom, + 'roomMessagePreview' + ); return ( @@ -573,6 +578,32 @@ export function Appearance({ /> + + + } + /> + + + + + } + /> + + eventToPreviewText(ev) !== undefined); + if (!match) return undefined; + const text = eventToPreviewText(match); + if (!text) return undefined; + + const senderId = match.getSender(); + let prefix: string; + if (senderId === mx.getUserId()) { + prefix = 'You'; + } else { + prefix = room.getMember(senderId ?? '')?.name ?? senderId ?? 'Unknown'; + } + return `${prefix}: ${text}`; +} + +/** + * Reactively returns a human-readable preview of the last message in a room's + * live timeline, prefixed with "You:" or the sender's display name. + * Listens to Timeline and Decrypted events so the preview updates as messages + * arrive or are decrypted. + * Pass `undefined` for room to disable (returns `undefined`). + */ +export function useRoomLastMessage( + room: Room | undefined, + mx: MatrixClient | undefined +): string | undefined { + const [text, setText] = useState(() => + room && mx ? getLastMessageText(room, mx) : undefined + ); + + useEffect(() => { + if (!room || !mx) { + setText(undefined); + return undefined; + } + setText(getLastMessageText(room, mx)); + + const update = () => setText(getLastMessageText(room, mx)); + room.on(RoomEventEnum.Timeline, update); + room.on(RoomEventEnum.LocalEchoUpdated, update); + + // Re-check when any event in this room is decrypted (encrypted → plaintext). + const onDecrypted = (ev: MatrixEvent) => { + if (ev.getRoomId() === room.roomId) update(); + }; + mx.on(MatrixEventEvent.Decrypted, onDecrypted); + + return () => { + room.off(RoomEventEnum.Timeline, update); + room.off(RoomEventEnum.LocalEchoUpdated, update); + mx.off(MatrixEventEvent.Decrypted, onDecrypted); + }; + }, [room, mx]); + + return text; +} diff --git a/src/app/pages/client/home/Home.tsx b/src/app/pages/client/home/Home.tsx index 8e16b5c79..b45947a35 100644 --- a/src/app/pages/client/home/Home.tsx +++ b/src/app/pages/client/home/Home.tsx @@ -204,6 +204,8 @@ export function Home() { const notificationPreferences = useRoomsNotificationPreferencesContext(); const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); + const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); + const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview'); const selectedRoomId = useSelectedRoom(); const createRoomSelected = useHomeCreateSelected(); @@ -353,6 +355,8 @@ export function Home() { Date: Sun, 12 Apr 2026 11:50:31 -0400 Subject: [PATCH 066/185] fix(sliding-sync): increase LIST_TIMELINE_LIMIT to 5 for message previews With timeline_limit: 1, if the latest event is a reaction or edit, the SDK drops it from getLiveTimeline() because it cannot resolve the parent event from a single-event batch. This leaves the timeline empty and breaks the room message preview. Fetching 5 events ensures the parent message is present alongside reactions/edits so the SDK places them correctly and getLastMessageText finds a displayable preview. --- src/client/slidingSync.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index cea5b1f78..3c0e250ad 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -43,9 +43,12 @@ export const LIST_SEARCH = 'search'; export const LIST_ROOM_SEARCH = 'room_search'; // Dynamic list key used for space-scoped room views. export const LIST_SPACE = 'space'; -// One event of timeline per list room is enough to compute unread counts; -// the full history is loaded when the user opens the room. -const LIST_TIMELINE_LIMIT = 1; +// A small number of timeline events per list room. Unread counts come from +// the server-side notification_count field, so a full history isn't needed. +// We fetch a few events (rather than 1) so that reactions and edits — which +// the SDK excludes from the main timeline when their parent event is absent — +// don't leave the timeline empty and break message previews. +const LIST_TIMELINE_LIMIT = 5; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; From 1ee632252480131cae4f5081f21ab8f0e2613b4d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 12:10:58 -0400 Subject: [PATCH 067/185] chore: add changeset for room-message-preview --- .changeset/room-message-preview.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/room-message-preview.md diff --git a/.changeset/room-message-preview.md b/.changeset/room-message-preview.md new file mode 100644 index 000000000..4f8d1cef8 --- /dev/null +++ b/.changeset/room-message-preview.md @@ -0,0 +1,5 @@ +--- +'@sable/client': minor +--- + +feat(room-nav): show topic and last-message preview for rooms in the sidebar, fetching enough timeline events to handle reactions and edits correctly From 7245aa1b87e7b0e4b5c4a493cf30e7b6f04574d2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 22:17:21 -0400 Subject: [PATCH 068/185] feat(dm-list): show latest message preview below room name --- src/app/features/room-nav/RoomNavItem.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 51a0fc4ee..980dba94f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -294,10 +294,10 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); - const lastMessage = useRoomLastMessage(!direct && roomMessagePreview ? room : undefined, mx); + const lastMessage = useRoomLastMessage(roomMessagePreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); const roomTopic = direct - ? ((customDMCards && getRoomTopic) ?? presence?.status) + ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status) : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined); const { navigateRoom } = useRoomNavigate(); From 2fa3342100992baf462a7a0c80363a418dd1679a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 23:13:41 -0400 Subject: [PATCH 069/185] chore: add changeset for dm message preview --- .changeset/feat-dm-message-preview.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/feat-dm-message-preview.md diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md new file mode 100644 index 000000000..ab8e37801 --- /dev/null +++ b/.changeset/feat-dm-message-preview.md @@ -0,0 +1,5 @@ +--- +'@sable/client': minor +--- + +feat(dm-list): show last-message preview below DM room name From b811e453aef5475b1fd7eaf936cfae6347f1b4b1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 00:18:31 -0400 Subject: [PATCH 070/185] feat(dm-list): add toggle to hide DM message preview --- src/app/features/room-nav/RoomNavItem.tsx | 5 ++++- src/app/features/settings/cosmetics/Themes.tsx | 9 +++++++++ src/app/pages/client/direct/Direct.tsx | 2 ++ src/app/state/settings.ts | 2 ++ 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 980dba94f..33d2a9a26 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -263,6 +263,7 @@ type RoomNavItemProps = { customDMCards?: boolean; roomTopicPreview?: boolean; roomMessagePreview?: boolean; + dmMessagePreview?: boolean; }; export function RoomNavItem({ @@ -273,6 +274,7 @@ export function RoomNavItem({ customDMCards, roomTopicPreview = false, roomMessagePreview = false, + dmMessagePreview = true, notificationMode, linkPath, }: RoomNavItemProps) { @@ -294,7 +296,8 @@ export function RoomNavItem({ const matrixRoomName = useRoomName(room); const roomName = (dmUserId && nicknames[dmUserId]) || matrixRoomName; const presence = useUserPresence(dmUserId ?? ''); - const lastMessage = useRoomLastMessage(roomMessagePreview ? room : undefined, mx); + const showPreview = direct ? dmMessagePreview : roomMessagePreview; + const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); const roomTopic = direct ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status) diff --git a/src/app/features/settings/cosmetics/Themes.tsx b/src/app/features/settings/cosmetics/Themes.tsx index 91bbccd98..dd0669e17 100644 --- a/src/app/features/settings/cosmetics/Themes.tsx +++ b/src/app/features/settings/cosmetics/Themes.tsx @@ -515,6 +515,7 @@ export function Appearance({ } = {}) { const [twitterEmoji, setTwitterEmoji] = useSetting(settingsAtom, 'twitterEmoji'); const [customDMCards, setCustomDMCards] = useSetting(settingsAtom, 'customDMCards'); + const [dmMessagePreview, setDmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); const [showEasterEggs, setShowEasterEggs] = useSetting(settingsAtom, 'showEasterEggs'); const [themeBrowserOpen, setThemeBrowserOpen] = useState(false); const [closeFoldersByDefault, setCloseFoldersByDefault] = useSetting( @@ -576,6 +577,14 @@ export function Appearance({ } /> + + } + /> diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index e84e04daa..134fefd1a 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -179,6 +179,7 @@ export function Direct() { const roomToUnread = useAtomValue(roomToUnreadAtom); const navigate = useNavigate(); const [customDMCards] = useSetting(settingsAtom, 'customDMCards'); + const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); const createDirectSelected = useDirectCreateSelected(); @@ -298,6 +299,7 @@ export function Direct() { showAvatar direct customDMCards={customDMCards} + dmMessagePreview={dmMessagePreview} linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))} notificationMode={getRoomNotificationMode( notificationPreferences, diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index ea0c182dd..7f528fd3c 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -161,6 +161,7 @@ export interface Settings { closeFoldersByDefault: boolean; roomTopicPreview: boolean; roomMessagePreview: boolean; + dmMessagePreview: boolean; // furry stuff renderAnimals: boolean; @@ -286,6 +287,7 @@ export const defaultSettings: Settings = { closeFoldersByDefault: false, roomTopicPreview: false, roomMessagePreview: false, + dmMessagePreview: true, // furry stuff renderAnimals: true, From 1909bd0043db2ab61e086b008274990880d82cde Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 22:50:43 -0400 Subject: [PATCH 071/185] refactor(sliding-sync): gate listTimelineLimit behind message preview settings LIST_TIMELINE_LIMIT is now configurable via SlidingSyncConfig.listTimelineLimit (default: 1). When dmMessagePreview or roomMessagePreview is enabled, the limit is bumped to 5 so reactions/edits don't leave the preview empty. Users with both preview settings disabled keep the lightweight limit of 1. --- src/app/pages/client/ClientRoot.tsx | 15 +++++++++++---- src/client/slidingSync.ts | 25 ++++++++++++++----------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 70ba9221e..dba111d54 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -50,6 +50,7 @@ import { useSyncNicknames } from '$hooks/useNickname'; import { useAppVisibility } from '$hooks/useAppVisibility'; import { getHomePath } from '$pages/pathUtils'; import { useClientConfig } from '$hooks/useClientConfig'; +import { getSettings } from '$state/settings'; import { pushSessionToSW } from '../../../sw-session'; import { SyncStatus } from './SyncStatus'; import { SpecVersions } from './SpecVersions'; @@ -214,12 +215,18 @@ export function ClientRoot({ children }: ClientRootProps) { const [startState, startMatrix] = useAsyncCallback( useCallback( - (m) => - startClient(m, { + (m) => { + const s = getSettings(); + const needsPreviewTimeline = s.dmMessagePreview || s.roomMessagePreview; + return startClient(m, { baseUrl: activeSession?.baseUrl, - slidingSync: clientConfig.slidingSync, + slidingSync: { + ...clientConfig.slidingSync, + listTimelineLimit: needsPreviewTimeline ? 5 : undefined, + }, sessionSlidingSyncOptIn: activeSession?.slidingSyncOptIn, - }), + }); + }, [activeSession?.baseUrl, activeSession?.slidingSyncOptIn, clientConfig.slidingSync] ) ); diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 3c0e250ad..015e0c56d 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -45,10 +45,9 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; // A small number of timeline events per list room. Unread counts come from // the server-side notification_count field, so a full history isn't needed. -// We fetch a few events (rather than 1) so that reactions and edits — which -// the SDK excludes from the main timeline when their parent event is absent — -// don't leave the timeline empty and break message previews. -const LIST_TIMELINE_LIMIT = 5; +// When message previews are enabled, a higher limit (e.g. 5) avoids empty +// timelines caused by reactions/edits whose parent event is absent. +const DEFAULT_LIST_TIMELINE_LIMIT = 1; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; @@ -62,7 +61,7 @@ const LIST_SORT_ORDER = ['by_recency', 'by_name']; // Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members. const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; // Timeline limit for the active-room subscription (full history load). -// List entries always use LIST_TIMELINE_LIMIT=1 for lightweight previews. +// List entries use a small timeline limit (default 1) for lightweight previews. const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { @@ -76,6 +75,7 @@ export type SlidingSyncConfig = { proxyBaseUrl?: string; bootstrapClassicOnColdCache?: boolean; listPageSize?: number; + listTimelineLimit?: number; timelineLimit?: number; pollTimeoutMs?: number; maxRooms?: number; @@ -156,7 +156,7 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri ], }); -const buildLists = (pageSize: number, includeInviteList: boolean): Map => { +const buildLists = (pageSize: number, includeInviteList: boolean, listTimelineLimit: number): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); @@ -168,7 +168,7 @@ const buildLists = (pageSize: number, includeInviteList: boolean): Map void; @@ -310,12 +312,13 @@ export class SlidingSyncManager { this.maxRooms = clampPositive(config.maxRooms, DEFAULT_MAX_ROOMS); this.listPageSize = listPageSize; const includeInviteList = config.includeInviteList !== false; + this.listTimelineLimit = clampPositive(config.listTimelineLimit, DEFAULT_LIST_TIMELINE_LIMIT); const roomTimelineLimit = clampPositive(config.timelineLimit, ACTIVE_ROOM_TIMELINE_LIMIT); this.roomTimelineLimit = roomTimelineLimit; const defaultSubscription = buildEncryptedSubscription(roomTimelineLimit); - const lists = buildLists(listPageSize, includeInviteList); + const lists = buildLists(listPageSize, includeInviteList, this.listTimelineLimit); this.listKeys = Array.from(lists.keys()); this.slidingSync = new SlidingSync(proxyBaseUrl, lists, defaultSubscription, mx, pollTimeoutMs); @@ -746,7 +749,7 @@ export class SlidingSyncManager { list = { ranges: [[0, 20]], sort: LIST_SORT_ORDER, - timeline_limit: LIST_TIMELINE_LIMIT, + timeline_limit: this.listTimelineLimit, required_state: buildListRequiredState(), ...updateArgs, }; From 5abd1f8e123707247e4208b60aab2587927fd266 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 15:20:39 -0400 Subject: [PATCH 072/185] fix: use || instead of ?? for DM preview fallback chain The nullish coalescing operator (??) only falls through on null/undefined, but (customDMCards && getRoomTopic) can evaluate to false or empty string, which blocked the fallback to lastMessage. Using || ensures all falsy values correctly fall through to show the message preview. This caused DM message previews to not appear in /direct while the same rooms showed previews in space views (where customDMCards was undefined). --- src/app/features/room-nav/RoomNavItem.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 33d2a9a26..7b3bd56bc 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -300,7 +300,7 @@ export function RoomNavItem({ const lastMessage = useRoomLastMessage(showPreview ? room : undefined, mx); const getRoomTopic = useRoomTopic(room); const roomTopic = direct - ? ((customDMCards && getRoomTopic) ?? lastMessage ?? presence?.status) + ? (customDMCards && getRoomTopic) || lastMessage || presence?.status : (roomTopicPreview && getRoomTopic) || (roomMessagePreview ? lastMessage : undefined); const { navigateRoom } = useRoomNavigate(); From 4ce48da0e05a0b0b32dbbf78b288646660213900 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:02:46 -0400 Subject: [PATCH 073/185] fix(room-nav): address review feedback for message preview - Filter reaction and edit events from last-message preview - Strip reply fallback prefix from preview text - Pass dmMessagePreview setting to RoomNavItem in Space view - Fix changeset frontmatter to use default: minor --- .changeset/feat-dm-message-preview.md | 2 +- .changeset/room-message-preview.md | 2 +- src/app/hooks/useRoomLastMessage.ts | 20 +++++++++++++++++++- src/app/pages/client/space/Space.tsx | 2 ++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.changeset/feat-dm-message-preview.md b/.changeset/feat-dm-message-preview.md index ab8e37801..46cbcff81 100644 --- a/.changeset/feat-dm-message-preview.md +++ b/.changeset/feat-dm-message-preview.md @@ -1,5 +1,5 @@ --- -'@sable/client': minor +default: minor --- feat(dm-list): show last-message preview below DM room name diff --git a/.changeset/room-message-preview.md b/.changeset/room-message-preview.md index 4f8d1cef8..3f8587b85 100644 --- a/.changeset/room-message-preview.md +++ b/.changeset/room-message-preview.md @@ -1,5 +1,5 @@ --- -'@sable/client': minor +default: minor --- feat(room-nav): show topic and last-message preview for rooms in the sidebar, fetching enough timeline events to handle reactions and edits correctly diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index b4c829f10..1e87d0092 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -9,18 +9,36 @@ import { } from '$types/matrix-sdk'; import { MessageEvent } from '$types/matrix/room'; +/** + * Strip the legacy reply fallback (lines starting with `> `) that some + * clients prepend when replying to a message. + */ +function stripReplyFallback(body: string): string { + const lines = body.split('\n'); + let i = 0; + while (i < lines.length && lines[i].startsWith('> ')) i++; + // Skip the blank separator line that follows the fallback block. + if (i > 0 && i < lines.length && lines[i] === '') i++; + return lines.slice(i).join('\n'); +} + function eventToPreviewText(ev: MatrixEvent): string | undefined { if (ev.isRedacted()) return undefined; const type = ev.getType(); + // Skip reactions and edits — they aren't standalone messages. + if (type === MessageEvent.Reaction) return undefined; + const relType = ev.getContent()?.['m.relates_to']?.rel_type; + if (relType === 'm.replace') return undefined; + if (type === MessageEvent.RoomMessageEncrypted) return '🔒 Encrypted message'; if (type === MessageEvent.RoomMessage) { const content = ev.getContent(); const { msgtype } = content; if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { - return content.body; + return stripReplyFallback(content.body); } if (msgtype === MsgType.Image) return '📷 Image'; if (msgtype === MsgType.Video) return '📹 Video'; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index 6acf85bde..7d8dc16fc 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -530,6 +530,7 @@ export function Space() { const [subspaceHierarchyLimit] = useSetting(settingsAtom, 'subspaceHierarchyLimit'); const [roomTopicPreview] = useSetting(settingsAtom, 'roomTopicPreview'); const [roomMessagePreview] = useSetting(settingsAtom, 'roomMessagePreview'); + const [dmMessagePreview] = useSetting(settingsAtom, 'dmMessagePreview'); /** * Creates an SVG used for connecting spaces to their subrooms. * @param virtualizedItems - The virtualized item list that will be used to render elements in the nav @@ -836,6 +837,7 @@ export function Space() { direct={mDirects.has(roomId)} roomTopicPreview={roomTopicPreview} roomMessagePreview={roomMessagePreview} + dmMessagePreview={dmMessagePreview} linkPath={getToLink(roomId)} notificationMode={getRoomNotificationMode( notificationPreferences, From 36065bbc82951e2eb1621823244647580f618630 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 14 Apr 2026 23:25:13 -0400 Subject: [PATCH 074/185] test(room-nav): add useRoomLastMessage unit tests (28 tests) - Test stripReplyFallback: plain text, quoted lines, no separator, multi-line - Test eventToPreviewText: all msg types, encrypted, sticker, reactions, edits, reply fallback - Test getLastMessageText: You prefix, display name, userId fallback, skip reactions, empty timeline - Test useRoomLastMessage hook: undefined room, initial render, Timeline event updates - Export pure functions for testability --- src/app/hooks/useRoomLastMessage.test.tsx | 246 ++++++++++++++++++++++ src/app/hooks/useRoomLastMessage.ts | 6 +- 2 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 src/app/hooks/useRoomLastMessage.test.tsx diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx new file mode 100644 index 000000000..4e3065583 --- /dev/null +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -0,0 +1,246 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + stripReplyFallback, + eventToPreviewText, + getLastMessageText, + useRoomLastMessage, +} from './useRoomLastMessage'; + +// -------- helpers -------- + +function makeEvent(overrides: { + type?: string; + content?: Record; + sender?: string; + roomId?: string; + redacted?: boolean; +}) { + return { + getType: () => overrides.type ?? 'm.room.message', + getContent: () => overrides.content ?? { msgtype: 'm.text', body: 'hello' }, + getSender: () => overrides.sender ?? '@alice:test', + getRoomId: () => overrides.roomId ?? '!room:test', + isRedacted: () => overrides.redacted ?? false, + } as never; +} + +// -------- stripReplyFallback -------- + +describe('stripReplyFallback', () => { + it('returns the body unchanged when there is no fallback', () => { + expect(stripReplyFallback('hello world')).toBe('hello world'); + }); + + it('strips lines starting with > and the blank separator', () => { + const body = '> reply line 1\n> reply line 2\n\nactual message'; + expect(stripReplyFallback(body)).toBe('actual message'); + }); + + it('strips fallback with no separator line', () => { + const body = '> quoted\nrest'; + expect(stripReplyFallback(body)).toBe('rest'); + }); + + it('returns empty string when the entire body is a fallback', () => { + expect(stripReplyFallback('> only quote\n')).toBe(''); + }); + + it('handles multi-line actual message after fallback', () => { + const body = '> quote\n\nline 1\nline 2'; + expect(stripReplyFallback(body)).toBe('line 1\nline 2'); + }); +}); + +// -------- eventToPreviewText -------- + +describe('eventToPreviewText', () => { + it('returns body for m.text message', () => { + const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hi' } }); + expect(eventToPreviewText(ev)).toBe('hi'); + }); + + it('returns body for m.emote message', () => { + const ev = makeEvent({ content: { msgtype: 'm.emote', body: 'waves' } }); + expect(eventToPreviewText(ev)).toBe('waves'); + }); + + it('returns body for m.notice message', () => { + const ev = makeEvent({ content: { msgtype: 'm.notice', body: 'notice' } }); + expect(eventToPreviewText(ev)).toBe('notice'); + }); + + it('returns image icon for m.image', () => { + const ev = makeEvent({ content: { msgtype: 'm.image', body: 'photo.png' } }); + expect(eventToPreviewText(ev)).toBe('📷 Image'); + }); + + it('returns video icon for m.video', () => { + const ev = makeEvent({ content: { msgtype: 'm.video', body: 'clip.mp4' } }); + expect(eventToPreviewText(ev)).toBe('📹 Video'); + }); + + it('returns audio icon for m.audio', () => { + const ev = makeEvent({ content: { msgtype: 'm.audio', body: 'song.mp3' } }); + expect(eventToPreviewText(ev)).toBe('🎵 Audio'); + }); + + it('returns file icon for m.file', () => { + const ev = makeEvent({ content: { msgtype: 'm.file', body: 'doc.pdf' } }); + expect(eventToPreviewText(ev)).toBe('📎 File'); + }); + + it('returns encrypted placeholder for encrypted events', () => { + const ev = makeEvent({ type: 'm.room.encrypted', content: {} }); + expect(eventToPreviewText(ev)).toBe('🔒 Encrypted message'); + }); + + it('returns sticker text', () => { + const ev = makeEvent({ type: 'm.sticker', content: { body: 'party' } }); + expect(eventToPreviewText(ev)).toBe('🎉 party'); + }); + + it('returns undefined for redacted events', () => { + const ev = makeEvent({ redacted: true }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); + + it('returns undefined for reaction events', () => { + const ev = makeEvent({ type: 'm.reaction', content: {} }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); + + it('returns undefined for edit events (m.replace)', () => { + const ev = makeEvent({ + content: { + msgtype: 'm.text', + body: 'edited', + 'm.relates_to': { rel_type: 'm.replace', event_id: '$orig' }, + }, + }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); + + it('strips reply fallback from text body', () => { + const ev = makeEvent({ + content: { msgtype: 'm.text', body: '> quoted\n\nreal message' }, + }); + expect(eventToPreviewText(ev)).toBe('real message'); + }); + + it('returns undefined for unknown event types', () => { + const ev = makeEvent({ type: 'm.room.power_levels', content: {} }); + expect(eventToPreviewText(ev)).toBeUndefined(); + }); +}); + +// -------- getLastMessageText -------- + +describe('getLastMessageText', () => { + const makeMx = (userId = '@alice:test') => + ({ getUserId: () => userId }) as never; + + const makeRoom = (events: ReturnType[], members?: Record) => + ({ + roomId: '!room:test', + getLiveTimeline: () => ({ + getEvents: () => events, + }), + getMember: (id: string) => (members?.[id] ? { name: members[id] } : null), + }) as never; + + it('returns "You: text" when the sender is the current user', () => { + const ev = makeEvent({ sender: '@alice:test', content: { msgtype: 'm.text', body: 'hi' } }); + expect(getLastMessageText(makeRoom([ev]), makeMx())).toBe('You: hi'); + }); + + it('returns "DisplayName: text" for another user', () => { + const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); + const room = makeRoom([ev], { '@bob:test': 'Bob' }); + expect(getLastMessageText(room, makeMx())).toBe('Bob: hey'); + }); + + it('falls back to userId when no display name is available', () => { + const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); + const room = makeRoom([ev]); + expect(getLastMessageText(room, makeMx())).toBe('@bob:test: hey'); + }); + + it('skips reactions and picks the last real message', () => { + const msg = makeEvent({ content: { msgtype: 'm.text', body: 'real' } }); + const reaction = makeEvent({ type: 'm.reaction', content: {} }); + expect(getLastMessageText(makeRoom([msg, reaction]), makeMx())).toBe('You: real'); + }); + + it('returns undefined when there are no displayable events', () => { + const reaction = makeEvent({ type: 'm.reaction', content: {} }); + expect(getLastMessageText(makeRoom([reaction]), makeMx())).toBeUndefined(); + }); + + it('returns undefined for an empty timeline', () => { + expect(getLastMessageText(makeRoom([]), makeMx())).toBeUndefined(); + }); +}); + +// -------- useRoomLastMessage hook -------- + +describe('useRoomLastMessage', () => { + const makeMx = (userId = '@alice:test') => ({ + getUserId: () => userId, + on: vi.fn(), + off: vi.fn(), + }); + + const roomListeners = new Map void)[]>(); + + const makeRoom = (events: ReturnType[]) => ({ + roomId: '!room:test', + getLiveTimeline: () => ({ getEvents: () => events }), + getMember: () => null, + on: vi.fn().mockImplementation((event: string, handler: (...args: unknown[]) => void) => { + const list = roomListeners.get(event) ?? []; + list.push(handler); + roomListeners.set(event, list); + }), + off: vi.fn(), + }); + + beforeEach(() => { + roomListeners.clear(); + }); + + it('returns undefined when room is undefined', () => { + const mx = makeMx(); + const { result } = renderHook(() => useRoomLastMessage(undefined, mx as never)); + expect(result.current).toBeUndefined(); + }); + + it('returns the last message preview on mount', () => { + const ev = makeEvent({ content: { msgtype: 'm.text', body: 'hello' } }); + const room = makeRoom([ev]); + const mx = makeMx(); + const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never)); + expect(result.current).toBe('You: hello'); + }); + + it('updates when a Timeline event fires', () => { + const ev1 = makeEvent({ content: { msgtype: 'm.text', body: 'first' } }); + const events = [ev1]; + const room = makeRoom(events); + const mx = makeMx(); + + const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never)); + expect(result.current).toBe('You: first'); + + // Simulate a new message arriving. + const ev2 = makeEvent({ content: { msgtype: 'm.text', body: 'second' } }); + events.push(ev2); + + const timelineHandlers = roomListeners.get('Room.timeline') ?? []; + act(() => { + timelineHandlers.forEach((h) => h()); + }); + + expect(result.current).toBe('You: second'); + }); +}); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 1e87d0092..7f773ec97 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -13,7 +13,7 @@ import { MessageEvent } from '$types/matrix/room'; * Strip the legacy reply fallback (lines starting with `> `) that some * clients prepend when replying to a message. */ -function stripReplyFallback(body: string): string { +export function stripReplyFallback(body: string): string { const lines = body.split('\n'); let i = 0; while (i < lines.length && lines[i].startsWith('> ')) i++; @@ -22,7 +22,7 @@ function stripReplyFallback(body: string): string { return lines.slice(i).join('\n'); } -function eventToPreviewText(ev: MatrixEvent): string | undefined { +export function eventToPreviewText(ev: MatrixEvent): string | undefined { if (ev.isRedacted()) return undefined; const type = ev.getType(); @@ -53,7 +53,7 @@ function eventToPreviewText(ev: MatrixEvent): string | undefined { return undefined; } -function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { +export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { const events = room.getLiveTimeline().getEvents(); const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined); if (!match) return undefined; From 6cd97b45bf4e8c63b5807d6e66d1fe9cf61058a7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:34:33 -0400 Subject: [PATCH 075/185] fix(room-nav): use effective event type for decrypted message previews Use getEffectiveEvent()?.type instead of getType() to get the decrypted event type. getType() returns the wire type (m.room.encrypted) even after decryption, causing previews to always show 'Encrypted message' instead of the actual message content. --- src/app/hooks/useRoomLastMessage.test.tsx | 17 +++++++++++++++-- src/app/hooks/useRoomLastMessage.ts | 12 ++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 4e3065583..5f685f342 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -15,13 +15,17 @@ function makeEvent(overrides: { sender?: string; roomId?: string; redacted?: boolean; + effectiveType?: string; }) { + const type = overrides.type ?? 'm.room.message'; + const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' }; return { - getType: () => overrides.type ?? 'm.room.message', - getContent: () => overrides.content ?? { msgtype: 'm.text', body: 'hello' }, + getType: () => type, + getContent: () => content, getSender: () => overrides.sender ?? '@alice:test', getRoomId: () => overrides.roomId ?? '!room:test', isRedacted: () => overrides.redacted ?? false, + getEffectiveEvent: () => ({ type: overrides.effectiveType ?? type, content }), } as never; } @@ -95,6 +99,15 @@ describe('eventToPreviewText', () => { expect(eventToPreviewText(ev)).toBe('🔒 Encrypted message'); }); + it('returns decrypted content when event has been decrypted', () => { + const ev = makeEvent({ + type: 'm.room.encrypted', + content: { msgtype: 'm.text', body: 'decrypted text' }, + effectiveType: 'm.room.message', + }); + expect(eventToPreviewText(ev)).toBe('decrypted text'); + }); + it('returns sticker text', () => { const ev = makeEvent({ type: 'm.sticker', content: { body: 'party' } }); expect(eventToPreviewText(ev)).toBe('🎉 party'); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 7f773ec97..c8f27e2a3 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -25,17 +25,21 @@ export function stripReplyFallback(body: string): string { export function eventToPreviewText(ev: MatrixEvent): string | undefined { if (ev.isRedacted()) return undefined; - const type = ev.getType(); + // After decryption, getType() still returns 'm.room.encrypted' (the wire type). + // Use the effective event type to get the decrypted type when available. + const effectiveType = (ev.getEffectiveEvent()?.type as string | undefined) ?? ev.getType(); + const type = effectiveType; + const content = ev.getContent(); // Skip reactions and edits — they aren't standalone messages. if (type === MessageEvent.Reaction) return undefined; - const relType = ev.getContent()?.['m.relates_to']?.rel_type; + const relType = content?.['m.relates_to']?.rel_type; if (relType === 'm.replace') return undefined; + // Only show encrypted placeholder if the event is still encrypted (not yet decrypted). if (type === MessageEvent.RoomMessageEncrypted) return '🔒 Encrypted message'; if (type === MessageEvent.RoomMessage) { - const content = ev.getContent(); const { msgtype } = content; if (msgtype === MsgType.Text || msgtype === MsgType.Emote || msgtype === MsgType.Notice) { return stripReplyFallback(content.body); @@ -47,7 +51,7 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { } if (type === MessageEvent.Sticker) { - return `🎉 ${ev.getContent().body ?? 'Sticker'}`; + return `🎉 ${content.body ?? 'Sticker'}`; } return undefined; From 4383c4bdd3b5f3cd9e9878c8cd99f3cb92605933 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:18:01 -0400 Subject: [PATCH 076/185] chore: fix lint and format issues Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 3 +-- src/app/hooks/useRoomLastMessage.ts | 4 ++-- src/client/slidingSync.ts | 6 +++++- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 5f685f342..e58357834 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -150,8 +150,7 @@ describe('eventToPreviewText', () => { // -------- getLastMessageText -------- describe('getLastMessageText', () => { - const makeMx = (userId = '@alice:test') => - ({ getUserId: () => userId }) as never; + const makeMx = (userId = '@alice:test') => ({ getUserId: () => userId }) as never; const makeRoom = (events: ReturnType[], members?: Record) => ({ diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index c8f27e2a3..e0d6d99f4 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -16,9 +16,9 @@ import { MessageEvent } from '$types/matrix/room'; export function stripReplyFallback(body: string): string { const lines = body.split('\n'); let i = 0; - while (i < lines.length && lines[i].startsWith('> ')) i++; + while (i < lines.length && lines[i].startsWith('> ')) i += 1; // Skip the blank separator line that follows the fallback block. - if (i > 0 && i < lines.length && lines[i] === '') i++; + if (i > 0 && i < lines.length && lines[i] === '') i += 1; return lines.slice(i).join('\n'); } diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 015e0c56d..c1e043177 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -156,7 +156,11 @@ const buildUnencryptedSubscription = (timelineLimit: number): MSC3575RoomSubscri ], }); -const buildLists = (pageSize: number, includeInviteList: boolean, listTimelineLimit: number): Map => { +const buildLists = ( + pageSize: number, + includeInviteList: boolean, + listTimelineLimit: number +): Map => { const lists = new Map(); const listRequiredState = buildListRequiredState(); From a852c75accc62cc6c28e262059b5e8b35ef3df62 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:26:30 -0400 Subject: [PATCH 077/185] docs: clarify that listTimelineLimit scales with message preview setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/client/slidingSync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index c1e043177..5da2713ab 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -61,7 +61,7 @@ const LIST_SORT_ORDER = ['by_recency', 'by_name']; // Encrypted rooms get [*,*] required_state; unencrypted rooms also request lazy members. const UNENCRYPTED_SUBSCRIPTION_KEY = 'unencrypted'; // Timeline limit for the active-room subscription (full history load). -// List entries use a small timeline limit (default 1) for lightweight previews. +// List entries use a configurable timeline limit (default 1; raised to 5 when message previews are enabled). const ACTIVE_ROOM_TIMELINE_LIMIT = 50; export type PartialSlidingSyncRequest = { From ef672d163cfe44eee2aaaef1ae159c3560a4056e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 23:37:30 -0400 Subject: [PATCH 078/185] fix(preview): close decryption race in useRoomLastMessage Subscribe to Decrypted events before reading current state so events that decrypt between the initial render and listener mount are not missed. Explicitly request decryption for the last encrypted event on mount so rooms not yet opened (e.g. sliding-sync previews) resolve their preview text without requiring the user to visit the room. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index e0d6d99f4..a27c86d5a 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -94,18 +94,34 @@ export function useRoomLastMessage( setText(undefined); return undefined; } - setText(getLastMessageText(room, mx)); const update = () => setText(getLastMessageText(room, mx)); + + // Subscribe before reading to close the race window: any decryption that + // completes after this point will trigger an update via the listener. room.on(RoomEventEnum.Timeline, update); room.on(RoomEventEnum.LocalEchoUpdated, update); - // Re-check when any event in this room is decrypted (encrypted → plaintext). const onDecrypted = (ev: MatrixEvent) => { if (ev.getRoomId() === room.roomId) update(); }; mx.on(MatrixEventEvent.Decrypted, onDecrypted); + // Read current state after subscribing to catch any events that decrypted + // between the initial render and the listener mount. + update(); + + // If the last displayable event is still encrypted, explicitly request + // decryption. Sliding sync may not auto-decrypt events in rooms that + // haven't been opened yet; this ensures the preview resolves on mount. + const events = room.getLiveTimeline().getEvents(); + const lastDisplayable = [...events] + .reverse() + .find((ev) => eventToPreviewText(ev) !== undefined); + if (lastDisplayable && lastDisplayable.isEncrypted()) { + mx.decryptEventIfNeeded(lastDisplayable).catch(() => undefined); + } + return () => { room.off(RoomEventEnum.Timeline, update); room.off(RoomEventEnum.LocalEchoUpdated, update); From ed0312e7df50c70240a05094146c4139ae846ec4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 00:42:28 -0400 Subject: [PATCH 079/185] fix(preview): poll/location preview, mxid localpart fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add poll start event preview (📊 + question text) and m.location preview. When room.getMember() returns null (common with sliding sync list subscriptions), fall back to localpart extracted from mxid instead of showing the raw @user:server string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 27 +++++++++++++++++++++-- src/app/hooks/useRoomLastMessage.ts | 24 +++++++++++++++++++- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index e58357834..a049a8e3f 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -16,6 +16,7 @@ function makeEvent(overrides: { roomId?: string; redacted?: boolean; effectiveType?: string; + encrypted?: boolean; }) { const type = overrides.type ?? 'm.room.message'; const content = overrides.content ?? { msgtype: 'm.text', body: 'hello' }; @@ -25,6 +26,7 @@ function makeEvent(overrides: { getSender: () => overrides.sender ?? '@alice:test', getRoomId: () => overrides.roomId ?? '!room:test', isRedacted: () => overrides.redacted ?? false, + isEncrypted: () => overrides.encrypted ?? false, getEffectiveEvent: () => ({ type: overrides.effectiveType ?? type, content }), } as never; } @@ -141,6 +143,27 @@ describe('eventToPreviewText', () => { expect(eventToPreviewText(ev)).toBe('real message'); }); + it('returns poll text for MSC3381 poll start events', () => { + const ev = makeEvent({ + type: 'org.matrix.msc3381.poll.start', + content: { 'org.matrix.msc3381.poll.start': { question: { body: 'Lunch?' } } }, + }); + expect(eventToPreviewText(ev)).toBe('📊 Lunch?'); + }); + + it('returns poll text for stable poll start events', () => { + const ev = makeEvent({ + type: 'm.poll.start', + content: { 'm.poll.start': { question: { body: 'Dinner?' } } }, + }); + expect(eventToPreviewText(ev)).toBe('📊 Dinner?'); + }); + + it('returns location icon for m.location message', () => { + const ev = makeEvent({ content: { msgtype: 'm.location', body: 'geo:0,0' } }); + expect(eventToPreviewText(ev)).toBe('📍 Location'); + }); + it('returns undefined for unknown event types', () => { const ev = makeEvent({ type: 'm.room.power_levels', content: {} }); expect(eventToPreviewText(ev)).toBeUndefined(); @@ -172,10 +195,10 @@ describe('getLastMessageText', () => { expect(getLastMessageText(room, makeMx())).toBe('Bob: hey'); }); - it('falls back to userId when no display name is available', () => { + it('falls back to localpart when no display name is available', () => { const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); const room = makeRoom([ev]); - expect(getLastMessageText(room, makeMx())).toBe('@bob:test: hey'); + expect(getLastMessageText(room, makeMx())).toBe('bob: hey'); }); it('skips reactions and picks the last real message', () => { diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index a27c86d5a..04dc4fd9b 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -48,15 +48,36 @@ export function eventToPreviewText(ev: MatrixEvent): string | undefined { if (msgtype === MsgType.Video) return '📹 Video'; if (msgtype === MsgType.Audio) return '🎵 Audio'; if (msgtype === MsgType.File) return '📎 File'; + if (msgtype === 'm.location') return '📍 Location'; } if (type === MessageEvent.Sticker) { return `🎉 ${content.body ?? 'Sticker'}`; } + // Polls — show the question text when available. + if (type === 'org.matrix.msc3381.poll.start' || type === 'm.poll.start') { + const pollBody = + content?.['org.matrix.msc3381.poll.start']?.question?.body ?? + content?.['m.poll.start']?.question?.body; + return `📊 ${pollBody ?? 'Poll'}`; + } + return undefined; } +/** + * Extract a human-readable name from a Matrix user ID (@localpart:server). + * Falls back to the raw id if the format is unexpected. + */ +function displayNameFromMxid(mxid: string): string { + if (mxid.startsWith('@')) { + const localpart = mxid.slice(1).split(':')[0]; + if (localpart) return localpart; + } + return mxid; +} + export function getLastMessageText(room: Room, mx: MatrixClient): string | undefined { const events = room.getLiveTimeline().getEvents(); const match = [...events].reverse().find((ev) => eventToPreviewText(ev) !== undefined); @@ -69,7 +90,8 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef if (senderId === mx.getUserId()) { prefix = 'You'; } else { - prefix = room.getMember(senderId ?? '')?.name ?? senderId ?? 'Unknown'; + prefix = + room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); } return `${prefix}: ${text}`; } From eac4c42aa2402d3c55a2a48c2bc04be0d2890312 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 16:20:04 -0400 Subject: [PATCH 080/185] fix(timeline): restore useLayoutEffect auto-scroll, fix new-message scroll, fix eventId drag-to-bottom, increase list timeline limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTimelineSync: change auto-scroll recovery useEffect → useLayoutEffect to prevent one-frame flash after timeline reset - useTimelineSync: remove premature scrollToBottom from useLiveTimelineRefresh (operated on pre-commit DOM with stale scrollSize) - useTimelineSync: remove scrollToBottom + eventsLengthRef suppression from useLiveEventArrive; let useLayoutEffect handle scroll after React commits - RoomTimeline: init atBottomState to false when eventId is set, and reset it in the eventId useEffect, so auto-scroll doesn't drag to bottom on bookmark nav - RoomTimeline: change instant scrollToBottom to use scrollToIndex instead of scrollTo(scrollSize) — works correctly regardless of VList measurement state - slidingSync: increase DEFAULT_LIST_TIMELINE_LIMIT 1→3 to reduce empty previews when recent events are reactions/edits/state Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 23 ++++++++++++++++++++--- src/app/hooks/timeline/useTimelineSync.ts | 22 +++++++++------------- src/client/slidingSync.ts | 6 +++--- 3 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..44f1b7c07 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,7 +213,14 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(true); + // Load any cached scroll state for this room on mount. A fresh RoomTimeline is + // mounted per room (via key={roomId} in RoomView) so this is the only place we + // need to read the cache — the render-phase room-change block below only fires + // in the (hypothetical) case where the room prop changes without a remount. + const scrollCacheForRoomRef = useRef( + roomScrollCache.load(mxUserId, room.roomId) + ); + const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -257,7 +264,14 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - vListRef.current.scrollTo(vListRef.current.scrollSize); + if (behavior === 'smooth') { + vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); + } else { + // scrollToIndex works reliably regardless of VList measurement state. + // The auto-scroll useLayoutEffect fires after React commits new items, + // so lastIndex is always valid when this is called. + vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + } }, []); const timelineSync = useTimelineSync({ @@ -420,8 +434,11 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); + // Ensure auto-scroll to bottom doesn't fire while we're navigating to a + // specific event — atBottom will be updated correctly once the user scrolls. + setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId]); + }, [eventId, room.roomId, setAtBottom]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 0abfc8a91..4a7f82192 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -1,5 +1,5 @@ import type { Dispatch, SetStateAction } from 'react'; -import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; +import { useState, useMemo, useCallback, useRef, useEffect, useLayoutEffect } from 'react'; import to from 'await-to-js'; import * as Sentry from '@sentry/react'; import type { @@ -467,9 +467,6 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); - const eventsLengthRef = useRef(eventsLength); - eventsLengthRef.current = eventsLength; - useLiveEventArrive( room, useCallback( @@ -489,9 +486,6 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); - lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; - setTimeline((ct) => ({ ...ct })); return; } @@ -501,7 +495,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] ) ); @@ -526,10 +520,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - if (wasAtBottom) { - scrollToBottom('instant'); - } - }, [room, isAtBottomRef, scrollToBottom]) + // Scroll is handled by the useLayoutEffect auto-scroll recovery which + // fires after React commits the new timeline state — scrolling here + // would operate on the pre-commit DOM with a stale scrollSize. + }, [room, isAtBottomRef]) ); useRelationUpdate( @@ -546,7 +540,9 @@ export function useTimelineSync({ }, []) ); - useEffect(() => { + // useLayoutEffect so scroll fires before paint — prevents the one-frame flash + // where new VList content is briefly visible at the wrong position. + useLayoutEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 5da2713ab..949c1c7d0 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -45,9 +45,9 @@ export const LIST_ROOM_SEARCH = 'room_search'; export const LIST_SPACE = 'space'; // A small number of timeline events per list room. Unread counts come from // the server-side notification_count field, so a full history isn't needed. -// When message previews are enabled, a higher limit (e.g. 5) avoids empty -// timelines caused by reactions/edits whose parent event is absent. -const DEFAULT_LIST_TIMELINE_LIMIT = 1; +// Higher limit avoids empty previews when the most-recent events are +// reactions/edits/state that useRoomLatestRenderedEvent skips over. +const DEFAULT_LIST_TIMELINE_LIMIT = 3; const DEFAULT_LIST_PAGE_SIZE = 250; const DEFAULT_POLL_TIMEOUT_MS = 20000; const DEFAULT_MAX_ROOMS = 5000; From e70e4ace9de3a8b7196c9d6d008398f8c5c9b6a5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 19:33:47 -0400 Subject: [PATCH 081/185] fix(timeline): restore upstream scroll pattern for new messages Restore scrollToBottom call in useLiveEventArrive with instant/smooth based on sender, add back eventsLengthRef and lastScrolledAt suppression, restore scrollToBottom in useLiveTimelineRefresh when wasAtBottom, and revert instant scrollToBottom to scrollTo(scrollSize) matching upstream. The previous changes removed all scroll calls from event arrival handlers and relied solely on the useLayoutEffect auto-scroll recovery, which has timing issues with VList measurement. Upstream's pattern of scrolling in the event handler and suppressing the effect works reliably. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 5 +---- src/app/hooks/timeline/useTimelineSync.ts | 16 +++++++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 44f1b7c07..aacc9a6b5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -267,10 +267,7 @@ export function RoomTimeline({ if (behavior === 'smooth') { vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); } else { - // scrollToIndex works reliably regardless of VList measurement state. - // The auto-scroll useLayoutEffect fires after React commits new items, - // so lastIndex is always valid when this is called. - vListRef.current.scrollToIndex(lastIndex, { align: 'end' }); + vListRef.current.scrollTo(vListRef.current.scrollSize); } }, []); diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 4a7f82192..e19851b1a 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -467,6 +467,9 @@ export function useTimelineSync({ const lastScrolledAtEventsLengthRef = useRef(eventsLength); + const eventsLengthRef = useRef(eventsLength); + eventsLengthRef.current = eventsLength; + useLiveEventArrive( room, useCallback( @@ -486,6 +489,9 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; + setTimeline((ct) => ({ ...ct })); return; } @@ -495,7 +501,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } }, - [mx, room, isAtBottomRef, unreadInfo, setUnreadInfo, hideReadsRef] + [mx, room, isAtBottomRef, unreadInfo, scrollToBottom, setUnreadInfo, hideReadsRef] ) ); @@ -520,10 +526,10 @@ export function useTimelineSync({ const wasAtBottom = isAtBottomRef.current; resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - // Scroll is handled by the useLayoutEffect auto-scroll recovery which - // fires after React commits the new timeline state — scrolling here - // would operate on the pre-commit DOM with a stale scrollSize. - }, [room, isAtBottomRef]) + if (wasAtBottom) { + scrollToBottom('instant'); + } + }, [room, isAtBottomRef, scrollToBottom]) ); useRelationUpdate( From c5af048a42e4a00e1cb06b5d520f137ec7cdb90f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 22:26:19 -0400 Subject: [PATCH 082/185] fix(timeline): align scrollToBottom with upstream, fix eventId race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove behavior parameter from scrollToBottom — always use scrollTo(scrollSize) matching upstream. The smooth scrollToIndex was scrolling to stale lastIndex (before new item measured), leaving new messages below the fold. - Revert auto-scroll recovery from useLayoutEffect back to useEffect (matches upstream). useLayoutEffect fires before VList measures new items and before setAtBottom(false) in eventId effect. - Remove stale scrollCacheForRoomRef that referenced missing imports. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 15 +-------------- src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 14 ++++++-------- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index aacc9a6b5..2022cc280 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,13 +213,6 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - // Load any cached scroll state for this room on mount. A fresh RoomTimeline is - // mounted per room (via key={roomId} in RoomView) so this is the only place we - // need to read the cache — the render-phase room-change block below only fires - // in the (hypothetical) case where the room prop changes without a remount. - const scrollCacheForRoomRef = useRef( - roomScrollCache.load(mxUserId, room.roomId) - ); const [atBottomState, setAtBottomState] = useState(!eventId); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { @@ -264,11 +257,7 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; - if (behavior === 'smooth') { - vListRef.current.scrollToIndex(lastIndex, { align: 'end', smooth: true }); - } else { - vListRef.current.scrollTo(vListRef.current.scrollSize); - } + vListRef.current.scrollTo(vListRef.current.scrollSize); }, []); const timelineSync = useTimelineSync({ @@ -431,8 +420,6 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - // Ensure auto-scroll to bottom doesn't fire while we're navigating to a - // specific event — atBottom will be updated correctly once the user scrolls. setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); }, [eventId, room.roomId, setAtBottom]); diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..5dfc0c9ed 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -130,7 +130,7 @@ describe('useTimelineSync', () => { await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalledWith('instant'); + expect(scrollToBottom).toHaveBeenCalled(); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index e19851b1a..5a4972986 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -352,7 +352,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: (behavior?: 'instant' | 'smooth') => void; + scrollToBottom: () => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -461,7 +461,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom('instant'); + scrollToBottom(); }, [alive, room, scrollToBottom]) ); @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); + scrollToBottom(); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -546,9 +546,7 @@ export function useTimelineSync({ }, []) ); - // useLayoutEffect so scroll fires before paint — prevents the one-frame flash - // where new VList content is briefly visible at the wrong position. - useLayoutEffect(() => { + useEffect(() => { const resetAutoScrollPending = resetAutoScrollPendingRef.current; if (resetAutoScrollPending) resetAutoScrollPendingRef.current = false; @@ -566,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { From 2809ea7ff58fe9fe2a4d7271338d000c51b38726 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:43:28 -0400 Subject: [PATCH 083/185] perf(sidebar): debounce room preview and DM sort updates - Debounce useRoomLastMessage update handler (300ms) to avoid re-rendering every room preview on each timeline event - Debounce Direct.tsx activityCounter (500ms) to batch DM list re-sorts during rapid event bursts (reactions, edits, etc.) - Update test to account for debounced update timing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomLastMessage.test.tsx | 8 +++++++- src/app/hooks/useRoomLastMessage.ts | 16 ++++++++++++---- src/app/pages/client/direct/Direct.tsx | 11 +++++++---- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index a049a8e3f..2e4b725a3 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -259,13 +259,13 @@ describe('useRoomLastMessage', () => { }); it('updates when a Timeline event fires', () => { + vi.useFakeTimers(); const ev1 = makeEvent({ content: { msgtype: 'm.text', body: 'first' } }); const events = [ev1]; const room = makeRoom(events); const mx = makeMx(); const { result } = renderHook(() => useRoomLastMessage(room as never, mx as never)); - expect(result.current).toBe('You: first'); // Simulate a new message arriving. const ev2 = makeEvent({ content: { msgtype: 'm.text', body: 'second' } }); @@ -276,6 +276,12 @@ describe('useRoomLastMessage', () => { timelineHandlers.forEach((h) => h()); }); + // The update is debounced — advance past the 300ms timer. + act(() => { + vi.advanceTimersByTime(350); + }); + expect(result.current).toBe('You: second'); + vi.useRealTimers(); }); }); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index 04dc4fd9b..e40daf938 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { MatrixClient, MatrixEvent, @@ -90,8 +90,7 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef if (senderId === mx.getUserId()) { prefix = 'You'; } else { - prefix = - room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); + prefix = room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); } return `${prefix}: ${text}`; } @@ -111,13 +110,21 @@ export function useRoomLastMessage( room && mx ? getLastMessageText(room, mx) : undefined ); + // Debounce timer ref — cleared on unmount and room change. + const debounceRef = useRef | undefined>(undefined); + useEffect(() => { if (!room || !mx) { setText(undefined); return undefined; } - const update = () => setText(getLastMessageText(room, mx)); + const update = () => { + clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setText(getLastMessageText(room, mx)); + }, 300); + }; // Subscribe before reading to close the race window: any decryption that // completes after this point will trigger an update via the listener. @@ -145,6 +152,7 @@ export function useRoomLastMessage( } return () => { + clearTimeout(debounceRef.current); room.off(RoomEventEnum.Timeline, update); room.off(RoomEventEnum.LocalEchoUpdated, update); mx.off(MatrixEventEvent.Decrypted, onDecrypted); diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 134fefd1a..179d32219 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -188,16 +188,18 @@ export function Direct() { const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); // Track timeline activity to trigger re-sorting when messages arrive. - // Without this, DMs only re-sort when you switch rooms because getLastActiveTimestamp() - // is internal SDK state not tracked by React dependencies. + // Debounced to prevent excessive re-renders on rapid events (reactions, edits, etc.). const [activityCounter, setActivityCounter] = useState(0); const directsSetRef = useRef(directs); + const activityTimerRef = useRef | undefined>(undefined); directsSetRef.current = directs; useEffect(() => { const handleTimeline = () => { - // Increment counter to trigger re-sort when any timeline event happens - setActivityCounter((prev) => prev + 1); + clearTimeout(activityTimerRef.current); + activityTimerRef.current = setTimeout(() => { + setActivityCounter((prev) => prev + 1); + }, 500); }; // Listen to timeline events only for direct message rooms @@ -207,6 +209,7 @@ export function Direct() { }); return () => { + clearTimeout(activityTimerRef.current); directsSetRef.current.forEach((roomId) => { const room = mx.getRoom(roomId); room?.off(RoomEvent.Timeline, handleTimeline); From 40f73823aa4c23c58127be8a9951b1dbdaf7a983 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:03 -0400 Subject: [PATCH 084/185] fix(preview): resolve display names in room previews --- src/app/hooks/useRoomLastMessage.test.tsx | 9 ++++++++- src/app/hooks/useRoomLastMessage.ts | 4 +++- src/client/slidingSync.ts | 16 +++++++++++----- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/src/app/hooks/useRoomLastMessage.test.tsx b/src/app/hooks/useRoomLastMessage.test.tsx index 2e4b725a3..f8cc9d528 100644 --- a/src/app/hooks/useRoomLastMessage.test.tsx +++ b/src/app/hooks/useRoomLastMessage.test.tsx @@ -181,7 +181,8 @@ describe('getLastMessageText', () => { getLiveTimeline: () => ({ getEvents: () => events, }), - getMember: (id: string) => (members?.[id] ? { name: members[id] } : null), + getMember: (id: string) => + members?.[id] ? { name: members[id], rawDisplayName: members[id] } : null, }) as never; it('returns "You: text" when the sender is the current user', () => { @@ -201,6 +202,12 @@ describe('getLastMessageText', () => { expect(getLastMessageText(room, makeMx())).toBe('bob: hey'); }); + it('falls back to localpart when member is loaded but has no display name', () => { + const ev = makeEvent({ sender: '@bob:test', content: { msgtype: 'm.text', body: 'hey' } }); + const room = makeRoom([ev], { '@bob:test': '@bob:test' }); + expect(getLastMessageText(room, makeMx())).toBe('bob: hey'); + }); + it('skips reactions and picks the last real message', () => { const msg = makeEvent({ content: { msgtype: 'm.text', body: 'real' } }); const reaction = makeEvent({ type: 'm.reaction', content: {} }); diff --git a/src/app/hooks/useRoomLastMessage.ts b/src/app/hooks/useRoomLastMessage.ts index e40daf938..92b4c3128 100644 --- a/src/app/hooks/useRoomLastMessage.ts +++ b/src/app/hooks/useRoomLastMessage.ts @@ -8,6 +8,7 @@ import { RoomEvent as RoomEventEnum, } from '$types/matrix-sdk'; import { MessageEvent } from '$types/matrix/room'; +import { getMemberDisplayName } from '$utils/room'; /** * Strip the legacy reply fallback (lines starting with `> `) that some @@ -90,7 +91,8 @@ export function getLastMessageText(room: Room, mx: MatrixClient): string | undef if (senderId === mx.getUserId()) { prefix = 'You'; } else { - prefix = room.getMember(senderId ?? '')?.name ?? displayNameFromMxid(senderId ?? 'Unknown'); + prefix = + getMemberDisplayName(room, senderId ?? '') ?? displayNameFromMxid(senderId ?? 'Unknown'); } return `${prefix}: ${text}`; } diff --git a/src/client/slidingSync.ts b/src/client/slidingSync.ts index 949c1c7d0..0c7905ba4 100644 --- a/src/client/slidingSync.ts +++ b/src/client/slidingSync.ts @@ -106,8 +106,11 @@ const clampPositive = (value: number | undefined, fallback: number): number => { // Notes: // - RoomName/RoomCanonicalAlias are omitted: sliding sync returns the room name as a // top-level field in every list response, so fetching them as state events is redundant. -// - MSC3575_STATE_KEY_LAZY is omitted: lazy-loading members is only needed when the -// user is actively viewing a room; loading them for every list entry wastes bandwidth. +// - MSC3575_STATE_KEY_LAZY is included only when `includeMembers=true` (i.e. when +// message previews are enabled and listTimelineLimit > 0). Lazy loading brings in +// m.room.member state events for senders of the preview timeline events so that +// display names resolve correctly. When previews are disabled, lazy loading is +// omitted to avoid wasteful member fetches for every list entry. // - SpaceChild with wildcard is required: the roomToParents atom reads m.space.child // state events (one per child, keyed by child room ID) to build the space hierarchy. // Without these events the SDK has no parent→child mapping, so all rooms appear as @@ -125,7 +128,9 @@ const clampPositive = (value: number | undefined, fallback: number): number => { // for non-active rooms — notification serverName extraction, mention autocomplete // alias display, and getCanonicalAliasOrRoomId for navigation. Without it, aliases // fall back silently to room IDs. -const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [ +const buildListRequiredState = ( + includeMembers: boolean +): MSC3575RoomSubscription['required_state'] => [ [EventType.RoomJoinRules, ''], [EventType.RoomAvatar, ''], [EventType.RoomTombstone, ''], @@ -134,6 +139,7 @@ const buildListRequiredState = (): MSC3575RoomSubscription['required_state'] => [EventType.RoomTopic, ''], [EventType.RoomCanonicalAlias, ''], [EventType.RoomMember, MSC3575_STATE_KEY_ME], + ...(includeMembers ? [[EventType.RoomMember, MSC3575_STATE_KEY_LAZY] as [string, string]] : []), ['m.space.child', MSC3575_WILDCARD], ['im.ponies.room_emotes', MSC3575_WILDCARD], ['moe.sable.room.abbreviations', ''], @@ -162,7 +168,7 @@ const buildLists = ( listTimelineLimit: number ): Map => { const lists = new Map(); - const listRequiredState = buildListRequiredState(); + const listRequiredState = buildListRequiredState(listTimelineLimit > 0); // Start with a reasonable initial range that will quickly expand to full list // Since timeline_limit=1, loading many rooms is very cheap @@ -754,7 +760,7 @@ export class SlidingSyncManager { ranges: [[0, 20]], sort: LIST_SORT_ORDER, timeline_limit: this.listTimelineLimit, - required_state: buildListRequiredState(), + required_state: buildListRequiredState(this.listTimelineLimit > 0), ...updateArgs, }; } else { From 6270b40dcfdced1789bea61380d03462a76a393c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 20:47:17 -0400 Subject: [PATCH 085/185] fix(preview): remove timeline spillover --- src/app/features/room/RoomTimeline.tsx | 5 ++--- src/app/hooks/timeline/useTimelineSync.test.tsx | 2 +- src/app/hooks/timeline/useTimelineSync.ts | 10 +++++----- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 2022cc280..d63faa989 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -213,7 +213,7 @@ export function RoomTimeline({ const setOpenThread = useSetAtom(openThreadAtom); const vListRef = useRef(null); - const [atBottomState, setAtBottomState] = useState(!eventId); + const [atBottomState, setAtBottomState] = useState(true); const atBottomRef = useRef(atBottomState); const setAtBottom = useCallback((val: boolean) => { setAtBottomState(val); @@ -420,9 +420,8 @@ export function RoomTimeline({ useEffect(() => { if (!eventId) return; setIsReady(false); - setAtBottom(false); timelineSyncRef.current.loadEventTimeline(eventId); - }, [eventId, room.roomId, setAtBottom]); + }, [eventId, room.roomId]); useEffect(() => { if (eventId) return; diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index 5dfc0c9ed..b9d253c6a 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -130,7 +130,7 @@ describe('useTimelineSync', () => { await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalled(); + expect(scrollToBottom).toHaveBeenCalledWith('instant'); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 5a4972986..800e4213d 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -352,7 +352,7 @@ export interface UseTimelineSyncOptions { eventId?: string; isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; - scrollToBottom: () => void; + scrollToBottom: (behavior?: 'instant' | 'smooth') => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -461,7 +461,7 @@ export function useTimelineSync({ useCallback(() => { if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); - scrollToBottom(); + scrollToBottom('instant'); }, [alive, room, scrollToBottom]) ); @@ -489,7 +489,7 @@ export function useTimelineSync({ setUnreadInfo(getRoomUnreadInfo(room)); } - scrollToBottom(); + scrollToBottom(getSender.call(mEvt) === mx.getUserId() ? 'instant' : 'smooth'); lastScrolledAtEventsLengthRef.current = eventsLengthRef.current + 1; setTimeline((ct) => ({ ...ct })); @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom(); + scrollToBottom('instant'); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -564,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom(); + scrollToBottom('instant'); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { From 199efeb9bdd4b9112113c56eacf9940f9360409c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:46:13 -0400 Subject: [PATCH 086/185] fix(hooks): handle unhandled rejections in useAsyncCallback Wrap the inner callback with a no-op .catch() so fire-and-forget call sites (e.g. loadSrc in useEffect) do not produce 'Uncaught (in promise)' console warnings. The promise is still returned and re-thrown for callers that await or chain, so intentional error handling is unaffected. --- src/app/hooks/useAsyncCallback.test.tsx | 2 +- src/app/hooks/useAsyncCallback.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/app/hooks/useAsyncCallback.test.tsx b/src/app/hooks/useAsyncCallback.test.tsx index c27d06478..c6e3f45ce 100644 --- a/src/app/hooks/useAsyncCallback.test.tsx +++ b/src/app/hooks/useAsyncCallback.test.tsx @@ -30,7 +30,7 @@ describe('useAsyncCallback', () => { ); await act(async () => { - await result.current[1]().catch(() => {}); + await expect(result.current[1]()).rejects.toBe(boom); }); expect(result.current[0]).toEqual({ status: AsyncStatus.Error, error: boom }); diff --git a/src/app/hooks/useAsyncCallback.ts b/src/app/hooks/useAsyncCallback.ts index 3412fc87e..2c017726a 100644 --- a/src/app/hooks/useAsyncCallback.ts +++ b/src/app/hooks/useAsyncCallback.ts @@ -74,6 +74,9 @@ export const useAsync = ( }); }); } + // Re-throw so .then()/.catch() callers see the rejection and success + // handlers are skipped. Fire-and-forget unhandled-rejection warnings are + // suppressed at the useAsyncCallback level via a no-op .catch wrapper. throw e; } @@ -103,7 +106,19 @@ export const useAsyncCallback = ( status: AsyncStatus.Idle, }); - const callback = useAsync(asyncCallback, setState); + const innerCallback = useAsync(asyncCallback, setState); + + // Re-throw preserves rejection for callers that await/chain; the no-op .catch + // suppresses "Uncaught (in promise)" for fire-and-forget call sites (e.g. + // loadSrc() in a useEffect) without swallowing the error from intentional callers. + const callback = useCallback( + (...args: TArgs): Promise => { + const p = innerCallback(...args); + p.catch(() => {}); + return p; + }, + [innerCallback] + ) as AsyncCallback; return [state, callback, setState]; }; From c08fdb122d974607b4746eab8d97a80b05a2c352 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 12:25:13 -0400 Subject: [PATCH 087/185] chore: add changeset for async-callback-rejections --- .changeset/async-callback-rejections.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/async-callback-rejections.md diff --git a/.changeset/async-callback-rejections.md b/.changeset/async-callback-rejections.md new file mode 100644 index 000000000..89297b90e --- /dev/null +++ b/.changeset/async-callback-rejections.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix unhandled promise rejections in useAsyncCallback by propagating errors to the error boundary From 54be4722129184ba90559dbc2b2cb954323de299 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:22:52 -0400 Subject: [PATCH 088/185] docs(changeset): accurately describe unhandled-rejection suppression behaviour Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .changeset/async-callback-rejections.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/async-callback-rejections.md b/.changeset/async-callback-rejections.md index 89297b90e..a8e8af829 100644 --- a/.changeset/async-callback-rejections.md +++ b/.changeset/async-callback-rejections.md @@ -2,4 +2,4 @@ default: patch --- -Fix unhandled promise rejections in useAsyncCallback by propagating errors to the error boundary +Suppress "Uncaught (in promise)" console noise for fire-and-forget `useAsyncCallback` call sites; errors are still surfaced to callers that await the returned promise and captured in `AsyncState` From 4906b100a2b0542edfb7ce1af4b3dccb462da221 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 18:48:03 -0400 Subject: [PATCH 089/185] fix(notifications): skip in-app notification for active room MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add activeRoomIdAtom (synced from all RoomProviders) so BackgroundNotifications can detect when the user is already viewing the notification room. When the room matches and the window is focused, the background handler now returns early — no banner, no OS notification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pages/client/BackgroundNotifications.tsx | 15 +++++++++++++++ src/app/pages/client/direct/RoomProvider.tsx | 3 +++ src/app/pages/client/home/RoomProvider.tsx | 3 +++ src/app/pages/client/space/RoomProvider.tsx | 3 +++ src/app/state/room/activeRoomId.ts | 19 +++++++++++++++++++ 5 files changed, 43 insertions(+) create mode 100644 src/app/state/room/activeRoomId.ts diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 79be8d1b4..1ee728936 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -37,6 +37,7 @@ import { createLogger } from '$utils/debug'; import { createDebugLogger } from '$utils/debugLogger'; import LogoSVG from '$public/res/svg/cinny-logo.svg'; import { nicknamesAtom } from '$state/nicknames'; +import { activeRoomIdAtom } from '$state/room/activeRoomId'; import { buildRoomMessageNotification, resolveNotificationPreviewText, @@ -134,8 +135,11 @@ export function BackgroundNotifications() { ); const shouldRunBackgroundNotifications = showNotifications || usePushNotifications; const nicknames = useAtomValue(nicknamesAtom); + const activeRoomId = useAtomValue(activeRoomIdAtom); const nicknamesRef = useRef(nicknames); nicknamesRef.current = nicknames; + const activeRoomIdRef = useRef(activeRoomId); + activeRoomIdRef.current = activeRoomId; // Refs so handleTimeline callbacks always read current settings without stale closures const showNotificationsRef = useRef(showNotifications); showNotificationsRef.current = showNotifications; @@ -480,6 +484,17 @@ export function BackgroundNotifications() { }); }; + // Skip notifications entirely when the active session is viewing + // this exact room and the window has focus — the user is already + // looking at the messages. + if (room.roomId === activeRoomIdRef.current && document.hasFocus()) { + debugLog.debug('notification', 'Skipping notification — room is active', { + roomId: room.roomId, + eventId, + }); + return; + } + // Show in-app banner when app is visible, mobile, and in-app notifications enabled const canShowInAppBanner = document.visibilityState === 'visible' && diff --git a/src/app/pages/client/direct/RoomProvider.tsx b/src/app/pages/client/direct/RoomProvider.tsx index 666477dd0..fe93c7621 100644 --- a/src/app/pages/client/direct/RoomProvider.tsx +++ b/src/app/pages/client/direct/RoomProvider.tsx @@ -4,6 +4,7 @@ import { useSelectedRoom } from '$hooks/router/useSelectedRoom'; import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; +import { useActiveRoomIdSync } from '$state/room/activeRoomId'; import { useDirectRooms } from './useDirectRooms'; export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { @@ -16,6 +17,8 @@ export function DirectRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + useActiveRoomIdSync(roomId); + if (!room || !rooms.includes(room.roomId)) { return ; } diff --git a/src/app/pages/client/home/RoomProvider.tsx b/src/app/pages/client/home/RoomProvider.tsx index 1b48811ce..96057d373 100644 --- a/src/app/pages/client/home/RoomProvider.tsx +++ b/src/app/pages/client/home/RoomProvider.tsx @@ -5,6 +5,7 @@ import { IsDirectRoomProvider, RoomProvider } from '$hooks/useRoom'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { JoinBeforeNavigate } from '$features/join-before-navigate'; import { useSearchParamsViaServers } from '$hooks/router/useSearchParamsViaServers'; +import { useActiveRoomIdSync } from '$state/room/activeRoomId'; import { useHomeRooms } from './useHomeRooms'; export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { @@ -18,6 +19,8 @@ export function HomeRouteRoomProvider({ children }: { children: ReactNode }) { const roomId = useSelectedRoom(); const room = mx.getRoom(roomId); + useActiveRoomIdSync(roomId); + if (!room || !rooms.includes(room.roomId)) { return ( (undefined); + +/** Keep {@link activeRoomIdAtom} in sync with the current route's room. */ +export function useActiveRoomIdSync(roomId: string | undefined): void { + const setActiveRoomId = useSetAtom(activeRoomIdAtom); + useEffect(() => { + setActiveRoomId(roomId); + return () => setActiveRoomId(undefined); + }, [roomId, setActiveRoomId]); +} From 38a731c2e04d92563258629cf6dedff11af4d5f6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 12 Apr 2026 17:10:26 -0400 Subject: [PATCH 090/185] fix(notifications): pass room and userId context to reaction notification filter --- .changeset/reaction-notification-context.md | 5 +++++ src/app/pages/client/BackgroundNotifications.tsx | 2 +- src/app/pages/client/ClientNonUIFeatures.tsx | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/reaction-notification-context.md diff --git a/.changeset/reaction-notification-context.md b/.changeset/reaction-notification-context.md new file mode 100644 index 000000000..18e22446b --- /dev/null +++ b/.changeset/reaction-notification-context.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix reaction notifications not being delivered by passing room and user context to the notification event filter diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 1ee728936..c663a0585 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -352,7 +352,7 @@ export function BackgroundNotifications() { return; } - if (!isNotificationEvent(mEvent)) { + if (!isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 9035d10dc..47c608705 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -342,7 +342,7 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) { + if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { return; } From 58ec144b28b04151b006b756b63406b38ab1041e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 00:43:11 -0400 Subject: [PATCH 091/185] fix(badge): only clear app badge when foregrounded When backgrounded, the service worker manages the badge from push payloads. The app's local unread state may be stale before sync catches up, causing the badge to flash on then immediately off. Guard clearAppBadge() with a visibility check so the SW badge persists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 47c608705..960aaab6b 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -140,7 +140,9 @@ function FaviconUpdater() { // for an OS-level app badge. if (highlightTotal > 0) { navigator.setAppBadge(highlightTotal); - } else { + } else if (document.visibilityState === 'visible') { + // Only clear when foregrounded — the SW sets the badge from push + // payloads while backgrounded, and local state may be stale. navigator.clearAppBadge(); } if (usePushNotifications && registration) { From be9a5a4c791067494070c734b4663833ee868a8c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:54:36 -0400 Subject: [PATCH 092/185] fix(notifications): open joined rooms at live timeline on notification click --- src/app/hooks/useNotificationJumper.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index a03342fc5..655e7679b 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -52,13 +52,17 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - log.log('jumping to:', pending.roomId, pending.eventId); + // Always open joined rooms at the live timeline for notification clicks. + // Event-scoped navigation can create a sparse historical context where the + // room appears to contain only the notification event. + const targetEventId = undefined; + log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); if (mDirects.has(pending.roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getDirectRoomPath(roomIdOrAlias, targetEventId)); } else { // If the room lives inside a space, route through the space path so // SpaceRouteRoomProvider can resolve it — HomeRouteRoomProvider only @@ -74,11 +78,11 @@ export function NotificationJumper() { getSpaceRoomPath( getCanonicalAliasOrRoomId(mx, parentSpace ?? pending.roomId), roomIdOrAlias, - pending.eventId + targetEventId ) ); } else { - navigate(getHomeRoomPath(roomIdOrAlias, pending.eventId)); + navigate(getHomeRoomPath(roomIdOrAlias, targetEventId)); } } setPending(null); From 17b66f1098a97606cc17f4f9969923ce210b3e5d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 02:58:37 -0400 Subject: [PATCH 093/185] fix(notifications): prefer live timeline before event-scoped jump --- src/app/hooks/useNotificationJumper.ts | 27 +++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 655e7679b..0e9467b32 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useAtom, useAtomValue } from 'jotai'; import { useNavigate } from 'react-router-dom'; -import { SyncState, ClientEvent } from '$types/matrix-sdk'; +import { SyncState, ClientEvent, RoomEvent, Room, MatrixEvent } from '$types/matrix-sdk'; import { activeSessionIdAtom, pendingNotificationAtom } from '../state/sessions'; import { mDirectAtom } from '../state/mDirectList'; import { useSyncState } from './useSyncState'; @@ -52,10 +52,22 @@ export function NotificationJumper() { const isJoined = room?.getMyMembership() === 'join'; if (isSyncing && isJoined) { - // Always open joined rooms at the live timeline for notification clicks. - // Event-scoped navigation can create a sparse historical context where the - // room appears to contain only the notification event. - const targetEventId = undefined; + const liveEvents = + room?.getUnfilteredTimelineSet?.()?.getLiveTimeline?.()?.getEvents?.() ?? []; + const eventInLive = pending.eventId + ? liveEvents.some((event) => event.getId() === pending.eventId) + : false; + + // If the live timeline is empty the room data is not ready yet. + // Defer and retry on RoomEvent.Timeline so we can decide with real data. + if (!eventInLive && liveEvents.length === 0) { + log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); + return; + } + + // Keep event targeting when needed, but avoid event-scoped navigation for + // events already in the live timeline to prevent sparse historical context. + const targetEventId = eventInLive ? undefined : pending.eventId; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which @@ -121,11 +133,16 @@ export function NotificationJumper() { if (!pending) return undefined; const onRoom = () => performJumpRef.current(); + const onTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => { + if (eventRoom?.roomId === pending.roomId) performJumpRef.current(); + }; mx.on(ClientEvent.Room, onRoom); + mx.on(RoomEvent.Timeline, onTimeline); performJumpRef.current(); return () => { mx.removeListener(ClientEvent.Room, onRoom); + mx.removeListener(RoomEvent.Timeline, onTimeline); }; }, [pending, mx]); // performJump intentionally omitted — use ref above From 1aec2a830fca44e4bc52c8eb31f7f5a5238a41f5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 13 Apr 2026 03:21:33 -0400 Subject: [PATCH 094/185] fix(notifications): defer event-scoped jump until event appears in live timeline --- src/app/hooks/useNotificationJumper.ts | 43 ++++++++++++++++++++------ 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 0e9467b32..faefae8ed 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -12,6 +12,10 @@ import { getOrphanParents, guessPerfectParent } from '../utils/room'; import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; +// How long to wait for the notification event to appear in the live timeline +// before falling back to opening the room at the live bottom. +const JUMP_TIMEOUT_MS = 15_000; + export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); const activeSessionId = useAtomValue(activeSessionIdAtom); @@ -27,6 +31,9 @@ export function NotificationJumper() { // churn re-calls performJump (from the ClientEvent.Room listener or effect // re-runs) before React has committed the null, causing repeated navigation. const jumpingRef = useRef(false); + // Tracks when we first started waiting for the target event to appear in the + // live timeline. Reset whenever `pending` changes. + const jumpStartTimeRef = useRef(null); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -58,16 +65,33 @@ export function NotificationJumper() { ? liveEvents.some((event) => event.getId() === pending.eventId) : false; - // If the live timeline is empty the room data is not ready yet. - // Defer and retry on RoomEvent.Timeline so we can decide with real data. - if (!eventInLive && liveEvents.length === 0) { - log.log('live timeline empty, deferring jump...', { roomId: pending.roomId }); - return; + // Defer while the target event hasn't arrived in the live timeline yet. + // Navigating with an eventId not in the live timeline triggers a sparse + // historical context load — the room appears empty or shows only one message. + // Retry on each RoomEvent.Timeline until the event appears, then navigate + // with the eventId so the room scrolls to and highlights it in full context. + // After JUMP_TIMEOUT_MS fall back to opening the room at the live bottom. + if (pending.eventId && !eventInLive) { + if (jumpStartTimeRef.current === null) { + jumpStartTimeRef.current = Date.now(); + } + if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) { + log.log('event not yet in live timeline, deferring jump...', { + roomId: pending.roomId, + eventId: pending.eventId, + }); + return; + } + log.log('timed out waiting for event in live; falling back to live bottom', { + roomId: pending.roomId, + eventId: pending.eventId, + }); } - // Keep event targeting when needed, but avoid event-scoped navigation for - // events already in the live timeline to prevent sparse historical context. - const targetEventId = eventInLive ? undefined : pending.eventId; + // Pass eventId only when confirmed in the live timeline — scrolls to and + // highlights the event in full room context without a sparse historical load. + // Falls back to undefined (live bottom) when the event never appears in live. + const targetEventId = eventInLive ? pending.eventId : undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which @@ -108,9 +132,10 @@ export function NotificationJumper() { } }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); - // Reset the guard only when pending is replaced (new notification or cleared). + // Reset guards only when pending is replaced (new notification or cleared). useEffect(() => { jumpingRef.current = false; + jumpStartTimeRef.current = null; }, [pending]); // Keep a stable ref to the latest performJump so that the listeners below From b9def38186e8da41c8152c1ed20dd15dd5bd4527 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 17 Apr 2026 23:46:08 -0400 Subject: [PATCH 095/185] fix(notifications): improve notification jump reliability - Increase jump timeout from 15s to 30s for slow sync catch-up - Always pass eventId on navigation (even after timeout) so the room loads historical context around the notification message instead of dumping the user at live bottom Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useNotificationJumper.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index faefae8ed..657629299 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -13,8 +13,8 @@ import { roomToParentsAtom } from '../state/room/roomToParents'; import { createLogger } from '../utils/debug'; // How long to wait for the notification event to appear in the live timeline -// before falling back to opening the room at the live bottom. -const JUMP_TIMEOUT_MS = 15_000; +// before navigating with the eventId anyway (triggers historical context load). +const JUMP_TIMEOUT_MS = 30_000; export function NotificationJumper() { const [pending, setPending] = useAtom(pendingNotificationAtom); @@ -88,10 +88,11 @@ export function NotificationJumper() { }); } - // Pass eventId only when confirmed in the live timeline — scrolls to and - // highlights the event in full room context without a sparse historical load. - // Falls back to undefined (live bottom) when the event never appears in live. - const targetEventId = eventInLive ? pending.eventId : undefined; + // Pass eventId when confirmed in the live timeline (best case — scrolls to + // and highlights the event in full room context), OR when the timeout fires + // (triggers a historical context load so the user at least sees the message + // they tapped). Only omit eventId when we never had one in the first place. + const targetEventId = pending.eventId ?? undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; // Navigate directly to home or direct path — bypasses space routing which From 5095eddf3ddf706eeee0a12c5a4ecb25d85c9c65 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:26:05 -0400 Subject: [PATCH 096/185] fix(notifications): guarantee jump timeout fallback --- src/app/hooks/useNotificationJumper.test.tsx | 121 +++++++++++++++++++ src/app/hooks/useNotificationJumper.ts | 40 +++++- 2 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 src/app/hooks/useNotificationJumper.test.tsx diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx new file mode 100644 index 000000000..f1076eaf4 --- /dev/null +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -0,0 +1,121 @@ +import { ReactNode } from 'react'; +import { act, render } from '@testing-library/react'; +import { Provider } from 'jotai'; +import { useHydrateAtoms } from 'jotai/utils'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { SyncState } from '$types/matrix-sdk'; +import { getHomeRoomPath } from '$pages/pathUtils'; +import { activeSessionIdAtom, pendingNotificationAtom } from '$state/sessions'; +import { mDirectAtom } from '$state/mDirectList'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { NotificationJumper } from './useNotificationJumper'; + +const navigateMock = vi.fn(); + +const roomTimelineEvents: { getId: () => string }[] = []; +const roomMock = { + roomId: '!room:test', + getMyMembership: vi.fn(() => 'join'), + getCanonicalAlias: vi.fn(() => undefined), + getUnfilteredTimelineSet: vi.fn(() => ({ + getLiveTimeline: () => ({ + getEvents: () => roomTimelineEvents, + }), + })), +}; + +const mxMock = { + getUserId: vi.fn(() => '@alice:test'), + getSyncState: vi.fn(() => SyncState.Syncing), + getRoom: vi.fn(() => roomMock), + getRooms: vi.fn(() => []), + on: vi.fn(), + removeListener: vi.fn(), +}; + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +vi.mock('./useMatrixClient', () => ({ + useMatrixClient: () => mxMock, +})); + +vi.mock('./useSyncState', () => ({ + useSyncState: vi.fn(), +})); + +vi.mock('../utils/debug', () => ({ + createLogger: () => ({ + log: vi.fn(), + }), +})); + +type WrapperProps = { + children: ReactNode; +}; + +function HydrateAtoms({ children }: WrapperProps) { + useHydrateAtoms([ + [activeSessionIdAtom, '@alice:test'], + [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], + [mDirectAtom, new Set()], + [roomToParentsAtom, new Map()], + ]); + + return <>{children}; +} + +function HydratedWrapper({ children }: WrapperProps) { + return ( + + + {children} + + + ); +} + +describe('NotificationJumper', () => { + beforeEach(() => { + vi.useFakeTimers(); + navigateMock.mockReset(); + roomTimelineEvents.length = 0; + roomMock.getMyMembership.mockReturnValue('join'); + mxMock.getUserId.mockReturnValue('@alice:test'); + mxMock.getSyncState.mockReturnValue(SyncState.Syncing); + mxMock.getRoom.mockReturnValue(roomMock); + mxMock.getRooms.mockReturnValue([]); + mxMock.on.mockClear(); + mxMock.removeListener.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('navigates immediately when the target event is already in the live timeline', () => { + roomTimelineEvents.push({ getId: () => '$event:test' }); + + render(, { wrapper: HydratedWrapper }); + + expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test')); + }); + + it('falls back after the timeout even if no further room events arrive', () => { + render(, { wrapper: HydratedWrapper }); + + expect(navigateMock).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(30_000); + }); + + expect(navigateMock).toHaveBeenCalledWith(getHomeRoomPath('!room:test', '$event:test')); + }); +}); diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 657629299..65a1d6334 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -34,6 +34,14 @@ export function NotificationJumper() { // Tracks when we first started waiting for the target event to appear in the // live timeline. Reset whenever `pending` changes. const jumpStartTimeRef = useRef(null); + const jumpTimeoutRef = useRef | undefined>(undefined); + + const clearJumpTimeout = useCallback(() => { + if (jumpTimeoutRef.current !== undefined) { + clearTimeout(jumpTimeoutRef.current); + jumpTimeoutRef.current = undefined; + } + }, []); const performJump = useCallback(() => { if (!pending || jumpingRef.current) return; @@ -75,7 +83,14 @@ export function NotificationJumper() { if (jumpStartTimeRef.current === null) { jumpStartTimeRef.current = Date.now(); } - if (Date.now() - jumpStartTimeRef.current < JUMP_TIMEOUT_MS) { + const elapsedMs = Date.now() - jumpStartTimeRef.current; + if (elapsedMs < JUMP_TIMEOUT_MS) { + if (jumpTimeoutRef.current === undefined) { + jumpTimeoutRef.current = setTimeout(() => { + jumpTimeoutRef.current = undefined; + performJumpRef.current(); + }, JUMP_TIMEOUT_MS - elapsedMs); + } log.log('event not yet in live timeline, deferring jump...', { roomId: pending.roomId, eventId: pending.eventId, @@ -95,6 +110,7 @@ export function NotificationJumper() { const targetEventId = pending.eventId ?? undefined; log.log('jumping to:', pending.roomId, targetEventId); jumpingRef.current = true; + clearJumpTimeout(); // Navigate directly to home or direct path — bypasses space routing which // on mobile shows the space-nav panel first instead of the room timeline. const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, pending.roomId); @@ -131,13 +147,24 @@ export function NotificationJumper() { membership: room?.getMyMembership(), }); } - }, [pending, activeSessionId, mx, mDirects, roomToParents, navigate, setPending, log]); + }, [ + pending, + activeSessionId, + mx, + mDirects, + roomToParents, + navigate, + setPending, + log, + clearJumpTimeout, + ]); // Reset guards only when pending is replaced (new notification or cleared). useEffect(() => { + clearJumpTimeout(); jumpingRef.current = false; jumpStartTimeRef.current = null; - }, [pending]); + }, [pending, clearJumpTimeout]); // Keep a stable ref to the latest performJump so that the listeners below // always invoke the current version without adding performJump to their dep @@ -172,5 +199,12 @@ export function NotificationJumper() { }; }, [pending, mx]); // performJump intentionally omitted — use ref above + useEffect( + () => () => { + clearJumpTimeout(); + }, + [clearJumpTimeout] + ); + return null; } From ba3f2fa3c40fe2143895740bbb0af01e3bac73bd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:26:41 -0400 Subject: [PATCH 097/185] test(notifications): align jumper room mock with room utils --- src/app/hooks/useNotificationJumper.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx index f1076eaf4..efcca25f2 100644 --- a/src/app/hooks/useNotificationJumper.test.tsx +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -18,6 +18,11 @@ const roomMock = { roomId: '!room:test', getMyMembership: vi.fn(() => 'join'), getCanonicalAlias: vi.fn(() => undefined), + getLiveTimeline: vi.fn(() => ({ + getState: vi.fn(() => ({ + getStateEvents: vi.fn(() => undefined), + })), + })), getUnfilteredTimelineSet: vi.fn(() => ({ getLiveTimeline: () => ({ getEvents: () => roomTimelineEvents, From 126bb3d38a9051e4433b95e00c4b1f610cca9013 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:27:06 -0400 Subject: [PATCH 098/185] test(notifications): use current jotai hydrate api --- src/app/hooks/useNotificationJumper.test.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx index efcca25f2..576a06581 100644 --- a/src/app/hooks/useNotificationJumper.test.tsx +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -66,12 +66,14 @@ type WrapperProps = { }; function HydrateAtoms({ children }: WrapperProps) { - useHydrateAtoms([ - [activeSessionIdAtom, '@alice:test'], - [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], - [mDirectAtom, new Set()], - [roomToParentsAtom, new Map()], - ]); + useHydrateAtoms( + new Map([ + [activeSessionIdAtom, '@alice:test'], + [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], + [mDirectAtom, new Set()], + [roomToParentsAtom, new Map()], + ]) + ); return <>{children}; } From 62d712c2b6b5f94da5917784492c1526791201db Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:27:37 -0400 Subject: [PATCH 099/185] test(notifications): initialize jumper atoms via jotai store --- src/app/hooks/useNotificationJumper.test.tsx | 28 +++++++------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/app/hooks/useNotificationJumper.test.tsx b/src/app/hooks/useNotificationJumper.test.tsx index 576a06581..55bde4b48 100644 --- a/src/app/hooks/useNotificationJumper.test.tsx +++ b/src/app/hooks/useNotificationJumper.test.tsx @@ -1,7 +1,6 @@ import { ReactNode } from 'react'; import { act, render } from '@testing-library/react'; -import { Provider } from 'jotai'; -import { useHydrateAtoms } from 'jotai/utils'; +import { Provider, createStore } from 'jotai'; import { MemoryRouter } from 'react-router-dom'; import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; import { SyncState } from '$types/matrix-sdk'; @@ -65,25 +64,16 @@ type WrapperProps = { children: ReactNode; }; -function HydrateAtoms({ children }: WrapperProps) { - useHydrateAtoms( - new Map([ - [activeSessionIdAtom, '@alice:test'], - [pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }], - [mDirectAtom, new Set()], - [roomToParentsAtom, new Map()], - ]) - ); - - return <>{children}; -} - function HydratedWrapper({ children }: WrapperProps) { + const store = createStore(); + store.set(activeSessionIdAtom, '@alice:test'); + store.set(pendingNotificationAtom, { roomId: '!room:test', eventId: '$event:test' }); + store.set(mDirectAtom, { type: 'INITIALIZE', rooms: new Set() }); + store.set(roomToParentsAtom, { type: 'INITIALIZE', roomToParents: new Map() }); + return ( - - - {children} - + + {children} ); } From fe6e0285c0b0e8ffc95148e8ecf78eef4dcad135 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 22:35:14 -0400 Subject: [PATCH 100/185] fix(notifications): avoid jumper ref lint error --- src/app/hooks/useNotificationJumper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/hooks/useNotificationJumper.ts b/src/app/hooks/useNotificationJumper.ts index 65a1d6334..c51f9d338 100644 --- a/src/app/hooks/useNotificationJumper.ts +++ b/src/app/hooks/useNotificationJumper.ts @@ -35,6 +35,7 @@ export function NotificationJumper() { // live timeline. Reset whenever `pending` changes. const jumpStartTimeRef = useRef(null); const jumpTimeoutRef = useRef | undefined>(undefined); + const performJumpRef = useRef<() => void>(() => undefined); const clearJumpTimeout = useCallback(() => { if (jumpTimeoutRef.current !== undefined) { @@ -171,7 +172,6 @@ export function NotificationJumper() { // arrays. Adding performJump as a dep causes the effect to re-run (and call // performJump again) on every atom change during an account switch — that is // the second source of repeated navigation. - const performJumpRef = useRef(performJump); performJumpRef.current = performJump; useSyncState( From ee3fd3ea826f5b1c49f0681dbe85f918ff5907a0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:41:02 -0400 Subject: [PATCH 101/185] fix(notifications): normalize DM room names --- src/app/pages/client/BackgroundNotifications.tsx | 5 +++-- src/app/pages/client/ClientNonUIFeatures.tsx | 7 ++++--- src/app/utils/room.ts | 10 ++++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index c663a0585..9161eb07d 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -28,6 +28,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + getRoomDisplayName, isNotificationEvent, getMDirects, isDMRoom, @@ -451,7 +452,7 @@ export function BackgroundNotifications() { } const notificationPayload = buildRoomMessageNotification({ - roomName: room.name ?? room.getCanonicalAlias() ?? room.roomId, + roomName: getRoomDisplayName(room), roomAvatar, username: senderName, recipientId: session.userId, @@ -511,7 +512,7 @@ export function BackgroundNotifications() { setInAppBannerRef.current({ id: dedupeId, title: notificationPayload.title, - roomName: room.name ?? room.getCanonicalAlias() ?? undefined, + roomName: getRoomDisplayName(room), senderName, body: notificationPayload.options.body, icon: notificationPayload.options.icon, diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 960aaab6b..8232af629 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -36,6 +36,7 @@ import { getMemberDisplayName, getNotificationType, getStateEvent, + getRoomDisplayName, isDMRoom, isNotificationEvent, } from '$utils/room'; @@ -422,7 +423,7 @@ function MessageNotifications() { const avatarMxc = room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl(); const osPayload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', + roomName: getRoomDisplayName(room), roomAvatar: avatarMxc ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) : undefined, @@ -502,7 +503,7 @@ function MessageNotifications() { } const payload = buildRoomMessageNotification({ - roomName: room.name ?? 'Unknown', + roomName: getRoomDisplayName(room), roomAvatar, username: resolvedSenderName, previewText, @@ -517,7 +518,7 @@ function MessageNotifications() { setInAppBanner({ id: eventId, title: payload.title, - roomName: room.name ?? undefined, + roomName: getRoomDisplayName(room), serverName, senderName: resolvedSenderName, body: previewText, diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index e15630c79..db759d0bf 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -561,6 +561,16 @@ export const getMemberDisplayName = ( return name; }; +/** + * Returns the room's display name, normalising the case where the SDK computes + * a raw Matrix user ID for unnamed DMs. In that case, use the localpart so + * notifications and banners do not show the full MXID. + */ +export const getRoomDisplayName = (room: Room): string => { + const { name } = room; + return name.match(/^@([^:]+):/)?.[1] ?? name; +}; + export const getMemberSearchStr = ( member: RoomMember, query: string, From 775b17783ce897c57f15bddd197766bbd91990bd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:32:27 -0400 Subject: [PATCH 102/185] fix(sw): reuse preloaded session in handleMinimalPushPayload onPushNotification already fetches the persisted session and stores it in preloadedSession. Thread that through handleMinimalPushPayload's fallback chain so we skip the second cache.match() call on iOS restarts where the in-memory sessions Map is empty. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/sw.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 222156e72..0219a479b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -423,9 +423,19 @@ async function handleMinimalPushPayload( windowClients: readonly Client[] ): Promise { // On iOS the SW is killed and restarted for every push, clearing the in-memory sessions - // Map. Fall back to the Cache Storage copy that was written when the user last opened - // the app (same pattern as settings persistence). - const session = getAnyStoredSession() ?? (await loadPersistedSession()); + // Map. Fall back to the Cache Storage copy that was written when the user last opened + // the app (same pattern as settings persistence). If onPushNotification already loaded + // the persisted session into preloadedSession, reuse it to avoid a second cache read. + // Last resort: if neither the in-memory map nor the cache has a session, ask any live + // window client for a fresh token (the app may be backgrounded but still alive in memory). + let session = getAnyStoredSession() ?? preloadedSession ?? (await loadPersistedSession()); + if (!session && windowClients.length > 0) { + console.debug('[SW push] no cached session, requesting from window clients'); + const results = await Promise.all( + Array.from(windowClients).map((c) => requestSessionWithTimeout(c.id, 1500)) + ); + session = results.find((r) => r != null) ?? undefined; + } if (!session) { // No session anywhere — app was never opened since install, or the user logged out. From 62ebfb1be2b0ec3164374716272bb3b226c42faa Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 13:35:58 -0400 Subject: [PATCH 103/185] fix(sw): improve push notification reliability and encrypted room handling - Use getEffectiveEvent()?.type for decrypted event type in BackgroundNotifications - Fix isEncryptedRoom flag in pushNotification.ts (was hardcoded false) - Add isEncryptedRoom: true to relay payload when decryption succeeds - Wrap push handlers in try/catch with fallback notifications (prevents silent drops on iOS) - Parallelize requestDecryptionFromClient with Promise.any + shared timeout (was sequential) --- .../pages/client/BackgroundNotifications.tsx | 7 +- src/sw.ts | 95 ++++++++++++------- src/sw/pushNotification.ts | 2 +- 3 files changed, 70 insertions(+), 34 deletions(-) diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 9161eb07d..774c558e6 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -444,6 +444,11 @@ export function BackgroundNotifications() { const isEncryptedRoom = !!getStateEvent(room, EventType.RoomEncryption); + // After decryption, getType() still returns the wire type (m.room.encrypted). + // Use the effective event type to get the decrypted type when available. + const effectiveEventType = + (mEvent.getEffectiveEvent()?.type as string) ?? mEvent.getType(); + notifiedEventsRef.current.add(dedupeId); // Cap the set so it doesn't grow unbounded if (notifiedEventsRef.current.size > 200) { @@ -458,7 +463,7 @@ export function BackgroundNotifications() { recipientId: session.userId, previewText: resolveNotificationPreviewText({ content: mEvent.getContent(), - eventType: mEvent.getType(), + eventType: effectiveEventType, isEncryptedRoom, showMessageContent: showMessageContentRef.current, showEncryptedMessageContent: showEncryptedMessageContentRef.current, diff --git a/src/sw.ts b/src/sw.ts index 0219a479b..d76b2ec3b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -377,39 +377,40 @@ async function requestDecryptionFromClient( ): Promise { const eventId = rawEvent.event_id as string; - // Chain clients sequentially using reduce to avoid await-in-loop and for-of. - return Array.from(windowClients).reduce( - async (prevPromise, client) => { - const prev = await prevPromise; - if (prev?.success) return prev; - - const promise = new Promise((resolve) => { - decryptionPendingMap.set(eventId, resolve); - }); + // Try all window clients in parallel with a single shared timeout. + // This avoids the worst case of N × 5s sequential timeouts when multiple + // tabs are frozen (common on iOS). + const clientAttempts = Array.from(windowClients).map((client) => { + const promise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, resolve); + }); - const timeout = new Promise((resolve) => { - setTimeout(() => { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] timed out waiting for client', client.id); - resolve(undefined); - }, 5000); - }); + try { + (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + } catch (err) { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] postMessage error', err); + return Promise.resolve(undefined as DecryptionResult | undefined); + } - try { - (client as WindowClient).postMessage({ - type: 'decryptPushEvent', - rawEvent, - }); - } catch (err) { - decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] postMessage error', err); - return undefined; - } + return promise as Promise; + }); - return Promise.race([promise, timeout]); - }, - Promise.resolve(undefined) as Promise - ); + if (clientAttempts.length === 0) return undefined; + + const timeout = new Promise((resolve) => { + setTimeout(() => { + decryptionPendingMap.delete(eventId); + console.warn('[SW decryptRelay] timed out waiting for all clients'); + resolve(undefined); + }, 5000); + }); + + // Return as soon as any client succeeds or the shared timeout fires. + return Promise.race([ + Promise.any(clientAttempts).catch(() => undefined), + timeout, + ]); } /** @@ -516,6 +517,7 @@ async function handleMinimalPushPayload( // Prefer relay's room name (has m.direct / computed SDK name); fall back to state fetch. room_name: result.room_name || resolvedRoomName, room_avatar_url: notificationAvatarUrl, + isEncryptedRoom: true, }); } else { // App is frozen or fully closed — show "Encrypted message" fallback. @@ -824,11 +826,40 @@ const onPushNotification = async (event: PushEvent) => { // to relay decryption to an open app tab. if (isMinimalPushPayload(pushData)) { console.debug('[SW push] minimal payload detected — fetching event', pushData.event_id); - await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + try { + await handleMinimalPushPayload(pushData.room_id, pushData.event_id, clients); + } catch (err) { + console.error('[SW push] handleMinimalPushPayload failed:', err); + // Show a generic fallback so the user still sees something on iOS. + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: `room-${pushData.room_id}`, + renotify: true, + data: { room_id: pushData.room_id, event_id: pushData.event_id }, + } as NotificationOptions); + } return; } - await handlePushNotificationPushData(pushData); + try { + await handlePushNotificationPushData(pushData); + } catch (err) { + console.error('[SW push] handlePushNotificationPushData failed:', err); + await self.registration.showNotification('New Message', { + body: undefined, + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + tag: pushData.room_id ? `room-${pushData.room_id}` : (pushData.event_id ?? 'Cinny'), + renotify: true, + data: { + room_id: pushData.room_id, + event_id: pushData.event_id, + user_id: pushData.user_id, + }, + } as NotificationOptions); + } }; // --------------------------------------------------------------------------- diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index d040d066e..a2b36fd38 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -100,7 +100,7 @@ export const createPushNotifications = ( previewText: resolveNotificationPreviewText({ content: pushData?.content, eventType: pushData?.type, - isEncryptedRoom: false, + isEncryptedRoom: pushData?.isEncryptedRoom === true, showMessageContent: getNotificationSettings().showMessageContent, showEncryptedMessageContent: getNotificationSettings().showEncryptedMessageContent, }), From 34b40edfe803e8ecdfec354f74e5e3e2c5e3ad0d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 13:38:15 -0400 Subject: [PATCH 104/185] fix(sw): add media and notification diagnostics --- src/sw.ts | 93 ++++++++++++++++++++++++++++++++++++++ src/sw/pushNotification.ts | 10 ++++ 2 files changed, 103 insertions(+) diff --git a/src/sw.ts b/src/sw.ts index d76b2ec3b..02e4db58c 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -660,6 +660,99 @@ function fetchConfig(token: string): RequestInit { }; } +/** + * Fetch a media URL, retrying once with the most-current in-memory session on 401. + * + * There is a timing window between when the SDK refreshes its access token + * (tokenRefreshFunction resolves) and when the resulting pushSessionToSW() + * postMessage is processed by the SW. Media requests that land in this window + * are sent with the stale token and receive 401. By the time the retry runs, + * the setSession message will normally have been processed and sessions will + * hold the new token. + * + * A second timing window exists at startup: preloadedSession may hold a stale + * token but the live setSession from the page hasn't arrived yet. In that case + * the in-memory check yields no fresher token, so we ask the live client tab + * directly (requestSessionWithTimeout) before giving up. + */ +async function fetchMediaWithRetry( + url: string, + token: string, + redirect: RequestRedirect, + clientId: string +): Promise { + let response = await fetch(url, { ...fetchConfig(token), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + + console.warn('[SW media] Initial authenticated fetch failed', { + url, + status: response.status, + clientId, + hasClientBoundSession: !!(clientId && sessions.get(clientId)), + hasPreloadedSession: !!preloadedSession, + }); + + const attemptedTokens = new Set([token]); + const retrySessions: Array<{ session: SessionInfo; source: string }> = []; + const seenSessions = new Set(); + + const addRetrySession = (session: SessionInfo | undefined, source: string) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seenSessions.has(key)) return; + seenSessions.add(key); + retrySessions.push({ session, source }); + }; + + if (clientId) addRetrySession(sessions.get(clientId), 'client_session'); + getMatchingSessions(url).forEach((session, index) => + addRetrySession(session, `matching_session_${index}`) + ); + addRetrySession(preloadedSession, 'preloaded_session'); + addRetrySession(await loadPersistedSession(), 'persisted_session'); + (await getLiveWindowSessions(url, clientId)).forEach((session, index) => + addRetrySession(session, `live_window_${index}`) + ); + + console.debug('[SW media] Retry candidates collected', { + url, + candidateSources: retrySessions.map(({ source }) => source), + candidateCount: retrySessions.length, + }); + + // Try each plausible token once. This handles token-refresh races and ambiguous + // multi-account sessions on the same homeserver, including no-clientId requests. + // Sequential await is intentional: we want to try one token at a time until one succeeds. + /* eslint-disable no-await-in-loop */ + for (let i = 0; i < retrySessions.length; i += 1) { + const candidate = retrySessions[i]; + if (!candidate || attemptedTokens.has(candidate.session.accessToken)) { + // skip this candidate + } else { + attemptedTokens.add(candidate.session.accessToken); + console.debug('[SW media] Retrying with alternate session', { + url, + source: candidate.source, + attempt: i + 1, + }); + response = await fetch(url, { ...fetchConfig(candidate.session.accessToken), redirect }); + if (!isAuthFailureStatus(response.status)) return response; + console.warn('[SW media] Alternate session also failed auth', { + url, + source: candidate.source, + status: response.status, + }); + } + } + /* eslint-enable no-await-in-loop */ + + console.warn('[SW media] Exhausted authenticated retry candidates', { + url, + finalStatus: response.status, + attemptedTokenCount: attemptedTokens.size, + }); + return response; +} self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; diff --git a/src/sw/pushNotification.ts b/src/sw/pushNotification.ts index a2b36fd38..989b116ba 100644 --- a/src/sw/pushNotification.ts +++ b/src/sw/pushNotification.ts @@ -57,7 +57,17 @@ export const createPushNotifications = ( silent, data, }; + const existingNotifications = await self.registration.getNotifications(); + const replacedCount = existingNotifications.filter( + (notification) => notification.tag === tag + ).length; console.debug('[SW showNotification] title:', title, '| data:', JSON.stringify(data, null, 2)); + console.debug('[SW showNotification] tag diagnostics:', { + tag, + roomId, + renotify, + replacedCount, + }); await self.registration.showNotification(title, notifOptions as NotificationOptions); }; From c37c9d7b7afb323e8d7564a294a0e74cec5883a0 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:30:45 -0400 Subject: [PATCH 105/185] fix(sw): increase session TTL to 24h and add requestSessionWithTimeout fallback Matrix access tokens are long-lived and only invalidated on logout or server revocation. The previous 60s TTL caused iOS push handlers (which restart the SW per push) to reject cached sessions as stale, resulting in generic 'New Message' notifications. Also adds a requestSessionWithTimeout fallback in handleMinimalPushPayload that asks live window clients for a fresh session when neither the in-memory map nor the persisted cache contains a usable session. --- src/sw.ts | 152 +++++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 117 insertions(+), 35 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 02e4db58c..001e0a50b 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -69,9 +69,10 @@ async function loadPersistedSettings() { async function persistSession(session: SessionInfo): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); + const sessionWithTimestamp = { ...session, persistedAt: Date.now() }; await cache.put( SW_SESSION_URL, - new Response(JSON.stringify(session), { + new Response(JSON.stringify(sessionWithTimestamp), { headers: { 'Content-Type': 'application/json' }, }) ); @@ -93,13 +94,32 @@ async function loadPersistedSession(): Promise { try { const cache = await self.caches.open(SW_SESSION_CACHE); const response = await cache.match(SW_SESSION_URL); - if (!response) return undefined; - const s = await response.json(); - if (typeof s.accessToken === 'string' && typeof s.baseUrl === 'string') { + if (response) { + const s = await response.json(); + + // Reject persisted sessions older than 24 hours. Matrix access tokens are + // long-lived and are only invalidated on explicit logout or device revocation — + // not by the passage of time. A short TTL (e.g. 60 s) was too aggressive: it + // caused the SW to show generic "New Message" notifications whenever the app + // was backgrounded for more than a minute, because the cached session was + // rejected and requestSession had no live window client to reach. + // If the token truly is revoked the fetches in handleMinimalPushPayload will + // receive a 401 and gracefully fall back to a generic notification anyway. + const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; + const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + if (age > MAX_SESSION_AGE_MS) { + console.debug('[SW] loadPersistedSession: session expired', { + age, + accessToken: s.accessToken.slice(0, 8), + }); + return undefined; + } + return { accessToken: s.accessToken, baseUrl: s.baseUrl, userId: typeof s.userId === 'string' ? s.userId : undefined, + persistedAt: s.persistedAt, }; } return undefined; @@ -113,6 +133,8 @@ type SessionInfo = { baseUrl: string; /** Matrix user ID of the account, used to identify which account a push belongs to. */ userId?: string; + /** Timestamp when this session was persisted to cache, used to expire stale tokens. */ + persistedAt?: number; }; /** @@ -578,6 +600,14 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); + // Keep the SW alive until the cache write completes. persistSession is + // called fire-and-forget inside setSession; without waitUntil the browser + // can kill the SW before caches.put resolves, leaving the persisted session + // stale on the next restart and causing intermittent 401s on media fetches. + const persisted = sessions.get(client.id); + event.waitUntil( + (persisted ? persistSession(persisted) : clearPersistedSession()).catch(() => undefined) + ); event.waitUntil(cleanupDeadClients()); } if (type === 'pushDecryptResult') { @@ -627,12 +657,24 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { const MEDIA_PATHS = [ '/_matrix/client/v1/media/download', '/_matrix/client/v1/media/thumbnail', + '/_matrix/client/v1/media/preview_url', + '/_matrix/client/v3/media/download', + '/_matrix/client/v3/media/thumbnail', + '/_matrix/client/v3/media/preview_url', + '/_matrix/client/r0/media/download', + '/_matrix/client/r0/media/thumbnail', + '/_matrix/client/r0/media/preview_url', + '/_matrix/client/unstable/org.matrix.msc3916/media/download', + '/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail', + '/_matrix/client/unstable/org.matrix.msc3916/media/preview_url', // Legacy unauthenticated endpoints — servers that require auth return 404/403 // for these when no token is present, so intercept and add auth here too. '/_matrix/media/v3/download', '/_matrix/media/v3/thumbnail', + '/_matrix/media/v3/preview_url', '/_matrix/media/r0/download', '/_matrix/media/r0/thumbnail', + '/_matrix/media/r0/preview_url', ]; function mediaPath(url: string): boolean { @@ -651,6 +693,39 @@ function validMediaRequest(url: string, baseUrl: string): boolean { }); } +function getMatchingSessions(url: string): SessionInfo[] { + return [...sessions.values()].filter((s) => validMediaRequest(url, s.baseUrl)); +} + +function isAuthFailureStatus(status: number): boolean { + return status === 401 || status === 403; +} + +async function getLiveWindowSessions(url: string, clientId: string): Promise { + const collected: SessionInfo[] = []; + const seen = new Set(); + const add = (session?: SessionInfo) => { + if (!session || !validMediaRequest(url, session.baseUrl)) return; + const key = `${session.baseUrl}\x00${session.accessToken}`; + if (seen.has(key)) return; + seen.add(key); + collected.push(session); + }; + + if (clientId) { + add(await requestSessionWithTimeout(clientId, 1500)); + return collected; + } + + const windowClients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }); + const liveSessions = await Promise.all( + windowClients.map((client) => requestSessionWithTimeout(client.id, 750)) + ); + liveSessions.forEach((session) => add(session)); + + return collected; +} + function fetchConfig(token: string): RequestInit { return { headers: { @@ -753,6 +828,7 @@ async function fetchMediaWithRetry( }); return response; } + self.addEventListener('message', (event: ExtendableMessageEvent) => { if (event.data.type === 'togglePush') { const token = event.data?.token; @@ -783,40 +859,24 @@ self.addEventListener('fetch', (event: FetchEvent) => { const session = clientId ? sessions.get(clientId) : undefined; if (session && validMediaRequest(url, session.baseUrl)) { - event.respondWith(fetch(url, { ...fetchConfig(session.accessToken), redirect })); - return; - } - - // Since widgets like element call have their own client ids, - // we need this logic. We just go through the sessions list and get a session - // with the right base url. Media requests to a homeserver simply are fine with any account - // on the homeserver authenticating it, so this is fine. But it can be technically wrong. - // If you have two tabs for different users on the same homeserver, it might authenticate - // as the wrong one. - // Thus any logic in the future which cares about which user is authenticating the request - // might break this. Also, again, it is technically wrong. - // Also checks preloadedSession — populated from cache at SW activate — for the window - // between SW restart and the first live setSession arriving from the page. - const byBaseUrl = - [...sessions.values()].find((s) => validMediaRequest(url, s.baseUrl)) ?? - (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl) - ? preloadedSession - : undefined); - if (byBaseUrl) { - event.respondWith(fetch(url, { ...fetchConfig(byBaseUrl.accessToken), redirect })); + event.respondWith(fetchMediaWithRetry(url, session.accessToken, redirect, clientId)); return; } // No clientId: the fetch came from a context not associated with a specific - // window (e.g. a prerender). Fall back to the persisted session directly. + // window (e.g. a prerender). Fall back to persisted/unique-by-baseUrl sessions. if (!clientId) { event.respondWith( loadPersistedSession().then((persisted) => { if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { - ...fetchConfig(persisted.accessToken), - redirect, - }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, ''); + } + const matching = getMatchingSessions(url); + if (matching.length === 1) { + return fetchMediaWithRetry(url, matching[0].accessToken, redirect, ''); + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + return fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, ''); } return fetch(event.request); }) @@ -824,17 +884,30 @@ self.addEventListener('fetch', (event: FetchEvent) => { return; } + // Synchronous fast-path: check in-memory sessions by baseUrl and the + // preloaded session before paying the 3-second requestSessionWithTimeout + // cost. This restores the old byBaseUrl behaviour while keeping retry logic. + const syncByBaseUrl = getMatchingSessions(url); + if (syncByBaseUrl.length === 1) { + event.respondWith(fetchMediaWithRetry(url, syncByBaseUrl[0].accessToken, redirect, clientId)); + return; + } + if (preloadedSession && validMediaRequest(url, preloadedSession.baseUrl)) { + event.respondWith(fetchMediaWithRetry(url, preloadedSession.accessToken, redirect, clientId)); + return; + } + event.respondWith( requestSessionWithTimeout(clientId).then(async (s) => { // Primary: session received from the live client window. if (s && validMediaRequest(url, s.baseUrl)) { - return fetch(url, { ...fetchConfig(s.accessToken), redirect }); + return fetchMediaWithRetry(url, s.accessToken, redirect, clientId); } // Fallback: try the persisted session (helps when SW restarts on iOS and // the client window hasn't responded to requestSession yet). const persisted = await loadPersistedSession(); if (persisted && validMediaRequest(url, persisted.baseUrl)) { - return fetch(url, { ...fetchConfig(persisted.accessToken), redirect }); + return fetchMediaWithRetry(url, persisted.accessToken, redirect, clientId); } console.warn( '[SW fetch] No valid session for media request', @@ -868,10 +941,19 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. - // Combine clients.matchAll() visibility with the explicit appIsVisible flag - // because iOS Safari PWA often returns empty or stale results from matchAll(). + // + // When clients.matchAll() returns ≥1 client, trust its visibilityState + // directly. iOS can suspend the JS thread before postMessage({ visible: + // false }) is processed, leaving appIsVisible stuck at true. matchAll() + // still reports the backgrounded client as 'hidden', so it is the + // authoritative and most reliable signal. + // + // When matchAll() returns zero clients (a separate iOS Safari PWA quirk), + // visibility is unknowable — do NOT suppress. Better to show a duplicate + // (handled gracefully by the in-app banner) than to silently drop a + // notification while the app is backgrounded. const hasVisibleClient = - appIsVisible || clients.some((client) => client.visibilityState === 'visible'); + clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; console.debug( '[SW push] appIsVisible:', appIsVisible, From be49cdef526606ca32d1e1288220e69e0ab71430 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 11 Apr 2026 17:58:21 -0400 Subject: [PATCH 106/185] fix(sw): reset heartbeat backoff on foreground sync; warm preloadedSession from push handler When phase3AdaptiveBackoffJitter is enabled, successful foreground/focus session pushes (phase1ForegroundResync) now reset heartbeatFailuresRef to 0. Previously a period of SW controller absence (e.g. SW update) could inflate the heartbeat interval to its maximum (30 min) even after the SW became healthy again, reducing session-refresh frequency below the intended 10-minute rate. Also captures the loadPersistedSession() result in onPushNotification and assigns it to preloadedSession, avoiding a redundant second cache read in handleMinimalPushPayload when the SW is restarted by iOS for a push event. --- src/app/hooks/useAppVisibility.ts | 213 +++++++++++++++++++++++++++++- src/sw.ts | 8 +- 2 files changed, 216 insertions(+), 5 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 7fd5f2325..ed2d69cfb 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -1,23 +1,112 @@ -import { useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; +import { Session } from '$state/sessions'; import { useAtom } from 'jotai'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; -import { useClientConfig } from './useClientConfig'; +import { useClientConfig, useExperimentVariant } from './useClientConfig'; import { useSetting } from '../state/hooks/settings'; import { settingsAtom } from '../state/settings'; import { pushSubscriptionAtom } from '../state/pushSubscription'; import { mobileOrTablet } from '../utils/user-agent'; import { createDebugLogger } from '../utils/debugLogger'; +import { pushSessionToSW } from '../../sw-session'; const debugLog = createDebugLogger('AppVisibility'); -export function useAppVisibility(mx: MatrixClient | undefined) { +const DEFAULT_FOREGROUND_DEBOUNCE_MS = 1500; +const DEFAULT_HEARTBEAT_INTERVAL_MS = 10 * 60 * 1000; +const DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS = 60 * 1000; +const DEFAULT_HEARTBEAT_MAX_BACKOFF_MS = 30 * 60 * 1000; + +export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: Session) { const clientConfig = useClientConfig(); const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const pushSubAtom = useAtom(pushSubscriptionAtom); const isMobile = mobileOrTablet(); + const sessionSyncConfig = clientConfig.sessionSync; + const sessionSyncVariant = useExperimentVariant('sessionSyncStrategy', activeSession?.userId); + + // Derive phase flags from experiment variant; fall back to direct config when not in experiment. + const inSessionSync = sessionSyncVariant.inExperiment; + const syncVariant = sessionSyncVariant.variant; + const phase1ForegroundResync = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase1ForegroundResync === true; + const phase2VisibleHeartbeat = inSessionSync + ? syncVariant === 'session-sync-heartbeat' || syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase2VisibleHeartbeat === true; + const phase3AdaptiveBackoffJitter = inSessionSync + ? syncVariant === 'session-sync-adaptive' + : sessionSyncConfig?.phase3AdaptiveBackoffJitter === true; + + const foregroundDebounceMs = Math.max( + 0, + sessionSyncConfig?.foregroundDebounceMs ?? DEFAULT_FOREGROUND_DEBOUNCE_MS + ); + const heartbeatIntervalMs = Math.max( + 1000, + sessionSyncConfig?.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS + ); + const resumeHeartbeatSuppressMs = Math.max( + 0, + sessionSyncConfig?.resumeHeartbeatSuppressMs ?? DEFAULT_RESUME_HEARTBEAT_SUPPRESS_MS + ); + const heartbeatMaxBackoffMs = Math.max( + heartbeatIntervalMs, + sessionSyncConfig?.heartbeatMaxBackoffMs ?? DEFAULT_HEARTBEAT_MAX_BACKOFF_MS + ); + + const lastForegroundPushAtRef = useRef(0); + const suppressHeartbeatUntilRef = useRef(0); + const heartbeatFailuresRef = useRef(0); + + const pushSessionNow = useCallback( + (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { + const baseUrl = activeSession?.baseUrl; + const accessToken = activeSession?.accessToken; + const userId = activeSession?.userId; + const canPush = + !!mx && + typeof baseUrl === 'string' && + typeof accessToken === 'string' && + typeof userId === 'string' && + 'serviceWorker' in navigator && + !!navigator.serviceWorker.controller; + + if (!canPush) { + debugLog.warn('network', 'Skipped SW session sync', { + reason, + hasClient: !!mx, + hasBaseUrl: !!baseUrl, + hasAccessToken: !!accessToken, + hasUserId: !!userId, + hasSwController: !!navigator.serviceWorker?.controller, + }); + return 'skipped'; + } + + pushSessionToSW(baseUrl, accessToken, userId); + debugLog.info('network', 'Pushed session to SW', { + reason, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + }); + return 'sent'; + }, + [ + activeSession?.accessToken, + activeSession?.baseUrl, + activeSession?.userId, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + ] + ); + useEffect(() => { const handleVisibilityChange = () => { const isVisible = document.visibilityState === 'visible'; @@ -29,15 +118,66 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { appEvents.onVisibilityHidden?.(); + return; + } + + // Always kick the sync loop on foreground regardless of phase flags — + // the SDK may be sitting in exponential backoff after iOS froze the tab. + mx?.retryImmediately(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('foreground') === 'sent') { + // A successful push proves the SW controller is up — reset adaptive backoff + // so the heartbeat returns to its normal interval immediately rather than + // staying on an inflated delay left over from a prior SW absence period. + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } + } + }; + + const handleFocus = () => { + if (document.visibilityState !== 'visible') return; + + // Always kick the sync loop on focus for the same reason as above. + mx?.retryImmediately(); + + if (!phase1ForegroundResync) return; + + const now = Date.now(); + if (now - lastForegroundPushAtRef.current < foregroundDebounceMs) return; + lastForegroundPushAtRef.current = now; + + if (pushSessionNow('focus') === 'sent') { + if (phase3AdaptiveBackoffJitter) heartbeatFailuresRef.current = 0; + if (phase3AdaptiveBackoffJitter && phase2VisibleHeartbeat) { + suppressHeartbeatUntilRef.current = now + resumeHeartbeatSuppressMs; + } } }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('focus', handleFocus); }; - }, []); + }, [ + foregroundDebounceMs, + mx, + phase1ForegroundResync, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + resumeHeartbeatSuppressMs, + ]); useEffect(() => { if (!mx) return; @@ -52,4 +192,69 @@ export function useAppVisibility(mx: MatrixClient | undefined) { appEvents.onVisibilityChange = null; }; }, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]); + + useEffect(() => { + if (!phase2VisibleHeartbeat) return undefined; + + // Reset adaptive backoff/suppression so a config or session change starts fresh. + heartbeatFailuresRef.current = 0; + suppressHeartbeatUntilRef.current = 0; + + let timeoutId: number | undefined; + + const getDelayMs = (): number => { + let delay = heartbeatIntervalMs; + + if (phase3AdaptiveBackoffJitter) { + const failures = heartbeatFailuresRef.current; + const backoffFactor = Math.min(2 ** failures, heartbeatMaxBackoffMs / heartbeatIntervalMs); + delay = Math.min(heartbeatMaxBackoffMs, Math.round(heartbeatIntervalMs * backoffFactor)); + + // Add +-20% jitter to avoid synchronized heartbeat spikes across many clients. + const jitter = 0.8 + Math.random() * 0.4; + delay = Math.max(1000, Math.round(delay * jitter)); + } + + return delay; + }; + + const tick = () => { + const now = Date.now(); + + if (document.visibilityState !== 'visible' || !navigator.onLine) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + if (phase3AdaptiveBackoffJitter && now < suppressHeartbeatUntilRef.current) { + timeoutId = window.setTimeout(tick, getDelayMs()); + return; + } + + const result = pushSessionNow('heartbeat'); + if (phase3AdaptiveBackoffJitter) { + if (result === 'sent') { + heartbeatFailuresRef.current = 0; + } else { + // 'skipped' means prerequisites (SW controller, session) aren't ready. + // Treat as a transient failure so backoff grows until the SW is ready. + heartbeatFailuresRef.current += 1; + } + } + + timeoutId = window.setTimeout(tick, getDelayMs()); + }; + + timeoutId = window.setTimeout(tick, getDelayMs()); + + return () => { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + }; + }, [ + heartbeatIntervalMs, + heartbeatMaxBackoffMs, + phase2VisibleHeartbeat, + phase3AdaptiveBackoffJitter, + pushSessionNow, + ]); } diff --git a/src/sw.ts b/src/sw.ts index 001e0a50b..05396230d 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -933,11 +933,17 @@ const onPushNotification = async (event: PushEvent) => { // The SW may have been restarted by the OS (iOS is aggressive about this), // so in-memory settings would be at their defaults. Reload from cache and // match active clients in parallel — they are independent operations. - const [, , clients] = await Promise.all([ + // Capture the persisted session result into preloadedSession so that + // getAnyStoredSession() returns it in handleMinimalPushPayload without a + // second cache read. + const [, persistedSession, clients] = await Promise.all([ loadPersistedSettings(), loadPersistedSession(), self.clients.matchAll({ type: 'window', includeUncontrolled: true }), ]); + if (persistedSession && !preloadedSession) { + preloadedSession = persistedSession; + } // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. From bc73097016bb501422ac54f8e6cbc46363c54654 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 19:19:59 -0400 Subject: [PATCH 107/185] fix(notifications): restore background visibility sync --- src/app/hooks/useAppVisibility.ts | 19 +++++++++++++--- src/sw.ts | 38 ++++++++++++++++--------------- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index ed2d69cfb..c8cf46c6a 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -61,6 +61,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S const lastForegroundPushAtRef = useRef(0); const suppressHeartbeatUntilRef = useRef(0); const heartbeatFailuresRef = useRef(0); + const lastEmittedVisibilityRef = useRef(undefined); const pushSessionNow = useCallback( (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { @@ -108,12 +109,14 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S ); useEffect(() => { - const handleVisibilityChange = () => { - const isVisible = document.visibilityState === 'visible'; + const handleVisibilityState = (isVisible: boolean, source: 'visibilitychange' | 'pagehide') => { + if (lastEmittedVisibilityRef.current === isVisible) return; + lastEmittedVisibilityRef.current = isVisible; + debugLog.info( 'general', `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, - { visibilityState: document.visibilityState } + { visibilityState: document.visibilityState, source } ); appEvents.onVisibilityChange?.(isVisible); if (!isVisible) { @@ -142,6 +145,14 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S } }; + const handleVisibilityChange = () => { + handleVisibilityState(document.visibilityState === 'visible', 'visibilitychange'); + }; + + const handlePageHide = () => { + handleVisibilityState(false, 'pagehide'); + }; + const handleFocus = () => { if (document.visibilityState !== 'visible') return; @@ -163,10 +174,12 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S }; document.addEventListener('visibilitychange', handleVisibilityChange); + window.addEventListener('pagehide', handlePageHide); window.addEventListener('focus', handleFocus); return () => { document.removeEventListener('visibilitychange', handleVisibilityChange); + window.removeEventListener('pagehide', handlePageHide); window.removeEventListener('focus', handleFocus); }; }, [ diff --git a/src/sw.ts b/src/sw.ts index 05396230d..2f4c3ace2 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -398,41 +398,43 @@ async function requestDecryptionFromClient( rawEvent: Record ): Promise { const eventId = rawEvent.event_id as string; - - // Try all window clients in parallel with a single shared timeout. - // This avoids the worst case of N × 5s sequential timeouts when multiple - // tabs are frozen (common on iOS). - const clientAttempts = Array.from(windowClients).map((client) => { - const promise = new Promise((resolve) => { - decryptionPendingMap.set(eventId, resolve); + if (windowClients.length === 0) return undefined; + + // Broadcast to all clients, but resolve once from the first successful reply. + // The prior Promise.any implementation accidentally overwrote the pending + // resolver on each iteration, leaving only the last client able to satisfy + // the request for a given eventId. + const resultPromise = new Promise((resolve) => { + decryptionPendingMap.set(eventId, (result) => { + decryptionPendingMap.delete(eventId); + resolve(result); }); + }); + let postedToClient = false; + Array.from(windowClients).forEach((client) => { try { (client as WindowClient).postMessage({ type: 'decryptPushEvent', rawEvent }); + postedToClient = true; } catch (err) { - decryptionPendingMap.delete(eventId); console.warn('[SW decryptRelay] postMessage error', err); - return Promise.resolve(undefined as DecryptionResult | undefined); } - - return promise as Promise; }); - if (clientAttempts.length === 0) return undefined; + if (!postedToClient) { + decryptionPendingMap.delete(eventId); + return undefined; + } const timeout = new Promise((resolve) => { setTimeout(() => { decryptionPendingMap.delete(eventId); - console.warn('[SW decryptRelay] timed out waiting for all clients'); + console.warn('[SW decryptRelay] timed out waiting for client response'); resolve(undefined); }, 5000); }); - // Return as soon as any client succeeds or the shared timeout fires. - return Promise.race([ - Promise.any(clientAttempts).catch(() => undefined), - timeout, - ]); + return Promise.race([resultPromise, timeout]); } /** From 7ebd31e47fcfd09a05acd3d60ff24000677a38c1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 09:03:16 -0400 Subject: [PATCH 108/185] fix(sw): recover session sync without controller --- src/app/hooks/useAppVisibility.ts | 4 +-- src/sw-session.ts | 45 ++++++++++++++++++++++++++----- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index c8cf46c6a..590d746ae 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -73,8 +73,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S typeof baseUrl === 'string' && typeof accessToken === 'string' && typeof userId === 'string' && - 'serviceWorker' in navigator && - !!navigator.serviceWorker.controller; + 'serviceWorker' in navigator; if (!canPush) { debugLog.warn('network', 'Skipped SW session sync', { @@ -94,6 +93,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter, + hasSwController: !!navigator.serviceWorker?.controller, }); return 'sent'; }, diff --git a/src/sw-session.ts b/src/sw-session.ts index bbfd81fc3..63d285d99 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -1,12 +1,45 @@ -export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string) { - if (!('serviceWorker' in navigator)) return; - if (!navigator.serviceWorker.controller) return; +type ServiceWorkerSessionPayload = { + type: 'setSession'; + accessToken?: string; + baseUrl?: string; + userId?: string; +}; - navigator.serviceWorker.controller.postMessage({ +function postSessionPayload( + target: ServiceWorker | null | undefined, + payload: ServiceWorkerSessionPayload, + seenTargets: WeakSet +) { + if (!target || seenTargets.has(target)) return false; + seenTargets.add(target); + target.postMessage(payload); + return true; +} + +export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: string): boolean { + if (!('serviceWorker' in navigator)) return false; + + const payload: ServiceWorkerSessionPayload = { type: 'setSession', accessToken, baseUrl, userId, - // oxlint-disable-next-line unicorn/require-post-message-target-origin - }); + }; + const seenTargets = new WeakSet(); + postSessionPayload(navigator.serviceWorker.controller, payload, seenTargets); + + // Backgrounded/mobile browsers can drop the current controller reference even + // though the registration is still active. Post to any reachable worker from + // navigator.serviceWorker.ready so the session is restored without a reload. + void navigator.serviceWorker.ready + .then((registration) => { + postSessionPayload(registration.active, payload, seenTargets); + postSessionPayload(registration.waiting, payload, seenTargets); + postSessionPayload(registration.installing, payload, seenTargets); + }) + .catch(() => undefined); + + // Treat a queued ready() delivery as a successful attempt so foreground/heartbeat + // recovery keeps running even if controller is temporarily absent. + return true; } From 3fdf3160d90d4461ae330747fe74848d537388ef Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 09:06:39 -0400 Subject: [PATCH 109/185] fix: kick sliding sync on foreground return MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retryImmediately() is a no-op on SlidingSyncSdk — it returns true without touching the polling loop. Call slidingSync.resend() on foreground/focus to abort a stale long-poll and start a fresh one. Also fixes activeSession references that should use mx methods (getHomeserverUrl/getAccessToken/getUserId). --- src/app/hooks/useAppVisibility.ts | 21 +++++++++------------ 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index 590d746ae..e6773290e 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { MatrixClient } from '$types/matrix-sdk'; import { Session } from '$state/sessions'; import { useAtom } from 'jotai'; +import { getSlidingSyncManager } from '$client/initMatrix'; import { togglePusher } from '../features/settings/notifications/PushNotifications'; import { appEvents } from '../utils/appEvents'; import { useClientConfig, useExperimentVariant } from './useClientConfig'; @@ -65,9 +66,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S const pushSessionNow = useCallback( (reason: 'foreground' | 'focus' | 'heartbeat'): 'sent' | 'skipped' => { - const baseUrl = activeSession?.baseUrl; - const accessToken = activeSession?.accessToken; - const userId = activeSession?.userId; + const baseUrl = mx?.getHomeserverUrl(); + const accessToken = mx?.getAccessToken(); + const userId = mx?.getUserId(); const canPush = !!mx && typeof baseUrl === 'string' && @@ -97,15 +98,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S }); return 'sent'; }, - [ - activeSession?.accessToken, - activeSession?.baseUrl, - activeSession?.userId, - mx, - phase1ForegroundResync, - phase2VisibleHeartbeat, - phase3AdaptiveBackoffJitter, - ] + [mx, phase1ForegroundResync, phase2VisibleHeartbeat, phase3AdaptiveBackoffJitter] ); useEffect(() => { @@ -127,6 +120,9 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S // Always kick the sync loop on foreground regardless of phase flags — // the SDK may be sitting in exponential backoff after iOS froze the tab. mx?.retryImmediately(); + // retryImmediately() is a no-op on SlidingSyncSdk — call resend() on the + // SlidingSync instance directly to abort a stale long-poll and start fresh. + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); if (!phase1ForegroundResync) return; @@ -158,6 +154,7 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S // Always kick the sync loop on focus for the same reason as above. mx?.retryImmediately(); + if (mx) getSlidingSyncManager(mx)?.slidingSync.resend(); if (!phase1ForegroundResync) return; From c65be5c9b1992470549c520bb2fc165f0169d5ed Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:25:27 -0400 Subject: [PATCH 110/185] fix(config): enable SW session sync phases for reliable mobile notifications Add sessionSync.phase1ForegroundResync and phase2VisibleHeartbeat to config.json so the service worker session stays fresh on iOS. Without these flags useAppVisibility disables both foreground resync (phase1) and the 10-min visible heartbeat (phase2), leaving the CacheStorage session to age out after 24 h with no refresh. When iOS kills the SW while backgrounded and the session has gone stale, push decryption fails and notifications are silently dropped. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- config.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config.json b/config.json index 3620c8188..96c044e16 100644 --- a/config.json +++ b/config.json @@ -18,6 +18,11 @@ "enabled": true }, + "sessionSync": { + "phase1ForegroundResync": true, + "phase2VisibleHeartbeat": true + }, + "featuredCommunities": { "openAsDefault": false, "spaces": [ From 77547d0ca078488381b3518b6a7fc3a1a6729131 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 13:38:16 -0400 Subject: [PATCH 111/185] fix(timeline): stabilize bottom pin and unread fallback --- src/app/features/room/RoomTimeline.tsx | 55 +++++++++++++++++++++++++- src/app/utils/room.ts | 1 + 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index d63faa989..882032117 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -115,6 +115,20 @@ const getDayDividerText = (ts: number) => { return timeDayMonthYear(ts); }; +const SCROLL_SETTLE_MS = 250; + +const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; +const buildRoomScrollFingerprint = ( + eventIds: string[], + readUptoEventId: string | undefined, + layoutKey: string +): RoomScrollFingerprint => ({ + eventCount: eventIds.length, + headEventIds: eventIds.slice(0, 5), + tailEventIds: eventIds.slice(-5), + readUptoEventId, + layoutKey, +}); export type RoomTimelineProps = { room: Room; eventId?: string; @@ -226,6 +240,7 @@ export function RoomTimeline({ const topSpacerHeightRef = useRef(0); const mountScrollWindowRef = useRef(Date.now() + 3000); const hasInitialScrolledRef = useRef(false); + const lastProgrammaticBottomPinAtRef = useRef(0); // Stored in a ref so eventsLength fluctuations (e.g. onLifecycle timeline reset // firing within the window) cannot cancel it via useLayoutEffect cleanup. const initialScrollTimerRef = useRef | undefined>(undefined); @@ -257,8 +272,10 @@ export function RoomTimeline({ if (!vListRef.current) return; const lastIndex = processedEventsRef.current.length - 1; if (lastIndex < 0) return; + lastProgrammaticBottomPinAtRef.current = Date.now(); + setAtBottom(true); vListRef.current.scrollTo(vListRef.current.scrollSize); - }, []); + }, [setAtBottom]); const timelineSync = useTimelineSync({ room, @@ -675,6 +692,42 @@ export function RoomTimeline({ const distanceFromBottom = v.scrollSize - offset - v.viewportSize; const isNowAtBottom = distanceFromBottom < 100; + const withinSettleWindow = + Date.now() - lastProgrammaticBottomPinAtRef.current < SCROLL_SETTLE_MS; + + // When the user is pinned to the bottom and content grows (images, embeds, + // video thumbnails loading), scrollSize increases while offset stays put, + // pushing distanceFromBottom above the threshold. Instead of flipping + // atBottom to false (which shows the "Jump to Latest" button), chase the + // bottom so the user stays pinned. + const contentGrew = v.scrollSize > prevScrollSizeRef.current; + prevScrollSizeRef.current = v.scrollSize; + + // Skip content-chase and cache saves during init: the timeline is hidden + // (opacity 0) while VList measures items and fires intermediate scroll + // events. Chasing the bottom here causes cascading scrollTo calls that + // upstream doesn't have, producing visible layout churn after isReady. + if (!isReadyRef.current) return; + + // While a jump is in progress (focusItem set), VList fires scroll events + // from scrollToIndex that can incorrectly flip atBottom=true — especially + // if the target happens to be near the end. Ignore scroll-position + // updates until the jump transition finishes and focusItem is cleared. + if (timelineSyncRef.current.focusItem) return; + + if (atBottomRef.current && !isNowAtBottom && (contentGrew || withinSettleWindow)) { + // Defer the chase to the next animation frame so VList finishes its + // current layout pass. Synchronous scrollTo causes cascading scroll + // events that produce visible jumps when images/embeds load. + requestAnimationFrame(() => { + const vl = vListRef.current; + if (vl && atBottomRef.current) { + lastProgrammaticBottomPinAtRef.current = Date.now(); + vl.scrollTo(vl.scrollSize); + } + }); + return; + } if (isNowAtBottom !== atBottomRef.current) { setAtBottom(isNowAtBottom); } diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index db759d0bf..a2ce122e0 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -402,6 +402,7 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } } + // For DMs with Default or AllMessages notification type: if there are unread messages, // ensure we show a notification badge (treat as highlight for badge color purposes). // This handles cases where push rules don't properly match (e.g., classic sync with From bbaa4bc6829412487dd658ac31f91e567c17bd55 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 19 Apr 2026 14:03:29 -0400 Subject: [PATCH 112/185] fix(timeline): align initial room-fill thresholds --- src/app/features/room/RoomTimeline.tsx | 47 ++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 882032117..a12d1761f 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -116,6 +116,8 @@ const getDayDividerText = (ts: number) => { }; const SCROLL_SETTLE_MS = 250; +const MIN_INITIAL_SCROLL_ROOM_PX = 300; +const MIN_INITIAL_SCROLL_ROOM_PX = 300; const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; const buildRoomScrollFingerprint = ( @@ -334,11 +336,22 @@ export function RoomTimeline({ initialScrollTimerRef.current = setTimeout(() => { initialScrollTimerRef.current = undefined; if (processedEventsRef.current.length > 0) { - vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); - // Only mark ready once we've successfully scrolled. If processedEvents - // was empty when the timer fired (e.g. the onLifecycle reset cleared the - // timeline within the 80 ms window), defer setIsReady until the recovery - // effect below fires once events repopulate. + vListRef.current?.scrollToIndex(processedEventsRef.current.length - 1, { + align: 'end', + }); + const v = vListRef.current; + // If backward pagination can still fill the viewport, delay revealing + // until that pagination settles so the user never sees the 3→60 event jump. + const needsFill = + canPaginateBackRef.current && + v && + v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX && + backwardStatusRef.current !== 'error'; + if (needsFill) { + readyBlockedByPaginationRef.current = true; + return; + } + saveRoomScrollStateRef.current?.(v?.cache, true); setIsReady(true); } else { pendingReadyRef.current = true; @@ -368,7 +381,27 @@ export function RoomTimeline({ if (timelineSync.eventsLength > 0) return; setIsReady(false); hasInitialScrolledRef.current = false; - }, [isReady, timelineSync.eventsLength]); + }, [isReady, timelineSync.eventsLength, room]); + + // Reveal the timeline once backward pagination has settled and the viewport is + // filled. This handles the case where the 80 ms timer fired before sliding sync + // had delivered enough events to fill the screen. + useLayoutEffect(() => { + if (!readyBlockedByPaginationRef.current) return; + if (timelineSync.backwardStatus === 'loading') return; + const v = vListRef.current; + if (!v) return; + // Still not filled and can paginate more — keep waiting. + if ( + canPaginateBackRef.current && + v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX + ) + return; + readyBlockedByPaginationRef.current = false; + v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); + saveRoomScrollStateRef.current?.(v.cache, true); + setIsReady(true); + }, [timelineSync.backwardStatus, timelineSync.eventsLength, timelineSync.canPaginateBack]); const recalcTopSpacer = useCallback(() => { const v = vListRef.current; @@ -905,7 +938,7 @@ export function RoomTimeline({ const atTop = v.scrollOffset < 500; const noVisibleGrowth = processedEvents.length === processedLengthAtEffectStart; - const hasRealScrollRoom = v.scrollSize > v.viewportSize + 300; + const hasRealScrollRoom = v.scrollSize > v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX; if (!hasRealScrollRoom || (atTop && noVisibleGrowth)) { timelineSyncRef.current.handleTimelinePagination(true); From 386f97370ed9bb572391cd397802e40a72b7ae57 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 18 Apr 2026 23:31:56 -0400 Subject: [PATCH 113/185] fix(timeline): align reset relinking with upstream --- src/app/hooks/timeline/useTimelineSync.test.tsx | 3 +-- src/app/hooks/timeline/useTimelineSync.ts | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/hooks/timeline/useTimelineSync.test.tsx b/src/app/hooks/timeline/useTimelineSync.test.tsx index b9d253c6a..46899ba9d 100644 --- a/src/app/hooks/timeline/useTimelineSync.test.tsx +++ b/src/app/hooks/timeline/useTimelineSync.test.tsx @@ -124,13 +124,12 @@ describe('useTimelineSync', () => { readUptoEventIdRef: { current: undefined }, }) ); - await act(async () => { timelineSet.emit(RoomEvent.TimelineReset); await Promise.resolve(); }); - expect(scrollToBottom).toHaveBeenCalledWith('instant'); + expect(scrollToBottom).toHaveBeenCalled(); }); it('resets timeline state when room.roomId changes and eventId is not set', async () => { diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 800e4213d..3442ea367 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -527,7 +527,7 @@ export function useTimelineSync({ resetAutoScrollPendingRef.current = wasAtBottom; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); if (wasAtBottom) { - scrollToBottom('instant'); + scrollToBottom(); } }, [room, isAtBottomRef, scrollToBottom]) ); @@ -564,7 +564,7 @@ export function useTimelineSync({ if (eventsLength <= lastScrolledAtEventsLengthRef.current && !resetAutoScrollPending) return; lastScrolledAtEventsLengthRef.current = eventsLength; - scrollToBottom('instant'); + scrollToBottom(); }, [isAtBottom, liveTimelineLinked, eventsLength, scrollToBottom]); useEffect(() => { From 4265ae4e5a0d480cc267585886e6f13d03825dde Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Thu, 16 Apr 2026 10:31:58 -0400 Subject: [PATCH 114/185] feat(cache): unregister service workers on Clear Cache Unregister all service worker registrations before reloading when the user clicks Clear Cache & Reload. On iOS/mobile, stale SWs can persist and serve outdated assets even after an app update; this ensures the next load starts completely fresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/client/initMatrix.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 9e0496ee3..db150a5a2 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -742,6 +742,14 @@ export const clearCacheAndReload = async (mx: MatrixClient) => { stopClient(mx); clearNavToActivePathStore(mx.getSafeUserId()); await mx.store.deleteAllData(); + + // Unregister all service workers so the next load starts fresh. + // Especially important on iOS/mobile where stale SWs can persist. + if ('serviceWorker' in navigator) { + const registrations = await navigator.serviceWorker.getRegistrations(); + await Promise.all(registrations.map((r) => r.unregister())); + } + window.location.reload(); }; From f6b29e6bd01191e4f58bc947a49f3b7186cbc2db Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 17:52:48 -0400 Subject: [PATCH 115/185] fix(nav): check DM membership before space parents in useRoomNavigate Mirrors the fix already applied in useNotificationJumper: when a room belongs to both the direct-message list and a space, prefer the /direct route over the space route. Previously useRoomNavigate checked orphan space parents first, which caused bookmark jumps and room-nav clicks on DMs-in-spaces to open the room via the space path instead of the direct path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useRoomNavigate.ts | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/app/hooks/useRoomNavigate.ts b/src/app/hooks/useRoomNavigate.ts index 8e4abb172..900110610 100644 --- a/src/app/hooks/useRoomNavigate.ts +++ b/src/app/hooks/useRoomNavigate.ts @@ -38,7 +38,20 @@ export const useRoomNavigate = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, roomId); const openSpaceTimeline = developerTools && spaceSelectedId === roomId; - const orphanParents = openSpaceTimeline ? [roomId] : getOrphanParents(roomToParents, roomId); + // Developer-mode: view the space's own timeline (must be checked first). + if (openSpaceTimeline) { + navigate(getSpaceRoomPath(roomIdOrAlias, roomId, eventId), opts); + return; + } + + // DMs take priority over space membership so direct chats always open + // via the direct route, even when the room also belongs to a space. + if (mDirects.has(roomId)) { + navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + return; + } + + const orphanParents = getOrphanParents(roomToParents, roomId); if (orphanParents.length > 0) { let parentSpace: string; if (spaceSelectedId && orphanParents.includes(spaceSelectedId)) { @@ -49,15 +62,7 @@ export const useRoomNavigate = () => { const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, parentSpace); - navigate( - getSpaceRoomPath(pSpaceIdOrAlias, openSpaceTimeline ? roomId : roomIdOrAlias, eventId), - opts - ); - return; - } - - if (mDirects.has(roomId)) { - navigate(getDirectRoomPath(roomIdOrAlias, eventId), opts); + navigate(getSpaceRoomPath(pSpaceIdOrAlias, roomIdOrAlias, eventId), opts); return; } From d4447c87201bdb79f76d1ae96da75d4c28378865 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:39:43 -0400 Subject: [PATCH 116/185] Change GitHub owner from 'SableClient' to 'Just-Insane' --- knope.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/knope.toml b/knope.toml index fc533824c..9be54d79d 100644 --- a/knope.toml +++ b/knope.toml @@ -62,7 +62,7 @@ help_text = "Create a new change file to be included in the next release" type = "CreateChangeFile" [github] -owner = "SableClient" +owner = "Just-Insane" repo = "Sable" [release_notes] From 01a699e606a132c5a4d313e32fc87354d361162e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:40:40 -0400 Subject: [PATCH 117/185] Change default custom domain for Worker --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 3569c9822..7d3a50ece 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "app.sable.moe" + default = "app.cloudhub.social" } variable "worker_name" { From a6240413b165edfbe191597e3979d6fde1510cdc Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 17:45:52 -0400 Subject: [PATCH 118/185] Change default custom domain in variables.tf --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 7d3a50ece..96a6be2c5 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "app.cloudhub.social" + default = "sable.cloudhub.social" } variable "worker_name" { From dfe145090bdedbeb8f60aef009ccdfa6a8997054 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 21 Mar 2026 18:20:43 -0400 Subject: [PATCH 119/185] Change default custom domain to dev.cloudhub.social --- infra/web/variables.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/web/variables.tf b/infra/web/variables.tf index 96a6be2c5..8ddd72ae4 100644 --- a/infra/web/variables.tf +++ b/infra/web/variables.tf @@ -7,7 +7,7 @@ variable "account_id" { variable "custom_domain" { description = "Custom domain attached to the Worker" type = string - default = "sable.cloudhub.social" + default = "dev.cloudhub.social" } variable "worker_name" { From a4952864e7ca01e7d65f7ca0df3d653e436117d5 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 20:20:08 -0400 Subject: [PATCH 120/185] chore: ignore .vscode/launch.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 76af75542..e57c115bc 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ build.sh # the following line was added with nvim by Shea because its annoying to clear every so often .vscode/bookmarks.json +.vscode/launch.json # Nix things ## nix build output From 3e13abb87d12da6d9b892ac835fd2009700f5750 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 24 Mar 2026 22:33:41 -0400 Subject: [PATCH 121/185] ci: build latest Docker image from integration branch too --- .github/workflows/docker-publish.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 03a63ef99..426f1d826 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and publish Docker image on: push: - branches: [dev] + branches: [dev, integration] tags: - 'v*' pull_request: @@ -70,9 +70,9 @@ jobs: flavor: | latest=false tags: | - # dev branch or manual dispatch without a tag: short commit SHA + latest - type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + # dev/integration branch or manual dispatch without a tag: short commit SHA + latest + type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} From e411e5930be2ec4caff9c1052f9e7e962b337b3a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 25 Mar 2026 00:08:01 -0400 Subject: [PATCH 122/185] ci: add Sentry env vars to Docker image build step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass VITE_SENTRY_DSN, VITE_SENTRY_ENVIRONMENT, VITE_APP_VERSION, SENTRY_AUTH_TOKEN, SENTRY_ORG, and SENTRY_PROJECT to the build step so that the Docker image build (dev, integration, and release tags) includes Sentry instrumentation and source map uploads, matching the Cloudflare deploy workflow. Environment mapping: - dev branch / release tags → production - integration branch / manual dispatch without tag → preview --- .github/workflows/docker-publish.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 426f1d826..64c78f755 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -90,6 +90,12 @@ jobs: env: VITE_BUILD_HASH: ${{ steps.vars.outputs.short_sha }} VITE_IS_RELEASE_TAG: ${{ steps.release_tag.outputs.is_release }} + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + VITE_SENTRY_ENVIRONMENT: ${{ (steps.release_tag.outputs.is_release == 'true' || github.ref == 'refs/heads/dev') && 'production' || 'preview' }} + VITE_APP_VERSION: ${{ github.ref_name }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} run: | NODE_OPTIONS=--max_old_space_size=4096 pnpm run build From 4db08b51384820d8dd9c2555057647bdba8660fd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 27 Mar 2026 10:26:35 -0400 Subject: [PATCH 123/185] ci: tag integration branch Docker image as 'integration' --- .github/workflows/docker-publish.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 64c78f755..82fa8406f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -70,9 +70,14 @@ jobs: flavor: | latest=false tags: | - # dev/integration branch or manual dispatch without a tag: short commit SHA + latest + # dev/integration branch or manual dispatch without a tag: short commit SHA type=sha,prefix=,format=short,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/integration' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + + # dev branch or manual dispatch without a tag: latest tag + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.git_tag == '') }} + + # integration branch: stable integration tag + type=raw,value=integration,enable=${{ github.ref == 'refs/heads/integration' }} # git tags (push or manual dispatch with a tag): semver breakdown type=semver,pattern={{version}},value=${{ steps.release_tag.outputs.value }},enable=${{ steps.release_tag.outputs.is_release == 'true' }} From 8dfe6660e2f9cf27cc96c4e98ed379005a0cec9d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 12:14:10 -0400 Subject: [PATCH 124/185] feat: add pre-push git hook for quality checks - Adds pre-push hook that runs typecheck, lint, and format checks - Blocks pushes that would fail CI - Includes install script for easy setup - Tracked on personal/config to persist across dev pulls --- scripts/git-hooks/README.md | 28 ++++++++++++++++++++++++++++ scripts/git-hooks/pre-push | 35 +++++++++++++++++++++++++++++++++++ scripts/install-git-hooks.sh | 25 +++++++++++++++++++++++++ 3 files changed, 88 insertions(+) create mode 100644 scripts/git-hooks/README.md create mode 100644 scripts/git-hooks/pre-push create mode 100644 scripts/install-git-hooks.sh diff --git a/scripts/git-hooks/README.md b/scripts/git-hooks/README.md new file mode 100644 index 000000000..2793d1921 --- /dev/null +++ b/scripts/git-hooks/README.md @@ -0,0 +1,28 @@ +# Git Hooks + +This directory contains git hooks that enforce quality standards before pushing code. + +## Installation + +Run the installation script from the repository root: + +```bash +./scripts/install-git-hooks.sh +``` + +This will copy the hooks to `.git/hooks/` and make them executable. + +## Hooks + +### pre-push + +Runs before every `git push` and enforces: +- TypeScript type checking (`npm run typecheck`) +- ESLint checks (`npm run lint`) +- Prettier formatting (`npm run fmt:check`) + +If any check fails, the push is blocked. To bypass in emergencies: `git push --no-verify` + +## Maintenance + +This directory is tracked on the `personal/config` branch to persist across `dev` pulls and merges. diff --git a/scripts/git-hooks/pre-push b/scripts/git-hooks/pre-push new file mode 100644 index 000000000..d4c02c37a --- /dev/null +++ b/scripts/git-hooks/pre-push @@ -0,0 +1,35 @@ +#!/bin/zsh +# Pre-push hook: Run quality checks before allowing push +# This prevents pushing code that will fail CI checks + +set -e + +echo "🔍 Running pre-push quality checks..." + +# Run typecheck +echo " → Running typecheck..." +if ! npm run typecheck > /dev/null 2>&1; then + echo "❌ Typecheck failed. Fix errors before pushing." + npm run typecheck + exit 1 +fi +echo " ✓ Typecheck passed" + +# Run lint +echo " → Running lint..." +if ! npm run lint > /dev/null 2>&1; then + echo "❌ Lint failed. Fix errors before pushing." + npm run lint + exit 1 +fi +echo " ✓ Lint passed" + +# Run format check +echo " → Running format check..." +if ! npm run fmt:check > /dev/null 2>&1; then + echo "❌ Format check failed. Run 'npm run fmt' to fix." + exit 1 +fi +echo " ✓ Format check passed" + +echo "✅ All quality checks passed. Proceeding with push..." diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh new file mode 100644 index 000000000..c90efc819 --- /dev/null +++ b/scripts/install-git-hooks.sh @@ -0,0 +1,25 @@ +#!/bin/zsh +# Setup script: Install git hooks from scripts/git-hooks/ + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +HOOKS_DIR="$REPO_ROOT/.git/hooks" +SOURCE_DIR="$REPO_ROOT/scripts/git-hooks" + +echo "🔧 Installing git hooks..." + +# Install pre-push hook +if [ -f "$SOURCE_DIR/pre-push" ]; then + cp "$SOURCE_DIR/pre-push" "$HOOKS_DIR/pre-push" + chmod +x "$HOOKS_DIR/pre-push" + echo " ✓ Installed pre-push hook" +else + echo " ⚠ pre-push hook not found in $SOURCE_DIR" +fi + +echo "✅ Git hooks installation complete!" +echo "" +echo "The pre-push hook will now run quality checks (typecheck, lint, format)" +echo "before every git push. To bypass in emergencies, use: git push --no-verify" From a56dd100c87c23b8e31163bb042b4f0e4da26380 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 19:56:52 -0400 Subject: [PATCH 125/185] ci(docker): load env-specific client config overrides --- .github/workflows/docker-publish.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 82fa8406f..5badb90a4 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -23,12 +23,16 @@ env: jobs: build-and-push: runs-on: ubuntu-latest + environment: ${{ github.event_name == 'pull_request' && (github.base_ref == 'dev' && 'production' || github.base_ref == 'integration' && 'preview' || 'preview') || github.ref == 'refs/heads/dev' && 'production' || github.ref == 'refs/heads/integration' && 'preview' || 'preview' }} permissions: contents: read packages: write attestations: write artifact-metadata: write id-token: write + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} steps: - name: Checkout repository From e3f393910e79f103c55b5ccfa4bd5b62a7134c77 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 20:01:23 -0400 Subject: [PATCH 126/185] ci: integration uses preview env, dev uses production env --- .github/workflows/cloudflare-dev-deploy.yml | 103 +++++++++++++++++++ .github/workflows/cloudflare-web-preview.yml | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/cloudflare-dev-deploy.yml diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml new file mode 100644 index 000000000..e113e954d --- /dev/null +++ b/.github/workflows/cloudflare-dev-deploy.yml @@ -0,0 +1,103 @@ +name: Cloudflare Worker Dev Deploy + +on: + push: + branches: + - dev + paths: + - 'src/**' + - 'index.html' + - 'package.json' + - 'package-lock.json' + - 'vite.config.ts' + - 'tsconfig.json' + - '.github/workflows/cloudflare-dev-deploy.yml' + - '.github/actions/setup/**' + +concurrency: + group: cloudflare-worker-dev-deploy + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + environment: production + permissions: + contents: read + env: + CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }} + CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Prepare preview metadata + id: metadata + shell: bash + run: | + preview_message="$(git log -1 --pretty=%s)" + preview_message="$(printf '%s' "$preview_message" | head -c 100)" + + { + echo 'preview_message<> "$GITHUB_OUTPUT" + + - name: Set Sentry build environment + env: + VITE_SENTRY_DSN: ${{ secrets.VITE_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.SENTRY_ORG }} + SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }} + shell: bash + run: | + echo "VITE_SENTRY_DSN=$VITE_SENTRY_DSN" >> "$GITHUB_ENV" + echo "VITE_SENTRY_ENVIRONMENT=production" >> "$GITHUB_ENV" + echo "SENTRY_AUTH_TOKEN=$SENTRY_AUTH_TOKEN" >> "$GITHUB_ENV" + echo "SENTRY_ORG=$SENTRY_ORG" >> "$GITHUB_ENV" + echo "SENTRY_PROJECT=$SENTRY_PROJECT" >> "$GITHUB_ENV" + + - name: Setup app and build + uses: ./.github/actions/setup + with: + build: 'true' + + - name: Upload Worker preview + id: deploy + uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3.14.1 + env: + PREVIEW_MESSAGE: ${{ steps.metadata.outputs.preview_message }} + with: + apiToken: ${{ secrets.TF_CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.TF_VAR_ACCOUNT_ID }} + command: > + versions upload + -c dist/wrangler.json + --preview-alias dev + --message "$PREVIEW_MESSAGE" + + - name: Publish summary + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + DEPLOYMENT_URL: ${{ steps.deploy.outputs.deployment-url }} + SHORT_SHA: ${{ github.sha }} + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const deploymentUrl = process.env.DEPLOYMENT_URL; + const shortSha = process.env.SHORT_SHA?.slice(0, 7); + const now = new Date().toUTCString().replace(':00 GMT', ' UTC'); + + const tableRow = "| ✅ Dev deployment successful! | " + deploymentUrl + " | " + shortSha + " | `dev` | " + now + " |"; + const comment = [ + `## Deploying with  Cloudflare Workers  Cloudflare Workers (dev → production config)`, + ``, + `| Status | URL | Commit | Alias | Updated (UTC) |`, + `| - | - | - | - | - |`, + tableRow, + ].join("\n"); + + await core.summary.addRaw(comment).write(); diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index 82046559c..a5de5b4c3 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -13,7 +13,7 @@ on: - '.github/actions/setup/**' push: branches: - - dev + - integration paths: - 'src/**' - 'index.html' From 827d29d17c8944e6f2e7530e986c2d37e041a61e Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 20:01:41 -0400 Subject: [PATCH 127/185] ci(workflows): trigger app deploys on config.json changes --- .github/workflows/cloudflare-dev-deploy.yml | 2 ++ .github/workflows/cloudflare-web-preview.yml | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/cloudflare-dev-deploy.yml b/.github/workflows/cloudflare-dev-deploy.yml index e113e954d..5bc6421e0 100644 --- a/.github/workflows/cloudflare-dev-deploy.yml +++ b/.github/workflows/cloudflare-dev-deploy.yml @@ -6,9 +6,11 @@ on: - dev paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-dev-deploy.yml' diff --git a/.github/workflows/cloudflare-web-preview.yml b/.github/workflows/cloudflare-web-preview.yml index a5de5b4c3..762c8fe9d 100644 --- a/.github/workflows/cloudflare-web-preview.yml +++ b/.github/workflows/cloudflare-web-preview.yml @@ -4,9 +4,11 @@ on: pull_request: paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-web-preview.yml' @@ -16,9 +18,11 @@ on: - integration paths: - 'src/**' + - 'config.json' - 'index.html' - 'package.json' - 'package-lock.json' + - 'scripts/inject-client-config.js' - 'vite.config.ts' - 'tsconfig.json' - '.github/workflows/cloudflare-web-preview.yml' From a6643df84028122f6cfd8e998d97f37afa2af9e1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:24:27 -0400 Subject: [PATCH 128/185] chore: codespace devcontainer config --- .devcontainer/devcontainer.json | 71 ++++++++++++++++++++++++++++++++ .devcontainer/on-create.sh | 19 +++++++++ .devcontainer/post-create.sh | 72 +++++++++++++++++++++++++++++++++ .devcontainer/post-start.sh | 39 ++++++++++++++++++ .devcontainer/setup-signing.sh | 51 +++++++++++++++++++++++ .devcontainer/update-content.sh | 19 +++++++++ sable.code-workspace | 27 +++++++++++++ 7 files changed, 298 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/on-create.sh create mode 100644 .devcontainer/post-create.sh create mode 100644 .devcontainer/post-start.sh create mode 100644 .devcontainer/setup-signing.sh create mode 100644 .devcontainer/update-content.sh create mode 100644 sable.code-workspace diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..45329c341 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,71 @@ +{ + "name": "Sable", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + + // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl + "hostRequirements": { + "cpus": 4, + "memory": "8gb", + "storage": "32gb" + }, + + "features": { + // GitHub CLI for PR/issue/fork management + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // Expose Vite dev server and Zola docs preview + "forwardPorts": [5173, 8080, 1111], + "portsAttributes": { + "5173": { "label": "Vite Dev Server", "onAutoForward": "notify" }, + "8080": { "label": "App Preview", "onAutoForward": "notify" }, + "1111": { "label": "Docs Preview (Zola)", "onAutoForward": "notify" } + }, + + // Open the multi-root workspace covering both Sable + Sable-Docs + "workspaceFile": "${localWorkspaceFolder}/sable.code-workspace", + + "customizations": { + "vscode": { + "extensions": [ + // JS/TS toolchain + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "webpro.vscode-knip", + "ms-vscode.vscode-typescript-next", + // Git & GitHub + "github.vscode-pull-request-github", + "eamodio.gitlens", + // Docs (Zola / TOML / Markdown) + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one", + "eliostruyf.vscode-front-matter", + // Misc + "EditorConfig.EditorConfig" + ], + "settings": { + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "typescript.tsdk": "node_modules/typescript/lib", + "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, + "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, + "git.autofetch": true, + "terminal.integrated.defaultProfile.linux": "bash" + } + } + }, + + // ── Lifecycle hooks ──────────────────────────────────────────────────────── + // on-create : runs ONCE when the prebuild image is first built (cached) + // update-content: re-runs on each prebuild refresh & new codespace create (cached) + // post-create : runs once on each new codespace (not cached) – user-specific setup + // post-start : runs on EVERY codespace start (fetch upstream, signing check) + + "onCreateCommand": "bash .devcontainer/on-create.sh", + "updateContentCommand": "bash .devcontainer/update-content.sh", + "postCreateCommand": "bash .devcontainer/post-create.sh", + "postStartCommand": "bash .devcontainer/post-start.sh", + + "remoteUser": "node" +} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh new file mode 100644 index 000000000..1d5123eaa --- /dev/null +++ b/.devcontainer/on-create.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# on-create.sh — runs ONCE when the prebuild image is first built +# Everything here is cached between prebuild refreshes. +set -euo pipefail + +echo "==> [on-create] Enabling corepack (pnpm)..." +corepack enable +corepack prepare pnpm@latest --activate + +echo "==> [on-create] Configuring pnpm global store..." +pnpm config set store-dir /home/node/.local/share/pnpm/store + +echo "==> [on-create] Installing Zola (for Sable-Docs preview)..." +ZOLA_VERSION="0.19.2" +ZOLA_URL="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" +curl -fsSL "$ZOLA_URL" | sudo tar xz -C /usr/local/bin +zola --version + +echo "==> [on-create] Done." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 000000000..4f2ef27a1 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# post-create.sh — runs ONCE per new codespace (not cached in prebuild). +# Handles user-specific git setup: remotes, branches, signing. +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +DOCS_DIR="/workspaces/Sable-Docs" + +# ── 1. Upstream remotes ─────────────────────────────────────────────────────── +echo "==> [post-create] Configuring upstream remotes..." + +# Sable: fork = origin (Just-Insane/Sable), upstream = SableClient/Sable +if ! git -C "$SABLE_DIR" remote | grep -q "^upstream$"; then + git -C "$SABLE_DIR" remote add upstream https://github.com/SableClient/Sable.git + echo " Added upstream → SableClient/Sable" +else + echo " upstream remote already set" +fi +git -C "$SABLE_DIR" fetch --all --quiet + +# Docs: fork = origin (Just-Insane/docs), upstream = SableClient/docs +if ! git -C "$DOCS_DIR" remote | grep -q "^upstream$"; then + git -C "$DOCS_DIR" remote add upstream https://github.com/SableClient/docs.git + echo " [docs] Added upstream → SableClient/docs" +else + echo " [docs] upstream remote already set" +fi +git -C "$DOCS_DIR" fetch --all --quiet + +# ── 2. Ensure required branches exist ──────────────────────────────────────── +echo "==> [post-create] Ensuring branches exist in Sable..." + +ensure_branch() { + local dir="$1" + local branch="$2" + local start_point="${3:-HEAD}" + if git -C "$dir" ls-remote --heads origin "$branch" | grep -q "$branch"; then + echo " Branch '$branch' already exists on origin, checking out..." + git -C "$dir" fetch origin "$branch" --quiet + if ! git -C "$dir" show-ref --quiet "refs/heads/$branch"; then + git -C "$dir" branch --track "$branch" "origin/$branch" + fi + else + echo " Creating branch '$branch' from $start_point and pushing to origin..." + git -C "$dir" checkout -b "$branch" "$start_point" 2>/dev/null || true + git -C "$dir" push -u origin "$branch" + fi +} + +# Switch back to integration after branch ops +CURRENT_BRANCH=$(git -C "$SABLE_DIR" rev-parse --abbrev-ref HEAD) + +ensure_branch "$SABLE_DIR" "integration" "upstream/dev" +ensure_branch "$SABLE_DIR" "personal/config" "integration" +ensure_branch "$DOCS_DIR" "integration" "upstream/main" + +# Return to whatever branch we were on +git -C "$SABLE_DIR" checkout "$CURRENT_BRANCH" 2>/dev/null || true + +# ── 3. Git signing (SSH via forwarded YubiKey) ──────────────────────────────── +echo "==> [post-create] Configuring SSH commit signing..." +bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true + +# ── 4. Install git hooks ────────────────────────────────────────────────────── +echo "==> [post-create] Installing git hooks..." +if [ -f "$SABLE_DIR/scripts/install-git-hooks.sh" ]; then + bash "$SABLE_DIR/scripts/install-git-hooks.sh" +fi + +echo "" +echo "==> [post-create] Done! Open sable.code-workspace for the multi-root view." +echo " Run '.devcontainer/setup-signing.sh' any time to reconfigure commit signing." diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh new file mode 100644 index 000000000..c49b2eeb2 --- /dev/null +++ b/.devcontainer/post-start.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# post-start.sh — runs on EVERY codespace start. +# Fetches upstream changes and re-checks signing (agent may have changed). +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +DOCS_DIR="/workspaces/Sable-Docs" + +# ── Fetch upstream for both repos ──────────────────────────────────────────── +echo "==> [post-start] Fetching upstream..." +git -C "$SABLE_DIR" fetch upstream --quiet 2>/dev/null && echo " Sable upstream fetched" || echo " ⚠ Could not fetch Sable upstream" +git -C "$DOCS_DIR" fetch upstream --quiet 2>/dev/null && echo " Docs upstream fetched" || echo " ⚠ Could not fetch Docs upstream" + +# ── Show how far behind integration is from upstream/dev ───────────────────── +BEHIND=$(git -C "$SABLE_DIR" rev-list --count HEAD..upstream/dev 2>/dev/null || echo "?") +if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then + echo "" + echo " ℹ Your current branch is $BEHIND commit(s) behind upstream/dev." + echo " To sync: git merge upstream/dev (or: git rebase upstream/dev)" +fi + +# ── Re-configure SSH signing if not already set (agent may now be available) ─ +if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then + bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true +else + # Verify the key still exists in the agent (yubikey could have changed) + CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") + if [ -n "$CONFIGURED_KEY" ]; then + if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then + echo "" + echo " ⚠ Signing key not found in SSH agent. YubiKey present?" + echo " Re-run: bash .devcontainer/setup-signing.sh" + else + echo " ✓ Commit signing ready (SSH via forwarded agent)" + fi + fi +fi + +echo "" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh new file mode 100644 index 000000000..d8262cf3b --- /dev/null +++ b/.devcontainer/setup-signing.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# setup-signing.sh — configures SSH commit signing via forwarded SSH agent. +# Safe to re-run at any time. YubiKey-backed keys work as long as the +# SSH agent from your local machine is forwarded (VS Code handles this). +set -euo pipefail + +SABLE_DIR="/workspaces/Sable" +ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" + +# Check if SSH agent is available and has keys loaded +if ! ssh-add -L &>/dev/null || [ -z "$(ssh-add -L 2>/dev/null)" ]; then + echo "⚠ No SSH keys found in the forwarded agent." + echo " Make sure your local SSH agent is running and your YubiKey key is loaded." + echo " On macOS: ssh-add --apple-use-keychain ~/.ssh/id_ed25519" + echo " To retry: bash .devcontainer/setup-signing.sh" + exit 0 +fi + +# Pick the first key; if your YubiKey-backed key is not first, adjust: +# e.g. SIGNING_KEY=$(ssh-add -L | grep "cardno:" | head -1) +SIGNING_KEY=$(ssh-add -L | head -1) +KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') + +echo "✓ Found SSH key: ...${KEY_COMMENT}" + +# Configure git to use SSH signing +git config --global gpg.format ssh +git config --global user.signingkey "$SIGNING_KEY" +git config --global commit.gpgsign true +git config --global tag.gpgsign true + +# Set up allowed_signers for local verification +USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") +if [ -n "$USER_EMAIL" ]; then + mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" + # Remove stale entry for this email if present, then add fresh one + if [ -f "$ALLOWED_SIGNERS_FILE" ]; then + grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true + mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" + fi + echo "$USER_EMAIL namespaces=\"git\" $SIGNING_KEY" >> "$ALLOWED_SIGNERS_FILE" + git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" + echo "✓ SSH commit signing configured for <$USER_EMAIL>" +else + echo "⚠ user.email not set globally. Run: git config --global user.email 'you@example.com'" + echo " Then re-run: bash .devcontainer/setup-signing.sh" +fi + +echo "" +echo "Test signing with: git commit --allow-empty -m 'test signing'" +echo "Verify with: git log --show-signature -1" diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh new file mode 100644 index 000000000..572ae73ba --- /dev/null +++ b/.devcontainer/update-content.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# update-content.sh — runs on each prebuild refresh AND on new codespace creation. +# The resulting filesystem state is cached in the prebuild snapshot. +set -euo pipefail + +echo "==> [update-content] Installing Sable dependencies (pnpm install)..." +pnpm install --frozen-lockfile + +echo "==> [update-content] Cloning / updating Sable-Docs..." +DOCS_DIR="/workspaces/Sable-Docs" +if [ -d "$DOCS_DIR/.git" ]; then + echo " Docs already present, fetching latest..." + git -C "$DOCS_DIR" fetch --all +else + echo " Cloning Just-Insane/docs → $DOCS_DIR" + git clone https://github.com/Just-Insane/docs "$DOCS_DIR" +fi + +echo "==> [update-content] Done." diff --git a/sable.code-workspace b/sable.code-workspace new file mode 100644 index 000000000..b7b699ce8 --- /dev/null +++ b/sable.code-workspace @@ -0,0 +1,27 @@ +{ + "folders": [ + { + "path": ".", + "name": "Sable" + }, + { + "path": "../Sable-Docs", + "name": "Sable-Docs" + } + ], + "settings": { + "editor.formatOnSave": true, + "typescript.tsdk": "Sable/node_modules/typescript/lib" + }, + "extensions": { + "recommendations": [ + "dbaeumer.vscode-eslint", + "esbenp.prettier-vscode", + "webpro.vscode-knip", + "tamasfe.even-better-toml", + "yzhang.markdown-all-in-one", + "github.vscode-pull-request-github", + "eamodio.gitlens" + ] + } +} From 928663cc01a651def462cc5cbccfd686b0f5e726 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:29:30 -0400 Subject: [PATCH 129/185] Update image --- .devcontainer/devcontainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 45329c341..ddf43676c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl "hostRequirements": { From 9e8735c64dd2f596c722955bf110bae0e03ffea7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:35:08 -0400 Subject: [PATCH 130/185] update startup script --- .devcontainer/on-create.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh index 1d5123eaa..7f6f789d8 100644 --- a/.devcontainer/on-create.sh +++ b/.devcontainer/on-create.sh @@ -4,7 +4,7 @@ set -euo pipefail echo "==> [on-create] Enabling corepack (pnpm)..." -corepack enable +sudo corepack enable corepack prepare pnpm@latest --activate echo "==> [on-create] Configuring pnpm global store..." From b9587fa43f08d07c8d8f3e460cdd546f35282501 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sat, 28 Mar 2026 23:56:22 -0400 Subject: [PATCH 131/185] Update setup-signing script --- .devcontainer/setup-signing.sh | 61 ++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 21 deletions(-) diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index d8262cf3b..9191572f9 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -1,29 +1,49 @@ #!/usr/bin/env bash -# setup-signing.sh — configures SSH commit signing via forwarded SSH agent. -# Safe to re-run at any time. YubiKey-backed keys work as long as the -# SSH agent from your local machine is forwarded (VS Code handles this). +# setup-signing.sh — configures SSH commit signing. +# Supports two modes: +# 1. Forwarded SSH agent (VS Code desktop + YubiKey) +# 2. Codespace-local SSH key (browser/web Codespaces) +# Safe to re-run at any time. set -euo pipefail SABLE_DIR="/workspaces/Sable" ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" +CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" -# Check if SSH agent is available and has keys loaded -if ! ssh-add -L &>/dev/null || [ -z "$(ssh-add -L 2>/dev/null)" ]; then - echo "⚠ No SSH keys found in the forwarded agent." - echo " Make sure your local SSH agent is running and your YubiKey key is loaded." - echo " On macOS: ssh-add --apple-use-keychain ~/.ssh/id_ed25519" - echo " To retry: bash .devcontainer/setup-signing.sh" - exit 0 -fi - -# Pick the first key; if your YubiKey-backed key is not first, adjust: -# e.g. SIGNING_KEY=$(ssh-add -L | grep "cardno:" | head -1) -SIGNING_KEY=$(ssh-add -L | head -1) -KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') +# ── MODE 1: Forwarded SSH agent (desktop VS Code) ──────────────────────────── +if ssh-add -L &>/dev/null && [ -n "$(ssh-add -L 2>/dev/null)" ]; then + echo "✓ Detected forwarded SSH agent (desktop VS Code + YubiKey mode)" + SIGNING_KEY=$(ssh-add -L | head -1) + KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') + echo " Using key: ...${KEY_COMMENT}" -echo "✓ Found SSH key: ...${KEY_COMMENT}" +# ── MODE 2: Codespace-local key (web Codespaces) ───────────────────────────── +else + echo "ℹ No forwarded agent (web Codespace mode)" + + if [ ! -f "$CODESPACE_KEY" ]; then + echo " Generating new Ed25519 signing key..." + mkdir -p "$HOME/.ssh" + ssh-keygen -t ed25519 -f "$CODESPACE_KEY" -N "" -C "codespace-signing@$(hostname)" + echo "" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " 🔑 Add this PUBLIC KEY to GitHub as a SIGNING key:" + echo "" + cat "${CODESPACE_KEY}.pub" + echo "" + echo " 👉 https://github.com/settings/keys → New SSH key" + echo " Title: Codespace Signing Key" + echo " Key type: Signing Key" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + read -p "Press Enter after adding the key to GitHub..." + fi + + SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") + echo " Using Codespace key: ${CODESPACE_KEY}" +fi -# Configure git to use SSH signing +# ── Common: Configure git ──────────────────────────────────────────────────── git config --global gpg.format ssh git config --global user.signingkey "$SIGNING_KEY" git config --global commit.gpgsign true @@ -33,7 +53,6 @@ git config --global tag.gpgsign true USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") if [ -n "$USER_EMAIL" ]; then mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" - # Remove stale entry for this email if present, then add fresh one if [ -f "$ALLOWED_SIGNERS_FILE" ]; then grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" @@ -47,5 +66,5 @@ else fi echo "" -echo "Test signing with: git commit --allow-empty -m 'test signing'" -echo "Verify with: git log --show-signature -1" +echo "Test signing: git commit --allow-empty -m 'test signing'" +echo "Verify: git log --show-signature -1" \ No newline at end of file From ed3b571464c59d9bca3cdb1c0ea0544bf0c3c503 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:06:13 -0400 Subject: [PATCH 132/185] Updates for ssh --- .devcontainer/post-start.sh | 20 +++++++++++++++----- .devcontainer/setup-signing.sh | 10 ++++++++++ sable.code-workspace | 14 +++++++------- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index c49b2eeb2..7afe0c3cc 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -20,18 +20,28 @@ if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then fi # ── Re-configure SSH signing if not already set (agent may now be available) ─ +CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true else - # Verify the key still exists in the agent (yubikey could have changed) + # Verify the key still exists in the agent CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") if [ -n "$CONFIGURED_KEY" ]; then if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - echo "" - echo " ⚠ Signing key not found in SSH agent. YubiKey present?" - echo " Re-run: bash .devcontainer/setup-signing.sh" + # In web Codespace mode, reload the key into a fresh agent + if [ -f "$CODESPACE_KEY" ]; then + echo " ↻ Reloading Codespace signing key into SSH agent..." + if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + eval "$(ssh-agent -s)" > /dev/null + echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" + echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" + fi + ssh-add "$CODESPACE_KEY" 2>/dev/null && echo " ✓ Commit signing ready" + else + echo " ⚠ Signing key not found. YubiKey present or re-run: bash .devcontainer/setup-signing.sh" + fi else - echo " ✓ Commit signing ready (SSH via forwarded agent)" + echo " ✓ Commit signing ready" fi fi fi diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 9191572f9..189f29383 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -39,6 +39,16 @@ else read -p "Press Enter after adding the key to GitHub..." fi + # Start ssh-agent if not already running and add the key + if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + echo " Starting SSH agent and loading key..." + eval "$(ssh-agent -s)" > /dev/null + ssh-add "$CODESPACE_KEY" 2>/dev/null + # Persist SSH_AUTH_SOCK for future shells + echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" + echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" + fi + SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") echo " Using Codespace key: ${CODESPACE_KEY}" fi diff --git a/sable.code-workspace b/sable.code-workspace index b7b699ce8..f937d83ca 100644 --- a/sable.code-workspace +++ b/sable.code-workspace @@ -2,16 +2,16 @@ "folders": [ { "path": ".", - "name": "Sable" + "name": "Sable", }, { "path": "../Sable-Docs", - "name": "Sable-Docs" - } + "name": "Sable-Docs", + }, ], "settings": { "editor.formatOnSave": true, - "typescript.tsdk": "Sable/node_modules/typescript/lib" + "typescript.tsdk": "Sable/node_modules/typescript/lib", }, "extensions": { "recommendations": [ @@ -21,7 +21,7 @@ "tamasfe.even-better-toml", "yzhang.markdown-all-in-one", "github.vscode-pull-request-github", - "eamodio.gitlens" - ] - } + "eamodio.gitlens", + ], + }, } From b27232c8a4ff2f4722eb1ae355b20d9961d36fcf Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:13:24 -0400 Subject: [PATCH 133/185] More script fixes --- .devcontainer/post-start.sh | 2 +- .devcontainer/setup-signing.sh | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index 7afe0c3cc..e4f64eda4 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -31,7 +31,7 @@ else # In web Codespace mode, reload the key into a fresh agent if [ -f "$CODESPACE_KEY" ]; then echo " ↻ Reloading Codespace signing key into SSH agent..." - if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then eval "$(ssh-agent -s)" > /dev/null echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 189f29383..647c5926c 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -40,7 +40,7 @@ else fi # Start ssh-agent if not already running and add the key - if [ -z "$SSH_AUTH_SOCK" ] || ! ssh-add -l &>/dev/null; then + if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then echo " Starting SSH agent and loading key..." eval "$(ssh-agent -s)" > /dev/null ssh-add "$CODESPACE_KEY" 2>/dev/null From cd187d1148462b0b0180a664536cc90bb747fcc3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 00:20:15 -0400 Subject: [PATCH 134/185] more fixes --- .devcontainer/post-start.sh | 27 ++++++++++++++------------- .devcontainer/setup-signing.sh | 25 +++++++++++++------------ 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh index e4f64eda4..f2353f39f 100644 --- a/.devcontainer/post-start.sh +++ b/.devcontainer/post-start.sh @@ -24,24 +24,25 @@ CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true else - # Verify the key still exists in the agent + # Verify the signing key is still accessible CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") if [ -n "$CONFIGURED_KEY" ]; then - if ! ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - # In web Codespace mode, reload the key into a fresh agent - if [ -f "$CODESPACE_KEY" ]; then - echo " ↻ Reloading Codespace signing key into SSH agent..." - if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then - eval "$(ssh-agent -s)" > /dev/null - echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" - echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" - fi - ssh-add "$CODESPACE_KEY" 2>/dev/null && echo " ✓ Commit signing ready" + # If it's a file path (MODE 2), check file exists + if [[ "$CONFIGURED_KEY" == /* ]]; then + if [ -f "$CONFIGURED_KEY" ]; then + echo " ✓ Commit signing ready (private key file)" else - echo " ⚠ Signing key not found. YubiKey present or re-run: bash .devcontainer/setup-signing.sh" + echo " ⚠ Signing key file not found: $CONFIGURED_KEY" + echo " Re-run: bash .devcontainer/setup-signing.sh" fi + # If it's a public key string (MODE 1), check agent else - echo " ✓ Commit signing ready" + if ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then + echo " ✓ Commit signing ready (forwarded agent)" + else + echo " ⚠ Signing key not in SSH agent. YubiKey present?" + echo " Re-run: bash .devcontainer/setup-signing.sh" + fi fi fi fi diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh index 647c5926c..ca5866095 100644 --- a/.devcontainer/setup-signing.sh +++ b/.devcontainer/setup-signing.sh @@ -39,17 +39,8 @@ else read -p "Press Enter after adding the key to GitHub..." fi - # Start ssh-agent if not already running and add the key - if [ -z "${SSH_AUTH_SOCK:-}" ] || ! ssh-add -l &>/dev/null; then - echo " Starting SSH agent and loading key..." - eval "$(ssh-agent -s)" > /dev/null - ssh-add "$CODESPACE_KEY" 2>/dev/null - # Persist SSH_AUTH_SOCK for future shells - echo "export SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$HOME/.bashrc" - echo "export SSH_AGENT_PID=$SSH_AGENT_PID" >> "$HOME/.bashrc" - fi - - SIGNING_KEY=$(cat "${CODESPACE_KEY}.pub") + # Use the private key file directly (git supports this without ssh-agent) + SIGNING_KEY="$CODESPACE_KEY" echo " Using Codespace key: ${CODESPACE_KEY}" fi @@ -67,7 +58,17 @@ if [ -n "$USER_EMAIL" ]; then grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" fi - echo "$USER_EMAIL namespaces=\"git\" $SIGNING_KEY" >> "$ALLOWED_SIGNERS_FILE" + + # For allowed_signers, always use the public key (even if signing with private key file) + if [ -f "$CODESPACE_KEY" ]; then + # MODE 2: read public key from file + PUBLIC_KEY=$(cat "${CODESPACE_KEY}.pub") + else + # MODE 1: already have public key in $SIGNING_KEY + PUBLIC_KEY="$SIGNING_KEY" + fi + + echo "$USER_EMAIL namespaces=\"git\" $PUBLIC_KEY" >> "$ALLOWED_SIGNERS_FILE" git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" echo "✓ SSH commit signing configured for <$USER_EMAIL>" else From 1eb238b45a4e82e394bba2c39e13a23f808bb5b6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 04:31:10 +0000 Subject: [PATCH 135/185] updates --- .devcontainer/on-create.sh | 0 .devcontainer/post-create.sh | 0 .devcontainer/post-start.sh | 0 .devcontainer/setup-signing.sh | 0 .devcontainer/update-content.sh | 0 5 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 .devcontainer/on-create.sh mode change 100644 => 100755 .devcontainer/post-create.sh mode change 100644 => 100755 .devcontainer/post-start.sh mode change 100644 => 100755 .devcontainer/setup-signing.sh mode change 100644 => 100755 .devcontainer/update-content.sh diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh old mode 100644 new mode 100755 diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh old mode 100644 new mode 100755 From e9e03f69f57108f4b8bd6e5de64fa54cd4508d30 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 04:36:12 +0000 Subject: [PATCH 136/185] add/setup extensions --- .devcontainer/devcontainer.json | 21 ++++++++++++++++++++- .vscode/extensions.json | 27 ++++++++++++++++++++++++++- .vscode/settings.json | 19 ++++++++++++++++++- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ddf43676c..c1ffa7c9e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -33,15 +33,34 @@ "esbenp.prettier-vscode", "webpro.vscode-knip", "ms-vscode.vscode-typescript-next", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", // Git & GitHub "github.vscode-pull-request-github", "eamodio.gitlens", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", // Docs (Zola / TOML / Markdown) "tamasfe.even-better-toml", "yzhang.markdown-all-in-one", "eliostruyf.vscode-front-matter", + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", // Misc - "EditorConfig.EditorConfig" + "EditorConfig.EditorConfig", + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" ], "settings": { "editor.formatOnSave": true, diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 434432fea..5233852e8 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,28 @@ { - "recommendations": ["webpro.vscode-knip", "oxc.oxc-vscode"] + "recommendations": [ + // JS/TS toolchain + "oxc.oxc-vscode", + "webpro.vscode-knip", + "usernamehw.errorlens", + "christian-kohler.path-intellisense", + "styled-components.vscode-styled-components", + "bradlc.vscode-tailwindcss", + // React/TypeScript + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // i18n + "lokalise.i18n-ally", + // Testing + "vitest.explorer", + // Infrastructure + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax", + // Documentation + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // Quality of Life + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 60018f71d..e3a56b5cd 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -14,5 +14,22 @@ "editor.defaultFormatter": "oxc.oxc-vscode" }, "nixEnvSelector.nixFile": "${workspaceFolder}/flake.nix", - "nixEnvSelector.useFlakes": true + "nixEnvSelector.useFlakes": true, + // i18n Ally configuration + "i18n-ally.localesPaths": ["public/locales"], + "i18n-ally.keystyle": "nested", + "i18n-ally.enabledFrameworks": ["react", "i18next"], + "i18n-ally.namespace": true, + "i18n-ally.pathMatcher": "{locale}.json", + // Error Lens configuration + "errorLens.enabled": true, + // Import Cost configuration + "importCost.bundleSizeDecoration": "both", + "importCost.showCalculatingDecoration": true, + // Todo Tree configuration + "todo-tree.general.tags": ["TODO", "FIXME", "HACK", "XXX", "NOTE", "BUG"], + "todo-tree.highlights.defaultHighlight": { + "icon": "alert", + "type": "text" + } } From 849158308351ba5f30e4f06e540aa55bc567cb8a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 11:53:49 -0400 Subject: [PATCH 137/185] chore(config): add Copilot workspace instructions --- .github/copilot-instructions.md | 82 +++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..882847e80 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,82 @@ +# Sable – GitHub Copilot Workspace Instructions + +These rules apply to every chat and agent session in this workspace. + +--- + +## Git & Branching + +- **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). +- Before building `integration`, always **force-update `dev` from `upstream/dev`**: + ``` + git fetch upstream && git checkout dev && git reset --hard upstream/dev + ``` +- When asked to build `integration`, **always prompt for which feature/fix branches to include**. If a needed branch doesn't exist yet, create it first. +- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). + +## Quality Gates (must pass before every commit) + +Run these in order and fix all failures before committing: + +``` +pnpm lint # ESLint +pnpm fmt:check # Prettier +pnpm typecheck # TypeScript +pnpm test:run # Vitest unit tests +pnpm knip # Dead-code / unused exports check +``` + +Also run a **production build** and confirm it succeeds with no errors: +``` +pnpm build +``` + +## Pull Requests + +- Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. +- Descriptions should be short, clear, and human-readable. No AI-generated explanations in the AI disclosure section. +- Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). +- PRs must not target `dev` directly without a reviewed branch. +- Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. +- Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). +- If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. + +## Matrix Spec Compliance + +- New features and fixes must match the **current Matrix spec** or the relevant **MSC** if the spec change is pending. +- Check how **Element Web**, **FluffyChat**, or **Nheko** implement the same thing before diverging from established client patterns. +- Link the relevant spec section or MSC in the PR description when the change is spec-driven. + +## Feature Flags + +- Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. +- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. +- Document the flag in `docs/sample.env` and in the Sable-Docs documentation repo. + +## Code Quality + +- Code must follow **TypeScript/React best practices**: functional components, hooks, no class components, proper dependency arrays on `useEffect`/`useCallback`/`useMemo`. +- No `any` casts without a comment explaining why it's unavoidable. +- Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. +- Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. +- Prefer explicit types over inferred types for public function signatures. + +## Documentation + +- When a new feature is added (or an existing one materially changed), **update the Sable-Docs repo** (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. +- Keep docs concise — match the style of existing pages. + +## Security + +- Follow OWASP Top 10 guidance. No `innerHTML`, no `eval`, sanitise all user/Matrix-sourced content before rendering. +- Do not log or expose access tokens, room keys, or other secrets. +- Content Security Policy headers (Caddyfile / Dockerfile) must not be weakened without a documented reason. + +## Additional Rules + +- **No over-engineering**: only make changes directly requested or clearly necessary. Don't add abstractions for one-off operations. +- **Reversible actions only**: ask before deleting files/branches, force-pushing, or dropping data. +- **Dependency changes** (adding/removing packages) require explicit confirmation before running `pnpm install`. +- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. +- Test files live alongside source in `src/` (e.g. `*.test.ts`). Match the naming convention of existing tests. +- **Write tests when needed**: any new utility function, hook, or non-trivial logic should have a corresponding Vitest test. Bug fixes should include a regression test where feasible. From b34be8f35fb3181ecab4fa58a16275cf4e642eec Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 12:29:40 -0400 Subject: [PATCH 138/185] chore(config): remove devcontainer (setup didn't work out) --- .devcontainer/devcontainer.json | 90 --------------------------------- .devcontainer/on-create.sh | 19 ------- .devcontainer/post-create.sh | 72 -------------------------- .devcontainer/post-start.sh | 50 ------------------ .devcontainer/setup-signing.sh | 81 ----------------------------- .devcontainer/update-content.sh | 19 ------- 6 files changed, 331 deletions(-) delete mode 100644 .devcontainer/devcontainer.json delete mode 100755 .devcontainer/on-create.sh delete mode 100755 .devcontainer/post-create.sh delete mode 100755 .devcontainer/post-start.sh delete mode 100755 .devcontainer/setup-signing.sh delete mode 100755 .devcontainer/update-content.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index c1ffa7c9e..000000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:24-bookworm", - - // Minimum 4 cores / 8 GB RAM so Vite builds and TypeScript checks don't crawl - "hostRequirements": { - "cpus": 4, - "memory": "8gb", - "storage": "32gb" - }, - - "features": { - // GitHub CLI for PR/issue/fork management - "ghcr.io/devcontainers/features/github-cli:1": {} - }, - - // Expose Vite dev server and Zola docs preview - "forwardPorts": [5173, 8080, 1111], - "portsAttributes": { - "5173": { "label": "Vite Dev Server", "onAutoForward": "notify" }, - "8080": { "label": "App Preview", "onAutoForward": "notify" }, - "1111": { "label": "Docs Preview (Zola)", "onAutoForward": "notify" } - }, - - // Open the multi-root workspace covering both Sable + Sable-Docs - "workspaceFile": "${localWorkspaceFolder}/sable.code-workspace", - - "customizations": { - "vscode": { - "extensions": [ - // JS/TS toolchain - "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", - "webpro.vscode-knip", - "ms-vscode.vscode-typescript-next", - "usernamehw.errorlens", - "christian-kohler.path-intellisense", - "styled-components.vscode-styled-components", - "bradlc.vscode-tailwindcss", - // React/TypeScript - "dsznajder.es7-react-js-snippets", - "formulahendry.auto-rename-tag", - "wix.vscode-import-cost", - // i18n - "lokalise.i18n-ally", - // Testing - "vitest.explorer", - // Git & GitHub - "github.vscode-pull-request-github", - "eamodio.gitlens", - // Infrastructure - "hashicorp.terraform", - "zamerick.vscode-caddyfile-syntax", - // Docs (Zola / TOML / Markdown) - "tamasfe.even-better-toml", - "yzhang.markdown-all-in-one", - "eliostruyf.vscode-front-matter", - "streetsidesoftware.code-spell-checker", - "davidanson.vscode-markdownlint", - // Misc - "EditorConfig.EditorConfig", - "gruntfuggly.todo-tree", - "wayou.vscode-todo-highlight" - ], - "settings": { - "editor.formatOnSave": true, - "editor.defaultFormatter": "esbenp.prettier-vscode", - "typescript.tsdk": "node_modules/typescript/lib", - "[jsonc]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" }, - "git.autofetch": true, - "terminal.integrated.defaultProfile.linux": "bash" - } - } - }, - - // ── Lifecycle hooks ──────────────────────────────────────────────────────── - // on-create : runs ONCE when the prebuild image is first built (cached) - // update-content: re-runs on each prebuild refresh & new codespace create (cached) - // post-create : runs once on each new codespace (not cached) – user-specific setup - // post-start : runs on EVERY codespace start (fetch upstream, signing check) - - "onCreateCommand": "bash .devcontainer/on-create.sh", - "updateContentCommand": "bash .devcontainer/update-content.sh", - "postCreateCommand": "bash .devcontainer/post-create.sh", - "postStartCommand": "bash .devcontainer/post-start.sh", - - "remoteUser": "node" -} diff --git a/.devcontainer/on-create.sh b/.devcontainer/on-create.sh deleted file mode 100755 index 7f6f789d8..000000000 --- a/.devcontainer/on-create.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# on-create.sh — runs ONCE when the prebuild image is first built -# Everything here is cached between prebuild refreshes. -set -euo pipefail - -echo "==> [on-create] Enabling corepack (pnpm)..." -sudo corepack enable -corepack prepare pnpm@latest --activate - -echo "==> [on-create] Configuring pnpm global store..." -pnpm config set store-dir /home/node/.local/share/pnpm/store - -echo "==> [on-create] Installing Zola (for Sable-Docs preview)..." -ZOLA_VERSION="0.19.2" -ZOLA_URL="https://github.com/getzola/zola/releases/download/v${ZOLA_VERSION}/zola-v${ZOLA_VERSION}-x86_64-unknown-linux-gnu.tar.gz" -curl -fsSL "$ZOLA_URL" | sudo tar xz -C /usr/local/bin -zola --version - -echo "==> [on-create] Done." diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index 4f2ef27a1..000000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,72 +0,0 @@ -#!/usr/bin/env bash -# post-create.sh — runs ONCE per new codespace (not cached in prebuild). -# Handles user-specific git setup: remotes, branches, signing. -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -DOCS_DIR="/workspaces/Sable-Docs" - -# ── 1. Upstream remotes ─────────────────────────────────────────────────────── -echo "==> [post-create] Configuring upstream remotes..." - -# Sable: fork = origin (Just-Insane/Sable), upstream = SableClient/Sable -if ! git -C "$SABLE_DIR" remote | grep -q "^upstream$"; then - git -C "$SABLE_DIR" remote add upstream https://github.com/SableClient/Sable.git - echo " Added upstream → SableClient/Sable" -else - echo " upstream remote already set" -fi -git -C "$SABLE_DIR" fetch --all --quiet - -# Docs: fork = origin (Just-Insane/docs), upstream = SableClient/docs -if ! git -C "$DOCS_DIR" remote | grep -q "^upstream$"; then - git -C "$DOCS_DIR" remote add upstream https://github.com/SableClient/docs.git - echo " [docs] Added upstream → SableClient/docs" -else - echo " [docs] upstream remote already set" -fi -git -C "$DOCS_DIR" fetch --all --quiet - -# ── 2. Ensure required branches exist ──────────────────────────────────────── -echo "==> [post-create] Ensuring branches exist in Sable..." - -ensure_branch() { - local dir="$1" - local branch="$2" - local start_point="${3:-HEAD}" - if git -C "$dir" ls-remote --heads origin "$branch" | grep -q "$branch"; then - echo " Branch '$branch' already exists on origin, checking out..." - git -C "$dir" fetch origin "$branch" --quiet - if ! git -C "$dir" show-ref --quiet "refs/heads/$branch"; then - git -C "$dir" branch --track "$branch" "origin/$branch" - fi - else - echo " Creating branch '$branch' from $start_point and pushing to origin..." - git -C "$dir" checkout -b "$branch" "$start_point" 2>/dev/null || true - git -C "$dir" push -u origin "$branch" - fi -} - -# Switch back to integration after branch ops -CURRENT_BRANCH=$(git -C "$SABLE_DIR" rev-parse --abbrev-ref HEAD) - -ensure_branch "$SABLE_DIR" "integration" "upstream/dev" -ensure_branch "$SABLE_DIR" "personal/config" "integration" -ensure_branch "$DOCS_DIR" "integration" "upstream/main" - -# Return to whatever branch we were on -git -C "$SABLE_DIR" checkout "$CURRENT_BRANCH" 2>/dev/null || true - -# ── 3. Git signing (SSH via forwarded YubiKey) ──────────────────────────────── -echo "==> [post-create] Configuring SSH commit signing..." -bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true - -# ── 4. Install git hooks ────────────────────────────────────────────────────── -echo "==> [post-create] Installing git hooks..." -if [ -f "$SABLE_DIR/scripts/install-git-hooks.sh" ]; then - bash "$SABLE_DIR/scripts/install-git-hooks.sh" -fi - -echo "" -echo "==> [post-create] Done! Open sable.code-workspace for the multi-root view." -echo " Run '.devcontainer/setup-signing.sh' any time to reconfigure commit signing." diff --git a/.devcontainer/post-start.sh b/.devcontainer/post-start.sh deleted file mode 100755 index f2353f39f..000000000 --- a/.devcontainer/post-start.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env bash -# post-start.sh — runs on EVERY codespace start. -# Fetches upstream changes and re-checks signing (agent may have changed). -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -DOCS_DIR="/workspaces/Sable-Docs" - -# ── Fetch upstream for both repos ──────────────────────────────────────────── -echo "==> [post-start] Fetching upstream..." -git -C "$SABLE_DIR" fetch upstream --quiet 2>/dev/null && echo " Sable upstream fetched" || echo " ⚠ Could not fetch Sable upstream" -git -C "$DOCS_DIR" fetch upstream --quiet 2>/dev/null && echo " Docs upstream fetched" || echo " ⚠ Could not fetch Docs upstream" - -# ── Show how far behind integration is from upstream/dev ───────────────────── -BEHIND=$(git -C "$SABLE_DIR" rev-list --count HEAD..upstream/dev 2>/dev/null || echo "?") -if [ "$BEHIND" != "0" ] && [ "$BEHIND" != "?" ]; then - echo "" - echo " ℹ Your current branch is $BEHIND commit(s) behind upstream/dev." - echo " To sync: git merge upstream/dev (or: git rebase upstream/dev)" -fi - -# ── Re-configure SSH signing if not already set (agent may now be available) ─ -CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" -if [ "$(git config --global gpg.format 2>/dev/null)" != "ssh" ]; then - bash "$SABLE_DIR/.devcontainer/setup-signing.sh" || true -else - # Verify the signing key is still accessible - CONFIGURED_KEY=$(git config --global user.signingkey 2>/dev/null || echo "") - if [ -n "$CONFIGURED_KEY" ]; then - # If it's a file path (MODE 2), check file exists - if [[ "$CONFIGURED_KEY" == /* ]]; then - if [ -f "$CONFIGURED_KEY" ]; then - echo " ✓ Commit signing ready (private key file)" - else - echo " ⚠ Signing key file not found: $CONFIGURED_KEY" - echo " Re-run: bash .devcontainer/setup-signing.sh" - fi - # If it's a public key string (MODE 1), check agent - else - if ssh-add -L 2>/dev/null | grep -qF "$CONFIGURED_KEY"; then - echo " ✓ Commit signing ready (forwarded agent)" - else - echo " ⚠ Signing key not in SSH agent. YubiKey present?" - echo " Re-run: bash .devcontainer/setup-signing.sh" - fi - fi - fi -fi - -echo "" diff --git a/.devcontainer/setup-signing.sh b/.devcontainer/setup-signing.sh deleted file mode 100755 index ca5866095..000000000 --- a/.devcontainer/setup-signing.sh +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env bash -# setup-signing.sh — configures SSH commit signing. -# Supports two modes: -# 1. Forwarded SSH agent (VS Code desktop + YubiKey) -# 2. Codespace-local SSH key (browser/web Codespaces) -# Safe to re-run at any time. -set -euo pipefail - -SABLE_DIR="/workspaces/Sable" -ALLOWED_SIGNERS_FILE="$HOME/.config/git/allowed_signers" -CODESPACE_KEY="$HOME/.ssh/codespace_signing_ed25519" - -# ── MODE 1: Forwarded SSH agent (desktop VS Code) ──────────────────────────── -if ssh-add -L &>/dev/null && [ -n "$(ssh-add -L 2>/dev/null)" ]; then - echo "✓ Detected forwarded SSH agent (desktop VS Code + YubiKey mode)" - SIGNING_KEY=$(ssh-add -L | head -1) - KEY_COMMENT=$(echo "$SIGNING_KEY" | awk '{print $NF}') - echo " Using key: ...${KEY_COMMENT}" - -# ── MODE 2: Codespace-local key (web Codespaces) ───────────────────────────── -else - echo "ℹ No forwarded agent (web Codespace mode)" - - if [ ! -f "$CODESPACE_KEY" ]; then - echo " Generating new Ed25519 signing key..." - mkdir -p "$HOME/.ssh" - ssh-keygen -t ed25519 -f "$CODESPACE_KEY" -N "" -C "codespace-signing@$(hostname)" - echo "" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " 🔑 Add this PUBLIC KEY to GitHub as a SIGNING key:" - echo "" - cat "${CODESPACE_KEY}.pub" - echo "" - echo " 👉 https://github.com/settings/keys → New SSH key" - echo " Title: Codespace Signing Key" - echo " Key type: Signing Key" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - read -p "Press Enter after adding the key to GitHub..." - fi - - # Use the private key file directly (git supports this without ssh-agent) - SIGNING_KEY="$CODESPACE_KEY" - echo " Using Codespace key: ${CODESPACE_KEY}" -fi - -# ── Common: Configure git ──────────────────────────────────────────────────── -git config --global gpg.format ssh -git config --global user.signingkey "$SIGNING_KEY" -git config --global commit.gpgsign true -git config --global tag.gpgsign true - -# Set up allowed_signers for local verification -USER_EMAIL=$(git config --global user.email 2>/dev/null || echo "") -if [ -n "$USER_EMAIL" ]; then - mkdir -p "$(dirname "$ALLOWED_SIGNERS_FILE")" - if [ -f "$ALLOWED_SIGNERS_FILE" ]; then - grep -v "^$USER_EMAIL " "$ALLOWED_SIGNERS_FILE" > "${ALLOWED_SIGNERS_FILE}.tmp" || true - mv "${ALLOWED_SIGNERS_FILE}.tmp" "$ALLOWED_SIGNERS_FILE" - fi - - # For allowed_signers, always use the public key (even if signing with private key file) - if [ -f "$CODESPACE_KEY" ]; then - # MODE 2: read public key from file - PUBLIC_KEY=$(cat "${CODESPACE_KEY}.pub") - else - # MODE 1: already have public key in $SIGNING_KEY - PUBLIC_KEY="$SIGNING_KEY" - fi - - echo "$USER_EMAIL namespaces=\"git\" $PUBLIC_KEY" >> "$ALLOWED_SIGNERS_FILE" - git config --global gpg.ssh.allowedSignersFile "$ALLOWED_SIGNERS_FILE" - echo "✓ SSH commit signing configured for <$USER_EMAIL>" -else - echo "⚠ user.email not set globally. Run: git config --global user.email 'you@example.com'" - echo " Then re-run: bash .devcontainer/setup-signing.sh" -fi - -echo "" -echo "Test signing: git commit --allow-empty -m 'test signing'" -echo "Verify: git log --show-signature -1" \ No newline at end of file diff --git a/.devcontainer/update-content.sh b/.devcontainer/update-content.sh deleted file mode 100755 index 572ae73ba..000000000 --- a/.devcontainer/update-content.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# update-content.sh — runs on each prebuild refresh AND on new codespace creation. -# The resulting filesystem state is cached in the prebuild snapshot. -set -euo pipefail - -echo "==> [update-content] Installing Sable dependencies (pnpm install)..." -pnpm install --frozen-lockfile - -echo "==> [update-content] Cloning / updating Sable-Docs..." -DOCS_DIR="/workspaces/Sable-Docs" -if [ -d "$DOCS_DIR/.git" ]; then - echo " Docs already present, fetching latest..." - git -C "$DOCS_DIR" fetch --all -else - echo " Cloning Just-Insane/docs → $DOCS_DIR" - git clone https://github.com/Just-Insane/docs "$DOCS_DIR" -fi - -echo "==> [update-content] Done." From 441669682797e196e0579440dbf89187ff82dc8b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 17:25:18 -0400 Subject: [PATCH 139/185] Revise GitHub Copilot workspace instructions Updated instructions for pull requests and feature flags. --- .github/copilot-instructions.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 882847e80..1b7ec036c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,6 +1,6 @@ # Sable – GitHub Copilot Workspace Instructions -These rules apply to every chat and agent session in this workspace. +These rules apply to every chat and agent session in this workspace. Follow all rules that follow while responding to chat requests. --- @@ -34,7 +34,7 @@ pnpm build ## Pull Requests - Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. -- Descriptions should be short, clear, and human-readable. No AI-generated explanations in the AI disclosure section. +- Descriptions should be short, clear, and human-readable. - Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). - PRs must not target `dev` directly without a reviewed branch. - Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. @@ -51,7 +51,7 @@ pnpm build - Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. - Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. -- Document the flag in `docs/sample.env` and in the Sable-Docs documentation repo. +- Document the flag in `config.json` and in the Sable-Docs documentation repo. ## Code Quality From eabb98972846a3056db67bce7e82bfec949ffac6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 29 Mar 2026 18:07:14 -0400 Subject: [PATCH 140/185] Update branching instructions for syncing with upstream Added instructions for syncing branches before creating a new branch. --- .github/copilot-instructions.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1b7ec036c..10a5a8b50 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -7,6 +7,7 @@ These rules apply to every chat and agent session in this workspace. Follow all ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). + - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev From ba8b222eb3ec652c7d4256ed4aec51a08c424663 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:29:02 -0400 Subject: [PATCH 141/185] Revise instructions for clarity and consistency Updated wording for clarity and consistency in instructions. --- .github/copilot-instructions.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10a5a8b50..e23b792a5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,13 +1,13 @@ # Sable – GitHub Copilot Workspace Instructions -These rules apply to every chat and agent session in this workspace. Follow all rules that follow while responding to chat requests. +These rules apply to every chat and agent session in this workspace. Follow all instructions below while responding to chat requests. --- ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and build the branch from `dev` + - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev From dc5b5a85e4279e0d12b9b8273423b49838696553 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:32:23 -0400 Subject: [PATCH 142/185] Move `copilot-instructions.md` to correct location --- .github/copilot-instructions.md => copilot-instructions.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/copilot-instructions.md => copilot-instructions.md (100%) diff --git a/.github/copilot-instructions.md b/copilot-instructions.md similarity index 100% rename from .github/copilot-instructions.md rename to copilot-instructions.md From 714800ba7d380e6382df08187be1f27f552e3cf8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 13:41:11 -0400 Subject: [PATCH 143/185] Clarify branch creation and PR instructions --- copilot-instructions.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/copilot-instructions.md b/copilot-instructions.md index e23b792a5..bf4716e92 100644 --- a/copilot-instructions.md +++ b/copilot-instructions.md @@ -7,12 +7,12 @@ These rules apply to every chat and agent session in this workspace. Follow all ## Git & Branching - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch, always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` + - When creating a branch (i.e. if a branch for the requested change doesn't exist or there isn't an existing branch that fits), always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` - Before building `integration`, always **force-update `dev` from `upstream/dev`**: ``` git fetch upstream && git checkout dev && git reset --hard upstream/dev ``` -- When asked to build `integration`, **always prompt for which feature/fix branches to include**. If a needed branch doesn't exist yet, create it first. +- When asked to build `integration`, **always prompt for which feature/fix branches to include**. In general, all feat/fix/chore/etc branches should be inlcuded. - Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). ## Quality Gates (must pass before every commit) @@ -34,10 +34,9 @@ pnpm build ## Pull Requests -- Use the upstream PR template ([`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. +- Use the upstream PR template (i.e. [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. - Descriptions should be short, clear, and human-readable. - Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). -- PRs must not target `dev` directly without a reviewed branch. - Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. - Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). - If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. @@ -60,6 +59,7 @@ pnpm build - No `any` casts without a comment explaining why it's unavoidable. - Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. - Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. +- Add concise docstrings, comments, and/or type annotations on updating/new code in the current task. - Prefer explicit types over inferred types for public function signatures. ## Documentation From 3849d44d8677fe3672805bf7d71972537c7bbb51 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 30 Mar 2026 15:22:58 -0400 Subject: [PATCH 144/185] Docs have this location too... --- copilot-instructions.md => .github/copilot-instructions.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename copilot-instructions.md => .github/copilot-instructions.md (100%) diff --git a/copilot-instructions.md b/.github/copilot-instructions.md similarity index 100% rename from copilot-instructions.md rename to .github/copilot-instructions.md From 598b60d462337aa20676f7faed711d95ff854419 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 10:55:38 -0400 Subject: [PATCH 145/185] chore(config): split copilot-instructions into scoped instruction files and AGENTS.md --- .github/copilot-instructions.md | 85 ++----------------- .github/instructions/security.instructions.md | 10 +++ .../instructions/typescript.instructions.md | 29 +++++++ AGENTS.md | 73 ++++++++++++++++ 4 files changed, 121 insertions(+), 76 deletions(-) create mode 100644 .github/instructions/security.instructions.md create mode 100644 .github/instructions/typescript.instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index bf4716e92..401bc55cb 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,83 +1,16 @@ -# Sable – GitHub Copilot Workspace Instructions +# Sable – GitHub Copilot Instructions -These rules apply to every chat and agent session in this workspace. Follow all instructions below while responding to chat requests. +Universal rules that apply to every session. Detailed guidance lives in `.github/instructions/` and `AGENTS.md`. ---- - -## Git & Branching +## Core Rules - **Never commit directly to `dev` or `integration`.** All work goes on a dedicated branch (`fix/…`, `feat/…`, `chore/…`, etc.). - - When creating a branch (i.e. if a branch for the requested change doesn't exist or there isn't an existing branch that fits), always sync `upstream/dev` to `origin/dev` and `dev`, and then build the branch from `dev` -- Before building `integration`, always **force-update `dev` from `upstream/dev`**: +- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). +- Run quality gates in order and fix all failures before committing: ``` - git fetch upstream && git checkout dev && git reset --hard upstream/dev + pnpm lint && pnpm fmt:check && pnpm typecheck && pnpm test:run && pnpm knip && pnpm build ``` -- When asked to build `integration`, **always prompt for which feature/fix branches to include**. In general, all feat/fix/chore/etc branches should be inlcuded. -- Use short, scoped commit messages: `type(scope): description` (e.g. `fix(timeline): correct scroll anchor on bulk load`). - -## Quality Gates (must pass before every commit) - -Run these in order and fix all failures before committing: - -``` -pnpm lint # ESLint -pnpm fmt:check # Prettier -pnpm typecheck # TypeScript -pnpm test:run # Vitest unit tests -pnpm knip # Dead-code / unused exports check -``` - -Also run a **production build** and confirm it succeeds with no errors: -``` -pnpm build -``` - -## Pull Requests - -- Use the upstream PR template (i.e. [`.github/PULL_REQUEST_TEMPLATE.md`](.github/PULL_REQUEST_TEMPLATE.md)) in full — all checkboxes must be present. -- Descriptions should be short, clear, and human-readable. -- Each PR gets **one changeset line** (or one `fix:` + one `feat:` if both are genuinely present, though prefer separate PRs). -- Before opening a PR, **search for related open and merged PRs on both `upstream` (SableClient/Sable or cinnyapp/cinny) and `origin`**. Review them to understand what else may be in flight that could affect the change. Summarise any findings and ask the user how to proceed if there is overlap or conflict. -- Before opening a PR, **search for related open issues on both `upstream` and `origin`**. If any are related, prompt the user to confirm, then link them in the PR description (`Closes #N` / `Related to #N`). -- If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other in their descriptions. - -## Matrix Spec Compliance - -- New features and fixes must match the **current Matrix spec** or the relevant **MSC** if the spec change is pending. -- Check how **Element Web**, **FluffyChat**, or **Nheko** implement the same thing before diverging from established client patterns. -- Link the relevant spec section or MSC in the PR description when the change is spec-driven. - -## Feature Flags - -- Every user-visible new feature must be gated behind a **feature flag** in `config.json` / `useClientConfig`. -- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. -- Document the flag in `config.json` and in the Sable-Docs documentation repo. - -## Code Quality - -- Code must follow **TypeScript/React best practices**: functional components, hooks, no class components, proper dependency arrays on `useEffect`/`useCallback`/`useMemo`. -- No `any` casts without a comment explaining why it's unavoidable. -- Comments must be **short and purposeful** — explain *why*, not *what*. No decorative separator lines (`//------`), no block comments restating the code. -- Do not add docstrings, comments, or type annotations to code that wasn't changed in the current task. -- Add concise docstrings, comments, and/or type annotations on updating/new code in the current task. -- Prefer explicit types over inferred types for public function signatures. - -## Documentation - -- When a new feature is added (or an existing one materially changed), **update the Sable-Docs repo** (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. -- Keep docs concise — match the style of existing pages. - -## Security - -- Follow OWASP Top 10 guidance. No `innerHTML`, no `eval`, sanitise all user/Matrix-sourced content before rendering. -- Do not log or expose access tokens, room keys, or other secrets. -- Content Security Policy headers (Caddyfile / Dockerfile) must not be weakened without a documented reason. - -## Additional Rules - +- No `any` casts without an inline comment explaining why it's unavoidable. - **No over-engineering**: only make changes directly requested or clearly necessary. Don't add abstractions for one-off operations. -- **Reversible actions only**: ask before deleting files/branches, force-pushing, or dropping data. -- **Dependency changes** (adding/removing packages) require explicit confirmation before running `pnpm install`. -- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. -- Test files live alongside source in `src/` (e.g. `*.test.ts`). Match the naming convention of existing tests. -- **Write tests when needed**: any new utility function, hook, or non-trivial logic should have a corresponding Vitest test. Bug fixes should include a regression test where feasible. +- **Reversible actions only**: ask before deleting files/branches, force-pushing, dropping data, or running `pnpm install` to add/remove packages. +- Do not log or expose access tokens, room keys, or other secrets. diff --git a/.github/instructions/security.instructions.md b/.github/instructions/security.instructions.md new file mode 100644 index 000000000..9586e7e1f --- /dev/null +++ b/.github/instructions/security.instructions.md @@ -0,0 +1,10 @@ +--- +applyTo: "src/**,Caddyfile,Dockerfile" +--- + +## Security + +- Follow OWASP Top 10 guidance. +- No `innerHTML`, no `eval`; sanitise all user-supplied and Matrix-sourced content before rendering. +- Do not log or expose access tokens, room keys, or other secrets. +- Content Security Policy headers in `Caddyfile` and `Dockerfile` must not be weakened without a documented reason. diff --git a/.github/instructions/typescript.instructions.md b/.github/instructions/typescript.instructions.md new file mode 100644 index 000000000..4ea1a1ac3 --- /dev/null +++ b/.github/instructions/typescript.instructions.md @@ -0,0 +1,29 @@ +--- +applyTo: "src/**" +--- + +## TypeScript & React + +- Functional components and hooks only. No class components. +- Proper dependency arrays on `useEffect`, `useCallback`, and `useMemo`. +- Prefer explicit types over inferred types for public/exported function signatures. +- No `any` casts without an inline comment explaining why it's unavoidable. + +## Comments & Documentation + +- Comments must be **short and purposeful** — explain *why*, not *what*. +- No decorative separator lines (`//------`), no block comments restating the code. +- Do not add docstrings, comments, or type annotations to code that was not changed in the current task. +- Add concise docstrings, comments, and/or type annotations to new or updated code. + +## Testing + +- Test files live alongside source in `src/` (e.g. `foo.test.ts`). Match the naming convention of existing tests. +- Write Vitest tests for any new utility function, hook, or non-trivial logic. +- Bug fixes should include a regression test where feasible. + +## Feature Flags + +- Every user-visible new feature must be gated behind a feature flag in `config.json` / `useClientConfig`. +- Flags default to `false` (opt-in) unless the feature is a bug fix or a non-breaking improvement with no regressions. +- Document the flag in `config.json` and in the Sable-Docs documentation repo. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..62c3f8d99 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,73 @@ +# Sable – Agent Instructions + +Workflow and process rules for AI agents. These complement the universal rules in `.github/copilot-instructions.md`. + +--- + +## Git & Branching + +- Never commit directly to `dev` or `integration`. +- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`: + ``` + git fetch upstream + git checkout dev && git reset --hard upstream/dev + git push origin dev + git checkout -b feat/your-branch dev + ``` +- Before building `integration`, always force-update `dev` from `upstream/dev`: + ``` + git fetch upstream && git checkout dev && git reset --hard upstream/dev + ``` +- When asked to build `integration`, always prompt for which feature/fix/chore branches to include. In general, include all non-`dev` branches. + +## Quality Gates + +Run these in order and fix all failures before committing: + +``` +pnpm lint # ESLint +pnpm fmt:check # Prettier +pnpm typecheck # TypeScript +pnpm test:run # Vitest unit tests +pnpm knip # Dead-code / unused exports check +pnpm build # Production build — must succeed with no errors +``` + +## Pull Requests + +- Use the PR template (`.github/PULL_REQUEST_TEMPLATE.md`) in full — all checkboxes must be present. +- Descriptions should be short, clear, and human-readable. +- Each PR gets one changeset line (or `fix:` + `feat:` if both are genuinely present; prefer separate PRs otherwise). + +### Pre-PR Research + +1. Search for related open **and** merged PRs on `upstream` (`SableClient/Sable` and `cinnyapp/cinny`) and `origin`. Summarise findings and ask how to proceed if there is overlap or conflict. +2. Search for related open **issues** on `upstream` and `origin`. Confirm with the user, then link any related ones in the PR description (`Closes #N` / `Related to #N`). +3. If the PR has a corresponding `SableClient/docs` PR, link both PRs to each other. + +## Matrix Spec Compliance + +- New features and fixes must match the current Matrix spec, or the relevant MSC if the spec change is pending. +- Check how Element Web, FluffyChat, or Nheko implement the same thing before diverging from established client patterns. +- Link the relevant spec section or MSC in the PR description when the change is spec-driven. + +## Documentation + +- When a new feature is added (or an existing one materially changed), update the Sable-Docs repo (`/Users/evie/git/Sable-Docs`). Add or update the relevant page under `content/features/` or `content/general/`. +- Keep docs concise — match the style of existing pages. + +## Dependency Changes + +- Adding or removing packages requires explicit user confirmation before running `pnpm install`. + +## Merge Conflicts + +- When resolving merge conflicts, prefer the version from the feature branch; ask if the intent is ambiguous. + +## Destructive Actions + +Always ask before: +- Deleting files or branches (`git branch -D`, `rm`, etc.) +- Force-pushing (`git push --force`) +- Hard-resetting local branches other than `dev`/`integration` (`git reset --hard`) +- Dropping or truncating data From 8cdbee1f3fc96e71f0084ac16e5abf0b598eb348 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 11:02:00 -0400 Subject: [PATCH 146/185] Update git instructions in AGENTS.md --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 62c3f8d99..266aa22d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ Workflow and process rules for AI agents. These complement the universal rules i ## Git & Branching - Never commit directly to `dev` or `integration`. -- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`: +- When creating a branch, first sync `upstream/dev` to `origin/dev` and local `dev`, then branch from `dev`, with `origin/dev` as the remote: ``` git fetch upstream git checkout dev && git reset --hard upstream/dev From c545a4801fdced5d8ae183d7eb6a67068c46d398 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 31 Mar 2026 11:42:00 -0400 Subject: [PATCH 147/185] Update git commands --- AGENTS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 266aa22d2..c44ee7052 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,9 +14,9 @@ Workflow and process rules for AI agents. These complement the universal rules i git push origin dev git checkout -b feat/your-branch dev ``` -- Before building `integration`, always force-update `dev` from `upstream/dev`: +- Before building `integration`, always force-update `origin/dev` from `upstream/dev`, then force-update `dev`: ``` - git fetch upstream && git checkout dev && git reset --hard upstream/dev + git fetch upstream && git push origin upstream/dev:dev --force && git fetch origin && git checkout dev && git reset --hard origin/dev ``` - When asked to build `integration`, always prompt for which feature/fix/chore branches to include. In general, include all non-`dev` branches. @@ -67,6 +67,7 @@ pnpm build # Production build — must succeed with no errors ## Destructive Actions Always ask before: + - Deleting files or branches (`git branch -D`, `rm`, etc.) - Force-pushing (`git push --force`) - Hard-resetting local branches other than `dev`/`integration` (`git reset --hard`) From dfe68004f7203ba976cc98f20332fdb64bf2c9b4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:28:26 +0000 Subject: [PATCH 148/185] chore(codespace): add devcontainer for iPad browser + SSH signing --- .devcontainer/devcontainer.json | 90 +++++++++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 65 ++++++++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/postCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..30f6a2a33 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,90 @@ +// Codespace configuration — lives on personal/config (not ephemeral dev/feat branches). +// This file intentionally targets browser-based use on iPad. +{ + "name": "Sable", + "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + + "features": { + // Keep git up-to-date for SSH signing support (git ≥ 2.34). + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/devcontainers/features/github-cli:1": {} + }, + + // ── Codespace user secrets ────────────────────────────────────────────────── + // Configure these at: github.com/settings/codespaces > Secrets + // + // GIT_SIGNING_KEY — passphrase-free SSH private key (ed25519 recommended). + // Add the matching public key to your GitHub account as a + // "signing key": github.com/settings/keys + // postCreate.sh will wire up git automatically if set. + // + // GIT_USER_NAME — e.g. "Evie" + // GIT_USER_EMAIL — e.g. "you@example.com" + // ─────────────────────────────────────────────────────────────────────────── + + "remoteEnv": { + // Pin the pnpm store to a known path so the volume mount works across rebuilds. + "PNPM_STORE_DIR": "/home/node/.pnpm-store" + }, + + "customizations": { + "vscode": { + "settings": { + // ── Layout — tuned for iPad browser (vscode.dev / Codespaces web) ───── + // Move the activity bar to the top so it isn't hidden by the iOS Safari + // toolbar or the browser's combined title/status bar. + "workbench.activityBar.location": "top", + // Use a menu for the layout control — fewer tiny hit targets on touch. + "workbench.layoutControl.type": "menu", + // Place the panel (Terminal, Problems, Copilot Chat history) on the + // right so it doesn't fight with the keyboard on small screens. + "workbench.panel.defaultLocation": "right", + // Keep editor tabs visible and wrap them so none are hidden off-screen. + "workbench.editor.showTabs": "multiple", + "workbench.editor.wrapTabs": true, + // Disable minimap — saves horizontal space, improves touch accuracy. + "editor.minimap.enabled": false, + "editor.scrollBeyondLastLine": false, + // Larger default fonts for retina/HiDPI iPad displays. + "editor.fontSize": 14, + "terminal.integrated.fontSize": 14, + + // ── Git signing ─────────────────────────────────────────────────────── + // postCreate.sh configures gpg.format and user.signingkey if + // GIT_SIGNING_KEY secret is present. This just keeps VS Code's git + // UI in sync. + "git.enableCommitSigning": true, + "git.confirmSync": false, + + // ── Copilot Chat ────────────────────────────────────────────────────── + // Always show follow-ups and keep chat history accessible. + "github.copilot.chat.followUps": "always" + }, + "extensions": [ + "GitHub.copilot", + "GitHub.copilot-chat", + "esbenp.prettier-vscode", + "dbaeumer.vscode-eslint", + "vitest.explorer" + ] + } + }, + + // ── Port forwarding ───────────────────────────────────────────────────────── + "forwardPorts": [5173, 4173], + "portsAttributes": { + "5173": { "label": "Vite dev", "onAutoForward": "notify" }, + "4173": { "label": "Vite preview", "onAutoForward": "notify" } + }, + + // ── Persistence ───────────────────────────────────────────────────────────── + // Named volume keeps the pnpm content-addressable store across rebuilds. + // Combined with the PNPM_STORE_DIR env var above so postCreate can also + // point pnpm at the same path. + "mounts": [ + "source=sable-pnpm-store,target=/home/node/.pnpm-store,type=volume" + ], + + "postCreateCommand": "bash .devcontainer/postCreate.sh", + "remoteUser": "node" +} diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh new file mode 100644 index 000000000..f6c539985 --- /dev/null +++ b/.devcontainer/postCreate.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# postCreate.sh — runs once after the Codespace container is created. +set -euo pipefail + +# ── pnpm ────────────────────────────────────────────────────────────────────── +# Enable corepack so the exact pnpm version from package.json#packageManager is used. +corepack enable + +# Point pnpm at the persistent named-volume store so packages survive rebuilds. +if [ -n "${PNPM_STORE_DIR:-}" ]; then + pnpm config set store-dir "${PNPM_STORE_DIR}" +fi + +pnpm install + +# ── Git identity ────────────────────────────────────────────────────────────── +# Populate from Codespace user secrets if they aren't already set by dotfiles. +if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then + git config --global user.name "${GIT_USER_NAME}" +fi + +if [ -n "${GIT_USER_EMAIL:-}" ] && [ -z "$(git config --global user.email 2>/dev/null)" ]; then + git config --global user.email "${GIT_USER_EMAIL}" +fi + +# ── Git SSH commit signing ──────────────────────────────────────────────────── +# Requires a Codespace user secret named GIT_SIGNING_KEY containing a +# passphrase-free SSH private key (ed25519 recommended). +# +# To set up: +# 1. Generate a key: ssh-keygen -t ed25519 -C "codespace signing" -N "" -f ~/.ssh/signing_key +# 2. Copy the private key into a GitHub Codespace secret called GIT_SIGNING_KEY: +# github.com/settings/codespaces > Secrets > New secret +# 3. Add the *public* key to your GitHub account as a signing key (not auth key): +# github.com/settings/keys > New signing key +# ---------------------------------------------------------------------------- +if [ -n "${GIT_SIGNING_KEY:-}" ]; then + SSH_DIR="${HOME}/.ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + + KEY_FILE="${SSH_DIR}/git_signing_key" + printf '%s\n' "${GIT_SIGNING_KEY}" > "${KEY_FILE}" + chmod 600 "${KEY_FILE}" + + # Derive the public key from the private key so the user only stores one secret. + ssh-keygen -y -f "${KEY_FILE}" > "${KEY_FILE}.pub" + chmod 644 "${KEY_FILE}.pub" + + # Configure git to use SSH signing. + git config --global gpg.format ssh + git config --global user.signingkey "${KEY_FILE}.pub" + git config --global commit.gpgsign true + git config --global tag.gpgsign true + + # Allow this key when verifying signatures locally. + ALLOWED_SIGNERS="${SSH_DIR}/allowed_signers" + EMAIL="$(git config --global user.email 2>/dev/null || echo "you@example.com")" + echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" + git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" + + echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" +fi + +echo "✓ postCreate complete" From 2633396ad2878aa21cae7ffa6a146ced3d75ec46 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:43:17 +0000 Subject: [PATCH 149/185] chore(codespace): add Fira Code font + ligatures --- .devcontainer/devcontainer.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 30f6a2a33..5a9c48160 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -47,7 +47,10 @@ "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. "editor.fontSize": 14, + "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, + "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if @@ -63,6 +66,7 @@ "extensions": [ "GitHub.copilot", "GitHub.copilot-chat", + "tonsky.font-fira-code", "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", "vitest.explorer" From debac1bfad2ff98de4e39c36c3050999bec399e4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:45:50 +0000 Subject: [PATCH 150/185] chore(codespace): split onCreate/postCreate for prebuild caching --- .devcontainer/devcontainer.json | 1 + .devcontainer/onCreate.sh | 16 ++++++++++++++++ .devcontainer/postCreate.sh | 14 ++------------ 3 files changed, 19 insertions(+), 12 deletions(-) create mode 100644 .devcontainer/onCreate.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 5a9c48160..94864c6fb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -90,5 +90,6 @@ ], "postCreateCommand": "bash .devcontainer/postCreate.sh", + "onCreateCommand": "bash .devcontainer/onCreate.sh", "remoteUser": "node" } diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh new file mode 100644 index 000000000..dcb012cd0 --- /dev/null +++ b/.devcontainer/onCreate.sh @@ -0,0 +1,16 @@ +#!/bin/bash +# onCreate.sh — runs during prebuild AND on first Codespace creation. +# No user secrets are available here — keep this purely about dependencies. +set -euo pipefail + +# Enable corepack so the exact pnpm version from package.json#packageManager is used. +corepack enable + +# Point pnpm at the persistent named-volume store so packages survive rebuilds. +if [ -n "${PNPM_STORE_DIR:-}" ]; then + pnpm config set store-dir "${PNPM_STORE_DIR}" +fi + +pnpm install + +echo "✓ onCreate complete" diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index f6c539985..1d76c8e70 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -1,18 +1,8 @@ #!/bin/bash -# postCreate.sh — runs once after the Codespace container is created. +# postCreate.sh — runs once after the Codespace container is created (NOT during prebuild). +# Secrets (GIT_SIGNING_KEY, GIT_USER_NAME, GIT_USER_EMAIL) are available here. set -euo pipefail -# ── pnpm ────────────────────────────────────────────────────────────────────── -# Enable corepack so the exact pnpm version from package.json#packageManager is used. -corepack enable - -# Point pnpm at the persistent named-volume store so packages survive rebuilds. -if [ -n "${PNPM_STORE_DIR:-}" ]; then - pnpm config set store-dir "${PNPM_STORE_DIR}" -fi - -pnpm install - # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 3bfa86ec208609ea5e1db3526118b1f8f053f965 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 19:58:56 +0000 Subject: [PATCH 151/185] chore(codespace): fix image tag, install OMZ+P10k, wire dotfiles bare-repo --- .devcontainer/devcontainer.json | 13 +++++++++---- .devcontainer/onCreate.sh | 26 ++++++++++++++++++++++++++ .devcontainer/postCreate.sh | 29 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 4 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 94864c6fb..f5223c3cb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -2,9 +2,12 @@ // This file intentionally targets browser-based use on iPad. { "name": "Sable", - "image": "mcr.microsoft.com/devcontainers/javascript-node:1-24-bookworm", + // Using base + node feature instead of javascript-node: to avoid + // tag availability issues on newer Node versions. + "image": "mcr.microsoft.com/devcontainers/base:bookworm", "features": { + "ghcr.io/devcontainers/features/node:1": { "version": "24" }, // Keep git up-to-date for SSH signing support (git ≥ 2.34). "ghcr.io/devcontainers/features/git:1": {}, "ghcr.io/devcontainers/features/github-cli:1": {} @@ -24,7 +27,7 @@ "remoteEnv": { // Pin the pnpm store to a known path so the volume mount works across rebuilds. - "PNPM_STORE_DIR": "/home/node/.pnpm-store" + "PNPM_STORE_DIR": "/home/vscode/.pnpm-store" }, "customizations": { @@ -51,6 +54,8 @@ "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + // Use zsh (installed in onCreate) as the default terminal shell. + "terminal.integrated.defaultProfile.linux": "zsh", // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if @@ -86,10 +91,10 @@ // Combined with the PNPM_STORE_DIR env var above so postCreate can also // point pnpm at the same path. "mounts": [ - "source=sable-pnpm-store,target=/home/node/.pnpm-store,type=volume" + "source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume" ], "postCreateCommand": "bash .devcontainer/postCreate.sh", "onCreateCommand": "bash .devcontainer/onCreate.sh", - "remoteUser": "node" + "remoteUser": "vscode" } diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index dcb012cd0..bc2d2a967 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -1,8 +1,10 @@ #!/bin/bash # onCreate.sh — runs during prebuild AND on first Codespace creation. # No user secrets are available here — keep this purely about dependencies. +# Everything here is cached in the prebuild snapshot. set -euo pipefail +# ── pnpm ────────────────────────────────────────────────────────────────────── # Enable corepack so the exact pnpm version from package.json#packageManager is used. corepack enable @@ -13,4 +15,28 @@ fi pnpm install +# ── Zsh + Oh My Zsh + Powerlevel10k ────────────────────────────────────────── +# Install these during prebuild so the first Codespace start is fast. +# The dotfiles checkout in postCreate.sh will provide .zshrc / .p10k.zsh. + +# Install zsh if not already present (base:bookworm ships it, but be safe). +if ! command -v zsh &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq zsh +fi + +# Install Oh My Zsh non-interactively (KEEP_ZSHRC=yes preserves any existing .zshrc). +if [ ! -d "${HOME}/.oh-my-zsh" ]; then + KEEP_ZSHRC=yes CHSH=no RUNZSH=no \ + sh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" +fi + +# Install Powerlevel10k as an OMZ custom theme. +P10K_DIR="${ZSH_CUSTOM:-${HOME}/.oh-my-zsh/custom}/themes/powerlevel10k" +if [ ! -d "${P10K_DIR}" ]; then + git clone --depth=1 https://github.com/romkatv/powerlevel10k.git "${P10K_DIR}" +fi + +# Make zsh the default shell for this user. +sudo chsh -s "$(command -v zsh)" "$(whoami)" + echo "✓ onCreate complete" diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 1d76c8e70..8d9a404f7 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -3,6 +3,35 @@ # Secrets (GIT_SIGNING_KEY, GIT_USER_NAME, GIT_USER_EMAIL) are available here. set -euo pipefail +# ── Dotfiles (bare git repo, MacStudio branch) ──────────────────────────────── +# The dotfiles repo uses the "bare repo in $HOME" pattern. +# We clone a specific branch so we get the VS Code / Codespace-aware config +# (e.g. the P10k instant-prompt guard for $TERM_PROGRAM == "vscode"). +DOTFILES_REPO="https://github.com/Just-Insane/dotfiles.git" +DOTFILES_BRANCH="MacStudio" +DOTFILES_DIR="${HOME}/.cfg" + +if [ ! -d "${DOTFILES_DIR}" ]; then + git clone --bare --branch "${DOTFILES_BRANCH}" "${DOTFILES_REPO}" "${DOTFILES_DIR}" + + # Check out dotfiles to $HOME. Use --force to overwrite any stub files + # created by the devcontainer (e.g. a default .bashrc). + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" checkout --force "${DOTFILES_BRANCH}" + + # Don't show untracked files (the whole home dir) in status. + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + config --local status.showUntrackedFiles no + + echo "✓ Dotfiles checked out from ${DOTFILES_BRANCH}" +else + # Already exists (e.g. Codespace resumed) — just pull latest. + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + fetch origin "${DOTFILES_BRANCH}" && \ + git --git-dir="${DOTFILES_DIR}" --work-tree="${HOME}" \ + checkout --force "${DOTFILES_BRANCH}" + echo "✓ Dotfiles updated" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 06a3e50690d7f3568ec8e4a23cfe2ff5ecd0a24f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 20:12:26 +0000 Subject: [PATCH 152/185] fix(codespace): suppress corepack download prompt, source nvm in onCreate --- .devcontainer/onCreate.sh | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index bc2d2a967..e06e627d7 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -4,7 +4,19 @@ # Everything here is cached in the prebuild snapshot. set -euo pipefail +# ── Ensure the node feature's PATH additions are active ────────────────────── +# The devcontainers node feature installs via nvm; source it so `node`/`pnpm` +# resolve correctly even in non-login, non-interactive shells. +export NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" +# shellcheck source=/dev/null +[ -s "${NVM_DIR}/nvm.sh" ] && source "${NVM_DIR}/nvm.sh" --no-use +# Activate the version pinned in .nvmrc / package.json engines. +nvm use 24 2>/dev/null || nvm use node + # ── pnpm ────────────────────────────────────────────────────────────────────── +# Suppress corepack's interactive download-confirmation prompt in CI/prebuild. +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 + # Enable corepack so the exact pnpm version from package.json#packageManager is used. corepack enable From cc178683b64d9ec2c17bc03d2d00e882cbca00a6 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 20:17:55 +0000 Subject: [PATCH 153/185] fix(codespace): chown pnpm store volume before writing --- .devcontainer/onCreate.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index e06e627d7..d9ac8e0c2 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -13,6 +13,12 @@ export NVM_DIR="${NVM_DIR:-/usr/local/share/nvm}" # Activate the version pinned in .nvmrc / package.json engines. nvm use 24 2>/dev/null || nvm use node +# ── Fix named-volume ownership ──────────────────────────────────────────────── +# Docker mounts named volumes as root; fix ownership so the vscode user can write. +if [ -d "${PNPM_STORE_DIR:-}" ]; then + sudo chown -R "$(id -u):$(id -g)" "${PNPM_STORE_DIR}" +fi + # ── pnpm ────────────────────────────────────────────────────────────────────── # Suppress corepack's interactive download-confirmation prompt in CI/prebuild. export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 From 671fb1e6a7adf34da5071f7b457995d1965e19f8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 21:26:03 +0000 Subject: [PATCH 154/185] chore(devcontainer): add tmux, fix terminal font, add GitHub MCP server --- .devcontainer/devcontainer.json | 2 +- .devcontainer/onCreate.sh | 6 +++--- .vscode/mcp.json | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) create mode 100644 .vscode/mcp.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f5223c3cb..e3fe567e9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -53,7 +53,7 @@ "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", + "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", diff --git a/.devcontainer/onCreate.sh b/.devcontainer/onCreate.sh index d9ac8e0c2..2f2943fa9 100644 --- a/.devcontainer/onCreate.sh +++ b/.devcontainer/onCreate.sh @@ -37,9 +37,9 @@ pnpm install # Install these during prebuild so the first Codespace start is fast. # The dotfiles checkout in postCreate.sh will provide .zshrc / .p10k.zsh. -# Install zsh if not already present (base:bookworm ships it, but be safe). -if ! command -v zsh &>/dev/null; then - sudo apt-get update -qq && sudo apt-get install -y -qq zsh +# Install zsh and tmux if not already present (base:bookworm ships zsh, but be safe). +if ! command -v zsh &>/dev/null || ! command -v tmux &>/dev/null; then + sudo apt-get update -qq && sudo apt-get install -y -qq zsh tmux fi # Install Oh My Zsh non-interactively (KEEP_ZSHRC=yes preserves any existing .zshrc). diff --git a/.vscode/mcp.json b/.vscode/mcp.json new file mode 100644 index 000000000..b2bc0a4e8 --- /dev/null +++ b/.vscode/mcp.json @@ -0,0 +1,10 @@ +{ + // GitHub MCP server — uses existing Copilot auth, no token prompt needed. + // Works in browser-based Codespaces (no vscode:// redirect required). + "servers": { + "github": { + "type": "http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} From e62e9f732d21fbfc376cb32760a766cb43bc54cd Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 22:14:04 +0000 Subject: [PATCH 155/185] fix(devcontainer): use browser-safe font and compatible p10k glyphs for iPad --- .devcontainer/devcontainer.json | 4 +++- .devcontainer/postCreate.sh | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e3fe567e9..4f00813c1 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -53,7 +53,9 @@ "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, monospace", + // MesloLGS NF is a local system font — unavailable in the browser. + // Fira Code is loaded as a web font via the tonsky.font-fira-code extension. + "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 8d9a404f7..5df377989 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -32,6 +32,16 @@ else echo "✓ Dotfiles updated" fi +# ── Powerlevel10k — browser-compatible glyph mode ──────────────────────────── +# MesloLGS NF / Nerd Font glyphs are unavailable in browser-based Codespaces. +# Patch .p10k.zsh to use the 'compatible' Unicode symbol set instead, which +# renders correctly with any modern monospace font (e.g. Fira Code via extension). +if [ -f "${HOME}/.p10k.zsh" ]; then + sed -i "s/POWERLEVEL9K_MODE='nerdfont-v3'/POWERLEVEL9K_MODE='compatible'/" \ + "${HOME}/.p10k.zsh" + echo "✓ p10k mode set to compatible" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 3a44ec6f94dec23cc19c8f2ebf671e183ebef79a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Mon, 6 Apr 2026 22:59:15 +0000 Subject: [PATCH 156/185] fix(devcontainer): use Menlo as terminal font for iOS compatibility --- .devcontainer/devcontainer.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4f00813c1..1390755db 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -54,8 +54,10 @@ "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, // MesloLGS NF is a local system font — unavailable in the browser. - // Fira Code is loaded as a web font via the tonsky.font-fira-code extension. - "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", + // Fira Code (loaded via the tonsky extension) works for the editor renderer but + // not for the terminal canvas/DOM renderer on iOS — it doesn't arrive in time. + // Menlo is a native iOS/macOS system font and is always immediately available. + "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From e81e4e5f2745384b48bf1b132d5ea74e7564a505 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:18:28 +0000 Subject: [PATCH 157/185] update devcontainer settings --- .devcontainer/devcontainer.json | 14 +++----------- .devcontainer/postCreate.sh | 2 +- 2 files changed, 4 insertions(+), 12 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1390755db..21a144e1a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,7 +22,7 @@ // postCreate.sh will wire up git automatically if set. // // GIT_USER_NAME — e.g. "Evie" - // GIT_USER_EMAIL — e.g. "you@example.com" + // GIT_USER_EMAIL — e.g. "evie@gauthier.id" // ─────────────────────────────────────────────────────────────────────────── "remoteEnv": { @@ -50,14 +50,8 @@ "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. "editor.fontSize": 14, - "editor.fontFamily": "'Fira Code', 'Cascadia Code', Menlo, monospace", - "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - // MesloLGS NF is a local system font — unavailable in the browser. - // Fira Code (loaded via the tonsky extension) works for the editor renderer but - // not for the terminal canvas/DOM renderer on iOS — it doesn't arrive in time. - // Menlo is a native iOS/macOS system font and is always immediately available. - "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", + // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", @@ -94,9 +88,7 @@ // Named volume keeps the pnpm content-addressable store across rebuilds. // Combined with the PNPM_STORE_DIR env var above so postCreate can also // point pnpm at the same path. - "mounts": [ - "source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume" - ], + "mounts": ["source=sable-pnpm-store,target=/home/vscode/.pnpm-store,type=volume"], "postCreateCommand": "bash .devcontainer/postCreate.sh", "onCreateCommand": "bash .devcontainer/onCreate.sh", diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 5df377989..2e88c4e41 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -84,7 +84,7 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then # Allow this key when verifying signatures locally. ALLOWED_SIGNERS="${SSH_DIR}/allowed_signers" - EMAIL="$(git config --global user.email 2>/dev/null || echo "you@example.com")" + EMAIL="$(git config --global user.email 2>/dev/null || echo "evie@gauthier.id")" echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" From f3f8cfd8a4bea66607284d2c19722441e949d0e1 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:48:36 +0000 Subject: [PATCH 158/185] fix(devcontainer): restore missing fontFamily settings --- .devcontainer/devcontainer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 21a144e1a..e8adea620 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,8 +49,13 @@ "editor.minimap.enabled": false, "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. + // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, + // making it available in the browser terminal (Safari on iPad included). "editor.fontSize": 14, + "editor.fontFamily": "'Fira Code', Menlo, monospace", + "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, + "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 27266b3b107242988127c3d81c572980be3f3550 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:50:09 +0000 Subject: [PATCH 159/185] Update fontfamily --- .devcontainer/devcontainer.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8adea620..eb77e0164 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -52,10 +52,11 @@ // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, // making it available in the browser terminal (Safari on iPad included). "editor.fontSize": 14, - "editor.fontFamily": "'Fira Code', Menlo, monospace", + "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'Fira Code', Menlo, monospace", + "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", + "terminal.integrated.fontLigatures.enabled": true, // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From 47f27833f84d80ca510fe7eb6ab3eebbe601a59f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 14:55:58 +0000 Subject: [PATCH 160/185] chore(devcontainer): sync extensions list with installed extensions --- .devcontainer/devcontainer.json | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index eb77e0164..d1e4039f2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -73,12 +73,37 @@ "github.copilot.chat.followUps": "always" }, "extensions": [ + // ── Copilot ─────────────────────────────────────────────────────────── "GitHub.copilot", "GitHub.copilot-chat", + "GitHub.vscode-pull-request-github", + // ── Font (web font — required for terminal in browser/iPad) ─────────── "tonsky.font-fira-code", + // ── Theme ───────────────────────────────────────────────────────────── + "GitHub.github-vscode-theme", + // ── Formatting & linting ────────────────────────────────────────────── "esbenp.prettier-vscode", "dbaeumer.vscode-eslint", - "vitest.explorer" + "streetsidesoftware.code-spell-checker", + "davidanson.vscode-markdownlint", + // ── Testing ─────────────────────────────────────────────────────────── + "vitest.explorer", + // ── TypeScript / React ──────────────────────────────────────────────── + "bradlc.vscode-tailwindcss", + "styled-components.vscode-styled-components", + "dsznajder.es7-react-js-snippets", + "formulahendry.auto-rename-tag", + "wix.vscode-import-cost", + // ── Utilities ───────────────────────────────────────────────────────── + "christian-kohler.path-intellisense", + "usernamehw.errorlens", + "gruntfuggly.todo-tree", + "wayou.vscode-todo-highlight", + "webpro.vscode-knip", + "lokalise.i18n-ally", + // ── Infrastructure ──────────────────────────────────────────────────── + "hashicorp.terraform", + "zamerick.vscode-caddyfile-syntax" ] } }, From 5e2af59e6bf27627c85e1a3fd518e2f4d39ed16a Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 15:39:38 +0000 Subject: [PATCH 161/185] Update container config --- .devcontainer/devcontainer.json | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index d1e4039f2..da8fcc009 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,14 +49,19 @@ "editor.minimap.enabled": false, "editor.scrollBeyondLastLine": false, // Larger default fonts for retina/HiDPI iPad displays. - // Fira Code is loaded as a web font by the tonsky.font-fira-code extension, - // making it available in the browser terminal (Safari on iPad included). + // Fira Code is loaded as a web font by the tonsky.font-fira-code extension. + // This works for the Monaco *editor* (HTML/CSS rendered), but xterm.js uses + // canvas drawing — it does NOT reliably inherit CSS @font-face on iOS Safari. + // MesloLGS NF / Monaco / Meslo are not iOS system fonts either. + // → Editor: Fira Code via extension is fine. + // → Terminal: use Menlo only (ships with iOS since iOS 7, always available). "editor.fontSize": 14, - "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", + "editor.fontFamily": "'MesloLGS NF', 'Fira Code', Menlo, 'Courier New', monospace", "editor.fontLigatures": true, "terminal.integrated.fontSize": 14, - "terminal.integrated.fontFamily": "'MesloLGS NF', 'Fira Code', Meslo, Monaco, 'Courier New', monospace", - "terminal.integrated.fontLigatures.enabled": true, + "terminal.integrated.fontFamily": "Menlo, 'Courier New', monospace", + "terminal.integrated.fontLigatures.enabled": false, + "terminal.integrated.gpuAcceleration": "off", // Use zsh (installed in onCreate) as the default terminal shell. "terminal.integrated.defaultProfile.linux": "zsh", From ffcc16d3465b0bdd3dfaa3f3591107865c39a4a4 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 16:38:55 +0000 Subject: [PATCH 162/185] fix(devcontainer): load signing key into ssh-agent in postCreate --- .devcontainer/postCreate.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 2e88c4e41..52e37fb11 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -88,6 +88,10 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then echo "${EMAIL} $(cat "${KEY_FILE}.pub")" > "${ALLOWED_SIGNERS}" git config --global gpg.ssh.allowedSignersFile "${ALLOWED_SIGNERS}" + # Load the key into the ssh-agent so it's available for signing and SSH auth. + eval "$(ssh-agent -s)" &>/dev/null || true + ssh-add "${KEY_FILE}" + echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" fi From c94ce66405b1dd0c1c82fc1c18cd1b3e496a9de3 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 16:39:54 +0000 Subject: [PATCH 163/185] feat(devcontainer): add SSH_AUTH_KEY secret support for server access --- .devcontainer/devcontainer.json | 4 ++++ .devcontainer/postCreate.sh | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index da8fcc009..c7376421c 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -21,6 +21,10 @@ // "signing key": github.com/settings/keys // postCreate.sh will wire up git automatically if set. // + // SSH_AUTH_KEY — passphrase-free SSH private key (ed25519 recommended). + // Add the matching public key to ~/.ssh/authorized_keys on + // any server you want to SSH into from the Codespace. + // // GIT_USER_NAME — e.g. "Evie" // GIT_USER_EMAIL — e.g. "evie@gauthier.id" // ─────────────────────────────────────────────────────────────────────────── diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 52e37fb11..08be0b1ee 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -95,4 +95,32 @@ if [ -n "${GIT_SIGNING_KEY:-}" ]; then echo "✓ Git SSH commit signing configured (${KEY_FILE}.pub)" fi +# ── SSH auth key ────────────────────────────────────────────────────────────── +# Requires a Codespace user secret named SSH_AUTH_KEY containing a +# passphrase-free SSH private key (ed25519 recommended). +# +# To set up: +# 1. Generate a key: ssh-keygen -t ed25519 -C "codespace auth" -N "" -f ~/.ssh/id_ed25519 +# 2. Copy the private key into a GitHub Codespace secret called SSH_AUTH_KEY: +# github.com/settings/codespaces > Secrets > New secret +# 3. Add the *public* key to ~/.ssh/authorized_keys on your server. +# ---------------------------------------------------------------------------- +if [ -n "${SSH_AUTH_KEY:-}" ]; then + SSH_DIR="${HOME}/.ssh" + mkdir -p "${SSH_DIR}" + chmod 700 "${SSH_DIR}" + + AUTH_KEY_FILE="${SSH_DIR}/id_ed25519" + printf '%s\n' "${SSH_AUTH_KEY}" > "${AUTH_KEY_FILE}" + chmod 600 "${AUTH_KEY_FILE}" + + ssh-keygen -y -f "${AUTH_KEY_FILE}" > "${AUTH_KEY_FILE}.pub" + chmod 644 "${AUTH_KEY_FILE}.pub" + + eval "$(ssh-agent -s)" &>/dev/null || true + ssh-add "${AUTH_KEY_FILE}" + + echo "✓ SSH auth key loaded (${AUTH_KEY_FILE}.pub)" +fi + echo "✓ postCreate complete" From 41dea18fb0a3636af526ba40fbe2f89705e6914c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 17:00:56 +0000 Subject: [PATCH 164/185] fix(devcontainer): disable extension MCP auto-discovery, fix p10k sed pattern --- .devcontainer/devcontainer.json | 7 ++++++- .devcontainer/postCreate.sh | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index c7376421c..66fddc62d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -79,7 +79,12 @@ // ── Copilot Chat ────────────────────────────────────────────────────── // Always show follow-ups and keep chat history accessible. - "github.copilot.chat.followUps": "always" + "github.copilot.chat.followUps": "always", + // Disable auto-discovery of extension-provided MCP servers (e.g. the + // io.github.github/github-mcp-server registered by vscode-pull-request-github). + // Our explicit HTTP server in .vscode/mcp.json is unaffected and handles all + // GitHub MCP calls without requiring a token prompt. + "chat.mcp.discovery.enabled": false }, "extensions": [ // ── Copilot ─────────────────────────────────────────────────────────── diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 08be0b1ee..053c91c23 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -36,10 +36,13 @@ fi # MesloLGS NF / Nerd Font glyphs are unavailable in browser-based Codespaces. # Patch .p10k.zsh to use the 'compatible' Unicode symbol set instead, which # renders correctly with any modern monospace font (e.g. Fira Code via extension). +# The POWERLEVEL9K_MODE line has no quotes: POWERLEVEL9K_MODE=nerdfont-complete if [ -f "${HOME}/.p10k.zsh" ]; then - sed -i "s/POWERLEVEL9K_MODE='nerdfont-v3'/POWERLEVEL9K_MODE='compatible'/" \ + sed -i "s/POWERLEVEL9K_MODE=.*/POWERLEVEL9K_MODE=compatible/" \ "${HOME}/.p10k.zsh" echo "✓ p10k mode set to compatible" +else + echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi # ── Git identity ────────────────────────────────────────────────────────────── From 53b77a80b31ae563790a7e07897d6a9474c5e4c2 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 7 Apr 2026 18:27:51 +0000 Subject: [PATCH 165/185] fix(devcontainer): enable shell integration for Copilot Chat terminal --- .devcontainer/devcontainer.json | 11 +++++++++++ .devcontainer/postCreate.sh | 24 ++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 66fddc62d..f97cbc6fc 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -68,7 +68,18 @@ "terminal.integrated.gpuAcceleration": "off", // Use zsh (installed in onCreate) as the default terminal shell. + // Explicit profile with -l (login shell) ensures nvm / PATH additions + // from the devcontainer node feature are loaded inside the terminal. "terminal.integrated.defaultProfile.linux": "zsh", + "terminal.integrated.profiles.linux": { + "zsh": { "path": "/bin/zsh", "args": ["-l"] } + }, + + // Shell integration MUST be enabled for Copilot Chat to run terminal + // commands. We set it explicitly because Powerlevel10k instant prompt + // can fire before VS Code injects its integration script and suppress + // the markers — postCreate.sh patches .zshrc to guard against this. + "terminal.integrated.shellIntegration.enabled": true, // ── Git signing ─────────────────────────────────────────────────────── // postCreate.sh configures gpg.format and user.signingkey if diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 053c91c23..be95325ec 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -45,6 +45,30 @@ else echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi +# ── Powerlevel10k — disable instant prompt in VS Code terminal ──────────────── +# Instant prompt outputs to the terminal before VS Code injects its shell +# integration script. This breaks the integration markers that Copilot Chat +# relies on to run commands. We prepend a one-liner to .zshrc that sets +# POWERLEVEL9K_INSTANT_PROMPT=off whenever $TERM_PROGRAM is "vscode". +# The check is idempotent — safe to run on Codespace resume. +if [ -f "${HOME}/.zshrc" ]; then + if ! grep -q 'POWERLEVEL9K_INSTANT_PROMPT=off' "${HOME}/.zshrc"; then + tmp=$(mktemp) + { + printf '# Disable P10k instant prompt in VS Code — it fires before shell\n' + printf '# integration is injected, which breaks Copilot Chat terminal access.\n' + printf '[[ "$TERM_PROGRAM" == "vscode" ]] && typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' + cat "${HOME}/.zshrc" + } > "$tmp" + mv "$tmp" "${HOME}/.zshrc" + echo "✓ P10k instant prompt disabled for VS Code terminal" + else + echo "✓ P10k instant prompt VS Code guard already present" + fi +else + echo "⚠ ~/.zshrc not found — skipping instant-prompt patch (dotfiles not checked out?)" +fi + # ── Git identity ────────────────────────────────────────────────────────────── # Populate from Codespace user secrets if they aren't already set by dotfiles. if [ -n "${GIT_USER_NAME:-}" ] && [ -z "$(git config --global user.name 2>/dev/null)" ]; then From 5538e32e821037abd2e2e1a68ac7a2ef16d2a342 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 8 Apr 2026 18:24:20 +0000 Subject: [PATCH 166/185] chore(devcontainer): switch dotfiles branch to codespaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linux/Codespaces-clean branch — removes macOS NVM lazy-loader, Homebrew paths, macOS-only OMZ plugins, and hardcoded macOS gitconfig paths. --- .devcontainer/postCreate.sh | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index be95325ec..58dec301c 100644 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -8,7 +8,7 @@ set -euo pipefail # We clone a specific branch so we get the VS Code / Codespace-aware config # (e.g. the P10k instant-prompt guard for $TERM_PROGRAM == "vscode"). DOTFILES_REPO="https://github.com/Just-Insane/dotfiles.git" -DOTFILES_BRANCH="MacStudio" +DOTFILES_BRANCH="codespaces" DOTFILES_DIR="${HOME}/.cfg" if [ ! -d "${DOTFILES_DIR}" ]; then @@ -45,25 +45,29 @@ else echo "⚠ ~/.p10k.zsh not found — skipping p10k patch (add it to your dotfiles repo)" fi -# ── Powerlevel10k — disable instant prompt in VS Code terminal ──────────────── +# ── Powerlevel10k — disable instant prompt in Codespace terminal ────────────── # Instant prompt outputs to the terminal before VS Code injects its shell # integration script. This breaks the integration markers that Copilot Chat -# relies on to run commands. We prepend a one-liner to .zshrc that sets -# POWERLEVEL9K_INSTANT_PROMPT=off whenever $TERM_PROGRAM is "vscode". +# relies on to run commands. +# We unconditionally disable it here because: +# - In a Codespace, VS Code shell integration is always needed for Copilot Chat. +# - $TERM_PROGRAM is NOT reliably set to "vscode" in browser-based Codespaces +# (e.g. iPad / vscode.dev), so a conditional guard can silently fail. # The check is idempotent — safe to run on Codespace resume. if [ -f "${HOME}/.zshrc" ]; then if ! grep -q 'POWERLEVEL9K_INSTANT_PROMPT=off' "${HOME}/.zshrc"; then tmp=$(mktemp) { - printf '# Disable P10k instant prompt in VS Code — it fires before shell\n' - printf '# integration is injected, which breaks Copilot Chat terminal access.\n' - printf '[[ "$TERM_PROGRAM" == "vscode" ]] && typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' + printf '# Disable P10k instant prompt — it fires before VS Code shell\n' + printf '# integration is injected, breaking Copilot Chat terminal access.\n' + printf '# Unconditional: $TERM_PROGRAM is not reliable in browser Codespaces.\n' + printf 'typeset -g POWERLEVEL9K_INSTANT_PROMPT=off\n\n' cat "${HOME}/.zshrc" } > "$tmp" mv "$tmp" "${HOME}/.zshrc" - echo "✓ P10k instant prompt disabled for VS Code terminal" + echo "✓ P10k instant prompt unconditionally disabled" else - echo "✓ P10k instant prompt VS Code guard already present" + echo "✓ P10k instant prompt already disabled" fi else echo "⚠ ~/.zshrc not found — skipping instant-prompt patch (dotfiles not checked out?)" From 1ae33444837b7c07ebde3bbf9cc3a3276c119c3f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 15 Apr 2026 18:00:19 -0400 Subject: [PATCH 167/185] chore(prompts): add rebuild integration and review upstream PRs prompts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/prompts/rebuild integration.prompt.md | 12 ++++++++++++ .../review open PRs against `upstream`.prompt.md | 10 ++++++++++ 2 files changed, 22 insertions(+) create mode 100644 .github/prompts/rebuild integration.prompt.md create mode 100644 .github/prompts/review open PRs against `upstream`.prompt.md diff --git a/.github/prompts/rebuild integration.prompt.md b/.github/prompts/rebuild integration.prompt.md new file mode 100644 index 000000000..000673e52 --- /dev/null +++ b/.github/prompts/rebuild integration.prompt.md @@ -0,0 +1,12 @@ +--- +name: rebuild integration +description: When asked to rebuild integration, or if there are large numbers of changes to branches +--- + + + +Please rebuild the `integration` branch, by deleting `integration` and then creating a new `integration` branch from `dev`, after updating `dev` from `upstream/dev` (and push `dev` to `origin/dev`). This is needed because there are large numbers of changes to branches, and rebuilding the integration branch will help to ensure that it is up to date with the latest changes. + +Please prompt for which branches to include, and always include `personal/config`, as it is needed for the integration branch to work properly. If there are any other branches that need to be included, please prompt for those as well. + +We should also ensure that any necessary tests are run after rebuilding the integration branch, to verify that everything is working correctly. Please let me know if you have any questions or need any assistance with this process. \ No newline at end of file diff --git a/.github/prompts/review open PRs against `upstream`.prompt.md b/.github/prompts/review open PRs against `upstream`.prompt.md new file mode 100644 index 000000000..7c85531be --- /dev/null +++ b/.github/prompts/review open PRs against `upstream`.prompt.md @@ -0,0 +1,10 @@ +--- +name: review open PRs against `upstream` +description: When asked to review open PRs against `upstream` +--- + + + +Please look for all of my open/pending PRs against `upstream`, and review them for any issues, such as merge conflicts, failing checks, comments, or outdated code. If you find any problems, please provide feedback on how to resolve them. And/or implement the necessary changes to get the PRs ready for merging. + +Once done, please provide a summary of the status of each PR, including any actions taken or needed to get them merged. \ No newline at end of file From beb856d99ac456adaf865ac0cc09782d30e9597f Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 18:33:20 -0400 Subject: [PATCH 168/185] fix(notifications): add registration.active fallback for SW message relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When navigator.serviceWorker.controller is null — which happens when a window is opened by a notification tap before the SW has claimed it, or transiently during a SW update cycle — postMessage calls were silently dropped. This caused the decryption-result reply to never reach the SW, so the 5-second timeout fired and the notification either showed "Encrypted message" or was skipped entirely. Subsequent notifications after the first tended to fail because the tab that received the push was often the freshly-opened uncontrolled window. The same null-controller bug affected enablePushNotifications / disablePushNotifications: if the togglePush message was dropped, the homeserver pusher state was never updated, so no further pushes arrived. Fix: mirror the pattern already used by SyncNotificationSettingsWithSW and setNotificationSettings in the same file — send to both controller (fast path, usually correct) and registration.active (belt-and-suspenders via ready.then). Double delivery is safe: the SW's decryptionPendingMap deduplicates pushDecryptResult, and duplicate pusher SET/DELETE requests to the homeserver are idempotent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../notifications/PushNotifications.tsx | 27 +++++++------------ src/app/pages/client/ClientNonUIFeatures.tsx | 17 +++++++++--- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 7f510b444..39e3f50da 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -69,12 +69,9 @@ export async function enablePushNotifications( }, append: false, }; - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); + const toggleMsg = { url: mx.baseUrl, type: 'togglePush', pusherData, token: mx.getAccessToken() }; + navigator.serviceWorker.controller?.postMessage(toggleMsg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(toggleMsg)); return; } @@ -118,12 +115,9 @@ export async function enablePushNotifications( append: false, }; - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); + const enableMsg = { url: mx.baseUrl, type: 'togglePush', pusherData, token: mx.getAccessToken() }; + navigator.serviceWorker.controller?.postMessage(enableMsg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(enableMsg)); } /** @@ -144,12 +138,9 @@ export async function disablePushNotifications( pushkey: pushSubAtom?.keys?.p256dh, }; - navigator.serviceWorker.controller?.postMessage({ - url: mx.baseUrl, - type: 'togglePush', - pusherData, - token: mx.getAccessToken(), - }); + const disableMsg = { url: mx.baseUrl, type: 'togglePush', pusherData, token: mx.getAccessToken() }; + navigator.serviceWorker.controller?.postMessage(disableMsg); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(disableMsg)); } export async function deRegisterAllPushers(mx: MatrixClient): Promise { diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 8232af629..2479a16df 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -813,7 +813,7 @@ function HandleDecryptPushEvent() { appVisible: visible, }); - navigator.serviceWorker.controller?.postMessage({ + const successReply = { type: 'pushDecryptResult', eventId, success: true, @@ -822,7 +822,14 @@ function HandleDecryptPushEvent() { sender_display_name: senderName, room_name: room?.name ?? '', visibilityState: document.visibilityState, - }); + }; + navigator.serviceWorker.controller?.postMessage(successReply); + // Belt-and-suspenders: also post via registration.active so the reply + // reaches the SW even when controller is transiently null (e.g., the + // window was opened by a notification tap before the SW claimed it, or + // during a SW update cycle). The SW deduplicates via decryptionPendingMap + // — the second message is a no-op once the entry is already resolved. + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(successReply)); } catch (err) { console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err); pushRelayLog.error( @@ -830,12 +837,14 @@ function HandleDecryptPushEvent() { 'Push relay decryption failed', err instanceof Error ? err : new Error(String(err)) ); - navigator.serviceWorker.controller?.postMessage({ + const errorReply = { type: 'pushDecryptResult', eventId, success: false, visibilityState: document.visibilityState, - }); + }; + navigator.serviceWorker.controller?.postMessage(errorReply); + navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(errorReply)); } }; From 53a5a33c79dd73d4d7471441a8ef2dce5cb56f1c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 18:37:10 -0400 Subject: [PATCH 169/185] fix(timeline): remove duplicate MIN_INITIAL_SCROLL_ROOM_PX declaration Duplicate const introduced during integration merge causing build failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index a12d1761f..b52c6ffda 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -117,7 +117,6 @@ const getDayDividerText = (ts: number) => { const SCROLL_SETTLE_MS = 250; const MIN_INITIAL_SCROLL_ROOM_PX = 300; -const MIN_INITIAL_SCROLL_ROOM_PX = 300; const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; const buildRoomScrollFingerprint = ( From 4f7638d99647349c043e3ffa33602e1b68f97734 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 19:09:49 -0400 Subject: [PATCH 170/185] fix(timeline): restore missing refs and roomScrollCache utility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The integration rebuild partially included perf/timeline-rendering — it got the 'align initial room-fill thresholds' and 'stabilize bottom pin' commits but missed the 'feat(timeline): restore room cache by event anchor' commit chain. That commit introduced several refs and a utility module that the later commits depend on, causing a ReferenceError crash on room open: readyBlockedByPaginationRef is not defined (already restored earlier) saveRoomScrollStateRef / currentScrollFingerprintRef (type-only — no-op without wiring) prevScrollSizeRef (content-grow detection in handleVListScroll) isReadyRef (scroll-event guard during init) RoomScrollFingerprint type (return type of buildRoomScrollFingerprint) Restore: - src/app/utils/roomScrollCache.ts (deleted during merge) - import { roomScrollCache, RoomScrollCache, RoomScrollFingerprint, RoomScrollPosition } from roomScrollCache - const prevScrollSizeRef = useRef(0) - const isReadyRef = useRef(false); isReadyRef.current = isReady - const saveRoomScrollStateRef / currentScrollFingerprintRef The full scroll-position restore wiring (roomScrollCache.save/load, saveRoomScrollState callback, restoreRoomScrollPosition) is not yet wired up — the saveRoomScrollStateRef.current stays undefined so those call sites are no-ops — but all runtime references are now defined and the app no longer crashes on room open. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 17 +++++++ src/app/utils/roomScrollCache.ts | 67 ++++++++++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 src/app/utils/roomScrollCache.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index b52c6ffda..4e7a7ccea 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -16,6 +16,12 @@ import classNames from 'classnames'; import type { VListHandle } from 'virtua'; import { VList } from 'virtua'; import type { ContainerColor } from 'folds'; +import { + roomScrollCache, + RoomScrollCache, + RoomScrollFingerprint, + RoomScrollPosition, +} from '$utils/roomScrollCache'; import { as, Box, @@ -205,6 +211,7 @@ export function RoomTimeline({ hideReadsRef.current = hideReads; const prevViewportHeightRef = useRef(0); + const prevScrollSizeRef = useRef(0); const messageListRef = useRef(null); const mediaAuthentication = useMediaAuthentication(); @@ -250,9 +257,19 @@ export function RoomTimeline({ // A recovery useLayoutEffect watches for processedEvents becoming non-empty // and performs the final scroll + setIsReady when this flag is set. const pendingReadyRef = useRef(false); + // Set to true when the 80 ms timer fires but backward pagination hasn't yet + // filled the viewport. The pagination-settle effect below watches for this + // flag and performs the final scroll + setIsReady when pagination settles. + const readyBlockedByPaginationRef = useRef(false); const currentRoomIdRef = useRef(room.roomId); + const currentScrollFingerprintRef = useRef(undefined); + const saveRoomScrollStateRef = useRef< + ((measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => void) | undefined + >(undefined); const [isReady, setIsReady] = useState(false); + const isReadyRef = useRef(false); + isReadyRef.current = isReady; if (currentRoomIdRef.current !== room.roomId) { hasInitialScrolledRef.current = false; diff --git a/src/app/utils/roomScrollCache.ts b/src/app/utils/roomScrollCache.ts new file mode 100644 index 000000000..9739a8fbe --- /dev/null +++ b/src/app/utils/roomScrollCache.ts @@ -0,0 +1,67 @@ +import { CacheSnapshot } from 'virtua'; + +export type RoomScrollFingerprint = { + eventCount: number; + headEventIds: string[]; + tailEventIds: string[]; + readUptoEventId?: string; + layoutKey: string; +}; + +export type RoomScrollPosition = + | { + kind: 'live'; + } + | { + kind: 'anchor'; + eventId: string; + offset: number; + }; + +export type RoomScrollCache = { + /** VList item-size snapshot — restored via VList `cache=` prop on remount. */ + measurementCache?: CacheSnapshot; + /** Logical restore position captured from the rendered timeline. */ + position: RoomScrollPosition; + /** Timeline/layout fingerprint used to validate index-based measurements. */ + fingerprint: RoomScrollFingerprint; +}; + +/** Session-scoped, per-room scroll cache. Not persisted across page reloads. */ +const scrollCacheMap = new Map(); + +const cacheKey = (userId: string, roomId: string): string => `${userId}:${roomId}`; + +const fingerprintMatches = ( + saved: RoomScrollFingerprint, + current: RoomScrollFingerprint +): boolean => + saved.layoutKey === current.layoutKey && + saved.readUptoEventId === current.readUptoEventId && + saved.eventCount === current.eventCount && + saved.headEventIds.length > 0 && + saved.tailEventIds.length > 0 && + saved.headEventIds.every((eventId, index) => current.headEventIds[index] === eventId) && + saved.tailEventIds.every((eventId, index) => current.tailEventIds[index] === eventId); + +export const roomScrollCache = { + save(userId: string, roomId: string, data: RoomScrollCache): void { + scrollCacheMap.set(cacheKey(userId, roomId), data); + }, + load( + userId: string, + roomId: string, + currentFingerprint?: RoomScrollFingerprint + ): RoomScrollCache | undefined { + const cached = scrollCacheMap.get(cacheKey(userId, roomId)); + if (!cached) return undefined; + if (!currentFingerprint) return cached; + if (!fingerprintMatches(cached.fingerprint, currentFingerprint)) { + return { + ...cached, + measurementCache: undefined, + }; + } + return cached; + }, +}; From 092567cd34d16f3e9a5cacbd2c5adbba68d5071c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 19:15:34 -0400 Subject: [PATCH 171/185] fix(lint): resolve all ESLint errors across integration - RoomTimeline: remove unused TIMELINE_ANCHOR_SELECTOR, buildRoomScrollFingerprint, currentScrollFingerprintRef (incomplete perf/timeline wiring stubs) - RoomTimeline: narrow import to RoomScrollCache only (RoomScrollFingerprint unused) - sw-session: remove void operator to satisfy no-void rule - Auto-fixed prettier formatting in PushNotifications, ClientNonUIFeatures, useProcessedTimeline, BackgroundNotifications Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 25 ++----------------- .../notifications/PushNotifications.tsx | 14 +++++++++-- .../hooks/timeline/useProcessedTimeline.ts | 4 +-- .../pages/client/BackgroundNotifications.tsx | 3 +-- src/app/pages/client/ClientNonUIFeatures.tsx | 7 +++++- src/sw-session.ts | 2 +- 6 files changed, 23 insertions(+), 32 deletions(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 4e7a7ccea..15ac39b5b 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -16,12 +16,7 @@ import classNames from 'classnames'; import type { VListHandle } from 'virtua'; import { VList } from 'virtua'; import type { ContainerColor } from 'folds'; -import { - roomScrollCache, - RoomScrollCache, - RoomScrollFingerprint, - RoomScrollPosition, -} from '$utils/roomScrollCache'; +import { RoomScrollCache } from '$utils/roomScrollCache'; import { as, Box, @@ -124,18 +119,6 @@ const getDayDividerText = (ts: number) => { const SCROLL_SETTLE_MS = 250; const MIN_INITIAL_SCROLL_ROOM_PX = 300; -const TIMELINE_ANCHOR_SELECTOR = '[data-timeline-event-id]'; -const buildRoomScrollFingerprint = ( - eventIds: string[], - readUptoEventId: string | undefined, - layoutKey: string -): RoomScrollFingerprint => ({ - eventCount: eventIds.length, - headEventIds: eventIds.slice(0, 5), - tailEventIds: eventIds.slice(-5), - readUptoEventId, - layoutKey, -}); export type RoomTimelineProps = { room: Room; eventId?: string; @@ -262,7 +245,6 @@ export function RoomTimeline({ // flag and performs the final scroll + setIsReady when pagination settles. const readyBlockedByPaginationRef = useRef(false); const currentRoomIdRef = useRef(room.roomId); - const currentScrollFingerprintRef = useRef(undefined); const saveRoomScrollStateRef = useRef< ((measurementCache: RoomScrollCache['measurementCache'], atBottom: boolean) => void) | undefined >(undefined); @@ -408,10 +390,7 @@ export function RoomTimeline({ const v = vListRef.current; if (!v) return; // Still not filled and can paginate more — keep waiting. - if ( - canPaginateBackRef.current && - v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX - ) + if (canPaginateBackRef.current && v.scrollSize <= v.viewportSize + MIN_INITIAL_SCROLL_ROOM_PX) return; readyBlockedByPaginationRef.current = false; v.scrollToIndex(processedEventsRef.current.length - 1, { align: 'end' }); diff --git a/src/app/features/settings/notifications/PushNotifications.tsx b/src/app/features/settings/notifications/PushNotifications.tsx index 39e3f50da..684840149 100644 --- a/src/app/features/settings/notifications/PushNotifications.tsx +++ b/src/app/features/settings/notifications/PushNotifications.tsx @@ -69,7 +69,12 @@ export async function enablePushNotifications( }, append: false, }; - const toggleMsg = { url: mx.baseUrl, type: 'togglePush', pusherData, token: mx.getAccessToken() }; + const toggleMsg = { + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }; navigator.serviceWorker.controller?.postMessage(toggleMsg); navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(toggleMsg)); return; @@ -138,7 +143,12 @@ export async function disablePushNotifications( pushkey: pushSubAtom?.keys?.p256dh, }; - const disableMsg = { url: mx.baseUrl, type: 'togglePush', pusherData, token: mx.getAccessToken() }; + const disableMsg = { + url: mx.baseUrl, + type: 'togglePush', + pusherData, + token: mx.getAccessToken(), + }; navigator.serviceWorker.controller?.postMessage(disableMsg); navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(disableMsg)); } diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 115bdfcc4..835bc7399 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -117,9 +117,7 @@ export function useProcessedTimeline({ // via RoomEvent.Timeline listeners in PollEvent and must never render as timeline items. // Also check the effective (decrypted) type for encrypted events that have been decrypted. const effectiveType = - type === 'm.room.encrypted' - ? ((mEvent.getEffectiveEvent()?.type as string) ?? type) - : type; + type === 'm.room.encrypted' ? (mEvent.getEffectiveEvent()?.type ?? type) : type; if ( effectiveType === 'org.matrix.msc3381.poll.response' || effectiveType === 'org.matrix.msc3381.poll.end' || diff --git a/src/app/pages/client/BackgroundNotifications.tsx b/src/app/pages/client/BackgroundNotifications.tsx index 774c558e6..23edeb527 100644 --- a/src/app/pages/client/BackgroundNotifications.tsx +++ b/src/app/pages/client/BackgroundNotifications.tsx @@ -446,8 +446,7 @@ export function BackgroundNotifications() { // After decryption, getType() still returns the wire type (m.room.encrypted). // Use the effective event type to get the decrypted type when available. - const effectiveEventType = - (mEvent.getEffectiveEvent()?.type as string) ?? mEvent.getType(); + const effectiveEventType = mEvent.getEffectiveEvent()?.type ?? mEvent.getType(); notifiedEventsRef.current.add(dedupeId); // Cap the set so it doesn't grow unbounded diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 2479a16df..00c4b3af0 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -345,7 +345,12 @@ function MessageNotifications() { return; } - if (!room || isHistoricalEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined)) { + if ( + !room || + isHistoricalEvent || + room.isSpaceRoom() || + !isNotificationEvent(mEvent, room, mx.getUserId() ?? undefined) + ) { return; } diff --git a/src/sw-session.ts b/src/sw-session.ts index 63d285d99..326487c3a 100644 --- a/src/sw-session.ts +++ b/src/sw-session.ts @@ -31,7 +31,7 @@ export function pushSessionToSW(baseUrl?: string, accessToken?: string, userId?: // Backgrounded/mobile browsers can drop the current controller reference even // though the registration is still active. Post to any reachable worker from // navigator.serviceWorker.ready so the session is restored without a reload. - void navigator.serviceWorker.ready + navigator.serviceWorker.ready .then((registration) => { postSessionPayload(registration.active, payload, seenTargets); postSessionPayload(registration.waiting, payload, seenTargets); From c85bae76799a11c57de9f619c79d07d68391e356 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Tue, 21 Apr 2026 21:27:18 -0400 Subject: [PATCH 172/185] feat(timeline): configurable message grouping threshold Add Discord-style message grouping where consecutive messages from the same sender within a configurable time window collapse the sender header. - Add messageGroupingThreshold to Settings type (default: 2 min) - Replace hardcoded 2-min threshold in useProcessedTimeline with the setting - Wire useSetting in RoomTimeline and pass to useProcessedTimeline - Add MessageGrouping component in Experimental settings with 5 options: 2 min (default), 5 min, 15 min (Discord-style), 30 min, 60 min Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 2 + .../settings/experimental/Experimental.tsx | 2 + .../settings/experimental/MessageGrouping.tsx | 53 +++++++++++++++++++ .../hooks/timeline/useProcessedTimeline.ts | 11 +++- src/app/state/settings.ts | 6 +++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 src/app/features/settings/experimental/MessageGrouping.tsx diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 15ac39b5b..95f9d46a5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -165,6 +165,7 @@ export function RoomTimeline({ ); const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); const [hideMemberInReadOnly] = useSetting(settingsAtom, 'hideMembershipInReadOnly'); + const [messageGroupingThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); const showUrlPreview = room.hasEncryptionStateEvent() ? encUrlPreview : urlPreview; const showClientUrlPreview = room.hasEncryptionStateEvent() @@ -865,6 +866,7 @@ export function RoomTimeline({ hideNickAvatarEvents, isReadOnly, hideMemberInReadOnly, + messageGroupingThreshold, }); processedEventsRef.current = processedEvents; diff --git a/src/app/features/settings/experimental/Experimental.tsx b/src/app/features/settings/experimental/Experimental.tsx index e048ed281..14da03165 100644 --- a/src/app/features/settings/experimental/Experimental.tsx +++ b/src/app/features/settings/experimental/Experimental.tsx @@ -11,6 +11,7 @@ import { SettingsSectionPage } from '../SettingsSectionPage'; import { BandwidthSavingEmojis } from './BandwithSavingEmojis'; import { MSC4268HistoryShare } from './MSC4268HistoryShare'; import { MSC4438MessageBookmarks } from './MSC4438MessageBookmarks'; +import { MessageGrouping } from './MessageGrouping'; function PersonaToggle() { const [showPersonaSetting, setShowPersonaSetting] = useSetting( @@ -61,6 +62,7 @@ export function Experimental({ requestBack, requestClose }: Readonly + diff --git a/src/app/features/settings/experimental/MessageGrouping.tsx b/src/app/features/settings/experimental/MessageGrouping.tsx new file mode 100644 index 000000000..95ce8139f --- /dev/null +++ b/src/app/features/settings/experimental/MessageGrouping.tsx @@ -0,0 +1,53 @@ +import { Box, Text, config } from 'folds'; +import { settingsAtom } from '$state/settings'; +import { useSetting } from '$state/hooks/settings'; +import { SequenceCardStyle } from '$features/common-settings/styles.css'; +import { SettingTile } from '$components/setting-tile'; +import { SequenceCard } from '$components/sequence-card'; + +const THRESHOLD_OPTIONS: { value: number; label: string }[] = [ + { value: 2, label: '2 min (default)' }, + { value: 5, label: '5 min' }, + { value: 15, label: '15 min (Discord-style)' }, + { value: 30, label: '30 min' }, + { value: 60, label: '60 min' }, +]; + +export function MessageGrouping() { + const [threshold, setThreshold] = useSetting(settingsAtom, 'messageGroupingThreshold'); + + return ( + + Message Grouping + + setThreshold(Number(e.target.value))} + style={{ + background: 'var(--bg-surface)', + color: 'var(--tc-surface-high)', + border: '1px solid var(--bg-surface-border)', + borderRadius: config.radii.R300, + padding: `${config.space.S100} ${config.space.S200}`, + fontSize: config.fontSize.T300, + cursor: 'pointer', + }} + > + {THRESHOLD_OPTIONS.map(({ value, label }) => ( + + ))} + + } + /> + + + ); +} diff --git a/src/app/hooks/timeline/useProcessedTimeline.ts b/src/app/hooks/timeline/useProcessedTimeline.ts index 835bc7399..216d25b8b 100644 --- a/src/app/hooks/timeline/useProcessedTimeline.ts +++ b/src/app/hooks/timeline/useProcessedTimeline.ts @@ -26,6 +26,12 @@ export interface UseProcessedTimelineOptions { * where every reply legitimately has `threadRootId` set to the root. */ skipThreadFilter?: boolean; + /** + * Minutes of inactivity before a new message from the same sender gets a + * full user header. Defaults to 2 (the original behaviour). Set higher + * (e.g. 15) for Discord-style compact grouping. + */ + messageGroupingThreshold?: number; } export interface ProcessedEvent { @@ -78,6 +84,7 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + messageGroupingThreshold = 2, }: UseProcessedTimelineOptions): ProcessedEvent[] { return useMemo(() => { let prevEvent: MatrixEvent | undefined; @@ -172,7 +179,8 @@ export function useProcessedTimeline({ let collapsed = false; if (isPrevRendered && !dayDivider && prevEvent !== undefined) { if (isMessageEvent) { - const withinTimeThreshold = minuteDifference(prevEvent.getTs(), mEvent.getTs()) < 2; + const withinTimeThreshold = + minuteDifference(prevEvent.getTs(), mEvent.getTs()) < messageGroupingThreshold; const senderMatch = prevEvent.getSender() === eventSender; const typeMatch = normalizeMessageType(prevEvent.getType()) === normalizeMessageType(type); @@ -226,5 +234,6 @@ export function useProcessedTimeline({ isReadOnly, hideMemberInReadOnly, skipThreadFilter, + messageGroupingThreshold, ]); } diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 7f528fd3c..466dd84bf 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -163,6 +163,9 @@ export interface Settings { roomMessagePreview: boolean; dmMessagePreview: boolean; + // experimental + messageGroupingThreshold: number; + // furry stuff renderAnimals: boolean; @@ -289,6 +292,9 @@ export const defaultSettings: Settings = { roomMessagePreview: false, dmMessagePreview: true, + // experimental + messageGroupingThreshold: 2, + // furry stuff renderAnimals: true, From b77489494ab0ada7155d1145f3b1610ecc6b015b Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 08:16:08 -0400 Subject: [PATCH 173/185] fix(sw): defer skipWaiting to user-confirmed update prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously skipWaiting() was called automatically in the install event, causing every new deployment to immediately activate the new SW mid-session. When users then navigated to a lazy-loaded route, the old chunk hash no longer existed on the server → 404 → chunk-error handler → silent reload. This presented as the app 'randomly restarting' after deployments. Changes: - Remove auto-skipWaiting() from SW install handler - Add SKIP_WAITING_AND_CLAIM message handler — the update prompt already sends this message; now the SW acts on it with self.skipWaiting() - Deduplicate the double navigator.serviceWorker.register() call in index.tsx (was registering twice with different options in dev mode) - Move sendSessionToSW into the single registration .then() handler so the session is sent as soon as the registration resolves, and keep the .ready fallback for the case where registration already settled The new SW will now sit in 'waiting' state until the user confirms the update prompt ('A new version of the app is available. Refresh?'). Existing sessions are uninterrupted until then. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/index.tsx | 28 +++++++++++++--------------- src/sw.ts | 13 +++++++++++-- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/index.tsx b/src/index.tsx index 1721755d5..e74fc716c 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -58,7 +58,20 @@ if ('serviceWorker' in navigator) { swRegisterOptions.type = 'module'; } + const sendSessionToSW = () => { + // Use the active session from the new multi-session store, fall back to legacy + const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); + const activeId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); + const active = + sessions.find((s) => s.userId === activeId) ?? sessions[0] ?? getFallbackSession(); + pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); + }; + navigator.serviceWorker.register(swUrl, swRegisterOptions).then((registration) => { + // Send session immediately so the SW can serve authenticated media fetches + // as soon as it is active, without waiting for the page to mount. + sendSessionToSW(); + registration.addEventListener('updatefound', () => { const installingWorker = registration.installing; if (installingWorker) { @@ -73,21 +86,6 @@ if ('serviceWorker' in navigator) { }); }); - const sendSessionToSW = () => { - // Use the active session from the new multi-session store, fall back to legacy - const sessions = getLocalStorageItem(MATRIX_SESSIONS_KEY, []); - const activeId = getLocalStorageItem(ACTIVE_SESSION_KEY, undefined); - const active = - sessions.find((s) => s.userId === activeId) ?? sessions[0] ?? getFallbackSession(); - pushSessionToSW(active?.baseUrl, active?.accessToken, active?.userId); - }; - - navigator.serviceWorker - .register(swUrl) - .then(sendSessionToSW) - .catch((err) => { - log.warn('SW registration failed:', err); - }); navigator.serviceWorker.ready.then(sendSessionToSW).catch((err) => { log.warn('SW ready failed:', err); }); diff --git a/src/sw.ts b/src/sw.ts index 2f4c3ace2..55e0c58f3 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -567,8 +567,12 @@ async function handleMinimalPushPayload( } } -self.addEventListener('install', (event: ExtendableEvent) => { - event.waitUntil(self.skipWaiting()); +self.addEventListener('install', () => { + // Do NOT call skipWaiting() here. Activating immediately would discard the + // old SW mid-session; any lazy-loaded JS chunk that only existed in the old + // precache becomes a 404, causing the chunk-error handler to silently reload + // the page. Instead, the new SW waits until the update prompt is confirmed + // (SKIP_WAITING_AND_CLAIM message) or the user manually refreshes. }); self.addEventListener('activate', (event: ExtendableEvent) => { @@ -600,6 +604,11 @@ self.addEventListener('message', (event: ExtendableMessageEvent) => { if (!data || typeof data !== 'object') return; const { type, accessToken, baseUrl, userId } = data as Record; + if (type === 'SKIP_WAITING_AND_CLAIM') { + // Sent by the update prompt when the user confirms they want to update. + // skipWaiting() activates this SW immediately; the page then reloads. + event.waitUntil(self.skipWaiting()); + } if (type === 'setSession') { setSession(client.id, accessToken, baseUrl, userId); // Keep the SW alive until the cache write completes. persistSession is From 8f6e44a07475b3cf757443a7622480f034679b22 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 08:16:25 -0400 Subject: [PATCH 174/185] Merge fix/sw-update-skipwaiting into integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 8475092c5f7079092cf3406c1c92fac204628e31 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 09:02:04 -0400 Subject: [PATCH 175/185] fix(room-nav): use stable room ID keys in virtualized room lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously all three room list virtualizers used `key={vItem.index}` (position-based). When a room moves position (e.g. a DM jumps to the top after a new message), React reuses the component at that index rather than remounting it. This caused `useRoomHasUnread`'s lazy `useState` initializer to retain stale state from the previous room at that index — resulting in the unread dot appearing on the wrong room until the next Timeline/Receipt event fired. Fix: add `getItemKey` to each `useVirtualizer` config so TanStack Virtual assigns stable room-ID-based keys, then use `vItem.key` on ``. React now correctly unmounts/remounts when a different room occupies a position, so all per-room state is fresh. - Direct.tsx: getItemKey = sortedDirects[index] - Home.tsx: getItemKey = sortedRooms[index] - Space.tsx: getItemKey = hierarchy[index]?.roomId ?? index Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/direct/Direct.tsx | 3 ++- src/app/pages/client/home/Home.tsx | 3 ++- src/app/pages/client/space/Space.tsx | 11 ++++------- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 179d32219..6961550d8 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -235,6 +235,7 @@ export function Direct() { getScrollElement: () => scrollRef.current, estimateSize: () => 38, overscan: 10, + getItemKey: (index) => sortedDirects[index], }); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => @@ -293,7 +294,7 @@ export function Direct() { return ( scrollRef.current, estimateSize: () => 38, overscan: 10, + getItemKey: (index) => sortedRooms[index], }); const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => @@ -349,7 +350,7 @@ export function Home() { return ( scrollRef.current, estimateSize: () => 32, overscan: 10, + getItemKey: (index) => hierarchy[index]?.roomId ?? index, }); const virtualizedItems = virtualizer.getVirtualItems(); @@ -777,7 +778,7 @@ export function Space() { return (
@@ -824,11 +825,7 @@ export function Space() { } return ( - +
Date: Wed, 22 Apr 2026 09:59:51 -0400 Subject: [PATCH 176/185] fix(presence): add heartbeat to win back online state on multi-device MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matrix presence is per-user on the server, not per-device. When an idle device sends `setPresence({ presence: 'unavailable' })`, the shared server state changes for all clients. The active device's PresenceFeature only re-sends its state when `autoIdled`, `presenceMode`, or `sendPresence` changes — none of which fire on the active device, so the idle device permanently 'wins' until the user switches tabs or interacts. Fix: add a 2-minute heartbeat that re-asserts `{ presence: 'online' }` Within one heartbeat cycle the active device wins back the server state. The heartbeat is idle-free (stops when autoIdled or mode changes), so it doesn't fight against intentional DND/offline/idle changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 24 ++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 00c4b3af0..b503998bd 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -860,6 +860,12 @@ function HandleDecryptPushEvent() { return null; } +// How often an active device re-asserts its online state to the server. +// Matrix presence is per-user (not per-device): if another device sets you to +// idle/unavailable, this heartbeat wins the server state back within one interval. +// Must be shorter than the shortest expected idle timeout (default 5 min). +const PRESENCE_HEARTBEAT_INTERVAL_MS = 2 * 60_000; // 2 minutes + function PresenceFeature() { const mx = useMatrixClient(); const [sendPresence] = useSetting(settingsAtom, 'sendPresence'); @@ -905,6 +911,24 @@ function PresenceFeature() { }; }, [autoIdled, mx, presenceMode, sendPresence]); + // Presence heartbeat: periodically re-assert online state while this device + // is active. Fixes a multi-device race where a different idle device sets the + // shared server presence to unavailable while the user is active here. + useEffect(() => { + const isActiveOnline = sendPresence && !autoIdled && presenceMode === 'online'; + if (!isActiveOnline) return undefined; + + const heartbeatId = window.setInterval(() => { + mx.setPresence({ presence: 'online' }).catch(() => { + // Silently ignore — the main effect will retry on next state change. + }); + }, PRESENCE_HEARTBEAT_INTERVAL_MS); + + return () => { + window.clearInterval(heartbeatId); + }; + }, [autoIdled, mx, presenceMode, sendPresence]); + return null; } From 4c7b344b6a2bed4dfb762100f844e46e284f8c24 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 16:07:40 -0400 Subject: [PATCH 177/185] fix(notifications): re-register pusher on mount and catch togglePusher errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, the pusher was only re-registered with Sygnal on visibility changes (backgrounding/foregrounding). If the browser rotated the push subscription endpoint while the app was closed, there was a window from app open until the first background event where pushes would silently fail (Sygnal 410s on the stale endpoint). Also, the togglePusher Promise was fire-and-forget — unhandled rejections from enablePushNotifications (e.g. pushManager.subscribe() failure due to transient network error) were silently swallowed, leaving the pusher in an unknown state with no log trail. Fixes: - Call togglePusher once immediately on mount with the current visibility state so the endpoint is always current on app startup - Wrap all togglePusher calls with .catch(debugLog.warn) so failures are surfaced in the debug log and Sentry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/hooks/useAppVisibility.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/app/hooks/useAppVisibility.ts b/src/app/hooks/useAppVisibility.ts index e6773290e..ba47af279 100644 --- a/src/app/hooks/useAppVisibility.ts +++ b/src/app/hooks/useAppVisibility.ts @@ -192,11 +192,24 @@ export function useAppVisibility(mx: MatrixClient | undefined, activeSession?: S useEffect(() => { if (!mx) return; - const handleVisibilityForNotifications = (isVisible: boolean) => { - togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile); + const runTogglePusher = (isVisible: boolean) => { + togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile).catch( + (err) => + debugLog.warn( + 'notification', + 'togglePusher failed', + err instanceof Error ? err : new Error(String(err)) + ) + ); }; - appEvents.onVisibilityChange = handleVisibilityForNotifications; + // Re-register the pusher on mount so the endpoint is always current after + // an app restart, SW update, or browser push-subscription rotation. + // togglePusher/enablePushNotifications is idempotent — it reuses the existing + // subscription when the endpoint hasn't changed, so this is cheap. + runTogglePusher(document.visibilityState === 'visible'); + + appEvents.onVisibilityChange = runTogglePusher; // eslint-disable-next-line consistent-return return () => { appEvents.onVisibilityChange = null; From 55bbb7eb509cb1e1889f9947243c9eec8a2a73b8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 16:24:48 -0400 Subject: [PATCH 178/185] fix(notifications): clear badge on foreground when highlights already zero FaviconUpdater's badge-clearing logic only runs inside the roomToUnread useEffect. If roomToUnread reached highlightTotal=0 while the app was hidden (background sync from another device), document.visibilityState was not 'visible' so clearAppBadge was skipped. When the user then foregrounds the app roomToUnread hasn't changed so the effect doesn't re-run and the stale badge stays set. Fix: add a visibilitychange listener that clears the badge on foreground whenever highlightTotalRef is 0. A ref tracks the latest value so the listener doesn't need to close over the roomToUnread effect. Also suppress the pre-existing no-console lint warning on the push relay error path with an inline disable comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 26 +++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index b503998bd..c59f50bf7 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -110,6 +110,9 @@ function FaviconUpdater() { const [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); const [faviconForMentionsOnly] = useSetting(settingsAtom, 'faviconForMentionsOnly'); const registration = useAtomValue(registrationAtom); + // Track the latest highlight total so the visibilitychange handler can check + // it without needing to be inside the roomToUnread effect. + const highlightTotalRef = useRef(0); useEffect(() => { let notification = false; @@ -129,6 +132,8 @@ function FaviconUpdater() { } }); + highlightTotalRef.current = highlightTotal; + if (highlight) { setFavicon(LogoHighlightSVG); } else if (!faviconForMentionsOnly && notification) { @@ -169,6 +174,25 @@ function FaviconUpdater() { } }, [roomToUnread, usePushNotifications, registration, faviconForMentionsOnly]); + // Clear the badge whenever the app comes to the foreground with no active + // highlights. The main effect above only runs when roomToUnread changes, so + // if highlights reached 0 while the app was backgrounded (e.g. read on + // another device during a background sync), the badge would stay set until + // the next roomToUnread change. This listener closes that gap. + useEffect(() => { + const handleVisibilityChange = () => { + if (document.visibilityState !== 'visible') return; + if (highlightTotalRef.current > 0) return; + try { + navigator.clearAppBadge(); + } catch { + // Badging API not supported — ignore + } + }; + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => document.removeEventListener('visibilitychange', handleVisibilityChange); + }, []); + return null; } @@ -836,7 +860,7 @@ function HandleDecryptPushEvent() { // — the second message is a no-op once the entry is already resolved. navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(successReply)); } catch (err) { - console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err); + console.warn('[app] HandleDecryptPushEvent: failed to decrypt push event', err); // eslint-disable-line no-console pushRelayLog.error( 'notification', 'Push relay decryption failed', From 921c81658b731472e06ac15c0baee1ff9e65e77d Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Wed, 22 Apr 2026 16:29:05 -0400 Subject: [PATCH 179/185] fix(notifications): restore unread fallback paths for non-DM rooms Two fallback paths in getUnreadInfo were accidentally dropped during a checkpoint commit (2eaefb19d): 1. PushProcessor fallback: when SDK total/highlight are both 0 but roomHaveUnread() confirms there ARE unread events (stale SDK counters), walk the live timeline with PushProcessor to compute real counts. 2. No-receipt sliding sync fallback: rooms that have never been visited in sliding sync have no read receipt, making SDK counts unreliable. If the timeline has activity from other users, return total=1 (or the existing SDK counts when present) so the dot badge appears. These fallbacks are essential for non-DM rooms: DMs benefit from shouldForceDMHighlight which forces highlight=total, but regular rooms rely entirely on these paths when SDK counts are zero or stale. Restores parity with the dev branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/utils/room.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index a2ce122e0..e752d7224 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -403,6 +403,7 @@ export const getUnreadInfo = (room: Room, options?: UnreadInfoOptions): UnreadIn } + // For DMs with Default or AllMessages notification type: if there are unread messages, // ensure we show a notification badge (treat as highlight for badge color purposes). // This handles cases where push rules don't properly match (e.g., classic sync with From ec5cf6e76c4f6b043a9002c9610f1d8a874703e7 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 24 Apr 2026 10:15:50 -0400 Subject: [PATCH 180/185] fix(sw): remove 24h session TTL and guard event.data.json() - Remove the 24h TTL check from loadPersistedSession(). Sessions cached before the persistedAt field was added (age=Infinity) were being incorrectly rejected, causing every push notification to fall back to the generic 'New Message' text until the user opened the app again. Matrix access tokens are long-lived; if a token is truly revoked the downstream 401 handling in handleMinimalPushPayload provides the graceful fallback already. - Wrap event.data.json() in a try/catch with a 'New Message' fallback notification. A malformed push payload previously caused an unhandled rejection in onPushNotification, which could silently drop the push event on iOS. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/sw.ts | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 55e0c58f3..62f58def8 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -97,24 +97,14 @@ async function loadPersistedSession(): Promise { if (response) { const s = await response.json(); - // Reject persisted sessions older than 24 hours. Matrix access tokens are - // long-lived and are only invalidated on explicit logout or device revocation — - // not by the passage of time. A short TTL (e.g. 60 s) was too aggressive: it - // caused the SW to show generic "New Message" notifications whenever the app - // was backgrounded for more than a minute, because the cached session was - // rejected and requestSession had no live window client to reach. - // If the token truly is revoked the fetches in handleMinimalPushPayload will - // receive a 401 and gracefully fall back to a generic notification anyway. - const age = typeof s.persistedAt === 'number' ? Date.now() - s.persistedAt : Infinity; - const MAX_SESSION_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours - if (age > MAX_SESSION_AGE_MS) { - console.debug('[SW] loadPersistedSession: session expired', { - age, - accessToken: s.accessToken.slice(0, 8), - }); - return undefined; - } - + // Matrix access tokens are long-lived and are only invalidated on explicit + // logout or device revocation — not by the passage of time. If the token truly + // is revoked the fetches in handleMinimalPushPayload will receive a 401 and + // gracefully fall back to a generic notification anyway. We intentionally do + // NOT apply a TTL here: sessions without a persistedAt field (stored before + // that field was added) would get age=Infinity and be incorrectly rejected, + // showing "New Message" for users who hadn't opened the app since the SW + // was last updated. return { accessToken: s.accessToken, baseUrl: s.baseUrl, @@ -983,7 +973,17 @@ const onPushNotification = async (event: PushEvent) => { return; } - const pushData = event.data.json(); + let pushData: any; + try { + pushData = event.data.json(); + } catch (err) { + console.error('[SW push] failed to parse push payload:', err); + await self.registration.showNotification('New Message', { + icon: '/public/res/logo-maskable/cinny-logo-maskable-180x180.png', + badge: '/public/res/logo-maskable/cinny-logo-maskable-72x72.png', + } as NotificationOptions); + return; + } console.debug('[SW push] raw payload:', JSON.stringify(pushData, null, 2)); try { From e38f99aa2f081ab6421ceefa6aa243713aeb9d28 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 24 Apr 2026 10:53:15 -0400 Subject: [PATCH 181/185] fix(sw): require focused+visible to suppress push on iOS On iOS PWA, visibilityState can get stuck at 'visible' after the user opens the app via a notification tap and then backgrounds it again. This caused all subsequent push notifications to be silently suppressed ("first notification works, second and beyond don't"). The fix: require BOTH visibilityState === 'visible' AND client.focused. The focused property transitions to false immediately when the user presses the home button (before visibilitychange fires), so it is the reliable signal that the user is actually looking at the app. Also log focused state in the existing debug output. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/sw.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/sw.ts b/src/sw.ts index 62f58def8..510017c0a 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -949,23 +949,27 @@ const onPushNotification = async (event: PushEvent) => { // If the app is open and visible, skip the OS push notification — the in-app // pill notification handles the alert instead. // - // When clients.matchAll() returns ≥1 client, trust its visibilityState - // directly. iOS can suspend the JS thread before postMessage({ visible: - // false }) is processed, leaving appIsVisible stuck at true. matchAll() - // still reports the backgrounded client as 'hidden', so it is the - // authoritative and most reliable signal. + // Require BOTH visibilityState === 'visible' AND focused === true. + // On iOS PWA, visibilityState can get stuck at 'visible' after the user opens + // the app via a notification tap and then backgrounds it again — the + // visibilitychange event doesn't always fire reliably on iOS. The focused + // property updates immediately when the user presses the home button (the + // window loses OS focus before visibilitychange fires), so it is the + // authoritative signal for "user is actually looking at the app right now". // - // When matchAll() returns zero clients (a separate iOS Safari PWA quirk), - // visibility is unknowable — do NOT suppress. Better to show a duplicate - // (handled gracefully by the in-app banner) than to silently drop a - // notification while the app is backgrounded. + // When matchAll() returns zero clients (iOS Safari PWA quirk where the app + // is fully suspended), visibility is unknowable — do NOT suppress. Better + // to show a duplicate (handled gracefully by the in-app banner) than to + // silently drop a notification while the app is backgrounded. const hasVisibleClient = - clients.length > 0 ? clients.some((client) => client.visibilityState === 'visible') : false; + clients.length > 0 + ? clients.some((client) => client.visibilityState === 'visible' && client.focused) + : false; console.debug( '[SW push] appIsVisible:', appIsVisible, '| clients:', - clients.map((c) => ({ url: c.url, visibility: c.visibilityState })) + clients.map((c) => ({ url: c.url, visibility: c.visibilityState, focused: c.focused })) ); console.debug('[SW push] hasVisibleClient:', hasVisibleClient); if (hasVisibleClient) { From b796b13d19fde899f1b1fbc76a9791982e51fa3c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 24 Apr 2026 11:38:27 -0400 Subject: [PATCH 182/185] fix(timeline): restore visible timeline when loadEventTimeline fails When a user opens a room via notification tap and loadEventTimeline encounters an error (e.g. network not yet ready after iOS app resume), the onError handler resets to the live timeline but never called setIsReady(true). This left the timeline permanently invisible (opacity: 0), producing a blank white screen. Add onJumpError callback to UseTimelineSyncOptions. RoomTimeline passes handleJumpError which calls setIsReady(true). The error handler in useTimelineSync now calls onJumpError() after restoring the live timeline and scrolling to bottom, so the room becomes visible. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/features/room/RoomTimeline.tsx | 3 +++ src/app/hooks/timeline/useTimelineSync.ts | 6 +++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 95f9d46a5..4c5546c75 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -278,6 +278,8 @@ export function RoomTimeline({ vListRef.current.scrollTo(vListRef.current.scrollSize); }, [setAtBottom]); + const handleJumpError = useCallback(() => setIsReady(true), []); + const timelineSync = useTimelineSync({ room, mx, @@ -285,6 +287,7 @@ export function RoomTimeline({ isAtBottom: atBottomState, isAtBottomRef: atBottomRef, scrollToBottom, + onJumpError: handleJumpError, unreadInfo, setUnreadInfo, hideReadsRef, diff --git a/src/app/hooks/timeline/useTimelineSync.ts b/src/app/hooks/timeline/useTimelineSync.ts index 3442ea367..c53f3d4fe 100644 --- a/src/app/hooks/timeline/useTimelineSync.ts +++ b/src/app/hooks/timeline/useTimelineSync.ts @@ -353,6 +353,8 @@ export interface UseTimelineSyncOptions { isAtBottom: boolean; isAtBottomRef: React.MutableRefObject; scrollToBottom: (behavior?: 'instant' | 'smooth') => void; + /** Called when a loadEventTimeline jump fails so the caller can reveal the live timeline. */ + onJumpError?: () => void; unreadInfo: ReturnType; setUnreadInfo: Dispatch>>; hideReadsRef: React.MutableRefObject; @@ -366,6 +368,7 @@ export function useTimelineSync({ isAtBottom, isAtBottomRef, scrollToBottom, + onJumpError, unreadInfo, setUnreadInfo, hideReadsRef, @@ -462,7 +465,8 @@ export function useTimelineSync({ if (!alive()) return; setTimeline({ linkedTimelines: getInitialTimeline(room).linkedTimelines }); scrollToBottom('instant'); - }, [alive, room, scrollToBottom]) + onJumpError?.(); + }, [alive, room, scrollToBottom, onJumpError]) ); const lastScrolledAtEventsLengthRef = useRef(eventsLength); From bad4c2d491b57ee4428a3ff618b979a7edbecd30 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Fri, 24 Apr 2026 14:04:17 -0400 Subject: [PATCH 183/185] fix(SyncStatus): only show Connecting... on reconnect, not initial load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After iOS kills a backgrounded PWA and the app cold-starts, SyncStatus was showing a Connecting... banner for up to 20 seconds after the loading screen disappeared. This is because the banner fired on any transition where previous !== SyncState.Syncing, including the initial null→PREPARED→SYNCING cold-start sequence. With sliding sync the second long-poll can take up to 20 s (the poll timeout) when there are no new messages, keeping the banner visible the entire time even though the app is fully loaded and usable. Fix: only show Connecting... when recovering from an actual disconnect (previous === Reconnecting or previous === Error). The loading screen already covers the initial connection; the banner is only meaningful when we are re-connecting after a network disruption. State transitions: - null → PREPARED: no banner (loading screen handles it) ✓ - RECONNECTING → PREPARED/SYNCING: shows banner ✓ - ERROR → PREPARED/SYNCING: shows banner ✓ - SYNCING → SYNCING: no banner ✓ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/SyncStatus.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index ae222b4f7..4a08b2571 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -48,7 +48,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { (stateData.current === SyncState.Prepared || stateData.current === SyncState.Syncing || stateData.current === SyncState.Catchup) && - stateData.previous !== SyncState.Syncing + (stateData.previous === SyncState.Reconnecting || stateData.previous === SyncState.Error) ) { return ( From c9aeec9d78f0d9cf54f0ecfc2ab69f9623f3b27c Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 26 Apr 2026 11:02:11 -0400 Subject: [PATCH 184/185] fix(sw): guard decrypt-relay suppression with appFocused check The SW has two paths where it suppresses OS notifications when the app appears visible: 1. hasVisibleClient (already fixed): requires both visibilityState === 'visible' AND client.focused from clients.matchAll(). 2. Decrypt relay result (this commit): after requestDecryptionFromClient(), the code checked only result?.visibilityState === 'visible' to decide whether the in-app UI was already showing the message. On iOS PWA, document.visibilityState can get stuck at 'visible' after the user backgrounds the app because visibilitychange doesn't always fire. document.hasFocus() is updated by the browser process immediately when the OS removes window focus (home button press), so it is the reliable signal. Add appFocused: document.hasFocus() to both pushDecryptResult reply payloads in ClientNonUIFeatures.tsx. In sw.ts, require BOTH visibilityState === 'visible' AND appFocused === true before skipping the OS notification, mirroring the same dual-guard logic in hasVisibleClient. This closes the second suppression path that was silently dropping background push notifications on iOS after the user had previously opened the app from a notification tap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/ClientNonUIFeatures.tsx | 2 ++ src/sw.ts | 18 +++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index c59f50bf7..0382f0e22 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -851,6 +851,7 @@ function HandleDecryptPushEvent() { sender_display_name: senderName, room_name: room?.name ?? '', visibilityState: document.visibilityState, + appFocused: document.hasFocus(), }; navigator.serviceWorker.controller?.postMessage(successReply); // Belt-and-suspenders: also post via registration.active so the reply @@ -871,6 +872,7 @@ function HandleDecryptPushEvent() { eventId, success: false, visibilityState: document.visibilityState, + appFocused: document.hasFocus(), }; navigator.serviceWorker.controller?.postMessage(errorReply); navigator.serviceWorker.ready.then((reg) => reg.active?.postMessage(errorReply)); diff --git a/src/sw.ts b/src/sw.ts index 510017c0a..a3e5efe4c 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -239,6 +239,14 @@ type DecryptionResult = { room_name?: string; /** document.visibilityState reported by the responding app tab. */ visibilityState?: string; + /** + * document.hasFocus() reported by the responding app tab. + * Unlike visibilityState, this is updated by the browser process immediately + * when the OS removes focus (e.g. home button on iOS) and does not get stuck + * at true after backgrounding. Used alongside visibilityState so that both + * must be true before suppressing the OS notification. + */ + appFocused?: boolean; }; /** Pending decryption requests keyed by event_id. */ @@ -515,9 +523,13 @@ async function handleMinimalPushPayload( ? await requestDecryptionFromClient(windowClients, rawEvent) : undefined; - // If the relay responded and the app is currently visible, the in-app UI is already - // displaying the message — skip the OS notification entirely. - if (result?.visibilityState === 'visible') return; + // If the relay responded and the app is currently visible AND focused, the + // in-app UI is already displaying the message — skip the OS notification. + // Require BOTH visibilityState === 'visible' AND appFocused === true here, + // mirroring the hasVisibleClient check above. document.visibilityState can + // get stuck at 'visible' on iOS PWA after backgrounding; document.hasFocus() + // (reported as appFocused) updates reliably when the OS removes focus. + if (result?.visibilityState === 'visible' && result?.appFocused === true) return; if (result?.success) { // App was backgrounded but not frozen — decryption succeeded. From 0625646eee404fac713c00c3ac1e340854f05fe8 Mon Sep 17 00:00:00 2001 From: Evie Gauthier Date: Sun, 26 Apr 2026 11:55:55 -0400 Subject: [PATCH 185/185] revert(SyncStatus): restore upstream connecting banner condition The previous change narrowed the 'Connecting...' banner to only show on reconnect/error. Reverting to upstream's condition: stateData.previous !== SyncState.Syncing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/app/pages/client/SyncStatus.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/pages/client/SyncStatus.tsx b/src/app/pages/client/SyncStatus.tsx index 4a08b2571..ae222b4f7 100644 --- a/src/app/pages/client/SyncStatus.tsx +++ b/src/app/pages/client/SyncStatus.tsx @@ -48,7 +48,7 @@ export function SyncStatus({ mx }: SyncStatusProps) { (stateData.current === SyncState.Prepared || stateData.current === SyncState.Syncing || stateData.current === SyncState.Catchup) && - (stateData.previous === SyncState.Reconnecting || stateData.previous === SyncState.Error) + stateData.previous !== SyncState.Syncing ) { return (