Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 f9172cc339 fix(polls): you can vote on a poll you just created (#284)
CI / Build & Quality Checks (pull_request) Successful in 2m53s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 8s
CI / Playwright smoke (e2e) (pull_request) Successful in 14m12s
PollContent read mEvent.getId() once at render. For your own new poll
that is the local echo id (~!room:txn), and nothing re-rendered it when
the SDK swapped in the real $id, so the vote related to the local id
and the SDK threw synchronously (getPendingEvents under chronological
ordering): no vote was ever sent. Track the id via LocalEventIdReplaced
and Status, block voting while it is still local, and catch synchronous
throws. Adds the Playwright regression the issue asks for.

Fixes #284

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-17 19:54:01 -04:00
3 changed files with 221 additions and 202 deletions
+46
View File
@@ -55,6 +55,52 @@ test.describe('local homeserver regression', () => {
expect(console_.unexpected()).toEqual([]);
});
test('you can vote on a poll you just created, without reloading (#284)', async ({ page }) => {
const console_ = collectConsole(page);
const room = await createRoom(alice, 'Poll Room');
await loginUI(page, alice);
await openRoom(page, room);
const question = uniq('Lunch? ');
await page.getByRole('button', { name: 'Create poll' }).first().click();
await page.getByPlaceholder('Ask a question…').fill(question);
await page.getByLabel('Option 1', { exact: true }).fill('Pizza');
await page.getByLabel('Option 2', { exact: true }).fill('Tacos');
await page.getByRole('button', { name: 'Create Poll' }).last().click();
const poll = page.locator('[data-message-id]', { hasText: question }).last();
await expect(poll).toBeVisible();
type Ev = {
type: string;
event_id: string;
content: { 'm.relates_to'?: { event_id?: string } };
};
const events = async () =>
(
await api<{ chunk: Ev[] }>(
'GET',
`/_matrix/client/v3/rooms/${enc(room)}/messages?dir=b&limit=10`,
alice.token,
)
).chunk;
// Vote once the poll has reached the server (its row may still carry the local id).
await expect
.poll(async () => (await events()).some((e) => /poll\.start/.test(e.type)))
.toBe(true);
await poll.getByText('Tacos', { exact: true }).first().click();
await expect
.poll(async () => {
const chunk = await events();
const start = chunk.find((e) => /poll\.start/.test(e.type));
return chunk.some(
(e) =>
/poll\.response/.test(e.type) &&
e.content['m.relates_to']?.event_id === start?.event_id &&
start?.event_id.startsWith('$'),
);
})
.toBe(true);
expect(console_.unexpected()).toEqual([]);
});
test('your own message scrolls into view even after scrolling up (#212)', async ({ page }) => {
const room = await createRoom(alice, 'Scroll Room');
await Array.from({ length: 40 }).reduce<Promise<unknown>>(
@@ -1,7 +1,7 @@
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getMemberName, sendRoomEvent } from '../../../utils/room';
import {
@@ -99,7 +99,21 @@ export function PollContent({
}) {
const mx = useMatrixClient();
const roomId = room.roomId;
const eventId = mEvent.getId();
// [Gitea #284] A poll you just created starts with its local-echo id
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
// doesn't re-render us. Track the id so votes and the tally use the real one.
const [eventId, setEventId] = useState(() => mEvent.getId());
useEffect(() => {
const sync = () => setEventId(mEvent.getId());
sync();
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.on(MatrixEventEvent.Status, sync);
return () => {
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
mEvent.off(MatrixEventEvent.Status, sync);
};
}, [mEvent]);
const isLocalEcho = !!eventId?.startsWith('~');
const senderId = mEvent.getSender();
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
@@ -192,8 +206,9 @@ export function PollContent({
const isMultiple = maxSelections > 1;
const showResults = resultsVisible(isUndisclosed, isEnded);
const canVote = !!roomId && !!eventId && !isEnded;
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
const canEnd =
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
const myVotes = pending ?? tally.myVotes;
const { counts, total, voters } = tally;
@@ -219,25 +234,34 @@ export function PollContent({
setPending(next);
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
// accepts both namespaces).
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
// The SDK can throw synchronously (e.g. a relation to a pending event).
try {
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.selections': Array.from(next),
}).catch(() => setPending(null));
} catch {
setPending(null);
}
};
const handleEndPoll = () => {
if (!roomId || !eventId || ending) return;
setEnding(true);
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
})
.then(() => {
setConfirmEnd(false);
setEnding(false);
try {
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
'm.poll.end': {},
'm.text': 'The poll has ended.',
})
.catch(() => setEnding(false));
.then(() => {
setConfirmEnd(false);
setEnding(false);
})
.catch(() => setEnding(false));
} catch {
setEnding(false);
}
};
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
+134 -185
View File
@@ -255,8 +255,6 @@ function HomeCustomSection({
return (localName ?? matrixName).toLowerCase().includes(query);
});
}, [mx, section.rooms, closed, roomsWithUnreadSet, selectedRoomId, filterQuery]);
// [Gitea #289] While filtering, a section with no matches is hidden.
if (filterQuery.trim() && items.length === 0) return null;
// Sections are user-curated and small, so they render plainly — no
// virtualizer per section (a late-mounted one never measured its scroller).
return (
@@ -445,26 +443,6 @@ export function Home() {
});
}, [mx, sortedRooms, filterQuery]);
// [Gitea #289] While filtering, hide empty sections; with no match at all,
// say so instead of leaving bare headers.
const filtering = filterQuery.trim().length > 0;
const customSectionMatch = useMemo(() => {
if (!filtering) return true;
const query = filterQuery.toLowerCase();
const localNames = getLocalRoomNamesContent(mx);
return customSections.some((section) =>
section.rooms.some((rId) =>
(localNames.rooms[rId] ?? mx.getRoom(rId)?.name ?? '').toLowerCase().includes(query),
),
);
}, [mx, customSections, filtering, filterQuery]);
const noFilterMatch =
filtering &&
!customSectionMatch &&
filteredFavoriteRooms.length === 0 &&
filteredRooms.length === 0 &&
filteredLowPriorityRooms.length === 0;
const favVirtualizer = useVirtualizer({
count: filteredFavoriteRooms.length,
getScrollElement: () => scrollRef.current,
@@ -602,7 +580,7 @@ export function Home() {
/>
</Box>
</NavCategory>
{favoriteRooms.length > 0 && (!filtering || filteredFavoriteRooms.length > 0) && (
{favoriteRooms.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
@@ -651,171 +629,142 @@ export function Home() {
notificationPreferences={notificationPreferences}
/>
))}
{noFilterMatch && (
<NavCategory>
<Box
direction="Column"
alignItems="Center"
gap="200"
style={{ padding: config.space.S400 }}
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
<Text size="T300" priority="300" align="Center">
No rooms match &ldquo;{filterQuery.trim()}&rdquo;
</Text>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => setFilterQuery('')}
>
<Text size="B300">Clear filter</Text>
</Button>
</Box>
</NavCategory>
)}
{(!filtering || filteredRooms.length > 0) && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton
closed={closedCategories.has(DEFAULT_CATEGORY_ID)}
data-category-id={DEFAULT_CATEGORY_ID}
onClick={handleCategoryClick}
>
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
Rooms
</RoomNavCategoryButton>
{!closedCategories.has(DEFAULT_CATEGORY_ID) && (
<>
<IconButton
aria-expanded={!!sortMenuAnchor}
aria-haspopup="menu"
variant="Background"
fill="None"
size="300"
radii="300"
onClick={(evt: React.MouseEvent<HTMLButtonElement>) => {
const cords = evt.currentTarget.getBoundingClientRect();
setSortMenuAnchor((current) => (current ? undefined : cords));
}}
aria-label="Sort rooms"
>
<Icon src={Icons.Sort} size="100" />
</IconButton>
<PopOut
anchor={sortMenuAnchor}
position="Bottom"
align="End"
offset={6}
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setSortMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<Menu style={{ maxWidth: toRem(180), width: '100vw' }}>
<Box
direction="Column"
gap="100"
style={{ padding: config.space.S100 }}
>
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<MenuItem
onClick={() => {
setHomeRoomSort('recent');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'recent' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A → Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Recent Activity
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('alpha');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'alpha' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
A → Z
</Text>
</MenuItem>
<MenuItem
onClick={() => {
setHomeRoomSort('unread');
setSortMenuAnchor(undefined);
}}
size="300"
after={
homeRoomSort === 'unread' ? (
<Icon size="100" src={Icons.Check} />
) : undefined
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Unread First
</Text>
</MenuItem>
</Box>
</Menu>
</FocusTrap>
}
/>
</>
)}
</NavCategoryHeader>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const roomId = filteredRooms[vItem.index];
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={roomId}
ref={virtualizer.measureElement}
>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
)}
{lowPriorityRooms.length > 0 && (!filtering || filteredLowPriorityRooms.length > 0) && (
return (
<VirtualTile virtualItem={vItem} key={roomId} ref={virtualizer.measureElement}>
<RoomNavItem
room={room}
selected={selected}
linkPath={getHomeRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId,
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
{lowPriorityRooms.length > 0 && (
<NavCategory>
<NavCategoryHeader>
<RoomNavCategoryButton