Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69e05bbf8d | ||
|
|
3878a85339 | ||
|
|
9edb76835c | ||
|
|
1784d40ba1 | ||
|
|
76ef8a52c7 | ||
|
|
a3ef2b4098 | ||
|
|
0c5cf4109a | ||
|
|
39f74d51e6 | ||
|
|
36e697613b | ||
|
|
2d31312a73 | ||
|
|
57036bb6a4 | ||
|
|
c6419044cb | ||
|
|
29f6a35bfd | ||
|
|
78691f1fff | ||
|
|
37372b215a | ||
|
|
8b2322f751 | ||
|
|
02950499ea | ||
|
|
bbb2034080 |
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { BreakWord } from '../../../styles/Text.css';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { stateToCarry } from '../../../utils/roomUpgradeState';
|
||||
|
||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||
const [upgradeState, upgrade] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (version: string, newAdditionalCreators?: string[]) => {
|
||||
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
});
|
||||
// [Gitea #327] Synapse doesn't copy Lotus room state (voice limit, call
|
||||
// policy, soundboard, retention, role tags, emoji packs) into the
|
||||
// replacement room: read it first, re-create it after the upgrade.
|
||||
const carried = stateToCarry(
|
||||
Array.from(room.currentState.events.values()).flatMap((byKey) =>
|
||||
Array.from(byKey.values()),
|
||||
),
|
||||
);
|
||||
const res = await mx.http.authedRequest<{ replacement_room?: string }>(
|
||||
Method.Post,
|
||||
`/rooms/${room.roomId}/upgrade`,
|
||||
undefined,
|
||||
{
|
||||
new_version: version,
|
||||
additional_creators: newAdditionalCreators,
|
||||
},
|
||||
);
|
||||
const newRoomId = res?.replacement_room;
|
||||
if (!newRoomId || carried.length === 0) return;
|
||||
const results = await Promise.allSettled(
|
||||
carried.map((st) =>
|
||||
mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/rooms/${encodeURIComponent(newRoomId)}/state/${encodeURIComponent(st.type)}/${encodeURIComponent(st.stateKey)}`,
|
||||
undefined,
|
||||
st.content,
|
||||
),
|
||||
),
|
||||
);
|
||||
const failed = results.filter((r) => r.status === 'rejected').length;
|
||||
if (failed > 0) {
|
||||
throw new Error(
|
||||
`Room upgraded, but ${failed} of ${carried.length} Lotus settings (voice limit, call policy, soundboard, retention…) could not be copied to the new room.`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[mx, room],
|
||||
),
|
||||
|
||||
@@ -43,6 +43,7 @@ import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { MsgTypeFilter } from './useMessageSearch';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
|
||||
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
|
||||
@@ -137,14 +138,19 @@ type SelectRoomButtonProps = {
|
||||
onChange: (rooms?: string[]) => void;
|
||||
};
|
||||
function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButtonProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [localSelected, setLocalSelected] = useState(selectedRooms);
|
||||
|
||||
const getRoomNameStr: SearchItemStrGetter<string> = useCallback(
|
||||
(rId) => mx.getRoom(rId)?.name ?? rId,
|
||||
[mx],
|
||||
(rId) => {
|
||||
const name = mx.getRoom(rId)?.name ?? rId;
|
||||
// [Gitea #336] match the personal "Rename for me" name too
|
||||
return localNames[rId] ? [localNames[rId], name] : name;
|
||||
},
|
||||
[mx, localNames],
|
||||
);
|
||||
|
||||
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
}
|
||||
>
|
||||
<Text truncate size="T300">
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</VirtualTile>
|
||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
||||
pinnedOnly,
|
||||
onTogglePinnedOnly,
|
||||
}: SearchFiltersProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
return (
|
||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
||||
}
|
||||
after={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">{room.name}</Text>
|
||||
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
} from '../../hooks/useMemberPowerTag';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
import { useLocalRoomNames } from '../../hooks/useRoomMeta';
|
||||
|
||||
type SearchResultGroupProps = {
|
||||
room: Room;
|
||||
@@ -73,6 +74,7 @@ export function SearchResultGroup({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: SearchResultGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const highlightRegex = useMemo(() => makeHighlightRegex(highlights), [highlights]);
|
||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
@@ -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 }]);
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
import { stripTrackingParamsInHtml, stripTrackingParamsInText } from '../../utils/urlTracking';
|
||||
import { plaintextCachesWiped } from '../../state/plaintextCaches';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
import('../../components/GifPicker').then((m) => ({ default: m.GifPicker })),
|
||||
@@ -562,6 +563,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
// Persist the current editor state to the draft atom + localStorage; an empty
|
||||
// editor clears both so the draft indicators never claim a draft that isn't there.
|
||||
const persistDraft = useCallback(() => {
|
||||
// [Gitea #319] Logout already wiped the drafts; don't write this one back.
|
||||
if (plaintextCachesWiped()) return;
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
|
||||
@@ -121,7 +121,7 @@ import { ThreadSummary } from './thread/ThreadSummary';
|
||||
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { RetentionContent, isExpired } from '../../utils/retention';
|
||||
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||
@@ -494,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||
// re-renders. `undefined` = no policy.
|
||||
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 [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
|
||||
@@ -31,12 +31,13 @@ import React, {
|
||||
MouseEventHandler,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
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 classNames from 'classnames';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
@@ -114,6 +115,20 @@ function DeliveryStatus({
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
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;
|
||||
if (status === null) return null; // confirmed by server — read receipts take over
|
||||
let iconSrc: IconSrc;
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useThreadNotificationMode } from '../../../hooks/useThreadNotifications
|
||||
import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import { ThreadNotificationMode } from '../../../utils/threadNotifications';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type ThreadPanelHeaderProps = {
|
||||
room: Room;
|
||||
@@ -39,6 +40,7 @@ type ThreadPanelHeaderProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mode = useThreadNotificationMode(room.roomId, threadId);
|
||||
const { format } = useTimestampFormatter();
|
||||
// [Gitea #165] Who started the thread and when — the first thing you want
|
||||
@@ -55,7 +57,7 @@ function ThreadPanelHeader({ room, threadId, requestClose }: ThreadPanelHeaderPr
|
||||
Thread
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
@@ -48,6 +48,7 @@ import { useTimestampFormatter } from '../../../hooks/useTimestampFormatter';
|
||||
import { formatRelativeAge } from '../../../utils/formatTimestamp';
|
||||
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
|
||||
import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
@@ -235,6 +236,7 @@ export type ThreadsListPanelProps = {
|
||||
onOpenThread: (threadId: string) => void;
|
||||
};
|
||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const screenSize = useScreenSizeContext();
|
||||
const handleJump = useCallback(
|
||||
@@ -361,7 +363,7 @@ export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPan
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
{totalUnread > 0 && (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||
import { manualDndAtom } from '../state/manualDnd';
|
||||
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
|
||||
const QUIET_TICK_MS = 30_000;
|
||||
|
||||
export type NotificationsQuietInputs = {
|
||||
focusAssistActive: boolean;
|
||||
manualDnd: boolean;
|
||||
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
|
||||
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
||||
// components.
|
||||
export function useNotificationsQuiet(): boolean {
|
||||
// [Gitea #325] The quiet-hours window and the snooze expiry depend on the
|
||||
// clock, but the long-lived notifiers using this hook rarely re-render, so a
|
||||
// window starting (or a 30-minute pause ending) while the app stayed open was
|
||||
// never noticed. Re-evaluate on a tick, like the night-light overlay.
|
||||
const [, setTick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setTick((t) => t + 1), QUIET_TICK_MS);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||
const manualDnd = useAtomValue(manualDndAtom);
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
|
||||
@@ -48,7 +48,7 @@ import {
|
||||
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
|
||||
inter: `'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||
inter: `'Inter Variable', 'InterVariable', ${EMOJI_FONTS}, sans-serif`,
|
||||
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
|
||||
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
||||
};
|
||||
|
||||
@@ -58,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';
|
||||
@@ -68,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||
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 { settingsRequestAtom } from '../../state/settingsRequest';
|
||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||
@@ -86,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.
|
||||
@@ -364,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]);
|
||||
|
||||
@@ -910,6 +921,9 @@ function RetentionSweeper() {
|
||||
mx.getRooms().forEach((room) => {
|
||||
const maxLifetime = getRoomRetentionMs(room);
|
||||
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
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
|
||||
@@ -98,6 +98,7 @@ import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
import { useLocalRoomNames } from '../../../hooks/useRoomMeta';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
@@ -223,6 +224,7 @@ function RoomNotificationsGroupComp({
|
||||
hour24Clock,
|
||||
dateFormatString,
|
||||
}: RoomNotificationsGroupProps) {
|
||||
const localNames = useLocalRoomNames();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="H4" truncate>
|
||||
{room.name}
|
||||
{localNames[room.roomId] ?? room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
|
||||
@@ -26,7 +26,7 @@ const api = {
|
||||
: Object.getOwnPropertyDescriptor(target, key),
|
||||
});
|
||||
|
||||
const { clearPlaintextCaches } = await import('./plaintextCaches');
|
||||
const { clearPlaintextCaches, plaintextCachesWiped } = await import('./plaintextCaches');
|
||||
|
||||
test('clearPlaintextCaches removes every plaintext/PII localStorage key', () => {
|
||||
store.clear();
|
||||
@@ -94,3 +94,13 @@ test('clearPlaintextCaches removes the call-volume list and the settings-sync st
|
||||
assert.equal(store.has('settings-sync-meta:@me:server'), false);
|
||||
assert.equal(store.has('settings'), true, 'device preferences are kept');
|
||||
});
|
||||
|
||||
test('clearPlaintextCaches clears the encrypted-search opt-in and flags the wipe (#296, #319)', () => {
|
||||
store.clear();
|
||||
store.set('searchCacheEnabled', 'true');
|
||||
store.set('mxjssdk_memory_filter_FILTER_SYNC_@me:server', '0');
|
||||
clearPlaintextCaches('@me:server');
|
||||
assert.equal(store.has('searchCacheEnabled'), false);
|
||||
assert.equal(store.has('mxjssdk_memory_filter_FILTER_SYNC_@me:server'), false);
|
||||
assert.equal(plaintextCachesWiped(), true, 'draft writes stop until the page reloads');
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -112,7 +113,34 @@ const clearCallVolumesAndSyncMeta = (userId?: string): void => {
|
||||
});
|
||||
};
|
||||
|
||||
// [Gitea #319] Set once a logout has wiped the caches. Logout then reloads the
|
||||
// page, and the reload's `pagehide` made RoomInput flush the open composer's
|
||||
// draft straight back to localStorage; draft writes check this and stop.
|
||||
// Module state, so it resets with the new page.
|
||||
let wiped = false;
|
||||
export const plaintextCachesWiped = (): boolean => wiped;
|
||||
|
||||
/**
|
||||
* [Gitea #296] Device-wide flags/keys that carry the previous account's choice
|
||||
* or id: the encrypted-search opt-in (the next account would otherwise start
|
||||
* indexing decrypted text without opting in) and the SDK's per-user filter key.
|
||||
*/
|
||||
const clearAccountScopedFlags = (userId?: string): void => {
|
||||
const keys = [
|
||||
'searchCacheEnabled',
|
||||
...(userId ? [`mxjssdk_memory_filter_FILTER_SYNC_${userId}`] : []),
|
||||
];
|
||||
keys.forEach((key) => {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const clearPlaintextCaches = (userId?: string): void => {
|
||||
wiped = true;
|
||||
clearTranslationCache();
|
||||
clearScheduledMessages();
|
||||
clearRecentSearches();
|
||||
@@ -124,5 +152,6 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
||||
clearCallSession();
|
||||
clearStatusMessage();
|
||||
clearCallVolumesAndSyncMeta(userId);
|
||||
clearAccountScopedFlags(userId);
|
||||
if (userId) clearNavToActivePathStore(userId);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { test } from 'node:test';
|
||||
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
|
||||
// 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',
|
||||
);
|
||||
});
|
||||
|
||||
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 },
|
||||
];
|
||||
|
||||
/**
|
||||
* [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. */
|
||||
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
||||
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
||||
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
||||
};
|
||||
|
||||
/**
|
||||
* [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. */
|
||||
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { isCarriedStateType, stateToCarry } from './roomUpgradeState';
|
||||
|
||||
const ev = (type: string, content: Record<string, unknown>, stateKey = '') => ({
|
||||
getType: () => type,
|
||||
getStateKey: () => stateKey,
|
||||
getContent: () => content,
|
||||
});
|
||||
|
||||
test('Lotus room state is carried; state Synapse already copies is not (#327)', () => {
|
||||
assert.equal(isCarriedStateType('io.lotus.voice_limit'), true);
|
||||
assert.equal(isCarriedStateType('io.lotus.room_quality'), true);
|
||||
assert.equal(isCarriedStateType('io.lotus.soundboard'), true);
|
||||
assert.equal(isCarriedStateType('m.room.retention'), true);
|
||||
assert.equal(isCarriedStateType('in.cinny.room.power_level_tags'), true);
|
||||
assert.equal(isCarriedStateType('im.ponies.room_emotes'), true);
|
||||
assert.equal(isCarriedStateType('m.room.power_levels'), false);
|
||||
assert.equal(isCarriedStateType('m.room.name'), false);
|
||||
assert.equal(isCarriedStateType('m.room.member'), false);
|
||||
});
|
||||
|
||||
test('stateToCarry keeps state keys and skips emptied events', () => {
|
||||
const out = stateToCarry([
|
||||
ev('io.lotus.voice_limit', { max_users: 5 }),
|
||||
ev('io.lotus.soundboard', { clips: [1] }, 'pack-a'),
|
||||
ev('io.lotus.room_quality', {}),
|
||||
ev('m.room.name', { name: 'x' }),
|
||||
ev('m.room.retention', { max_lifetime: 604800000 }),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
out.map((s) => `${s.type}|${s.stateKey}`),
|
||||
['io.lotus.voice_limit|', 'io.lotus.soundboard|pack-a', 'm.room.retention|'],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* [Gitea #327] Room state Synapse does NOT copy into the replacement room on
|
||||
* `POST /rooms/{id}/upgrade` (it carries only power levels, join rules,
|
||||
* name/topic/avatar, history visibility, guest access, encryption, server ACL
|
||||
* and space parent/child). Lotus keeps call policy, voice limits, soundboard,
|
||||
* message retention, role tags and room emoji packs in state, so an upgrade
|
||||
* silently dropped all of them.
|
||||
*/
|
||||
const CARRIED_TYPES = new Set([
|
||||
'm.room.retention',
|
||||
'in.cinny.room.power_level_tags',
|
||||
'im.ponies.room_emotes',
|
||||
]);
|
||||
const CARRIED_PREFIXES = ['io.lotus.'];
|
||||
|
||||
export type StateToCarry = { type: string; stateKey: string; content: Record<string, unknown> };
|
||||
|
||||
export const isCarriedStateType = (type: string): boolean =>
|
||||
CARRIED_TYPES.has(type) || CARRIED_PREFIXES.some((p) => type.startsWith(p));
|
||||
|
||||
/** The state events to re-create in the replacement room (non-empty content only). */
|
||||
export const stateToCarry = (
|
||||
events: Array<{
|
||||
getType: () => string;
|
||||
getStateKey: () => string | undefined;
|
||||
getContent: () => Record<string, unknown>;
|
||||
}>,
|
||||
): StateToCarry[] =>
|
||||
events
|
||||
.filter((ev) => isCarriedStateType(ev.getType()))
|
||||
.map((ev) => ({
|
||||
type: ev.getType(),
|
||||
stateKey: ev.getStateKey() ?? '',
|
||||
content: ev.getContent(),
|
||||
}))
|
||||
.filter((s) => s.content && Object.keys(s.content).length > 0);
|
||||
@@ -20,9 +20,12 @@ export const IDB_VERSION_CONFLICT = 'IDB_VERSION_CONFLICT';
|
||||
* 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
|
||||
* `400 M_UNKNOWN: … already exists` upload-conflict storm (and, downstream,
|
||||
* undecryptable to-device/media keys). `persist()` grants based on engagement / PWA
|
||||
* install and shows no prompt; denial/absence is non-fatal.
|
||||
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
|
||||
* (engagement / PWA install); Firefox shows a permission prompt and the promise
|
||||
* stays pending until it's answered. Denial/absence is non-fatal.
|
||||
*/
|
||||
const PERSIST_WAIT_MS = 1500;
|
||||
|
||||
export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||
try {
|
||||
if (!navigator.storage?.persist) return false;
|
||||
@@ -39,7 +42,15 @@ export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||
|
||||
export const initClient = async (session: Session): Promise<MatrixClient> => {
|
||||
// Protect the crypto store from eviction before we create and write to it.
|
||||
await requestPersistentStorage();
|
||||
// [Gitea #303] Never block startup on the answer: Firefox prompts and leaves
|
||||
// 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({
|
||||
indexedDB: globalThis.indexedDB,
|
||||
|
||||
+3
-2
@@ -42,7 +42,8 @@
|
||||
|
||||
--font-emoji: 'Twemoji_DISABLED';
|
||||
--font-flags: 'TwemojiFlags_DISABLED';
|
||||
--font-secondary: 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
|
||||
--font-secondary:
|
||||
'Inter Variable', 'InterVariable', var(--font-flags), var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
||||
.dark-theme,
|
||||
@@ -66,7 +67,7 @@
|
||||
--mx-uc-7: hsl(243, 100%, 80%);
|
||||
--mx-uc-8: hsl(94, 100%, 80%);
|
||||
|
||||
--font-secondary: 'InterVariable', var(--font-emoji), sans-serif;
|
||||
--font-secondary: 'Inter Variable', 'InterVariable', var(--font-emoji), sans-serif;
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
@@ -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