Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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