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