Compare commits

..
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 97d2d195c3 fix(outbox): two tabs no longer overwrite each other's queue (#268)
CI / Build & Quality Checks (pull_request) Successful in 1m40s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 6s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m4s
OutboxFeature loaded lotus_outbox_v1 once and then saved its own
in-memory list over the key on every change, so a second tab's save
dropped the first tab's unsent messages. persist() now re-reads the
stored list and applies the change (add/remove by txnId) to it.

Fixes #268

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 14:49:40 -04:00
14 changed files with 58 additions and 223 deletions
@@ -1,11 +1,10 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
import { config, toRem } from 'folds';
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
export const BookmarksPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -52,7 +52,8 @@ import { settingsAtom } from '../../../state/settings';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useAlive } from '../../../hooks/useAlive';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
import { buildTopicContent } from '../../../utils/topicMarkdown';
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
const start = textarea.selectionStart;
@@ -70,6 +71,30 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
textarea.setSelectionRange(cursorStart, cursorEnd);
}
function topicMarkdownToHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/__(.+?)__/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/_(.+?)_/g, '<em>$1</em>')
.replace(/~~(.+?)~~/g, '<del>$1</del>')
.replace(/`(.+?)`/g, '<code>$1</code>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
.replace(/\n/g, '<br>');
}
function buildTopicContent(topic: string): Record<string, string> {
if (!MARKDOWN_PATTERN.test(topic)) return { topic };
const formattedBody = topicMarkdownToHtml(topic);
// Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax
const plainTopic = formattedBody.replace(/<br>/g, '\n').replace(/<[^>]+>/g, '');
return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody };
}
type RoomProfileEditProps = {
canEditAvatar: boolean;
canEditName: boolean;
+12 -7
View File
@@ -94,10 +94,15 @@ export function OutboxFeature() {
OFFLINE.has(mx.getSyncState()) ||
(typeof navigator !== 'undefined' && navigator.onLine === false);
const persist = (next: OutboxEntry[]) => {
if (next === entries) return;
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
// entries since we last looked, and saving our own copy over the key
// would drop theirs. Entries are added/removed by txnId, so applying our
// change to the stored list merges cleanly.
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
const stored = loadOutbox(userId);
const next = update(stored);
entries = next;
saveOutbox(userId, entries);
if (next !== stored) saveOutbox(userId, next);
};
/**
@@ -123,8 +128,8 @@ export function OutboxFeature() {
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
live.set(txnId, { event, room });
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
persist(
withEntry(entries, {
persist((list) =>
withEntry(list, {
txnId,
roomId: room.roomId,
threadId: event.threadRootId ?? null,
@@ -138,7 +143,7 @@ export function OutboxFeature() {
// SENT, CANCELLED, or the remote echo replaced it (status null).
live.delete(txnId);
autoRetries.delete(txnId);
persist(withoutEntry(entries, txnId));
persist((list) => withoutEntry(list, txnId));
};
/** Re-send network failures, oldest first; a room stops at its first failure. */
@@ -191,7 +196,7 @@ export function OutboxFeature() {
return !!room && isDelivered(room, entry.txnId);
},
);
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
plan.restore.forEach((entry) => {
const room = mx.getRoom(entry.roomId);
if (!room || live.has(entry.txnId)) return;
+5 -5
View File
@@ -1,13 +1,13 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
// Right-side drawer DOCKED into the room layout row (a flex sibling of the
// timeline), exactly like MembersDrawer — not a floating overlay. Same width as
// every other right-hand panel (styles/sidePanel.ts, #263). On narrow viewports
// it becomes a full-screen fixed panel, matching the app's other drawers.
// timeline), exactly like MembersDrawer — not a floating overlay. 320px is a
// little wider than the 266px member/bookmark drawers because it hosts a media
// grid. On narrow viewports it becomes a full-screen fixed panel, matching the
// app's other drawers.
export const MediaGalleryDrawer = style({
width: SIDE_PANEL_WIDTH,
width: toRem(320),
overflow: 'hidden',
'@media': {
'(max-width: 750px)': {
+2 -3
View File
@@ -1,9 +1,8 @@
import { keyframes, style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
import { config, toRem } from 'folds';
export const MembersDrawer = style({
width: SIDE_PANEL_WIDTH,
width: toRem(266),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
+4 -22
View File
@@ -20,12 +20,7 @@ import { IContent } from 'matrix-js-sdk';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import {
describeScheduleError,
MAX_SCHEDULE_AHEAD_MS,
scheduleMessage,
SCHEDULE_TOO_FAR_TEXT,
} from '../../utils/scheduledMessages';
import { scheduleMessage } from '../../utils/scheduledMessages';
import { useModalStyle } from '../../hooks/useModalStyle';
import {
toLocalDate,
@@ -126,13 +121,7 @@ export function ScheduleMessageModal({
return;
}
const diffMs = sendAt.getTime() - Date.now();
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
setError((e) => {
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
});
if (diffMs < 60_000 || tooFar) {
if (diffMs < 60_000) {
setPreview(null);
return;
}
@@ -160,10 +149,6 @@ export function ScheduleMessageModal({
setError('Scheduled time must be at least 1 minute in the future.');
return;
}
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
setError(SCHEDULE_TOO_FAR_TEXT);
return;
}
if (!messageText.trim()) {
setError('Please enter a message to schedule.');
return;
@@ -177,7 +162,7 @@ export function ScheduleMessageModal({
onScheduled(delayId, sendAt.getTime(), content);
onClose();
} catch (err) {
setError(describeScheduleError(err));
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
setSubmitting(false);
}
};
@@ -262,8 +247,6 @@ export function ScheduleMessageModal({
<input
id="schedule-date"
type="date"
min={toLocalDate(new Date())}
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
value={dateValue}
onChange={(e) => setDateValue(e.target.value)}
style={pickerInputStyle(color, config)}
@@ -311,8 +294,7 @@ export function ScheduleMessageModal({
</Box>
</Box>
) : (
(dateValue || timeValue) &&
error !== SCHEDULE_TOO_FAR_TEXT && (
(dateValue || timeValue) && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future
</Text>
@@ -1,9 +1,8 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
import { config, toRem } from 'folds';
export const ThreadPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -1,9 +1,8 @@
import { style } from '@vanilla-extract/css';
import { color, config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
import { color, config, toRem } from 'folds';
export const ThreadsListPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(340),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
@@ -1,9 +1,8 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
import { config, toRem } from 'folds';
export const WidgetsPanel = style({
width: SIDE_PANEL_WIDTH,
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
-9
View File
@@ -1,9 +0,0 @@
import { toRem } from 'folds';
/**
* One width for every right-hand room panel (members, bookmarks, threads, a
* thread, media gallery, widgets). Swapping one panel for another must not
* resize the room view: when the widths differed, the header icons slid
* sideways and a second click on the same spot hit a different button (#263).
*/
export const SIDE_PANEL_WIDTH = toRem(340);
-12
View File
@@ -179,15 +179,3 @@ test('groupScheduledByRoom: groups and sorts by sendAt', () => {
);
assert.equal(g.get('!2')?.length, 1);
});
test('[#275] the server limit maps to friendly text; other errors keep theirs', async () => {
const { describeScheduleError, SCHEDULE_TOO_FAR_TEXT, MAX_SCHEDULE_AHEAD_MS } =
await import('./scheduledMessages');
assert.equal(MAX_SCHEDULE_AHEAD_MS, 86_400_000);
const tooLarge = Object.assign(new Error('The requested delay (172800000ms) exceeds…'), {
errcode: 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE',
});
assert.equal(describeScheduleError(tooLarge), SCHEDULE_TOO_FAR_TEXT);
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
});
-16
View File
@@ -1,22 +1,6 @@
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
import type { ScheduledMessage } from '../state/scheduledMessages';
/**
* [Gitea #275] Production Synapse caps delayed events at
* `max_event_delay_duration: 24h`; a later time is rejected with
* ORG.MATRIX.MSC4140_DELAY_TOO_LARGE. The dialog enforces the same limit and
* the server's error maps to the same text, in case the limit changes.
*/
export const MAX_SCHEDULE_AHEAD_MS = 24 * 60 * 60 * 1000;
export const SCHEDULE_TOO_FAR_TEXT = 'You can schedule up to 24 hours ahead.';
/** A user-facing sentence for a failed schedule. */
export const describeScheduleError = (err: unknown): string => {
const errcode = (err as { errcode?: unknown } | null)?.errcode;
if (errcode === 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE') return SCHEDULE_TOO_FAR_TEXT;
return err instanceof Error ? err.message : 'Failed to schedule message.';
};
/**
* Schedule a message via MSC4140 (delayed messages).
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
-72
View File
@@ -1,72 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { buildTopicContent } from './topicMarkdown';
test('a URL with underscores is sent verbatim (#262)', () => {
const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq';
assert.deepEqual(buildTopicContent(t), { topic: t });
});
test('a markdown link keeps its underscored URL in both fields (#262)', () => {
const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/);
assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)');
});
test('room aliases and user IDs with underscores are untouched (#262)', () => {
for (const t of [
'Join #my_room_name:matrix.org',
'Ask @some_user_x:matrix.org',
'see snake_case_word',
]) {
assert.deepEqual(buildTopicContent(t), { topic: t });
}
});
test('emphasis still works at word boundaries', () => {
const c = buildTopicContent('this is _really_ *great* and **bold**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<i data-md="_">really<\/i>/);
assert.match(c.formatted_body, /<i data-md="\*">great<\/i>/);
assert.match(c.formatted_body, /<strong data-md="\*\*">bold<\/strong>/);
assert.equal(c.topic, 'this is really great and bold');
});
test('emphasis around an alias keeps the alias intact', () => {
const c = buildTopicContent('**Join #my_room:matrix.org**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<strong data-md="\*\*">Join #my_room:matrix\.org<\/strong>/);
assert.equal(c.topic, 'Join #my_room:matrix.org');
});
test('inline code keeps its underscores and no backslashes appear', () => {
const c = buildTopicContent('run `a_b_c` now');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /<code data-md="`">a_b_c<\/code>/);
assert.equal(c.topic, 'run a_b_c now');
});
test('a double quote in a link target cannot break out of the attribute (#262)', () => {
const c = buildTopicContent('[x](https://a" onclick="alert(1))');
assert.ok('formatted_body' in c);
assert.doesNotMatch(c.formatted_body, /" onclick="/);
});
test('HTML in the topic is escaped, and the plain topic shows it as typed', () => {
const c = buildTopicContent('**<b>hi</b> & bye**');
assert.ok('formatted_body' in c);
assert.match(c.formatted_body, /&lt;b&gt;hi&lt;\/b&gt; &amp; bye/);
assert.equal(c.topic, '<b>hi</b> & bye');
});
test('line breaks become <br> and survive in the plain topic', () => {
const c = buildTopicContent('**Rules**\nBe nice');
assert.ok('formatted_body' in c);
assert.equal(c.formatted_body, '<strong data-md="**">Rules</strong><br>Be nice');
assert.equal(c.topic, 'Rules\nBe nice');
});
test('plain text with no markdown is sent as typed', () => {
assert.deepEqual(buildTopicContent("It's <fun> & games"), { topic: "It's <fun> & games" });
});
-63
View File
@@ -1,63 +0,0 @@
import { parseInlineMD } from '../plugins/markdown';
import { sanitizeText } from './sanitize';
/**
* Room topic markdown → `m.room.topic` content (#262).
*
* Uses the composer's inline markdown parser (the same rules as messages: links,
* bare URLs and `"` are handled), with one topic-specific guard: an `_` between
* two letters/digits is literal, as in CommonMark. Topics are full of
* `#room_alias:server`, `@user_name:server` and snake_case paths, which the
* shared parser would otherwise turn into italics and, worse, drop from the
* plain `topic` that every other client shows.
*/
// Spans the guard must not touch: inline code, link targets, bare URLs.
const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/;
const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu;
const escapeIntrawordUnderscores = (line: string): string =>
line
.split(PROTECTED)
.map((part, i) =>
// split() with one capture group puts the protected spans at odd indexes.
i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')),
)
.join('');
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
};
const decodeEntities = (s: string): string =>
s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]);
/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */
const htmlToPlain = (html: string): string =>
decodeEntities(
html
.replace(/<a\b[^>]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => {
const text = label.replace(/<[^>]+>/g, '');
return text === href ? href : `${text} (${href})`;
})
.replace(/<br>/g, '\n')
.replace(/<[^>]+>/g, ''),
);
export type TopicContent =
| { topic: string }
| { topic: string; format: 'org.matrix.custom.html'; formatted_body: string };
export function buildTopicContent(text: string): TopicContent {
const lines = text.split('\n');
const html = lines
.map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line))))
.join('<br>');
const plainHtml = lines.map(sanitizeText).join('<br>');
// No markdown at all: send the text as typed, without a formatted body.
if (html === plainHtml) return { topic: text };
return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html };
}