Skip to content

Commit 1d06a65

Browse files
committed
feat(presence): configurable idle timeout setting
1 parent 69b34b0 commit 1d06a65

4 files changed

Lines changed: 90 additions & 102 deletions

File tree

src/app/features/settings/general/General.tsx

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) {
474474
<SettingTile
475475
title="Auto-Idle"
476476
focusId="auto-idle-presence"
477-
description="Automatically appear unavailable after 5 minutes of inactivity or when the app isn't active."
477+
description="Automatically appear unavailable after a period of inactivity or when the app isn't active."
478478
after={
479479
<Switch
480480
variant="Primary"
@@ -485,6 +485,16 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) {
485485
/>
486486
</SequenceCard>
487487
)}
488+
{sendPresence && autoIdlePresence && (
489+
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
490+
<SettingTile
491+
title="Idle Timeout"
492+
focusId="presence-idle-timeout"
493+
description="Minutes of inactivity before appearing unavailable."
494+
after={<PresenceIdleTimeoutInput />}
495+
/>
496+
</SequenceCard>
497+
)}
488498
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
489499
<SettingTile
490500
title="Room Message Preview"
@@ -863,6 +873,52 @@ function EmojiSelectorThresholdInput() {
863873
);
864874
}
865875

876+
function PresenceIdleTimeoutInput() {
877+
const [idleTimeoutMins, setIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins');
878+
const [inputValue, setInputValue] = useState(idleTimeoutMins.toString());
879+
880+
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
881+
const val = evt.target.value;
882+
setInputValue(val);
883+
const parsed = Number.parseInt(val, 10);
884+
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) {
885+
setIdleTimeoutMins(parsed);
886+
}
887+
};
888+
889+
const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
890+
if (isKeyHotkey('escape', evt)) {
891+
evt.stopPropagation();
892+
setInputValue(idleTimeoutMins.toString());
893+
(evt.target as HTMLInputElement).blur();
894+
}
895+
if (isKeyHotkey('enter', evt)) {
896+
(evt.target as HTMLInputElement).blur();
897+
}
898+
};
899+
900+
return (
901+
<Box alignItems="Center" gap="200">
902+
<Input
903+
style={{ width: toRem(80) }}
904+
variant={Number.parseInt(inputValue, 10) === idleTimeoutMins ? 'Secondary' : 'Success'}
905+
size="300"
906+
radii="300"
907+
type="number"
908+
min="1"
909+
max="60"
910+
value={inputValue}
911+
onChange={handleChange}
912+
onKeyDown={handleKeyDown}
913+
outlined
914+
/>
915+
<Text size="T200" priority="300">
916+
min
917+
</Text>
918+
</Box>
919+
);
920+
}
921+
866922
function Calls() {
867923
const [alwaysShowCallButton, setAlwaysShowCallButton] = useSetting(
868924
settingsAtom,

src/app/hooks/useUserPresence.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useEffect, useMemo, useState } from 'react';
2-
import type { User, UserEventHandlerMap } from '$types/matrix-sdk';
3-
import { UserEvent } from '$types/matrix-sdk';
2+
import type { MatrixEvent, User, UserEventHandlerMap } from '$types/matrix-sdk';
3+
import { ClientEvent, UserEvent } from '$types/matrix-sdk';
44
import { useMatrixClient } from './useMatrixClient';
55

66
export enum Presence {
@@ -31,7 +31,21 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
3131
useEffect(() => {
3232
if (!user) {
3333
setPresence(undefined);
34-
return undefined;
34+
35+
// When the user isn't in the SDK store yet (e.g., presence arrived before
36+
// any membership event), listen on the client for incoming events so we
37+
// can re-evaluate once a presence event for this user is stored.
38+
const handleEvent = (event: MatrixEvent) => {
39+
if (event.getType() !== 'm.presence') return;
40+
const sender = event.getSender();
41+
if (sender !== userId) return;
42+
const latestUser = mx.getUser(userId);
43+
if (latestUser) setPresence(getUserPresence(latestUser));
44+
};
45+
mx.on(ClientEvent.Event, handleEvent);
46+
return () => {
47+
mx.removeListener(ClientEvent.Event, handleEvent);
48+
};
3549
}
3650
setPresence(getUserPresence(user));
3751
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (e, u) => {
@@ -48,7 +62,7 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
4862
user.removeListener(UserEvent.CurrentlyActive, updatePresence);
4963
user.removeListener(UserEvent.LastPresenceTs, updatePresence);
5064
};
51-
}, [user]);
65+
}, [mx, user, userId]);
5266

5367
return presence;
5468
};

