Compare commits

...
15 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 3045a9f014 docs: add DP1-DP18 test items (LOTUS_TESTING §R), retire DP backlog
CI / Trigger Desktop Build (push) Successful in 7s
CI / Build & Quality Checks (push) Successful in 10m57s
The discovery-pass items DP1-DP18 are implemented + TPVR-reviewed, so move their
manual QA steps into LOTUS_TESTING.md (new section R) and replace the DP backlog
block in LOTUS_TODO.md with a done-breadcrumb pointing at §R + the commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:05:05 -04:00
jaredandClaude Opus 4.8 c2598d21cd fix(dp): address TPVR review findings (DP2 redo, DP4 dedup, DP15 gap)
- DP2: the earlier seed fix was ineffective (allInvitesAtom populates post-mount,
  so first render is still empty). Rewrite to track invite room ids and stay
  'unarmed' until the initial sync settles (+3s grace), notifying only for ids that
  first appear after arming — robust to the async population race.
- DP4: batch the mutually-exclusive tag writes via Promise.all so a failure surfaces
  a single toast instead of one per operation.
- DP15: route the two remaining StateEvent writes (RoomSoundboardPack / RoomImagePack,
  which used an 'as unknown as keyof StateEvents' idiom the sweep missed) through the
  typed sendStateEvent helper.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:02:04 -04:00
jaredandClaude Opus 4.8 4fa4327a18 DP17: use folds Icons + named brand-color consts in UrlPreviewCard
Replace literal-glyph UI icons in the URL preview cards with folds
Icon/Icons components, and hoist hardcoded provider brand hex values
into a single named BRAND_COLORS map.

Glyph -> folds icon:
- TikTok/Spotify musical note (♫) -> Icons.VolumeHigh
- Portrait play button (▶)        -> Icons.Play
- Reddit comment count (💬)       -> Icons.Message
- Steam gear (⚙)                  -> Icons.Setting
All use size="Inherit" so they keep the surrounding font-size/color.

Brand hex now referenced via BRAND_COLORS (tiktok, spotify, steam,
twitch, reddit, discord, npm, stackOverflow) instead of scattered
literals; colors are byte-for-byte identical and remain fixed brand
identities (not converted to TDS theme vars).

The game controller glyph (🎮) is left as-is: folds has no game/
controller icon and no close semantic match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:40:49 -04:00
jaredandClaude Opus 4.8 101e4116e8 refactor(DP15): centralize sendStateEvent as-any cast in typed helper
Add a typed `sendStateEvent(mx, roomId, eventType, content, stateKey?)`
helper in utils/room.ts that mirrors the DP16 account-data helper pattern.
The SDK's typed `sendStateEvent` overload rejects the fork's custom
`StateEvent` enum values, so every call site cast arg 2 to `any` (which
also collapsed the content type). The single `as any` cast now lives inside
the helper; a generic `content: T extends object` keeps each call site's
content type checked.

Route all 32 `mx.sendStateEvent(..., StateEvent.X as any, ...)` casts across
20 files through the helper. The 2 dynamic-string casts in developer-tools
(SendRoomEvent, StateEventEditor) pass a runtime string, not an enum value,
so they stay as-is.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:32:50 -04:00
jaredandClaude Opus 4.8 4fc3f7a35f DP13: extract shared account-data list-store engine
useBookmarks / useReminders / useUserNotes were near-verbatim copies of a
concurrency-critical engine (module-scoped singleton, per-client
subscribe/teardown, a serialized write-queue that prevents lost-update
clobbering, a listener Set, and the account-data subscription).

Extract it into createAccountDataListStore<T, C>({ eventType, read, write }) in
src/app/hooks/createAccountDataListStore.ts. The write-serialization semantics
are preserved identically (still the lost-update fix). The differing payload
shapes are parameterized via read/write: bookmarks/reminders wrap a list
({bookmarks}/{reminders}); notes is a flat Record passed through unchanged.

The three hooks become thin wrappers with their exact public APIs unchanged
(same exported names, signatures, return shapes, and mutators), so no call site
changes. The DP16 setAccountData helper is used inside the queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:24:08 -04:00
jaredandClaude Opus 4.8 b1ee3ada98 DP16: add typed get/setAccountData helpers, remove ~19 any-casts
Add centralized typed helpers in src/app/utils/accountData.ts:
- getAccountData<T>(mx, eventType): T | undefined (returns content)
- setAccountData<T>(mx, eventType, content): Promise<void>

These wrap the single `as any` cast needed because matrix-js-sdk's typed
overloads reject the fork's custom account-data event names. Every call site
now stays fully typed on its content shape.

Route all account-data reads/writes that previously used
`(mx as any).getAccountData/setAccountData` or `mx.getAccountData(... as any)`
through the helpers (or, where a MatrixEvent is needed, through the existing
utils/room.ts getAccountData whose param is widened to accept string keys).
No behavior change: same event types, same content shapes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:14:35 -04:00
jaredandClaude Opus 4.8 e545706c3b DP18: add useMemberAvatar hook and adopt it in reader avatars
Add a reactive `useMemberAvatar(room, userId, w?, h?, resize?)` hook
returning { name, avatarUrl }, standardizing the member name + avatar
trio and the RoomStateEvent.Members reactivity pattern (N6). Convert
ReadReceiptAvatars and EventReaders to render per-user avatars via a
small child component using the hook, preserving exact sizes, fallback
rendering, TDS pill/tooltip styling and behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:01:39 -04:00
jaredandClaude Opus 4.8 8c0e2b4250 DP18: add getMemberName helper and dedup name-fallback sites
Add a pure `getMemberName(room, userId): string` helper in utils/room.ts
(= getMemberDisplayName ?? getMxIdLocalPart ?? userId) and replace the
inline `getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id`
fallback across the codebase. No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 23:01:32 -04:00
jaredandClaude Opus 4.8 165714e133 fix(tds): resolve send-status and read-receipt colors from --lt-* vars
DP14: Message send-status icon and ReadReceiptAvatars pill hardcoded
dark-theme accent hex/rgba, so TDS light mode kept bright cyan/red
instead of the theme-overridden darker values. Route all colors/glows
through the theme-aware --lt-* CSS variables (mirroring EventReaders),
using color-mix for translucent tints and accent-alpha icon colors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:40:54 -04:00
jaredandClaude Opus 4.8 6cf18c3bd9 fix(a11y): correct call-control aria + picker/recorder/live-chip UX (DP7-DP12)
DP7: fix inverted Deafen/Undeafen aria-label on the sound button so it
  matches the action and tooltip.
DP8: add aria-pressed to Sound/Video/ScreenShare toggles and align the
  Microphone toggle to the same "pressed = feature on/active" semantic.
DP9: return focus to the trigger when the GifPicker closes and cap its
  fixed width to min(312px, calc(100vw - 16px)) so it can't overflow
  narrow viewports.
DP10: drop redundant mouse-only clear onClick nested inside the search
  filter Chip buttons (parent chip/menu already performs the clear);
  the cross icons are now purely decorative.
DP11: constrain the voice recorder widget and let the waveform shrink so
  it fits a narrow composer, and expose the live duration as a role="timer"
  snapshot instead of spamming a screen reader every 100ms.
DP12: announce the live-call participant count via a visually-hidden
  role="status" region.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:37:22 -04:00
jaredandClaude Opus 4.8 db86432644 fix(state): correct invite re-notify, status clear sync, soundboard resync (DP2/DP3/DP5)
- Invites: seed the notify baseline with the invite count at mount instead
  of 0, so a warm reload (allInvitesAtom populates synchronously from cache
  while SYNCING) no longer re-fires the toast+sound for pre-existing invites.
  Only a genuine increase notifies.
- Status: the cross-device sync effect gated on a truthy presence.status, so
  a remote CLEAR never reset the input or localStorage[STATUS_MSG_KEY] and
  usePresenceUpdater.readStatus() re-sent the stale status. Now mirror an
  empty status as a clear (skipping offline/invisible, which carries an empty
  status_msg by design).
- Soundboard: useState initializers ran once and never recomputed when the
  room/rooms arg changed. Add a resync effect keyed on the arg while keeping
  the live state-event update path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:29:47 -04:00
jaredandClaude Opus 4.8 8eb961b682 fix(errors): surface previously-silent async failures (DP1/DP4/DP6)
Several fire-and-forget promises failed silently:
- RoomInput slash-command exe() rejections reset the editor as if they
  succeeded; now caught and shown via an error toast.
- Favourite / low-priority room-tag toggles (setRoomTag/deleteRoomTag)
  swallowed rejections; now surfaced via the same error toast.
- Call-decline sendEvent(RTCDecline) was uncaught; now logged best-effort
  while the local UI still dismisses.

