Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72277399f0 | ||
|
|
e1168f47bb | ||
|
|
f5a184e428 | ||
|
|
a5471a0576 | ||
|
|
ed494d2ae5 | ||
|
|
ce1354ed93 | ||
|
|
adf94d7bc1 | ||
|
|
0eccf9bea9 | ||
|
|
62c9ac8436 | ||
|
|
aaf75fe74a | ||
|
|
968507b4b1 | ||
|
|
893bf0f6c4 | ||
|
|
8459427c1e | ||
|
|
ec47d542dd | ||
|
|
ef3849eb2f | ||
|
|
aaa915fe6b | ||
|
|
029550c855 | ||
|
|
5bc0942eea | ||
|
|
a937831e6b | ||
|
|
0b940a6df7 | ||
|
|
00f962a463 | ||
|
|
46da76c21b | ||
|
|
fee3fbb7fc | ||
|
|
a20accd446 | ||
|
|
674aef94fb | ||
|
|
080831d4ea | ||
|
|
2ac764cd45 | ||
|
|
8b6464c8b9 | ||
|
|
2a9b7f68b7 | ||
|
|
2815767d47 | ||
|
|
7ef24c14e0 | ||
|
|
ca284a75e3 | ||
|
|
708e5c2c14 | ||
|
|
b07eb35159 | ||
|
|
6648ca928d | ||
|
|
258ffc88be | ||
|
|
338b24c43f | ||
|
|
562198860f | ||
|
|
c4c54dad7a | ||
|
|
fe70616412 | ||
|
|
f41de57893 | ||
|
|
ef7690135d |
@@ -12,6 +12,9 @@ regexes = [
|
||||
'''https?://matrix\.lotusguild\.org''',
|
||||
'''https?://code\.lotusguild\.org/api/packages/LotusGuild/npm/''',
|
||||
'''matrix\.lotusguild\.org''',
|
||||
# Lotus Chat desktop updater's minisign PUBLIC key (published in the
|
||||
# cinny-desktop README); utils/updateErrors.ts verifies Linux downloads with it.
|
||||
'''RWRZZdyii05/NUcz337A5SAbiZK\+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l''',
|
||||
]
|
||||
paths = [
|
||||
'''config\.json''',
|
||||
|
||||
+1
-1
@@ -233,7 +233,7 @@ All decoration files are 256×256 APNGs. They animate natively in all modern bro
|
||||
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
|
||||
|
||||
**CDN:**
|
||||
Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png`. `<img>` elements load cross-origin freely — no CORS headers needed.
|
||||
Static files at `https://chat.lotusguild.org/decorations/{slug}.png` (thumbnails: `thumbs/{slug}.webp`), cached for a week. They come from the versioned `avatar-decorations` package in Gitea's package registry, installed by the matrix repo's LXC 106 deploy (see its README, "Avatar decorations"). To add or replace decorations, publish a new package version there, then update the catalog in `avatarDecorations.ts`. They used to be served from Nextcloud (matrix #22). `<img>` elements load cross-origin freely, so no CORS headers are needed.
|
||||
|
||||
**Module-level cache with in-flight deduplication:**
|
||||
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
|
||||
|
||||
@@ -265,6 +265,46 @@ test.describe('local homeserver regression', () => {
|
||||
await expect.poll(threadUnread).toBe(false);
|
||||
});
|
||||
|
||||
test("a thread I don't follow stays unread while I view the room (#314)", async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Unfollowed Thread Room', { invite: [bob.userId] });
|
||||
await joinRoom(bob, room);
|
||||
const inThread = (root: string) => ({
|
||||
'm.relates_to': {
|
||||
rel_type: 'm.thread',
|
||||
event_id: root,
|
||||
is_falling_back: true,
|
||||
'm.in_reply_to': { event_id: root },
|
||||
},
|
||||
});
|
||||
const root = await sendText(bob, room, "bob's thread root");
|
||||
await loginUI(page, alice);
|
||||
await openRoom(page, room);
|
||||
await sendText(bob, room, 'reply in bob thread', inThread(root));
|
||||
await sendText(bob, room, 'newer main message 314');
|
||||
await expect(page.getByText('newer main message 314')).toBeVisible();
|
||||
const threadUnread = async () => {
|
||||
const filter = {
|
||||
room: {
|
||||
rooms: [room],
|
||||
timeline: { limit: 1, unread_thread_notifications: true, not_types: [uniq('x.')] },
|
||||
},
|
||||
};
|
||||
const sync = await api<{
|
||||
rooms: {
|
||||
join: Record<string, { unread_thread_notifications?: Record<string, unknown> }>;
|
||||
};
|
||||
}>(
|
||||
'GET',
|
||||
`/_matrix/client/v3/sync?timeout=0&filter=${enc(JSON.stringify(filter))}`,
|
||||
alice.token,
|
||||
);
|
||||
return root in (sync.rooms.join[room]?.unread_thread_notifications ?? {});
|
||||
};
|
||||
await page.waitForTimeout(2000); // let any receipt the room view would send go out
|
||||
// Viewing the room used to read every thread you don't follow (#314).
|
||||
expect(await threadUnread()).toBe(true);
|
||||
});
|
||||
|
||||
test('timeline image opens the gallery lightbox (#219) @webkit', async ({ page }) => {
|
||||
const room = await createRoom(alice, 'Lightbox Room');
|
||||
const png = Buffer.from(
|
||||
|
||||
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.23",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
|
||||
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
||||
"version": "0.25.0-lotus.26",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
|
||||
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -86,10 +86,14 @@ import { CallAvatarAnimation } from '../styles/Animations.css';
|
||||
import { webRTCSupported } from '../utils/rtc';
|
||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
import { useReconnectWhenSynced } from '../hooks/useCallError';
|
||||
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
/** [Gitea #323] Minimised PiP bar size. */
|
||||
const PIP_MINI_W = 220;
|
||||
const PIP_MINI_H = 40;
|
||||
/** Default PiP top: just below the 64px-ish room header. */
|
||||
const PIP_DEFAULT_TOP = 72;
|
||||
|
||||
@@ -814,6 +818,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useCallPolicyRevokedToast(embed, joined);
|
||||
useCallEndedToast(embed);
|
||||
useScreenshareNotices(embed);
|
||||
useReconnectWhenSynced(embed.control);
|
||||
useDenoiseNotices(embed);
|
||||
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||
// as the host bar's (hidden where the server would refuse the share).
|
||||
@@ -1003,6 +1008,28 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
);
|
||||
|
||||
const [pipIsFullscreen, setPipIsFullscreen] = useState(false);
|
||||
// [Gitea #323] Minimised PiP: a small draggable bar (room name + expand)
|
||||
// instead of the video. Remembered; phones start minimised.
|
||||
const [pipMinimized, setPipMinimized] = useState<boolean>(() => {
|
||||
try {
|
||||
const v = localStorage.getItem('pip-minimized');
|
||||
if (v !== null) return v === '1';
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return window.innerWidth < 600;
|
||||
});
|
||||
const pipFullSizeRef = React.useRef({ width: 280, height: 158 });
|
||||
const togglePipMinimized = useCallback(() => {
|
||||
setPipMinimized((m) => {
|
||||
try {
|
||||
localStorage.setItem('pip-minimized', m ? '0' : '1');
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
return !m;
|
||||
});
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onFsChange = () => setPipIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFsChange);
|
||||
@@ -1103,6 +1130,26 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
}
|
||||
}, [pipMode, callVisible]);
|
||||
|
||||
// [Gitea #323] Apply the minimised / full PiP size (after the entry effect
|
||||
// below has positioned and sized it). Keeps the bar on screen.
|
||||
React.useEffect(() => {
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || !pipMode) return;
|
||||
if (pipMinimized) {
|
||||
if (el.offsetHeight > PIP_MINI_H) {
|
||||
pipFullSizeRef.current = { width: el.offsetWidth, height: el.offsetHeight };
|
||||
}
|
||||
el.style.width = `${PIP_MINI_W}px`;
|
||||
el.style.height = `${PIP_MINI_H}px`;
|
||||
} else {
|
||||
el.style.width = `${pipFullSizeRef.current.width}px`;
|
||||
el.style.height = `${pipFullSizeRef.current.height}px`;
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
el.style.left = `${Math.max(0, Math.min(rect.left, window.innerWidth - el.offsetWidth))}px`;
|
||||
el.style.top = `${Math.max(0, Math.min(rect.top, window.innerHeight - el.offsetHeight))}px`;
|
||||
}, [pipMode, pipMinimized, callEmbedRef]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pipMode) return;
|
||||
const onPipWindowResize = (): void => {
|
||||
@@ -1421,19 +1468,50 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
background: 'transparent',
|
||||
// Minimised: cover the frame so only the bar shows.
|
||||
background: pipMinimized ? color.Surface.Container : 'transparent',
|
||||
cursor: 'grab',
|
||||
// [Gitea #43] The call's "Share your screen?" prompt is drawn
|
||||
// inside the frame: let the click reach its Share button.
|
||||
pointerEvents: pipPromptOpen ? 'none' : undefined,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: pipMinimized ? 'center' : 'flex-start',
|
||||
padding: '6px',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
|
||||
{document.fullscreenEnabled && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: config.space.S100,
|
||||
alignItems: 'center',
|
||||
...(pipMinimized ? { width: '100%' } : {}),
|
||||
}}
|
||||
>
|
||||
{/* Leftmost: the top-right corner is a resize handle. */}
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Surface"
|
||||
fill="None"
|
||||
aria-label={pipMinimized ? 'Expand call' : 'Minimise call'}
|
||||
aria-pressed={pipMinimized}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
togglePipMinimized();
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onTouchStart={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'rgba(0,0,0,0.65)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
<Icon size="100" src={pipMinimized ? Icons.ChevronTop : Icons.Minus} />
|
||||
</IconButton>
|
||||
{!pipMinimized && document.fullscreenEnabled && (
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
@@ -1465,14 +1543,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
pointerEvents: 'none',
|
||||
...(pipMinimized
|
||||
? {
|
||||
flexGrow: 1,
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
↗ Return to call
|
||||
{pipMinimized ? `↗ ${callEmbed.room.name ?? 'Call'}` : '↗ Return to call'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PipMuteOverlay callEmbed={callEmbed} />
|
||||
{(['se', 'sw', 'ne', 'nw'] as Corner[]).map((corner) => {
|
||||
{!pipMinimized && <PipMuteOverlay callEmbed={callEmbed} />}
|
||||
{(pipMinimized ? [] : (['se', 'sw', 'ne', 'nw'] as Corner[])).map((corner) => {
|
||||
const s = corner.includes('s');
|
||||
const e2 = corner.includes('e');
|
||||
const dots = [
|
||||
|
||||
@@ -13,11 +13,13 @@ import {
|
||||
import React, { ReactNode } from 'react';
|
||||
import * as css from './styles.css';
|
||||
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||
import { isDndPresence } from '../../utils/busyPresence';
|
||||
|
||||
const PresenceToColor: Record<Presence, MainColor> = {
|
||||
[Presence.Online]: 'Success',
|
||||
[Presence.Unavailable]: 'Warning',
|
||||
[Presence.Offline]: 'Secondary',
|
||||
[Presence.Busy]: 'Critical',
|
||||
};
|
||||
|
||||
type PresenceBadgeProps = {
|
||||
@@ -31,9 +33,11 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
||||
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
||||
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
||||
const isDnd = presence === Presence.Unavailable && status === 'dnd';
|
||||
// [Gitea #283] or MSC3026 busy, which keeps a real status line.
|
||||
const isDnd = isDndPresence(presence, status);
|
||||
const legacyDnd = isDnd && status === 'dnd';
|
||||
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
||||
const displayStatus = isDnd ? undefined : status;
|
||||
const displayStatus = legacyDnd ? undefined : status;
|
||||
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
||||
|
||||
function presenceRingColor(presence: Presence | undefined, status?: string): string | null {
|
||||
if (!presence || presence === Presence.Offline) return null;
|
||||
if (presence === Presence.Busy) return color.Critical.Main; // [Gitea #283] DND
|
||||
if (presence === Presence.Unavailable) {
|
||||
return status === 'dnd' ? color.Critical.Main : color.Warning.Main;
|
||||
}
|
||||
|
||||
@@ -286,10 +286,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
</Box>
|
||||
<textarea
|
||||
aria-label="Private note about this user"
|
||||
aria-describedby="private-note-storage"
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
maxLength={USER_NOTE_MAX_LENGTH}
|
||||
placeholder="Notes only visible to you…"
|
||||
placeholder="Add a private note…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -305,6 +306,11 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{/* [Gitea #329] Account data isn't end-to-end encrypted: say so. */}
|
||||
<Text id="private-note-storage" size="T200" priority="300">
|
||||
Only you can see this in Lotus Chat. It's stored on your homeserver and isn't
|
||||
end-to-end encrypted.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
// Mirrors MembersDrawer: a 266px side panel on desktop that becomes a
|
||||
// Mirrors MembersDrawer: a side panel (shared width) on desktop that becomes a
|
||||
// full-screen fixed panel on narrow viewports — the app's canonical drawer.
|
||||
export const BookmarksPanel = style({
|
||||
width: toRem(266),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { CallEmbed } from '../../plugins/call';
|
||||
import { AUDIO_OUTPUT_STATE } from '../../plugins/call/CallControl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
@@ -39,6 +40,21 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
const [devices, setDevices] = useState<OutputDevice[]>([]);
|
||||
const [selected, setSelected] = useState<string | undefined>(embed.control.audioOutputId);
|
||||
|
||||
// [Gitea #310] Prefer the fork's list: its ids are the ones valid inside
|
||||
// the call iframe. Older forks don't report one; fall back to our own.
|
||||
const [forkDevices, setForkDevices] = useState(embed.control.audioOutputs);
|
||||
useEffect(() => {
|
||||
const onState = () => {
|
||||
setForkDevices(embed.control.audioOutputs);
|
||||
setSelected(embed.control.audioOutputId);
|
||||
};
|
||||
onState();
|
||||
embed.control.on(AUDIO_OUTPUT_STATE, onState);
|
||||
return () => {
|
||||
embed.control.off(AUDIO_OUTPUT_STATE, onState);
|
||||
};
|
||||
}, [embed]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const all = await navigator.mediaDevices.enumerateDevices();
|
||||
@@ -53,11 +69,13 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return undefined;
|
||||
if (!anchor || forkDevices) return undefined;
|
||||
refresh();
|
||||
navigator.mediaDevices.addEventListener('devicechange', refresh);
|
||||
return () => navigator.mediaDevices.removeEventListener('devicechange', refresh);
|
||||
}, [anchor, refresh]);
|
||||
}, [anchor, refresh, forkDevices]);
|
||||
|
||||
const shown = forkDevices ?? devices;
|
||||
|
||||
const choose = (id: string) => {
|
||||
embed.control.setAudioOutput(id);
|
||||
@@ -90,13 +108,14 @@ export function AudioOutputButton({ embed, disabled }: { embed: CallEmbed; disab
|
||||
aria-label="Audio output"
|
||||
>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{devices.length === 0 && (
|
||||
{shown.length === 0 && (
|
||||
<Text size="T200" priority="300" style={{ padding: config.space.S200 }}>
|
||||
No audio outputs found.
|
||||
</Text>
|
||||
)}
|
||||
{devices.map((d) => {
|
||||
const isSelected = selected ? d.id === selected : d.id === 'default';
|
||||
{shown.map((d) => {
|
||||
const isSelected =
|
||||
selected !== undefined ? d.id === selected : d.id === 'default' || d.id === '';
|
||||
return (
|
||||
<MenuItem
|
||||
key={d.id}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Spinner, Text, color } from 'folds';
|
||||
import { Box, Button, Spinner, Text, color } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import classNames from 'classnames';
|
||||
import { LiveChip } from './LiveChip';
|
||||
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { MemberSpeaking } from './MemberSpeaking';
|
||||
import { clockSkewAtom } from '../../state/clockSkew';
|
||||
import { describeSkewVsServer } from '../../utils/clockSkew';
|
||||
import { useCallError } from '../../hooks/useCallError';
|
||||
|
||||
type CallStatusProps = {
|
||||
callEmbed: CallEmbed;
|
||||
@@ -42,6 +43,8 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
const clockSkew = useAtomValue(clockSkewAtom);
|
||||
|
||||
const compact = screenSize === ScreenSize.Mobile;
|
||||
// [Gitea #318] The call dropped ("Connection lost" in the frame).
|
||||
const callError = useCallError(callEmbed.control);
|
||||
|
||||
const memberVisible = callJoined && callMembers.length > 0;
|
||||
|
||||
@@ -65,6 +68,25 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
{!compact && (
|
||||
<>
|
||||
<CallRoomName room={room} />
|
||||
{callError && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
<Text size="T200" truncate role="alert" style={{ color: color.Critical.Main }}>
|
||||
Call disconnected
|
||||
</Text>
|
||||
{callError.recoverable && (
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
onClick={() => callEmbed.control.reconnect()}
|
||||
>
|
||||
<Text size="B300">Reconnect</Text>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{clockSkew.warning && clockSkew.skewMs !== null && (
|
||||
<>
|
||||
<StatusDivider />
|
||||
@@ -102,8 +124,19 @@ export function CallStatus({ callEmbed }: CallStatusProps) {
|
||||
{memberVisible && !compact && <StatusDivider />}
|
||||
<Box shrink="No" alignItems="Center" gap="Inherit">
|
||||
{compact && (
|
||||
<Box grow="Yes">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<CallRoomName room={room} />
|
||||
{callError?.recoverable && (
|
||||
<Button
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
onClick={() => callEmbed.control.reconnect()}
|
||||
>
|
||||
<Text size="B300">Reconnect</Text>
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<CallControl callJoined={callJoined} compact={compact} callEmbed={callEmbed} />
|
||||
|
||||
@@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
|
||||
const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/;
|
||||
import { buildTopicContent } from '../../../utils/topicMarkdown';
|
||||
|
||||
function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) {
|
||||
const start = textarea.selectionStart;
|
||||
@@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde
|
||||
textarea.setSelectionRange(cursorStart, cursorEnd);
|
||||
}
|
||||
|
||||
function topicMarkdownToHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.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;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
// of this file at runtime (by regex) instead of re-declaring it, so the two can
|
||||
// never drift. If you migrate the CDN, change it here ONLY — keep the
|
||||
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
|
||||
export const DECORATION_CDN =
|
||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
// matrix #22: static files on chat.lotusguild.org (a versioned package installed
|
||||
// by the matrix repo's LXC 106 deploy), not Nextcloud's public DAV. Absolute, so
|
||||
// the desktop app and the Element Call fork's origin allowlist both work.
|
||||
export const DECORATION_CDN = 'https://chat.lotusguild.org/decorations';
|
||||
|
||||
// Runtime base. A deployment can repoint the decorations at a different host
|
||||
// without editing the catalog literal above (which scripts/syncDecorations.mjs
|
||||
|
||||
@@ -32,24 +32,12 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
||||
import { isValidServerPattern, matchesAnyGlob, normalizeServerAcl } from '../../utils/serverAcl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_ACL: ServerAclContent = {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: false,
|
||||
};
|
||||
|
||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||
|
||||
type ServerListProps = {
|
||||
@@ -185,20 +173,20 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
|
||||
// Read current ACL from room state
|
||||
const aclEvent = useStateEvent(room, StateEvent.RoomServerAcl);
|
||||
const currentAcl = aclEvent?.getContent<ServerAclContent>() ?? DEFAULT_ACL;
|
||||
const currentAcl = normalizeServerAcl(aclEvent?.getContent());
|
||||
|
||||
// Local draft state — initialised from current ACL
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow ?? ['*']);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny ?? []);
|
||||
const [allowList, setAllowList] = useState<string[]>(() => currentAcl.allow);
|
||||
const [denyList, setDenyList] = useState<string[]>(() => currentAcl.deny);
|
||||
const [allowIpLiterals, setAllowIpLiterals] = useState<boolean>(
|
||||
() => currentAcl.allow_ip_literals ?? false,
|
||||
() => currentAcl.allow_ip_literals,
|
||||
);
|
||||
|
||||
// Track whether there are unsaved changes
|
||||
const isDirty =
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow ?? ['*']) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny ?? []) ||
|
||||
allowIpLiterals !== (currentAcl.allow_ip_literals ?? false);
|
||||
JSON.stringify(allowList) !== JSON.stringify(currentAcl.allow) ||
|
||||
JSON.stringify(denyList) !== JSON.stringify(currentAcl.deny) ||
|
||||
allowIpLiterals !== currentAcl.allow_ip_literals;
|
||||
|
||||
// Save handler
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
@@ -336,7 +324,10 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
id="allow-ip-literals"
|
||||
checked={allowIpLiterals}
|
||||
disabled={!canEdit}
|
||||
onClick={() => setAllowIpLiterals(!allowIpLiterals)}
|
||||
// folds' Checkbox has no onChange; readOnly tells React the
|
||||
// click handler controls it (no "checked without onChange").
|
||||
readOnly
|
||||
onClick={(e) => setAllowIpLiterals(e.currentTarget.checked)}
|
||||
size="300"
|
||||
variant="Primary"
|
||||
/>
|
||||
|
||||
@@ -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. 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.
|
||||
// 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.
|
||||
export const MediaGalleryDrawer = style({
|
||||
width: toRem(320),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
overflow: 'hidden',
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../styles/sidePanel';
|
||||
|
||||
export const MembersDrawer = style({
|
||||
width: toRem(266),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const ThreadPanel = style({
|
||||
width: toRem(360),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { color, config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const ThreadsListPanel = style({
|
||||
width: toRem(340),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config, toRem } from 'folds';
|
||||
import { config } from 'folds';
|
||||
import { SIDE_PANEL_WIDTH } from '../../../styles/sidePanel';
|
||||
|
||||
export const WidgetsPanel = style({
|
||||
width: toRem(360),
|
||||
width: SIDE_PANEL_WIDTH,
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
|
||||
@@ -33,16 +33,9 @@ import { About } from './about';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { LogoutDialog } from '../../components/LogoutDialog';
|
||||
import { SettingsPages } from './pages';
|
||||
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
export { SettingsPages };
|
||||
|
||||
type SettingsMenuItem = {
|
||||
page: SettingsPages;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import React, { lazy, Suspense } from 'react';
|
||||
import { Box, Spinner, config } from 'folds';
|
||||
import type { SettingsPages } from './pages';
|
||||
|
||||
// Settings is a large, rarely-open dialog (all its pages, pickers, the
|
||||
// denoise tester…). Load it on first open instead of in the entry chunk, and
|
||||
// warm the chunk once the app is idle so opening it rarely waits.
|
||||
const loadSettings = () => import('./Settings');
|
||||
const SettingsDialog = lazy(() => loadSettings().then((m) => ({ default: m.Settings })));
|
||||
if (typeof window !== 'undefined') {
|
||||
const warm = () => {
|
||||
loadSettings().catch(() => undefined);
|
||||
};
|
||||
if ('requestIdleCallback' in window) window.requestIdleCallback(warm, { timeout: 10_000 });
|
||||
else setTimeout(warm, 5_000);
|
||||
}
|
||||
|
||||
type SettingsProps = {
|
||||
initialPage?: SettingsPages;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function Settings(props: SettingsProps) {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
// Settings opens inside a FocusTrap, which needs a tabbable node.
|
||||
<Box
|
||||
tabIndex={0}
|
||||
role="status"
|
||||
aria-label="Loading settings"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{ padding: config.space.S700, outline: 'none' }}
|
||||
>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<SettingsDialog {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -38,7 +38,11 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getAccountData, setAccountData } from '../../../utils/accountData';
|
||||
import { presenceStateFromSetting } from '../../../hooks/usePresenceUpdater';
|
||||
import { describePresenceError, setPresenceWithRetry } from '../../../utils/presenceWrite';
|
||||
import {
|
||||
describePresenceError,
|
||||
setPresenceWithRetry,
|
||||
writePresence,
|
||||
} from '../../../utils/presenceWrite';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UserProfile, useUserProfile } from '../../../hooks/useUserProfile';
|
||||
@@ -67,6 +71,7 @@ import {
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../../utils/statusKeys';
|
||||
import { writeStatusExpiry } from '../../../utils/statusExpiry';
|
||||
|
||||
type ProfileProps = {
|
||||
@@ -366,8 +371,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
export { STATUS_EXPIRY_KEY, STATUS_MSG_KEY };
|
||||
|
||||
const CLEAR_AFTER_OPTIONS = [
|
||||
{ label: 'Never', value: '0' },
|
||||
@@ -560,7 +564,7 @@ function ProfileStatus() {
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
writeStatusExpiry(mx, '').catch(() => undefined);
|
||||
// Preserve the user's chosen presence when clearing the status message.
|
||||
mx.setPresence({
|
||||
writePresence(mx, {
|
||||
presence: presenceStateFromSetting(presenceStatus, hidePresence),
|
||||
status_msg: '',
|
||||
}).catch(() => undefined);
|
||||
|
||||
@@ -108,6 +108,7 @@ import {
|
||||
TRANSLATE_TARGET_LANGUAGES,
|
||||
normalizeLang,
|
||||
isSupportedTargetLang,
|
||||
translationUnavailableText,
|
||||
} from '../../../utils/translation/langUtils';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
@@ -2756,7 +2757,7 @@ function Messages() {
|
||||
description={
|
||||
translationSupported
|
||||
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
||||
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||
: translationUnavailableText(isTauriEnv())
|
||||
}
|
||||
after={
|
||||
<SettingsSelect
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from './Settings';
|
||||
export { SettingsPages } from './pages';
|
||||
export { Settings } from './SettingsLazy';
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Settings dialog pages. Kept apart from Settings.tsx so callers can name a
|
||||
* page without pulling the dialog into the entry chunk. */
|
||||
export enum SettingsPages {
|
||||
GeneralPage,
|
||||
AccountPage,
|
||||
NotificationPage,
|
||||
DevicesPage,
|
||||
EmojisStickersPage,
|
||||
DeveloperToolsPage,
|
||||
AboutPage,
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { SyncState } from 'matrix-js-sdk';
|
||||
import { CALL_ERROR_CHANGED, CallControl } from '../plugins/call/CallControl';
|
||||
import { CallErrorState } from '../plugins/call/callError';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useSyncState } from './useSyncState';
|
||||
|
||||
/** [Gitea #318] The fork's current call error (null while the call is fine). */
|
||||
export function useCallError(control: CallControl): CallErrorState {
|
||||
const [err, setErr] = useState(control.callError);
|
||||
useEffect(() => {
|
||||
const on = () => setErr(control.callError);
|
||||
on();
|
||||
control.on(CALL_ERROR_CHANGED, on);
|
||||
return () => {
|
||||
control.off(CALL_ERROR_CHANGED, on);
|
||||
};
|
||||
}, [control]);
|
||||
return err;
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #318] After a homeserver stall the call drops with "Connection
|
||||
* lost". Once sync is back, reconnect automatically (once per drop).
|
||||
*/
|
||||
export function useReconnectWhenSynced(control: CallControl): void {
|
||||
const mx = useMatrixClient();
|
||||
const err = useCallError(control);
|
||||
const tried = useRef<CallErrorState>(null);
|
||||
useSyncState(
|
||||
mx,
|
||||
useCallback(
|
||||
(state: SyncState | null) => {
|
||||
if (state !== SyncState.Syncing || !err?.recoverable || tried.current === err) return;
|
||||
tried.current = err;
|
||||
control.reconnect();
|
||||
},
|
||||
[err, control],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,27 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { SetPresence } from 'matrix-js-sdk';
|
||||
import { PresenceDeduper, PresenceWrite } from '../utils/presenceWrite';
|
||||
import { PresenceDeduper, PresenceWrite, writePresence } from '../utils/presenceWrite';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getLastLocalStatusWrite, pickHeartbeatStatus } from '../utils/ownStatusSync';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import {
|
||||
BUSY_PRESENCE,
|
||||
BUSY_PRESENCE_FEATURE,
|
||||
isBusyPresenceSupported,
|
||||
setBusyPresenceSupported,
|
||||
} from '../utils/busyPresence';
|
||||
|
||||
const IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const ACTIVITY_THROTTLE_MS = 1000;
|
||||
|
||||
export type PresenceSetting = 'auto' | 'online' | 'idle' | 'dnd' | 'invisible';
|
||||
export type PresenceState = 'online' | 'unavailable' | 'offline';
|
||||
export type PresenceState = 'online' | 'unavailable' | 'offline' | typeof BUSY_PRESENCE;
|
||||
|
||||
/**
|
||||
* Single source of truth for mapping the user's presence preference to the
|
||||
* Matrix presence value: auto/online → 'online', idle/dnd → 'unavailable',
|
||||
* Matrix presence value: auto/online → 'online', idle → 'unavailable', dnd →
|
||||
* MSC3026 busy (or 'unavailable' where the server lacks it),
|
||||
* invisible (or the hidePresence override) → 'offline'. Shared with the Profile
|
||||
* status writer so setting/clearing a status message never overrides the user's
|
||||
* chosen presence (e.g. outing an Invisible user as online).
|
||||
@@ -24,7 +31,10 @@ export function presenceStateFromSetting(
|
||||
hidePresence: boolean,
|
||||
): PresenceState {
|
||||
if (hidePresence || presenceStatus === 'invisible') return 'offline';
|
||||
if (presenceStatus === 'idle' || presenceStatus === 'dnd') return 'unavailable';
|
||||
// [Gitea #283] DND is MSC3026 busy where the server supports it, so status
|
||||
// writes (save / clear / auto-clear) don't downgrade it to "unavailable".
|
||||
if (presenceStatus === 'dnd') return isBusyPresenceSupported() ? BUSY_PRESENCE : 'unavailable';
|
||||
if (presenceStatus === 'idle') return 'unavailable';
|
||||
return 'online';
|
||||
}
|
||||
|
||||
@@ -62,8 +72,7 @@ export function usePresenceUpdater() {
|
||||
// by a heartbeat that changed nothing.
|
||||
const send = (write: PresenceWrite, label: string) => {
|
||||
if (!deduper.shouldSend(write)) return Promise.resolve();
|
||||
return mx
|
||||
.setPresence(write)
|
||||
return writePresence(mx, write)
|
||||
.then(() => deduper.sent(write))
|
||||
.catch((err) => warnPresenceFailure(label, err));
|
||||
};
|
||||
@@ -134,7 +143,22 @@ export function usePresenceUpdater() {
|
||||
return undefined;
|
||||
}
|
||||
if (presenceStatus === 'dnd') {
|
||||
setUnavailable('dnd');
|
||||
// [Gitea #283] Real "busy" presence where supported: the status text
|
||||
// stays yours. /sync reporting "online" doesn't override busy on
|
||||
// Synapse, so the sync loop can stay online. Older servers keep the
|
||||
// legacy unavailable + "dnd" text encoding.
|
||||
mx.doesServerSupportUnstableFeature(BUSY_PRESENCE_FEATURE)
|
||||
.catch(() => false)
|
||||
.then(async (busy) => {
|
||||
setBusyPresenceSupported(busy);
|
||||
if (!busy) {
|
||||
setUnavailable('dnd');
|
||||
return;
|
||||
}
|
||||
syncPresence(SetPresence.Online);
|
||||
const status = await resolveStatus();
|
||||
send({ presence: BUSY_PRESENCE, ...(status ? { status_msg: status } : {}) }, 'busy');
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ export enum Presence {
|
||||
Online = 'online',
|
||||
Unavailable = 'unavailable',
|
||||
Offline = 'offline',
|
||||
// [Gitea #283] MSC3026 busy: Lotus's Do Not Disturb.
|
||||
Busy = 'org.matrix.msc3026.busy',
|
||||
}
|
||||
|
||||
export type UserPresence = {
|
||||
@@ -129,6 +131,7 @@ export const usePresenceLabel = (): Record<Presence, string> =>
|
||||
[Presence.Online]: 'Online',
|
||||
[Presence.Unavailable]: 'Idle',
|
||||
[Presence.Offline]: 'Offline',
|
||||
[Presence.Busy]: 'Do Not Disturb',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -63,7 +63,7 @@ import {
|
||||
migrateLocalMuteTimers,
|
||||
unmuteRoom,
|
||||
} from '../../features/room-nav/muteTimers';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../utils/statusKeys';
|
||||
import { noteLocalStatusWrite } from '../../utils/ownStatusSync';
|
||||
import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { EventEmitter } from 'events';
|
||||
import { CallControlState } from './CallControlState';
|
||||
import { ElementMediaStateDetail, ElementMediaStatePayload, ElementWidgetActions } from './types';
|
||||
import { canDelegateCapability } from './utils';
|
||||
import { CallErrorState, parseCallError } from './callError';
|
||||
|
||||
export enum CallControlEvent {
|
||||
StateUpdate = 'state_update',
|
||||
@@ -54,6 +55,29 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
|
||||
};
|
||||
}
|
||||
|
||||
export const CALL_ERROR_CHANGED = 'call_error_changed';
|
||||
|
||||
export type AudioOutputOption = { id: string; label: string };
|
||||
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
|
||||
|
||||
/** Pure: parse the fork's `io.lotus.audio_output_state` (#310). */
|
||||
export function parseAudioOutputState(
|
||||
data: unknown,
|
||||
): { deviceId?: string; available?: AudioOutputOption[] } | undefined {
|
||||
if (typeof data !== 'object' || data === null) return undefined;
|
||||
const { deviceId, available } = data as { deviceId?: unknown; available?: unknown };
|
||||
const list = Array.isArray(available)
|
||||
? available.filter(
|
||||
(o): o is AudioOutputOption =>
|
||||
typeof o === 'object' &&
|
||||
o !== null &&
|
||||
typeof (o as AudioOutputOption).id === 'string' &&
|
||||
typeof (o as AudioOutputOption).label === 'string',
|
||||
)
|
||||
: undefined;
|
||||
return { deviceId: typeof deviceId === 'string' ? deviceId : undefined, available: list };
|
||||
}
|
||||
|
||||
export class CallControl extends EventEmitter implements CallControlState {
|
||||
private state: CallControlState;
|
||||
|
||||
@@ -212,11 +236,51 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
return this._audioOutputId;
|
||||
}
|
||||
|
||||
// [Gitea #318] The fork's error page ("Connection lost") is up; the host
|
||||
// kept showing the call as joined with controls that did nothing.
|
||||
private _callError: CallErrorState = null;
|
||||
|
||||
public get callError(): CallErrorState {
|
||||
return this._callError;
|
||||
}
|
||||
|
||||
/** The fork's `io.lotus.call_error` report. */
|
||||
public onCallError(data: unknown) {
|
||||
const next = parseCallError(data);
|
||||
if (next === undefined) return;
|
||||
this._callError = next;
|
||||
this.emit(CALL_ERROR_CHANGED);
|
||||
}
|
||||
|
||||
/** Press the fork's Reconnect / Try again. */
|
||||
public reconnect(): void {
|
||||
this.call.transport.send('io.lotus.reconnect', {}).catch(() => undefined);
|
||||
}
|
||||
|
||||
public setAudioOutput(deviceId: string): void {
|
||||
this._audioOutputId = deviceId;
|
||||
this.sendAudioOutput(deviceId);
|
||||
}
|
||||
|
||||
// [Gitea #310] deviceIds are hashed per frame/origin, so ids from the
|
||||
// host's own enumerateDevices() mean nothing inside the call iframe. The
|
||||
// fork reports its outputs (ids valid in the frame) and its effective
|
||||
// selection; the call bar's menu uses those when present.
|
||||
private _audioOutputs: AudioOutputOption[] | undefined;
|
||||
|
||||
public get audioOutputs(): AudioOutputOption[] | undefined {
|
||||
return this._audioOutputs;
|
||||
}
|
||||
|
||||
/** The fork's `io.lotus.audio_output_state` report. */
|
||||
public onAudioOutputState(data: unknown) {
|
||||
const report = parseAudioOutputState(data);
|
||||
if (!report) return;
|
||||
this._audioOutputs = report.available;
|
||||
if (report.deviceId !== undefined) this._audioOutputId = report.deviceId;
|
||||
this.emit(AUDIO_OUTPUT_STATE);
|
||||
}
|
||||
|
||||
private sendAudioOutput(deviceId: string): void {
|
||||
if (!this.joined) return;
|
||||
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
||||
|
||||
@@ -453,6 +453,18 @@ export class CallEmbed {
|
||||
this.control.onControlsState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||
}),
|
||||
);
|
||||
// [Gitea #318] The fork's error page ("Connection lost") is up / cleared.
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.call_error', (evt) => {
|
||||
this.control.onCallError((evt.detail as { data?: unknown } | undefined)?.data);
|
||||
}),
|
||||
);
|
||||
// [Gitea #310] The fork's own output list + selection (frame-valid ids).
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.audio_output_state', (evt) => {
|
||||
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||
}),
|
||||
);
|
||||
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.hotkey', (evt) => {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseAudioOutputState } from './CallControl';
|
||||
|
||||
test('parseAudioOutputState: keeps valid options, tolerates garbage (#310)', () => {
|
||||
assert.deepEqual(
|
||||
parseAudioOutputState({
|
||||
deviceId: 'f2',
|
||||
available: [{ id: '', label: 'Default' }, { id: 'f2', label: 'Headset' }, { id: 3 }, null],
|
||||
}),
|
||||
{
|
||||
deviceId: 'f2',
|
||||
available: [
|
||||
{ id: '', label: 'Default' },
|
||||
{ id: 'f2', label: 'Headset' },
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(parseAudioOutputState({ deviceId: null }), {
|
||||
deviceId: undefined,
|
||||
available: undefined,
|
||||
});
|
||||
assert.equal(parseAudioOutputState('x'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseCallError } from './callError';
|
||||
|
||||
test('parseCallError (#318)', () => {
|
||||
assert.deepEqual(parseCallError({ code: 'CONNECTION_LOST_ERROR', recoverable: true }), {
|
||||
code: 'CONNECTION_LOST_ERROR',
|
||||
recoverable: true,
|
||||
});
|
||||
assert.deepEqual(parseCallError({ code: 'E2EE_NOT_SUPPORTED' }), {
|
||||
code: 'E2EE_NOT_SUPPORTED',
|
||||
recoverable: false,
|
||||
});
|
||||
assert.equal(parseCallError({ code: null }), null);
|
||||
assert.equal(parseCallError({}), undefined);
|
||||
assert.equal(parseCallError('x'), undefined);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* [Gitea #318] The fork's `io.lotus.call_error`: its error page is showing
|
||||
* (`{ code, recoverable }`) or was cleared (`{ code: null }`).
|
||||
*/
|
||||
export type CallErrorState = { code: string; recoverable: boolean } | null;
|
||||
|
||||
/** Pure: parse a report; undefined for garbage (ignored). */
|
||||
export function parseCallError(data: unknown): CallErrorState | undefined {
|
||||
if (typeof data !== 'object' || data === null) return undefined;
|
||||
const { code, recoverable } = data as { code?: unknown; recoverable?: unknown };
|
||||
if (code === null) return null;
|
||||
if (typeof code !== 'string' || !code) return undefined;
|
||||
return { code, recoverable: recoverable === true };
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
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);
|
||||
@@ -0,0 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BUSY_PRESENCE, isDndPresence } from './busyPresence';
|
||||
|
||||
test('isDndPresence: busy, or the legacy unavailable + "dnd" text (#283)', () => {
|
||||
assert.equal(isDndPresence(BUSY_PRESENCE, '🎮 Gaming'), true);
|
||||
assert.equal(isDndPresence(BUSY_PRESENCE, undefined), true);
|
||||
assert.equal(isDndPresence('unavailable', 'dnd'), true);
|
||||
assert.equal(isDndPresence('unavailable', 'lunch'), false);
|
||||
assert.equal(isDndPresence('online', 'dnd'), false);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* [Gitea #283] Do Not Disturb as a real presence state: MSC3026 "busy"
|
||||
* (Synapse `experimental_features.msc3026_enabled`). Before, DND was sent as
|
||||
* presence `unavailable` with the literal status text "dnd", which replaced
|
||||
* your status and showed as "dnd" in other clients.
|
||||
*/
|
||||
export const BUSY_PRESENCE = 'org.matrix.msc3026.busy';
|
||||
export const BUSY_PRESENCE_FEATURE = 'org.matrix.msc3026.busy_presence';
|
||||
|
||||
let busySupported = false;
|
||||
|
||||
/** Recorded by the presence updater from the server's unstable_features. */
|
||||
export const setBusyPresenceSupported = (supported: boolean): void => {
|
||||
busySupported = supported;
|
||||
};
|
||||
|
||||
export const isBusyPresenceSupported = (): boolean => busySupported;
|
||||
|
||||
/** Pure: whether a presence means Do Not Disturb (busy, or the legacy "dnd" text). */
|
||||
export const isDndPresence = (presence: string | undefined, status: string | undefined): boolean =>
|
||||
presence === BUSY_PRESENCE || (presence === 'unavailable' && status === 'dnd');
|
||||
@@ -169,7 +169,7 @@ test('private receipt flag uses ReadPrivate', async () => {
|
||||
// follows unread; an explicit mark-as-read still clears everything.
|
||||
const sender = (userId: string) => ({ getSender: () => userId });
|
||||
|
||||
test('passive: a thread I replied in stays unread; main receipt is scoped to main', async () => {
|
||||
test('passive: threads stay unread, followed or not; main receipt is scoped to main (#314)', async () => {
|
||||
const mine = thread('$mine', evt('$r1'), { hasCurrentUserParticipated: true });
|
||||
const other = thread('$other', evt('$r2'));
|
||||
const { mx, calls } = setup({
|
||||
@@ -181,10 +181,9 @@ test('passive: a thread I replied in stays unread; main receipt is scoped to mai
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[
|
||||
['b', false], // main-scoped: unthreaded would also read $mine's reply
|
||||
['$r2', false],
|
||||
],
|
||||
// main-scoped (unthreaded would read the threads' replies), and no thread
|
||||
// receipts: [Gitea #314] unfollowed threads used to be read silently.
|
||||
[['b', false]],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -203,7 +202,7 @@ test('passive: threads I started or was mentioned in stay unread', async () => {
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
test('passive: no followed thread unread → main receipt stays unthreaded', async () => {
|
||||
test('passive: an unfollowed unread thread is left unread too (#314)', async () => {
|
||||
const other = thread('$other', evt('$r2'), { rootEvent: sender('@bob:server') });
|
||||
const { mx, calls } = setup({
|
||||
timeline: [evt('a'), evt('b')],
|
||||
@@ -214,10 +213,16 @@ test('passive: no followed thread unread → main receipt stays unthreaded', asy
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[
|
||||
['b', true],
|
||||
['$r2', false],
|
||||
],
|
||||
[['b', false]],
|
||||
);
|
||||
});
|
||||
|
||||
test('passive: no thread unread → main receipt stays unthreaded', async () => {
|
||||
const { mx, calls } = setup({ timeline: [evt('a'), evt('b')], readUpTo: 'a', threads: [] });
|
||||
await markAsRead(mx, '!r:server', false, { passive: true });
|
||||
assert.deepEqual(
|
||||
calls.map((c) => [c.eventId, c.unthreaded]),
|
||||
[['b', true]],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -249,9 +254,10 @@ test('passive: the thread open in the panel is left to the panel', async () => {
|
||||
threadUnread: { $open: 1, $other: 1 },
|
||||
});
|
||||
await markAsRead(mx, '!r:server', false, { passive: true, openThreadId: '$open' });
|
||||
// The open thread is left to the panel, and [#314] the other one stays unread.
|
||||
assert.deepEqual(
|
||||
calls.map((c) => c.eventId),
|
||||
['$r2'],
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Room, Thread } from 'matrix-js-sdk';
|
||||
import { MatrixClient, NotificationCountType, ReceiptType, Thread } from 'matrix-js-sdk';
|
||||
import { getSettings } from '../state/settings';
|
||||
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
|
||||
import { isThreadFollowed } from './room';
|
||||
|
||||
/**
|
||||
* [Gitea #217] Threads the user is following: they started it, replied in it,
|
||||
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
|
||||
* thread bundle and lags a reply we just sent, so our own loaded events count too.
|
||||
*/
|
||||
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
|
||||
if (thread.hasCurrentUserParticipated) return true;
|
||||
if (userId && thread.rootEvent?.getSender() === userId) return true;
|
||||
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
|
||||
return (
|
||||
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
|
||||
);
|
||||
}
|
||||
// isThreadFollowed lives in utils/room (getUnreadInfo needs it too).
|
||||
export { isThreadFollowed };
|
||||
|
||||
export type MarkAsReadOptions = {
|
||||
/**
|
||||
* [Gitea #217] The read comes from simply looking at the room (timeline at
|
||||
* the bottom and focused), not an explicit "mark as read". Threads the user
|
||||
* follows stay unread until their panel is opened, like Slack and Discord;
|
||||
* the rest are cleared so they don't keep the room dot lit forever.
|
||||
* the bottom and focused), not an explicit "mark as read". Threads stay
|
||||
* unread until opened (or "Mark all read"), like Slack and Discord.
|
||||
* [Gitea #314] That includes threads you don't follow; those no longer
|
||||
* count toward the room's unread dot (see getUnreadInfo), so leaving them
|
||||
* unread doesn't keep the room lit.
|
||||
*/
|
||||
passive?: boolean;
|
||||
/**
|
||||
@@ -66,7 +58,9 @@ export async function markAsRead(
|
||||
const threads = room.getThreads();
|
||||
const threadUnread = (thread: Thread) =>
|
||||
room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
const keepThread = (thread: Thread) => passive && isThreadFollowed(room, thread, mx.getUserId());
|
||||
// [Gitea #314] A passive read leaves every thread's state alone (it used to
|
||||
// silently read every thread you don't follow).
|
||||
const keepThread = (_thread: Thread) => passive;
|
||||
// [Gitea #217] An unthreaded receipt also reads every thread reply older than
|
||||
// it, so a passive read while a followed thread is unread must stay scoped
|
||||
// to the main timeline or the next main message would clear that thread.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MatrixClient, MatrixError } from 'matrix-js-sdk';
|
||||
import { MatrixClient, MatrixError, IPresenceOpts, Method } from 'matrix-js-sdk';
|
||||
import { BUSY_PRESENCE } from './busyPresence';
|
||||
|
||||
/**
|
||||
* Synapse rate-limits `PUT /presence/{user}/status` to ONE request per 10 s per
|
||||
@@ -10,7 +11,29 @@ import { MatrixClient, MatrixError } from 'matrix-js-sdk';
|
||||
* and `PresenceDeduper` lets the heartbeat skip writes that would not change
|
||||
* anything, so it stops spending the budget in the first place.
|
||||
*/
|
||||
export type PresenceWrite = { presence: 'online' | 'offline' | 'unavailable'; status_msg?: string };
|
||||
export type PresenceWrite = {
|
||||
// [Gitea #283] or MSC3026 busy (Do Not Disturb)
|
||||
presence: 'online' | 'offline' | 'unavailable' | typeof BUSY_PRESENCE;
|
||||
status_msg?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* `mx.setPresence` for a PresenceWrite. The SDK refuses anything but the three
|
||||
* spec presences ("Bad presence value"), so MSC3026 busy (#283) goes straight
|
||||
* to the same endpoint.
|
||||
*/
|
||||
export const writePresence = async (
|
||||
mx: Pick<MatrixClient, 'setPresence'> & Partial<Pick<MatrixClient, 'getSafeUserId' | 'http'>>,
|
||||
write: PresenceWrite,
|
||||
): Promise<void> => {
|
||||
if (write.presence !== BUSY_PRESENCE) {
|
||||
await mx.setPresence(write as IPresenceOpts);
|
||||
return;
|
||||
}
|
||||
if (!mx.getSafeUserId || !mx.http) throw new Error('busy presence needs a full MatrixClient');
|
||||
const path = `/presence/${encodeURIComponent(mx.getSafeUserId())}/status`;
|
||||
await mx.http.authedRequest(Method.Put, path, undefined, write);
|
||||
};
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((r) => {
|
||||
@@ -28,7 +51,7 @@ export async function setPresenceWithRetry(
|
||||
for (;;) {
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mx.setPresence(write);
|
||||
await writePresence(mx, write);
|
||||
return;
|
||||
} catch (e) {
|
||||
if (!(e instanceof MatrixError) || e.httpStatus !== 429) throw e;
|
||||
|
||||
@@ -928,3 +928,42 @@ test('getAllVersionsRoomCreator returns empty set when no create event', () => {
|
||||
test('EventTimeline.FORWARDS constant is available', () => {
|
||||
assert.equal(typeof EventTimeline.FORWARDS, 'string');
|
||||
});
|
||||
|
||||
test('getUnreadInfo: unfollowed threads do not light the room; followed and mentioned do (#314)', () => {
|
||||
const threads = [
|
||||
{ id: '$followed', hasCurrentUserParticipated: true },
|
||||
{ id: '$other' },
|
||||
{ id: '$mention' },
|
||||
];
|
||||
const counts: Record<string, { total: number; highlight: number }> = {
|
||||
$followed: { total: 2, highlight: 0 },
|
||||
$other: { total: 3, highlight: 0 },
|
||||
$mention: { total: 1, highlight: 1 },
|
||||
};
|
||||
const room = {
|
||||
roomId: '!r:x',
|
||||
// Server room totals include thread counts: 2 + 3 + 1 thread, 0 main.
|
||||
getUnreadNotificationCount: (type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? 6 : 1,
|
||||
getThreads: () => threads,
|
||||
getThreadUnreadNotificationCount: (id: string, type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? counts[id].total : counts[id].highlight,
|
||||
getEventReadUpTo: () => null,
|
||||
getLiveTimeline: () => ({ getEvents: () => [] }),
|
||||
} as unknown as Room;
|
||||
const me = { getUserId: () => '@me:x' } as unknown as MatrixClient;
|
||||
assert.deepEqual(getUnreadInfo(room, undefined, me), { roomId: '!r:x', highlight: 1, total: 3 });
|
||||
// Only an unfollowed thread is unread: the room isn't lit.
|
||||
counts.$followed.total = 0;
|
||||
counts.$mention = { total: 0, highlight: 0 };
|
||||
const onlyOther = {
|
||||
...room,
|
||||
getUnreadNotificationCount: (type: NotificationCountType) =>
|
||||
type === NotificationCountType.Total ? 3 : 0,
|
||||
} as unknown as Room;
|
||||
assert.deepEqual(getUnreadInfo(onlyOther, undefined, me), {
|
||||
roomId: '!r:x',
|
||||
highlight: 0,
|
||||
total: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
Thread,
|
||||
RoomMember,
|
||||
TimelineEvents,
|
||||
IRoomTimelineData,
|
||||
@@ -418,6 +419,20 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #217] Threads the user is following: they started it, replied in it,
|
||||
* or were mentioned in it. `hasCurrentUserParticipated` comes from the server's
|
||||
* thread bundle and lags a reply we just sent, so our own loaded events count too.
|
||||
*/
|
||||
export function isThreadFollowed(room: Room, thread: Thread, userId: string | null): boolean {
|
||||
if (thread.hasCurrentUserParticipated) return true;
|
||||
if (userId && thread.rootEvent?.getSender() === userId) return true;
|
||||
if (userId && thread.timeline?.some((e) => e.getSender() === userId)) return true;
|
||||
return (
|
||||
(room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight) ?? 0) > 0
|
||||
);
|
||||
}
|
||||
|
||||
export const getUnreadInfo = (
|
||||
room: Room,
|
||||
mutedThreads?: Set<string>,
|
||||
@@ -439,6 +454,18 @@ export const getUnreadInfo = (
|
||||
if (highlight < 0) highlight = 0;
|
||||
}
|
||||
|
||||
// [Gitea #314] Threads you don't follow stay unread in the Threads panel
|
||||
// (viewing the room no longer reads them) but don't light the room's dot.
|
||||
// A mention makes a thread followed, so highlights are never subtracted.
|
||||
if (mx) {
|
||||
room.getThreads().forEach((thread) => {
|
||||
if (mutedThreads?.has(thread.id)) return;
|
||||
if (isThreadFollowed(room, thread, mx.getUserId())) return;
|
||||
total -= room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) ?? 0;
|
||||
});
|
||||
if (total < 0) total = 0;
|
||||
}
|
||||
|
||||
const resolvedTotal = highlight > total ? highlight : total;
|
||||
|
||||
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isValidServerPattern, globToRegExp, matchesAnyGlob } from './serverAcl';
|
||||
import {
|
||||
isValidServerPattern,
|
||||
globToRegExp,
|
||||
matchesAnyGlob,
|
||||
normalizeServerAcl,
|
||||
} from './serverAcl';
|
||||
|
||||
test('isValidServerPattern: accepts hostnames, IPs, and globs', () => {
|
||||
for (const v of [
|
||||
@@ -42,3 +47,35 @@ test('matchesAnyGlob: true if any glob matches (self-ban detection)', () => {
|
||||
assert.equal(matchesAnyGlob('lotusguild.org', ['*.evil.com']), false);
|
||||
assert.equal(matchesAnyGlob('lotusguild.org', []), false);
|
||||
});
|
||||
|
||||
test('no ACL event: every server allowed, IP literals allowed (#261)', () => {
|
||||
assert.deepEqual(normalizeServerAcl(undefined), {
|
||||
allow: ['*'],
|
||||
deny: [],
|
||||
allow_ip_literals: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('a missing allow_ip_literals defaults to true, per the spec (#261)', () => {
|
||||
assert.equal(normalizeServerAcl({ allow: ['*'], deny: ['bad.example'] }).allow_ip_literals, true);
|
||||
});
|
||||
|
||||
test('an explicit allow_ip_literals is kept', () => {
|
||||
assert.equal(
|
||||
normalizeServerAcl({ allow: ['*'], allow_ip_literals: false }).allow_ip_literals,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
normalizeServerAcl({ allow: ['*'], allow_ip_literals: true }).allow_ip_literals,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('missing or malformed lists become empty, non-strings are dropped', () => {
|
||||
assert.deepEqual(normalizeServerAcl({ allow: ['a.example', 3, null] }), {
|
||||
allow: ['a.example'],
|
||||
deny: [],
|
||||
allow_ip_literals: true,
|
||||
});
|
||||
assert.deepEqual(normalizeServerAcl({ allow: 'a.example', deny: {} }).allow, []);
|
||||
});
|
||||
|
||||
@@ -50,3 +50,29 @@ export function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `m.room.server_acl` content with the spec's defaults filled in (#261).
|
||||
* `allow_ip_literals` defaults to **true** when absent; `allow` and `deny`
|
||||
* default to empty lists, but with no ACL event at all every server is allowed,
|
||||
* so the editor starts from `allow: ['*']` in that case.
|
||||
* https://spec.matrix.org/v1.12/client-server-api/#mroomserver_acl
|
||||
*/
|
||||
export type ServerAclContent = {
|
||||
allow: string[];
|
||||
deny: string[];
|
||||
allow_ip_literals: boolean;
|
||||
};
|
||||
|
||||
const stringList = (value: unknown): string[] | undefined =>
|
||||
Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : undefined;
|
||||
|
||||
export function normalizeServerAcl(content: Record<string, unknown> | undefined): ServerAclContent {
|
||||
if (!content) return { allow: ['*'], deny: [], allow_ip_literals: true };
|
||||
return {
|
||||
allow: stringList(content.allow) ?? [],
|
||||
deny: stringList(content.deny) ?? [],
|
||||
allow_ip_literals:
|
||||
typeof content.allow_ip_literals === 'boolean' ? content.allow_ip_literals : true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
/** localStorage keys for this device's copy of the status message and its expiry. */
|
||||
export const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
export const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
@@ -0,0 +1,72 @@
|
||||
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, /<b>hi<\/b> & 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" });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
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> = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
};
|
||||
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 };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
translationUnavailableText,
|
||||
normalizeLang,
|
||||
sameLanguage,
|
||||
languageName,
|
||||
@@ -53,3 +54,9 @@ test('curated list: unique codes, English present', () => {
|
||||
assert.equal(new Set(codes).size, codes.length);
|
||||
assert.ok(codes.includes('en'));
|
||||
});
|
||||
|
||||
test('translationUnavailableText: no "use the desktop app" inside the desktop app (#349)', () => {
|
||||
assert.doesNotMatch(translationUnavailableText(true), /desktop app\.$|Lotus desktop app/);
|
||||
assert.match(translationUnavailableText(true), /this version of the desktop app/);
|
||||
assert.match(translationUnavailableText(false), /this browser/);
|
||||
});
|
||||
|
||||
@@ -86,6 +86,16 @@ export function isSupportedTargetLang(code: string): boolean {
|
||||
return CURATED_NAME.has(normalizeLang(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* [Gitea #349] Why translation is unavailable, by platform. The desktop app
|
||||
* on Linux/macOS (WebKit) lacks the on-device APIs too, so it must not send
|
||||
* people to "the Lotus desktop app" they're already using.
|
||||
*/
|
||||
export const translationUnavailableText = (desktopApp: boolean): string =>
|
||||
desktopApp
|
||||
? 'On-device translation isn’t available in this version of the desktop app. It works in Chrome or Edge 138+ on a computer.'
|
||||
: 'On-device translation isn’t available in this browser. Use Chrome or Edge 138+ on a computer.';
|
||||
|
||||
/** Pure: why a manual Translate did nothing (#287). */
|
||||
export const translateSkipMessage = (from: string | undefined, targetLang: string): string =>
|
||||
from
|
||||
|
||||
@@ -69,13 +69,23 @@ test('install kinds: anything unknown keeps the in-app updater', async () => {
|
||||
test('package installs get their package manager’s command, not an Install button', async () => {
|
||||
const { packageUpdateHelp, isPackageManagedError } = await import('./updateErrors');
|
||||
const pacman = packageUpdateHelp('pacman')!;
|
||||
// Not `pacman -U <url>`: that also wants `<url>.sig`, which we don't publish.
|
||||
assert.equal(
|
||||
pacman.command,
|
||||
'curl -LO https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst',
|
||||
// [cinny-desktop #36] signature, then checksum, then install, in that order;
|
||||
// a local `pacman -U ./file` (not `<url>`, which also wants `<url>.sig`).
|
||||
const steps = pacman.command!.split(' && ');
|
||||
assert.equal(steps[0], 'cd "$(mktemp -d)"');
|
||||
assert.match(
|
||||
steps[1],
|
||||
/-fLO \S+\/LotusChat-x86_64\.pkg\.tar\.zst -fLO \S+\/SHA256SUMS\.txt -fLO \S+\/SHA256SUMS\.txt\.minisig$/,
|
||||
);
|
||||
assert.equal(
|
||||
steps[2],
|
||||
'minisign -Vm SHA256SUMS.txt -P RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l',
|
||||
);
|
||||
assert.equal(steps[3], 'sha256sum -c --ignore-missing SHA256SUMS.txt');
|
||||
assert.equal(steps[4], 'sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst');
|
||||
assert.match(pacman.url, /LotusChat-x86_64\.pkg\.tar\.zst$/);
|
||||
const deb = packageUpdateHelp('deb')!;
|
||||
assert.match(deb.command!, /minisign -Vm SHA256SUMS\.txt/);
|
||||
assert.match(deb.command!, /sudo apt install \.\/LotusChat-x86_64\.deb$/);
|
||||
assert.equal(packageUpdateHelp('manual')?.command, undefined);
|
||||
assert.equal(packageUpdateHelp('in-app'), undefined);
|
||||
|
||||
@@ -64,23 +64,38 @@ export const toInstallKind = (value: unknown): InstallKind =>
|
||||
|
||||
export type PackageUpdateHelp = { command?: string; url: string; download: string };
|
||||
|
||||
/** The desktop updater's minisign public key (cinny-desktop tauri.conf.json). */
|
||||
export const RELEASE_MINISIGN_KEY = 'RWRZZdyii05/NUcz337A5SAbiZK+NPVDWuiL2mM5Jk1p/LfRmNfiZ/6l';
|
||||
|
||||
/**
|
||||
* [cinny-desktop #36] Download a Linux package and install it only after
|
||||
* checking the release's minisign-signed SHA256SUMS.txt. Runs in a fresh temp
|
||||
* directory so a stale SHA256SUMS.txt can't be picked up.
|
||||
*/
|
||||
const verifiedInstall = (file: string, install: string): string =>
|
||||
[
|
||||
`cd "$(mktemp -d)"`,
|
||||
`curl -fLO ${RELEASE_DOWNLOAD}/${file} -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt -fLO ${RELEASE_DOWNLOAD}/SHA256SUMS.txt.minisig`,
|
||||
`minisign -Vm SHA256SUMS.txt -P ${RELEASE_MINISIGN_KEY}`,
|
||||
`sha256sum -c --ignore-missing SHA256SUMS.txt`,
|
||||
`${install} ./${file}`,
|
||||
].join(' && ');
|
||||
|
||||
export const packageUpdateHelp = (kind: InstallKind): PackageUpdateHelp | undefined => {
|
||||
if (kind === 'pacman') {
|
||||
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`;
|
||||
// Download first, then install the local file: `pacman -U <url>` also
|
||||
// fetches `<url>.sig` and fails without it (we don't sign packages),
|
||||
// while a local file falls under LocalFileSigLevel (signature optional).
|
||||
// Install the local file: `pacman -U <url>` also fetches `<url>.sig` and
|
||||
// fails without it (packages aren't signed for pacman), while a local file
|
||||
// falls under LocalFileSigLevel. Integrity comes from SHA256SUMS.txt.
|
||||
return {
|
||||
command: `curl -LO ${url} && sudo pacman -U ./LotusChat-x86_64.pkg.tar.zst`,
|
||||
url,
|
||||
command: verifiedInstall('LotusChat-x86_64.pkg.tar.zst', 'sudo pacman -U'),
|
||||
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.pkg.tar.zst`,
|
||||
download: 'Download package',
|
||||
};
|
||||
}
|
||||
if (kind === 'deb') {
|
||||
const url = `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`;
|
||||
return {
|
||||
command: `curl -LO ${url} && sudo apt install ./LotusChat-x86_64.deb`,
|
||||
url,
|
||||
command: verifiedInstall('LotusChat-x86_64.deb', 'sudo apt install'),
|
||||
url: `${RELEASE_DOWNLOAD}/LotusChat-x86_64.deb`,
|
||||
download: 'Download package',
|
||||
};
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -251,10 +252,47 @@ const vendorChunks = (id) => {
|
||||
if (id.includes('node_modules/immer')) return 'immer';
|
||||
if (id.includes('node_modules/folds')) return 'folds';
|
||||
if (id.includes('node_modules/emojibase')) return 'emojibase';
|
||||
// HTML sanitising (sanitize-html pulls in postcss + htmlparser2) and colour
|
||||
// maths are needed at startup but are big, stable libraries: keep them in
|
||||
// their own cacheable chunks instead of wherever the chunk graph drops them
|
||||
// (once Settings became lazy they landed in the entry chunk).
|
||||
if (
|
||||
id.includes('node_modules/sanitize-html') ||
|
||||
id.includes('node_modules/postcss') ||
|
||||
id.includes('node_modules/htmlparser2') ||
|
||||
id.includes('node_modules/domhandler') ||
|
||||
id.includes('node_modules/domutils') ||
|
||||
id.includes('node_modules/dom-serializer') ||
|
||||
id.includes('node_modules/entities') ||
|
||||
id.includes('node_modules/parse-srcset') ||
|
||||
id.includes('node_modules/nanoid') ||
|
||||
id.includes('node_modules/picocolors') ||
|
||||
id.includes('node_modules/source-map-js')
|
||||
)
|
||||
return 'sanitize-html';
|
||||
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: {
|
||||
|
||||
Reference in New Issue
Block a user