Compare commits

...
6 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 3b6de2fdac fix(desktop): hydrate Focus Assist on mount; thread-aware toast quick reply
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
- useTauriFocusAssist queries the new `get_focus_assist` command on mount.
  The native poll's first reading is emitted during app setup, before the
  page listens, and the atom resets on every reload — so with Focus Assist
  already on, notifications leaked until the OS state flipped
  (cinny-desktop #15).
- The toast quick reply takes the real `threadId` from the notification
  data and replies inside the thread (cinny-desktop #17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:32:42 -04:00
jaredandClaude Opus 5 f5ee3c0d0e fix(assets): rename Lotus.png so it no longer collides with lotus.png (#229)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 8m41s
public/res/ carried two DIFFERENT files whose names differ only in case —
Lotus.png (19 897 B, the 256px logo used by the welcome page, auth layout,
OIDC config and About) and lotus.png (2 073 B, the notification icon).
Windows and macOS filesystems are case-insensitive, so a checkout there
collapses them into one path: one of the two references then resolves to
the wrong bytes or to nothing, which is why the desktop client's home
logo rendered as alt text from
http://localhost:44548/public/res/Lotus.png.

Renamed the logo to lotus-logo.png and updated its four references;
verified no case-only filename collisions remain anywhere in the repo,
and that both logos still load (256x256 on the welcome page and About).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:39:26 -04:00
jaredandClaude Opus 5 08abf5aec8 fix(embeds): players fill the card and keep their aspect ratio (#228)
Two sizing bugs, both visible the moment a video starts:

1. The video/TikTok embed columns had no width. UrlPreview is a flex ROW,
   so they shrink-to-fit: the facade's <img> supplied the width, but the
   player <iframe> is absolutely positioned and supplies none, so pressing
   play collapsed the whole embed to the iframe's ~200px intrinsic size
   (measured 606x341 -> 204x115 in a 608px card). Both columns are now
   width: 100%.
2. EmbedMediaPortrait's 9:16 came from a 177.78% padding-top, but a
   padding percentage resolves against the CONTAINING BLOCK's width, not
   the element's — so inside a wide card a Short/TikTok rendered
   300x1077 instead of 300x533. Capped with min() so it is exact at
   >= 300px and still correct on narrower phones.

Audited every provider before/after play at 1300 px, 500 px and Pixel 7:
YouTube, Vimeo, Twitch 16:9 (0.56); Shorts, TikTok 9:16 (1.78); Spotify
152 px and SoundCloud 166 px fixed-height — all stable across play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:37:57 -04:00
jaredandClaude Opus 5 e393c45f50 fix(status): auto-clear survives a rate-limited presence write (#227)
The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.

The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.

Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:31:57 -04:00
jaredandClaude Opus 5 fdec3ed7f2 feat(threads): "Mark all read" in the threads list (#165)
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
A "Mark all read" chip appears in the Threads panel header whenever any
thread in the room has unread replies, and sends one threaded receipt per
unread thread (never the root — a root lives in the main timeline and a
receipt there would drag the MAIN read marker backwards, the P6
regression). Honours the private-receipt settings.

Verified: two threads with unread replies → the chip appears, the rows
say "unread" in their labels; after clicking, no unread rows and the chip
is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:25:53 -04:00
jaredandClaude Opus 5 cf80729a5c feat(media): swipe between items in the full-screen viewer (#164)
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Touch had no way to move between images except the small arrow buttons.
A one-finger horizontal swipe now goes next/previous, and is inert while
the image is zoomed in (where the same gesture pans). The classifier is
pure and unit-tested: ≥ 60 px horizontal, not mostly vertical, under
800 ms, single finger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:20:22 -04:00
15 changed files with 240 additions and 21 deletions

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -246,11 +246,15 @@ export const EmbedMediaPortrait = style([
DefaultReset,
{
position: 'relative',
width: toRem(300),
maxWidth: '100%',
// 9:16 via the padding-top hack (177.78%) — see EmbedMediaLandscape.
// [Gitea #228] `min()` on both axes so the 9:16 ratio holds whatever the
// parent's width is. A padding-top percentage resolves against the
// CONTAINING BLOCK's width, not the element's, so a bare `177.78%` inside a
// wide card made the box ~2x too tall (606px card → 1077px instead of 533).
// Capped at the pixel height of a 300px-wide 9:16 box, it is exact when the
// parent is at least 300px and falls back to the percentage below that.
width: `min(${toRem(300)}, 100%)`,
height: 0,
paddingTop: '177.78%',
paddingTop: `min(${toRem(533)}, 177.78%)`,
margin: '0 auto',
overflow: 'hidden',
backgroundColor: color.Surface.Container,
@@ -1203,7 +1203,11 @@ function MediaEmbedCard({
};
return (
<Box direction="Column">
// [Gitea #228] `width: 100%` — UrlPreview is a flex ROW, so this column
// shrink-to-fits its content. The facade's <img> supplied that width, but
// the player <iframe> is absolutely positioned and contributes none, so on
// play the whole embed collapsed to the iframe's ~200px intrinsic size.
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
{playing && rich ? (
// Rich post embeds (Instagram/Reddit) are variable-height and self-size.
<iframe
@@ -1380,7 +1384,9 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
);
return (
<Box direction="Column">
// [Gitea #228] See the video embed above: this column must fill the card's
// flex row, not shrink to the iframe's intrinsic width.
<Box direction="Column" style={{ width: '100%', minWidth: 0 }}>
<div ref={mediaRef} className={previewCss.EmbedMediaPortrait}>
{playing && videoId ? (
<iframe
+12 -1
View File
@@ -23,6 +23,7 @@ import classNames from 'classnames';
import { useNearViewport } from '../../hooks/useNearViewport';
import { useZoom } from '../../hooks/useZoom';
import { usePan, Pan } from '../../hooks/usePan';
import { useSwipeNav } from '../../hooks/useSwipeNav';
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
@@ -255,7 +256,15 @@ export function Lightbox({
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
// Pan is only active for a zoomed-in image; usePan resets its offset when this
// flips false (i.e. back to 1x, on navigation, or on a video).
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
const zoomedIn = isImage && zoom !== 1;
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoomedIn);
// [Gitea #164] Swipe between items on touch — only while not zoomed in,
// where the same gesture pans the image instead.
const swipe = useSwipeNav(
!zoomedIn,
useCallback(() => setIndex((i) => Math.min(items.length - 1, i + 1)), [items.length]),
useCallback(() => setIndex((i) => Math.max(0, i - 1)), []),
);
const dialogRef = useRef<HTMLDivElement>(null);
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
@@ -446,6 +455,8 @@ export function Lightbox({
alignItems="Center"
justifyContent="Center"
onWheel={handleWheel}
onTouchStart={swipe.onTouchStart}
onTouchEnd={swipe.onTouchEnd}
style={{ overflow: 'hidden', padding: config.space.S400 }}
>
{index > 0 && (
@@ -1,8 +1,20 @@
import React, { useEffect, useMemo, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
import {
Avatar,
Box,
Button,
Chip,
Header,
Icon,
IconButton,
Icons,
Scroll,
Text,
config,
} from 'folds';
import classNames from 'classnames';
import { useVirtualizer } from '@tanstack/react-virtual';
import * as css from './ThreadsListPanel.css';
@@ -14,6 +26,10 @@ import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
import { trimReplyFromBody } from '../../../utils/room';
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
import { markAllThreadsRead } from '../../../utils/markThreadsRead';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { threadNotificationsAtom } from '../../../state/threadNotifications';
import { getMutedThreads } from '../../../utils/threadNotifications';
import { nameInitials } from '../../../utils/common';
@@ -259,6 +275,22 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
};
}, [threads, room, filter, sort, threadNotifications]);
// [Gitea #165] "Mark all read" — one threaded receipt per unread thread.
const mx = useMatrixClient();
const [marking, setMarking] = useState(false);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
const totalUnread = useMemo(
() => [...unreadById.values()].reduce((n, c) => n + c, 0),
[unreadById],
);
const handleMarkAllRead = useCallback(() => {
setMarking(true);
markAllThreadsRead(mx, room, hideActivity || privateReadReceipts).finally(() =>
setMarking(false),
);
}, [mx, room, hideActivity, privateReadReceipts]);
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
const virtualizer = useVirtualizer({
count: visible.length,
@@ -291,6 +323,19 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
{room.name}
</Text>
</Box>
{totalUnread > 0 && (
<Chip
variant="Secondary"
fill="Soft"
radii="300"
outlined
disabled={marking}
onClick={handleMarkAllRead}
before={<Icon size="50" src={Icons.Check} />}
>
<Text size="B300">Mark all read</Text>
</Chip>
)}
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
<Icon src={Icons.Cross} />
</IconButton>
+1 -1
View File
@@ -9,7 +9,7 @@ import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
type MSC1929Contact = {
matrix_id?: string;
+23
View File
@@ -0,0 +1,23 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { classifySwipe } from './useSwipeNav';
const start = { x: 300, y: 200, t: 1_000 };
const at = (x: number, y: number) => ({ x, y });
describe('classifySwipe', () => {
it('left is next, right is prev', () => {
assert.equal(classifySwipe(start, at(100, 210), 1_200), 'next');
assert.equal(classifySwipe({ ...start, x: 100 }, at(300, 190), 1_200), 'prev');
});
it('ignores short, too-vertical and too-slow gestures', () => {
assert.equal(classifySwipe(start, at(260, 200), 1_200), undefined);
assert.equal(classifySwipe(start, at(100, 400), 1_200), undefined);
assert.equal(classifySwipe(start, at(100, 200), 2_500), undefined);
});
it('allows a little vertical drift on a long horizontal swipe', () => {
assert.equal(classifySwipe(start, at(60, 340), 1_300), 'next');
});
});
+66
View File
@@ -0,0 +1,66 @@
import { TouchEventHandler, useCallback, useRef } from 'react';
/** Horizontal travel that counts as a swipe rather than a tap or a scroll. */
const THRESHOLD_PX = 60;
/** Beyond this vertical travel it's a scroll/pan, not a horizontal swipe. */
const VERTICAL_TOLERANCE = 1.2;
const MAX_DURATION_MS = 800;
export type SwipeStart = { x: number; y: number; t: number };
/**
* Classify a finished one-finger gesture. Exported for tests.
*/
export function classifySwipe(
start: SwipeStart,
end: { x: number; y: number },
now: number,
): 'next' | 'prev' | undefined {
if (now - start.t > MAX_DURATION_MS) return undefined;
const dx = end.x - start.x;
const dy = end.y - start.y;
if (Math.abs(dx) < THRESHOLD_PX) return undefined;
if (Math.abs(dy) > Math.abs(dx) / VERTICAL_TOLERANCE) return undefined;
return dx < 0 ? 'next' : 'prev';
}
/**
* [Gitea #164] One-finger horizontal swipe for the media viewer: left goes to
* the next item, right to the previous. Inert while `active` is false (e.g.
* the image is zoomed in, where the same gesture pans instead).
*/
export function useSwipeNav(
active: boolean,
onNext: () => void,
onPrev: () => void,
): { onTouchStart: TouchEventHandler<HTMLElement>; onTouchEnd: TouchEventHandler<HTMLElement> } {
const start = useRef<SwipeStart | null>(null);
const onTouchStart = useCallback<TouchEventHandler<HTMLElement>>(
(evt) => {
if (!active || evt.touches.length !== 1) {
start.current = null;
return;
}
const t = evt.touches[0];
start.current = { x: t.clientX, y: t.clientY, t: Date.now() };
},
[active],
);
const onTouchEnd = useCallback<TouchEventHandler<HTMLElement>>(
(evt) => {
const s = start.current;
start.current = null;
if (!active || !s) return;
const t = evt.changedTouches[0];
if (!t) return;
const verdict = classifySwipe(s, { x: t.clientX, y: t.clientY }, Date.now());
if (verdict === 'next') onNext();
else if (verdict === 'prev') onPrev();
},
[active, onNext, onPrev],
);
return { onTouchStart, onTouchEnd };
}
+14 -1
View File
@@ -1,6 +1,7 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { focusAssistActiveAtom } from '../state/focusAssist';
import { useTauriEvent } from './useTauri';
import { tauriInvoke, useTauriEvent } from './useTauri';
/** Detail shape of the `focus-assist-changed` event emitted by the native side. */
type FocusAssistChangedDetail = {
@@ -21,4 +22,16 @@ export function useTauriFocusAssist(): void {
useTauriEvent<FocusAssistChangedDetail>('focus-assist-changed', ({ active }) =>
setFocusAssist(active),
);
// Hydrate on mount. The native poll only emits on a transition, and its first
// reading lands during app setup before this page is listening — so with Focus
// Assist already on at launch (or after a reload) notifications leaked until
// the OS state next flipped (cinny-desktop #15). `null` = no reading yet.
useEffect(() => {
tauriInvoke()?.('get_focus_assist')
.then((active) => {
if (typeof active === 'boolean') setFocusAssist(active);
})
.catch(() => undefined);
}, [setFocusAssist]);
}
+6 -2
View File
@@ -14,6 +14,8 @@ interface ActivateDetail {
/** Payload of the `lotus-notification-reply` event (the inline reply box). */
interface ReplyDetail {
roomId?: string;
/** Set for a thread notification: the reply goes into that thread. */
threadId?: string;
text?: string;
}
@@ -36,12 +38,14 @@ export function useTauriToastActions(): void {
if (path) navigate(path);
});
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, threadId, text }) => {
// `roomId` is the real room id carried in the notification data, never the
// coalescing tag (`room:thread`, `lotus-invites`) — cinny-desktop #17.
if (!roomId || !text) return;
// #79 — a quick-reply failure (offline, no send permission, etc.) used to
// be swallowed with no feedback, leaving the user believing it was sent.
// Surface it via the in-app error toast instead.
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => {
mx.sendMessage(roomId, threadId ?? null, { msgtype: MsgType.Text, body: text }).catch(() => {
setToast(
createErrorToast(
'Your quick reply could not be sent. Please try again.',
+1 -1
View File
@@ -31,7 +31,7 @@ import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
/**
* Absolute URL the OIDC provider redirects back to after authorization.
+23 -5
View File
@@ -60,6 +60,7 @@ import {
unmuteRoom,
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { setPresenceWithRetry } from '../../utils/presenceWrite';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
import { useReminders } from '../../hooks/useReminders';
@@ -389,21 +390,38 @@ function StatusExpiryMonitor() {
const expiryKey = STATUS_EXPIRY_KEY(userId);
const msgKey = STATUS_MSG_KEY(userId);
// [Gitea #227] The clear must survive a failed write. Synapse rate-limits
// presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
// budget, so a bare setPresence here 429s often — and because the keys used
// to be removed BEFORE the request, a single failure dropped the auto-clear
// permanently and the status stayed set forever. Retry the write (honouring
// Retry-After), and only forget the status once the server has taken it.
let clearing = false;
const check = () => {
if (clearing) return;
const stored = localStorage.getItem(expiryKey);
if (!stored) return;
const ts = parseInt(stored, 10);
if (!ts || Date.now() < ts) return;
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
mx.setPresence({
clearing = true;
setPresenceWithRetry(mx, {
presence: presenceStateFromSetting(presenceStatusRef.current, hidePresenceRef.current),
status_msg: '',
}).catch(() => undefined);
})
.then(() => {
localStorage.removeItem(msgKey);
localStorage.removeItem(expiryKey);
})
.catch(() => undefined)
.finally(() => {
clearing = false;
});
};
check();
const interval = setInterval(check, 30_000);
// Poll often enough that a rate-limited retry lands promptly, but not so
// often that a persistent failure hammers the server.
const interval = setInterval(check, 15_000);
const onVisible = () => {
if (document.visibilityState === 'visible') check();
};
+1 -1
View File
@@ -4,7 +4,7 @@ import { Page, PageHero, PageHeroSection } from '../../components/page';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import pkg from '../../../../package.json';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
export function WelcomePage() {
return (
+29
View File
@@ -0,0 +1,29 @@
import { MatrixClient, NotificationCountType, ReceiptType, Room } from 'matrix-js-sdk';
/**
* [Gitea #165] Send a threaded read receipt for every thread in the room that
* has unread replies. Mirrors the per-thread half of `markAsRead`, including
* its rule: never fall back to the thread root (a root lives in the main
* timeline, so a receipt there would move the MAIN read marker backwards).
* Returns how many threads were marked.
*/
export async function markAllThreadsRead(
mx: MatrixClient,
room: Room,
privateReceipt: boolean,
): Promise<number> {
const receiptType = privateReceipt ? ReceiptType.ReadPrivate : ReceiptType.Read;
const sends = room
.getThreads()
.map((thread) => {
const unread =
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
if (unread <= 0) return undefined;
const lastReply = thread.lastReply();
if (!lastReply || lastReply.isSending()) return undefined;
return mx.sendReadReceipt(lastReply, receiptType, false).catch(() => undefined);
})
.filter((p): p is NonNullable<typeof p> => !!p);
await Promise.all(sends);
return sends.length;
}