Compare commits

..
Author SHA1 Message Date
jared 1a0e581f21 Merge pull request 'fix(copy): soundboard wording, deafen hint, pin typo; keep Reddit context= (#279, #280)' (#435) from fix/copy-and-reddit-context into lotus
CI / Build & Quality Checks (push) Waiting to run
CI / Trigger Desktop Build (push) Blocked by required conditions
CI / Secret scan (gitleaks) (push) Waiting to run
CI / Docker image build & smoke test (push) Blocked by required conditions
CI / Playwright smoke (e2e) (push) Blocked by required conditions
2026-10-19 16:54:30 -04:00
jared 2fff7a5388 Merge pull request 'feat(about): show the desktop version and the web build's commit and date (#276)' (#434) from feat/about-build-info into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 16:42:07 -04:00
jared 8ea03ab9b6 Merge pull request 'fix(schedule): limit to 24 hours ahead with a clear message (#275)' (#433) from fix/schedule-24h into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 16:28:34 -04:00
jared a1223ba5cf Merge pull request 'fix(sync): "Connecting…" only while catching up, not for 30 s after load (#274)' (#432) from fix/connecting-strip into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 16:15:39 -04:00
jared 400545c79a Merge pull request 'fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273)' (#431) from fix/ui-polish-batch into lotus
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 15:59:47 -04:00
jared 997df415dd Merge pull request 'fix(outbox): two tabs no longer overwrite each other's queue (#268)' (#430) from fix/outbox-multi-tab into lotus
CI / Build & Quality Checks (push) Successful in 1m57s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
2026-10-19 15:31:01 -04:00
Lotus CIandClaude Opus 5.5 8bfb8737d6 fix(copy): soundboard room/space wording, deafen Escape hint, pin typo; keep Reddit context= (#279, #280)
CI / Build & Quality Checks (pull_request) Successful in 1m50s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m29s
#279: Room Settings → Soundboard said clips are "inherited by every room
under this space" for plain rooms too; the wording now follows
room.isSpaceRoom(). The Push to Deafen description only mentions Escape
while rebinding. "can pin a messages" → "can pin a message".

#280: the tracking stripper removed Reddit's context=, which is what
makes a comment permalink show its parent comments. Only share_id and
rdt are stripped now.

Fixes #279
Fixes #280

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:26:21 -04:00
Lotus CIandClaude Opus 5.5 72277399f0 feat(about): show the desktop version and the web build's commit and date (#276)
CI / Build & Quality Checks (pull_request) Successful in 1m46s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 12m27s
Settings → About only showed cinny's package version, the same on web
and desktop and unchanged across many deploys a day. vite.config.js now
stamps the short commit (git, or LOTUS_BUILD_COMMIT; "unknown" without
git) and build time into the bundle, and About shows "Web build <commit>
· built <date>", linking the commit. On desktop it also shows "Desktop
<version>" from Tauri's built-in plugin:app|version (allowed by
core:default).

Fixes #276

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:24:37 -04:00
Lotus CIandClaude Opus 5.5 d223f847c6 fix(schedule): limit to 24 hours ahead with a clear message (#275)
CI / Build & Quality Checks (pull_request) Successful in 1m43s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 7s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m4s
Production Synapse caps delayed events at 24h, but the dialog let any
later time be picked and then showed the raw server error. The date
input now has min/max (today .. now+24h), a too-late time shows "You can
schedule up to 24 hours ahead." as soon as it is picked (and blocks
Schedule), and ORG.MATRIX.MSC4140_DELAY_TOO_LARGE maps to the same text.

Fixes #275

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:20:08 -04:00
Lotus CIandClaude Opus 5.5 c6aac574ee fix(sync): "Connecting…" only while catching up, not for 30 s after load (#274)
CI / Build & Quality Checks (pull_request) Successful in 1m55s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 13s
CI / Playwright smoke (e2e) (pull_request) Successful in 13m48s
The strip showed for PREPARED and for the first SYNCING after it (or after
RECONNECTING), i.e. until the next /sync returned: ~30 s of long-poll on a
quiet account while the app already worked. A SYNCING means a sync just
succeeded, so the strip now shows only during CATCHUP; failures still show
as Reconnecting / Connection lost.

Fixes #274

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:14:38 -04:00
Lotus CIandClaude Opus 5.5 53ac87befe fix(ui): receipt pill, insights counts, settings dividers, decoration preview (#269-#273)
CI / Build & Quality Checks (pull_request) Successful in 2m0s
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 12m45s
#269 read-receipt pill sits at the message row's right edge, not where
     the text ends (outside the shrink-to-fit content column; grows in
     Compact). Bubble layout unchanged.
#270 Most Active Members: the bar's % is of its own track, so every row
     shows its count.
#271 Noise Suppression description at T200 like the rest.
#272 decoration preview shows your own avatar under the decoration; the
     empty state is a visible dashed circle.
#273 grouped settings get a divider between tiles (SettingTileGroup, via
     globalStyle; SettingTile roots carry data-setting-tile), and on phones
     the Sync tile's buttons sit under its description.

Fixes #269
Fixes #270
Fixes #271
Fixes #272
Fixes #273

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5
2026-10-19 15:06:20 -04:00
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
35 changed files with 334 additions and 160 deletions
+1 -19
View File
@@ -118,26 +118,8 @@ test.describe('accessibility', () => {
test('room settings', async ({ page }) => {
await page.getByRole('button', { name: 'More Options' }).first().click();
await page.getByText('Room Settings', { exact: true }).click();
// [Gitea #278] The dialog has an accessible name.
await expect(page.getByRole('dialog', { name: 'A11y Room settings' })).toBeVisible();
await expect(page.getByRole('dialog').first()).toBeVisible();
await auditPage(page, 'room settings');
// [Gitea #278] aria-selected on the power chips, unlabelled pack name and
// export date inputs were only on these pages.
// One page at a time, in order.
for (const name of ['Permissions', 'Emojis & Stickers', 'Export']) {
// eslint-disable-next-line no-await-in-loop
await page.getByRole('dialog').getByText(name, { exact: true }).first().click();
// eslint-disable-next-line no-await-in-loop
await page.waitForTimeout(1000);
// eslint-disable-next-line no-await-in-loop
await auditPage(page, `room settings: ${name}`);
}
});
test('inbox invites', async ({ page }) => {
await page.goto('/inbox/invites/');
await expect(page.getByRole('button', { name: /Known|Unknown|Spam/ }).first()).toBeVisible();
await auditPage(page, 'inbox invites');
});
});
});
+2 -2
View File
@@ -82,7 +82,7 @@ export function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-pressed={value === ManualVerificationMethod.RecoveryPassphrase}
aria-selected={value === ManualVerificationMethod.RecoveryPassphrase}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryPassphrase)}
>
@@ -93,7 +93,7 @@ export function ManualVerificationMethodSwitcher({
<MenuItem
size="300"
variant="Surface"
aria-pressed={value === ManualVerificationMethod.RecoveryKey}
aria-selected={value === ManualVerificationMethod.RecoveryKey}
radii="300"
onClick={() => handleSelect(ManualVerificationMethod.RecoveryKey)}
>
+1 -4
View File
@@ -7,10 +7,8 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
type Modal500Props = {
requestClose: () => void;
children: ReactNode;
/** [Gitea #278] The dialog's accessible name (role="dialog" needs one). */
label: string;
};
export function Modal500({ requestClose, children, label }: Modal500Props) {
export function Modal500({ requestClose, children }: Modal500Props) {
const isMobile = useScreenSizeContext() === ScreenSize.Mobile;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
@@ -28,7 +26,6 @@ export function Modal500({ requestClose, children, label }: Modal500Props) {
variant="Background"
role="dialog"
aria-modal="true"
aria-label={label}
// On mobile expand to fill the viewport. On desktop fall back to the
// folds `size="500"` width (~50rem) — overriding maxWidth here would
// squish the two-pane settings layout.
+1 -8
View File
@@ -117,14 +117,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
const renderPlaceholder = useCallback(
({ attributes, children }: RenderPlaceholderProps) => (
<span
{...attributes}
// [Gitea #278] Slate puts an inline `opacity: 0.333` on the placeholder,
// which beat EditorPlaceholderContainer's AA-contrast opacity (#222):
// measured 2.66:1. Drop it so the class decides.
style={{ ...attributes.style, opacity: undefined }}
className={css.EditorPlaceholderContainer}
>
<span {...attributes} className={css.EditorPlaceholderContainer}>
{/* Inner component to style the actual text position and appearance */}
<Text as="span" className={css.EditorPlaceholderTextVisual} truncate>
{children}
@@ -39,7 +39,7 @@ export function UsageSelector({ selected, onChange }: UsageSelectorProps) {
key={getUsageStr(usage)}
size="300"
variant={isSelected(usage) ? 'SurfaceVariant' : 'Surface'}
aria-pressed={isSelected(usage)}
aria-selected={isSelected(usage)}
radii="300"
onClick={() => onChange(usage)}
>
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
});
return (
<Box alignItems="Center" gap="300">
// data-setting-tile: lets a group card draw dividers between tiles only (#273).
<Box alignItems="Center" gap="300" data-setting-tile="">
{before && <Box shrink="No">{before}</Box>}
<Box grow="Yes" direction="Column" gap="100">
{title && (
+3 -3
View File
@@ -69,7 +69,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedDay === day ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-pressed={selectedDay === day}
aria-selected={selectedDay === day}
onClick={() => handleDay(day)}
disabled={
(selectedYear === minYear && selectedMonth === minMonth && day < minDay) ||
@@ -90,7 +90,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedMonth === month ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-pressed={selectedMonth === month}
aria-selected={selectedMonth === month}
onClick={() => handleMonth(month)}
disabled={
(selectedYear === minYear && month < minMonth) ||
@@ -115,7 +115,7 @@ export const DatePicker = forwardRef<HTMLDivElement, DatePickerProps>(
variant={selectedYear === year ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-pressed={selectedYear === year}
aria-selected={selectedYear === year}
onClick={() => handleYear(year)}
>
<Text size="T300">{year}</Text>
+5 -5
View File
@@ -71,7 +71,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-pressed={hour === selectedHour}
aria-selected={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={(minDay && hour < minHour24) || (maxDay && hour > maxHour24)}
>
@@ -90,7 +90,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={hour === selectedHour ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-pressed={hour === selectedHour}
aria-selected={hour === selectedHour}
onClick={() => handleHour(hour)}
disabled={
(minDay && hour12to24(hour, selectedPM) < minHour24) ||
@@ -109,7 +109,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={minute === selectedMinute ? 'Primary' : 'Background'}
fill="None"
radii="300"
aria-pressed={minute === selectedMinute}
aria-selected={minute === selectedMinute}
onClick={() => handleMinute(minute)}
disabled={
(minDay && hour24 === minHour24 && minute < minMinute) ||
@@ -127,7 +127,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={!selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-pressed={!selectedPM}
aria-selected={!selectedPM}
onClick={() => handlePeriod(false)}
disabled={minDay && minPM}
>
@@ -138,7 +138,7 @@ export const TimePicker = forwardRef<HTMLDivElement, TimePickerProps>(
variant={selectedPM ? 'Primary' : 'SurfaceVariant'}
fill="None"
radii="300"
aria-pressed={selectedPM}
aria-selected={selectedPM}
onClick={() => handlePeriod(true)}
disabled={maxDay && !maxPM}
>
@@ -33,9 +33,7 @@ export function UserAvatar({ className, userId, src, alt, renderFallback }: User
<AvatarImage
className={classNames(css.UserAvatar, className)}
src={src}
// [Gitea #278] An avatar sits next to the person's name or inside a
// labelled button, so without an explicit alt it is decorative.
alt={alt ?? ''}
alt={alt}
onError={() => setError(true)}
onLoad={handleLoad}
draggable={false}
@@ -109,11 +109,8 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
alignItems="End"
>
<Box direction="Column" gap="100" grow="Yes">
<Text as="label" htmlFor="room-pack-name" size="L400">
Name
</Text>
<Text size="L400">Name</Text>
<Input
id="room-pack-name"
name="nameInput"
required
size="400"
@@ -136,8 +136,7 @@ export function PermissionGroups({
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-haspopup="menu"
aria-expanded={opened}
aria-selected={opened}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
@@ -201,8 +200,7 @@ export function PermissionGroups({
outlined={powerChanges}
fill="Soft"
radii="Pill"
aria-haspopup="menu"
aria-expanded={opened}
aria-selected={opened}
disabled={!canEdit || applyingChanges}
after={
powerChanges && (
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
<PageContent>
<Box direction="Column" gap="700">
<Box direction="Column" gap="200">
<Text size="L400">This room / space (shared)</Text>
<Text size="L400">
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
</Text>
<Text size="T200" priority="300">
Clips here are shared with everyone, and inherited by every room under this space
— just like emoji/sticker packs. Only members with permission can edit.
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
{room?.isSpaceRoom()
? 'Clips here are shared with everyone, and inherited by every room under this space — just like emoji/sticker packs. Only members with permission can edit.'
: 'Clips here are shared with everyone in this room — just like emoji/sticker packs. Only members with permission can edit.'}
</Text>
{room && <RoomSoundboardPack room={room} stateKey="" />}
</Box>
+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;
@@ -353,11 +353,8 @@ ${msgRows}
>
<Box gap="400" wrap="Wrap">
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text as="label" htmlFor="export-from-date" size="T300">
From
</Text>
<Text size="T300">From</Text>
<Input
id="export-from-date"
type="date"
variant="Secondary"
size="400"
@@ -367,11 +364,8 @@ ${msgRows}
/>
</Box>
<Box direction="Column" gap="100" style={{ flex: 1, minWidth: 160 }}>
<Text as="label" htmlFor="export-to-date" size="T300">
To
</Text>
<Text size="T300">To</Text>
<Input
id="export-to-date"
type="date"
variant="Secondary"
size="400"
+13 -10
View File
@@ -401,16 +401,19 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{displayName}
</Text>
<Box alignItems="Center" gap="200">
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
flexShrink: 0,
}}
/>
{/* [Gitea #270] The bar's % width is of its own track,
not of the row, so the count always fits beside it. */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
height: 6,
width: barWidth,
background: color.Primary.Main,
borderRadius: config.radii.R300,
transition: 'width 0.3s ease',
}}
/>
</div>
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
{count}
</Text>
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
if (!room) return null;
return (
<Modal500 requestClose={closeSettings} label={`${room.name} settings`}>
<Modal500 requestClose={closeSettings}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<RoomSettings initialPage={page} requestClose={closeSettings} />
+22 -4
View File
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { stopPropagation } from '../../utils/keyboard';
import { scheduleMessage } from '../../utils/scheduledMessages';
import {
describeScheduleError,
MAX_SCHEDULE_AHEAD_MS,
scheduleMessage,
SCHEDULE_TOO_FAR_TEXT,
} from '../../utils/scheduledMessages';
import { useModalStyle } from '../../hooks/useModalStyle';
import {
toLocalDate,
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
return;
}
const diffMs = sendAt.getTime() - Date.now();
if (diffMs < 60_000) {
// [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) {
setPreview(null);
return;
}
@@ -149,6 +160,10 @@ 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;
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
onScheduled(delayId, sendAt.getTime(), content);
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
setError(describeScheduleError(err));
setSubmitting(false);
}
};
@@ -247,6 +262,8 @@ 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)}
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
</Box>
</Box>
) : (
(dateValue || timeValue) && (
(dateValue || timeValue) &&
error !== SCHEDULE_TOO_FAR_TEXT && (
<Text size="T200" style={{ color: color.Critical.Main }}>
Must be at least 1 minute in the future
</Text>
+24 -18
View File
@@ -1137,25 +1137,31 @@ export const Message = React.memo(
</AvatarBase>
);
// [Gitea #269] The content column shrinks to the message's width; the
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
// so their `margin-left: auto` reaches the row's right edge instead of
// wherever the text happens to end.
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
</Box>
{readReceiptUsers.length > 0 && (
<ReadReceiptAvatars
room={room}
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
No Pinned Messages
</Text>
<Text size="T400" align="Center">
Users with sufficient power level can pin a messages from its context menu.
Users with sufficient power level can pin a message from its context menu.
</Text>
</Box>
</Box>
+47
View File
@@ -8,6 +8,7 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { tauriInvoke } from '../../../hooks/useTauri';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
@@ -78,6 +79,51 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
type AboutProps = {
requestClose: () => void;
};
/**
* [Gitea #276] Which build someone is on: the web deploy's commit and build
* date (stamped in by vite.config.js), plus the desktop app's own version from
* Tauri, the number a desktop or updater bug report needs.
*/
function BuildInfo() {
const [desktopVersion, setDesktopVersion] = useState<string>();
useEffect(() => {
tauriInvoke()?.('plugin:app|version')
.then((v) => {
if (typeof v === 'string') setDesktopVersion(v);
})
.catch(() => undefined);
}, []);
const commit = __LOTUS_BUILD_COMMIT__;
const built = new Date(__LOTUS_BUILD_DATE__);
const builtText = Number.isNaN(built.getTime())
? undefined
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
return (
<Box direction="Column" gap="0">
{desktopVersion && (
<Text size="T200" priority="300">
Desktop {desktopVersion}
</Text>
)}
<Text size="T200" priority="300">
Web build{' '}
{commit !== 'unknown' ? (
<a
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
target="_blank"
rel="noreferrer noopener"
>
{commit}
</a>
) : (
commit
)}
{builtText && ` · built ${builtText}`}
</Text>
</Box>
);
}
export function About({ requestClose }: AboutProps) {
const mx = useMatrixClient();
const { support: serverSupport, loading: supportLoading } = useServerSupport();
@@ -117,6 +163,7 @@ export function About({ requestClose }: AboutProps) {
<Text size="T200">v{pkg.version}</Text>
</Box>
<Text>A Matrix client for Lotus Guild.</Text>
<BuildInfo />
</Box>
<Box gap="200" wrap="Wrap">
@@ -1,7 +1,12 @@
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useUserProfile } from '../../../hooks/useUserProfile';
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
import { nameInitials } from '../../../utils/common';
import { UserAvatar } from '../../../components/user-avatar';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import {
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72;
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
const PREVIEW_INSET = 8;
function DecorationPreviewCell({
slug,
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const useAuthentication = useMediaAuthentication();
const profile = useUserProfile(userId);
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
const myAvatarUrl = profile.avatarUrl
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
<Box direction="Column" gap="300">
{/* Current selection preview */}
<Box alignItems="Center" gap="300">
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
timeline draws it (AvatarDecoration: the image is the avatar plus
an 8px margin on every side). With no decoration the circle gets
a visible dashed outline instead of vanishing into the card. */}
<div
style={{
position: 'relative',
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
background: color.SurfaceVariant.Container,
overflow: 'hidden',
boxSizing: 'border-box',
border: selected
? 'none'
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
}}
>
<Avatar
size="500"
radii="Pill"
style={{
position: 'absolute',
inset: PREVIEW_INSET,
width: CELL_SIZE - PREVIEW_INSET * 2,
height: CELL_SIZE - PREVIEW_INSET * 2,
}}
>
<UserAvatar
userId={userId}
src={myAvatarUrl}
alt=""
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
/>
</Avatar>
{selected && (
<img
src={decorationUrl(selected)}
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
zIndex: 1,
}}
/>
)}
+57 -35
View File
@@ -111,7 +111,8 @@ import {
translationUnavailableText,
} from '../../../utils/translation/langUtils';
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
import { SequenceCardStyle } from '../styles.css';
import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
import {
describeUpdateError,
@@ -1598,6 +1599,7 @@ function Privacy() {
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
// device-local (never synced) so switching it off here sticks.
function SettingsSyncSection() {
const isMobile = useScreenSize() === ScreenSize.Mobile;
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
const { pushNow, clearRemote } = useSettingsSyncActions();
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
@@ -1621,6 +1623,34 @@ function SettingsSyncSection() {
}
};
const syncButtons = (
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
);
return (
<Box direction="Column" gap="100">
<Text size="L400">Sync</Text>
@@ -1636,34 +1666,12 @@ function SettingsSyncSection() {
<SettingTile
title="Use This Device's Settings Everywhere"
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
after={
<Box gap="200">
<Button
size="300"
variant="Secondary"
fill="Soft"
outlined
radii="300"
disabled={busy !== null}
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
onClick={() => run('push')}
>
<Text size="B300">Push now</Text>
</Button>
<Button
size="300"
variant="Critical"
fill="None"
radii="300"
disabled={busy !== null}
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
onClick={() => run('clear')}
>
<Text size="B300">Clear synced copy</Text>
</Button>
</Box>
}
/>
// [Gitea #273] On phones the two buttons squeezed the text into a
// narrow column; put them under the description there.
after={isMobile ? undefined : syncButtons}
>
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
</SettingTile>
{note && (
<Text
size="T200"
@@ -1800,7 +1808,8 @@ function Calls() {
title="Noise Suppression"
description={
<Box direction="Column" gap="200">
<Text>
{/* [Gitea #271] T200 like every other setting description. */}
<Text size="T200">
Filter background noise from your mic during calls. Browser-native uses the built-in
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
</Text>
@@ -2033,7 +2042,7 @@ function Calls() {
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
gap="400"
@@ -2089,7 +2098,12 @@ function Calls() {
description={
deafenBind.error ??
(deafenHotkey
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
? `${
// [Gitea #279] The Escape hint only applies while rebinding.
deafenBind.listening
? 'Press a key to bind it, or Escape to cancel.'
: `Press ${keyLabel(deafenKey)} during a call to mute your speakers.`
}${
!isSafeGlobalToggleKey(deafenKey)
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
@@ -2130,7 +2144,7 @@ function Calls() {
)}
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
gap="400"
@@ -2264,7 +2278,11 @@ function Calls() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
title="Soundboard"
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
@@ -2751,7 +2769,11 @@ function Messages() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SequenceCard
className={`${SequenceCardStyle} ${SettingTileGroup}`}
variant="SurfaceVariant"
direction="Column"
>
<SettingTile
title="Translate Messages Into"
description={
@@ -98,7 +98,7 @@ export function NotificationModeSwitcher({ pushRule, onChange }: NotificationMod
key={mode}
size="300"
variant="Surface"
aria-pressed={mode === selectedMode}
aria-selected={mode === selectedMode}
radii="300"
onClick={() => handleSelect(mode)}
>
+14 -2
View File
@@ -1,6 +1,18 @@
import { style } from '@vanilla-extract/css';
import { config } from 'folds';
import { globalStyle, style } from '@vanilla-extract/css';
import { color, config } from 'folds';
export const SequenceCardStyle = style({
padding: config.space.S300,
});
/**
* [Gitea #273] Several settings share one card; without a separator the next
* tile's title read like part of the previous description. A thin line goes
* between tiles only (not between a tile and its own slider or note).
*/
export const SettingTileGroup = style({});
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
paddingTop: config.space.S400,
});
@@ -21,7 +21,7 @@ function RenderSettings({ state }: RenderSettingsProps) {
if (!room) return null;
return (
<Modal500 requestClose={closeSettings} label={`${room.name} space settings`}>
<Modal500 requestClose={closeSettings}>
<SpaceProvider value={space ?? null}>
<RoomProvider value={room}>
<SpaceSettings initialPage={page} requestClose={closeSettings} />
+6 -6
View File
@@ -65,12 +65,12 @@ export function SyncStatus({ mx }: SyncStatusProps) {
);
}
if (
(stateData.current === SyncState.Prepared ||
stateData.current === SyncState.Syncing ||
stateData.current === SyncState.Catchup) &&
stateData.previous !== SyncState.Syncing
) {
// [Gitea #274] Only while catching up after a drop. Upstream also showed it
// for PREPARED and for the first SYNCING after PREPARED/RECONNECTING, i.e.
// until the NEXT /sync returned: on a quiet account that long-polls ~30 s
// while the app already works (a SYNCING means a sync just succeeded).
// A real failure still surfaces as Reconnecting / Connection lost.
if (stateData.current === SyncState.Catchup) {
return (
<Box direction="Column" shrink="No">
<Box
+3 -3
View File
@@ -369,7 +369,7 @@ function InviteFilters({
<Box gap="200">
<Chip
variant={isKnown ? 'Success' : 'Surface'}
aria-pressed={isKnown}
aria-selected={isKnown}
outlined={!isKnown}
onClick={() => onFilter(InviteFilter.Known)}
before={isKnown && <Icon size="100" src={Icons.Check} />}
@@ -385,7 +385,7 @@ function InviteFilters({
</Chip>
<Chip
variant={isUnknown ? 'Warning' : 'Surface'}
aria-pressed={isUnknown}
aria-selected={isUnknown}
outlined={!isUnknown}
onClick={() => onFilter(InviteFilter.Unknown)}
before={isUnknown && <Icon size="100" src={Icons.Check} />}
@@ -401,7 +401,7 @@ function InviteFilters({
</Chip>
<Chip
variant={isSpam ? 'Critical' : 'Surface'}
aria-pressed={isSpam}
aria-selected={isSpam}
outlined={!isSpam}
onClick={() => onFilter(InviteFilter.Spam)}
before={isSpam && <Icon size="100" src={Icons.Check} />}
+1 -1
View File
@@ -217,7 +217,7 @@ export function SettingsTab() {
</SidebarItemTooltip>
<PresencePicker />
{settingsOpen && (
<Modal500 requestClose={() => setSettingsOpen(false)} label="Settings">
<Modal500 requestClose={() => setSettingsOpen(false)}>
<Settings initialPage={initialPage} requestClose={() => setSettingsOpen(false)} />
</Modal500>
)}
@@ -82,7 +82,7 @@ function UnverifiedIndicator() {
</SidebarItem>
)}
{settings && (
<Modal500 requestClose={closeSettings} label="Settings">
<Modal500 requestClose={closeSettings}>
<Settings initialPage={SettingsPages.DevicesPage} requestClose={closeSettings} />
</Modal500>
)}
+12
View File
@@ -179,3 +179,15 @@ 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,6 +1,22 @@
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
+7
View File
@@ -109,3 +109,10 @@ describe('stripTrackingParamsInHtml', () => {
assert.equal(stripTrackingParamsInHtml(html), html);
});
});
it('[#280] Reddit keeps context= (it shows the parent comments) but drops share_id', () => {
assert.equal(
stripTrackingParams('https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3&share_id=1'),
'https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3',
);
});
+3 -1
View File
@@ -103,7 +103,9 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
},
{
hosts: ['reddit.com'],
params: ['share_id', 'rdt', 'context'],
// [Gitea #280] Not `context`: on a comment permalink, ?context=N shows the
// N parent comments, i.e. the conversation the sender meant to share.
params: ['share_id', 'rdt'],
},
{
hosts: ['tiktok.com'],
+4
View File
@@ -1,5 +1,9 @@
/// <reference types="vite/client" />
// [Gitea #276] Set by vite.config.js `define`.
declare const __LOTUS_BUILD_COMMIT__: string;
declare const __LOTUS_BUILD_DATE__: string;
declare module 'browser-encrypt-attachment' {
export interface EncryptedAttachmentInfo {
v: string;
+19
View File
@@ -6,6 +6,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
import { VitePWA } from 'vite-plugin-pwa';
import fs from 'fs';
import { execSync } from 'child_process';
import path from 'path';
import buildConfig from './build.config';
@@ -272,8 +273,26 @@ const vendorChunks = (id) => {
if (id.includes('node_modules/chroma-js')) return 'chroma';
};
// [Gitea #276] Which build is this? The short commit and build time are shown
// in Settings → About (web deploys change many times a day while the package
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
const buildCommit = (() => {
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
try {
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
return 'unknown';
}
})();
export default defineConfig({
appType: 'spa',
define: {
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
},
publicDir: false,
base: buildConfig.base,
server: {