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
+8 -9
View File
@@ -32,12 +32,11 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 1. Presence Updater Reverts Status Updates
**File:** `src/app/hooks/usePresenceUpdater.ts` (Line 20)
**Status:** High Priority
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** The `storedStatus` variable is captured once when the `useEffect` starts.
* **Impact:** If a user updates their status message in the Profile settings, the presence updater hook continues to use the *old* status message from its closure. Every time the user moves their mouse/activity, the hook sends `setOnline` with the stale status, reverting the user's change.
* **Recommended Fix:**
1. Read the status from `localStorage` inside the `setOnline`/`setUnavailable` functions rather than at the top of the effect.
* **Issue:** The `storedStatus` variable was captured once when the `useEffect` started.
* **Impact:** If a user updated their status message in Profile Settings, the hook would continue broadcasting the old message on every activity event, silently reverting the change.
* **Fix Applied:** Replaced the single `localStorage.getItem` read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, ensuring the current value is always used.
### 2. Audio Playback Rate Reset
**File:** `src/app/components/message/content/AudioContent.tsx` (Line 97)
@@ -57,11 +56,11 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
### 4. Incorrect Ringing in Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx`
**Status:** Logic Bug
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Joining a static voice room (Public Space channel) triggers the "Incoming Call" ringing animation and sound. This should only occur for "StartedByUser" intents (DMs and Private Group Calls).
* **Root Cause:** The ringing logic does not distinguish between persistent voice rooms and user-initiated calls.
* **Recommended Fix:** Check `room.getJoinRule()` or check for the `m.space.parent` event. If the room is a public voice channel, suppress the ringing animation.
* **Issue:** Joining a static voice room (Public Space channel) triggered the "Incoming Call" ringing animation and sound.
* **Root Cause:** `getStateEvent(room, StateEvent.SpaceParent)` always returned `undefined` because `m.space.parent` events use the parent space's room ID as the state key, not an empty string. The ringing suppression check silently failed.
* **Fix Applied:** Replaced `getStateEvent` with `getStateEvents` (plural), which returns all events of a given type regardless of state key. A room with any `m.space.parent` event is correctly identified as a space channel and suppresses ringing.
---
+77 -14
View File
@@ -10,20 +10,21 @@ Last updated: June 2026.
1. [Branding & Identity](#branding--identity)
2. [LotusGuild Terminal Design System (TDS) v1.2](#lotusguild-terminal-design-system-tds-v12)
3. [Animated Chat Backgrounds (P5-4)](#animated-chat-backgrounds-p5-4)
4. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
5. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
6. [Voice / Video Call Improvements](#voice--video-call-improvements)
7. [Per-Message Read Receipts](#per-message-read-receipts)
8. [Delivery Status Indicators](#delivery-status-indicators)
9. [Messaging Enhancements](#messaging-enhancements)
10. [Presence](#presence)
11. [UX & Composer](#ux--composer)
12. [Room Customization](#room-customization)
13. [Moderation](#moderation)
14. [Notifications](#notifications)
15. [Server Integration](#server-integration)
16. [Infrastructure](#infrastructure)
17. [Key Custom Files](#key-custom-files)
4. [Seasonal Theme Overlays (P5-12)](#seasonal-theme-overlays-p5-12)
5. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
6. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
7. [Voice / Video Call Improvements](#voice--video-call-improvements)
8. [Per-Message Read Receipts](#per-message-read-receipts)
9. [Delivery Status Indicators](#delivery-status-indicators)
10. [Messaging Enhancements](#messaging-enhancements)
11. [Presence](#presence)
12. [UX & Composer](#ux--composer)
13. [Room Customization](#room-customization)
14. [Moderation](#moderation)
15. [Notifications](#notifications)
16. [Server Integration](#server-integration)
17. [Infrastructure](#infrastructure)
18. [Key Custom Files](#key-custom-files)
---
@@ -148,6 +149,16 @@ Strips all `animation` properties from the returned style object when either `pa
A "Pause Background Animations" toggle is exposed in **Settings → Appearance**. The preference is persisted and read by `getChatBg()` at render time.
### Animation Improvements (June 2026)
All five animated backgrounds were rewritten for smoother, more organic motion:
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`130 px`, `190 px`, `260 px`), eliminating the visible seam on loop
- **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
- **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
### Files
- `src/app/styles/Animations.css.ts` — vanilla-extract keyframe definitions
@@ -155,6 +166,40 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
---
## Seasonal Theme Overlays (P5-12)
Decorative CSS-only overlays that activate automatically on holidays and events. Manually overrideable in **Settings → Appearance → Seasonal Theme**.
### Themes
| Theme | Window | Effect |
|---|---|---|
| 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 410 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
- `SeasonalEffect` component mounted in `App.tsx` at `z-index: 9997` (below night light, above content)
- Auto-detection via `getActiveSeason(now: Date)` — themes checked in priority order (New Year > Valentine's > … > Autumn)
- `seasonalThemeOverride` setting: `'auto' | 'off' | <theme-name>` — persisted in `settingsAtom`
- All particle animations gated on `prefers-reduced-motion: reduce` — ambient overlays (tints, textures, shimmer) remain active
### Files
- `src/app/components/seasonal/SeasonalEffect.tsx` — theme detection, date ranges, all overlay components
- `src/app/components/seasonal/Seasonal.css.ts` — vanilla-extract keyframes (fall, leaf, float-up, bob, glitch, burst, warp, scanline, shimmer, etc.)
---
## Glassmorphism Sidebar (P5-3)
An optional frosted-glass sidebar style toggled in **Settings → Appearance**.
@@ -538,6 +583,12 @@ Applied in:
- `@mention` autocomplete dropdown
- Inbox / notifications panel
### Status Revert Bug Fix (June 2026)
`usePresenceUpdater` previously captured the user's custom status message once via `localStorage.getItem` at effect initialization. When the user changed their status message in Profile Settings, subsequent automatic transitions back to `online` (e.g., returning from idle) would silently broadcast the old status message, reverting the custom status.
Fixed by replacing the single read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, so the current localStorage value is always used.
### Document Title Unread Count
The browser tab title updates to reflect unread state:
@@ -565,6 +616,18 @@ When a user has `m.tz` set in their profile:
Hook: `src/app/hooks/useLocalTime.ts`
### User-to-User Private Notes (P5-34)
A private text note on any user's profile, visible only to the logged-in user and synced across all their devices.
- Textarea in the user profile popout (below device sessions), shown only when viewing another user — never on your own profile
- Auto-saves 800 ms after the last keystroke with a "Saving…" indicator
- Character counter appears when fewer than 100 characters remain (max 500)
- Stored in `io.lotus.user_notes` account data as `{ [userId]: string }` — deletes the key when the note is cleared
- Reactive: updates immediately if account data arrives from another device mid-session
Hook: `src/app/hooks/useUserNotes.ts`
---
## UX & Composer
+4 -2
View File
@@ -58,7 +58,8 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
- LotusGuild Terminal Design System (TDS) — a CRT terminal-inspired dark theme
- TDS light mode variant for daytime use
- 20+ static chat background patterns
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies (with improved per-layer looping, phosphor-flicker rain, fluid aurora sweep, and organic firefly bioluminescence)
- 11 seasonal & holiday theme overlays — Halloween, Christmas, New Year, Autumn, Valentine's Day, St. Patrick's Day, Earth Day, Lunar New Year, April Fools', Deep Space, and Retro Arcade; auto-selected by date with a manual override in Settings → Appearance
- Toggle to pause background animations
- Glassmorphism sidebar — frosted glass effect that lets the background show through
- Night Light / blue light filter with an adjustable intensity slider
@@ -69,10 +70,11 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
### Presence & Profile
- Discord-style presence selector: Online, Idle, Do Not Disturb, Invisible, or Auto
- Custom status message with emoji and an optional auto-clear timer
- Custom status message with emoji and an optional auto-clear timer (changing your status is never silently overwritten by activity events)
- Colored presence ring on member avatars (green / yellow / red)
- Profile fields for pronouns and timezone
- When a user's timezone is set, their current local time appears in their profile
- Private notes on any user's profile — freeform text visible only to you, auto-saves and syncs across devices
- Unread count shown in the browser tab title
### Moderation & Privacy
+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 };
}