Skip to content
Draft
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
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