src/app/pages/client/ClientNonUIFeatures.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -845,6 +845,7 @@ function PresenceFeature() {
845845
const mx = useMatrixClient();
846846
const [sendPresence] = useSetting(settingsAtom, 'sendPresence');
847847
const [autoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence');
848+
const [presenceIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins');
848849

849850
useEffect(() => {
850851
// Classic sync: set_presence query param on every /sync poll.
@@ -859,12 +860,12 @@ function PresenceFeature() {
859860
}
860861
}, [mx, sendPresence]);
861862

862-
// Auto-idle: set presence to unavailable after 5 minutes of inactivity or
863+
// Auto-idle: set presence to unavailable after inactivity or
863864
// when the tab is hidden, and restore online on activity.
864865
useEffect(() => {
865866
if (!sendPresence || !autoIdlePresence) return undefined;
866867

867-
const IDLE_TIMEOUT_MS = 5 * 60 * 1000;
868+
const IDLE_TIMEOUT_MS = Math.max(1, presenceIdleTimeoutMins) * 60 * 1000;
868869
let idleTimer: ReturnType<typeof setTimeout> | undefined;
869870
let isIdle = false;
870871

@@ -915,7 +916,7 @@ function PresenceFeature() {
915916
mx.setPresence({ presence: 'online' }).catch(() => {});
916917
}
917918
};
918-
}, [mx, sendPresence, autoIdlePresence]);
919+
}, [mx, sendPresence, autoIdlePresence, presenceIdleTimeoutMins]);
919920

920921
return null;
921922
}

src/app/state/settings.ts

Lines changed: 11 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { atom, type WritableAtom } from 'jotai';
1+
import { atom } from 'jotai';
22
import { mobileOrTablet } from '$utils/user-agent';
33

44
const STORAGE_KEY = 'settings';
@@ -23,36 +23,6 @@ export enum CaptionPosition {
2323
}
2424
export type JumboEmojiSize = 'none' | 'extraSmall' | 'small' | 'normal' | 'large' | 'extraLarge';
2525

