Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36e697613b |
@@ -18,10 +18,8 @@ import {
|
|||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import {
|
import {
|
||||||
ClientEvent,
|
|
||||||
EventTimelineSetHandlerMap,
|
EventTimelineSetHandlerMap,
|
||||||
EventType,
|
EventType,
|
||||||
SyncState,
|
|
||||||
RelationType,
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
@@ -74,7 +72,7 @@ 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, getMemberName } from '../utils/room';
|
import { getStateEvent, getMemberName } from '../utils/room';
|
||||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
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';
|
||||||
@@ -598,50 +596,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
refEventId,
|
refEventId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
setCallInfo(info);
|
||||||
// a reload the first sync replays the ring together with the caller's
|
|
||||||
// hang-up and our own decline. Check the room STATE (not the possibly
|
|
||||||
// stale RTC session). The caller's membership can arrive just after the
|
|
||||||
// ring, so an absent one gets a short grace period before giving up.
|
|
||||||
const myUserId = mx.getSafeUserId();
|
|
||||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hasActiveCallMembership(room, sender)) {
|
|
||||||
setCallInfo(info);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
|
||||||
if (Date.now() >= senderTs + lifetime) return;
|
|
||||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
|
||||||
}, 4000);
|
|
||||||
},
|
},
|
||||||
[mx, directs, callEmbed, setToast],
|
[mx, directs, callEmbed, setToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// [Gitea #312] A catch-up sync (after a reload or reconnect) can replay a
|
|
||||||
// call's join and leave in one batch, and the RTC session may keep the join
|
|
||||||
// (stale "1 Live", call screen instead of chat). Once sync is live again,
|
|
||||||
// recompute every active session from the room's current state.
|
|
||||||
useEffect(() => {
|
|
||||||
const onSync = (state: SyncState, prev: SyncState | null) => {
|
|
||||||
if (state !== SyncState.Syncing || prev === SyncState.Syncing) return;
|
|
||||||
mx.getRooms().forEach((r) => {
|
|
||||||
mx.matrixRTC
|
|
||||||
.getActiveRoomSession(r)
|
|
||||||
?._onRTCSessionMemberUpdate()
|
|
||||||
.catch(() => undefined);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
mx.on(ClientEvent.Sync, onSync);
|
|
||||||
return () => {
|
|
||||||
mx.removeListener(ClientEvent.Sync, onSync);
|
|
||||||
};
|
|
||||||
}, [mx]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
mx.on(RoomEvent.Timeline, handleTimelineEvent);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -670,10 +629,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
const { memberships } = session;
|
const { memberships } = session;
|
||||||
if (memberships.some((m) => m.sender === myUserId))
|
if (memberships.some((m) => m.sender === myUserId))
|
||||||
dismiss(); // answered elsewhere
|
dismiss(); // answered elsewhere
|
||||||
else if (memberships.length === 0)
|
else if (memberships.length === 0) dismiss(); // caller gone
|
||||||
dismiss(); // caller gone
|
|
||||||
// [Gitea #312] The session can be stale after a reload; the room state isn't.
|
|
||||||
else if (!hasActiveCallMembership(room, callInfo.sender)) dismiss();
|
|
||||||
};
|
};
|
||||||
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
const onTimeline: EventTimelineSetHandlerMap[RoomEvent.Timeline] = (
|
||||||
event,
|
event,
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
|||||||
import { VirtualTile } from '../../components/virtualizer';
|
import { VirtualTile } from '../../components/virtualizer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { MsgTypeFilter } from './useMessageSearch';
|
import { MsgTypeFilter } from './useMessageSearch';
|
||||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
|
||||||
|
|
||||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||||
@@ -138,19 +137,14 @@ type SelectRoomButtonProps = {
|
|||||||
onChange: (rooms?: string[]) => void;
|
onChange: (rooms?: string[]) => void;
|
||||||
};
|
};
|
||||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||||
const localNames = useLocalRoomNames();
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||||
|
|
||||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||||
(rId) => {
|
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||||
const name = mx.getRoom(rId)?.name ?? rId;
|
[mx],
|
||||||
// [Gitea #336] match the personal "Rename for me" name too
|
|
||||||
return localNames[rId] ? [localNames[rId], name] : name;
|
|
||||||
},
|
|
||||||
[mx, localNames],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||||
@@ -298,7 +292,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text truncate size="T300">
|
<Text truncate size="T300">
|
||||||
{localNames[room.roomId] ?? room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
@@ -707,7 +701,6 @@ export function SearchFilters({
|
|||||||
pinnedOnly,
|
pinnedOnly,
|
||||||
onTogglePinnedOnly,
|
onTogglePinnedOnly,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
const localNames = useLocalRoomNames();
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -755,7 +748,7 @@ export function SearchFilters({
|
|||||||
}
|
}
|
||||||
after={<Icon size="50" src={Icons.Cross} />}
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
>
|
>
|
||||||
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
<Text size="T200">{room.name}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import {
|
|||||||
} from '../../hooks/useMemberPowerTag';
|
} from '../../hooks/useMemberPowerTag';
|
||||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
|
||||||
|
|
||||||
type SearchResultGroupProps = {
|
type SearchResultGroupProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -74,7 +73,6 @@ export function SearchResultGroup({
|
|||||||
hour24Clock,
|
hour24Clock,
|
||||||
dateFormatString,
|
dateFormatString,
|
||||||
}: SearchResultGroupProps) {
|
}: SearchResultGroupProps) {
|
||||||
const localNames = useLocalRoomNames();
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||||
@@ -214,7 +212,7 @@ export function SearchResultGroup({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{localNames[room.roomId] ?? room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -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, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
import { RetentionContent, 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';
|
||||||
@@ -494,10 +494,7 @@ 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);
|
||||||
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
||||||
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');
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ import React, {
|
|||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useHover, useFocusWithin } from 'react-aria';
|
import { useHover, useFocusWithin } from 'react-aria';
|
||||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
import { MatrixEvent, MatrixEventEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom, useAtomValue } from 'jotai';
|
||||||
@@ -114,6 +115,20 @@ function DeliveryStatus({
|
|||||||
}) {
|
}) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const offline = useAtomValue(sendOfflineAtom);
|
const offline = useAtomValue(sendOfflineAtom);
|
||||||
|
// [Gitea #306] A local echo's status changes (SENDING → SENT → confirmed,
|
||||||
|
// NOT_SENT → retried) don't re-render the timeline, so the last message in a
|
||||||
|
// quiet room kept "Sending..." (or a stale "Failed to send") indefinitely.
|
||||||
|
// Re-render on the event's own status updates.
|
||||||
|
const [, setStatusTick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const bump = () => setStatusTick((t) => t + 1);
|
||||||
|
mEvent.on(MatrixEventEvent.Status, bump);
|
||||||
|
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||||
|
return () => {
|
||||||
|
mEvent.off(MatrixEventEvent.Status, bump);
|
||||||
|
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, bump);
|
||||||
|
};
|
||||||
|
}, [mEvent]);
|
||||||
const { status } = mEvent;
|
const { status } = mEvent;
|
||||||
if (status === null) return null; // confirmed by server — read receipts take over
|
if (status === null) return null; // confirmed by server — read receipts take over
|
||||||
let iconSrc: IconSrc;
|
let iconSrc: IconSrc;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
|||||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||||
import { getMemberName } from '../../../utils/room';
|
import { getMemberName } from '../../../utils/room';
|
||||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
|
||||||
|
|
||||||
type ThreadPanelHeaderProps = {
|
type ThreadPanelHeaderProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -40,7 +39,6 @@ type ThreadPanelHeaderProps = {
|
|||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||||
const localNames = useLocalRoomNames();
|
|
||||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||||
const { format } = useTimestampFormatter();
|
const { format } = useTimestampFormatter();
|
||||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||||
@@ -57,7 +55,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
|||||||
Thread
|
Thread
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
{localNames[room.roomId] ?? room.name}
|
{room.name}
|
||||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
|||||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
|
||||||
|
|
||||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||||
@@ -236,7 +235,6 @@ export type ThreadsListPanelProps = {
|
|||||||
onOpenThread: (threadId: string) => void;
|
onOpenThread: (threadId: string) => void;
|
||||||
};
|
};
|
||||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||||
const localNames = useLocalRoomNames();
|
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const screenSize = useScreenSizeContext();
|
const screenSize = useScreenSizeContext();
|
||||||
const handleJump = useCallback(
|
const handleJump = useCallback(
|
||||||
@@ -363,7 +361,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
|||||||
Threads
|
Threads
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||||
{localNames[room.roomId] ?? room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{totalUnread > 0 && (
|
{totalUnread > 0 && (
|
||||||
|
|||||||
@@ -68,12 +68,7 @@ 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 {
|
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
||||||
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';
|
||||||
@@ -915,9 +910,6 @@ 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()
|
||||||
|
|||||||
@@ -98,7 +98,6 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
|||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { PresenceRingAvatar } from '../../../components/presence';
|
import { PresenceRingAvatar } from '../../../components/presence';
|
||||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
|
||||||
|
|
||||||
type RoomNotificationsGroup = {
|
type RoomNotificationsGroup = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -224,7 +223,6 @@ function RoomNotificationsGroupComp({
|
|||||||
hour24Clock,
|
hour24Clock,
|
||||||
dateFormatString,
|
dateFormatString,
|
||||||
}: RoomNotificationsGroupProps) {
|
}: RoomNotificationsGroupProps) {
|
||||||
const localNames = useLocalRoomNames();
|
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||||
@@ -430,7 +428,7 @@ function RoomNotificationsGroupComp({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{localNames[room.roomId] ?? room.name}
|
{room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import type { Room } from 'matrix-js-sdk';
|
import type { Room } from 'matrix-js-sdk';
|
||||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
|
import { isRingableRoom } from './callRing';
|
||||||
|
|
||||||
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||||
({
|
({
|
||||||
@@ -27,43 +27,3 @@ test('space channels, public and restricted rooms do not ring', () => {
|
|||||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'public' }), false), false);
|
||||||
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
assert.equal(isRingableRoom(fakeRoom({ joinRule: 'restricted' }), false), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
const stateRoom = (
|
|
||||||
members: Array<{ sender: string; content: Record<string, unknown> }>,
|
|
||||||
timeline: unknown[] = [],
|
|
||||||
): Room =>
|
|
||||||
({
|
|
||||||
getLiveTimeline: () => ({
|
|
||||||
getState: () => ({
|
|
||||||
getStateEvents: () =>
|
|
||||||
members.map((m) => ({
|
|
||||||
getSender: () => m.sender,
|
|
||||||
getStateKey: () => `_${m.sender}_DEV_m.call`,
|
|
||||||
getContent: () => m.content,
|
|
||||||
})),
|
|
||||||
}),
|
|
||||||
getEvents: () => timeline,
|
|
||||||
}),
|
|
||||||
}) as unknown as Room;
|
|
||||||
|
|
||||||
test('hasActiveCallMembership reads the room state: joined vs left (#312)', () => {
|
|
||||||
const room = stateRoom([
|
|
||||||
{ sender: '@bob:x', content: { application: 'm.call', call_id: '' } },
|
|
||||||
{ sender: '@carol:x', content: {} },
|
|
||||||
]);
|
|
||||||
assert.equal(hasActiveCallMembership(room, '@bob:x'), true);
|
|
||||||
assert.equal(hasActiveCallMembership(room, '@carol:x'), false, 'left = empty content');
|
|
||||||
assert.equal(hasActiveCallMembership(room, '@dave:x'), false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('declinedRing finds our own decline of that ring only (#312)', () => {
|
|
||||||
const decline = (sender: string, rel: string) => ({
|
|
||||||
getType: () => 'org.matrix.msc4310.rtc.decline',
|
|
||||||
getSender: () => sender,
|
|
||||||
getRelation: () => ({ event_id: rel }),
|
|
||||||
});
|
|
||||||
const room = stateRoom([], [decline('@me:x', '$ring1'), decline('@bob:x', '$ring2')]);
|
|
||||||
assert.equal(declinedRing(room, '$ring1', '@me:x'), true);
|
|
||||||
assert.equal(declinedRing(room, '$ring2', '@me:x'), false, "someone else's decline");
|
|
||||||
assert.equal(declinedRing(room, '$ring3', '@me:x'), false);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -16,37 +16,3 @@ export const isRingableRoom = (room: Room, isDirect: boolean): boolean => {
|
|||||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||||
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
return !isSpaceChild && room.getJoinRule() === JoinRule.Invite;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** A call-membership state event that is still "in the call" (left = empty content). */
|
|
||||||
const isActiveMembership = (content: Record<string, unknown> | undefined): boolean =>
|
|
||||||
!!content &&
|
|
||||||
(typeof content.application === 'string' ||
|
|
||||||
(Array.isArray(content.memberships) && content.memberships.length > 0));
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [Gitea #312] Whether `userId` is in a call in `room` according to the room's
|
|
||||||
* current STATE. After a reload the MatrixRTC session can be stale (a join and
|
|
||||||
* leave replayed in one catch-up batch), but the state is right.
|
|
||||||
*/
|
|
||||||
export const hasActiveCallMembership = (room: Room, userId: string): boolean =>
|
|
||||||
getStateEvents(room, StateEvent.GroupCallMemberPrefix).some(
|
|
||||||
(ev) =>
|
|
||||||
(ev.getSender() === userId || ev.getStateKey()?.includes(userId)) &&
|
|
||||||
isActiveMembership(ev.getContent()),
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [Gitea #312] Whether we already handled the ring `refEventId` points at:
|
|
||||||
* declined it (our own RTCDecline relates to it) — e.g. replayed together with
|
|
||||||
* the ring in the first sync after a restart, before any dismiss listener.
|
|
||||||
*/
|
|
||||||
export const declinedRing = (room: Room, refEventId: string, myUserId: string): boolean =>
|
|
||||||
room
|
|
||||||
.getLiveTimeline()
|
|
||||||
.getEvents()
|
|
||||||
.some(
|
|
||||||
(ev) =>
|
|
||||||
ev.getType() === 'org.matrix.msc4310.rtc.decline' &&
|
|
||||||
ev.getSender() === myUserId &&
|
|
||||||
ev.getRelation()?.event_id === refEventId,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {
|
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
||||||
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
|
||||||
@@ -47,22 +40,3 @@ 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,38 +20,13 @@ 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, '');
|
||||||
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
const ms = 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;
|
||||||
|
|||||||
@@ -20,12 +20,9 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
|||||||
* session. When that happens the device "resurrects" with a blank key store and the
|
* session. When that happens the device "resurrects" with a blank key store and the
|
||||||
* client re-uploads a one-time key at an id Synapse already holds → a permanent
|
* client re-uploads a one-time key at an id Synapse already holds → a permanent
|
||||||
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
||||||
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
|
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
|
||||||
* (engagement / PWA install); Firefox shows a permission prompt and the promise
|
* install and shows no prompt; denial/absence is non-fatal.
|
||||||
* stays pending until it's answered. Denial/absence is non-fatal.
|
|
||||||
*/
|
*/
|
||||||
const PERSIST_WAIT_MS = 1500;
|
|
||||||
|
|
||||||
export const requestPersistentStorage = async (): Promise<boolean> => {
|
export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
if (!navigator.storage?.persist) return false;
|
if (!navigator.storage?.persist) return false;
|
||||||
@@ -42,15 +39,7 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
|
|||||||
|
|
||||||
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||||
// Protect the crypto store from eviction before we create and write to it.
|
// Protect the crypto store from eviction before we create and write to it.
|
||||||
// [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
|
await requestPersistentStorage();
|
||||||
// the promise pending until the user responds, which froze the app on
|
|
||||||
// "Heating up". KE-1 only needs the request made; give it 1.5 s at most.
|
|
||||||
await Promise.race([
|
|
||||||
requestPersistentStorage(),
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
setTimeout(() => resolve(false), PERSIST_WAIT_MS);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const indexedDBStore = new IndexedDBStore({
|
const indexedDBStore = new IndexedDBStore({
|
||||||
indexedDB: globalThis.indexedDB,
|
indexedDB: globalThis.indexedDB,
|
||||||
|
|||||||
Reference in New Issue
Block a user