Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
445d973
feat: presence auto-idle — set unavailable after 5 min inactivity or …
Just-Insane May 2, 2026
3f9700c
fix(presence): show presence dot in account switcher, DM sidebar, and…
Just-Insane May 2, 2026
bf787e7
fix(presence): publish online on enable, update auto-idle description
Just-Insane May 3, 2026
ab8e794
feat(presence): configurable idle timeout setting
Just-Insane May 5, 2026
0cac3c1
feat(presence): restore Discord-style presence picker and presenceMod…
Just-Insane May 13, 2026
6904a9c
fix(presence): show own presence/status with sliding sync
Just-Insane May 14, 2026
f01049e
fix(presence): show red DND dot in member list for all Sable clients
Just-Insane May 14, 2026
eec0421
fix(presence): fix status save and DND sentinel leaking as status text
Just-Insane May 14, 2026
780645a
fix(presence): update own presence badge optimistically, not in .then()
Just-Insane May 15, 2026
e4cd454
fix(presence): update own presence after network call succeeds
Just-Insane May 18, 2026
a5866c4
fix(presence): ignore mousemove without OS focus so idle timer runs o…
Just-Insane May 15, 2026
792214c
fix(presence): wire presence badges in member list and media caching
Just-Insane May 19, 2026
7dd384e
fix(presence): fall back to REST API when MSC4186 has no presence ext…
Just-Insane May 19, 2026
af70caf
chore: add changeset
Just-Insane May 19, 2026
7226bf0
fix(presence): remove stray bookmarks imports; fix mousemove focus gu…
Just-Insane May 19, 2026
173cfca
fix(presence): platform-aware SW media cache entry cap (200 mobile / …
Just-Insane May 20, 2026
a2b9c14
Revert "fix(presence): platform-aware SW media cache entry cap (200 m…
Just-Insane May 20, 2026
9b5b6f1
refactor(presence): move SW media cache to feat/media-cache branch
Just-Insane May 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/presence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: patch
---

Fix presence: optimistic badge updates, own-status sync, idle timer on desktop, and REST API fallback.
39 changes: 22 additions & 17 deletions src/app/components/member-tile/MemberTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import { useSableCosmetics } from '$hooks/useSableCosmetics';
import { useAtomValue } from 'jotai';
import { nicknamesAtom } from '$state/nicknames';
import { UserAvatar } from '$components/user-avatar';
import { useUserPresence } from '$hooks/useUserPresence';
import { PresenceBadge } from '$components/presence';
import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '$components/presence';
import * as css from './style.css';

const getName = (room: Room, member: RoomMember, nicknames: Record<string, string>) =>
Expand Down Expand Up @@ -39,25 +39,30 @@ export const MemberTile = as<'button', MemberTileProps>(

return (
<AsMemberTile className={css.MemberTile} {...props} ref={ref}>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
<AvatarPresence
badge={
presence && presence.presence !== Presence.Offline ? (
<PresenceBadge presence={presence.presence} size="200" />
) : undefined
}
>
<Avatar size="300" radii="400">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="300" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
<Box grow="Yes" as="span" direction="Column">
<Text as="span" size="T300" truncate style={{ color, fontFamily: font }}>
<b>{name}</b>
</Text>
{presence && presence.status && (
<Box alignItems="Center" gap="100">
<PresenceBadge presence={presence.presence} size="200" />
<Text as="span" size="T200" priority="300" truncate>
{presence.status}
</Text>
</Box>
{presence?.status && (
<Text as="span" size="T200" priority="300" truncate>
{presence.status}
</Text>
)}
</Box>
{after}
Expand Down
1 change: 1 addition & 0 deletions src/app/components/presence/Presence.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const PresenceToColor: Record<Presence, MainColor> = {
[Presence.Online]: 'Success',
[Presence.Unavailable]: 'Warning',
[Presence.Offline]: 'Secondary',
[Presence.Dnd]: 'Critical',
};

type PresenceBadgeProps = {
Expand Down
25 changes: 19 additions & 6 deletions src/app/components/room-avatar/AvatarImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ import { settingsAtom } from '$state/settings';
import { useSetting } from '$state/hooks/settings';
import * as css from './RoomAvatar.css';

// Module-level cache: maps a Matrix media URL → processed blob URL so that
// SVG processing only runs once per unique image, even as virtual-list items
// unmount and remount. MXC URLs are content-addressed and never change, so
// the mapping is stable for the lifetime of the page.
const svgBlobCache = new Map<string, string>();

type AvatarImageProps = {
src: string;
alt?: string;
Expand All @@ -23,9 +29,15 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp

useEffect(() => {
let isMounted = true;
let objectUrl: string | null = null;

const processImage = async () => {
// Return the cached blob URL immediately — no network round-trip needed.
const cachedBlobUrl = svgBlobCache.get(src);
if (cachedBlobUrl) {
setProcessedSrc(cachedBlobUrl);
return;
}

try {
const res = await fetch(src, { mode: 'cors' });
const contentType = res.headers.get('content-type');
Expand All @@ -46,8 +58,10 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp
const newSvgString = serializer.serializeToString(doc);
const blob = new Blob([newSvgString], { type: 'image/svg+xml' });

objectUrl = URL.createObjectURL(blob);
if (isMounted) setProcessedSrc(objectUrl);
const blobUrl = URL.createObjectURL(blob);
// Store in module cache so future remounts skip processing.
svgBlobCache.set(src, blobUrl);
if (isMounted) setProcessedSrc(blobUrl);
} else if (isMounted) setProcessedSrc(src);
} catch {
if (isMounted) setProcessedSrc(src);
Expand All @@ -58,9 +72,8 @@ export function AvatarImage({ src, alt, uniformIcons, onError }: AvatarImageProp

return () => {
isMounted = false;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
// Blob URLs are retained in svgBlobCache — do not revoke them here so
// that subsequent remounts can use the cached result without re-fetching.
};
}, [src]);

Expand Down
6 changes: 2 additions & 4 deletions src/app/features/room/MembersDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames';

import { AvatarPresence, PresenceBadge } from '$components/presence';
import { useUserPresence } from '$hooks/useUserPresence';
import { Presence, useUserPresence } from '$hooks/useUserPresence';
import { useMatrixClient } from '$hooks/useMatrixClient';
import { UseStateProvider } from '$components/UseStateProvider';
import type { SearchItemStrGetter, UseAsyncSearchOptions } from '$hooks/useAsyncSearch';
Expand Down Expand Up @@ -150,7 +150,7 @@ function MemberItem({
>
<AvatarPresence
badge={
presence && presence.lastActiveTs !== 0 ? (
presence && presence.presence !== Presence.Offline ? (
<PresenceBadge presence={presence.presence} size="200" />
) : undefined
}
Expand Down Expand Up @@ -296,8 +296,6 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
);

const handleMemberClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
// oxlint-disable-next-line no-console
console.log(evt);
const btn = evt.currentTarget as HTMLButtonElement;
const userId = btn.getAttribute('data-user-id');
if (!userId) return;
Expand Down
35 changes: 27 additions & 8 deletions src/app/features/settings/account/Profile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ import { useCapabilities } from '$hooks/useCapabilities';
import { profilesCacheAtom } from '$state/userRoomProfile';
import { SequenceCardStyle } from '$features/settings/styles.css';
import { useUserPresence } from '$hooks/useUserPresence';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { getSlidingSyncManager } from '$client/initMatrix';
import type { MSC1767Text } from '$types/matrix/common';
import { TimezoneEditor } from './TimezoneEditor';
import { PronounEditor } from './PronounEditor';
Expand Down Expand Up @@ -485,7 +488,13 @@ function ProfileExtended({ profile, userId }: Readonly<ProfileProps>) {

const pronouns = (profile.pronouns as PronounSet[]) || [];
const presence = useUserPresence(userId);
const currentStatus = presence?.status || '';
// presenceStatusMsg is the locally-cached status. On sliding sync, own presence is
// never echoed back by the server, so presence?.status would always be stale/empty.
// The settings atom is the authoritative local source; fall back to the SDK value for
// other clients (e.g. when viewing another user's profile page — but this component
// is only rendered for the own user, so the atom always wins in practice).
const [presenceStatusMsg, setPresenceStatusMsg] = useSetting(settingsAtom, 'presenceStatusMsg');
const currentStatus = presenceStatusMsg || presence?.status || '';

// Keys we don't render here nor handle seperately but still need to exclude
const EXCLUDED_KEYS = new Set([
Expand Down Expand Up @@ -513,14 +522,24 @@ function ProfileExtended({ profile, userId }: Readonly<ProfileProps>) {

const handleSaveStatus = useCallback(
async (newStatus: string) => {
const currentState = presence?.presence || 'online';

await mx.setPresence({
presence: currentState,
status_msg: newStatus,
});
// Only update the local atom. PresenceFeature's effect will broadcast the new
// status_msg to the server on its next run (triggered by this atom change).
// We don't call mx.setPresence here to avoid passing our internal Presence.Dnd
// value ('dnd'), which is not a valid Matrix presence state.
setPresenceStatusMsg(newStatus);

// Eagerly mirror the change in the SDK store so the member list updates without
// waiting for the PresenceFeature effect to resolve the network call.
const myUser = mx.getUser(mx.getUserId() ?? '');
const isDnd = myUser?.presence === 'online' && myUser?.presenceStatusMsg === 'dnd';
if (!isDnd) {
// Not in DND: update local presence to reflect the new status immediately.
getSlidingSyncManager(mx)?.updateOwnPresence(myUser?.presence ?? 'online', newStatus);
}
// In DND mode the sentinel ('dnd') stays as status_msg on the wire; the user's
// custom status is preserved in the atom and used once they leave DND.
},
[mx, presence]
[mx, setPresenceStatusMsg]
);

return (
Expand Down
69 changes: 69 additions & 0 deletions src/app/features/settings/general/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) {
const [hideActivity, setHideActivity] = useSetting(settingsAtom, 'hideActivity');
const [hideReads, setHideReads] = useSetting(settingsAtom, 'hideReads');
const [sendPresence, setSendPresence] = useSetting(settingsAtom, 'sendPresence');
const [autoIdlePresence, setAutoIdlePresence] = useSetting(settingsAtom, 'autoIdlePresence');
const [mentionInReplies, setMentionInReplies] = useSetting(settingsAtom, 'mentionInReplies');

return (
Expand Down Expand Up @@ -476,6 +477,28 @@ function Editor({ isMobile }: Readonly<{ isMobile: boolean }>) {
after={<Switch variant="Primary" value={sendPresence} onChange={setSendPresence} />}
/>
</SequenceCard>
{sendPresence && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Auto-Idle"
focusId="auto-idle-presence"
description="Automatically appear unavailable after a period of inactivity or when the app isn't active."
after={
<Switch variant="Primary" value={autoIdlePresence} onChange={setAutoIdlePresence} />
}
/>
</SequenceCard>
)}
{sendPresence && autoIdlePresence && (
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Idle Timeout"
focusId="presence-idle-timeout"
description="Minutes of inactivity before appearing unavailable."
after={<PresenceIdleTimeoutInput />}
/>
</SequenceCard>
)}
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Send notifications for replies"
Expand Down Expand Up @@ -840,6 +863,52 @@ function EmojiSelectorThresholdInput() {
);
}

function PresenceIdleTimeoutInput() {
const [idleTimeoutMins, setIdleTimeoutMins] = useSetting(settingsAtom, 'presenceIdleTimeoutMins');
const [inputValue, setInputValue] = useState(idleTimeoutMins.toString());

const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const val = evt.target.value;
setInputValue(val);
const parsed = Number.parseInt(val, 10);
if (!Number.isNaN(parsed) && parsed >= 1 && parsed <= 60) {
setIdleTimeoutMins(parsed);
}
};

const handleKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) {
evt.stopPropagation();
setInputValue(idleTimeoutMins.toString());
(evt.target as HTMLInputElement).blur();
}
if (isKeyHotkey('enter', evt)) {
(evt.target as HTMLInputElement).blur();
}
};

return (
<Box alignItems="Center" gap="200">
<Input
style={{ width: toRem(80) }}
variant={Number.parseInt(inputValue, 10) === idleTimeoutMins ? 'Secondary' : 'Success'}
size="300"
radii="300"
type="number"
min="1"
max="60"
value={inputValue}
onChange={handleChange}
onKeyDown={handleKeyDown}
outlined
/>
<Text size="T200" priority="300">
min
</Text>
</Box>
);
}

function Calls() {
const [alwaysShowCallButton, setAlwaysShowCallButton] = useSetting(
settingsAtom,
Expand Down
10 changes: 4 additions & 6 deletions src/app/hooks/useAppVisibility.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@ 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();
}
};

Expand All @@ -46,9 +46,7 @@ export function useAppVisibility(mx: MatrixClient | undefined) {
togglePusher(mx, clientConfig, isVisible, usePushNotifications, pushSubAtom, isMobile);
};

appEvents.onVisibilityChange = handleVisibilityForNotifications;
return () => {
appEvents.onVisibilityChange = null;
};
const unsub = appEvents.onVisibilityChange(handleVisibilityForNotifications);
return unsub;
}, [mx, clientConfig, usePushNotifications, pushSubAtom, isMobile]);
}
Loading
Loading