feat: presence fix, voice ringing fix, user private notes + doc updates
CI / Build & Quality Checks (push) Successful in 10m22s
Trigger Desktop Build / trigger (push) Successful in 5s

- usePresenceUpdater: replace stale closure with readStatus() called at
  invocation time so changing custom status in Profile Settings is never
  silently overwritten by subsequent activity events
- CallEmbedProvider: fix m.space.parent state-key lookup by switching
  getStateEvent → getStateEvents (plural); space channel voice rooms no
  longer trigger the incoming-call ring/animation
- Add useUserNotes hook (io.lotus.user_notes account data, reactive via
  useAccountDataCallback, 500-char limit, cross-device sync)
- UserRoomProfile: add UserPrivateNotes textarea with 800ms debounced
  auto-save, saving indicator, char counter when <100 chars remain;
  shown only when viewing another user's profile
- LOTUS_FEATURES.md: add Private Notes section, Status Revert fix note,
  animation improvements subsection, Seasonal Themes section
- LOTUS_BUGS.md: mark presence revert + voice ringing bugs as resolved
- README.md + landing/index.html: document all new June 2026 features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 00:47:14 -04:00
parent 6db07f1371
commit ca09e8e6ca
7 changed files with 228 additions and 36 deletions
+5 -2
View File
@@ -54,7 +54,7 @@ import { getChatBg } from '../features/lotus/chatBackground';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getMemberDisplayName } from '../utils/room';
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -329,7 +329,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// Space voice channels and public rooms fire room-level RTC notifications
// whenever anyone joins — ringing every member is incorrect behaviour.
const isDirect = directs.has(room.roomId);
const isSpaceChild = !!getStateEvent(room, StateEvent.SpaceParent);
// m.space.parent uses the parent space ID as the state key, so getStateEvent
// (which defaults to stateKey='') always returns undefined. Use getStateEvents
// (no key filter) to detect any space parent relationship.
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateGroup =
!isSpaceChild &&
@@ -1,5 +1,5 @@
import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
@@ -32,6 +32,7 @@ import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
import { useUserNotes, USER_NOTE_MAX_LENGTH } from '../../hooks/useUserNotes';
type VerifyDeviceButtonProps = {
userId: string;
@@ -207,6 +208,65 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
);
}
function UserPrivateNotes({ userId }: { userId: string }) {
const { getNote, setNote } = useUserNotes();
const [draft, setDraft] = useState(() => getNote(userId));
const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Sync if account data arrives after mount
useEffect(() => {
setDraft(getNote(userId));
}, [getNote, userId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setDraft(val);
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
setSaving(true);
await setNote(userId, val);
setSaving(false);
}, 800);
};
useEffect(() => () => clearTimeout(saveTimer.current), []);
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
return (
<Box direction="Column" gap="200">
<Box justifyContent="SpaceBetween" alignItems="Center">
<Text size="L400">Private Note</Text>
<Text size="T200" style={{ opacity: 0.5 }}>
{saving ? 'Saving…' : charsLeft < 100 ? `${charsLeft} left` : ''}
</Text>
</Box>
<textarea
value={draft}
onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH}
placeholder="Notes only visible to you…"
rows={3}
style={{
width: '100%',
resize: 'vertical',
background: 'var(--bg-surface-variant)',
color: 'inherit',
border: '1px solid var(--border-interactive)',
borderRadius: '6px',
padding: '8px 10px',
fontSize: '14px',
fontFamily: 'inherit',
lineHeight: 1.5,
boxSizing: 'border-box',
outline: 'none',
}}
/>
</Box>
);
}
type UserRoomProfileProps = {
userId: string;
};
@@ -331,6 +391,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canBan={canBanUser && membership !== Membership.Ban}
/>
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
{userId !== myUserId && <UserPrivateNotes userId={userId} />}
</Box>
</Box>
);
+16 -8
View File
@@ -17,26 +17,34 @@ export function usePresenceUpdater() {
useEffect(() => {
const userId = mx.getUserId();
const storedStatus = userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
const setOnline = () =>
mx
// Read status from localStorage at call time so manual updates from the
// Profile settings are never overwritten by a stale closure value.
const readStatus = () =>
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
const setOnline = () => {
const status = readStatus();
return mx
.setPresence({
presence: 'online',
...(storedStatus ? { status_msg: storedStatus } : {}),
...(status ? { status_msg: status } : {}),
})
.catch(() => undefined);
const setUnavailable = (statusMsg?: string) =>
mx
};
const setUnavailable = (statusMsg?: string) => {
const status = readStatus();
return mx
.setPresence({
presence: 'unavailable',
...(statusMsg
? { status_msg: statusMsg }
: storedStatus
? { status_msg: storedStatus }
: status
? { status_msg: status }
: {}),
})
.catch(() => undefined);
};
const setOffline = () =>
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
const NOTES_KEY = 'io.lotus.user_notes';
export const USER_NOTE_MAX_LENGTH = 500;
type UserNotesContent = Record<string, string>;
function readNotes(mx: MatrixClient): UserNotesContent {
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
}
export function useUserNotes(): {
getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx));
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === NOTES_KEY) {
setNotes(evt.getContent<UserNotesContent>() ?? {});
}
},
[],
),
);
useEffect(() => {
setNotes(readNotes(mx));
}, [mx]);
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback(
async (userId: string, note: string) => {
const current = readNotes(mx);
const updated = { ...current };
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
if (trimmed) {
updated[userId] = trimmed;
} else {
delete updated[userId];
}
await (mx as any).setAccountData(NOTES_KEY, updated);
},
[mx],
);
return { getNote, setNote };
}