Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e05bbf8d |
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
@@ -496,7 +496,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
@@ -684,7 +683,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
const related = event.getRelation()?.event_id;
|
||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||
|
||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Setup Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
||||
Reset Device Verification
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
setRoomNotificationPreference,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
||||
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
|
||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||
@@ -381,9 +381,10 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
prevMode,
|
||||
).catch(() => {});
|
||||
if (durationMs !== null) {
|
||||
scheduleMuteTimer(room.roomId, durationMs, () => {
|
||||
unmuteRoom(mx, room.roomId);
|
||||
});
|
||||
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
|
||||
} else {
|
||||
// An indefinite mute replaces any timed one.
|
||||
await clearMuteTimer(mx, room.roomId);
|
||||
}
|
||||
requestClose();
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { shouldResetMuteOnUnmute } from './muteTimers';
|
||||
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
|
||||
|
||||
test('resets to Unset when the room is still Mute at expiry', () => {
|
||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
||||
@@ -18,3 +18,16 @@ test('does not reset when the user switched to Special messages during the mute
|
||||
test('does not reset when the mode is already Unset', () => {
|
||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
||||
});
|
||||
|
||||
test('parseMuteTimers / muteTimersContent round-trip and tolerate garbage (#333)', () => {
|
||||
const entries = [
|
||||
{ roomId: '!a:x', unmuteAt: 1000 },
|
||||
{ roomId: '!b:x', unmuteAt: 2000 },
|
||||
];
|
||||
assert.deepEqual(parseMuteTimers(muteTimersContent(entries)), entries);
|
||||
assert.deepEqual(parseMuteTimers({ timers: { '!a:x': 'soon', '!b:x': 5 } }), [
|
||||
{ roomId: '!b:x', unmuteAt: 5 },
|
||||
]);
|
||||
assert.deepEqual(parseMuteTimers(undefined), []);
|
||||
assert.deepEqual(parseMuteTimers({ timers: null }), []);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
setRoomNotificationPreference,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
|
||||
// localStorage key for timed mute timers
|
||||
// Legacy localStorage key (before #333); migrated into account data once.
|
||||
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||
|
||||
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||
@@ -16,17 +16,60 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
||||
|
||||
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||
|
||||
export function loadMuteTimers(): MuteTimerEntry[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
|
||||
export function parseMuteTimers(content: unknown): MuteTimerEntry[] {
|
||||
const timers = (content as { timers?: unknown } | undefined)?.timers;
|
||||
if (!timers || typeof timers !== 'object') return [];
|
||||
return Object.entries(timers as Record<string, unknown>)
|
||||
.filter((e): e is [string, number] => typeof e[1] === 'number' && Number.isFinite(e[1]))
|
||||
.map(([roomId, unmuteAt]) => ({ roomId, unmuteAt }));
|
||||
}
|
||||
|
||||
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||
/** Pure: account data content for `entries`. */
|
||||
export const muteTimersContent = (entries: MuteTimerEntry[]) => ({
|
||||
timers: Object.fromEntries(entries.map((e) => [e.roomId, e.unmuteAt])),
|
||||
});
|
||||
|
||||
/**
|
||||
* [Gitea #333] Timed mutes live in account data: the mute itself is a
|
||||
* server-side push rule on every device, so the timer must be too — kept in one
|
||||
* browser's localStorage, only that device ever unmuted (and never after a
|
||||
* logout there).
|
||||
*/
|
||||
export function loadMuteTimers(mx: MatrixClient): MuteTimerEntry[] {
|
||||
return parseMuteTimers(getAccountData(mx, AccountDataEvent.LotusMuteTimers)?.getContent());
|
||||
}
|
||||
|
||||
async function saveMuteTimers(mx: MatrixClient, entries: MuteTimerEntry[]): Promise<void> {
|
||||
await mx.setAccountData(
|
||||
AccountDataEvent.LotusMuteTimers as never,
|
||||
muteTimersContent(entries) as never,
|
||||
);
|
||||
}
|
||||
|
||||
/** Move timers kept in this browser's localStorage (pre-#333) into account data. */
|
||||
export async function migrateLocalMuteTimers(mx: MatrixClient): Promise<void> {
|
||||
let legacy: MuteTimerEntry[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||
legacy = Array.isArray(parsed)
|
||||
? parsed.filter(
|
||||
(e): e is MuteTimerEntry =>
|
||||
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
|
||||
)
|
||||
: [];
|
||||
} catch {
|
||||
legacy = [];
|
||||
}
|
||||
if (legacy.length === 0) return;
|
||||
const current = loadMuteTimers(mx);
|
||||
const merged = [...current, ...legacy.filter((l) => !current.some((c) => c.roomId === l.roomId))];
|
||||
await saveMuteTimers(mx, merged);
|
||||
try {
|
||||
localStorage.removeItem(MUTE_TIMERS_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
||||
@@ -77,12 +120,30 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
|
||||
RoomNotificationMode.Mute,
|
||||
).catch(() => {});
|
||||
}
|
||||
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
||||
await clearMuteTimer(mx, roomId);
|
||||
}
|
||||
|
||||
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||
const unmuteAt = Date.now() + durationMs;
|
||||
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
||||
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
|
||||
export async function clearMuteTimer(mx: MatrixClient, roomId: string): Promise<void> {
|
||||
const timers = loadMuteTimers(mx);
|
||||
if (!timers.some((e) => e.roomId === roomId)) return;
|
||||
await saveMuteTimers(
|
||||
mx,
|
||||
timers.filter((e) => e.roomId !== roomId),
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Record when a timed mute ends. MuteTimerRestore (on every Lotus device)
|
||||
* schedules the unmute from account data, so whichever is online at expiry
|
||||
* lifts it.
|
||||
*/
|
||||
export async function scheduleMuteTimer(
|
||||
mx: MatrixClient,
|
||||
roomId: string,
|
||||
durationMs: number,
|
||||
): Promise<void> {
|
||||
const unmuteAt = Date.now() + durationMs;
|
||||
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
|
||||
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
||||
Create Poll
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
||||
Report Room
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
||||
Report User
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -90,7 +90,6 @@ import {
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
sendRoomEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { getLastEditDiff } from '../../utils/editDiff';
|
||||
import { tick } from '../../utils/haptics';
|
||||
@@ -411,9 +410,6 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
// [Gitea #259] The media gallery / activity log register filtered timeline
|
||||
// sets whose events the room re-emits as live: don't count them twice.
|
||||
if (isFromFilteredTimelineSet(data)) return;
|
||||
onArrive(mEvent);
|
||||
};
|
||||
const handleRedaction: RoomEventHandlerMap[RoomEvent.Redaction] = (mEvent, eventRoom) => {
|
||||
|
||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -725,13 +725,7 @@ export const MessageDeleteItem = as<
|
||||
Delete Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
@@ -871,13 +865,7 @@ export const MessageReportItem = as<
|
||||
Report Message
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
onClick={handleClose}
|
||||
radii="300"
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
|
||||
@@ -14,7 +14,6 @@ import { GeneralWidgetDriver } from './GeneralWidgetDriver';
|
||||
import { WidgetPermissionRequest, isWidgetUrlSafe } from './widgetUtils';
|
||||
import { restrictWidgetMessages } from '../../../plugins/widgetTransport';
|
||||
import { WidgetPermissionPrompt } from './WidgetPermissionPrompt';
|
||||
import { isFromFilteredTimelineSet } from '../../../utils/room';
|
||||
|
||||
type PendingAsk = {
|
||||
requests: WidgetPermissionRequest[];
|
||||
@@ -112,7 +111,6 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
|
||||
};
|
||||
const onTimeline: RoomEventHandlerMap[RoomEvent.Timeline] = (ev, r, toStart, removed, data) => {
|
||||
if (toStart || removed || !data?.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] don't feed widgets each event twice
|
||||
if (ev.isBeingDecrypted()) ev.once(MatrixEventEvent.Decrypted, feed);
|
||||
else feed(ev);
|
||||
};
|
||||
|
||||
@@ -49,7 +49,6 @@ import {
|
||||
getNotificationType,
|
||||
getUnreadInfo,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { NotificationType } from '../../../types/matrix/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
@@ -59,8 +58,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||
import {
|
||||
MAX_MUTE_TIMEOUT_MS,
|
||||
MuteTimerEntry,
|
||||
loadMuteTimers,
|
||||
migrateLocalMuteTimers,
|
||||
unmuteRoom,
|
||||
} from '../../features/room-nav/muteTimers';
|
||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||
@@ -92,6 +91,7 @@ import {
|
||||
shouldNotifyThreadReply,
|
||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||
} from '../../utils/threadNotifications';
|
||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
||||
|
||||
// Grace period after the initial sync settles before invite notifications arm, so
|
||||
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||
@@ -370,30 +370,35 @@ function PresenceUpdater() {
|
||||
function MuteTimerRestore() {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
// [Gitea #333] Timers are in account data, so a mute set on another device
|
||||
// is lifted here too, and changes there (a new mute, a manual unmute)
|
||||
// reschedule. Moves any timers this browser kept locally (pre-#333) first.
|
||||
useEffect(() => {
|
||||
const timers = loadMuteTimers();
|
||||
if (timers.length === 0) return undefined;
|
||||
|
||||
const now = Date.now();
|
||||
const pastDue: MuteTimerEntry[] = [];
|
||||
const future: MuteTimerEntry[] = [];
|
||||
timers.forEach((entry) => (entry.unmuteAt <= now ? pastDue : future).push(entry));
|
||||
|
||||
pastDue.forEach((entry) => {
|
||||
unmuteRoom(mx, entry.roomId);
|
||||
});
|
||||
|
||||
const handles = future.map((entry) =>
|
||||
setTimeout(
|
||||
() => {
|
||||
unmuteRoom(mx, entry.roomId);
|
||||
},
|
||||
Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS),
|
||||
),
|
||||
);
|
||||
migrateLocalMuteTimers(mx).catch(() => {});
|
||||
}, [mx]);
|
||||
|
||||
useEffect(() => {
|
||||
let handles: ReturnType<typeof setTimeout>[] = [];
|
||||
const schedule = () => {
|
||||
handles.forEach(clearTimeout);
|
||||
const now = Date.now();
|
||||
handles = loadMuteTimers(mx).map((entry) =>
|
||||
setTimeout(
|
||||
() => {
|
||||
unmuteRoom(mx, entry.roomId);
|
||||
},
|
||||
Math.max(0, Math.min(entry.unmuteAt - now, MAX_MUTE_TIMEOUT_MS)),
|
||||
),
|
||||
);
|
||||
};
|
||||
const onAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() === AccountDataEvent.LotusMuteTimers) schedule();
|
||||
};
|
||||
schedule();
|
||||
mx.on(ClientEvent.AccountData, onAccountData);
|
||||
return () => {
|
||||
handles.forEach(clearTimeout);
|
||||
mx.removeListener(ClientEvent.AccountData, onAccountData);
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
@@ -685,7 +690,6 @@ function MessageNotifications() {
|
||||
data,
|
||||
) => {
|
||||
if (mx.getSyncState() !== 'SYNCING') return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259] no duplicate notifications
|
||||
if (document.hasFocus() && (selectedRoomId === room?.roomId || notificationSelected)) return;
|
||||
if (
|
||||
!room ||
|
||||
@@ -1128,7 +1132,6 @@ function ClockSkewFeature() {
|
||||
data,
|
||||
) => {
|
||||
if (!data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Only events our homeserver stamped: a federated event's
|
||||
// origin_server_ts is the other server's clock.
|
||||
const senderServer = mEvent.getSender()?.split(':').slice(1).join(':');
|
||||
|
||||
@@ -64,9 +64,10 @@ const clearMsgDrafts = (): void => {
|
||||
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
||||
* swept with the rest — the server-side presence status survives, so a
|
||||
* re-login loses nothing)
|
||||
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
||||
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
||||
* preferences, not swept here
|
||||
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
|
||||
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
|
||||
* Timed mutes moved to account data in [Gitea #333]; a leftover local
|
||||
* `io.lotus.mute_timers` is migrated there on the next start.
|
||||
*/
|
||||
/**
|
||||
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
getUnreadInfo,
|
||||
getUnreadInfos,
|
||||
isNotificationEvent,
|
||||
isFromFilteredTimelineSet,
|
||||
} from '../../utils/room';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
import { useStateEventCallback } from '../../hooks/useStateEventCallback';
|
||||
@@ -237,7 +236,6 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
|
||||
data: IRoomTimelineData,
|
||||
) => {
|
||||
if (!room || !data.liveEvent || room.isSpaceRoom() || !isNotificationEvent(mEvent)) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// Single-owner rule: thread replies drive the room badge via
|
||||
// RoomEvent.UnreadNotifications below — ignore them here so the count is
|
||||
// never double-driven / mis-attributed to the main timeline.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { IRoomTimelineData } from 'matrix-js-sdk';
|
||||
import { isFromFilteredTimelineSet } from './room';
|
||||
|
||||
const data = (filter: unknown): IRoomTimelineData =>
|
||||
({
|
||||
liveEvent: true,
|
||||
timeline: { getTimelineSet: () => ({ getFilter: () => filter }) },
|
||||
}) as unknown as IRoomTimelineData;
|
||||
|
||||
test('isFromFilteredTimelineSet: gallery/activity-log sets are filtered, the main set is not (#259)', () => {
|
||||
assert.equal(isFromFilteredTimelineSet(data({ filterId: 'media' })), true);
|
||||
assert.equal(isFromFilteredTimelineSet(data(undefined)), false);
|
||||
assert.equal(isFromFilteredTimelineSet(undefined), false);
|
||||
assert.equal(
|
||||
isFromFilteredTimelineSet({ liveEvent: true } as unknown as IRoomTimelineData),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
Room,
|
||||
RoomMember,
|
||||
TimelineEvents,
|
||||
IRoomTimelineData,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import type { RoomMessageEventContent } from 'matrix-js-sdk/lib/@types/events';
|
||||
@@ -848,14 +847,3 @@ export const guessPerfectParent = (
|
||||
|
||||
return perfectParent;
|
||||
};
|
||||
|
||||
/**
|
||||
* [Gitea #259] Whether a RoomEvent.Timeline emit came from a FILTERED timeline
|
||||
* set. The media gallery and activity log register filtered sets on the room
|
||||
* (getOrCreateFilteredTimelineSet), and the room re-emits their events with
|
||||
* liveEvent: true, so listeners that mean "a new event in the room" saw each
|
||||
* matching event twice (the timeline window drifted, notifications doubled).
|
||||
* The room's main and thread sets have no filter.
|
||||
*/
|
||||
export const isFromFilteredTimelineSet = (data?: IRoomTimelineData): boolean =>
|
||||
!!data?.timeline?.getTimelineSet()?.getFilter();
|
||||
|
||||
@@ -26,6 +26,10 @@ export enum AccountDataEvent {
|
||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||
LotusSettings = 'io.lotus.settings',
|
||||
|
||||
// [Gitea #333] Timed mutes: { timers: { [roomId]: unmuteAt } }, so any Lotus
|
||||
// device can lift a mute set on another.
|
||||
LotusMuteTimers = 'io.lotus.mute_timers',
|
||||
|
||||
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||
|
||||
CrossSigningMaster = 'm.cross_signing.master',
|
||||
|
||||
Reference in New Issue
Block a user