-
Notifications
You must be signed in to change notification settings - Fork 46
feat(presence): presence badges, auto-idle, and Discord-style status picker #689
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from 23 commits
7c28e2b
e977b36
792fd83
6ab811d
a552488
58ae0eb
ff747a3
024401b
c40d5c8
a1aff9e
53a3aff
8366173
02ced60
5090322
bbb31fc
34b1b4d
3edf610
37b6af1
2ac47de
68fdb85
6aa40cc
698bda0
02e9d7a
2ab3bcd
2cf9895
bc6b57d
94fae05
f68ee8e
5c4fe1c
25258f1
bfee547
6df45a4
a5f35e9
201e230
04d59f5
73beb8c
4a6289e
85a7f6d
918f2d7
bc58d6e
add3987
718210f
dfebd09
a53112f
d9a6656
117949f
f726833
2a9fa52
68d35f9
d327e19
426ef74
53930d4
d9a10cb
6361351
3fcd6c1
76e0c49
ff1b207
bccc48f
2964e89
b3304bb
06c9810
ac899aa
dcb56f6
e848f3a
a294d16
2eeaa43
a4751d4
1ad6b03
12888d9
ac4e5b4
755588b
49a98b3
6a27271
35acb82
4404e84
8f69b61
1ba25f8
c178b77
ac75284
c7d44d8
ce458fb
f7c7fee
b86b5de
a71bdab
ca97c9b
594fda0
31441ad
d8da869
4a15b74
b0a8091
264e4ab
878f2fc
e4d24b2
05fa657
a2d5683
4eeaa38
260a4e8
9036ec9
db9c1a4
216aa6a
b848ac2
ec10020
20376bf
581f2ce
86701ca
4b29d29
2202bec
cd5192b
8cdf36a
e5bdd7c
3f03876
4cb00a5
0231581
1f9dae9
9d9dce7
29e4076
12b379e
2b39316
69179c1
9ebd8d3
da4a07b
d7fd640
82bde31
e0eb8ab
7680897
850e025
7bb22d2
42c59ad
cb24302
3149a37
56de896
ca65cef
cbd653e
8fee117
809a9bb
dc21873
8c3c0e7
71138ec
ad50ff1
83031b2
bd31c97
17ccebd
1392b77
399418b
700ea1e
272eff3
7579368
f8986c1
5f41827
111d57e
b60c2dc
77f806a
68d653d
5cc0da5
e1adb09
3bea590
fd9e007
ef01765
1e15b4b
164a6b6
143e012
910d13a
67b1a11
a7f8c1a
ce68c59
84fadae
c4ca608
157df81
be5bcd2
1fc16ae
0491959
f6a7cd7
2ef5386
afc2b49
6b89dc6
d863695
52bbbc6
ff50906
f95c396
39e25e3
e12a4b0
ead2bb1
e6e6048
449de0a
b851f43
a374275
21f0f39
1e483cc
344536c
0b2b720
2144458
90aee8c
88840d7
93ebd90
4896b70
aeeb6ec
0338a34
27ac950
6da27d6
88b7f05
15911d4
8054b69
ffd63b5
3484ec5
953c716
0b9b0fd
95c9993
f99f9ba
1eb3847
5ba7bd7
022649c
dbef5d9
a8b17b8
807d00f
13d1baf
1c50d87
708a76a
3e373ce
d570e4b
b2b043c
65b45ba
9199a87
d82f8e4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| default: minor | ||
| --- | ||
|
|
||
| feat(presence): add auto-idle presence after configurable inactivity timeout with Discord-style status picker |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| default: patch | ||
| --- | ||
|
|
||
| Add presence status badges to sidebar DM list and account switcher |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 '$types/matrix-sdk'; | ||
| import { PageContent } from '$components/page'; | ||
| import { SequenceCard } from '$components/sequence-card'; | ||
| import { SettingTile } from '$components/setting-tile'; | ||
|
|
@@ -9,6 +10,7 @@ import { useMatrixClient } from '$hooks/useMatrixClient'; | |
| import { AccountDataEditor, AccountDataSubmitCallback } from '$components/AccountDataEditor'; | ||
| import { copyToClipboard } from '$utils/dom'; | ||
| import { SequenceCardStyle } from '$features/settings/styles.css'; | ||
| import { AsyncStatus, useAsyncCallback } from '$hooks/useAsyncCallback'; | ||
| import { SettingsSectionPage } from '../SettingsSectionPage'; | ||
| import { AccountData } from './AccountData'; | ||
| import { SyncDiagnostics } from './SyncDiagnostics'; | ||
|
|
@@ -25,6 +27,33 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp | |
| const [expand, setExpend] = useState(false); | ||
| const [accountDataType, setAccountDataType] = useState<string | null>(); | ||
|
|
||
| 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]) | ||
|
Comment on lines
+31
to
+71
|
||
| ); | ||
|
|
||
| const submitAccountData: AccountDataSubmitCallback = useCallback( | ||
| async (type, content) => { | ||
| // TODO: remove cast once account data typing is unified. | ||
|
|
@@ -109,6 +138,57 @@ export function DeveloperTools({ requestBack, requestClose }: DeveloperToolsProp | |
| )} | ||
| </Box> | ||
| {developerTools && <SyncDiagnostics />} | ||
| {developerTools && ( | ||
| <Box direction="Column" gap="100"> | ||
| <Text size="L400">Encryption</Text> | ||
| <SequenceCard | ||
| className={SequenceCardStyle} | ||
| variant="SurfaceVariant" | ||
| direction="Column" | ||
| gap="400" | ||
| > | ||
| <SettingTile | ||
| title="Rotate Encryption Sessions" | ||
| focusId="rotate-encryption-sessions" | ||
| 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={ | ||
| <Button | ||
| onClick={rotateAllSessions} | ||
| variant="Secondary" | ||
| fill="Soft" | ||
| size="300" | ||
| radii="300" | ||
| outlined | ||
| disabled={rotateState.status === AsyncStatus.Loading} | ||
| before={ | ||
| rotateState.status === AsyncStatus.Loading && ( | ||
| <Spinner size="100" variant="Secondary" /> | ||
| ) | ||
| } | ||
| > | ||
| <Text size="B300"> | ||
| {rotateState.status === AsyncStatus.Loading ? 'Rotating…' : 'Rotate'} | ||
| </Text> | ||
| </Button> | ||
| } | ||
| > | ||
| {rotateState.status === AsyncStatus.Success && ( | ||
| <Text size="T200" style={{ color: color.Success.Main }}> | ||
| 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. | ||
| </Text> | ||
| )} | ||
| {rotateState.status === AsyncStatus.Error && ( | ||
| <Text size="T200" style={{ color: color.Critical.Main }}> | ||
| {rotateState.error.message} | ||
| </Text> | ||
| )} | ||
| </SettingTile> | ||
| </SequenceCard> | ||
| </Box> | ||
| )} | ||
| {developerTools && ( | ||
| <AccountData | ||
| expand={expand} | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -1,22 +1,96 @@ | ||||||
| import { useEffect } from 'react'; | ||||||
| import { useCallback, useEffect, useRef } 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 } 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 { useClientConfig, useExperimentVariant } from './useClientConfig'; | ||||||
| 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 [usePushNotifications] = useSetting(settingsAtom, 'usePushNotifications'); | ||||||
| const pushSubAtom = useAtom(pushSubscriptionAtom); | ||||||
| const isMobile = mobileOrTablet(); | ||||||
|
|
||||||
| 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] | ||||||
| ); | ||||||
|
|
||||||
| useEffect(() => { | ||||||
| const handleVisibilityChange = () => { | ||||||
|
|
@@ -26,30 +100,133 @@ export function useAppVisibility(mx: MatrixClient | undefined) { | |||||
| `App visibility changed: ${isVisible ? 'visible (foreground)' : 'hidden (background)'}`, | ||||||
| { visibilityState: document.visibilityState } | ||||||
| ); | ||||||
| appEvents.onVisibilityChange?.(isVisible); | ||||||
| appEvents.emitVisibilityChange(isVisible); | ||||||
| if (!isVisible) { | ||||||
| appEvents.onVisibilityHidden?.(); | ||||||
| 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; | ||||||
| } | ||||||
| } | ||||||
| }; | ||||||
|
|
||||||
| 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; | ||||||
| if (!phase2VisibleHeartbeat) return undefined; | ||||||
|
||||||
| if (!phase2VisibleHeartbeat) return undefined; | |
| if (!phase2VisibleHeartbeat || !mx) return undefined; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
crypto.prepareToEncrypt(room)is invoked fire-and-forget for each room. If it returns a Promise (or can throw), this can lead to unhandled rejections and makes failures invisible. Consider explicitlyvoiding and attaching a.catch(...)per call, or awaiting with controlled concurrency if you need to ensure key sharing is actually queued successfully.