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 ## 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. 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 ## 🔴 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) ### ✅ 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). 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 { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/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 { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels'; import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -161,9 +161,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
<Dialog style={{ maxWidth: toRem(324) }}> <Dialog style={{ maxWidth: toRem(324) }}>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700"> <Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
<Text size="T200" align="Center"> <Text size="T200" align="Center">
{getMemberDisplayName(info.room, info.sender) ?? {getMemberName(info.room, info.sender)}
getMxIdLocalPart(info.sender) ??
info.sender}
</Text> </Text>
<Box direction="Column" gap="500" alignItems="Center"> <Box direction="Column" gap="500" alignItems="Center">
<Box shrink="No"> <Box shrink="No">
@@ -316,8 +314,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
return () => clearTimeout(id); return () => clearTimeout(id);
}, [info.senderTs, info.lifetime, onIgnore]); }, [info.senderTs, info.lifetime, onIgnore]);
const callerName = const callerName = getMemberName(info.room, info.sender);
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
return ( return (
<Box <Box
@@ -453,8 +450,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
decliner !== mx.getSafeUserId() && decliner !== mx.getSafeUserId() &&
callEmbed?.roomId === room.roomId callEmbed?.roomId === room.roomId
) { ) {
const declinerName = const declinerName = getMemberName(room, decliner);
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
setToast({ setToast({
id: `rtc-decline-${event.getId() ?? decliner}`, id: `rtc-decline-${event.getId() ?? decliner}`,
displayName: declinerName, displayName: declinerName,
@@ -544,11 +540,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleReject = useCallback( const handleReject = useCallback(
(room: Room, eventId: string) => { (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, { mx.sendEvent(room.roomId, EventType.RTCDecline, {
'm.relates_to': { 'm.relates_to': {
rel_type: RelationType.Reference, rel_type: RelationType.Reference,
event_id: eventId, event_id: eventId,
}, },
}).catch((err) => {
console.error('Failed to send call decline:', err);
}); });
setCallInfo(undefined); setCallInfo(undefined);
}, },
+5 -4
View File
@@ -7,6 +7,7 @@ import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
const PICKER_WIDTH = 312; const PICKER_WIDTH = 312;
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
type GifPickerInnerProps = { type GifPickerInnerProps = {
onSelect: (url: string, width: number, height: number) => void; onSelect: (url: string, width: number, height: number) => void;
@@ -31,7 +32,7 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
); );
return ( return (
<Box direction="Column" style={{ width: `${PICKER_WIDTH}px` }}> <Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
{lotusTerminal && ( {lotusTerminal && (
<div <div
style={{ style={{
@@ -88,7 +89,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
overflow: 'hidden', overflow: 'hidden',
boxShadow: 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)', '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, background: color.Surface.Container,
@@ -96,14 +97,14 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
overflow: 'hidden', overflow: 'hidden',
boxShadow: color.Other.Shadow, boxShadow: color.Other.Shadow,
width: `${PICKER_WIDTH}px`, width: PICKER_WIDTH_CSS,
}; };
return ( return (
<FocusTrap <FocusTrap
focusTrapOptions={{ focusTrapOptions={{
initialFocus: false, initialFocus: false,
returnFocusOnDeactivate: false, returnFocusOnDeactivate: true,
onDeactivate: requestClose, onDeactivate: requestClose,
clickOutsideDeactivates: true, clickOutsideDeactivates: true,
allowOutsideClick: true, allowOutsideClick: true,
+5 -1
View File
@@ -197,6 +197,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
background: color.SurfaceVariant.Container, background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300, borderRadius: config.radii.R300,
padding: `${toRem(4)} ${toRem(8)}`, padding: `${toRem(4)} ${toRem(8)}`,
maxWidth: '100%',
minWidth: 0,
}} }}
> >
<Box <Box
@@ -212,6 +214,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
/> />
<Text <Text
size="T200" size="T200"
role="timer"
aria-label={`Recording duration ${formatDuration(durationMs)}`}
style={{ style={{
minWidth: toRem(32), minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums', fontVariantNumeric: 'tabular-nums',
@@ -230,7 +234,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
data-voice-waveform data-voice-waveform
alignItems="Center" alignItems="Center"
gap="100" gap="100"
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }} style={{ height: toRem(20), overflow: 'hidden', flexShrink: 1, minWidth: 0 }}
> >
{waveformBars.map((h, i) => ( {waveformBars.map((h, i) => (
<div <div
+4 -2
View File
@@ -8,6 +8,7 @@ import {
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { RoomType, StateEvent } from '../../../types/matrix/room'; import { RoomType, StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { getMxIdServer } from '../../utils/matrix'; import { getMxIdServer } from '../../utils/matrix';
import { CreateRoomAccess } from './types'; import { CreateRoomAccess } from './types';
@@ -150,9 +151,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
const result = await mx.createRoom(options); const result = await mx.createRoom(options);
if (data.parent) { if (data.parent) {
await mx.sendStateEvent( await sendStateEvent(
mx,
data.parent.roomId, data.parent.roomId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild,
{ {
auto_join: false, auto_join: false,
suggested: false, suggested: false,
@@ -16,7 +16,7 @@ import { onTabPress } from '../../../utils/keyboard';
import { createMentionElement, moveCursor, replaceWithElement } from '../utils'; import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
import { useKeyDown } from '../../../hooks/useKeyDown'; import { useKeyDown } from '../../../hooks/useKeyDown';
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix'; 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 { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room'; import { Membership } from '../../../../types/matrix/room';
@@ -140,8 +140,7 @@ export function UserMentionAutocomplete({
}); });
}); });
const getName = (member: RoomMember) => const getName = (member: RoomMember) => getMemberName(room, member.userId);
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
return ( return (
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}> <AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
@@ -15,12 +15,10 @@ import {
} from 'folds'; } from 'folds';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders'; import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import * as css from './EventReaders.css'; import * as css from './EventReaders.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMemberAvatar } from '../../hooks/useMemberAvatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { getMouseEventCords } from '../../utils/dom'; import { getMouseEventCords } from '../../utils/dom';
@@ -38,6 +36,64 @@ function formatReadTs(ts: number, hour24Clock: boolean): string {
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`; : `${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 = { export type EventReadersProps = {
room: Room; room: Room;
eventId: string; eventId: string;
@@ -46,7 +102,6 @@ export type EventReadersProps = {
export const EventReaders = as<'div', EventReadersProps>( export const EventReaders = as<'div', EventReadersProps>(
({ className, room, eventId, requestClose, ...props }, ref) => { ({ className, room, eventId, requestClose, ...props }, ref) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const myUserId = mx.getUserId(); const myUserId = mx.getUserId();
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId); const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
const openProfile = useOpenUserRoomProfile(); const openProfile = useOpenUserRoomProfile();
@@ -54,9 +109,6 @@ export const EventReaders = as<'div', EventReadersProps>(
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock'); const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const getName = (userId: string) =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
return ( return (
<Box <Box
className={classNames(css.EventReaders, className)} className={classNames(css.EventReaders, className)}
@@ -100,64 +152,24 @@ export const EventReaders = as<'div', EventReadersProps>(
<Box grow="Yes"> <Box grow="Yes">
<Scroll visibility="Hover" hideTrack size="300"> <Scroll visibility="Hover" hideTrack size="300">
<Box className={css.Content} direction="Column"> <Box className={css.Content} direction="Column">
{latestEventReaders.map((readerId) => { {latestEventReaders.map((readerId) => (
const name = getName(readerId); <EventReaderItem
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl(); key={readerId}
const avatarUrl = avatarMxcUrl room={room}
? (mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ?? readerId={readerId}
undefined) hour24Clock={hour24Clock}
: undefined; lotusTerminal={lotusTerminal}
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts; onSelect={(event) => {
openProfile(
return ( room.roomId,
<MenuItem space?.roomId,
key={readerId} readerId,
style={{ padding: `0 ${config.space.S200}` }} getMouseEventCords(event.nativeEvent),
radii="400" 'Bottom',
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>
);
})}
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { ImagePackContent } from './ImagePackContent'; import { ImagePackContent } from './ImagePackContent';
import { ImagePack, PackContent } from '../../plugins/custom-emoji'; import { ImagePack, PackContent } from '../../plugins/custom-emoji';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { useRoomImagePack } from '../../hooks/useImagePacks'; import { useRoomImagePack } from '../../hooks/useImagePacks';
import { randomStr } from '../../utils/common'; import { randomStr } from '../../utils/common';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
@@ -45,12 +46,7 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
const { address } = imagePack; const { address } = imagePack;
if (!address) return; if (!address) return;
await mx.sendStateEvent( await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
address.roomId,
StateEvent.PoniesRoomEmotes as unknown as keyof import('matrix-js-sdk').StateEvents,
packContent as any,
address.stateKey,
);
}, },
[mx, imagePack], [mx, imagePack],
); );
@@ -1,13 +1,12 @@
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { as, Avatar, Box, Icon, Icons, Text } from 'folds'; import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import * as css from './style.css'; import * as css from './style.css';
const getName = (room: Room, member: RoomMember) => const getName = (room: Room, member: RoomMember) => getMemberName(room, member.userId);
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
type MemberTileProps = { type MemberTileProps = {
mx: MatrixClient; mx: MatrixClient;
@@ -1,5 +1,5 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Room } from 'matrix-js-sdk'; import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
import { import {
Icon, Icon,
Icons, Icons,
@@ -15,18 +15,32 @@ import FocusTrap from 'focus-trap-react';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { getMemberName } from '../../utils/room';
import { getMemberDisplayName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../user-avatar'; import { UserAvatar } from '../user-avatar';
import { StackedAvatar } from '../stacked-avatar'; import { StackedAvatar } from '../stacked-avatar';
import { EventReaders } from '../event-readers'; import { EventReaders } from '../event-readers';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { useModalStyle } from '../../hooks/useModalStyle'; import { useModalStyle } from '../../hooks/useModalStyle';
import { useForceUpdate } from '../../hooks/useForceUpdate';
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
import * as css from './ReadReceiptAvatars.css'; import * as css from './ReadReceiptAvatars.css';
const MAX_DISPLAY = 5; 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({ export function ReadReceiptAvatars({
room, room,
eventId, eventId,
@@ -37,10 +51,31 @@ export function ReadReceiptAvatars({
userIds: string[]; userIds: string[];
}) { }) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const modalStyle = useModalStyle(360); 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; if (userIds.length === 0) return null;
@@ -49,7 +84,7 @@ export function ReadReceiptAvatars({
const tooltipNames = const tooltipNames =
userIds userIds
.slice(0, 5) .slice(0, 5)
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id) .map((id) => getMemberName(room, id))
.join(', ') + (extra > 0 ? ` +${extra} more` : ''); .join(', ') + (extra > 0 ? ` +${extra} more` : '');
return ( return (
@@ -83,40 +118,20 @@ export function ReadReceiptAvatars({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
backgroundColor: lotusTerminal backgroundColor: lotusTerminal
? 'rgba(0,212,255,0.07)' ? 'color-mix(in srgb, var(--lt-accent-cyan) 7%, transparent)'
: color.SurfaceVariant.Container, : color.SurfaceVariant.Container,
border: lotusTerminal 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`, : `${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, borderRadius: config.radii.Pill,
padding: `${config.space.S100} ${config.space.S200}`, padding: `${config.space.S100} ${config.space.S200}`,
gap: '0px', gap: '0px',
}} }}
> >
{displayed.map((userId) => { {displayed.map((userId) => (
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; <ReceiptStackedAvatar key={userId} room={room} 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>
);
})}
{extra > 0 && ( {extra > 0 && (
<Text <Text
size="T200" size="T200"
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { SoundboardPackEditor } from './SoundboardPackEditor'; import { SoundboardPackEditor } from './SoundboardPackEditor';
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard'; import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks'; import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
import { PackAddress } from '../../plugins/custom-emoji/PackAddress'; import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
import { randomStr } from '../../utils/common'; import { randomStr } from '../../utils/common';
@@ -35,12 +36,7 @@ export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps)
const handleUpdate = useCallback( const handleUpdate = useCallback(
async (content: SoundboardContent) => { async (content: SoundboardContent) => {
await mx.sendStateEvent( await sendStateEvent(mx, room.roomId, StateEvent.LotusSoundboardRoom, content, stateKey);
room.roomId,
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
content as never,
stateKey,
);
}, },
[mx, room.roomId, stateKey], [mx, room.roomId, stateKey],
); );
@@ -33,6 +33,22 @@ import {
const linkStyles = { color: color.Success.Main }; 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 // Helpers — URL parsing & variant detection
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -474,7 +490,11 @@ function TikTokCard({
<div className={previewCss.ShortsHeader}> <div className={previewCss.ShortsHeader}>
{/* TikTok badge with musical note icon */} {/* TikTok badge with musical note icon */}
<span className={`${previewCss.SiteBadge} ${previewCss.BadgeTikTok}`}> <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 TikTok
</span> </span>
{username && ( {username && (
@@ -512,12 +532,14 @@ function TikTokCard({
loading="lazy" loading="lazy"
/> />
<div className={previewCss.PortraitPlayOverlay}> <div className={previewCss.PortraitPlayOverlay}>
<div className={previewCss.PortraitPlayButton}></div> <div className={previewCss.PortraitPlayButton}>
<Icon size="Inherit" src={Icons.Play} />
</div>
</div> </div>
</a> </a>
) : ( ) : (
<div className={previewCss.PortraitPlaceholder}> <div className={previewCss.PortraitPlaceholder}>
<span style={{ color: '#EE1D52' }}></span> <Icon style={{ color: BRAND_COLORS.tiktok }} size="Inherit" src={Icons.VolumeHigh} />
</div> </div>
)} )}
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
@@ -828,7 +850,7 @@ function TwitchCard({
width={28} width={28}
height={28} height={28}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="#9146ff" fill={BRAND_COLORS.twitch}
aria-hidden="true" aria-hidden="true"
role="img" role="img"
> >
@@ -921,11 +943,11 @@ function RedditCard({
width={28} width={28}
height={28} height={28}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="#ff4500" fill={BRAND_COLORS.reddit}
aria-hidden="true" aria-hidden="true"
role="img" role="img"
> >
<circle cx="12" cy="12" r="12" fill="#ff4500" /> <circle cx="12" cy="12" r="12" fill={BRAND_COLORS.reddit} />
<path <path
fill="#ffffff" 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" 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 */} {/* Stats row */}
<div className={previewCss.RedditMeta}> <div className={previewCss.RedditMeta}>
{upvotes && <span className={previewCss.RedditUpvote}> {upvotes}</span>} {upvotes && <span className={previewCss.RedditUpvote}> {upvotes}</span>}
{comments && <span>💬 {comments}</span>} {comments && (
<span>
<Icon size="Inherit" src={Icons.Message} /> {comments}
</span>
)}
</div> </div>
</Box> </Box>
@@ -1473,7 +1499,11 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
justifyContent="Center" justifyContent="Center"
className={previewCss.IconWrapper} className={previewCss.IconWrapper}
> >
<span style={{ fontSize: '2rem', color: '#1db954' }}></span> <Icon
style={{ fontSize: '2rem', color: BRAND_COLORS.spotify }}
size="Inherit"
src={Icons.VolumeHigh}
/>
</Box> </Box>
)} )}
<UrlPreviewContent> <UrlPreviewContent>
@@ -1533,7 +1563,11 @@ function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
justifyContent="Center" justifyContent="Center"
className={previewCss.IconWrapper} 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> </Box>
)} )}
<UrlPreviewContent> <UrlPreviewContent>
@@ -1640,7 +1674,7 @@ function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
width={28} width={28}
height={28} height={28}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="#5865f2" fill={BRAND_COLORS.discord}
aria-hidden="true" aria-hidden="true"
role="img" role="img"
> >
@@ -1693,13 +1727,13 @@ function NpmCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
> >
{/* npm logo — simple square mark */} {/* npm logo — simple square mark */}
<svg width={32} height={32} viewBox="0 0 18 7" fill="none" aria-hidden="true" role="img"> <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="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="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="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> </svg>
</Box> </Box>
<UrlPreviewContent> <UrlPreviewContent>
@@ -1747,7 +1781,7 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
width={28} width={28}
height={28} height={28}
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="#f48024" fill={BRAND_COLORS.stackOverflow}
aria-hidden="true" aria-hidden="true"
role="img" role="img"
> >
@@ -38,7 +38,7 @@ import { mDirectAtom } from '../../state/mDirectList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { VirtualTile } from '../../components/virtualizer'; 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 { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -136,9 +136,10 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
await rateLimitedActions(selectedRooms, async (room) => { await rateLimitedActions(selectedRooms, async (room) => {
const via = getViaServers(room); const via = getViaServers(room);
await mx.sendStateEvent( await sendStateEvent(
mx,
parentId, parentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild,
{ {
auto_join: false, auto_join: false,
suggested: false, suggested: false,
+5 -2
View File
@@ -35,7 +35,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
outlined outlined
disabled={disabled || loading} disabled={disabled || loading}
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'} 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} /> <Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
</IconButton> </IconButton>
@@ -67,7 +67,8 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
radii="300" radii="300"
size="300" size="300"
onClick={() => onToggle()} onClick={() => onToggle()}
aria-label={enabled ? 'Undeafen' : 'Deafen'} aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined outlined
disabled={disabled} disabled={disabled}
> >
@@ -109,6 +110,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
size="300" size="300"
onClick={toggleVideo} onClick={toggleVideo}
aria-label={enabled ? 'Stop Video' : 'Start Video'} aria-label={enabled ? 'Stop Video' : 'Start Video'}
aria-pressed={enabled}
outlined outlined
disabled={disabled || loading} disabled={disabled || loading}
> >
@@ -147,6 +149,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
size="300" size="300"
onClick={onToggle} onClick={onToggle}
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'} aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
aria-pressed={enabled}
outlined outlined
disabled={disabled} disabled={disabled}
> >
+90 -86
View File
@@ -20,8 +20,8 @@ import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import * as css from './styles.css'; import * as css from './styles.css';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
@@ -45,93 +45,97 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
}; };
return ( return (
<PopOut <>
anchor={cords} <span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
position="Top" {`${count} in call`}
align="Start" </span>
content={ <PopOut
<FocusTrap anchor={cords}
focusTrapOptions={{ position="Top"
initialFocus: false, align="Start"
onDeactivate: () => setCords(undefined), content={
clickOutsideDeactivates: true, <FocusTrap
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', focusTrapOptions={{
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', initialFocus: false,
escapeDeactivates: stopPropagation, onDeactivate: () => setCords(undefined),
}} clickOutsideDeactivates: true,
> isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
<Menu isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
style={{ escapeDeactivates: stopPropagation,
maxHeight: '75vh',
maxWidth: toRem(300),
display: 'flex',
}} }}
> >
<Box grow="Yes"> <Menu
<Scroll size="0" hideTrack visibility="Hover"> style={{
<Box direction="Column" style={{ padding: config.space.S100 }}> maxHeight: '75vh',
{members.map((callMember) => { maxWidth: toRem(300),
const userId = callMember.sender; display: 'flex',
if (!userId) return null; }}
const name = >
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; <Box grow="Yes">
const avatarMxc = getMemberAvatarMxc(room, userId); <Scroll size="0" hideTrack visibility="Hover">
const avatarUrl = avatarMxc <Box direction="Column" style={{ padding: config.space.S100 }}>
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined) {members.map((callMember) => {
: undefined; 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 ( return (
<MenuItem <MenuItem
key={callMember.memberId} key={callMember.memberId}
size="400" size="400"
variant="Surface" variant="Surface"
radii="300" radii="300"
style={{ paddingLeft: config.space.S200 }} style={{ paddingLeft: config.space.S200 }}
onClick={(evt) => onClick={(evt) =>
openUserProfile( openUserProfile(
room.roomId, room.roomId,
undefined, undefined,
userId, userId,
getMouseEventCords(evt.nativeEvent), getMouseEventCords(evt.nativeEvent),
'Right', 'Right',
) )
} }
before={ before={
<Avatar size="200" radii="400"> <Avatar size="200" radii="400">
<UserAvatar <UserAvatar
userId={userId} userId={userId}
src={avatarUrl} src={avatarUrl}
alt={name} alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />} renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/> />
</Avatar> </Avatar>
} }
> >
<Text size="T300" truncate> <Text size="T300" truncate>
{name} {name}
</Text> </Text>
</MenuItem> </MenuItem>
); );
})} })}
</Box> </Box>
</Scroll> </Scroll>
</Box> </Box>
</Menu> </Menu>
</FocusTrap> </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}
> >
<Text className={css.LiveChipText} as="span" size="L400" truncate> <Chip
{count} Live variant="Surface"
</Text> fill="Soft"
</Chip> before={<Badge variant="Critical" fill="Solid" size="200" />}
</PopOut> 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 FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { StackedAvatar } from '../../components/stacked-avatar'; import { StackedAvatar } from '../../components/stacked-avatar';
@@ -128,7 +128,7 @@ export function MemberGlance({ room, members, speakers, callEmbed, max = 6 }: Me
{visibleMembers.map((callMember) => { {visibleMembers.map((callMember) => {
const { userId } = callMember; const { userId } = callMember;
if (!userId) return null; if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; const name = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined) ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
@@ -1,17 +1,14 @@
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import React from 'react'; import React from 'react';
import { Box, Icon, Icons, Text } from 'folds'; import { Box, Icon, Icons, Text } from 'folds';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
type MemberSpeakingProps = { type MemberSpeakingProps = {
room: Room; room: Room;
speakers: Set<string>; speakers: Set<string>;
}; };
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) { export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
const speakingNames = Array.from(speakers).map( const speakingNames = Array.from(speakers).map((userId) => getMemberName(room, userId));
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
);
return ( return (
<Box alignItems="Center" gap="100"> <Box alignItems="Center" gap="100">
<Icon size="100" src={Icons.Mic} filled /> <Icon size="100" src={Icons.Mic} filled />
@@ -5,6 +5,18 @@ export const LiveChipText = style({
color: color.Critical.Main, 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([ export const CallStatus = style([
{ {
padding: `${toRem(6)} ${config.space.S200}`, 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 { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration'; import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { getMouseEventCords } from '../../utils/dom'; import { getMouseEventCords } from '../../utils/dom';
@@ -26,7 +26,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
const { userId } = member; const { userId } = member;
if (!userId) return null; if (!userId) return null;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; const name = getMemberName(room, userId);
const avatarMxc = getMemberAvatarMxc(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined) ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
@@ -35,6 +35,7 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { usePowerLevels } from '../../../hooks/usePowerLevels';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { suffixRename } from '../../../utils/common'; import { suffixRename } from '../../../utils/common';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
@@ -57,7 +58,7 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
display_name: name, display_name: name,
}, },
}; };
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey); await sendStateEvent(mx, roomId, StateEvent.PoniesRoomEmotes, content, stateKey);
}, },
[mx, roomId], [mx, roomId],
), ),
@@ -164,7 +165,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
for (let i = 0; i < removedPacks.length; i += 1) { for (let i = 0; i < removedPacks.length; i += 1) {
const addr = removedPacks[i]; const addr = removedPacks[i];
// eslint-disable-next-line no-await-in-loop // 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]), }, [mx, room, removedPacks]),
); );
@@ -23,6 +23,7 @@ import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
@@ -48,7 +49,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
const [enableState, enable] = useAsyncCallback( const [enableState, enable] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, { await sendStateEvent(mx, room.roomId, StateEvent.RoomEncryption, {
algorithm: ROOM_ENC_ALGO, algorithm: ROOM_ENC_ALGO,
}); });
}, [mx, room.roomId]), }, [mx, room.roomId]),
@@ -21,6 +21,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
@@ -76,7 +77,7 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp
const content: RoomHistoryVisibilityEventContent = { const content: RoomHistoryVisibilityEventContent = {
history_visibility: visibility, history_visibility: visibility,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content); await sendStateEvent(mx, room.roomId, StateEvent.RoomHistoryVisibility, content);
}, },
[mx, room.roomId], [mx, room.roomId],
), ),
@@ -18,7 +18,7 @@ import { StateEvent } from '../../../../types/matrix/room';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useSpaceOptionally } from '../../../hooks/useSpace';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getStateEvents } from '../../../utils/room'; import { getStateEvents, sendStateEvent } from '../../../utils/room';
import { import {
useRecursiveChildSpaceScopeFactory, useRecursiveChildSpaceScopeFactory,
useSpaceChildren, useSpaceChildren,
@@ -111,7 +111,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
join_rule: joinRule as JoinRule, join_rule: joinRule as JoinRule,
}; };
if (allow.length > 0) c.allow = allow; 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], [mx, room, space, subspaces, roomIdToParents],
), ),
@@ -39,6 +39,7 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useObjectURL } from '../../../hooks/useObjectURL'; import { useObjectURL } from '../../../hooks/useObjectURL';
import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { createUploadAtom, UploadSuccess } from '../../../state/upload';
@@ -122,7 +123,9 @@ export function RoomProfileEdit({
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>(); const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => { 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); setEmojiAnchor(undefined);
}, []); }, []);
@@ -150,16 +153,16 @@ export function RoomProfileEdit({
useCallback( useCallback(
async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => { async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
if (roomAvatarMxc !== undefined) { if (roomAvatarMxc !== undefined) {
await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, { await sendStateEvent(mx, room.roomId, StateEvent.RoomAvatar, {
url: roomAvatarMxc, url: roomAvatarMxc,
}); });
} }
if (roomName !== undefined) { 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) { if (roomTopic !== undefined) {
const topicContent = buildTopicContent(roomTopic); 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], [mx, room.roomId],
@@ -309,6 +312,7 @@ export function RoomProfileEdit({
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
variant="Secondary" variant="Secondary"
radii="300" radii="300"
maxLength={255}
readOnly={!canEditName || submitting} readOnly={!canEditName || submitting}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
@@ -7,6 +7,7 @@ import { SettingsSelect } from '../../../components/settings-select/SettingsSele
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
@@ -55,7 +56,7 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
useCallback( useCallback(
async (next: RoomQualityContent) => { async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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], [mx, room.roomId],
), ),
@@ -7,6 +7,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; 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 // Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map). // typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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], [mx, room.roomId],
), ),
@@ -7,6 +7,7 @@ import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent'; import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
@@ -31,7 +32,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
useCallback( useCallback(
async (value: number) => { async (value: number) => {
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {}; 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], [mx, room.roomId],
), ),
@@ -15,6 +15,7 @@ import { getPowerLevelTag, getPowers, usePowerLevelTags } from '../../../hooks/u
import { useRoom } from '../../../hooks/useRoom'; import { useRoom } from '../../../hooks/useRoom';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { PowerSwitcher } from '../../../components/power'; import { PowerSwitcher } from '../../../components/power';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
@@ -84,7 +85,7 @@ export function PermissionGroups({
return draftPowerLevels; 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]), }, [mx, room, powerLevels, permissionUpdate, permissionGroups]),
); );
@@ -45,6 +45,7 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { MemberPowerTag, MemberPowerTagIcon, StateEvent } from '../../../../types/matrix/room'; import { MemberPowerTag, MemberPowerTagIcon, StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge'; import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
@@ -335,7 +336,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
deleted.forEach((power) => { deleted.forEach((power) => {
delete content[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]), }, [mx, room, powerLevelTags, editedPowerTags, deleted]),
); );
+4 -1
View File
@@ -102,7 +102,9 @@ export function CreateRoomForm({
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>(); const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
const handleEmojiSelect = useCallback((unicode: string) => { 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); setEmojiAnchor(undefined);
}, []); }, []);
@@ -240,6 +242,7 @@ export function CreateRoomForm({
disabled={disabled} disabled={disabled}
value={nameValue} value={nameValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
maxLength={255}
style={{ width: '100%' }} style={{ width: '100%' }}
/> />
</Box> </Box>
+3 -2
View File
@@ -18,6 +18,7 @@ import {
import { HierarchyItem } from '../../hooks/useSpaceHierarchy'; import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room'; import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { UseStateProvider } from '../../components/UseStateProvider'; import { UseStateProvider } from '../../components/UseStateProvider';
import { LeaveSpacePrompt } from '../../components/leave-space-prompt'; import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
@@ -48,7 +49,7 @@ function SuggestMenuItem({
const [toggleState, handleToggleSuggested] = useAsyncCallback( const [toggleState, handleToggleSuggested] = useAsyncCallback(
useCallback(() => { useCallback(() => {
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested }; 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]), }, [mx, parentId, roomId, content]),
); );
@@ -85,7 +86,7 @@ function RemoveMenuItem({
const [removeState, handleRemove] = useAsyncCallback( const [removeState, handleRemove] = useAsyncCallback(
useCallback( useCallback(
() => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId), () => sendStateEvent(mx, parentId, StateEvent.SpaceChild, {}, roomId),
[mx, parentId, 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 { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD'; import { CanDropCallback, useDnDMonitor } from './DnD';
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable'; 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 { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
import { import {
makeCinnySpacesContent, makeCinnySpacesContent,
@@ -271,9 +272,10 @@ export function Lobby() {
if (reorders) { if (reorders) {
await rateLimitedActions(reorders, async (reorder) => { await rateLimitedActions(reorders, async (reorder) => {
if (!reorder.item.parentId) return; if (!reorder.item.parentId) return;
await mx.sendStateEvent( await sendStateEvent(
mx,
reorder.item.parentId, reorder.item.parentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild,
{ ...reorder.item.content, order: reorder.orderKey }, { ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId, reorder.item.roomId,
); );
@@ -298,7 +300,7 @@ export function Lobby() {
// remove from current space // remove from current space
if (item.parentId !== containerParentId) { if (item.parentId !== containerParentId) {
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId); sendStateEvent(mx, item.parentId, StateEvent.SpaceChild, {}, item.roomId);
} }
if ( if (
@@ -318,7 +320,7 @@ export function Lobby() {
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ?? joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
[]; [];
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId }); allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, { sendStateEvent(mx, itemRoom.roomId, StateEvent.RoomJoinRules, {
...joinRuleContent, ...joinRuleContent,
allow, allow,
}); });
@@ -358,9 +360,10 @@ export function Lobby() {
if (reorders) { if (reorders) {
await rateLimitedActions(reorders, async (reorder) => { await rateLimitedActions(reorders, async (reorder) => {
await mx.sendStateEvent( await sendStateEvent(
mx,
containerParentId, containerParentId,
StateEvent.SpaceChild as any, StateEvent.SpaceChild,
{ ...reorder.item.content, order: reorder.orderKey }, { ...reorder.item.content, order: reorder.orderKey },
reorder.item.roomId, reorder.item.roomId,
); );
@@ -427,7 +430,7 @@ export function Lobby() {
newItems.push(rId); newItems.push(rId);
} }
const newSpacesContent = makeCinnySpacesContent(mx, newItems); const newSpacesContent = makeCinnySpacesContent(mx, newItems);
mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any); setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
}, },
[mx, sidebarItems, sidebarSpaces], [mx, sidebarItems, sidebarSpaces],
); );
@@ -643,18 +643,7 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
variant={hasRange ? 'Primary' : 'SurfaceVariant'} variant={hasRange ? 'Primary' : 'SurfaceVariant'}
radii="Pill" radii="Pill"
before={<Icon size="100" src={Icons.RecentClock} />} before={<Icon size="100" src={Icons.RecentClock} />}
after={ after={hasRange ? <Icon size="50" src={Icons.Cross} /> : undefined}
hasRange ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onChange(undefined, undefined);
}}
/>
) : undefined
}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
setMenuAnchor(e.currentTarget.getBoundingClientRect()) setMenuAnchor(e.currentTarget.getBoundingClientRect())
} }
@@ -795,18 +784,7 @@ export function SearchFilters({
radii="Pill" radii="Pill"
aria-pressed={!!containsUrl} aria-pressed={!!containsUrl}
before={<Icon size="100" src={Icons.Link} />} before={<Icon size="100" src={Icons.Link} />}
after={ after={containsUrl ? <Icon size="50" src={Icons.Cross} /> : undefined}
containsUrl ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onContainsUrlChange(undefined);
}}
/>
) : undefined
}
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)} onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
> >
<Text size="T200">Has link</Text> <Text size="T200">Has link</Text>
@@ -821,18 +799,7 @@ export function SearchFilters({
radii="Pill" radii="Pill"
aria-pressed={active} aria-pressed={active}
before={<Icon size="100" src={icon} />} before={<Icon size="100" src={icon} />}
after={ after={active ? <Icon size="50" src={Icons.Cross} /> : undefined}
active ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onToggleMsgTypeFilter(msgType);
}}
/>
) : undefined
}
onClick={() => onToggleMsgTypeFilter(msgType)} onClick={() => onToggleMsgTypeFilter(msgType)}
> >
<Text size="T200">{label}</Text> <Text size="T200">{label}</Text>
@@ -845,18 +812,7 @@ export function SearchFilters({
radii="Pill" radii="Pill"
aria-pressed={pinnedOnly} aria-pressed={pinnedOnly}
before={<Icon size="100" src={Icons.Pin} />} before={<Icon size="100" src={Icons.Pin} />}
after={ after={pinnedOnly ? <Icon size="50" src={Icons.Cross} /> : undefined}
pinnedOnly ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onTogglePinnedOnly();
}}
/>
) : undefined
}
onClick={onTogglePinnedOnly} onClick={onTogglePinnedOnly}
> >
<Text size="T200">Pinned</Text> <Text size="T200">Pinned</Text>
@@ -12,7 +12,7 @@ import {
makeMentionCustomProps, makeMentionCustomProps,
renderMatrixMention, renderMatrixMention,
} from '../../plugins/react-custom-html-parser'; } from '../../plugins/react-custom-html-parser';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer'; import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
import { import {
@@ -31,7 +31,7 @@ import { Image } from '../../components/media';
import { ImageViewer } from '../../components/image-viewer'; import { ImageViewer } from '../../components/image-viewer';
import * as customHtmlCss from '../../styles/CustomHtml.css'; import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; 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 { ResultItem } from './useMessageSearch';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
@@ -220,10 +220,7 @@ export function SearchResultGroup({
{items.map((item) => { {items.map((item) => {
const { event } = item; const { event } = item;
const displayName = const displayName = getMemberName(room, event.sender);
getMemberDisplayName(room, event.sender) ??
getMxIdLocalPart(event.sender) ??
event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const relation = event.content['m.relates_to']; const relation = event.content['m.relates_to'];
+27 -14
View File
@@ -26,7 +26,7 @@ import {
} from 'folds'; } from 'folds';
import { useFocusWithin, useHover } from 'react-aria'; import { useFocusWithin, useHover } from 'react-aria';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import isToday from 'dayjs/plugin/isToday'; import isToday from 'dayjs/plugin/isToday';
import isYesterday from 'dayjs/plugin/isYesterday'; 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 { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
@@ -68,6 +69,7 @@ import {
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
import { livekitSupport } from '../../hooks/useLivekitSupport'; import { livekitSupport } from '../../hooks/useLivekitSupport';
@@ -126,11 +128,9 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const existing = getLocalRoomNamesContent(mx); const existing = getLocalRoomNamesContent(mx);
if (newName === '') { if (newName === '') {
const { [room.roomId]: _removed, ...rest } = existing.rooms; const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
} else { } else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, {
rooms: { ...existing.rooms, [room.roomId]: newName }, rooms: { ...existing.rooms, [room.roomId]: newName },
}); });
} }
@@ -140,8 +140,7 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
const existing = getLocalRoomNamesContent(mx); const existing = getLocalRoomNamesContent(mx);
const { [room.roomId]: _removed, ...rest } = existing.rooms; const { [room.roomId]: _removed, ...rest } = existing.rooms;
// eslint-disable-next-line @typescript-eslint/no-explicit-any setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
onClose(); onClose();
}, [mx, room.roomId, onClose]); }, [mx, room.roomId, onClose]);
@@ -324,6 +323,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const canInvite = permissions.action('invite', mx.getSafeUserId()); const canInvite = permissions.action('invite', mx.getSafeUserId());
const openRoomSettings = useOpenRoomSettings(); const openRoomSettings = useOpenRoomSettings();
const space = useSpaceOptionally(); const space = useSpaceOptionally();
const setToast = useSetAtom(toastQueueAtom);
const [invitePrompt, setInvitePrompt] = useState(false); const [invitePrompt, setInvitePrompt] = useState(false);
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>(); const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
@@ -332,23 +332,36 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const isFavorite = !!room.tags?.['m.favourite']; const isFavorite = !!room.tags?.['m.favourite'];
const isLowPriority = !!room.tags?.['m.lowpriority']; 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 = () => { const handleToggleFavorite = () => {
if (isFavorite) { if (isFavorite) {
mx.deleteRoomTag(room.roomId, 'm.favourite'); mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
} else { } else {
// Favourite and low-priority are mutually exclusive. // Favourite and low-priority are mutually exclusive. Batch both writes so a
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority'); // failure surfaces a single toast, not one per operation.
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 }); 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(); requestClose();
}; };
const handleToggleLowPriority = () => { const handleToggleLowPriority = () => {
if (isLowPriority) { if (isLowPriority) {
mx.deleteRoomTag(room.roomId, 'm.lowpriority'); mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
} else { } else {
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite'); const ops: Promise<unknown>[] = [
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }); 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(); requestClose();
}; };
@@ -6,8 +6,8 @@ import { SequenceCard } from '../../components/sequence-card';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix'; import { mxcUrlToHttp } from '../../utils/matrix';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
// ── Helpers ─────────────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────────────
@@ -297,8 +297,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
<SectionHeader label="Most Active Members" /> <SectionHeader label="Most Active Members" />
<Box direction="Column" gap="200"> <Box direction="Column" gap="200">
{stats.top5.map(([userId, count], index) => { {stats.top5.map(([userId, count], index) => {
const displayName = const displayName = getMemberName(room, userId);
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
const avatarMxc = getMemberAvatarMxc(room, userId); const avatarMxc = getMemberAvatarMxc(room, userId);
const avatarUrl = avatarMxc const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ??
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { sendStateEvent } from '../../utils/room';
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels'; import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
@@ -250,7 +251,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
// Save handler // Save handler
const [saveState, save] = useAsyncCallback( const [saveState, save] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, { await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, {
allow: allowList, allow: allowList,
deny: denyList, deny: denyList,
allow_ip_literals: allowIpLiterals, allow_ip_literals: allowIpLiterals,
+3 -7
View File
@@ -42,7 +42,7 @@ import {
} from '../../hooks/useAsyncSearch'; } from '../../hooks/useAsyncSearch';
import { useDebounce } from '../../hooks/useDebounce'; import { useDebounce } from '../../hooks/useDebounce';
import { TypingIndicator } from '../../components/typing-indicator'; import { TypingIndicator } from '../../components/typing-indicator';
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room'; import { getMemberName, getMemberSearchStr } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix'; import { getMxIdLocalPart } from '../../utils/matrix';
import { useSetSetting, useSetting } from '../../state/hooks/settings'; import { useSetSetting, useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -134,8 +134,7 @@ function MemberItem({
typing, typing,
showEncryption, showEncryption,
}: MemberItemProps) { }: MemberItemProps) {
const name = const name = getMemberName(room, member.userId);
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const avatarMxcUrl = member.getMxcAvatarUrl(); const avatarMxcUrl = member.getMxcAvatarUrl();
const avatarUrl = avatarMxcUrl const avatarUrl = avatarMxcUrl
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication) ? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
@@ -418,10 +417,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
Pending Requests Pending Requests
</Text> </Text>
{knockMembers.map((knockMember) => { {knockMembers.map((knockMember) => {
const knockName = const knockName = getMemberName(room, knockMember.userId);
getMemberDisplayName(room, knockMember.userId) ??
getMxIdLocalPart(knockMember.userId) ??
knockMember.userId;
const knockAvatarMxc = knockMember.getMxcAvatarUrl(); const knockAvatarMxc = knockMember.getMxcAvatarUrl();
const knockAvatarUrl = knockAvatarMxc const knockAvatarUrl = knockAvatarMxc
? mx.mxcUrlToHttp( ? mx.mxcUrlToHttp(
+21 -8
View File
@@ -65,7 +65,6 @@ import {
TUploadContent, TUploadContent,
encryptFile, encryptFile,
getImageInfo, getImageInfo,
getMxIdLocalPart,
mxcUrlToHttp, mxcUrlToHttp,
tryDeleteMxcContent, tryDeleteMxcContent,
} from '../../utils/matrix'; } from '../../utils/matrix';
@@ -111,7 +110,7 @@ import {
getImageMsgContent, getImageMsgContent,
getVideoMsgContent, getVideoMsgContent,
} from './msgContent'; } from './msgContent';
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room'; import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
import { CommandAutocomplete } from './CommandAutocomplete'; import { CommandAutocomplete } from './CommandAutocomplete';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands'; import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
import { mobileOrTablet } from '../../utils/user-agent'; import { mobileOrTablet } from '../../utils/user-agent';
@@ -136,6 +135,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
import { ScheduledMessagesTray } from './ScheduledMessagesTray'; import { ScheduledMessagesTray } from './ScheduledMessagesTray';
import { DraftIndicator } from './DraftIndicator'; import { DraftIndicator } from './DraftIndicator';
import { scheduledMessagesAtom } from '../../state/scheduledMessages'; import { scheduledMessagesAtom } from '../../state/scheduledMessages';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { getThreadDraftKey } from '../../state/room/thread'; import { getThreadDraftKey } from '../../state/room/thread';
const GifPicker = React.lazy(() => const GifPicker = React.lazy(() =>
@@ -184,6 +184,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const [scheduleOpen, setScheduleOpen] = useState(false); const [scheduleOpen, setScheduleOpen] = useState(false);
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null); const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
const setScheduledMessages = useSetAtom(scheduledMessagesAtom); const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
const setToast = useSetAtom(toastQueueAtom);
const alive = useAlive(); const alive = useAlive();
// Scope drafts/replies/uploads by thread so a thread composer stays fully // 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) { } else if (commandName) {
const commandContent = commands[commandName as Command]; const commandContent = commands[commandName as Command];
if (commandContent) { 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); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
@@ -600,6 +616,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setReplyDraft, setReplyDraft,
isMarkdown, isMarkdown,
commands, commands,
setToast,
]); ]);
/** /**
@@ -975,11 +992,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
userColor={replyUsernameColor} userColor={replyUsernameColor}
username={ username={
<Text size="T300" truncate> <Text size="T300" truncate>
<b> <b>{getMemberName(room, replyDraft.userId)}</b>
{getMemberDisplayName(room, replyDraft.userId) ??
getMxIdLocalPart(replyDraft.userId) ??
replyDraft.userId}
</b>
</Text> </Text>
} }
> >
+4 -5
View File
@@ -81,6 +81,7 @@ import {
getEventReactions, getEventReactions,
getLatestEditableEvt, getLatestEditableEvt,
getMemberDisplayName, getMemberDisplayName,
getMemberName,
getReactionContent, getReactionContent,
isMembershipChanged, isMembershipChanged,
reactionOrEditEvent, reactionOrEditEvent,
@@ -1007,7 +1008,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
console.warn('Button should have "data-user-id" attribute!'); console.warn('Button should have "data-user-id" attribute!');
return; return;
} }
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; const name = getMemberName(room, userId);
editor.insertNode( editor.insertNode(
createMentionElement( createMentionElement(
userId, userId,
@@ -1106,8 +1107,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName = getMemberName(room, senderId);
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
return ( return (
<Message <Message
@@ -1290,8 +1290,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
mEvent.getContent()) as GetContentCallback; mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName = getMemberName(room, senderId);
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
return ( return (
<RenderMessageContent <RenderMessageContent
displayName={senderDisplayName} displayName={senderDisplayName}
+2 -6
View File
@@ -15,8 +15,7 @@ import { Room } from 'matrix-js-sdk';
import classNames from 'classnames'; import classNames from 'classnames';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { getMemberDisplayName } from '../../utils/room'; import { getMemberName } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
import * as css from './RoomViewFollowing.css'; import * as css from './RoomViewFollowing.css';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent'; import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
@@ -39,10 +38,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId()); const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
const names = latestEventReaders const names = latestEventReaders
.filter((readerId) => readerId !== mx.getUserId()) .filter((readerId) => readerId !== mx.getUserId())
.map( .map((readerId) => getMemberName(room, readerId));
(readerId) =>
getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId,
);
const eventId = latestEvent?.getId(); const eventId = latestEvent?.getId();
+13 -9
View File
@@ -53,9 +53,10 @@ import {
canEditEvent, canEditEvent,
getEventEdits, getEventEdits,
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberDisplayName, getMemberName,
sendStateEvent,
} from '../../../utils/room'; } from '../../../utils/room';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { messageAriaLabel } from '../../../utils/a11y'; import { messageAriaLabel } from '../../../utils/a11y';
import { MessageLayout, MessageSpacing } from '../../../state/settings'; import { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -104,15 +105,19 @@ function DeliveryStatus({
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) { if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
iconSrc = Icons.Cross; iconSrc = Icons.Cross;
label = 'Failed to send'; 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) { } else if (status === EventStatus.QUEUED || isSending) {
iconSrc = Icons.Send; iconSrc = Icons.Send;
label = isSending ? 'Sending...' : 'Queued'; 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 { } else {
iconSrc = Icons.Check; iconSrc = Icons.Check;
label = 'Sent'; 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 ( return (
<Box <Box
@@ -128,7 +133,7 @@ function DeliveryStatus({
opacity: 0.85, opacity: 0.85,
userSelect: 'none', userSelect: 'none',
...(lotusTerminal && status === EventStatus.NOT_SENT ...(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) { if (!isPinned && eventId) {
pinContent.pinned.push(eventId); pinContent.pinned.push(eventId);
} }
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent); sendStateEvent(mx, room.roomId, StateEvent.RoomPinnedEvents, pinContent);
onClose?.(); onClose?.();
}; };
@@ -805,8 +810,7 @@ export const Message = React.memo(
const [remindOpen, setRemindOpen] = useState(false); const [remindOpen, setRemindOpen] = useState(false);
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks(); const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
const senderDisplayName = const senderDisplayName = getMemberName(room, senderId);
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const senderAvatarMxc = getMemberAvatarMxc(room, senderId); const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
const tagColor = memberPowerTag?.color const tagColor = memberPowerTag?.color
@@ -53,11 +53,10 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import {
getEditedEvent, getEditedEvent,
getMemberDisplayName, getMemberName,
getMentionContent, getMentionContent,
trimReplyFromFormattedBody, trimReplyFromFormattedBody,
} from '../../../utils/room'; } from '../../../utils/room';
import { getMxIdLocalPart } from '../../../utils/matrix';
import { mobileOrTablet } from '../../../utils/user-agent'; import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck'; 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 // Accessible name for the edit textbox so screen readers announce which
// message is being edited (a11y, P3-4). // message is being edited (a11y, P3-4).
const editSenderId = mEvent.getSender(); const editSenderId = mEvent.getSender();
const editSenderName = editSenderId const editSenderName = editSenderId ? getMemberName(room, editSenderId) : '';
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
: '';
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline'); const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar'); const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown'); const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
@@ -16,7 +16,7 @@ import {
} from 'folds'; } from 'folds';
import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixEvent, Room, RoomMember } from 'matrix-js-sdk';
import { Relations } from 'matrix-js-sdk/lib/models/relations'; 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 { eventWithShortcode, getMxIdLocalPart } from '../../../utils/matrix';
import * as css from './ReactionViewer.css'; import * as css from './ReactionViewer.css';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -67,8 +67,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>(
} }
}; };
const getName = (member: RoomMember) => const getName = (member: RoomMember) => getMemberName(room, member.userId);
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
const getReactionsForKey = (key: string): MatrixEvent[] => { const getReactionsForKey = (key: string): MatrixEvent[] => {
const reactSet = reactions.find(([k]) => k === key)?.[1]; const reactSet = reactions.find(([k]) => k === key)?.[1];
@@ -40,13 +40,14 @@ import {
UsernameBold, UsernameBold,
} from '../../../components/message'; } from '../../../components/message';
import { UserAvatar } from '../../../components/user-avatar'; import { UserAvatar } from '../../../components/user-avatar';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { import {
getEditedEvent, getEditedEvent,
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberDisplayName, getMemberName,
getStateEvent, getStateEvent,
sendStateEvent,
} from '../../../utils/room'; } from '../../../utils/room';
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room'; import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler'; import { useMentionClickHandler } from '../../../hooks/useMentionClickHandler';
@@ -122,7 +123,7 @@ function PinnedMessage({
pinned: content.pinned.filter((id) => id !== eventId), 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]), }, [room, eventId, mx]),
); );
@@ -175,7 +176,7 @@ function PinnedMessage({
); );
const sender = pinnedEvent.getSender()!; const sender = pinnedEvent.getSender()!;
const displayName = getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender; const displayName = getMemberName(room, sender);
const senderAvatarMxc = getMemberAvatarMxc(room, sender); const senderAvatarMxc = getMemberAvatarMxc(room, sender);
const getContent = (() => pinnedEvent.getContent()) as GetContentCallback; 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 { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
import classNames from 'classnames'; import classNames from 'classnames';
import { Opts as LinkifyOpts } from 'linkifyjs'; 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 { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator'; import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
import { useAlive } from '../../../hooks/useAlive'; import { useAlive } from '../../../hooks/useAlive';
@@ -58,7 +58,7 @@ import {
decryptAllTimelineEvent, decryptAllTimelineEvent,
getEditedEvent, getEditedEvent,
getEventReactions, getEventReactions,
getMemberDisplayName, getMemberName,
getReactionContent, getReactionContent,
reactionOrEditEvent, reactionOrEditEvent,
} from '../../../utils/room'; } from '../../../utils/room';
@@ -503,7 +503,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
evt.preventDefault(); evt.preventDefault();
const userId = evt.currentTarget.getAttribute('data-user-id'); const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return; if (!userId) return;
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; const name = getMemberName(room, userId);
editor.insertNode( editor.insertNode(
createMentionElement( createMentionElement(
userId, userId,
@@ -651,8 +651,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
const getContent = (() => const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
const senderId = mEvent.getSender() ?? ''; const senderId = mEvent.getSender() ?? '';
const senderDisplayName = const senderDisplayName = getMemberName(room, senderId);
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
return ( return (
<RenderMessageContent <RenderMessageContent
displayName={senderDisplayName} displayName={senderDisplayName}
@@ -26,6 +26,7 @@ import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { sendStateEvent } from '../../../utils/room';
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils'; import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
const urlErrorMessage = (err: WidgetUrlError): string => { const urlErrorMessage = (err: WidgetUrlError): string => {
@@ -84,7 +85,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
}; };
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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); setAdding(false);
} catch (e) { } catch (e) {
setError((e as Error).message); setError((e as Error).message);
@@ -96,7 +97,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const handleRemove = (id: string) => { const handleRemove = (id: string) => {
if (viewingId === id) setViewingId(null); if (viewingId === id) setViewingId(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any // 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 ( return (
+23 -10
View File
@@ -35,6 +35,7 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater'; import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
import { useSetting } from '../../../state/hooks/settings'; import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings'; import { settingsAtom } from '../../../state/settings';
@@ -52,7 +53,7 @@ import { ModalWide } from '../../../styles/Modal.css';
import { createUploadAtom, UploadSuccess } from '../../../state/upload'; import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card'; import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities'; import { useCapabilities } from '../../../hooks/useCapabilities';
import { useUserPresence } from '../../../hooks/useUserPresence'; import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
import { ProfileDecoration } from './ProfileDecoration'; import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board'; import { EmojiBoard } from '../../../components/emoji-board';
@@ -285,6 +286,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
onChange={handleChange} onChange={handleChange}
variant="Secondary" variant="Secondary"
radii="300" radii="300"
maxLength={255}
style={{ paddingRight: config.space.S200 }} style={{ paddingRight: config.space.S200 }}
readOnly={changingDisplayName || disableSetDisplayname} readOnly={changingDisplayName || disableSetDisplayname}
after={ after={
@@ -364,13 +366,26 @@ function ProfileStatus() {
// Sync input when another device changes the status. // Sync input when another device changes the status.
// Skipped while the user has unsaved local edits to avoid clobbering // 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(() => { useEffect(() => {
if (!statusDirtyRef.current && presence?.status) { if (statusDirtyRef.current || !presence) return;
setStatusMsg(presence.status); // An offline/invisible presence carries an empty status_msg by design (see
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status); // 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( const [saveState, saveStatus] = useAsyncCallback(
useCallback( useCallback(
@@ -711,9 +726,7 @@ function ProfileTimezone() {
const [savedTimezone, setSavedTimezone] = useState<string>(''); const [savedTimezone, setSavedTimezone] = useState<string>('');
useEffect(() => { useEffect(() => {
const cached = mx const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
.getAccountData('im.lotus.timezone' as any)
?.getContent<{ timezone: string }>();
if (cached?.timezone) { if (cached?.timezone) {
setTimezone(cached.timezone); setTimezone(cached.timezone);
setSavedTimezone(cached.timezone); setSavedTimezone(cached.timezone);
@@ -740,7 +753,7 @@ function ProfileTimezone() {
Promise.all([ Promise.all([
// Self-fallback: account data is readable by useExtendedProfile for the // Self-fallback: account data is readable by useExtendedProfile for the
// own user even on servers without extended-profile (m.tz) support. // 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 // Mirror the pronouns write path so OTHER users can read the timezone
// via the m.tz profile field. Best-effort: standard Synapse rejects // via the m.tz profile field. Best-effort: standard Synapse rejects
// unknown profile fields, so a failure here must not fail the save. // unknown profile fields, so a failure here must not fail the save.
@@ -12,6 +12,7 @@ import {
AccountDataSubmitCallback, AccountDataSubmitCallback,
} from '../../../components/AccountDataEditor'; } from '../../../components/AccountDataEditor';
import { copyToClipboard } from '../../../utils/dom'; import { copyToClipboard } from '../../../utils/dom';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { AccountData } from './AccountData'; import { AccountData } from './AccountData';
import { CryptoDiagnostics } from '../developer/CryptoDiagnostics'; import { CryptoDiagnostics } from '../developer/CryptoDiagnostics';
@@ -26,7 +27,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback( const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => { async (type, content) => {
await (mx as any).setAccountData(type, content); await setAccountData(mx, type, content);
}, },
[mx], [mx],
); );
@@ -36,7 +37,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
<AccountDataEditor <AccountDataEditor
type={accountDataType ?? undefined} type={accountDataType ?? undefined}
content={ content={
accountDataType ? (mx as any).getAccountData(accountDataType)?.getContent() : undefined accountDataType ? getAccountData<object>(mx, accountDataType) : undefined
} }
submitChange={submitAccountData} submitChange={submitAccountData}
requestClose={() => setAccountDataType(undefined)} 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 React, { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react';
import { import {
Box, Box,
@@ -41,6 +41,7 @@ import {
import { LineClamp2 } from '../../../styles/Text.css'; import { LineClamp2 } from '../../../styles/Text.css';
import { allRoomsAtom } from '../../../state/room-list/roomList'; import { allRoomsAtom } from '../../../state/room-list/roomList';
import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { getAccountData, setAccountData } from '../../../utils/accountData';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { stopPropagation } from '../../../utils/keyboard'; import { stopPropagation } from '../../../utils/keyboard';
@@ -303,9 +304,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) {
const [applyState, applyChanges] = useAsyncCallback( const [applyState, applyChanges] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
const content = const content =
( getAccountData<EmoteRoomsContent>(mx, AccountDataEvent.PoniesEmoteRooms) ?? {};
(mx as any).getAccountData(AccountDataEvent.PoniesEmoteRooms) as MatrixEvent | undefined
)?.getContent<EmoteRoomsContent>() ?? {};
const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content)); const updatedContent: EmoteRoomsContent = JSON.parse(JSON.stringify(content));
selectedPacks.forEach((addr) => { 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]), }, [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 { useState, useCallback } from 'react';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback'; import { useAccountDataCallback } from './useAccountDataCallback';
import { getAccountData } from '../utils/room';
export function useAccountData(eventType: string): MatrixEvent | undefined { export function useAccountData(eventType: string): MatrixEvent | undefined {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [event, setEvent] = useState<MatrixEvent | undefined>( const [event, setEvent] = useState<MatrixEvent | undefined>(() =>
() => (mx as any).getAccountData(eventType) as MatrixEvent | undefined, getAccountData(mx, eventType),
); );
useAccountDataCallback( useAccountDataCallback(
+13 -92
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
export type Bookmark = { export type Bookmark = {
roomId: string; roomId: string;
@@ -17,81 +17,14 @@ type BookmarksContent = {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
}; };
function readBookmarks(mx: MatrixClient): Bookmark[] { // Shared, concurrency-safe store. See createAccountDataListStore for why the
return ( // snapshot + write queue must be module-scoped (writes are serialized to avoid
(mx.getAccountData(BOOKMARKS_KEY as any)?.getContent() as BookmarksContent | undefined) // lost updates, since setAccountData replaces the whole content with no merge).
?.bookmarks ?? [] const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
); eventType: BOOKMARKS_KEY,
} read: (content) => content?.bookmarks ?? [],
write: (bookmarks) => ({ 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;
}
export function useBookmarks(): { export function useBookmarks(): {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
@@ -100,23 +33,11 @@ export function useBookmarks(): {
isBookmarked: (eventId: string) => boolean; isBookmarked: (eventId: string) => boolean;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest); const bookmarks = bookmarksStore.useValue(mx);
// 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 addBookmark = useCallback( const addBookmark = useCallback(
(b: Bookmark) => (b: Bookmark) =>
enqueueBookmarkWrite(mx, (current) => { bookmarksStore.enqueueWrite(mx, (current) => {
// Avoid duplicates // Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== b.eventId); const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [b, ...filtered]; let next = [b, ...filtered];
@@ -130,7 +51,7 @@ export function useBookmarks(): {
const removeBookmark = useCallback( const removeBookmark = useCallback(
(eventId: string) => (eventId: string) =>
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)), bookmarksStore.enqueueWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
[mx], [mx],
); );
+16 -8
View File
@@ -24,7 +24,7 @@ import {
} from '../utils/matrix'; } from '../utils/matrix';
import { useRoomNavigate } from './useRoomNavigate'; import { useRoomNavigate } from './useRoomNavigate';
import { Membership, StateEvent } from '../../types/matrix/room'; import { Membership, StateEvent } from '../../types/matrix/room';
import { getStateEvent } from '../utils/room'; import { getStateEvent, sendStateEvent } from '../utils/room';
import { splitWithSpace } from '../utils/common'; import { splitWithSpace } from '../utils/common';
import { createRoomEncryptionState } from '../components/create-room'; 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]: { [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]: { [Command.UnBan]: {
@@ -362,9 +368,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId()); ?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId());
const content = mEvent?.getContent(); const content = mEvent?.getContent();
if (!content) return; if (!content) return;
await mx.sendStateEvent( await sendStateEvent(
mx,
room.roomId, room.roomId,
StateEvent.RoomMember as any, StateEvent.RoomMember,
{ {
...content, ...content,
displayname: nick, displayname: nick,
@@ -384,9 +391,10 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId()); ?.getStateEvents(StateEvent.RoomMember, mx.getSafeUserId());
const content = mEvent?.getContent(); const content = mEvent?.getContent();
if (!content) return; if (!content) return;
await mx.sendStateEvent( await sendStateEvent(
mx,
room.roomId, room.roomId,
StateEvent.RoomMember as any, StateEvent.RoomMember,
{ {
...content, ...content,
avatar_url: payload, avatar_url: payload,
@@ -526,7 +534,7 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
aclContent.allow?.sort(); aclContent.allow?.sort();
aclContent.deny?.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 { useCallback } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
export type Reminder = { export type Reminder = {
roomId: string; roomId: string;
@@ -15,81 +15,14 @@ type RemindersContent = {
reminders: Reminder[]; reminders: Reminder[];
}; };
function readReminders(mx: MatrixClient): Reminder[] { // Shared, concurrency-safe store. See createAccountDataListStore for why the
return ( // snapshot + write queue must be module-scoped (writes are serialized to avoid
(mx.getAccountData(REMINDERS_KEY as any)?.getContent() as RemindersContent | undefined) // lost updates, since setAccountData replaces the whole content with no merge).
?.reminders ?? [] const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>({
); eventType: REMINDERS_KEY,
} read: (content) => content?.reminders ?? [],
write: (reminders) => ({ 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;
}
export function useReminders(): { export function useReminders(): {
reminders: Reminder[]; reminders: Reminder[];
@@ -98,34 +31,22 @@ export function useReminders(): {
getReminders: () => Reminder[]; getReminders: () => Reminder[];
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest); const reminders = remindersStore.useValue(mx);
// 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 addReminder = useCallback( const addReminder = useCallback(
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]), (r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
[mx], [mx],
); );
const removeReminder = useCallback( const removeReminder = useCallback(
(eventId: string, timestamp: number) => (eventId: string, timestamp: number) =>
enqueueReminderWrite(mx, (current) => remindersStore.enqueueWrite(mx, (current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)), current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
), ),
[mx], [mx],
); );
const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]); const getReminders = useCallback(() => remindersStore.getLatest(mx), [mx]);
return { reminders, addReminder, removeReminder, getReminders }; return { reminders, addReminder, removeReminder, getReminders };
} }
+4 -4
View File
@@ -6,7 +6,7 @@ import { useMatrixClient } from './useMatrixClient';
import { useAlive } from './useAlive'; import { useAlive } from './useAlive';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room'; 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[]] => { export const usePublishedAliases = (room: Room): [string | undefined, string[]] => {
const aliasContent = useStateEvent( const aliasContent = useStateEvent(
@@ -56,7 +56,7 @@ export const useSetMainAlias = (room: Room): ((alias: string | undefined) => Pro
alt_aliases: altAliases, alt_aliases: altAliases,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent); await sendStateEvent(mx, room.roomId, StateEvent.RoomCanonicalAlias, newContent);
}, },
[mx, room], [mx, room],
); );
@@ -90,7 +90,7 @@ export const usePublishUnpublishAliases = (
alt_aliases: altAliases, alt_aliases: altAliases,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent); await sendStateEvent(mx, room.roomId, StateEvent.RoomCanonicalAlias, newContent);
}, },
[mx, room], [mx, room],
); );
@@ -114,7 +114,7 @@ export const usePublishUnpublishAliases = (
alt_aliases: altAliases, alt_aliases: altAliases,
}; };
await mx.sendStateEvent(room.roomId, StateEvent.RoomCanonicalAlias as any, newContent); await sendStateEvent(mx, room.roomId, StateEvent.RoomCanonicalAlias, newContent);
}, },
[mx, room], [mx, room],
); );
+2 -3
View File
@@ -4,6 +4,7 @@ import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from '
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { getAccountData } from '../utils/accountData';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar); const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -42,9 +43,7 @@ export type LocalRoomNamesContent = { rooms: Record<string, string> };
export function getLocalRoomNamesContent( export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>, mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent { ): LocalRoomNamesContent {
// Use any-cast because LOCAL_ROOM_NAMES_KEY is not in matrix-js-sdk AccountDataEvents const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const raw: unknown = (mx as any).getAccountData(LOCAL_ROOM_NAMES_KEY)?.getContent();
if ( if (
raw && raw &&
typeof raw === 'object' && typeof raw === 'object' &&
+19 -1
View File
@@ -1,5 +1,5 @@
import { Room } from 'matrix-js-sdk'; 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 { AccountDataEvent } from '../../types/matrix/accountData';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { import {
@@ -79,6 +79,13 @@ export const useRoomSoundboardPack = (room: Room, stateKey: string): SoundboardP
const mx = useMatrixClient(); const mx = useMatrixClient();
const [roomPack, setRoomPack] = useState(() => getRoomSoundboardPack(room, stateKey)); 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( useStateEventCallback(
mx, mx,
useCallback( useCallback(
@@ -102,6 +109,11 @@ export const useRoomSoundboardPacks = (room: Room): SoundboardPack[] => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room)); const [roomPacks, setRoomPacks] = useState(() => getRoomSoundboardPacks(room));
// Resync on room change (see useRoomSoundboardPack).
useEffect(() => {
setRoomPacks(getRoomSoundboardPacks(room));
}, [room]);
useStateEventCallback( useStateEventCallback(
mx, mx,
useCallback( useCallback(
@@ -124,6 +136,12 @@ export const useRoomsSoundboardPacks = (rooms: Room[]): SoundboardPack[] => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [roomPacks, setRoomPacks] = useState(() => rooms.flatMap(getRoomSoundboardPacks)); 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( useStateEventCallback(
mx, mx,
useCallback( useCallback(
+3 -4
View File
@@ -12,6 +12,7 @@ import {
} from '../utils/threadNotifications'; } from '../utils/threadNotifications';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { AsyncState, useAsyncCallback } from './useAsyncCallback'; import { AsyncState, useAsyncCallback } from './useAsyncCallback';
import { getAccountData, setAccountData } from '../utils/accountData';
/** Read the current notification mode for a thread from the bound atom. */ /** Read the current notification mode for a thread from the bound atom. */
export function useThreadNotificationMode( export function useThreadNotificationMode(
@@ -23,9 +24,7 @@ export function useThreadNotificationMode(
} }
const readContent = (mx: MatrixClient): ThreadNotificationsContent => const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as getAccountData<ThreadNotificationsContent>(mx, AccountDataEvent.LotusThreadNotifications) ?? {};
| ThreadNotificationsContent
| undefined) ?? {};
const getJoinedRoomIds = (mx: MatrixClient): Set<string> => { const getJoinedRoomIds = (mx: MatrixClient): Set<string> => {
const joined = new Set<string>(); const joined = new Set<string>();
@@ -74,7 +73,7 @@ const writeThreadNotificationMode = async (
// ALWAYS prune before persisting to keep account data bounded. // ALWAYS prune before persisting to keep account data bounded.
const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now); const finalContent = pruneThreadNotifications(next, getJoinedRoomIds(mx), now);
await (mx as any).setAccountData(AccountDataEvent.LotusThreadNotifications, finalContent); await setAccountData(mx, AccountDataEvent.LotusThreadNotifications, finalContent);
}; };
export function useSetThreadNotificationMode( export function useSetThreadNotificationMode(
+14 -87
View File
@@ -1,110 +1,37 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback } from 'react';
import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore';
const NOTES_KEY = 'io.lotus.user_notes'; const NOTES_KEY = 'io.lotus.user_notes';
export const USER_NOTE_MAX_LENGTH = 500; export const USER_NOTE_MAX_LENGTH = 500;
type UserNotesContent = Record<string, string>; type UserNotesContent = Record<string, string>;
function readNotes(mx: MatrixClient): UserNotesContent { // Shared, concurrency-safe store. See createAccountDataListStore for why the
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {}; // snapshot + write queue must be module-scoped (writes are serialized to avoid
} // lost updates, since setAccountData replaces the whole content with no merge).
// Module-scoped serialization state.
// //
// useUserNotes() can be mounted by many components at once, so a per-instance // Notes are a flat Record<userId, note> rather than a wrapped list, so `read`
// latest/queue would only serialize writes within one instance. Notes for // and `write` pass the content through unchanged.
// different users saved from different instances (before the server echo lands) const notesStore = createAccountDataListStore<UserNotesContent, UserNotesContent>({
// would each compute from a stale snapshot and clobber each other, since eventType: NOTES_KEY,
// setAccountData replaces the whole record with no server merge. We therefore read: (content) => content ?? {},
// keep a single shared latest record + write queue, keyed off the active client. write: (notes) => notes,
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;
}
export function useUserNotes(): { export function useUserNotes(): {
getNote: (userId: string) => string; getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>; setNote: (userId: string, note: string) => Promise<void>;
} { } {
const mx = useMatrixClient(); const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest); const notes = notesStore.useValue(mx);
// 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 getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]); const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback( const setNote = useCallback(
(userId: string, note: string) => { (userId: string, note: string) => {
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH); const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
return enqueueNotesWrite(mx, (current) => { return notesStore.enqueueWrite(mx, (current) => {
const updated = { ...current }; const updated = { ...current };
if (trimmed) { if (trimmed) {
updated[userId] = 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 React, { ReactNode, useCallback, useEffect, useRef } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
ClientEvent,
ClientEventHandlerMap,
MatrixEvent, MatrixEvent,
Room, Room,
RoomEvent, RoomEvent,
RoomEventHandlerMap, RoomEventHandlerMap,
SyncState,
Thread, Thread,
ThreadEvent, ThreadEvent,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
@@ -22,18 +25,21 @@ import { NOTIFICATION_SOUND_MAP } from '../../utils/notificationSounds';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList'; 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 { useMatrixClient } from '../../hooks/useMatrixClient';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils'; import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { import {
getMemberDisplayName, getMemberName,
getNotificationType, getNotificationType,
getUnreadInfo, getUnreadInfo,
isNotificationEvent, isNotificationEvent,
} from '../../utils/room'; } from '../../utils/room';
import { NotificationType } from '../../../types/matrix/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 { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox'; import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -134,8 +140,15 @@ function FaviconUpdater() {
function InviteNotifications() { function InviteNotifications() {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const invites = useAtomValue(allInvitesAtom); const invites = useAtomValue(allInvitesAtom);
const perviousInviteLen = usePreviousValue(invites.length, 0);
const mx = useMatrixClient(); 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 navigate = useNavigate();
const [showNotifications] = useSetting(settingsAtom, 'showNotifications'); const [showNotifications] = useSetting(settingsAtom, 'showNotifications');
@@ -199,26 +212,60 @@ function InviteNotifications() {
audioElement?.play(); 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(() => { useEffect(() => {
if (invites.length > perviousInviteLen && mx.getSyncState() === 'SYNCING') { let timer: ReturnType<typeof setTimeout> | undefined;
const quietActive = const arm = () => {
focusAssistActive || timer = setTimeout(() => {
manualDnd || armedRef.current = true;
(quietHoursEnabled && isInQuietHours(quietHoursStart, quietHoursEnd)); }, INVITE_NOTIFY_ARM_DELAY_MS);
if (!quietActive) { };
if (showNotifications && notificationPermission('granted')) { if (mx.getSyncState() === SyncState.Syncing) {
notify(invites.length - perviousInviteLen); arm();
} return () => {
if (timer) clearTimeout(timer);
if (notificationSound && inviteSoundId !== 'none') { };
playSound(); }
} 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, invites,
perviousInviteLen,
showNotifications, showNotifications,
notificationSound, notificationSound,
notify, notify,
@@ -496,7 +543,7 @@ function MessageNotifications() {
roomAvatar: avatarMxc roomAvatar: avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined) ? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined, : undefined,
username: getMemberDisplayName(room, sender) ?? getMxIdLocalPart(sender) ?? sender, username: getMemberName(room, sender),
roomId: room.roomId, roomId: room.roomId,
eventId, eventId,
body: (mEvent.getContent().body as string | undefined) ?? '', body: (mEvent.getContent().body as string | undefined) ?? '',
+2 -5
View File
@@ -37,7 +37,7 @@ import {
bannedInRooms, bannedInRooms,
getCommonRooms, getCommonRooms,
getDirectRoomAvatarUrl, getDirectRoomAvatarUrl,
getMemberDisplayName, getMemberName,
getRoomAvatarUrl, getRoomAvatarUrl,
getStateEvent, getStateEvent,
isDirectInvite, isDirectInvite,
@@ -48,7 +48,6 @@ import { RoomAvatar } from '../../../components/room-avatar';
import { import {
addRoomIdToMDirect, addRoomIdToMDirect,
declineInvite, declineInvite,
getMxIdLocalPart,
guessDmRoomUserId, guessDmRoomUserId,
rateLimitedActions, rateLimitedActions,
} from '../../../utils/matrix'; } from '../../../utils/matrix';
@@ -107,9 +106,7 @@ const makeInviteData = (mx: MatrixClient, room: Room, useAuthentication: boolean
const content = memberEvent?.getContent(); const content = memberEvent?.getContent();
const senderId = memberEvent?.getSender(); const senderId = memberEvent?.getSender();
const senderName = senderId const senderName = senderId ? getMemberName(room, senderId) : undefined;
? (getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId)
: undefined;
const inviteTs = memberEvent?.getTs(); const inviteTs = memberEvent?.getTs();
const reason = const reason =
content && 'reason' in content && typeof content.reason === 'string' 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 { useAtomValue } from 'jotai';
import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../../components/page';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix'; import { mxcUrlToHttp } from '../../../utils/matrix';
import { InboxNotificationsPathSearchParams } from '../../paths'; import { InboxNotificationsPathSearchParams } from '../../paths';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
@@ -36,7 +36,7 @@ import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { import {
getEditedEvent, getEditedEvent,
getMemberAvatarMxc, getMemberAvatarMxc,
getMemberDisplayName, getMemberName,
getRoomAvatarUrl, getRoomAvatarUrl,
} from '../../../utils/room'; } from '../../../utils/room';
import { ScrollTopContainer } from '../../../components/scroll-top-container'; import { ScrollTopContainer } from '../../../components/scroll-top-container';
@@ -448,10 +448,7 @@ function RoomNotificationsGroupComp({
{notifications.map((notification) => { {notifications.map((notification) => {
const { event } = notification; const { event } = notification;
const displayName = const displayName = getMemberName(room, event.sender);
getMemberDisplayName(room, event.sender) ??
getMxIdLocalPart(event.sender) ??
event.sender;
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender); const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
const getContent = (() => event.content) as GetContentCallback; const getContent = (() => event.content) as GetContentCallback;
+3 -2
View File
@@ -74,6 +74,7 @@ import {
useSidebarItems, useSidebarItems,
} from '../../../hooks/useSidebarItems'; } from '../../../hooks/useSidebarItems';
import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { setAccountData } from '../../../utils/accountData';
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath'; import { useNavToActivePathAtom } from '../../../state/hooks/navToActivePath';
import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder'; import { useOpenedSidebarFolderAtom } from '../../../state/hooks/openedSidebarFolder';
@@ -752,7 +753,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const newSpacesContent = makeCinnySpacesContent(mx, newItems); const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
(mx as any).setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
}, },
[mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces], [mx, sidebarItems, setOpenedFolder, localEchoSidebarItem, orphanSpaces],
), ),
@@ -798,7 +799,7 @@ export function SpaceTabs({ scrollRef }: SpaceTabsProps) {
const newSpacesContent = makeCinnySpacesContent(mx, newItems); const newSpacesContent = makeCinnySpacesContent(mx, newItems);
localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent)); localEchoSidebarItem(parseSidebar(mx, orphanSpaces, newSpacesContent));
(mx as any).setAccountData(AccountDataEvent.CinnySpaces, newSpacesContent); setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
}, },
[mx, sidebarItems, orphanSpaces, localEchoSidebarItem], [mx, sidebarItems, orphanSpaces, localEchoSidebarItem],
); );
+2 -1
View File
@@ -1,5 +1,6 @@
import { MatrixClient } from 'matrix-js-sdk'; import { MatrixClient } from 'matrix-js-sdk';
import { getAccountData } from '../utils/room'; import { getAccountData } from '../utils/room';
import { setAccountData } from '../utils/accountData';
import { IEmoji, emojis } from './emoji'; import { IEmoji, emojis } from './emoji';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
@@ -39,7 +40,7 @@ export function addRecentEmoji(mx: MatrixClient, unicode: string) {
entry[1] += 1; entry[1] += 1;
} }
recentEmoji.unshift(entry); recentEmoji.unshift(entry);
(mx as any).setAccountData(AccountDataEvent.ElementRecentEmoji, { setAccountData(mx, AccountDataEvent.ElementRecentEmoji, {
recent_emoji: recentEmoji.slice(0, 100), 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 { useEffect } from 'react';
import { AccountDataEvent } from '../../types/matrix/accountData'; import { AccountDataEvent } from '../../types/matrix/accountData';
import { ThreadNotificationsContent } from '../utils/threadNotifications'; import { ThreadNotificationsContent } from '../utils/threadNotifications';
import { getAccountData } from '../utils/accountData';
// Holds the parsed `io.lotus.thread_notifications` account data. Seeded and // Holds the parsed `io.lotus.thread_notifications` account data. Seeded and
// kept in sync by `useBindThreadNotificationsAtom`. // kept in sync by `useBindThreadNotificationsAtom`.
export const threadNotificationsAtom = atom<ThreadNotificationsContent>({}); export const threadNotificationsAtom = atom<ThreadNotificationsContent>({});
const readContent = (mx: MatrixClient): ThreadNotificationsContent => const readContent = (mx: MatrixClient): ThreadNotificationsContent =>
((mx as any).getAccountData(AccountDataEvent.LotusThreadNotifications)?.getContent() as getAccountData<ThreadNotificationsContent>(mx, AccountDataEvent.LotusThreadNotifications) ?? {};
| ThreadNotificationsContent
| undefined) ?? {};
export const useBindThreadNotificationsAtom = ( export const useBindThreadNotificationsAtom = (
mx: MatrixClient, mx: MatrixClient,
+17
View File
@@ -27,6 +27,23 @@ export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastN
onClick: () => undefined, 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[]>([]); const baseAtom = atom<ToastNotif[]>([]);
// Write-only setter used in ClientNonUIFeatures // 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, IPowerLevelsContent,
IPushRule, IPushRule,
IPushRules, IPushRules,
ISendEventResponse,
JoinRule, JoinRule,
MatrixClient, MatrixClient,
MatrixEvent, MatrixEvent,
@@ -30,6 +31,7 @@ import {
UnreadInfo, UnreadInfo,
} from '../../types/matrix/room'; } from '../../types/matrix/room';
import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications'; import { getMutedThreads, ThreadNotificationsContent } from './threadNotifications';
import { getMxIdLocalPart } from './matrix';
export const getStateEvent = ( export const getStateEvent = (
room: Room, room: Room,
@@ -42,9 +44,27 @@ export const getStateEvent = (
export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] => export const getStateEvents = (room: Room, eventType: StateEvent): MatrixEvent[] =>
room.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(eventType) ?? []; 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 = ( export const getAccountData = (
mx: MatrixClient, mx: MatrixClient,
eventType: AccountDataEvent, eventType: AccountDataEvent | string,
): MatrixEvent | undefined => mx.getAccountData(eventType as any); ): MatrixEvent | undefined => mx.getAccountData(eventType as any);
export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => { export const getMDirects = (mDirectEvent: MatrixEvent): Set<string> => {
@@ -405,6 +425,9 @@ export const getMemberDisplayName = (room: Room, userId: string): string | undef
return name; return name;
}; };
export const getMemberName = (room: Room, userId: string): string =>
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
export const getMemberSearchStr = ( export const getMemberSearchStr = (
member: RoomMember, member: RoomMember,
query: string, query: string,