Adds a shared createErrorToast builder mirroring createDownloadToast.
/kick and /ban route through rateLimitedActions, whose to() helper
swallows non-429 errors, so those two can still resolve on failure — noted
in code; the top-level exe() catch covers everything that does reject.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:29:39 -04:00
jaredandClaude Opus 4.8 2614e6b92d docs(todo): add DP1-DP18 discovery-pass findings (agent-surveyed + TPVR-verified)
Three discovery agents swept src/ for correctness/a11y/tech-debt issues beyond the
tracked backlog; a separate TPVR pass independently confirmed all 18 (5 refined to
partial with count/scope corrections). Filed as DP1-DP18 under Open - Actionable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:13:23 -04:00
jaredandClaude Opus 4.8 f9c03d5e33 fix(inputs): cap room-name / display-name length client-side (H10)
The room-name (settings + create-room) and display-name inputs had no client-side
length guard, so an over-long value only failed after a server round-trip. Add
maxLength={255} (matching the existing inline-rename cap in RoomNavItem; under
Synapse's 256 max_displayname_length). Also clamp the emoji-picker prepend in the
two room-name fields, since programmatic setState isn't constrained by the DOM
maxLength and could otherwise push past the cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:58:10 -04:00
jaredandClaude Opus 4.8 3f9fea1cf2 fix(receipts): refresh read-receipt avatars/names on member changes (N6)
ReadReceiptAvatars read avatar + display-name from room member state at render
time, so they only refreshed when the parent Message re-rendered (on a receipt
change). A reader changing their avatar/display-name left a stale avatar/name
until an unrelated re-render. Subscribe to RoomStateEvent.Members (the only signal
that fires for name, avatar AND membership changes — RoomMemberEvent has no Avatar
event) scoped to the displayed readers, and force a re-render so the pill updates
live. Listener is cleaned up; userIds identity is stable (memoized in the read-
positions map) so no subscribe churn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:58:10 -04:00
71 changed files with 957 additions and 750 deletions
+34
View File
@@ -699,6 +699,40 @@ Paste each of these into a room and confirm a media tile (not a plain link) with
---
## R. Discovery-pass fixes (DP1DP18, 2026-07)
Agent-surveyed + TPVR-verified low/med issues, now fixed (commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`). Behavioral items have concrete checks; the refactors just need a "still works" regression pass.
### Correctness
- [ ] **DP1 — slash-command errors are visible.** Run a slash command that must fail — e.g. `/kick @nobody:server` in a room where you lack permission, or `/join` a bad alias. **Expected:** an error toast appears (not a silent no-op); a successful command still clears the composer normally.
- [ ] **DP2 — no invite re-notify on reload.** With ≥1 pending invite, hard-reload (Ctrl+F5). **Expected:** NO "you have N new invitation" toast/sound on load. Then have someone invite you while the app is open → you DO get one notification for the new invite. (👥 2 accounts)
- [ ] **DP3 — status clear syncs across devices.** Set a status message on device A, confirm it shows on B; clear it on A. **Expected:** B clears too and does NOT re-publish the old status on its next presence heartbeat. Toggling Invisible must not wipe a real saved status. (👥 2 sessions)
- [ ] **DP4 — tag-toggle failure surfaced once.** With the network offline, toggle a room's Favourite/Low-priority. **Expected:** a single error toast (not two) on failure; on success the tag updates as before.
- [ ] **DP5 — soundboard packs update on room switch.** Open a soundboard in room A, then switch to room B (different pack) in the same mounted view. **Expected:** B's packs show without needing an unrelated event.
- [ ] **DP6 — declining a call still dismisses.** Decline an incoming call. **Expected:** the ringing UI dismisses even if the decline send fails (best-effort). (👥 2 accounts)
### a11y / UX (needs a screen reader + a narrow viewport)
- [ ] **DP7 / DP8 — call-control buttons announce correctly.** In a call with a screen reader: the deafen button announces "Deafen" when sound is on (not "Undeafen"); Sound / Video / Screenshare announce a consistent pressed/unpressed state like Mic.
- [ ] **DP9 — GIF picker focus + width.** Open the GIF picker, close it (Esc / click-out) → focus returns to the GIF button. On a ~320px viewport the picker doesn't overflow the page.
- [ ] **DP10 — search-filter clears by keyboard.** In message search, toggle a filter chip (Has link / msg-type / pinned) off with Enter; the date-range clears via its menu's Clear. No mouse-only clear needed.
- [ ] **DP11 — voice recorder fits + announces.** On a ~360px viewport, start a voice message → the recorder row doesn't overflow the composer; a screen reader can query the duration (role="timer") without being spammed.
- [ ] **DP12 — live-call count announced.** With a screen reader, when someone joins/leaves an active call, the "{n} Live" change is announced (polite status region).
### TDS colors (Lotus Terminal theme)
- [ ] **DP14 — send-status + receipt colors follow the theme.** In **TDS light** mode: the message send-status "failed" icon and the read-receipt pill use the theme's darker red/blue (from `--lt-*` tokens), NOT bright dark-mode cyan/red. TDS dark still looks right; non-TDS themes unchanged.
### Refactor regression pass (no behavior change intended)
- [ ] **DP13 — bookmarks / reminders / notes still work** (they now share one store engine). Add/remove a bookmark, set/clear a reminder, write/clear a user note; each persists across reload; rapid consecutive writes don't clobber each other.
- [ ] **DP15 / DP16 — state-event + account-data writes still work.** Edit room name/topic/avatar, join-rules, power levels, an emoji/soundboard pack (state events); toggle a setting stored in account data. All save + reflect correctly.
- [ ] **DP17 — link previews render.** Open a TikTok / Spotify / Steam / Reddit link preview; provider icons render (folds icons, not raw glyphs) and brand colors look right.
- [ ] **DP18 — member avatars/names live-update.** Read receipts + the "seen by" reader list show correct avatars/names and update live when a member changes their avatar or display name (no reload).
---
## Priority if you're short on time
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
+4
View File
@@ -63,6 +63,10 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
## 🔴 Open — Actionable
### ✅ Discovery pass (2026-07) — DP1DP18 DONE
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
+9 -8
View File
@@ -59,7 +59,7 @@ import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -161,9 +161,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
<Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center">
{getMemberDisplayName(info.room, info.sender) ??
getMxIdLocalPart(info.sender) ??
info.sender}
{getMemberName(info.room, info.sender)}
</Text>
<Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No">
@@ -316,8 +314,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
return () => clearTimeout(id);
}, [info.senderTs, info.lifetime, onIgnore]);
const callerName =
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
const callerName = getMemberName(info.room, info.sender);
return (
<Box
@@ -453,8 +450,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
decliner !== mx.getSafeUserId() &&
callEmbed?.roomId === room.roomId
) {
const declinerName =
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
const declinerName = getMemberName(room, decliner);
setToast({
id: `rtc-decline-${event.getId() ?? decliner}`,
displayName: declinerName,
@@ -544,11 +540,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleReject = useCallback(
(room: Room, eventId: string) => {
// Best-effort: the local UI dismisses regardless (below), but a failed
// decline used to reject uncaught. Log it instead of surfacing — the caller
// will time the call out on their side even if our decline never lands.
mx.sendEvent(room.roomId, EventType.RTCDecline, {
'm.relates_to': {
rel_type: RelationType.Reference,
event_id: eventId,
},
}).catch((err) => {
console.error('Failed to send call decline:', err);
});
setCallInfo(undefined);
},
+5 -4
View File
@@ -7,6 +7,7 @@ import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
const PICKER_WIDTH = 312;
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void;
@@ -31,7 +32,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
);
return (
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}>
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
{lotusTerminal && (
<div
style={{
@@ -88,7 +89,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
overflow: 'hidden',
boxShadow:
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
width: `${PICKER_WIDTH}px`,
width: PICKER_WIDTH_CSS,
}
: {
background: color.Surface.Container,
@@ -96,14 +97,14 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
borderRadius: config.radii.R400,
overflow: 'hidden',
boxShadow: color.Other.Shadow,
width: `${PICKER_WIDTH}px`,
width: PICKER_WIDTH_CSS,
};
return (
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
returnFocusOnDeactivate: true,
onDeactivate: requestClose,
clickOutsideDeactivates: true,
allowOutsideClick: true,
+5 -1
View File
@@ -197,6 +197,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`,
maxWidth: '100%',
minWidth: 0,
}}
>
<Box
@@ -212,6 +214,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
/>
<Text
size="T200"
role="timer"
aria-label={`Recording duration ${formatDuration(durationMs)}`}
style={{
minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums',
@@ -230,7 +234,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
data-voice-waveform
alignItems="Center"
gap="100"
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }}
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 1, minWidth: 0 }}
>
{waveformBars.map((h, i) => (
<div
+4 -2
View File
@@ -8,6 +8,7 @@ import {
} from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { RoomType, StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix';
import { CreateRoomAccess } from './types';
@@ -150,9 +151,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
const result = await mx.createRoom(options);
if (data.parent) {
await mx.sendStateEvent(
await sendStateEvent(
mx,
data.parent.roomId,
StateEvent.SpaceChild as any,
StateEvent.SpaceChild,
{
auto_join: false,
suggested: false,
@@ -16,7 +16,7 @@ import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
import { getMemberName, getMemberSearchStr } from '../../../utils/room';
import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
@@ -140,8 +140,7 @@ export function UserMentionAutocomplete({
});
});
const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const getName = (member: RoomMember) => getMemberName(room, member.userId);
return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
@@ -15,12 +15,10 @@ import {
} from 'folds';
import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom';
@@ -38,6 +36,64 @@ function formatReadTs(ts: number, hour24Clock: boolean): string {
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
}
type EventReaderItemProps = {
room: Room;
readerId: string;
hour24Clock: boolean;
lotusTerminal: boolean;
onSelect: React.MouseEventHandler<HTMLButtonElement>;
};
function EventReaderItem({
room,
readerId,
hour24Clock,
lotusTerminal,
onSelect,
}: EventReaderItemProps) {
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
return (
<MenuItem
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={onSelect}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
{receiptTs !== undefined && (
<Text
size="T200"
priority="300"
style={
lotusTerminal
? {
color: 'var(--lt-accent-amber)',
textShadow: 'var(--lt-glow-amber)',
}
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
</Text>
)}
</Box>
</MenuItem>
);
}
export type EventReadersProps = {
room: Room;
eventId: string;
@@ -46,7 +102,6 @@ export type EventReadersProps = {
export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const myUserId = mx.getUserId();
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
const openProfile = useOpenUserRoomProfile();
@@ -54,9 +109,6 @@ export const EventReaders = as<'div', EventReadersProps>(
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
return (
<Box
className={classNames(css.EventReaders, className)}
@@ -100,64 +152,24 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => {
const name = getName(readerId);
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? (mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ??
undefined)
: undefined;
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
return (
<MenuItem
key={readerId}
style={{ padding: `0 ${config.space.S200}` }}
radii="400"
onClick={(event) => {
openProfile(
room.roomId,
space?.roomId,
readerId,
getMouseEventCords(event.nativeEvent),
'Bottom',
);
}}
before={
<Avatar size="200">
<UserAvatar
userId={readerId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Box direction="Column" grow="Yes">
<Text size="T400" truncate>
{name}
</Text>
{receiptTs !== undefined && (
<Text
size="T200"
priority="300"
style={
lotusTerminal
? {
color: 'var(--lt-accent-amber)',
textShadow: 'var(--lt-glow-amber)',
}
: undefined
}
>
{formatReadTs(receiptTs, hour24Clock)}
</Text>
)}
</Box>
</MenuItem>
);
})}
{latestEventReaders.map((readerId) => (
<EventReaderItem
key={readerId}
room={room}
readerId={readerId}
hour24Clock={hour24Clock}
lotusTerminal={lotusTerminal}
onSelect={(event) => {
openProfile(
room.roomId,
space?.roomId,
readerId,
getMouseEventCords(event.nativeEvent),
'Bottom',
);
}}
/>
))}
</Box>
</Scroll>
</Box>
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { useRoomImagePack } from '../../hooks/useImagePacks';
import { randomStr } from '../../utils/common';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
@@ -45,12 +46,7 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const { address } = imagePack;
if (!address) return;
await mx.sendStateEvent(
address.roomId,
StateEvent.PoniesRoomEmotes as unknown as keyof import('matrix-js-sdk').StateEvents,
packContent as any,
address.stateKey,
);
await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
},
[mx, imagePack],
);
@@ -1,13 +1,12 @@
import React, { ReactNode } from 'react';
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { getMemberDisplayName } from '../../utils/room';
import { getMemberName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar';
import * as css from './style.css';
const getName = (room: Room, member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const getName = (room: Room, member: RoomMember) => getMemberName(room, member.userId);
type MemberTileProps = {
mx: MatrixClient;
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Room } from 'matrix-js-sdk';
import React, { useEffect, useState } from 'react';
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import {
Icon,
Icons,
@@ -15,18 +15,32 @@ import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberName } from '../../utils/room';
import { UserAvatar } from '../user-avatar';
import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5;
function ReceiptStackedAvatar({ room, userId }: { room: Room; userId: string }) {
const { name, avatarUrl } = useMemberAvatar(room, userId);
return (
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
}
export function ReadReceiptAvatars({
room,
eventId,
@@ -37,10 +51,31 @@ export function ReadReceiptAvatars({
userIds: string[];
}) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const modalStyle = useModalStyle(360);
const [, forceUpdate] = useForceUpdate();
// The avatars + names below are read from room member state at render time, so
// they only refresh when this component re-renders (which the timeline does on a
// receipt change). Re-render on a member-state change of any displayed reader so
// an avatar/display-name update shows live. RoomStateEvent.Members fires for name,
// avatar AND membership changes (RoomMemberEvent has no Avatar signal).
useEffect(() => {
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member,
) => {
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
forceUpdate();
}
};
mx.on(RoomStateEvent.Members, handleMembers);
return () => {
mx.removeListener(RoomStateEvent.Members, handleMembers);
};
}, [mx, room.roomId, userIds, forceUpdate]);
if (userIds.length === 0) return null;
@@ -49,7 +84,7 @@ export function ReadReceiptAvatars({
const tooltipNames =
userIds
.slice(0, 5)
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
.map((id) => getMemberName(room, id))
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
return (
@@ -83,40 +118,20 @@ export function ReadReceiptAvatars({
display: 'flex',
alignItems: 'center',
backgroundColor: lotusTerminal
? 'rgba(0,212,255,0.07)'
? 'color-mix(in srgb, var(--lt-accent-cyan) 7%, transparent)'
: color.SurfaceVariant.Container,
border: lotusTerminal
? `${config.borderWidth.B300} solid rgba(0,212,255,0.30)`
? `${config.borderWidth.B300} solid color-mix(in srgb, var(--lt-accent-cyan) 30%, transparent)`
: `${config.borderWidth.B300} solid transparent`,
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
boxShadow: lotusTerminal ? 'var(--lt-box-glow-cyan)' : 'none',
borderRadius: config.radii.Pill,
padding: `${config.space.S100} ${config.space.S200}`,
gap: '0px',
}}
>
{displayed.map((userId) => {
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined)
: undefined;
return (
<StackedAvatar
key={userId}
title={name}
variant="SurfaceVariant"
size="200"
radii="Pill"
>
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</StackedAvatar>
);
})}
{displayed.map((userId) => (
<ReceiptStackedAvatar key={userId} room={room} userId={userId} />
))}
{extra > 0 && (
<Text
size="T200"
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SoundboardPackEditor } from './SoundboardPackEditor';
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
import { randomStr } from '../../utils/common';
@@ -35,12 +36,7 @@ export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps)
const handleUpdate = useCallback(
async (content: SoundboardContent) => {
await mx.sendStateEvent(
room.roomId,
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
content as never,
stateKey,
);
await sendStateEvent(mx, room.roomId, StateEvent.LotusSoundboardRoom, content, stateKey);
},
[mx, room.roomId, stateKey],
);
@@ -33,6 +33,22 @@ import {
const linkStyles = { color: color.Success.Main };
// ---------------------------------------------------------------------------
// Provider brand identity colors.
// These are official brand-palette values (logos, badges), NOT theme colors,
// so they intentionally stay as fixed hex and must not become --lt-* TDS vars.
// ---------------------------------------------------------------------------
const BRAND_COLORS = {
tiktok: '#EE1D52',
spotify: '#1db954',
steam: '#c7d5e0',
twitch: '#9146ff',
reddit: '#ff4500',
discord: '#5865f2',
npm: '#cb3837',
stackOverflow: '#f48024',
} as const;
// ---------------------------------------------------------------------------
// Helpers — URL parsing & variant detection
// ---------------------------------------------------------------------------
@@ -474,7 +490,11 @@ function TikTokCard({
<div className={previewCss.ShortsHeader}>
{/* TikTok badge with musical note icon */}
<span className={`${previewCss.SiteBadge} ${previewCss.BadgeTikTok}`}>
<span style={{ color: '#EE1D52', marginRight: '2px' }}></span>
<Icon
style={{ color: BRAND_COLORS.tiktok, marginRight: '2px' }}
size="Inherit"
src={Icons.VolumeHigh}
/>
TikTok
</span>
{username && (
@@ -512,12 +532,14 @@ function TikTokCard({
loading="lazy"
/>
<div className={previewCss.PortraitPlayOverlay}>
<div className={previewCss.PortraitPlayButton}></div>
<div className={previewCss.PortraitPlayButton}>
<Icon size="Inherit" src={Icons.Play} />
</div>
</div>
</a>
) : (
<div className={previewCss.PortraitPlaceholder}>
<span style={{ color: '#EE1D52' }}></span>
<Icon style={{ color: BRAND_COLORS.tiktok }} size="Inherit" src={Icons.VolumeHigh} />
</div>
)}
<Box grow="Yes" direction="Column" gap="100">
@@ -828,7 +850,7 @@ function TwitchCard({
width={28}
height={28}
viewBox="0 0 24 24"
fill="#9146ff"
fill={BRAND_COLORS.twitch}
aria-hidden="true"
role="img"
>
@@ -921,11 +943,11 @@ function RedditCard({
width={28}
height={28}
viewBox="0 0 24 24"
fill="#ff4500"
fill={BRAND_COLORS.reddit}
aria-hidden="true"
role="img"
>
<circle cx="12" cy="12" r="12" fill="#ff4500" />
<circle cx="12" cy="12" r="12" fill={BRAND_COLORS.reddit} />
<path
fill="#ffffff"
d="M20 12a2 2 0 0 0-2-2 2 2 0 0 0-1.36.54C15.28 9.8 13.72 9.4 12 9.39l.7-3.26 2.23.47a1.4 1.4 0 1 0 .15-.69l-2.5-.52a.25.25 0 0 0-.29.19l-.78 3.65c-1.74.06-3.3.46-4.63 1.17A2 2 0 0 0 4 12a2 2 0 0 0 1.07 1.76 3.5 3.5 0 0 0 0 .49C5.07 16.69 8.27 19 12.07 19s6.97-2.31 6.97-4.75a3.5 3.5 0 0 0 0-.47A2 2 0 0 0 20 12zm-13 2a1 1 0 1 1 2 0 1 1 0 0 1-2 0zm5.59 2.71c-.72.72-2.1.77-2.53.77s-1.82-.05-2.53-.77a.25.25 0 0 1 .35-.35c.46.46 1.48.62 2.18.62s1.72-.16 2.18-.62a.25.25 0 0 1 .35.35zm-.15-1.71a1 1 0 1 1 2 0 1 1 0 0 1-2 0z"
@@ -1005,7 +1027,11 @@ function RedditCard({
{/* Stats row */}
<div className={previewCss.RedditMeta}>
{upvotes && <span className={previewCss.RedditUpvote}> {upvotes}</span>}
{comments && <span>💬 {comments}</span>}
{comments && (
<span>
<Icon size="Inherit" src={Icons.Message} /> {comments}
</span>
)}
</div>
</Box>
@@ -1473,7 +1499,11 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
justifyContent="Center"
className={previewCss.IconWrapper}
>
<span style={{ fontSize: '2rem', color: '#1db954' }}></span>
<Icon
style={{ fontSize: '2rem', color: BRAND_COLORS.spotify }}
size="Inherit"
src={Icons.VolumeHigh}
/>
</Box>
)}
<UrlPreviewContent>
@@ -1533,7 +1563,11 @@ function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
justifyContent="Center"
className={previewCss.IconWrapper}
>
<span style={{ fontSize: '1.5rem', color: '#c7d5e0' }}></span>
<Icon
style={{ fontSize: '1.5rem', color: BRAND_COLORS.steam }}
size="Inherit"
src={Icons.Setting}
/>
</Box>
)}
<UrlPreviewContent>
@@ -1640,7 +1674,7 @@ function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
width={28}
height={28}
viewBox="0 0 24 24"
fill="#5865f2"
fill={BRAND_COLORS.discord}
aria-hidden="true"
role="img"
>
@@ -1693,13 +1727,13 @@ function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
>
{/* npm logo — simple square mark */}
<svg width={32} height={32} viewBox="0 0 18 7" fill="none" aria-hidden="true" role="img">
<rect width="18" height="7" fill="#cb3837" />
<rect width="18" height="7" fill={BRAND_COLORS.npm} />
<rect x="1" y="1" width="4" height="5" fill="white" />
<rect x="2" y="1" width="1" height="4" fill="#cb3837" />
<rect x="2" y="1" width="1" height="4" fill={BRAND_COLORS.npm} />
<rect x="6" y="1" width="4" height="5" fill="white" />
<rect x="7" y="1" width="1" height="3" fill="#cb3837" />
<rect x="7" y="1" width="1" height="3" fill={BRAND_COLORS.npm} />
<rect x="11" y="1" width="4" height="4" fill="white" />
<rect x="12" y="1" width="1" height="3" fill="#cb3837" />
<rect x="12" y="1" width="1" height="3" fill={BRAND_COLORS.npm} />
</svg>
</Box>
<UrlPreviewContent>
@@ -1747,7 +1781,7 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
width={28}
height={28}
viewBox="0 0 24 24"
fill="#f48024"
fill={BRAND_COLORS.stackOverflow}
aria-hidden="true"
role="img"
>
@@ -38,7 +38,7 @@ import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { VirtualTile } from '../../components/virtualizer';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, sendStateEvent } from '../../utils/room';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -136,9 +136,10 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
await rateLimitedActions(selectedRooms, async (room) => {
const via = getViaServers(room);
await mx.sendStateEvent(
await sendStateEvent(
mx,
parentId,
StateEvent.SpaceChild as any,
StateEvent.SpaceChild,
{
auto_join: false,
suggested: false,
+5 -2
View File
@@ -35,7 +35,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
outlined
disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
aria-pressed={!enabled}
aria-pressed={enabled}
>
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton>
@@ -67,7 +67,8 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
radii="300"
size="300"
onClick={() => onToggle()}
aria-label={enabled ? 'Undeafen' : 'Deafen'}
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined
disabled={disabled}
>
@@ -109,6 +110,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
size="300"
onClick={toggleVideo}
aria-label={enabled ? 'Stop Video' : 'Start Video'}
aria-pressed={enabled}
outlined
disabled={disabled || loading}
>
@@ -147,6 +149,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
size="300"
onClick={onToggle}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
aria-pressed={enabled}
outlined
disabled={disabled}
>
+90 -86
View File
@@ -20,8 +20,8 @@ import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import * as css from './styles.css';
import { stopPropagation } from '../../utils/keyboard';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar';
@@ -45,93 +45,97 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
};
return (
<PopOut
anchor={cords}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
<>
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
{`${count} in call`}
</span>
<PopOut
anchor={cords}
position="Top"
align="Start"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Box grow="Yes">
<Scroll size="0" hideTrack visibility="Hover">
<Box direction="Column" style={{ padding: config.space.S100 }}>
{members.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
<Menu
style={{
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}}
>
<Box grow="Yes">
<Scroll size="0" hideTrack visibility="Hover">
<Box direction="Column" style={{ padding: config.space.S100 }}>
{members.map((callMember) => {
const userId = callMember.sender;
if (!userId) return null;
const name = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
: undefined;
return (
<MenuItem
key={callMember.memberId}
size="400"
variant="Surface"
radii="300"
style={{ paddingLeft: config.space.S200 }}
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right',
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<Chip
variant="Surface"
fill="Soft"
before={<Badge variant="Critical" fill="Solid" size="200" />}
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
radii="Pill"
onClick={handleOpenMenu}
return (
<MenuItem
key={callMember.memberId}
size="400"
variant="Surface"
radii="300"
style={{ paddingLeft: config.space.S200 }}
onClick={(evt) =>
openUserProfile(
room.roomId,
undefined,
userId,
getMouseEventCords(evt.nativeEvent),
'Right',
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={userId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
}
>
<Text size="T300" truncate>
{name}
</Text>
</MenuItem>
);
})}
</Box>
</Scroll>
</Box>
</Menu>
</FocusTrap>
}
>
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>
</PopOut>
<Chip
variant="Surface"
fill="Soft"
before={<Badge variant="Critical" fill="Solid" size="200" />}
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
radii="Pill"
onClick={handleOpenMenu}
>
<Text className={css.LiveChipText} as="span" size="L400" truncate>
{count} Live
</Text>
</Chip>
</PopOut>
</>
);
}
@@ -4,8 +4,8 @@ import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar';
@@ -128,7 +128,7 @@ export function MemberGlance({ room, members, speakers, callEmbed, max = 6 }: Me
{visibleMembers.map((callMember) => {
const { userId } = callMember;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const name = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
@@ -1,17 +1,14 @@
import { Room } from 'matrix-js-sdk';
import React from 'react';
import { Box, Icon, Icons, Text } from 'folds';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMemberName } from '../../utils/room';
type MemberSpeakingProps = {
room: Room;
speakers: Set<string>;
};
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
const speakingNames = Array.from(speakers).map(
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
);
const speakingNames = Array.from(speakers).map((userId) => getMemberName(room, userId));
return (
<Box alignItems="Center" gap="100">
<Icon size="100" src={Icons.Mic} filled />
@@ -5,6 +5,18 @@ export const LiveChipText = style({
color: color.Critical.Main,
});
export const SrOnly = style({
position: 'absolute',
width: 1,
height: 1,
padding: 0,
margin: -1,
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
border: 0,
});
export const CallStatus = style([
{
padding: `${toRem(6)} ${config.space.S200}`,
+3 -3
View File
@@ -5,9 +5,9 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { SequenceCard } from '../../components/sequence-card';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { useRoom } from '../../hooks/useRoom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { getMouseEventCords } from '../../utils/dom';
@@ -26,7 +26,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
const { userId } = member;
if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const name = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
@@ -35,6 +35,7 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { suffixRename } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
@@ -57,7 +58,7 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
display_name: name,
},
};
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
await sendStateEvent(mx, roomId, StateEvent.PoniesRoomEmotes, content, stateKey);
},
[mx, roomId],
),
@@ -164,7 +165,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
for (let i = 0; i < removedPacks.length; i += 1) {
const addr = removedPacks[i];
// eslint-disable-next-line no-await-in-loop
await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
await sendStateEvent(mx, room.roomId, StateEvent.PoniesRoomEmotes, {}, addr.stateKey);
}
}, [mx, room, removedPacks]),
);
@@ -23,6 +23,7 @@ import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent';
@@ -48,7 +49,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
const [enableState, enable] = useAsyncCallback(
useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
await sendStateEvent(mx, room.roomId, StateEvent.RoomEncryption, {
algorithm: ROOM_ENC_ALGO,
});
}, [mx, room.roomId]),
@@ -21,6 +21,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard';
@@ -76,7 +77,7 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp
const content: RoomHistoryVisibilityEventContent = {
history_visibility: visibility,
};
await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
await sendStateEvent(mx, room.roomId, StateEvent.RoomHistoryVisibility, content);
},
[mx, room.roomId],
),
@@ -18,7 +18,7 @@ import { StateEvent } from '../../../../types/matrix/room';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getStateEvents } from '../../../utils/room';
import { getStateEvents, sendStateEvent } from '../../../utils/room';
import {
useRecursiveChildSpaceScopeFactory,
useSpaceChildren,
@@ -111,7 +111,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
join_rule: joinRule as JoinRule,
};
if (allow.length > 0) c.allow = allow;
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
await sendStateEvent(mx, room.roomId, StateEvent.RoomJoinRules, c);
},
[mx, room, space, subspaces, roomIdToParents],
),
@@ -39,6 +39,7 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
@@ -122,7 +123,9 @@ export function RoomProfileEdit({
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => {
setNameValue((prev) => unicode + prev);
// Clamp to the same cap as the input's maxLength — programmatic setState isn't
// constrained by the DOM maxLength, so the picker could otherwise exceed it.
setNameValue((prev) => (unicode + prev).slice(0, 255));
setEmojiAnchor(undefined);
}, []);
@@ -150,16 +153,16 @@ export function RoomProfileEdit({
useCallback(
async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
if (roomAvatarMxc !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
await sendStateEvent(mx, room.roomId, StateEvent.RoomAvatar, {
url: roomAvatarMxc,
});
}
if (roomName !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
await sendStateEvent(mx, room.roomId, StateEvent.RoomName, { name: roomName });
}
if (roomTopic !== undefined) {
const topicContent = buildTopicContent(roomTopic);
await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, topicContent);
await sendStateEvent(mx, room.roomId, StateEvent.RoomTopic, topicContent);
}
},
[mx, room.roomId],
@@ -309,6 +312,7 @@ export function RoomProfileEdit({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
variant="Secondary"
radii="300"
maxLength={255}
readOnly={!canEditName || submitting}
style={{ width: '100%' }}
/>
@@ -7,6 +7,7 @@ import { SettingsSelect } from '../../../components/settings-select/SettingsSele
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -55,7 +56,7 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
},
[mx, room.roomId],
),
@@ -7,6 +7,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
@@ -31,7 +32,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
},
[mx, room.roomId],
),
@@ -7,6 +7,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
@@ -31,7 +32,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
useCallback(
async (value: number) => {
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
await sendStateEvent(mx, room.roomId, StateEvent.LotusVoiceLimit, content);
},
[mx, room.roomId],
),
@@ -15,6 +15,7 @@ import { getPowerLevelTag, getPowers, usePowerLevelTags } from '../../../hooks/u
import { useRoom } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { PowerSwitcher } from '../../../components/power';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
@@ -84,7 +85,7 @@ export function PermissionGroups({
return draftPowerLevels;
});
await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
await sendStateEvent(mx, room.roomId, StateEvent.RoomPowerLevels, editedPowerLevels);
}, [mx, room, powerLevels, permissionUpdate, permissionGroups]),
);
@@ -45,6 +45,7 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { MemberPowerTag, MemberPowerTagIcon, StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
@@ -335,7 +336,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
deleted.forEach((power) => {
delete content[power];
});
await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content);
await sendStateEvent(mx, room.roomId, StateEvent.PowerLevelTags, content);
}, [mx, room, powerLevelTags, editedPowerTags, deleted]),
);
+4 -1
View File
@@ -102,7 +102,9 @@ export function CreateRoomForm({
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => {
setNameValue((prev) => unicode + prev);
// Clamp to the same cap as the input's maxLength — programmatic setState isn't
// constrained by the DOM maxLength, so the picker could otherwise exceed it.
setNameValue((prev) => (unicode + prev).slice(0, 255));
setEmojiAnchor(undefined);
}, []);
@@ -240,6 +242,7 @@ export function CreateRoomForm({
disabled={disabled}
value={nameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
maxLength={255}
style={{ width: '100%' }}
/>
</Box>
+3 -2
View File
@@ -18,6 +18,7 @@ import {
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -48,7 +49,7 @@ function SuggestMenuItem({
const [toggleState, handleToggleSuggested] = useAsyncCallback(
useCallback(() => {
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId);
return sendStateEvent(mx, parentId, StateEvent.SpaceChild, newContent, roomId);
}, [mx, parentId, roomId, content]),
);
@@ -85,7 +86,7 @@ function RemoveMenuItem({
const [removeState, handleRemove] = useAsyncCallback(
useCallback(
() => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId),
() => sendStateEvent(mx, parentId, StateEvent.SpaceChild, {}, roomId),
[mx, parentId, roomId],
),
);
+11 -8
View File
@@ -40,7 +40,8 @@ import { getSpaceRoomPath, withSearchParam } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
import { getStateEvent } from '../../utils/room';
import { getStateEvent, sendStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import {
makeCinnySpacesContent,
@@ -271,9 +272,10 @@ export function Lobby() {
if (reorders) {
await rateLimitedActions(reorders, async (reorder) => {
if (!reorder.item.parentId) return;
await mx.sendStateEvent(
await sendStateEvent(
mx,
reorder.item.parentId,
StateEvent.SpaceChild as any,
StateEvent.SpaceChild,
{ ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId,
);
@@ -298,7 +300,7 @@ export function Lobby() {
// remove from current space
if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
sendStateEvent(mx, item.parentId, StateEvent.SpaceChild, {}, item.roomId);
}
if (
@@ -318,7 +320,7 @@ export function Lobby() {
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
[];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
sendStateEvent(mx, itemRoom.roomId, StateEvent.RoomJoinRules, {
...joinRuleContent,
allow,
});
@@ -358,9 +360,10 @@ export function Lobby() {
if (reorders) {
await rateLimitedActions(reorders, async (reorder) => {
await mx.sendStateEvent(
await sendStateEvent(
mx,
containerParentId,
StateEvent.SpaceChild as any,
StateEvent.SpaceChild,
{ ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId,
);
@@ -427,7 +430,7 @@ export function Lobby() {
newItems.push(rId);
}
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any);
setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, sidebarSpaces],
);
@@ -643,18 +643,7 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
variant={hasRange ? 'Primary' : 'SurfaceVariant'}
radii="Pill"
before={<Icon size="100" src={Icons.RecentClock} />}
after={
hasRange ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onChange(undefined, undefined);
}}
/>
) : undefined
}
after={hasRange ? <Icon size="50" src={Icons.Cross} /> : undefined}
onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
setMenuAnchor(e.currentTarget.getBoundingClientRect())
}
@@ -795,18 +784,7 @@ export function SearchFilters({
radii="Pill"
aria-pressed={!!containsUrl}
before={<Icon size="100" src={Icons.Link} />}
after={
containsUrl ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onContainsUrlChange(undefined);
}}
/>
) : undefined
}
after={containsUrl ? <Icon size="50" src={Icons.Cross} /> : undefined}
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
>
<Text size="T200">Has link</Text>
@@ -821,18 +799,7 @@ export function SearchFilters({
radii="Pill"
aria-pressed={active}
before={<Icon size="100" src={icon} />}
after={
active ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onToggleMsgTypeFilter(msgType);
}}
/>
) : undefined
}
after={active ? <Icon size="50" src={Icons.Cross} /> : undefined}
onClick={() => onToggleMsgTypeFilter(msgType)}
>
<Text size="T200">{label}</Text>
@@ -845,18 +812,7 @@ export function SearchFilters({
radii="Pill"
aria-pressed={pinnedOnly}
before={<Icon size="100" src={Icons.Pin} />}
after={
pinnedOnly ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onTogglePinnedOnly();
}}
/>
) : undefined
}
after={pinnedOnly ? <Icon size="50" src={Icons.Cross} /> : undefined}
onClick={onTogglePinnedOnly}
>
<Text size="T200">Pinned</Text>
@@ -12,7 +12,7 @@ import {
makeMentionCustomProps,
renderMatrixMention,
} from '../../plugins/react-custom-html-parser';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import {
@@ -31,7 +31,7 @@ import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
import { getMemberAvatarMxc, getMemberName, getRoomAvatarUrl } from '../../utils/room';
import { ResultItem } from './useMessageSearch';
import { SequenceCard } from '../../components/sequence-card';
import { UserAvatar } from '../../components/user-avatar';
@@ -220,10 +220,7 @@ export function SearchResultGroup({
{items.map((item) => {
const { event } = item;
const displayName =
getMemberDisplayName(room, event.sender) ??
getMxIdLocalPart(event.sender) ??
event.sender;
const displayName = getMemberName(room, event.sender);
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const relation = event.content['m.relates_to'];
+27 -14
View File
@@ -26,7 +26,7 @@ import {
} from 'folds';
import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday';
@@ -34,6 +34,7 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread';
@@ -68,6 +69,7 @@ import {
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport';
@@ -126,11 +128,9 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const existing = getLocalRoomNamesContent(mx);
if (newName === '') {
const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, {
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
rooms: { ...existing.rooms, [room.roomId]: newName },
});
}
@@ -140,8 +140,7 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const handleClear = useCallback(() => {
const existing = getLocalRoomNamesContent(mx);
const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
onClose();
}, [mx, room.roomId, onClose]);
@@ -324,6 +323,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const canInvite = permissions.action('invite', mx.getSafeUserId());
const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally();
const setToast = useSetAtom(toastQueueAtom);
const [invitePrompt, setInvitePrompt] = useState(false);
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
@@ -332,23 +332,36 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const isFavorite = !!room.tags?.['m.favourite'];
const isLowPriority = !!room.tags?.['m.lowpriority'];
// Surface a room-tag write failure instead of letting the toggle fail silently.
const notifyTagFailure = (err: unknown) => {
console.error('Failed to update room tag:', err);
setToast(
createErrorToast('Could not update this room. Please try again.', Icons.Warning, 'Failed'),
);
};
const handleToggleFavorite = () => {
if (isFavorite) {
mx.deleteRoomTag(room.roomId, 'm.favourite');
mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
} else {
// Favourite and low-priority are mutually exclusive.
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
// Favourite and low-priority are mutually exclusive. Batch both writes so a
// failure surfaces a single toast, not one per operation.
const ops: Promise<unknown>[] = [mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 })];
if (isLowPriority) ops.push(mx.deleteRoomTag(room.roomId, 'm.lowpriority'));
Promise.all(ops).catch(notifyTagFailure);
}
requestClose();
};
const handleToggleLowPriority = () => {
if (isLowPriority) {
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
} else {
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
const ops: Promise<unknown>[] = [
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }),
];
if (isFavorite) ops.push(mx.deleteRoomTag(room.roomId, 'm.favourite'));
Promise.all(ops).catch(notifyTagFailure);
}
requestClose();
};
@@ -6,8 +6,8 @@ import { SequenceCard } from '../../components/sequence-card';
import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -297,8 +297,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
<SectionHeader label="Most Active Members" />
<Box direction="Column" gap="200">
{stats.top5.map(([userId, count], index) => {
const displayName =
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const displayName = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ??
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom';
import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
@@ -250,7 +251,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Save handler
const [saveState, save] = useAsyncCallback(
useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, {
await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, {
allow: allowList,
deny: denyList,
allow_ip_literals: allowIpLiterals,
+3 -7
View File
@@ -42,7 +42,7 @@ import {
} from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce';
import { TypingIndicator } from '../../components/typing-indicator';
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
import { getMemberName, getMemberSearchStr } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { useSetSetting, useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
@@ -134,8 +134,7 @@ function MemberItem({
typing,
showEncryption,
}: MemberItemProps) {
const name =
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const name = getMemberName(room, member.userId);
const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
@@ -418,10 +417,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
Pending Requests
</Text>
{knockMembers.map((knockMember) => {
const knockName =
getMemberDisplayName(room, knockMember.userId) ??
getMxIdLocalPart(knockMember.userId) ??
knockMember.userId;
const knockName = getMemberName(room, knockMember.userId);
const knockAvatarMxc = knockMember.getMxcAvatarUrl();
const knockAvatarUrl = knockAvatarMxc
? mx.mxcUrlToHttp(
+21 -8
View File
@@ -65,7 +65,6 @@ import {
TUploadContent,
encryptFile,
getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp,
tryDeleteMxcContent,
} from '../../utils/matrix';
@@ -111,7 +110,7 @@ import {
getImageMsgContent,
getVideoMsgContent,
} from './msgContent';
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent';
@@ -136,6 +135,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread';
const GifPicker = React.lazy(() =>
@@ -184,6 +184,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
const setToast = useSetAtom(toastQueueAtom);
const alive = useAlive();
// Scope drafts/replies/uploads by thread so a thread composer stays fully
@@ -539,7 +540,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
} else if (commandName) {
const commandContent = commands[commandName as Command];
if (commandContent) {
commandContent.exe(plainText);
// Fire-and-forget by design (the editor resets immediately for UX), but
// surface a rejection instead of failing silently. NOTE: /kick and /ban
// route through rateLimitedActions (utils/matrix.ts), whose to() helper
// swallows non-429 errors, so those two commands can still resolve even
// when the underlying kick/ban failed — this catch only covers errors
// that actually reject out of exe().
commandContent.exe(plainText).catch((err) => {
console.error(`Failed to run /${commandName} command:`, err);
setToast(
createErrorToast(
`The /${commandName} command failed. Please try again.`,
Icons.Warning,
'Command failed',
),
);
});
}
resetEditor(editor);
resetEditorHistory(editor);
@@ -600,6 +616,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setReplyDraft,
isMarkdown,
commands,
setToast,
]);
/**
@@ -975,11 +992,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
userColor={replyUsernameColor}
username={
<Text size="T300" truncate>
<b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
<b>{getMemberName(room, replyDraft.userId)}</b>
</Text>
}
>
+4 -5
View File
@@ -81,6 +81,7 @@ import {
getEventReactions,
getLatestEditableEvt,
getMemberDisplayName,
getMemberName,
getReactionContent,
isMembershipChanged,
reactionOrEditEvent,
@@ -1007,7 +1008,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
console.warn('Button should have "data-user-id" attribute!');
return;
}
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const name = getMemberName(room, userId);
editor.insertNode(
createMentionElement(
userId,
@@ -1106,8 +1107,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderDisplayName = getMemberName(room, senderId);
return (
<Message
@@ -1290,8 +1290,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderDisplayName = getMemberName(room, senderId);
return (
<RenderMessageContent
displayName={senderDisplayName}
+2 -6
View File
@@ -15,8 +15,7 @@ import { Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import FocusTrap from 'focus-trap-react';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import { getMemberName } from '../../utils/room';
import * as css from './RoomViewFollowing.css';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
@@ -39,10 +38,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
const names = latestEventReaders
.filter((readerId) => readerId !== mx.getUserId())
.map(
(readerId) =>
getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId,
);
.map((readerId) => getMemberName(room, readerId));
const eventId = latestEvent?.getId();
+13 -9
View File
@@ -53,9 +53,10 @@ import {
canEditEvent,
getEventEdits,
getMemberAvatarMxc,
getMemberDisplayName,
getMemberName,
sendStateEvent,
} from '../../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -104,15 +105,19 @@ function DeliveryStatus({
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
iconSrc = Icons.Cross;
label = 'Failed to send';
colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
} else if (status === EventStatus.QUEUED || isSending) {
iconSrc = Icons.Send;
label = isSending ? 'Sending...' : 'Queued';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.60)' : color.Secondary.Main;
colorStyle = lotusTerminal
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
: color.Secondary.Main;
} else {
iconSrc = Icons.Check;
label = 'Sent';
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.70)' : color.Secondary.Main;
colorStyle = lotusTerminal
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
: color.Secondary.Main;
}
return (
<Box
@@ -128,7 +133,7 @@ function DeliveryStatus({
opacity: 0.85,
userSelect: 'none',
...(lotusTerminal && status === EventStatus.NOT_SENT
? { textShadow: '0 0 6px #FF3B3B' }
? { textShadow: 'var(--lt-glow-red)' }
: {}),
}}
>
@@ -424,7 +429,7 @@ export const MessagePinItem = as<
if (!isPinned && eventId) {
pinContent.pinned.push(eventId);
}
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent);
sendStateEvent(mx, room.roomId, StateEvent.RoomPinnedEvents, pinContent);
onClose?.();
};
@@ -805,8 +810,7 @@ export const Message = React.memo(
const [remindOpen, setRemindOpen] = useState(false);
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderDisplayName = getMemberName(room, senderId);
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const tagColor = memberPowerTag?.color
@@ -53,11 +53,10 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
getEditedEvent,
getMemberDisplayName,
getMemberName,
getMentionContent,
trimReplyFromFormattedBody,
} from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
@@ -75,9 +74,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
// Accessible name for the edit textbox so screen readers announce which
// message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender();
const editSenderName = editSenderId
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
: '';
const editSenderName = editSenderId ? getMemberName(room, editSenderId) : '';
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -16,7 +16,7 @@ import {
} from 'folds';
import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations';
import { getMemberDisplayName } from '../../../utils/room';
import { getMemberName } from '../../../utils/room';
import { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
import * as css from './ReactionViewer.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -67,8 +67,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
}
};
const getName = (member: RoomMember) =>
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const getName = (member: RoomMember) => getMemberName(room, member.userId);
const getReactionsForKey = (key: string): MatrixEvent[] => {
const reactSet = reactions.find(([k]) => k === key)?.[1];
@@ -40,13 +40,14 @@ import {
UsernameBold,
} from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import {
getEditedEvent,
getMemberAvatarMxc,
getMemberDisplayName,
getMemberName,
getStateEvent,
sendStateEvent,
} from '../../../utils/room';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
@@ -122,7 +123,7 @@ function PinnedMessage({
pinned: content.pinned.filter((id) => id !== eventId),
};
return mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, newContent);
return sendStateEvent(mx, room.roomId, StateEvent.RoomPinnedEvents, newContent);
}, [room, eventId, mx]),
);
@@ -175,7 +176,7 @@ function PinnedMessage({
);
const sender = pinnedEvent.getSender()!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender;
const displayName = getMemberName(room, sender);
const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback;
@@ -32,7 +32,7 @@ import { useAtomValue, useSetAtom } from 'jotai';
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs';
import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../../utils/matrix';
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive';
@@ -58,7 +58,7 @@ import {
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getMemberDisplayName,
getMemberName,
getReactionContent,
reactionOrEditEvent,
} from '../../../utils/room';
@@ -503,7 +503,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const name = getMemberName(room, userId);
editor.insertNode(
createMentionElement(
userId,
@@ -651,8 +651,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderDisplayName = getMemberName(room, senderId);
return (
<RenderMessageContent
displayName={senderDisplayName}
@@ -26,6 +26,7 @@ import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
const urlErrorMessage = (err: WidgetUrlError): string => {
@@ -84,7 +85,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
};
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.Widget as any, content as any, id);
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
setAdding(false);
} catch (e) {
setError((e as Error).message);
@@ -96,7 +97,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const handleRemove = (id: string) => {
if (viewingId === id) setViewingId(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.sendStateEvent(room.roomId, StateEvent.Widget as any, {} as any, id).catch(() => undefined);
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
};
return (
+23 -10
View File
@@ -35,6 +35,7 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
@@ -52,7 +53,7 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
@@ -285,6 +286,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
onChange={handleChange}
variant="Secondary"
radii="300"
maxLength={255}
style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname}
after={
@@ -364,13 +366,26 @@ function ProfileStatus() {
// Sync input when another device changes the status.
// Skipped while the user has unsaved local edits to avoid clobbering
// mid-flight input (e.g. an emoji being inserted).
// mid-flight input (e.g. an emoji being inserted), and while presence data has
// not loaded yet (presence === undefined is "no info", NOT a clear).
useEffect(() => {
if (!statusDirtyRef.current && presence?.status) {
setStatusMsg(presence.status);
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status);
if (statusDirtyRef.current || !presence) return;
// An offline/invisible presence carries an empty status_msg by design (see
// usePresenceUpdater.setOffline), so ignore it — reading it as a clear would
// wipe the saved status on every invisible toggle.
if (presence.presence === Presence.Offline) return;
const remoteStatus = presence.status ?? '';
if (remoteStatus) {
setStatusMsg(remoteStatus);
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
} else {
// Another device CLEARED the status. Mirror it locally AND drop the stored
// value, otherwise usePresenceUpdater.readStatus() re-sends the stale
// message on the next presence heartbeat.
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
}, [presence?.status, userId]);
}, [presence, userId]);
const [saveState, saveStatus] = useAsyncCallback(
useCallback(
@@ -711,9 +726,7 @@ function ProfileTimezone() {
const [savedTimezone, setSavedTimezone] = useState<string>('');
useEffect(() => {
const cached = mx
.getAccountData('im.lotus.timezone' as any)
?.getContent<{ timezone: string }>();
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
if (cached?.timezone) {
setTimezone(cached.timezone);
setSavedTimezone(cached.timezone);
@@ -740,7 +753,7 @@ function ProfileTimezone() {
Promise.all([
// Self-fallback: account data is readable by useExtendedProfile for the
// own user even on servers without extended-profile (m.tz) support.
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }),
setAccountData(mx, 'im.lotus.timezone', { timezone: value }),
// Mirror the pronouns write path so OTHER users can read the timezone
// via the m.tz profile field. Best-effort: standard Synapse rejects
// unknown profile fields, so a failure here must not fail the save.
@@ -12,6 +12,7 @@ import {
AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { AccountData } from './AccountData';
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
@@ -26,7 +27,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
await (mx as any).setAccountData(type, content);
await setAccountData(mx, type, content);
},
[mx],
);
@@ -36,7 +37,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
<AccountDataEditor
type={accountDataType ?? undefined}
content={
accountDataType ? (mx as any).getAccountData(accountDataType)?.getContent() : undefined
accountDataType ? getAccountData<object>(mx, accountDataType) : undefined
}
submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)}
@@ -1,4 +1,4 @@
import { MatrixEvent, Room } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import {
Box,
@@ -41,6 +41,7 @@ import {
import { LineClamp2 } from '../../../styles/Text.css';
import { allRoomsAtom } from '../../../state/room-list/roomList';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard';
@@ -303,9 +304,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => {
const content =
(
(mx as any).getAccountData(AccountDataEvent.PoniesEmoteRooms) as MatrixEvent | undefined
)?.getContent<EmoteRoomsContent>() ?? {};
getAccountData<EmoteRoomsContent>(mx, AccountDataEvent.PoniesEmoteRooms) ?? {};
const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
selectedPacks.forEach((addr) => {
@@ -322,7 +321,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
}
});
await (mx as any).setAccountData(AccountDataEvent.PoniesEmoteRooms, updatedContent);
await setAccountData(mx, AccountDataEvent.PoniesEmoteRooms, updatedContent);
}, [mx, selectedPacks, removedPacks]),
);
+130
View File
@@ -0,0 +1,130 @@
import { useEffect, useState } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { getAccountData, setAccountData } from '../utils/accountData';
// Generic, concurrency-safe store for a single global account-data event that
// holds a mutable collection (a list or a record). It is the shared engine
// behind useBookmarks / useReminders / useUserNotes.
//
// Why a module-scoped singleton (per store, per client):
//
// These hooks are mounted by many components at once (e.g. useBookmarks() lives
// on every message row). A per-instance snapshot + write queue would only
// serialize writes within a single instance, so two edits issued from different
// instances before the server echo lands would each compute from a stale
// snapshot and clobber each other — setAccountData replaces the whole content
// with no server-side merge. We therefore keep ONE shared `latest` snapshot and
// ONE write queue per store, keyed off the active MatrixClient, and serialize
// every write through that queue (the lost-update fix). Do not weaken this.
// How the differing payload shapes are parameterized:
// - `read` maps the raw account-data content (C | undefined) to the in-memory
// value T (e.g. `content?.bookmarks ?? []`, or `content ?? {}` for notes).
// - `write` maps the in-memory value T back to the content object to persist
// (e.g. `{ bookmarks: value }`, or just `value` for the flat notes record).
export type AccountDataListStoreConfig<T, C> = {
eventType: AccountDataEvent | string;
read: (content: C | undefined) => T;
write: (value: T) => object;
};
export type AccountDataListStore<T> = {
/** React hook: subscribe to the store and return the latest value. */
useValue: (mx: MatrixClient) => T;
/** Read the current shared snapshot without subscribing. */
getLatest: (mx: MatrixClient) => T;
/**
* Serialize a write. `compute` receives the latest snapshot and returns the
* next value; writes run one at a time so none is computed from stale data.
*/
enqueueWrite: (mx: MatrixClient, compute: (current: T) => T) => Promise<void>;
};
export function createAccountDataListStore<T, C = unknown>({
eventType,
read,
write,
}: AccountDataListStoreConfig<T, C>): AccountDataListStore<T> {
type ModuleState = {
mx: MatrixClient;
latest: T;
writeQueue: Promise<unknown>;
listeners: Set<(value: T) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ModuleState | null = null;
const readLatest = (mx: MatrixClient): T => read(getAccountData<C>(mx, eventType));
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): ModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ModuleState = {
mx,
latest: readLatest(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === eventType) {
const value = read(evt.getContent() as C);
state.latest = value;
state.listeners.forEach((listener) => listener(value));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueWrite(mx: MatrixClient, compute: (current: T) => T): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await setAccountData(mx, eventType, write(next));
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
function useValue(mx: MatrixClient): T {
const [value, setValue] = useState<T>(() => ensureModuleState(mx).latest);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => {
const state = ensureModuleState(mx);
setValue(state.latest);
state.listeners.add(setValue);
return () => {
state.listeners.delete(setValue);
};
}, [mx]);
return value;
}
const getLatest = (mx: MatrixClient): T => ensureModuleState(mx).latest;
return { useValue, getLatest, enqueueWrite };
}
+3 -2
View File
@@ -2,11 +2,12 @@ import { MatrixEvent } from 'matrix-js-sdk';
import { useState, useCallback } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
import { getAccountData } from '../utils/room';
export function useAccountData(eventType: string): MatrixEvent | undefined {
const mx = useMatrixClient();
const [event, setEvent] = useState<MatrixEvent | undefined>(
() => (mx as any).getAccountData(eventType) as MatrixEvent | undefined,
const [event, setEvent] = useState<MatrixEvent | undefined>(() =>
getAccountData(mx, eventType),
);
useAccountDataCallback(
+13 -92
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
export type Bookmark = {
roomId: string;
@@ -17,81 +17,14 @@ type BookmarksContent = {
bookmarks: Bookmark[];
};
function readBookmarks(mx: MatrixClient): Bookmark[] {
return (
(mx.getAccountData(BOOKMARKS_KEY as any)?.getContent() as BookmarksContent | undefined)
?.bookmarks ?? []
);
}
// Module-scoped serialization state.
//
// useBookmarks() is mounted once per message row (dozens of live instances), so
// a per-instance latest/queue would only serialize writes within a single row —
// bookmarking message A then message B from different rows (before the server
// echo lands) would let B compute from a stale snapshot and clobber A
// (setAccountData replaces the whole content, no server merge). We therefore
// keep a single shared latest ref + write queue, keyed off the active client.
type BookmarksModuleState = {
mx: MatrixClient;
latest: Bookmark[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Bookmark[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: BookmarksModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): BookmarksModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: BookmarksModuleState = {
mx,
latest: readBookmarks(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === BOOKMARKS_KEY) {
const list = evt.getContent<BookmarksContent>()?.bookmarks ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueBookmarkWrite(
mx: MatrixClient,
compute: (current: Bookmark[]) => Bookmark[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge).
const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
eventType: BOOKMARKS_KEY,
read: (content) => content?.bookmarks ?? [],
write: (bookmarks) => ({ bookmarks }),
});
export function useBookmarks(): {
bookmarks: Bookmark[];
@@ -100,23 +33,11 @@ export function useBookmarks(): {
isBookmarked: (eventId: string) => boolean;
} {
const mx = useMatrixClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => {
const state = ensureModuleState(mx);
setBookmarks(state.latest);
state.listeners.add(setBookmarks);
return () => {
state.listeners.delete(setBookmarks);
};
}, [mx]);
const bookmarks = bookmarksStore.useValue(mx);
const addBookmark = useCallback(
(b: Bookmark) =>
enqueueBookmarkWrite(mx, (current) => {
bookmarksStore.enqueueWrite(mx, (current) => {
// Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [b, ...filtered];
@@ -130,7 +51,7 @@ export function useBookmarks(): {
const removeBookmark = useCallback(
(eventId: string) =>
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
bookmarksStore.enqueueWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
[mx],
);
+16 -8
View File
@@ -24,7 +24,7 @@ import {
} from '../utils/matrix';
import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { getStateEvent, sendStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common';
import { createRoomEncryptionState } from '../components/create-room';
@@ -291,7 +291,11 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
});
}
rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
// NOTE: rateLimitedActions' to() helper (utils/matrix.ts) swallows every
// non-429 error, so a failed kick (e.g. insufficient power level) resolves
// silently here — the RoomInput exe() catch cannot surface it. Propagating
// would require refactoring rateLimitedActions' shared error handling.
await rateLimitedActions(users, (id) => mx.kick(room.roomId, id, reason));
},
},
[Command.Ban]: {
@@ -313,7 +317,9 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
});
}
rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
// See the /kick note: rateLimitedActions swallows non-429 errors, so a
// failed ban resolves silently and can't be surfaced by the exe() catch.
await rateLimitedActions(users, (id) => mx.ban(room.roomId, id, reason));
},
},
[Command.UnBan]: {
@@ -362,9 +368,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId());
const content = mEvent?.getContent();
if (!content) return;
await mx.sendStateEvent(
await sendStateEvent(
mx,
room.roomId,
StateEvent.RoomMember as any,
StateEvent.RoomMember,
{
...content,
displayname: nick,
@@ -384,9 +391,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId());
const content = mEvent?.getContent();
if (!content) return;
await mx.sendStateEvent(
await sendStateEvent(
mx,
room.roomId,
StateEvent.RoomMember as any,
StateEvent.RoomMember,
{
...content,
avatar_url: payload,
@@ -526,7 +534,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
aclContent.allow?.sort();
aclContent.deny?.sort();
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, aclContent);
await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, aclContent);
},
},
}),
+56
View File
@@ -0,0 +1,56 @@
import { useEffect } from 'react';
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useMediaAuthentication } from './useMediaAuthentication';
import { useForceUpdate } from './useForceUpdate';
import { getMemberName } from '../utils/room';
import { mxcUrlToHttp } from '../utils/matrix';
export type MemberAvatar = {
name: string;
avatarUrl: string | undefined;
};
/**
* Resolve a room member's display name and avatar http url, staying reactive to
* that member's profile (name/avatar/membership) changes.
*
* Mirrors the reactivity pattern in ReadReceiptAvatars (N6): subscribe to
* `RoomStateEvent.Members` filtered to this room + userId and force a re-render,
* since `RoomMemberEvent` has no dedicated Avatar signal.
*/
export const useMemberAvatar = (
room: Room,
userId: string,
width = 32,
height = 32,
resizeMethod = 'crop',
): MemberAvatar => {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [, forceUpdate] = useForceUpdate();
useEffect(() => {
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
event,
_state,
member,
) => {
if (event.getRoomId() === room.roomId && member.userId === userId) {
forceUpdate();
}
};
mx.on(RoomStateEvent.Members, handleMembers);
return () => {
mx.removeListener(RoomStateEvent.Members, handleMembers);
};
}, [mx, room.roomId, userId, forceUpdate]);
const name = getMemberName(room, userId);
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, width, height, resizeMethod) ?? undefined)
: undefined;
return { name, avatarUrl };
};
+14 -93
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
export type Reminder = {
roomId: string;
@@ -15,81 +15,14 @@ type RemindersContent = {
reminders: Reminder[];
};
function readReminders(mx: MatrixClient): Reminder[] {
return (
(mx.getAccountData(REMINDERS_KEY as any)?.getContent() as RemindersContent | undefined)
?.reminders ?? []
);
}
// Module-scoped serialization state.
//
// The latest snapshot and the write queue must be shared across every hook
// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog
// (adds reminders) mount separate hooks, and a per-instance queue would let a
// remove and an add race across instances and clobber each other (setAccountData
// replaces the whole content, no server merge). We therefore keep a single
// shared queue + latest ref, keyed off the active MatrixClient.
type ReminderModuleState = {
mx: MatrixClient;
latest: Reminder[];
writeQueue: Promise<unknown>;
listeners: Set<(list: Reminder[]) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: ReminderModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): ReminderModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: ReminderModuleState = {
mx,
latest: readReminders(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === REMINDERS_KEY) {
const list = evt.getContent<RemindersContent>()?.reminders ?? [];
state.latest = list;
state.listeners.forEach((listener) => listener(list));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueReminderWrite(
mx: MatrixClient,
compute: (current: Reminder[]) => Reminder[],
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge).
const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>({
eventType: REMINDERS_KEY,
read: (content) => content?.reminders ?? [],
write: (reminders) => ({ reminders }),
});
export function useReminders(): {
reminders: Reminder[];
@@ -98,34 +31,22 @@ export function useReminders(): {
getReminders: () => Reminder[];
} {
const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => {
const state = ensureModuleState(mx);
setReminders(state.latest);
state.listeners.add(setReminders);
return () => {
state.listeners.delete(setReminders);
};
}, [mx]);
const reminders = remindersStore.useValue(mx);
const addReminder = useCallback(
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
(r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
[mx],
);
const removeReminder = useCallback(
(eventId: string, timestamp: number) =>
enqueueReminderWrite(mx, (current) =>
remindersStore.enqueueWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
),
[mx],
);
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]);
const getReminders = useCallback(() => remindersStore.getLatest(mx), [mx]);
return { reminders, addReminder, removeReminder, getReminders };
}
+4 -4
View File
@@ -6,7 +6,7 @@ import { useMatrixClient } from './useMatrixClient';
import { useAlive } from './useAlive';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room';
import { getStateEvent, sendStateEvent } from '../utils/room';
export const usePublishedAliases = (room: Room): [string | undefined, string[]] => {
const aliasContent = useStateEvent(
@@ -56,7 +56,7 @@ export const useSetMainAlias = (room: Room): ((alias: string | undefined) => Pro
alt_aliases: altAliases,
};
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
await sendStateEvent(mx, room.roomId, StateEvent.RoomCanonicalAlias, newContent);
},
[mx, room],
);
@@ -90,7 +90,7 @@ export const usePublishUnpublishAliases = (
alt_aliases: altAliases,
};
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
await sendStateEvent(mx, room.roomId, StateEvent.RoomCanonicalAlias, newContent);
},
[mx, room],
);
@@ -114,7 +114,7 @@ export const usePublishUnpublishAliases = (
alt_aliases: altAliases,
};
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent);
await sendStateEvent(mx, room.roomId, StateEvent.RoomCanonicalAlias, newContent);
},
[mx, room],
);
+2 -3
View File
@@ -4,6 +4,7 @@ import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from '
import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient';
import { getAccountData } from '../utils/accountData';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -42,9 +43,7 @@ export type LocalRoomNamesContent = { rooms: Record<string, string> };
export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent {
// Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
if (
raw &&
typeof raw === 'object' &&
+19 -1
View File
@@ -1,5 +1,5 @@
import { Room } from 'matrix-js-sdk';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room';
import {
@@ -79,6 +79,13 @@ export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardP
const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey));
// Resync when the room/stateKey arg changes — the useState initializer only
// runs on mount, so without this a re-target (e.g. a different room) kept
// showing the first room's pack.
useEffect(() => {
setRoomPack(getRoomSoundboardPack(room, stateKey));
}, [room, stateKey]);
useStateEventCallback(
mx,
useCallback(
@@ -102,6 +109,11 @@ export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
// Resync on room change (see useRoomSoundboardPack).
useEffect(() => {
setRoomPacks(getRoomSoundboardPacks(room));
}, [room]);
useStateEventCallback(
mx,
useCallback(
@@ -124,6 +136,12 @@ export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks));
// Resync when the room list changes (callers pass a memoized array, so this
// fires only on an actual change — see useRoomSoundboardPack).
useEffect(() => {
setRoomPacks(rooms.flatMap(getRoomSoundboardPacks));
}, [rooms]);
useStateEventCallback(
mx,
useCallback(
+3 -4
View File
@@ -12,6 +12,7 @@ import {
} from '../utils/threadNotifications';
import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallback } from './useAsyncCallback';
import { getAccountData, setAccountData } from '../utils/accountData';
/** Read the current notification mode for a thread from the bound atom. */
export function useThreadNotificationMode(
@@ -23,9 +24,7 @@ export function useThreadNotificationMode(
}
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
| ThreadNotificationsContent
| undefined) ?? {};
getAccountData<ThreadNotificationsContent>(mx, AccountDataEvent.LotusThreadNotifications) ?? {};
const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
const joined = new Set<string>();
@@ -74,7 +73,7 @@ const writeThreadNotificationMode = async (
// ALWAYS prune before persisting to keep account data bounded.
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
await (mx as any).setAccountData(AccountDataEvent.LotusThreadNotifications, finalContent);
await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent);
};
export function useSetThreadNotificationMode(
+14 -87
View File
@@ -1,110 +1,37 @@
import { useCallback, useEffect, useState } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useCallback } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
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) ?? {};
}
// Module-scoped serialization state.
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge).
//
// useUserNotes() can be mounted by many components at once, so a per-instance
// latest/queue would only serialize writes within one instance. Notes for
// different users saved from different instances (before the server echo lands)
// would each compute from a stale snapshot and clobber each other, since
// setAccountData replaces the whole record with no server merge. We therefore
// keep a single shared latest record + write queue, keyed off the active client.
type UserNotesModuleState = {
mx: MatrixClient;
latest: UserNotesContent;
writeQueue: Promise<unknown>;
listeners: Set<(record: UserNotesContent) => void>;
onAccountData: ClientEventHandlerMap[ClientEvent.AccountData];
};
let moduleState: UserNotesModuleState | null = null;
// Lazily initialize the shared state for the given client. On a client change
// (login/logout swaps the MatrixClient) we tear down the old subscription and
// re-initialize against the new client so we never leak or double-subscribe.
function ensureModuleState(mx: MatrixClient): UserNotesModuleState {
if (moduleState && moduleState.mx === mx) {
return moduleState;
}
if (moduleState) {
moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData);
}
const state: UserNotesModuleState = {
mx,
latest: readNotes(mx),
writeQueue: Promise.resolve(),
listeners: new Set(),
// Reassigned below once `state` is captured.
onAccountData: () => undefined,
};
state.onAccountData = (evt) => {
if (evt.getType() === NOTES_KEY) {
const record = evt.getContent<UserNotesContent>() ?? {};
state.latest = record;
state.listeners.forEach((listener) => listener(record));
}
};
mx.on(ClientEvent.AccountData, state.onAccountData);
moduleState = state;
return state;
}
function enqueueNotesWrite(
mx: MatrixClient,
compute: (current: UserNotesContent) => UserNotesContent,
): Promise<void> {
const state = ensureModuleState(mx);
const run = state.writeQueue.then(async () => {
const next = compute(state.latest);
state.latest = next;
state.listeners.forEach((listener) => listener(next));
await (mx as any).setAccountData(NOTES_KEY, next);
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
state.writeQueue = run.catch(() => undefined);
return run;
}
// Notes are a flat Record<userId, note> rather than a wrapped list, so `read`
// and `write` pass the content through unchanged.
const notesStore = createAccountDataListStore<UserNotesContent, UserNotesContent>({
eventType: NOTES_KEY,
read: (content) => content ?? {},
write: (notes) => notes,
});
export function useUserNotes(): {
getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
// Subscribe to the shared module state. A single AccountData listener is
// installed per client (in ensureModuleState); each hook instance only
// registers a local setter and unregisters it on unmount / client change.
useEffect(() => {
const state = ensureModuleState(mx);
setNotes(state.latest);
state.listeners.add(setNotes);
return () => {
state.listeners.delete(setNotes);
};
}, [mx]);
const notes = notesStore.useValue(mx);
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback(
(userId: string, note: string) => {
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
return enqueueNotesWrite(mx, (current) => {
return notesStore.enqueueWrite(mx, (current) => {
const updated = { ...current };
if (trimmed) {
updated[userId] = trimmed;
+67 -20
View File
@@ -2,10 +2,13 @@ import { useAtomValue, useSetAtom } from 'jotai';
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ClientEvent,
ClientEventHandlerMap,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
SyncState,
Thread,
ThreadEvent,
} from 'matrix-js-sdk';
@@ -22,18 +25,21 @@ import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { usePreviousValue } from '../../hooks/usePreviousValue';
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberDisplayName,
getMemberName,
getNotificationType,
getUnreadInfo,
isNotificationEvent,
} from '../../utils/room';
import { NotificationType } from '../../../types/matrix/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { mxcUrlToHttp } from '../../utils/matrix';
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -134,8 +140,15 @@ function FaviconUpdater() {
function InviteNotifications() {
const audioRef = useRef<HTMLAudioElement>(null);
const invites = useAtomValue(allInvitesAtom);
const perviousInviteLen = usePreviousValue(invites.length, 0);
const mx = useMatrixClient();
// Notify only for invites that ARRIVE while the app runs — never for invites
// already present at load. allInvitesAtom (a string[] of invited room ids)
// populates asynchronously post-mount from the initial/cached sync, so any count/
// seed baseline mis-fires on reload. Stay "unarmed" until the initial sync settles
// (keeping the id baseline synced to whatever loads), then notify only for room
// ids that first appear AFTER arming.
const armedRef = useRef(false);
const knownInviteIdsRef = useRef<Set<string>>(new Set());
const navigate = useNavigate();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
@@ -199,26 +212,60 @@ function InviteNotifications() {
audioElement?.play();
}, []);
// Arm once the client's initial sync has settled (+ a short grace so the async
// atom population lands first). Until armed, the effect below only tracks the
// baseline of already-present invites without notifying.
useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') {
const quietActive =
focusAssistActive ||
manualDnd ||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (!quietActive) {
if (showNotifications && notificationPermission('granted')) {
notify(invites.length - perviousInviteLen);
}
if (notificationSound && inviteSoundId !== 'none') {
playSound();
}
let timer: ReturnType<typeof setTimeout> | undefined;
const arm = () => {
timer = setTimeout(() => {
armedRef.current = true;
}, INVITE_NOTIFY_ARM_DELAY_MS);
};
if (mx.getSyncState() === SyncState.Syncing) {
arm();
return () => {
if (timer) clearTimeout(timer);
};
}
const onSync: ClientEventHandlerMap[ClientEvent.Sync] = (state) => {
if (state === SyncState.Syncing) {
mx.off(ClientEvent.Sync, onSync);
arm();
}
};
mx.on(ClientEvent.Sync, onSync);
return () => {
mx.off(ClientEvent.Sync, onSync);
if (timer) clearTimeout(timer);
};
}, [mx]);
useEffect(() => {
const currentIds = new Set(invites);
if (!armedRef.current) {
// Not yet armed: keep the baseline synced to whatever the initial sync loads.
knownInviteIdsRef.current = currentIds;
return;
}
const newCount = invites.filter((id) => !knownInviteIdsRef.current.has(id)).length;
knownInviteIdsRef.current = currentIds;
if (newCount <= 0) return;
const quietActive =
focusAssistActive ||
manualDnd ||
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd));
if (quietActive) return;
if (showNotifications && notificationPermission('granted')) {
notify(newCount);
}
if (notificationSound && inviteSoundId !== 'none') {
playSound();
}
}, [
mx,
invites,
perviousInviteLen,
showNotifications,
notificationSound,
notify,
@@ -496,7 +543,7 @@ function MessageNotifications() {
roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender,
username: getMemberName(room, sender),
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
+2 -5
View File
@@ -37,7 +37,7 @@ import {
bannedInRooms,
getCommonRooms,
getDirectRoomAvatarUrl,
getMemberDisplayName,
getMemberName,
getRoomAvatarUrl,
getStateEvent,
isDirectInvite,
@@ -48,7 +48,6 @@ import { RoomAvatar } from '../../../components/room-avatar';
import {
addRoomIdToMDirect,
declineInvite,
getMxIdLocalPart,
guessDmRoomUserId,
rateLimitedActions,
} from '../../../utils/matrix';
@@ -107,9 +106,7 @@ const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean
const content = memberEvent?.getContent();
const senderId = memberEvent?.getSender();
const senderName = senderId
? (getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId)
: undefined;
const senderName = senderId ? getMemberName(room, senderId) : undefined;
const inviteTs = memberEvent?.getTs();
const reason =
content && 'reason' in content && typeof content.reason === 'string'
+3 -6
View File
@@ -28,7 +28,7 @@ import { Opts as LinkifyOpts } from 'linkifyjs';
import { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { mxcUrlToHttp } from '../../../utils/matrix';
import { InboxNotificationsPathSearchParams } from '../../paths';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card';
@@ -36,7 +36,7 @@ import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import {
getEditedEvent,
getMemberAvatarMxc,
getMemberDisplayName,
getMemberName,
getRoomAvatarUrl,
} from '../../../utils/room';
import { ScrollTopContainer } from '../../../components/scroll-top-container';
@@ -448,10 +448,7 @@ function RoomNotificationsGroupComp({
{notifications.map((notification) => {
const { event } = notification;
const displayName =
getMemberDisplayName(room, event.sender) ??
getMxIdLocalPart(event.sender) ??
event.sender;
const displayName = getMemberName(room, event.sender);
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback;
+3 -2
View File
@@ -74,6 +74,7 @@ import {
useSidebarItems,
} from '../../../hooks/useSidebarItems';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { setAccountData } from '../../../utils/accountData';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
@@ -752,7 +753,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
(mx as any).setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces],
),
@@ -798,7 +799,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
(mx as any).setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent);
setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
},
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem],
);
+2 -1
View File
@@ -1,5 +1,6 @@
import { MatrixClient } from 'matrix-js-sdk';
import { getAccountData } from '../utils/room';
import { setAccountData } from '../utils/accountData';
import { IEmoji, emojis } from './emoji';
import { AccountDataEvent } from '../../types/matrix/accountData';
@@ -39,7 +40,7 @@ export function addRecentEmoji(mx: MatrixClient, unicode: string) {
entry[1] += 1;
}
recentEmoji.unshift(entry);
(mx as any).setAccountData(AccountDataEvent.ElementRecentEmoji, {
setAccountData(mx, AccountDataEvent.ElementRecentEmoji, {
recent_emoji: recentEmoji.slice(0, 100),
});
}
+2 -3
View File
@@ -3,15 +3,14 @@ import { ClientEvent, MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData';
import { ThreadNotificationsContent } from '../utils/threadNotifications';
import { getAccountData } from '../utils/accountData';
// Holds the parsed `io.lotus.thread_notifications` account data. Seeded and
// kept in sync by `useBindThreadNotificationsAtom`.
export const threadNotificationsAtom = atom<ThreadNotificationsContent>({});
const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as
| ThreadNotificationsContent
| undefined) ?? {};
getAccountData<ThreadNotificationsContent>(mx, AccountDataEvent.LotusThreadNotifications) ?? {};
export const useBindThreadNotificationsAtom = (
mx: MatrixClient,
+17
View File
@@ -27,6 +27,23 @@ export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastN
onClick: () => undefined,
});
// Build an error/system toast (e.g. a failed command or account action). Mirrors
// createDownloadToast: folds-free (icon src passed in), empty roomId + no-op
// onClick so a click only dismisses (never navigates to a room).
export const createErrorToast = (
body: string,
iconSrc?: IconSrc,
displayName = 'Something went wrong',
): ToastNotif => ({
id: `error-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
displayName,
body,
roomName: '',
roomId: '',
iconSrc,
onClick: () => undefined,
});
const baseAtom = atom<ToastNotif[]>([]);
// Write-only setter used in ClientNonUIFeatures
+28
View File
@@ -0,0 +1,28 @@
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../types/matrix/accountData';
// Typed global account-data read/write helpers.
//
// matrix-js-sdk's `getAccountData`/`setAccountData` overloads only accept the
// SDK's built-in AccountDataEvents union and reject the fork's custom event
// names (the `io.lotus.*` / `in.cinny.*` values in the AccountDataEvent enum, as
// well as a few dynamic keys used by developer tools). We centralize the single
// `as any` cast here so every call site stays fully typed on its content shape.
export function getAccountData<T>(
mx: MatrixClient,
eventType: AccountDataEvent | string,
): T | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const event = (mx as any).getAccountData(eventType) as MatrixEvent | undefined;
return event?.getContent() as T | undefined;
}
export function setAccountData<T>(
mx: MatrixClient,
eventType: AccountDataEvent | string,
content: T,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (mx as any).setAccountData(eventType, content);
}
+24 -1
View File
@@ -8,6 +8,7 @@ import {
IPowerLevelsContent,
IPushRule,
IPushRules,
ISendEventResponse,
JoinRule,
MatrixClient,
MatrixEvent,
@@ -30,6 +31,7 @@ import {
UnreadInfo,
} from '../../types/matrix/room';
import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications';
import { getMxIdLocalPart } from './matrix';
export const getStateEvent = (
room: Room,
@@ -42,9 +44,27 @@ export const getStateEvent = (
export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] =>
room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(eventType) ?? [];
// Typed state-event write helper.
//
// matrix-js-sdk's `sendStateEvent` typed overload rejects the fork's custom
// `StateEvent` enum values (the `io.lotus.*` / `im.ponies.*` / etc. values in
// `types/matrix/room.ts`), and casting the event type to `any` at each call site
// also collapses the `content` argument to `any`. We centralize the single
// `as any` cast here so every call site keeps its own `content` type checked.
export function sendStateEvent<T extends object>(
mx: MatrixClient,
roomId: string,
eventType: StateEvent,
content: T,
stateKey = '',
): Promise<ISendEventResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendStateEvent(roomId, eventType as any, content, stateKey);
}
export const getAccountData = (
mx: MatrixClient,
eventType: AccountDataEvent,
eventType: AccountDataEvent | string,
): MatrixEvent | undefined => mx.getAccountData(eventType as any);
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
@@ -405,6 +425,9 @@ export const getMemberDisplayName = (room: Room, userId: string): string | undef
return name;
};
export const getMemberName = (room: Room, userId: string): string =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
export const getMemberSearchStr = (
member: RoomMember,
query: string,