26-
export type ThemeRemoteFavorite = {
27-
fullUrl: string;
28-
displayName: string;
29-
basename: string;
30-
kind: 'light' | 'dark';
31-
pinned?: boolean;
32-
importedLocal?: boolean;
33-
};
34-
35-
export type ThemeRemoteTweakFavorite = {
36-
fullUrl: string;
37-
displayName: string;
38-
basename: string;
39-
pinned?: boolean;
40-
importedLocal?: boolean;
41-
};
42-
43-
/** Custom profile card hero colors: which brightness schemes to honor. */
44-
export type RenderUserCardsMode = 'both' | 'light' | 'dark' | 'none';
45-
46-
export function shouldApplyUserHeroCards(
47-
mode: RenderUserCardsMode,
48-
brightness: string | undefined
49-
): boolean {
50-
if (mode === 'none') return false;
51-
if (mode === 'both') return true;
52-
if (brightness !== 'light' && brightness !== 'dark') return false;
53-
return brightness === mode;
54-
}
55-
5626
export interface Settings {
5727
themeId?: string;
5828
useSystemTheme: boolean;
@@ -64,6 +34,8 @@ export interface Settings {
6434
arboriumDarkTheme?: string;
6535
saturationLevel?: number;
6636
uniformIcons: boolean;
37+
isMarkdown: boolean;
38+
editorToolbar: boolean;
6739
twitterEmoji: boolean;
6840
pageZoom: number;
6941
hideActivity: boolean;
@@ -113,7 +85,6 @@ export interface Settings {
11385
showPronouns: boolean;
11486
parsePronouns: boolean;
11587
renderGlobalNameColors: boolean;
116-
renderUserCards: RenderUserCardsMode;
11788
filterPronounsBasedOnLanguage?: boolean;
11889
filterPronounsLanguages?: string[];
11990
renderRoomColors: boolean;
@@ -124,6 +95,7 @@ export interface Settings {
12495
// Sable features!
12596
sendPresence: boolean;
12697
autoIdlePresence: boolean;
98+
presenceIdleTimeoutMins: number;
12799
showRoomMessagePreview: boolean;
128100
mobileGestures: boolean;
129101
rightSwipeAction: RightSwipeAction;
@@ -153,26 +125,9 @@ export interface Settings {
153125

154126
// furry stuff
155127
renderAnimals: boolean;
156-
157-
// theme catalog
158-
themeCatalogOnboardingDone: boolean;
159-
themeRemoteFavorites: ThemeRemoteFavorite[];
160-
themeRemoteCatalogEnabled: boolean;
161-
themeChatSableWidgetsEnabled: boolean;
162-
themeChatAutoPreviewApprovedUrls: boolean;
163-
themeChatAutoPreviewAnyUrl: boolean;
164-
themeRemoteManualFullUrl?: string;
165-
themeRemoteLightFullUrl?: string;
166-
themeRemoteDarkFullUrl?: string;
167-
themeRemoteManualKind?: 'light' | 'dark';
168-
themeRemoteLightKind?: 'light' | 'dark';
169-
themeRemoteDarkKind?: 'light' | 'dark';
170-
themeMigrationDismissed: boolean;
171-
themeRemoteTweakFavorites: ThemeRemoteTweakFavorite[];
172-
themeRemoteEnabledTweakFullUrls: string[];
173128
}
174129

175-
export const defaultSettings: Settings = {
130+
const defaultSettings: Settings = {
176131
themeId: undefined,
177132
useSystemTheme: true,
178133
lightThemeId: undefined,
@@ -183,6 +138,8 @@ export const defaultSettings: Settings = {
183138
arboriumDarkTheme: 'dracula',
184139
saturationLevel: 100,
185140
uniformIcons: false,
141+
isMarkdown: true,
142+
editorToolbar: false,
186143
twitterEmoji: true,
187144
pageZoom: 100,
188145
hideActivity: false,
@@ -235,7 +192,6 @@ export const defaultSettings: Settings = {
235192
showPronouns: true,
236193
parsePronouns: true,
237194
renderGlobalNameColors: true,
238-
renderUserCards: 'both',
239195
renderRoomColors: true,
240196
renderRoomFonts: true,
241197
captionPosition: CaptionPosition.Below,
@@ -244,6 +200,7 @@ export const defaultSettings: Settings = {
244200
// Sable features!
245201
sendPresence: true,
246202
autoIdlePresence: true,
203+
presenceIdleTimeoutMins: 5,
247204
showRoomMessagePreview: true,
248205
mobileGestures: true,
249206
rightSwipeAction: RightSwipeAction.Reply,
@@ -273,23 +230,6 @@ export const defaultSettings: Settings = {
273230

274231
// furry stuff
275232
renderAnimals: true,
276-
277-
// theme catalog
278-
themeCatalogOnboardingDone: false,
279-
themeRemoteFavorites: [],
280-
themeRemoteCatalogEnabled: false,
281-
themeChatSableWidgetsEnabled: true,
282-
themeChatAutoPreviewApprovedUrls: true,
283-
themeChatAutoPreviewAnyUrl: false,
284-
themeRemoteManualFullUrl: undefined,
285-
themeRemoteLightFullUrl: undefined,
286-
themeRemoteDarkFullUrl: undefined,
287-
themeRemoteManualKind: undefined,
288-
themeRemoteLightKind: undefined,
289-
themeRemoteDarkKind: undefined,
290-
themeMigrationDismissed: false,
291-
themeRemoteTweakFavorites: [],
292-
themeRemoteEnabledTweakFullUrls: [],
293233
};
294234

295235
export const getSettings = () => {
@@ -307,34 +247,14 @@ export const getSettings = () => {
307247
}
308248
delete parsed.monochromeMode;
309249

310-
if (typeof parsed.renderUserCards === 'boolean') {
311-
parsed.renderUserCards = parsed.renderUserCards ? 'both' : 'none';
312-
} else if (
313-
parsed.renderUserCards !== 'both' &&
314-
parsed.renderUserCards !== 'light' &&
315-
parsed.renderUserCards !== 'dark' &&
316-
parsed.renderUserCards !== 'none'
317-
) {
318-
parsed.renderUserCards = 'both';
319-
}
320-
321-
const parsedRecord = parsed as Record<string, unknown>;
322-
if (
323-
typeof parsedRecord.themeChatAutoPreviewAnyUrl !== 'boolean' &&
324-
typeof parsedRecord.themeChatPreviewAnyUrl === 'boolean'
325-
) {
326-
parsedRecord.themeChatAutoPreviewAnyUrl = parsedRecord.themeChatPreviewAnyUrl;
327-
}
328-
delete parsedRecord.themeChatPreviewAnyUrl;
329-
delete parsedRecord.themeChatPreviewApprovedCatalogOnly;
330-
331250
return {
332251
...defaultSettings,
333252
...(parsed as Settings),
334253
};
335254
} catch {
336255
return defaultSettings;
337256
}
257+
};
338258

339259
export const setSettings = (settings: Settings) => {
340260
try {
@@ -347,11 +267,8 @@ export const setSettings = (settings: Settings) => {
347267
const baseSettings = atom(getSettings());
348268
export const settingsAtom = atom<Settings, [Settings], undefined>(
349269
(get) => get(baseSettings),
350-
(_get, set, update) => {
351-
(set as (atom: WritableAtom<Settings, [Settings], void>, val: Settings) => void)(
352-
baseSettings as WritableAtom<Settings, [Settings], void>,
353-
update
354-
);
270+
(get, set, update) => {
271+
set(baseSettings, update);
355272
setSettings(update);
356273
}
357274
);

0 commit comments

Comments
 (0)