Compare commits
6
Commits
738067cfc6
...
3b6de2fdac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b6de2fdac | ||
|
|
f5ee3c0d0e | ||
|
|
08abf5aec8 | ||
|
|
e393c45f50 | ||
|
|
fdec3ed7f2 | ||
|
|
cf80729a5c |
|
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user