Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b2322f751 | ||
|
|
400ee76b63 | ||
|
|
47c1e435b7 | ||
|
|
4650796120 | ||
|
|
d55a8ba8a5 | ||
|
|
4b8c3219e9 | ||
|
|
47328d6fee | ||
|
|
b0eb3da830 | ||
|
|
f22f9f07a3 | ||
|
|
2700aad209 | ||
|
|
a8a7e639a9 | ||
|
|
508a123150 | ||
|
|
fe61352d88 | ||
|
|
0bec28b651 | ||
|
|
75089ecd3f | ||
|
|
a4a163df3d | ||
|
|
70f1d640ed | ||
|
|
85a2abc46a | ||
|
|
5130dfc9c8 | ||
|
|
e57af60466 | ||
|
|
1f10866b60 |
+12
-1
@@ -42,7 +42,18 @@ export function collectConsole(page: Page): ConsoleCollector {
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const pageErrors: string[] = [];
|
const pageErrors: string[] = [];
|
||||||
page.on('console', (msg) => {
|
page.on('console', (msg) => {
|
||||||
if (msg.type() === 'error') errors.push(msg.text());
|
if (msg.type() !== 'error') return;
|
||||||
|
// The login page's registration probe (POST /_matrix/client/v3/register)
|
||||||
|
// answers 403 "Registration has been disabled" on a homeserver with sign-up
|
||||||
|
// closed (matrix.lotusguild.org since 2026-10-16: accounts come from SSO).
|
||||||
|
// Allowed only for that URL so any other 403 still fails the test.
|
||||||
|
if (
|
||||||
|
/status of 403/.test(msg.text()) &&
|
||||||
|
/\/_matrix\/client\/v3\/register/.test(msg.location().url)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
errors.push(msg.text());
|
||||||
});
|
});
|
||||||
page.on('pageerror', (err) => {
|
page.on('pageerror', (err) => {
|
||||||
pageErrors.push(err.message);
|
pageErrors.push(err.message);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
EventTimelineSetHandlerMap,
|
EventTimelineSetHandlerMap,
|
||||||
EventType,
|
EventType,
|
||||||
JoinRule,
|
|
||||||
RelationType,
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
@@ -72,7 +71,8 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
import { getStateEvent, getMemberName } from '../utils/room';
|
||||||
|
import { isRingableRoom } from '../utils/callRing';
|
||||||
import { StateEvent } from '../../types/matrix/room';
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
@@ -581,12 +581,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||||
// restricted rooms, and public rooms must never trigger ringing.
|
// restricted rooms, and public rooms must never trigger ringing.
|
||||||
if (room.isCallRoom()) return;
|
if (!isRingableRoom(room, directs.has(room.roomId))) return;
|
||||||
const isDirect = directs.has(room.roomId);
|
|
||||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
|
||||||
const joinRule = room.getJoinRule();
|
|
||||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
|
||||||
if (!isDirect && !isPrivateInviteGroup) return;
|
|
||||||
|
|
||||||
const info: IncomingCallInfo = {
|
const info: IncomingCallInfo = {
|
||||||
room,
|
room,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
|
import React, { CSSProperties, ReactNode, useEffect, useId, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
|
||||||
import { IContent, MatrixEvent } from 'matrix-js-sdk';
|
import { IContent, MatrixEvent } from 'matrix-js-sdk';
|
||||||
@@ -41,7 +41,10 @@ import { formatAccuracy } from '../../utils/geoShare';
|
|||||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||||
|
|
||||||
const COLLAPSE_MAX_HEIGHT = 320; // px ≈ 20 lines
|
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||||
|
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||||
|
const collapseMaxHeightPx = (): number =>
|
||||||
|
COLLAPSE_MAX_HEIGHT_REM * (parseFloat(getComputedStyle(document.documentElement).fontSize) || 16);
|
||||||
|
|
||||||
type CollapsibleBodyProps = {
|
type CollapsibleBodyProps = {
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
@@ -79,7 +82,7 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
|||||||
const el = bodyRef.current;
|
const el = bodyRef.current;
|
||||||
if (!el) return undefined;
|
if (!el) return undefined;
|
||||||
const observer = new ResizeObserver(() => {
|
const observer = new ResizeObserver(() => {
|
||||||
setNeedsCollapse(el.scrollHeight > COLLAPSE_MAX_HEIGHT);
|
setNeedsCollapse(el.scrollHeight > collapseMaxHeightPx());
|
||||||
});
|
});
|
||||||
observer.observe(el);
|
observer.observe(el);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
@@ -89,16 +92,36 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
|||||||
// (Gitea #85); useReducedMotion subscribes to the change event instead.
|
// (Gitea #85); useReducedMotion subscribes to the change event instead.
|
||||||
const prefersReducedMotion = useReducedMotion();
|
const prefersReducedMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const bodyId = useId();
|
||||||
|
const isClipped = needsCollapse && collapsed;
|
||||||
|
// [Gitea #343] The clipped part stays in the DOM, so Tab can reach a link in
|
||||||
|
// it; the browser would then scroll the overflow:hidden box itself (top cut
|
||||||
|
// off, and it stayed that way). Expand instead when focus lands inside.
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (isClipped) setCollapsed(false);
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
if (isClipped && bodyRef.current) bodyRef.current.scrollTop = 0;
|
||||||
|
}, [isClipped]);
|
||||||
|
|
||||||
|
// Fade the content itself rather than painting a fixed-colour gradient over
|
||||||
|
// it, so it blends on any background (bubbles, highlights, chat backgrounds).
|
||||||
|
const fadeMask = 'linear-gradient(#000 calc(100% - 3rem), transparent)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
ref={bodyRef}
|
ref={bodyRef}
|
||||||
|
id={bodyId}
|
||||||
|
onFocus={handleFocus}
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
...(needsCollapse && collapsed
|
...(isClipped
|
||||||
? {
|
? {
|
||||||
maxHeight: `${COLLAPSE_MAX_HEIGHT}px`,
|
maxHeight: `${COLLAPSE_MAX_HEIGHT_REM}rem`,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
maskImage: fadeMask,
|
||||||
|
WebkitMaskImage: fadeMask,
|
||||||
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
|
transition: prefersReducedMotion ? undefined : 'max-height 0.2s ease',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
@@ -107,23 +130,12 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{needsCollapse && collapsed && (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: '3rem',
|
|
||||||
background: `linear-gradient(transparent, ${color.Surface.Container})`,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
{needsCollapse && (
|
{needsCollapse && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
aria-expanded={!collapsed}
|
||||||
|
aria-controls={bodyId}
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@@ -134,7 +146,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
|
<Text as="span" size="T200" style={{ color: color.Primary.Main }}>
|
||||||
{collapsed ? 'Read more ↓' : 'Show less ↑'}
|
{collapsed ? 'Read more' : 'Show less'}
|
||||||
|
<span aria-hidden="true">{collapsed ? ' ↓' : ' ↑'}</span>
|
||||||
</Text>
|
</Text>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const dark: CSSProperties = {
|
|||||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||||
};
|
};
|
||||||
|
|
||||||
const light: CSSProperties = {
|
const light: CSSProperties = {
|
||||||
@@ -75,7 +75,7 @@ const light: CSSProperties = {
|
|||||||
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
backgroundSize: '260% 240%, 300% 260%, 240% 280%, 220% 200%, 100% 100%, 100% 100%',
|
||||||
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
backgroundPosition: '0% 30%, 100% 70%, 50% 0%, 20% 80%, 50% 50%, 50% 50%',
|
||||||
backgroundRepeat: 'no-repeat',
|
backgroundRepeat: 'no-repeat',
|
||||||
animation: `${auroraFlow} 60s ease-in-out infinite`,
|
animation: `${auroraFlow} 60s steps(180) infinite`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const animAurora: ChatBgVariants = { dark, light };
|
export const animAurora: ChatBgVariants = { dark, light };
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export const animFireflies: ChatBgVariants = {
|
|||||||
'0 0', // wash A (static)
|
'0 0', // wash A (static)
|
||||||
'0 0', // wash B (static)
|
'0 0', // wash B (static)
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${firefliesDrift} 44s linear infinite`,
|
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
// Light: a cozy warm dim-dusk. No harsh dots on white — soft amber motes with
|
||||||
@@ -93,6 +93,6 @@ export const animFireflies: ChatBgVariants = {
|
|||||||
'0 0', // wash A (static)
|
'0 0', // wash A (static)
|
||||||
'0 0', // wash B (static)
|
'0 0', // wash B (static)
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${firefliesDrift} 44s linear infinite`,
|
animation: `${firefliesDrift} 44s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export const animPulse: ChatBgVariants = {
|
|||||||
'0 0', // base wash
|
'0 0', // base wash
|
||||||
'0 0', // vignette
|
'0 0', // vignette
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
// Light: soft slate-blue grid on pale cool-white, a gentle luminance breathe.
|
||||||
@@ -116,6 +116,6 @@ export const animPulse: ChatBgVariants = {
|
|||||||
'0 0', // base wash
|
'0 0', // base wash
|
||||||
'0 0', // vignette
|
'0 0', // vignette
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${gridPulse} 22s ease-in-out infinite`,
|
animation: `${gridPulse} 22s steps(528) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export const animRain: ChatBgVariants = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
animation: `${rainFall} 12s linear infinite`,
|
animation: `${rainFall} 12s steps(288) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
// Light: soft teal-grey streaks on a pale cool base — elegant, never neon.
|
||||||
@@ -118,6 +118,6 @@ export const animRain: ChatBgVariants = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
backgroundSize: ['260px 200px', '100% 100%', '100% 100%', '100% 100%'].join(','),
|
||||||
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
backgroundPosition: ['0 0', '0 0', '0 0', '0 0'].join(','),
|
||||||
animation: `${rainFall} 12s linear infinite`,
|
animation: `${rainFall} 12s steps(288) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const animStars: ChatBgVariants = {
|
|||||||
'0 0', // nebula A
|
'0 0', // nebula A
|
||||||
'0 0', // nebula B
|
'0 0', // nebula B
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${starDrift} 90s linear infinite`,
|
animation: `${starDrift} 90s steps(1080) infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
// Light: an airy pre-dawn sky. No literal white-on-white stars — instead very
|
||||||
@@ -112,6 +112,6 @@ export const animStars: ChatBgVariants = {
|
|||||||
'0 0', // wash A
|
'0 0', // wash A
|
||||||
'0 0', // wash B
|
'0 0', // wash B
|
||||||
].join(','),
|
].join(','),
|
||||||
animation: `${starDrift} 100s linear infinite`,
|
animation: `${starDrift} 100s steps(1200) infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -149,6 +149,11 @@ export const getChatBg = (
|
|||||||
}
|
}
|
||||||
// For animated backgrounds, promote the element to its own compositor layer so
|
// For animated backgrounds, promote the element to its own compositor layer so
|
||||||
// background-position keyframes don't trigger repaints on descendant elements.
|
// background-position keyframes don't trigger repaints on descendant elements.
|
||||||
|
// [Gitea #337] background-position can't run on the compositor, so every
|
||||||
|
// animation frame repaints the whole chat area (45 to 70% main-thread busy
|
||||||
|
// at 60 fps). The drifts are slow (3–22 px/s), so each animation uses steps():
|
||||||
|
// ~12 updates/s for the slow ones, ~24/s for rain and pulse: visually the
|
||||||
|
// same, a fraction of the repaints.
|
||||||
if (style.animation) {
|
if (style.animation) {
|
||||||
return { ...style, willChange: 'background-position', contain: 'paint' };
|
return { ...style, willChange: 'background-position', contain: 'paint' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
|||||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -138,6 +138,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
import { useIgnoredUsers } from '../../hooks/useIgnoredUsers';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useIsDirectRoom } from '../../hooks/useRoom';
|
import { useIsDirectRoom } from '../../hooks/useRoom';
|
||||||
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
@@ -493,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||||
// re-renders. `undefined` = no policy.
|
// re-renders. `undefined` = no policy.
|
||||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||||
|
const retentionMs = effectiveRetentionMs(
|
||||||
|
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||||
|
);
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
@@ -2030,6 +2034,9 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
[EventType.RTCNotification]: (mEventId, mEvent, item) => {
|
||||||
const senderId = mEvent.getSender() ?? '';
|
const senderId = mEvent.getSender() ?? '';
|
||||||
if (!senderId || senderId === mx.getUserId()) return null;
|
if (!senderId || senderId === mx.getUserId()) return null;
|
||||||
|
// Same rule as ringing: no "Missed call" in voice rooms or space
|
||||||
|
// channels, where starting a call is just joining the channel.
|
||||||
|
if (!isRingableRoom(room, direct)) return null;
|
||||||
const content = mEvent.getContent();
|
const content = mEvent.getContent();
|
||||||
const mention =
|
const mention =
|
||||||
content['m.mentions']?.room ||
|
content['m.mentions']?.room ||
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
addRecentForwardTarget,
|
addRecentForwardTarget,
|
||||||
} from '../../../state/recentForwardTargets';
|
} from '../../../state/recentForwardTargets';
|
||||||
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent';
|
||||||
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type RoomRowProps = {
|
type RoomRowProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -58,6 +59,7 @@ type RoomRowProps = {
|
|||||||
sending: boolean;
|
sending: boolean;
|
||||||
};
|
};
|
||||||
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: RoomRowProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const avatarMxc = room.getMxcAvatarUrl();
|
const avatarMxc = room.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -97,7 +99,7 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
|
|||||||
>
|
>
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
{dm && (
|
{dm && (
|
||||||
<Text size="T200" priority="300" truncate>
|
<Text size="T200" priority="300" truncate>
|
||||||
@@ -212,6 +214,7 @@ function RecentChip({
|
|||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
sending: boolean;
|
sending: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const avatarMxc = room.getMxcAvatarUrl();
|
const avatarMxc = room.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -240,7 +243,7 @@ function RecentChip({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
|
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
@@ -252,6 +255,7 @@ type Props = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const modalStyle = useModalStyle(400);
|
const modalStyle = useModalStyle(400);
|
||||||
const directs = useAtomValue(mDirectAtom);
|
const directs = useAtomValue(mDirectAtom);
|
||||||
@@ -296,8 +300,10 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
|||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
if (!query) return allRooms;
|
if (!query) return allRooms;
|
||||||
const q = query.toLowerCase();
|
const q = query.toLowerCase();
|
||||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
return allRooms.filter((r) =>
|
||||||
}, [allRooms, query]);
|
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
|
||||||
|
);
|
||||||
|
}, [allRooms, query, localNames]);
|
||||||
|
|
||||||
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
|
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
|
||||||
const recentRooms = useMemo(
|
const recentRooms = useMemo(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
useAsyncSearch,
|
useAsyncSearch,
|
||||||
UseAsyncSearchOptions,
|
UseAsyncSearchOptions,
|
||||||
} from '../../hooks/useAsyncSearch';
|
} from '../../hooks/useAsyncSearch';
|
||||||
|
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import {
|
import {
|
||||||
@@ -157,17 +158,20 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
|
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
|
||||||
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
|
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
|
||||||
|
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
const getTargetStr: SearchItemStrGetter<string> = useCallback(
|
||||||
(roomId: string) => {
|
(roomId: string) => {
|
||||||
const roomName = getRoom(roomId)?.name ?? roomId;
|
const roomName = getRoom(roomId)?.name ?? roomId;
|
||||||
|
// [Gitea #336] Match the user's own "Rename for me" name as well.
|
||||||
|
const names = localNames[roomId] ? [localNames[roomId], roomName] : [roomName];
|
||||||
if (mDirects.has(roomId)) {
|
if (mDirects.has(roomId)) {
|
||||||
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
||||||
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
|
||||||
if (targetUsername) return [roomName, targetUsername];
|
if (targetUsername) return [...names, targetUsername];
|
||||||
}
|
}
|
||||||
return roomName;
|
return names.length === 1 ? names[0] : names;
|
||||||
},
|
},
|
||||||
[getRoom, mDirects, mx],
|
[getRoom, mDirects, mx, localNames],
|
||||||
);
|
);
|
||||||
|
|
||||||
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
|
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
|
||||||
@@ -388,8 +392,10 @@ export function Search({ requestClose }: SearchProps) {
|
|||||||
<Box grow="Yes" alignItems="Center" gap="100">
|
<Box grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text size="T400" truncate>
|
<Text size="T400" truncate>
|
||||||
{queryHighlighRegex
|
{queryHighlighRegex
|
||||||
? highlightText(queryHighlighRegex, [room.name])
|
? highlightText(queryHighlighRegex, [
|
||||||
: room.name}
|
localNames[room.roomId] ?? room.name,
|
||||||
|
])
|
||||||
|
: (localNames[room.roomId] ?? room.name)}
|
||||||
</Text>
|
</Text>
|
||||||
{dmUsername && (
|
{dmUsername && (
|
||||||
<Text as="span" size="T200" priority="300" truncate>
|
<Text as="span" size="T200" priority="300" truncate>
|
||||||
|
|||||||
@@ -102,6 +102,22 @@ export const useLocalRoomName = (room: Room): string => {
|
|||||||
return localNames[room.roomId] ?? name;
|
return localNames[room.roomId] ?? name;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #336] The whole "Rename for me" map, for lists that show many rooms
|
||||||
|
* (quick switcher, forward/share pickers) so they use the name the user sees in
|
||||||
|
* the sidebar, not the server name.
|
||||||
|
*/
|
||||||
|
export const useLocalRoomNames = (): Readonly<Record<string, string>> => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
return roomNamesStore.useValue(mx);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Non-hook read for notification paths (toast / OS notification titles). */
|
||||||
|
export const getLocalRoomName = (
|
||||||
|
mx: ReturnType<typeof useMatrixClient>,
|
||||||
|
roomId: string,
|
||||||
|
): string | undefined => roomNamesStore.getLatest(mx)[roomId];
|
||||||
|
|
||||||
export const useHasLocalRoomName = (roomId: string): boolean => {
|
export const useHasLocalRoomName = (roomId: string): boolean => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const localNames = roomNamesStore.useValue(mx);
|
const localNames = roomNamesStore.useValue(mx);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ThreadEvent,
|
ThreadEvent,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
|
import { useNotificationsQuiet } from '../../hooks/useNotificationsQuiet';
|
||||||
|
import { getLocalRoomName } from '../../hooks/useRoomMeta';
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||||
import NotificationSound from '../../../../public/sound/notification.ogg';
|
import NotificationSound from '../../../../public/sound/notification.ogg';
|
||||||
import InviteSound from '../../../../public/sound/invite.ogg';
|
import InviteSound from '../../../../public/sound/invite.ogg';
|
||||||
@@ -67,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
|||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
import {
|
||||||
|
getRoomRetentionMs,
|
||||||
|
getRoomRetentionSetAt,
|
||||||
|
isExpired,
|
||||||
|
retentionDeletesAllowed,
|
||||||
|
} from '../../utils/retention';
|
||||||
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
@@ -639,7 +645,8 @@ function MessageNotifications() {
|
|||||||
const avatarMxc =
|
const avatarMxc =
|
||||||
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
room.getAvatarFallbackMember()?.getMxcAvatarUrl() ?? room.getMxcAvatarUrl();
|
||||||
notify({
|
notify({
|
||||||
roomName: room.name ?? 'Unknown',
|
// [Gitea #336] the user's own "Rename for me" name when they set one
|
||||||
|
roomName: getLocalRoomName(mx, room.roomId) ?? room.name ?? 'Unknown',
|
||||||
roomAvatar: avatarMxc
|
roomAvatar: avatarMxc
|
||||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -808,7 +815,7 @@ function ReminderMonitor() {
|
|||||||
if (!firedRef.current.has(key)) {
|
if (!firedRef.current.has(key)) {
|
||||||
firedRef.current.add(key);
|
firedRef.current.add(key);
|
||||||
const room = mx.getRoom(r.roomId);
|
const room = mx.getRoom(r.roomId);
|
||||||
const roomName = room?.name ?? 'Unknown Room';
|
const roomName = getLocalRoomName(mx, r.roomId) ?? room?.name ?? 'Unknown Room';
|
||||||
// Reminders for E2EE rooms carry no text (account data is server-
|
// Reminders for E2EE rooms carry no text (account data is server-
|
||||||
// readable), so resolve the body from the local timeline at fire time.
|
// readable), so resolve the body from the local timeline at fire time.
|
||||||
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
|
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
|
||||||
@@ -908,6 +915,9 @@ function RetentionSweeper() {
|
|||||||
mx.getRooms().forEach((room) => {
|
mx.getRooms().forEach((room) => {
|
||||||
const maxLifetime = getRoomRetentionMs(room);
|
const maxLifetime = getRoomRetentionMs(room);
|
||||||
if (!maxLifetime) return;
|
if (!maxLifetime) return;
|
||||||
|
// [Gitea #298] Never delete under a policy that is less than a day old.
|
||||||
|
const setAt = getRoomRetentionSetAt(room);
|
||||||
|
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
|
||||||
room
|
room
|
||||||
.getLiveTimeline()
|
.getLiveTimeline()
|
||||||
.getEvents()
|
.getEvents()
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { BlockType } from '../../../components/editor';
|
import { BlockType } from '../../../components/editor';
|
||||||
import { bytesToSize } from '../../../utils/common';
|
import { bytesToSize } from '../../../utils/common';
|
||||||
import { getHomePath } from '../../pathUtils';
|
import { getHomePath } from '../../pathUtils';
|
||||||
|
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||||
|
|
||||||
type Shared = { payload: SharePayload; files: File[] };
|
type Shared = { payload: SharePayload; files: File[] };
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ type Shared = { payload: SharePayload; files: File[] };
|
|||||||
* as the draft) — the user still presses Send.
|
* as the draft) — the user still presses Send.
|
||||||
*/
|
*/
|
||||||
export function Share() {
|
export function Share() {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
@@ -69,8 +71,12 @@ export function Share() {
|
|||||||
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
.filter((r) => r.getMyMembership() === 'join' && !r.isSpaceRoom())
|
||||||
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
.sort((a, b) => (b.getLastActiveTimestamp() ?? 0) - (a.getLastActiveTimestamp() ?? 0));
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
return q ? all.filter((r) => r.name.toLowerCase().includes(q)) : all;
|
return q
|
||||||
}, [mx, query]);
|
? all.filter((r) =>
|
||||||
|
[r.name, localNames[r.roomId]].some((nm) => nm?.toLowerCase().includes(q)),
|
||||||
|
)
|
||||||
|
: all;
|
||||||
|
}, [mx, query, localNames]);
|
||||||
|
|
||||||
// Written straight into the room's draft atoms so its composer shows the
|
// Written straight into the room's draft atoms so its composer shows the
|
||||||
// upload board + text as soon as it mounts.
|
// upload board + text as soon as it mounts.
|
||||||
@@ -210,6 +216,7 @@ function ShareRoomRow({
|
|||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
onPick: (room: Room) => void;
|
onPick: (room: Room) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const avatarMxc = room.getMxcAvatarUrl();
|
const avatarMxc = room.getMxcAvatarUrl();
|
||||||
const avatarUrl = avatarMxc
|
const avatarUrl = avatarMxc
|
||||||
@@ -237,7 +244,7 @@ function ShareRoomRow({
|
|||||||
>
|
>
|
||||||
<Box direction="Column">
|
<Box direction="Column">
|
||||||
<Text size="T300" truncate>
|
<Text size="T300" truncate>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
{dm && (
|
{dm && (
|
||||||
<Text size="T200" priority="300" truncate>
|
<Text size="T200" priority="300" truncate>
|
||||||
|
|||||||
@@ -83,3 +83,14 @@ test('clearPlaintextCaches wipes drafts (#41) and the status message (#204) but
|
|||||||
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
|
assert.ok(!removed.includes('cinny_session'), 'session key not this module’s concern');
|
||||||
assert.ok(store.has('cinny_session'));
|
assert.ok(store.has('cinny_session'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clearPlaintextCaches removes the call-volume list and the settings-sync stamp (#347)', () => {
|
||||||
|
store.clear();
|
||||||
|
store.set('lotus-per-user-volume', '{"@bob:server":0.3}');
|
||||||
|
store.set('settings-sync-meta:@me:server', '{"lastSyncedAt":1}');
|
||||||
|
store.set('settings', '{}');
|
||||||
|
clearPlaintextCaches('@me:server');
|
||||||
|
assert.equal(store.has('lotus-per-user-volume'), false);
|
||||||
|
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||||||
|
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ const clearMsgDrafts = (): void => {
|
|||||||
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
||||||
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
||||||
* - `lotus_outbox_v1` — decrypted content of messages not yet sent ([Gitea #112])
|
* - `lotus_outbox_v1` — decrypted content of messages not yet sent ([Gitea #112])
|
||||||
|
* - `lotus-per-user-volume`, `settings-sync-meta:<userId>` — whom you adjusted in
|
||||||
|
* calls, and a key naming the account ([Gitea #347])
|
||||||
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
|
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
|
||||||
* user — see [Gitea #41]; previously deliberately preserved across logout
|
* user — see [Gitea #41]; previously deliberately preserved across logout
|
||||||
* (N98), which let the next account on this device see/send a prior user's
|
* (N98), which let the next account on this device see/send a prior user's
|
||||||
@@ -92,6 +94,24 @@ const clearStatusMessage = (): void => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #347] Per-person residue that isn't message text but still says whom
|
||||||
|
* the previous user dealt with: Element Call's per-user volume memory (same
|
||||||
|
* origin on the web — element-call `lotusVolumeMemory.ts`) and the settings-sync
|
||||||
|
* stamp, whose key names the account.
|
||||||
|
*/
|
||||||
|
export const CALL_VOLUME_KEY = 'lotus-per-user-volume';
|
||||||
|
const clearCallVolumesAndSyncMeta = (userId?: string): void => {
|
||||||
|
const keys = [CALL_VOLUME_KEY, ...(userId ? [`settings-sync-meta:${userId}`] : [])];
|
||||||
|
keys.forEach((key) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const clearPlaintextCaches = (userId?: string): void => {
|
export const clearPlaintextCaches = (userId?: string): void => {
|
||||||
clearTranslationCache();
|
clearTranslationCache();
|
||||||
clearScheduledMessages();
|
clearScheduledMessages();
|
||||||
@@ -103,5 +123,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearOutbox();
|
clearOutbox();
|
||||||
clearCallSession();
|
clearCallSession();
|
||||||
clearStatusMessage();
|
clearStatusMessage();
|
||||||
|
clearCallVolumesAndSyncMeta(userId);
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import type { Room } from 'matrix-js-sdk';
|
||||||
|
import { isRingableRoom } from './callRing';
|
||||||
|
|
||||||
|
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||||
|
({
|
||||||
|
isCallRoom: () => !!opts.callRoom,
|
||||||
|
getJoinRule: () => opts.joinRule ?? 'invite',
|
||||||
|
getLiveTimeline: () => ({
|
||||||
|
getState: () => ({ getStateEvents: () => (opts.spaceParent ? [{}] : []) }),
|
||||||
|
}),
|
||||||
|
}) as unknown as Room;
|
||||||
|
|
||||||
|
test('voice rooms never ring or show "missed call", even as DMs', () => {
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), false), false);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ callRoom: true }), true), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('DMs and invite-only groups outside a space ring', () => {
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), true), true);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite' }), false), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('space channels, public and restricted rooms do not ring', () => {
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'invite', spaceParent: true }), false), false);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||||
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { JoinRule, Room } from 'matrix-js-sdk';
|
||||||
|
import { StateEvent } from '../../types/matrix/room';
|
||||||
|
import { getStateEvents } from './room';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a call notification in this room may ring — and, by the same rule,
|
||||||
|
* whether an unanswered one may show as "Missed call" (Gitea #162 follow-up).
|
||||||
|
* Only DMs and invite-only groups outside any space ring, i.e. rooms where the
|
||||||
|
* call button is shown. Persistent voice rooms, space channels, restricted and
|
||||||
|
* public rooms never do: joining a voice channel alone is normal and isn't a
|
||||||
|
* "call" anyone missed.
|
||||||
|
*/
|
||||||
|
export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
||||||
|
if (room.isCallRoom()) return false;
|
||||||
|
if (isDirect) return true;
|
||||||
|
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||||
|
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||||
|
};
|
||||||
@@ -38,3 +38,38 @@ test('hostFromText only accepts URL/host-shaped text; IDN becomes punycode', ()
|
|||||||
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true);
|
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.punycode, true);
|
||||||
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true);
|
assert.equal(analyzeLink('paypal.com', 'https://xn--pypal-4ve.com/')?.mismatch, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a trusted name inside a sentence still exposes a different destination (#335)', () => {
|
||||||
|
const a = analyzeLink('Log in at chat.lotusguild.org', 'https://evil.example/');
|
||||||
|
assert.equal(a?.mismatch, true);
|
||||||
|
assert.equal(a?.shownHost, 'chat.lotusguild.org');
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('chat.lotusguild.org (official)', 'https://evil.example/')?.mismatch,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('Open https://paypal.com/login now', 'https://evil.example/')?.mismatch,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// honest sentences are not flagged
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('Log in at chat.lotusguild.org', 'https://chat.lotusguild.org/login')?.mismatch,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(analyzeLink('read the docs', 'https://evil.example/')?.mismatch, false);
|
||||||
|
assert.equal(analyzeLink('see main.py and report.pdf', 'https://github.com/x')?.mismatch, false);
|
||||||
|
assert.equal(analyzeLink('release v1.2.3 notes', 'https://github.com/x')?.mismatch, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shared hosting suffixes are separate sites (#335)', () => {
|
||||||
|
assert.equal(registrableDomain('alice.github.io'), 'alice.github.io');
|
||||||
|
assert.equal(registrableDomain('www.alice.github.io'), 'alice.github.io');
|
||||||
|
assert.equal(registrableDomain('bucket.s3.amazonaws.com'), 'bucket.s3.amazonaws.com');
|
||||||
|
assert.equal(analyzeLink('alice.github.io', 'https://evil.github.io/')?.mismatch, true);
|
||||||
|
assert.equal(analyzeLink('lotusguild.pages.dev', 'https://evil.pages.dev/')?.mismatch, true);
|
||||||
|
assert.equal(
|
||||||
|
analyzeLink('alice.github.io/blog', 'https://alice.github.io/blog/1')?.mismatch,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(registrableDomain('news.bbc.co.uk'), 'bbc.co.uk');
|
||||||
|
});
|
||||||
|
|||||||
+131
-7
@@ -48,11 +48,53 @@ const TWO_LABEL_SUFFIXES = new Set([
|
|||||||
'com.tw',
|
'com.tw',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// [Gitea #335] Shared-hosting suffixes (the Public Suffix List's "private"
|
||||||
|
// section, the ones phishing actually uses): every customer gets their own
|
||||||
|
// subdomain, so `alice.github.io` and `evil.github.io` are different sites.
|
||||||
|
// A curated list rather than the full PSL keeps this out of the bundle budget.
|
||||||
|
const SHARED_HOSTING_SUFFIXES = new Set([
|
||||||
|
'github.io',
|
||||||
|
'gitlab.io',
|
||||||
|
'codeberg.page',
|
||||||
|
'pages.dev',
|
||||||
|
'workers.dev',
|
||||||
|
'trycloudflare.com',
|
||||||
|
'netlify.app',
|
||||||
|
'vercel.app',
|
||||||
|
'web.app',
|
||||||
|
'firebaseapp.com',
|
||||||
|
'appspot.com',
|
||||||
|
'herokuapp.com',
|
||||||
|
'onrender.com',
|
||||||
|
'fly.dev',
|
||||||
|
'glitch.me',
|
||||||
|
'replit.app',
|
||||||
|
'repl.co',
|
||||||
|
'surge.sh',
|
||||||
|
'blogspot.com',
|
||||||
|
'wordpress.com',
|
||||||
|
'wixsite.com',
|
||||||
|
'weebly.com',
|
||||||
|
'azurewebsites.net',
|
||||||
|
'cloudfront.net',
|
||||||
|
'ngrok.io',
|
||||||
|
'ngrok-free.app',
|
||||||
|
'duckdns.org',
|
||||||
|
's3.amazonaws.com',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isPublicSuffix = (suffix: string): boolean =>
|
||||||
|
TWO_LABEL_SUFFIXES.has(suffix) || SHARED_HOSTING_SUFFIXES.has(suffix);
|
||||||
|
|
||||||
export const registrableDomain = (host: string): string => {
|
export const registrableDomain = (host: string): string => {
|
||||||
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
|
const labels = host.toLowerCase().replace(/\.$/, '').split('.');
|
||||||
if (labels.length <= 2) return labels.join('.');
|
// Longest matching public suffix wins (3 labels, e.g. s3.amazonaws.com, then 2).
|
||||||
const lastTwo = labels.slice(-2).join('.');
|
for (const n of [3, 2]) {
|
||||||
return TWO_LABEL_SUFFIXES.has(lastTwo) ? labels.slice(-3).join('.') : lastTwo;
|
if (labels.length > n && isPublicSuffix(labels.slice(-n).join('.'))) {
|
||||||
|
return labels.slice(-(n + 1)).join('.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return labels.slice(-2).join('.');
|
||||||
};
|
};
|
||||||
|
|
||||||
const HOST_LIKE =
|
const HOST_LIKE =
|
||||||
@@ -72,6 +114,85 @@ export const hostFromText = (text: string): string | null => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Words that look like hosts but are usually files or versions in chat
|
||||||
|
// ("see main.py", "report.pdf", "v1.2.3"): skipped inside sentences unless
|
||||||
|
// written as a URL (scheme, www., or a path) so ordinary links aren't flagged.
|
||||||
|
const FILE_EXTENSIONS = new Set([
|
||||||
|
'py',
|
||||||
|
'sh',
|
||||||
|
'md',
|
||||||
|
'rs',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'tsx',
|
||||||
|
'jsx',
|
||||||
|
'json',
|
||||||
|
'yaml',
|
||||||
|
'yml',
|
||||||
|
'toml',
|
||||||
|
'txt',
|
||||||
|
'log',
|
||||||
|
'csv',
|
||||||
|
'pdf',
|
||||||
|
'zip',
|
||||||
|
'gz',
|
||||||
|
'tar',
|
||||||
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'mp3',
|
||||||
|
'mp4',
|
||||||
|
'mov',
|
||||||
|
'html',
|
||||||
|
'htm',
|
||||||
|
'css',
|
||||||
|
'exe',
|
||||||
|
'dll',
|
||||||
|
'lock',
|
||||||
|
'cfg',
|
||||||
|
'conf',
|
||||||
|
'ini',
|
||||||
|
'xml',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'xls',
|
||||||
|
'xlsx',
|
||||||
|
'ppt',
|
||||||
|
'pptx',
|
||||||
|
'apk',
|
||||||
|
'iso',
|
||||||
|
'jar',
|
||||||
|
'java',
|
||||||
|
'go',
|
||||||
|
'rb',
|
||||||
|
'php',
|
||||||
|
'c',
|
||||||
|
'h',
|
||||||
|
'cpp',
|
||||||
|
]);
|
||||||
|
const TRIM_PUNCT = /^[\s"'“”‘’«»([{<]+|[\s"'“”‘’«»)\]}>.,;:!?]+$/g;
|
||||||
|
|
||||||
|
/** Every host a sentence names (Gitea #335), e.g. "Log in at chat.lotusguild.org". */
|
||||||
|
export const hostsFromText = (text: string): string[] => {
|
||||||
|
const hosts: string[] = [];
|
||||||
|
text.split(/\s+/).forEach((raw) => {
|
||||||
|
const word = raw.replace(TRIM_PUNCT, '');
|
||||||
|
const host = word && hostFromText(word);
|
||||||
|
if (!host) return;
|
||||||
|
const tld = host.split('.').pop() ?? '';
|
||||||
|
if (!/[a-z]/i.test(tld)) return; // version numbers, IPs-as-words
|
||||||
|
const urlShaped =
|
||||||
|
/^[a-z][a-z0-9+.-]*:\/\//i.test(word) || /^www\./i.test(word) || word.includes('/');
|
||||||
|
if (!urlShaped && host.split('.').length === 2 && FILE_EXTENSIONS.has(tld.toLowerCase()))
|
||||||
|
return;
|
||||||
|
if (!hosts.includes(host)) hosts.push(host);
|
||||||
|
});
|
||||||
|
return hosts;
|
||||||
|
};
|
||||||
|
|
||||||
export type LinkSafety = {
|
export type LinkSafety = {
|
||||||
/** The visible text names a different site than the destination. */
|
/** The visible text names a different site than the destination. */
|
||||||
mismatch: boolean;
|
mismatch: boolean;
|
||||||
@@ -92,8 +213,11 @@ export const analyzeLink = (text: string, href: string): LinkSafety | null => {
|
|||||||
if (url.hostname === 'matrix.to') return null;
|
if (url.hostname === 'matrix.to') return null;
|
||||||
const realHost = url.hostname;
|
const realHost = url.hostname;
|
||||||
const punycode = realHost.split('.').some((l) => l.startsWith('xn--'));
|
const punycode = realHost.split('.').some((l) => l.startsWith('xn--'));
|
||||||
const shownHost = hostFromText(text) ?? undefined;
|
// A single URL/host-shaped text, or (Gitea #335) host-shaped words inside a
|
||||||
const mismatch =
|
// sentence: "Log in at chat.lotusguild.org" must not hide a different site.
|
||||||
shownHost !== undefined && registrableDomain(shownHost) !== registrableDomain(realHost);
|
const single = hostFromText(text);
|
||||||
return { mismatch, shownHost, realHost, punycode };
|
const shown = single ? [single] : hostsFromText(text);
|
||||||
|
const realSite = registrableDomain(realHost);
|
||||||
|
const other = shown.find((h) => registrableDomain(h) !== realSite);
|
||||||
|
return { mismatch: other !== undefined, shownHost: other ?? shown[0], realHost, punycode };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
import {
|
||||||
|
effectiveRetentionMs,
|
||||||
|
isExpired,
|
||||||
|
RETENTION_DELETE_GRACE_MS,
|
||||||
|
RETENTION_MIN_MS,
|
||||||
|
RETENTION_PRESETS,
|
||||||
|
retentionDeletesAllowed,
|
||||||
|
} from './retention';
|
||||||
|
|
||||||
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||||
// retention window. It must be strict (> window, not >=) and a disabled policy
|
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||||
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
|
|||||||
'all presets above the floor',
|
'all presets above the floor',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
|
||||||
|
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
|
||||||
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
|
||||||
|
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
|
||||||
|
assert.equal(effectiveRetentionMs(0), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(-5), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs('86400000'), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
|
||||||
|
const now = 1_000 * HOUR;
|
||||||
|
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
|
|||||||
{ label: '1 Month', ms: 30 * DAY_MS },
|
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #298] A usable retention window, or `undefined`. Anything under
|
||||||
|
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
|
||||||
|
* from any client by anyone with state power) used to blank every timeline and
|
||||||
|
* get opted-in users' messages redacted within a minute.
|
||||||
|
*/
|
||||||
|
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
|
||||||
|
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
|
||||||
|
? maxLifetime
|
||||||
|
: undefined;
|
||||||
|
|
||||||
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||||
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||||
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||||
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
||||||
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
|
||||||
|
* policy for this long after it was set, so a mistaken or malicious change can
|
||||||
|
* be reverted before anything is permanently redacted. (Hiding expired
|
||||||
|
* messages locally is not destructive and applies at once.)
|
||||||
|
*/
|
||||||
|
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
|
||||||
|
|
||||||
|
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
|
||||||
|
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
|
||||||
|
|
||||||
|
/** When the room's current retention policy was set (its state event's ts). */
|
||||||
|
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
|
||||||
|
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
|
||||||
|
|
||||||
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||||
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||||
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||||
|
|||||||
@@ -104,3 +104,9 @@ describe('buildSyncedContent', () => {
|
|||||||
assert.deepEqual(c.settings, pickSyncable(base));
|
assert.deepEqual(c.settings, pickSyncable(base));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('device-local keys', () => {
|
||||||
|
it('keeps enterForNewline on each device (#348)', () => {
|
||||||
|
assert.equal(DEVICE_LOCAL_KEYS.has('enterForNewline'), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ export const DEVICE_LOCAL_KEYS: ReadonlySet<keyof Settings> = new Set<keyof Sett
|
|||||||
'ringtoneVolume',
|
'ringtoneVolume',
|
||||||
'soundboardVolume',
|
'soundboardVolume',
|
||||||
'cameraOnJoin',
|
'cameraOnJoin',
|
||||||
|
// [Gitea #348] Enter = new line suits a phone keyboard, Enter = send a desktop one;
|
||||||
|
// syncing it switched the desktop to Ctrl+Enter when set on the phone.
|
||||||
|
'enterForNewline',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** The subset of `settings` that is synced (deep-copied so callers can't alias). */
|
/** The subset of `settings` that is synced (deep-copied so callers can't alias). */
|
||||||
|
|||||||
Reference in New Issue
Block a user