Compare commits
10
Commits
9019d7c21e
...
f3119e3dc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3119e3dc2 | ||
|
|
cecf65a3a1 | ||
|
|
02592ed43c | ||
|
|
6bd2903de1 | ||
|
|
7c52027afb | ||
|
|
2344c8273e | ||
|
|
6e4c4bc795 | ||
|
|
e447fdc0f3 | ||
|
|
4dd0e6637d | ||
|
|
dfccaec9dc |
+1
-1
@@ -742,7 +742,7 @@ never leaves it.
|
||||
|
||||
### Message Search Date Range
|
||||
|
||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds); server results are filtered client-side by `origin_server_ts` (they are not Matrix filter fields), matching the local encrypted-room search
|
||||
- A chip shows the active date range with an **×** button to clear it
|
||||
|
||||
### Encrypted Search Cache (P4-8, opt-in)
|
||||
|
||||
@@ -214,24 +214,56 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
const [draft, setDraft] = useState(() => getNote(userId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
// True while the user has unsaved local edits — prevents the store-sync
|
||||
// effect below from reacting to the echo of our own save and reverting text
|
||||
// typed after the debounce fired but before that save's account-data echo
|
||||
// landed (mirrors statusDirtyRef in Profile.tsx's ProfileStatus).
|
||||
const dirtyRef = useRef(false);
|
||||
// Latest draft/userId, kept current on every render so the unmount cleanup
|
||||
// can flush a pending save without capturing a stale closure.
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const userIdRef = useRef(userId);
|
||||
userIdRef.current = userId;
|
||||
const setNoteRef = useRef(setNote);
|
||||
setNoteRef.current = setNote;
|
||||
const prevUserIdRef = useRef(userId);
|
||||
|
||||
// Sync if account data arrives after mount
|
||||
// Sync if account data arrives after mount, but never while there are
|
||||
// unsaved local edits (including our own save's in-flight echo).
|
||||
useEffect(() => {
|
||||
if (prevUserIdRef.current !== userId) {
|
||||
prevUserIdRef.current = userId;
|
||||
dirtyRef.current = false;
|
||||
}
|
||||
if (dirtyRef.current) return;
|
||||
setDraft(getNote(userId));
|
||||
}, [getNote, userId]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
dirtyRef.current = true;
|
||||
setDraft(val);
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
dirtyRef.current = false;
|
||||
setSaving(true);
|
||||
await setNote(userId, val);
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(saveTimer.current);
|
||||
// Flush a still-pending debounced save instead of dropping it (e.g. the
|
||||
// profile panel closes within the 800ms debounce window).
|
||||
if (dirtyRef.current) {
|
||||
setNoteRef.current(userIdRef.current, draftRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey || e.repeat) return;
|
||||
// [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't
|
||||
// hijack OS/app chords (e.g. Cmd+Space) that happen to share the code.
|
||||
// Shift is allowed through — Shift+Space is a harmless combo for PTT.
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement;
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
@@ -185,7 +189,23 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
return false;
|
||||
};
|
||||
if (isEditable(target)) return;
|
||||
e.preventDefault();
|
||||
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
|
||||
// engages the mic, but the key's default action (activating the control)
|
||||
// is left alone so keyboard users can still Tab+Space the call buttons.
|
||||
const isInteractive = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute('role');
|
||||
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!isInteractive(target)) e.preventDefault();
|
||||
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||
callEmbed.control.pttActive = true;
|
||||
@@ -256,6 +276,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== deafenKey) return;
|
||||
if (e.repeat) return;
|
||||
// [Gitea #23] Ignore the deafen key with any modifier held — with the
|
||||
// default 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that
|
||||
// shouldn't also toggle deafen (and previously got preventDefault()ed).
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
|
||||
if (isEditable(e.target as HTMLElement)) return;
|
||||
e.preventDefault();
|
||||
callEmbed.control.toggleSound();
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DECORATION_CATEGORIES,
|
||||
ALL_DECORATIONS,
|
||||
decorationUrl,
|
||||
isValidDecorationSlug,
|
||||
} from './avatarDecorations';
|
||||
|
||||
test('decorationUrl builds a CDN png url from the slug', () => {
|
||||
@@ -66,3 +67,20 @@ test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
|
||||
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: accepts a real catalog slug', () => {
|
||||
assert.equal(isValidDecorationSlug('joystick'), true);
|
||||
assert.equal(isValidDecorationSlug('lotus_flower'), true);
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: rejects a path-traversal string', () => {
|
||||
assert.equal(isValidDecorationSlug('../../anything'), false);
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: rejects a slug carrying a query string', () => {
|
||||
assert.equal(isValidDecorationSlug('joystick?u=probe'), false);
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: rejects an empty string', () => {
|
||||
assert.equal(isValidDecorationSlug(''), false);
|
||||
});
|
||||
|
||||
@@ -188,6 +188,19 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
|
||||
(c) => c.decorations,
|
||||
);
|
||||
|
||||
const DECORATION_SLUGS = new Set(ALL_DECORATIONS.map((d) => d.slug));
|
||||
|
||||
/**
|
||||
* Whether `slug` is a known catalog decoration. `io.lotus.avatar_decoration`
|
||||
* is a free-form MSC4133 profile field set by a remote user (and their
|
||||
* homeserver), and its value is interpolated verbatim into `decorationUrl`
|
||||
* — so anything not in the catalog (path traversal, a query string, an
|
||||
* oversized value) must be rejected before it reaches a URL.
|
||||
*/
|
||||
export function isValidDecorationSlug(slug: string): boolean {
|
||||
return DECORATION_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
export function decorationUrl(slug: string): string {
|
||||
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { getStateEvent } from '../../utils/room';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
MessageSearchParams,
|
||||
@@ -316,12 +317,21 @@ export function MessageSearch({
|
||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
||||
});
|
||||
|
||||
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
|
||||
// server results and the local/encrypted-cache results, so the filter chips
|
||||
// narrow the whole UI consistently rather than only the server section.
|
||||
// Shared client-side post-filter (date range + msgtype + pinned) applied to
|
||||
// BOTH the server results and the local/encrypted-cache results, so the
|
||||
// filter chips narrow the whole UI consistently rather than only the
|
||||
// server section. The date range must be enforced here because the Matrix
|
||||
// search API has no timestamp filter fields (see useMessageSearch.ts); the
|
||||
// local/encrypted path already filters in-range before this runs, so this
|
||||
// is a no-op there and only actually trims the server section.
|
||||
const applyResultFilters = useCallback(
|
||||
(allGroups: ResultGroup[]): ResultGroup[] => {
|
||||
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
|
||||
const inDateRange = filterGroupsByDateRange(
|
||||
allGroups,
|
||||
msgSearchParams.fromTs,
|
||||
msgSearchParams.toTs,
|
||||
);
|
||||
const byMsgType = filterGroupsByMsgType(inDateRange, msgTypeFilters);
|
||||
if (!pinnedOnly) return byMsgType;
|
||||
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
|
||||
// (where `mx` is available); the pure helper only consumes the predicate.
|
||||
@@ -343,7 +353,7 @@ export function MessageSearch({
|
||||
};
|
||||
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
|
||||
},
|
||||
[msgTypeFilters, pinnedOnly, mx],
|
||||
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
|
||||
import {
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
ResultGroup,
|
||||
} from './useMessageSearch';
|
||||
|
||||
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
|
||||
// (event.content.msgtype, event.event_id, group.roomId).
|
||||
@@ -9,6 +14,11 @@ const item = (msgtype: string | undefined, eventId: string) => ({
|
||||
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
|
||||
context: {},
|
||||
});
|
||||
const tsItem = (eventId: string, ts: number) => ({
|
||||
rank: 1,
|
||||
event: { event_id: eventId, origin_server_ts: ts, content: {} },
|
||||
context: {},
|
||||
});
|
||||
const mkGroups = (
|
||||
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
|
||||
): ResultGroup[] => groups as unknown as ResultGroup[];
|
||||
@@ -48,6 +58,33 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
|
||||
assert.equal(out[0].items[0].event.event_id, '$2');
|
||||
});
|
||||
|
||||
test('filterGroupsByDateRange: no bounds returns groups unchanged', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [tsItem('$1', 100)] });
|
||||
assert.equal(filterGroupsByDateRange(groups, undefined, undefined), groups);
|
||||
});
|
||||
|
||||
test('filterGroupsByDateRange: keeps only items within an inclusive range', () => {
|
||||
const groups = mkGroups({
|
||||
roomId: '!r1',
|
||||
items: [tsItem('$1', 50), tsItem('$2', 100), tsItem('$3', 150), tsItem('$4', 200)],
|
||||
});
|
||||
const out = filterGroupsByDateRange(groups, 100, 150);
|
||||
assert.deepEqual(
|
||||
out[0].items.map((i) => i.event.event_id),
|
||||
['$2', '$3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterGroupsByDateRange: drops groups left empty and supports one-sided bounds', () => {
|
||||
const groups = mkGroups(
|
||||
{ roomId: '!r1', items: [tsItem('$1', 50)] },
|
||||
{ roomId: '!r2', items: [tsItem('$2', 500)] },
|
||||
);
|
||||
const out = filterGroupsByDateRange(groups, 100, undefined);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].roomId, '!r2');
|
||||
});
|
||||
|
||||
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
|
||||
assert.equal(
|
||||
|
||||
@@ -71,6 +71,31 @@ export const filterGroupsByPinned = (
|
||||
.filter((group) => group.items.length > 0);
|
||||
};
|
||||
|
||||
/** Inclusive-range predicate, mirrored from `inRange` in useLocalMessageSearch.ts. */
|
||||
export const inTsRange = (ts: number, fromTs?: number, toTs?: number): boolean =>
|
||||
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
||||
|
||||
/**
|
||||
* Filter result groups to items whose `origin_server_ts` falls within
|
||||
* [fromTs, toTs] (inclusive, either bound optional). The Matrix search API
|
||||
* has no timestamp filter fields, so server results must be post-filtered
|
||||
* here — the same predicate the local/encrypted search already applies.
|
||||
* Now-empty groups are dropped.
|
||||
*/
|
||||
export const filterGroupsByDateRange = (
|
||||
groups: ResultGroup[],
|
||||
fromTs?: number,
|
||||
toTs?: number,
|
||||
): ResultGroup[] => {
|
||||
if (fromTs === undefined && toTs === undefined) return groups;
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => inTsRange(item.event.origin_server_ts, fromTs, toTs)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
@@ -119,7 +144,9 @@ export type MessageSearchParams = {
|
||||
};
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
|
||||
// fromTs/toTs are intentionally not sent to the server (see comment below) —
|
||||
// callers post-filter results with filterGroupsByDateRange instead.
|
||||
const { term, order, rooms, senders, containsUrl } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
@@ -142,9 +169,10 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
limit,
|
||||
rooms,
|
||||
senders,
|
||||
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
|
||||
...(fromTs !== undefined && { from_ts: fromTs }),
|
||||
...(toTs !== undefined && { to_ts: toTs }),
|
||||
// `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not
|
||||
// Matrix filter fields and the homeserver silently drops them, so the
|
||||
// date range is instead enforced client-side (see filterGroupsByDateRange).
|
||||
// contains_url is a valid spec field not yet in SDK types.
|
||||
...(containsUrl !== undefined && { contains_url: containsUrl }),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
@@ -161,7 +189,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
},
|
||||
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
|
||||
[mx, term, order, rooms, senders, containsUrl],
|
||||
);
|
||||
|
||||
return searchMessages;
|
||||
|
||||
@@ -42,7 +42,6 @@ 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';
|
||||
@@ -74,8 +73,8 @@ import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCr
|
||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
import {
|
||||
LOCAL_ROOM_NAMES_KEY,
|
||||
getLocalRoomNamesContent,
|
||||
setLocalRoomName,
|
||||
useHasLocalRoomName,
|
||||
useLocalRoomName,
|
||||
} from '../../hooks/useRoomMeta';
|
||||
@@ -138,22 +137,16 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||
const handleSave = useCallback(() => {
|
||||
const newName = inputRef.current?.value.trim() ?? '';
|
||||
if (newName.length > 255) return;
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
if (newName === '') {
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
} else {
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
|
||||
rooms: { ...existing.rooms, [room.roomId]: newName },
|
||||
});
|
||||
}
|
||||
// Routed through the shared write queue (setLocalRoomName) instead of a
|
||||
// read-modify-write against the SDK's local cache, which stays stale
|
||||
// until the /sync echo lands and would otherwise let a second rename
|
||||
// clobber a still-in-flight first rename.
|
||||
setLocalRoomName(mx, room.roomId, newName);
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
setLocalRoomName(mx, room.roomId, '');
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
|
||||
@@ -24,6 +24,14 @@ const POLICY_USER_EVENT = 'm.policy.rule.user';
|
||||
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
|
||||
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
|
||||
|
||||
// Legacy, unstable-prefixed event types still emitted by Draupnir/Mjolnir
|
||||
// policy lists that predate MSC stabilization (or haven't migrated). Queried
|
||||
// alongside the stable types and merged/de-duped so those lists don't show
|
||||
// as falsely empty.
|
||||
const LEGACY_POLICY_USER_EVENT = 'org.matrix.mjolnir.rule.user';
|
||||
const LEGACY_POLICY_ROOM_EVENT = 'org.matrix.mjolnir.rule.room';
|
||||
const LEGACY_POLICY_SERVER_EVENT = 'org.matrix.mjolnir.rule.server';
|
||||
|
||||
type PolicyRuleContent = {
|
||||
entity?: string;
|
||||
reason?: string;
|
||||
@@ -76,6 +84,23 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
||||
.filter((entry) => entry.entity !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge policy entries from the stable and legacy event types for a rule
|
||||
* kind, de-duplicating by entity+recommendation so a room that emits both a
|
||||
* stable and a legacy rule for the same target isn't double-listed.
|
||||
*/
|
||||
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const result: PolicyEntry[] = [];
|
||||
entries.forEach((entry) => {
|
||||
const key = `${entry.entity} ${entry.recommendation}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
result.push(entry);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Entry row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
||||
@@ -201,9 +226,24 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
|
||||
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
|
||||
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
|
||||
setUserEntries(
|
||||
dedupePolicyEntries([
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)),
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_USER_EVENT)),
|
||||
]),
|
||||
);
|
||||
setRoomEntries(
|
||||
dedupePolicyEntries([
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)),
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_ROOM_EVENT)),
|
||||
]),
|
||||
);
|
||||
setServerEntries(
|
||||
dedupePolicyEntries([
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)),
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_SERVER_EVENT)),
|
||||
]),
|
||||
);
|
||||
setLoadedRoomId(roomId);
|
||||
setError(undefined);
|
||||
}, [mx, roomIdInput]);
|
||||
|
||||
@@ -25,10 +25,12 @@ import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
interface PollCreatorProps {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
/** Set when the composer is inside a thread so the poll lands in that thread. */
|
||||
threadRootId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) {
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(440);
|
||||
const [question, setQuestion] = useState('');
|
||||
@@ -85,7 +87,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
|
||||
'\n',
|
||||
);
|
||||
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||
// Pass the thread id explicitly (like the sticker path in RoomInput); the
|
||||
// legacy 3-arg form always resolves to the main timeline.
|
||||
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
|
||||
'm.poll': {
|
||||
question: { 'm.text': trimmedQuestion },
|
||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||
|
||||
@@ -398,27 +398,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
try {
|
||||
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||||
if (stored) {
|
||||
const nodes = JSON.parse(stored);
|
||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||
Transforms.insertFragment(editor, nodes);
|
||||
// Mirror the restored draft into the atom so the draft indicator
|
||||
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||
// after a page reload — not only on same-session room re-entry.
|
||||
setMsgDraft(nodes);
|
||||
const parsed = JSON.parse(stored);
|
||||
// [Gitea #41] Only restore a draft this same account wrote. A legacy
|
||||
// draft (stored as a bare array, pre-dating user-scoping) or one
|
||||
// written by a different userId is foreign — drop it rather than
|
||||
// risk pre-filling another account's unsent text into the composer.
|
||||
const foreign =
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
parsed.userId !== mx.getUserId();
|
||||
if (foreign) {
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
} else {
|
||||
const nodes = parsed.nodes;
|
||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||
Transforms.insertFragment(editor, nodes);
|
||||
// Mirror the restored draft into the atom so the draft indicator
|
||||
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||
// after a page reload — not only on same-session room re-entry.
|
||||
setMsgDraft(nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed stored draft
|
||||
}
|
||||
}
|
||||
}, [editor, msgDraft, draftKey, setMsgDraft]);
|
||||
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
|
||||
// [Gitea #41] Tag the persisted draft with the writing user's id so a
|
||||
// different account logging into this browser can't have it hydrated
|
||||
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
|
||||
localStorage.setItem(
|
||||
`draft-msg-${draftKey}`,
|
||||
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
|
||||
);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
@@ -426,7 +446,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[draftKey, editor, setMsgDraft],
|
||||
[draftKey, editor, setMsgDraft, mx],
|
||||
);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
@@ -1480,7 +1500,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||
{pollOpen && (
|
||||
<PollCreator
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
threadRootId={threadRootId}
|
||||
onClose={() => setPollOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{scheduleOpen && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
|
||||
@@ -116,6 +116,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
useKeyDown(
|
||||
@@ -157,7 +159,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
}
|
||||
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
||||
lastReadEventIdRef.current = latestId;
|
||||
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
||||
markThreadAsRead(mx, thread, hideActivity || privateReadReceipts).catch(() => {
|
||||
// Allow a retry on the next event if the receipt POST failed.
|
||||
if (lastReadEventIdRef.current === latestId) {
|
||||
lastReadEventIdRef.current = undefined;
|
||||
@@ -171,7 +173,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
thread.off(ThreadEvent.NewReply, markRead);
|
||||
thread.off(RoomEvent.Timeline, markRead);
|
||||
};
|
||||
}, [mx, thread, privateReadReceipts]);
|
||||
}, [mx, thread, privateReadReceipts, hideActivity]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -545,9 +545,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
[room, thread, setReplyDraft, editor],
|
||||
);
|
||||
|
||||
// Non-thread relations (reactions, edits) that target the thread root live only in
|
||||
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
|
||||
// for the root must use the room set instead of the thread set.
|
||||
const getRelationTimelineSet = useCallback(
|
||||
(eventId: string) =>
|
||||
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
|
||||
[room, thread],
|
||||
);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
(targetEventId: string, key: string, shortcode?: string) => {
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const isRoot = targetEventId === thread.id;
|
||||
const timelineSet = getRelationTimelineSet(targetEventId);
|
||||
const relations = getEventReactions(timelineSet, targetEventId);
|
||||
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||
@@ -563,13 +573,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
thread.id,
|
||||
// A reaction on the root is a main-timeline event, not a thread reply.
|
||||
isRoot ? null : thread.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room, thread],
|
||||
[mx, room, thread, getRelationTimelineSet],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
@@ -715,7 +726,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
): ReactNode => {
|
||||
const mEventId = mEvent.getId();
|
||||
if (!mEventId) return null;
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const timelineSet = getRelationTimelineSet(mEventId);
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = !!reactions && reactions.length > 0;
|
||||
@@ -783,7 +794,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
);
|
||||
},
|
||||
[
|
||||
thread,
|
||||
room,
|
||||
messageSpacing,
|
||||
messageLayout,
|
||||
@@ -810,6 +820,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
lotusTerminal,
|
||||
mx,
|
||||
renderMessageContent,
|
||||
getRelationTimelineSet,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||
import { DenoiseTester } from './DenoiseTester';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||||
|
||||
/**
|
||||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||
@@ -1474,8 +1475,12 @@ function Privacy() {
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyBind(setter: (code: string) => void) {
|
||||
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
|
||||
// collides with the other call key (`otherKey`), so a rebind can never trap
|
||||
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
|
||||
function useKeyBind(setter: (code: string) => void, otherKey?: string) {
|
||||
const [listening, setListening] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||||
|
||||
useEffect(
|
||||
@@ -1487,19 +1492,28 @@ function useKeyBind(setter: (code: string) => void) {
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (listening) return;
|
||||
setError(null);
|
||||
setListening(true);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.code !== 'Escape') setter(e.code);
|
||||
if (e.code === 'Escape') {
|
||||
// Escape always cancels the rebind without changing the key.
|
||||
} else if (!isBindableCallKey(e.code)) {
|
||||
setError('That key can’t be bound — it’s needed for keyboard navigation.');
|
||||
} else if (otherKey && e.code === otherKey) {
|
||||
setError('That key is already bound to the other call shortcut.');
|
||||
} else {
|
||||
setter(e.code);
|
||||
}
|
||||
setListening(false);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
listenerRef.current = null;
|
||||
};
|
||||
listenerRef.current = onKey;
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
}, [listening, setter]);
|
||||
}, [listening, setter, otherKey]);
|
||||
|
||||
return { listening, startListening };
|
||||
return { listening, startListening, error };
|
||||
}
|
||||
|
||||
const keyLabel = (code: string) =>
|
||||
@@ -1556,8 +1570,8 @@ function Calls() {
|
||||
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||
};
|
||||
|
||||
const pttBind = useKeyBind(setPttKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey);
|
||||
const pttBind = useKeyBind(setPttKey, deafenKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey, pttKey);
|
||||
|
||||
const mlSupported = isMLDenoiseSupported();
|
||||
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
|
||||
@@ -1823,7 +1837,7 @@ function Calls() {
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="PTT Key"
|
||||
description="Press a key to bind it as your push-to-talk key."
|
||||
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
@@ -1841,7 +1855,9 @@ function Calls() {
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Deafen"
|
||||
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
|
||||
description={
|
||||
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MatrixError, Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
@@ -51,7 +52,10 @@ function fetchDecoration(
|
||||
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
|
||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
// The remote profile field is free-form and attacker-controlled; only
|
||||
// accept it when it names a real catalog decoration (see decorationUrl).
|
||||
const val = rawVal && isValidDecorationSlug(rawVal) ? rawVal : null;
|
||||
cache.set(userId, val);
|
||||
return val;
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { Descendant } from 'slate';
|
||||
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
|
||||
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
/**
|
||||
* On startup, pre-fill the per-room message-draft atoms from their localStorage
|
||||
@@ -13,11 +13,19 @@ import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
||||
* (identical content), so composer restore is unaffected.
|
||||
*
|
||||
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
|
||||
*
|
||||
* [Gitea #41] Drafts are stored as `{ userId, nodes }` (RoomInput's persist
|
||||
* path) so a draft written by a different account never gets hydrated into the
|
||||
* currently logged-in user's session. A legacy draft (stored as a bare array,
|
||||
* pre-dating user-scoping) has no userId to check, so it's treated as foreign
|
||||
* and dropped rather than trusted.
|
||||
*/
|
||||
export function useHydrateMsgDrafts(): void {
|
||||
const store = useStore();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
const userId = mx.getUserId();
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(localStorage);
|
||||
@@ -34,7 +42,19 @@ export function useHydrateMsgDrafts(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return;
|
||||
const nodes = JSON.parse(stored) as Descendant[];
|
||||
const parsed = JSON.parse(stored);
|
||||
const foreign =
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
parsed.userId !== userId;
|
||||
if (foreign) {
|
||||
// Another account's (or a pre-scoping legacy) draft — never hydrate it,
|
||||
// and drop it so it can't resurface for the next login either.
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const nodes = parsed.nodes;
|
||||
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
|
||||
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
|
||||
}
|
||||
@@ -42,5 +62,5 @@ export function useHydrateMsgDrafts(): void {
|
||||
// Ignore a malformed stored draft.
|
||||
}
|
||||
});
|
||||
}, [store]);
|
||||
}, [store, mx]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { getLocalRoomNamesContent, setLocalRoomName } from './useRoomMeta';
|
||||
|
||||
// Minimal fake client. Mirrors the real SDK behavior that matters here:
|
||||
// setAccountData resolves WITHOUT updating what getAccountData returns — the
|
||||
// local cache only updates once the /sync echo is delivered via the
|
||||
// AccountData listener. This is exactly the staleness that let two
|
||||
// back-to-back renames clobber each other before the fix (issue #17).
|
||||
const makeFakeMx = () => {
|
||||
const accountData: Record<string, unknown> = {};
|
||||
const listeners: Array<(e: { getType: () => string; getContent: () => unknown }) => void> = [];
|
||||
const setAccountDataCalls: Array<{ type: string; content: unknown }> = [];
|
||||
|
||||
const mx = {
|
||||
getAccountData: (type: string) => {
|
||||
const content = accountData[type];
|
||||
return content ? { getContent: () => content } : undefined;
|
||||
},
|
||||
setAccountData: (type: string, content: unknown) => {
|
||||
setAccountDataCalls.push({ type, content });
|
||||
// Deliberately do NOT update `accountData` here — the real SDK doesn't
|
||||
// either. It only updates on the emitted echo below.
|
||||
return Promise.resolve();
|
||||
},
|
||||
on: (_event: unknown, h: (e: { getType: () => string; getContent: () => unknown }) => void) => {
|
||||
listeners.push(h);
|
||||
},
|
||||
removeListener: (
|
||||
_event: unknown,
|
||||
h: (e: { getType: () => string; getContent: () => unknown }) => void,
|
||||
) => {
|
||||
const i = listeners.indexOf(h);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const emitEcho = (type: string, content: unknown) => {
|
||||
accountData[type] = content;
|
||||
listeners.forEach((h) => h({ getType: () => type, getContent: () => content }));
|
||||
};
|
||||
|
||||
return {
|
||||
mx: mx as unknown as MatrixClient,
|
||||
emitEcho,
|
||||
setAccountDataCalls,
|
||||
};
|
||||
};
|
||||
|
||||
test('back-to-back renames of different rooms both survive with no echo in between', async () => {
|
||||
const { mx } = makeFakeMx();
|
||||
|
||||
// Rename room A, then room B, before either write's /sync echo has landed —
|
||||
// the exact scenario from issue #17.
|
||||
const writeA = setLocalRoomName(mx, '!a:example.org', 'Room A renamed');
|
||||
const writeB = setLocalRoomName(mx, '!b:example.org', 'Room B renamed');
|
||||
await Promise.all([writeA, writeB]);
|
||||
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
assert.deepEqual(content.rooms, {
|
||||
'!a:example.org': 'Room A renamed',
|
||||
'!b:example.org': 'Room B renamed',
|
||||
});
|
||||
});
|
||||
|
||||
test("writes are serialized: the second write computes from the first write's result", async () => {
|
||||
const { mx, setAccountDataCalls } = makeFakeMx();
|
||||
|
||||
await Promise.all([
|
||||
setLocalRoomName(mx, '!a:example.org', 'A'),
|
||||
setLocalRoomName(mx, '!b:example.org', 'B'),
|
||||
]);
|
||||
|
||||
// The last PUT to the server must carry both renames — proof the second
|
||||
// write's compute() saw the first write's in-memory result rather than a
|
||||
// stale snapshot from before it landed.
|
||||
const lastCall = setAccountDataCalls[setAccountDataCalls.length - 1];
|
||||
assert.deepEqual(lastCall.content, {
|
||||
rooms: { '!a:example.org': 'A', '!b:example.org': 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
test('clearing a local name removes only that room', async () => {
|
||||
const { mx } = makeFakeMx();
|
||||
|
||||
await setLocalRoomName(mx, '!a:example.org', 'A');
|
||||
await setLocalRoomName(mx, '!b:example.org', 'B');
|
||||
await setLocalRoomName(mx, '!a:example.org', '');
|
||||
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
assert.deepEqual(content.rooms, { '!b:example.org': 'B' });
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getAccountData } from '../utils/accountData';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
|
||||
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
|
||||
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
|
||||
@@ -40,79 +40,72 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
|
||||
|
||||
export type LocalRoomNamesContent = { rooms: Record<string, string> };
|
||||
|
||||
type LocalRoomNamesMap = Record<string, string>;
|
||||
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped: setAccountData does not update
|
||||
// the SDK's local cache (it only resolves once the /sync echo lands), so a
|
||||
// plain read-modify-write against getAccountData can lose a rename that is
|
||||
// still in flight when a second rename is issued (fixed: back-to-back renames
|
||||
// of different rooms no longer clobber each other).
|
||||
const roomNamesStore = createAccountDataListStore<LocalRoomNamesMap, LocalRoomNamesContent>({
|
||||
eventType: LOCAL_ROOM_NAMES_KEY,
|
||||
read: (content) =>
|
||||
content && typeof content === 'object' && typeof content.rooms === 'object'
|
||||
? content.rooms
|
||||
: {},
|
||||
write: (rooms) => ({ rooms }),
|
||||
});
|
||||
|
||||
export function getLocalRoomNamesContent(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
): LocalRoomNamesContent {
|
||||
const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
|
||||
if (
|
||||
raw &&
|
||||
typeof raw === 'object' &&
|
||||
'rooms' in raw &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
typeof (raw as any).rooms === 'object'
|
||||
) {
|
||||
return raw as LocalRoomNamesContent;
|
||||
}
|
||||
return { rooms: {} };
|
||||
return { rooms: roomNamesStore.getLatest(mx) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, when `name` is empty) the local display name for a room.
|
||||
* Routed through the shared write queue so back-to-back renames of different
|
||||
* rooms are always computed from the latest snapshot instead of a stale one.
|
||||
*/
|
||||
export function setLocalRoomName(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return roomNamesStore.enqueueWrite(mx, (current) => {
|
||||
if (!name) {
|
||||
const { [roomId]: _removed, ...rest } = current;
|
||||
return rest;
|
||||
}
|
||||
return { ...current, [roomId]: name };
|
||||
});
|
||||
}
|
||||
|
||||
export const useLocalRoomName = (room: Room): string => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const getLocalName = useCallback((): string => {
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
return content.rooms[room.roomId] ?? room.name;
|
||||
}, [mx, room]);
|
||||
|
||||
const [name, setName] = useState(getLocalName);
|
||||
const localNames = roomNamesStore.useValue(mx);
|
||||
const [name, setName] = useState(room.name);
|
||||
|
||||
useEffect(() => {
|
||||
setName(getLocalName());
|
||||
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
|
||||
setName(getLocalName());
|
||||
};
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
setName(room.name);
|
||||
|
||||
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
||||
setName(getLocalName());
|
||||
setName(room.name);
|
||||
};
|
||||
room.on(RoomEvent.Name, handleRoomNameChange);
|
||||
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
room.removeListener(RoomEvent.Name, handleRoomNameChange);
|
||||
};
|
||||
}, [mx, room, getLocalName]);
|
||||
}, [room]);
|
||||
|
||||
return name;
|
||||
return localNames[room.roomId] ?? name;
|
||||
};
|
||||
|
||||
export const useHasLocalRoomName = (roomId: string): boolean => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const check = useCallback((): boolean => {
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
return !!content.rooms[roomId];
|
||||
}, [mx, roomId]);
|
||||
|
||||
const [hasLocal, setHasLocal] = useState(check);
|
||||
|
||||
useEffect(() => {
|
||||
setHasLocal(check());
|
||||
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
|
||||
setHasLocal(check());
|
||||
};
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [mx, check]);
|
||||
|
||||
return hasLocal;
|
||||
const localNames = roomNamesStore.useValue(mx);
|
||||
return !!localNames[roomId];
|
||||
};
|
||||
|
||||
export type RoomTopicContent = {
|
||||
|
||||
@@ -5,6 +5,31 @@ import { clearRecentForwardTargets } from './recentForwardTargets';
|
||||
import { clearRecentGifs } from './recentGifs';
|
||||
import { clearRecentStickers } from './recentStickers';
|
||||
import { clearNavToActivePathStore } from './navToActivePath';
|
||||
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
||||
|
||||
/**
|
||||
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
|
||||
* hold decrypted, unsent message text with no user scoping, so leaving them in
|
||||
* place across logout lets the next account on this device see (and send) the
|
||||
* previous user's draft the moment they open the same room.
|
||||
*/
|
||||
const clearMsgDrafts = (): void => {
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(localStorage);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
keys.forEach((key) => {
|
||||
if (key.startsWith(DRAFT_MSG_KEY_PREFIX)) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// Best-effort — a single unreadable/blocked key must not abort the sweep.
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Single auditable place that wipes the `localStorage` caches holding decrypted
|
||||
@@ -18,6 +43,10 @@ import { clearNavToActivePathStore } from './navToActivePath';
|
||||
* - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII)
|
||||
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
||||
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
||||
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
|
||||
* user — see [Gitea #41]; previously deliberately preserved across logout
|
||||
* (N98), which let the next account on this device see/send a prior user's
|
||||
* draft, so this is no longer a "by design" exemption)
|
||||
*
|
||||
* NOT swept here (by design):
|
||||
* - session credential keys → `removeFallbackSession()`
|
||||
@@ -25,9 +54,8 @@ import { clearNavToActivePathStore } from './navToActivePath';
|
||||
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
|
||||
* `mx.clearStores()` on both logout paths
|
||||
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
|
||||
* - unsent composer drafts (`draft-msg-*`) and the presence status message
|
||||
* (`lotus-status-msg-*`) are deliberately preserved across a normal logout
|
||||
* (N98); clearing them is a separate product decision
|
||||
* - the presence status message (`lotus-status-msg-*`) is deliberately
|
||||
* preserved across a normal logout; clearing it is a separate product decision
|
||||
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
||||
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
||||
* preferences, not swept here
|
||||
@@ -39,5 +67,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearRecentForwardTargets();
|
||||
clearRecentGifs();
|
||||
clearRecentStickers();
|
||||
clearMsgDrafts();
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isBindableCallKey } from './callKeybind';
|
||||
|
||||
test('isBindableCallKey rejects navigation-critical codes', () => {
|
||||
[
|
||||
'Escape',
|
||||
'Tab',
|
||||
'Enter',
|
||||
'NumpadEnter',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
].forEach((code) => {
|
||||
assert.equal(isBindableCallKey(code), false, `${code} should be unbindable`);
|
||||
});
|
||||
});
|
||||
|
||||
test('isBindableCallKey rejects bare modifier codes', () => {
|
||||
[
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
].forEach((code) => {
|
||||
assert.equal(isBindableCallKey(code), false, `${code} should be unbindable`);
|
||||
});
|
||||
});
|
||||
|
||||
test('isBindableCallKey accepts ordinary keys', () => {
|
||||
['Space', 'KeyM', 'KeyQ', 'Digit1', 'F13'].forEach((code) => {
|
||||
assert.equal(isBindableCallKey(code), true, `${code} should be bindable`);
|
||||
});
|
||||
});
|
||||
|
||||
test('isBindableCallKey rejects the empty string', () => {
|
||||
assert.equal(isBindableCallKey(''), false);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* `KeyboardEvent.code` values the push-to-talk / push-to-deafen rebind must
|
||||
* never accept. Binding one of these turns it into a keyboard trap (the call
|
||||
* hotkey listener swallows the key everywhere outside an editable field for
|
||||
* the rest of the call) or collides with a bare modifier chord.
|
||||
*/
|
||||
const UNBINDABLE_CALL_KEY_CODES = new Set<string>([
|
||||
'Escape',
|
||||
'Tab',
|
||||
'Enter',
|
||||
'NumpadEnter',
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Home',
|
||||
'End',
|
||||
'PageUp',
|
||||
'PageDown',
|
||||
'ShiftLeft',
|
||||
'ShiftRight',
|
||||
'ControlLeft',
|
||||
'ControlRight',
|
||||
'AltLeft',
|
||||
'AltRight',
|
||||
'MetaLeft',
|
||||
'MetaRight',
|
||||
]);
|
||||
|
||||
/** Whether `code` is safe to bind as a call hotkey (PTT / push-to-deafen). */
|
||||
export function isBindableCallKey(code: string): boolean {
|
||||
return code.length > 0 && !UNBINDABLE_CALL_KEY_CODES.has(code);
|
||||
}
|
||||
Reference in New Issue
Block a user