fix(threads): polls created from a thread composer land in the thread

PollCreator used the legacy 3-arg sendEvent (threadId null). Thread the
composer's threadRootId through and send like the sticker path.

Also (#41, same file): composer drafts are persisted as { userId, nodes }
and the restore path drops any draft with a different or missing userId.

Fixes #35

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
2026-09-12 14:48:34 -04:00
co-authored by Claude Opus 5
parent 4dd0e6637d
commit e447fdc0f3
2 changed files with 44 additions and 13 deletions
+6 -2
View File
@@ -25,10 +25,12 @@ import { useModalStyle } from '../../hooks/useModalStyle';
interface PollCreatorProps { interface PollCreatorProps {
roomId: string; roomId: string;
room: Room; room: Room;
/** Set when the composer is inside a thread so the poll lands in that thread. */
threadRootId?: string;
onClose: () => void; onClose: () => void;
} }
export function PollCreator({ roomId, onClose }: PollCreatorProps) { export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const modalStyle = useModalStyle(440); const modalStyle = useModalStyle(440);
const [question, setQuestion] = useState(''); 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( const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
'\n', '\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': { 'm.poll': {
question: { 'm.text': trimmedQuestion }, question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })), answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+38 -11
View File
@@ -398,27 +398,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
try { try {
const stored = localStorage.getItem(`draft-msg-${draftKey}`); const stored = localStorage.getItem(`draft-msg-${draftKey}`);
if (stored) { if (stored) {
const nodes = JSON.parse(stored); const parsed = JSON.parse(stored);
if (Array.isArray(nodes) && nodes.length > 0) { // [Gitea #41] Only restore a draft this same account wrote. A legacy
Transforms.insertFragment(editor, nodes); // draft (stored as a bare array, pre-dating user-scoping) or one
// Mirror the restored draft into the atom so the draft indicator // written by a different userId is foreign — drop it rather than
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft // risk pre-filling another account's unsent text into the composer.
// after a page reload — not only on same-session room re-entry. const foreign =
setMsgDraft(nodes); !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 { } catch {
// Ignore malformed stored draft // Ignore malformed stored draft
} }
} }
}, [editor, msgDraft, draftKey, setMsgDraft]); }, [editor, msgDraft, draftKey, setMsgDraft, mx]);
useEffect( useEffect(
() => () => { () => () => {
if (!isEmptyEditor(editor)) { if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children)); const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft); 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 { } else {
setMsgDraft([]); setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`); localStorage.removeItem(`draft-msg-${draftKey}`);
@@ -426,7 +446,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
}, },
[draftKey, editor, setMsgDraft], [draftKey, editor, setMsgDraft, mx],
); );
const handleFileMetadata = useCallback( 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 && ( {scheduleOpen && (
<ScheduleMessageModal <ScheduleMessageModal
roomId={roomId} roomId={roomId}