Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd760ebc3c | ||
|
|
906a034e26 | ||
|
|
b855eb505d | ||
|
|
cc8e7444f6 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
f0b7400d1b | ||
|
|
3878a85339 | ||
|
|
9edb76835c | ||
|
|
1784d40ba1 | ||
|
|
76ef8a52c7 | ||
|
|
a3ef2b4098 | ||
|
|
0c5cf4109a | ||
|
|
39f74d51e6 | ||
|
|
36e697613b | ||
|
|
2d31312a73 | ||
|
|
57036bb6a4 | ||
|
|
c6419044cb | ||
|
|
29f6a35bfd | ||
|
|
a86947be48 | ||
|
|
37372b215a | ||
|
|
b6f9af3f58 | ||
|
|
8b2322f751 | ||
|
|
02950499ea | ||
|
|
05d7e87d26 | ||
|
|
bbb2034080 |
Generated
+4
-4
@@ -81,7 +81,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
@@ -2695,9 +2695,9 @@
|
|||||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||||
},
|
},
|
||||||
"node_modules/@lotusguild/element-call-embedded": {
|
"node_modules/@lotusguild/element-call-embedded": {
|
||||||
"version": "0.25.0-lotus.22",
|
"version": "0.25.0-lotus.23",
|
||||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.22/element-call-embedded-0.25.0-lotus.22.tgz",
|
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.23/element-call-embedded-0.25.0-lotus.23.tgz",
|
||||||
"integrity": "sha512-AimDKZA4xUKEEyl2XUFUSUDl03tBKFv88T/3igKyH2VqNSPMGnqcQJC5HXIkHLTWrOmg8kLcJ//FFbqlUZiLcA==",
|
"integrity": "sha512-vRmcWeJcU0Accyl2x3mqNEzrIe8djE5BekyYbYOSCsMflC98rZgD1mO7KOe7bA1cX7gparRf6sAt1fotHoU+bA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||||
|
|||||||
+1
-1
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "4.13.0",
|
"@axe-core/playwright": "4.13.0",
|
||||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.22",
|
"@lotusguild/element-call-embedded": "0.25.0-lotus.23",
|
||||||
"@playwright/test": "1.63.0",
|
"@playwright/test": "1.63.0",
|
||||||
"@rollup/plugin-inject": "5.0.5",
|
"@rollup/plugin-inject": "5.0.5",
|
||||||
"@rollup/plugin-wasm": "6.2.2",
|
"@rollup/plugin-wasm": "6.2.2",
|
||||||
|
|||||||
@@ -297,7 +297,7 @@ export function AccountDataEditor({
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ import {
|
|||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import {
|
import {
|
||||||
|
ClientEvent,
|
||||||
EventTimelineSetHandlerMap,
|
EventTimelineSetHandlerMap,
|
||||||
EventType,
|
EventType,
|
||||||
|
SyncState,
|
||||||
RelationType,
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
@@ -72,7 +74,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 { isRingableRoom } from '../utils/callRing';
|
import { declinedRing, hasActiveCallMembership, 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';
|
||||||
@@ -596,11 +598,50 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
refEventId,
|
refEventId,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||||
|
// 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);
|
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 () => {
|
||||||
@@ -629,7 +670,10 @@ 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) dismiss(); // caller gone
|
else if (memberships.length === 0)
|
||||||
|
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,
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ export const DeviceVerificationSetup = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
Setup Device Verification
|
Setup Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -361,7 +361,7 @@ export const DeviceVerificationReset = forwardRef<HTMLDivElement, DeviceVerifica
|
|||||||
Reset Device Verification
|
Reset Device Verification
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
<IconButton type="button" size="300" radii="300" onClick={onCancel} aria-label="Cancel">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -139,8 +139,11 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
|||||||
data.allowFederation,
|
data.allowFederation,
|
||||||
data.additionalCreators,
|
data.additionalCreators,
|
||||||
),
|
),
|
||||||
power_level_content_override:
|
// [Gitea #328] Every non-space room gets call.member at user level, not just
|
||||||
data.type === RoomType.Call ? createVoiceRoomPowerLevelsOverride() : undefined,
|
// voice rooms: the call button is shown in private group rooms too, and with
|
||||||
|
// Synapse's state_default (50) only admins could start or join a call there
|
||||||
|
// ("You don't have permission to join!").
|
||||||
|
power_level_content_override: createVoiceRoomPowerLevelsOverride(),
|
||||||
initial_state: initialState,
|
initial_state: initialState,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi
|
|||||||
</Chip>
|
</Chip>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
<IconButton onClick={requestClose} variant="Surface" aria-label="Close">
|
<IconButton type="button" onClick={requestClose} variant="Surface" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import { creatorsSupported } from '../../../utils/matrix';
|
|||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { BreakWord } from '../../../styles/Text.css';
|
import { BreakWord } from '../../../styles/Text.css';
|
||||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||||
|
import { stateToCarry } from '../../../utils/roomUpgradeState';
|
||||||
|
|
||||||
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
@@ -63,10 +64,41 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) {
|
|||||||
const [upgradeState, upgrade] = useAsyncCallback(
|
const [upgradeState, upgrade] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
async (version: string, newAdditionalCreators?: string[]) => {
|
async (version: string, newAdditionalCreators?: string[]) => {
|
||||||
await mx.http.authedRequest(Method.Post, `/rooms/${room.roomId}/upgrade`, undefined, {
|
// [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,
|
new_version: version,
|
||||||
additional_creators: newAdditionalCreators,
|
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],
|
[mx, room],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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 },
|
||||||
@@ -137,14 +138,19 @@ 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) => mx.getRoom(rId)?.name ?? rId,
|
(rId) => {
|
||||||
[mx],
|
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(
|
const [searchResult, _searchRoom, resetSearch] = useAsyncSearch(
|
||||||
@@ -292,7 +298,7 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text truncate size="T300">
|
<Text truncate size="T300">
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</VirtualTile>
|
</VirtualTile>
|
||||||
@@ -701,6 +707,7 @@ export function SearchFilters({
|
|||||||
pinnedOnly,
|
pinnedOnly,
|
||||||
onTogglePinnedOnly,
|
onTogglePinnedOnly,
|
||||||
}: SearchFiltersProps) {
|
}: SearchFiltersProps) {
|
||||||
|
const localNames = useLocalRoomNames();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -748,7 +755,7 @@ export function SearchFilters({
|
|||||||
}
|
}
|
||||||
after={<Icon size="50" src={Icons.Cross} />}
|
after={<Icon size="50" src={Icons.Cross} />}
|
||||||
>
|
>
|
||||||
<Text size="T200">{room.name}</Text>
|
<Text size="T200">{localNames[room.roomId] ?? room.name}</Text>
|
||||||
</Chip>
|
</Chip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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;
|
||||||
@@ -73,6 +74,7 @@ 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]);
|
||||||
@@ -212,7 +214,7 @@ export function SearchResultGroup({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
|
|||||||
Create Poll
|
Create Poll
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ export function ReportRoomModal({ roomId, onClose }: ReportRoomModalProps) {
|
|||||||
Report Room
|
Report Room
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function ReportUserModal({ userId, onClose }: ReportUserModalProps) {
|
|||||||
Report User
|
Report User
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
<IconButton type="button" size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</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, isExpired } from '../../utils/retention';
|
import { RetentionContent, effectiveRetentionMs, isExpired } from '../../utils/retention';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
import { useDocumentFocusChange } from '../../hooks/useDocumentFocusChange';
|
||||||
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
import { RenderMessageContent } from '../../components/RenderMessageContent';
|
||||||
@@ -494,7 +494,10 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||||||
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
// timeline (unless "show hidden events" is on). Reactive so a policy change
|
||||||
// re-renders. `undefined` = no policy.
|
// re-renders. `undefined` = no policy.
|
||||||
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
const retentionEvent = useStateEvent(room, StateEvent.RoomRetention);
|
||||||
const retentionMs = retentionEvent?.getContent<RetentionContent>().max_lifetime;
|
// [Gitea #298] Same floor as everywhere else: a tiny value must not blank the room.
|
||||||
|
const retentionMs = effectiveRetentionMs(
|
||||||
|
retentionEvent?.getContent<RetentionContent>().max_lifetime,
|
||||||
|
);
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
|
|
||||||
|
|||||||
@@ -202,7 +202,7 @@ export function ScheduleMessageModal({
|
|||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
<IconButton type="button" size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -710,7 +725,13 @@ export const MessageDeleteItem = as<
|
|||||||
Delete Message
|
Delete Message
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
@@ -850,7 +871,13 @@ export const MessageReportItem = as<
|
|||||||
Report Message
|
Report Message
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<IconButton size="300" onClick={handleClose} radii="300" aria-label="Close">
|
<IconButton
|
||||||
|
type="button"
|
||||||
|
size="300"
|
||||||
|
onClick={handleClose}
|
||||||
|
radii="300"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
<Icon src={Icons.Cross} />
|
<Icon src={Icons.Cross} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Header>
|
</Header>
|
||||||
|
|||||||
@@ -69,8 +69,15 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
const measure = useCallback(() => {
|
const measure = useCallback(() => {
|
||||||
const container = containerRef.current;
|
const container = containerRef.current;
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
const rowWidth = container.getBoundingClientRect().width;
|
// [Gitea #305] The width the chips actually get (content box). The border
|
||||||
const gap = parseFloat(getComputedStyle(container).columnGap) || 8;
|
// box grows by 4 px while collapsed (margin -2 / padding 2), and measuring
|
||||||
|
// it made a row within 4 px of fitting flip collapsed↔expanded every frame.
|
||||||
|
const cs = getComputedStyle(container);
|
||||||
|
const rowWidth =
|
||||||
|
container.clientWidth -
|
||||||
|
(parseFloat(cs.paddingLeft) || 0) -
|
||||||
|
(parseFloat(cs.paddingRight) || 0);
|
||||||
|
const gap = parseFloat(cs.columnGap) || 8;
|
||||||
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
const chips = keys ? keys.split('\u0000').map((k) => chipRefs.current.get(k)) : [];
|
||||||
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
const widths = chips.map((el) => el?.getBoundingClientRect().width ?? 0);
|
||||||
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
const moreWidth = moreRef.current?.getBoundingClientRect().width ?? 48;
|
||||||
@@ -110,8 +117,21 @@ export const Reactions = as<'div', ReactionsProps>(
|
|||||||
style={{
|
style={{
|
||||||
...props.style,
|
...props.style,
|
||||||
...(collapsed && rowHeight
|
...(collapsed && rowHeight
|
||||||
? // 2px breathing room so row-1 focus outlines are not clipped.
|
? // 2px breathing room so row-1 focus outlines are not clipped. Longhand
|
||||||
{ maxHeight: rowHeight + 4, overflow: 'hidden', padding: 2, margin: -2 }
|
// margins so the timeline's marginTop survives (Gitea #305: the
|
||||||
|
// `margin` shorthand overrode it and, once removed, cleared it).
|
||||||
|
{
|
||||||
|
maxHeight: rowHeight + 4,
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 2,
|
||||||
|
marginLeft: -2,
|
||||||
|
marginRight: -2,
|
||||||
|
marginBottom: -2,
|
||||||
|
marginTop:
|
||||||
|
props.style?.marginTop !== undefined
|
||||||
|
? `calc(${typeof props.style.marginTop === 'number' ? `${props.style.marginTop}px` : props.style.marginTop} - 2px)`
|
||||||
|
: -2,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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;
|
||||||
@@ -39,6 +40,7 @@ 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
|
||||||
@@ -55,7 +57,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 }}>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
{startedBy && root && ` · started by ${startedBy}, ${format(root.getTs())}`}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ 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.
|
||||||
@@ -235,6 +236,7 @@ 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(
|
||||||
@@ -361,7 +363,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 }}>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
{totalUnread > 0 && (
|
{totalUnread > 0 && (
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import { focusAssistActiveAtom } from '../state/focusAssist';
|
import { focusAssistActiveAtom } from '../state/focusAssist';
|
||||||
import { manualDndAtom } from '../state/manualDnd';
|
import { manualDndAtom } from '../state/manualDnd';
|
||||||
@@ -6,6 +7,8 @@ import { isWithinTimeWindow } from '../utils/timeWindow';
|
|||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
|
||||||
|
const QUIET_TICK_MS = 30_000;
|
||||||
|
|
||||||
export type NotificationsQuietInputs = {
|
export type NotificationsQuietInputs = {
|
||||||
focusAssistActive: boolean;
|
focusAssistActive: boolean;
|
||||||
manualDnd: boolean;
|
manualDnd: boolean;
|
||||||
@@ -40,6 +43,15 @@ export function isNotificationsQuiet({
|
|||||||
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
// Live-atoms/settings-backed version of `isNotificationsQuiet` for use in
|
||||||
// components.
|
// components.
|
||||||
export function useNotificationsQuiet(): boolean {
|
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 focusAssistActive = useAtomValue(focusAssistActiveAtom);
|
||||||
const manualDnd = useAtomValue(manualDndAtom);
|
const manualDnd = useAtomValue(manualDndAtom);
|
||||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ import {
|
|||||||
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
const EMOJI_FONTS = 'var(--font-flags), var(--font-emoji)';
|
||||||
const FONT_MAP: Record<string, string> = {
|
const FONT_MAP: Record<string, string> = {
|
||||||
system: `system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', ${EMOJI_FONTS}, sans-serif`,
|
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`,
|
'jetbrains-mono': `'JetBrains Mono', ${EMOJI_FONTS}, monospace`,
|
||||||
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
'fira-code': `'Fira Code', ${EMOJI_FONTS}, monospace`,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ import { setPresenceWithRetry } from '../../utils/presenceWrite';
|
|||||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
import { dismissToastAtom, toastQueueAtom } from '../../state/toast';
|
||||||
import { useReminders } from '../../hooks/useReminders';
|
import { useReminders } from '../../hooks/useReminders';
|
||||||
import { getRoomRetentionMs, isExpired } from '../../utils/retention';
|
import {
|
||||||
|
getRoomRetentionMs,
|
||||||
|
getRoomRetentionSetAt,
|
||||||
|
isExpired,
|
||||||
|
retentionDeletesAllowed,
|
||||||
|
} from '../../utils/retention';
|
||||||
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpdater';
|
||||||
import { settingsRequestAtom } from '../../state/settingsRequest';
|
import { settingsRequestAtom } from '../../state/settingsRequest';
|
||||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
@@ -910,6 +915,9 @@ function RetentionSweeper() {
|
|||||||
mx.getRooms().forEach((room) => {
|
mx.getRooms().forEach((room) => {
|
||||||
const maxLifetime = getRoomRetentionMs(room);
|
const maxLifetime = getRoomRetentionMs(room);
|
||||||
if (!maxLifetime) return;
|
if (!maxLifetime) return;
|
||||||
|
// [Gitea #298] Never delete under a policy that is less than a day old.
|
||||||
|
const setAt = getRoomRetentionSetAt(room);
|
||||||
|
if (setAt === undefined || !retentionDeletesAllowed(setAt, now)) return;
|
||||||
room
|
room
|
||||||
.getLiveTimeline()
|
.getLiveTimeline()
|
||||||
.getEvents()
|
.getEvents()
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ 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;
|
||||||
@@ -223,6 +224,7 @@ 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);
|
||||||
@@ -428,7 +430,7 @@ function RoomNotificationsGroupComp({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<Text size="H4" truncate>
|
<Text size="H4" truncate>
|
||||||
{room.name}
|
{localNames[room.roomId] ?? room.name}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { verifyDenoiseAssets } from './denoiseSmokeCheck';
|
|||||||
import { canDelegateCapability } from './utils';
|
import { canDelegateCapability } from './utils';
|
||||||
import { getCallPageUrl } from './callPageUrl';
|
import { getCallPageUrl } from './callPageUrl';
|
||||||
import { restrictWidgetMessages } from '../widgetTransport';
|
import { restrictWidgetMessages } from '../widgetTransport';
|
||||||
|
import { isRingableRoom } from '../../utils/callRing';
|
||||||
|
|
||||||
// Maximum time to wait for the embedded Element Call iframe to progress from
|
// Maximum time to wait for the embedded Element Call iframe to progress from
|
||||||
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
// initial load to a ready/joined state. If it hasn't by then, we assume the
|
||||||
@@ -217,6 +218,9 @@ export class CallEmbed {
|
|||||||
// - transparent background so the room wallpaper shows through natively
|
// - transparent background so the room wallpaper shows through natively
|
||||||
lotusCallState: 'true',
|
lotusCallState: 'true',
|
||||||
lotusTransparent: 'true',
|
lotusTransparent: 'true',
|
||||||
|
// [Gitea #293] the fork applies the theme with transparency anyway, and
|
||||||
|
// warned in every call that lotusTheme wasn't set
|
||||||
|
lotusTheme: 'true',
|
||||||
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
|
// [Gitea #43] The fork hides its own footer (we draw the call bar) and
|
||||||
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
// sets its root color-scheme from the theme, instead of us injecting CSS.
|
||||||
lotusHostControls: 'true',
|
lotusHostControls: 'true',
|
||||||
@@ -251,8 +255,18 @@ export class CallEmbed {
|
|||||||
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
verifyDenoiseAssets(denoiseModel).catch(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Gitea #295] Only ask Element Call to notify where a call can ring at all
|
||||||
|
// (DMs, invite-only groups outside a space). In a voice channel or space
|
||||||
|
// room the notification carries an @room mention, so every member got a
|
||||||
|
// highlighted ping (and phone push) whenever a moderator hopped in, and
|
||||||
|
// other clients showed it as a missed call.
|
||||||
|
// `none` needs element-call-embedded ≥ 0.25.0-lotus.23 (older builds ignore it).
|
||||||
if (CallEmbed.startingCall(intent)) {
|
if (CallEmbed.startingCall(intent)) {
|
||||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
let notify = 'none';
|
||||||
|
if (isRingableRoom(room, CallEmbed.dmCall(intent))) {
|
||||||
|
notify = CallEmbed.dmCall(intent) ? 'ring' : 'notification';
|
||||||
|
}
|
||||||
|
params.append('sendNotificationType', notify);
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
// [Gitea #43] On its own origin when config.json sets elementCallUrl.
|
||||||
|
|||||||
@@ -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 { isRingableRoom } from './callRing';
|
import { declinedRing, hasActiveCallMembership, isRingableRoom } from './callRing';
|
||||||
|
|
||||||
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
const fakeRoom = (opts: { callRoom?: boolean; joinRule?: string; spaceParent?: boolean }): Room =>
|
||||||
({
|
({
|
||||||
@@ -27,3 +27,43 @@ 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,3 +16,37 @@ 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,6 +1,13 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { isExpired, RETENTION_PRESETS, RETENTION_MIN_MS } from './retention';
|
import {
|
||||||
|
effectiveRetentionMs,
|
||||||
|
isExpired,
|
||||||
|
RETENTION_DELETE_GRACE_MS,
|
||||||
|
RETENTION_MIN_MS,
|
||||||
|
RETENTION_PRESETS,
|
||||||
|
retentionDeletesAllowed,
|
||||||
|
} from './retention';
|
||||||
|
|
||||||
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
// MSC1763 retention: `isExpired` decides whether a message is past the room's
|
||||||
// retention window. It must be strict (> window, not >=) and a disabled policy
|
// retention window. It must be strict (> window, not >=) and a disabled policy
|
||||||
@@ -40,3 +47,22 @@ test('presets: Off is 0 and the rest are strictly increasing, all >= the floor',
|
|||||||
'all presets above the floor',
|
'all presets above the floor',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('effectiveRetentionMs: tiny or invalid windows count as off (#298)', () => {
|
||||||
|
assert.equal(effectiveRetentionMs(1000), undefined, '1 s is a typo, not a policy');
|
||||||
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS - 1), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(RETENTION_MIN_MS), RETENTION_MIN_MS);
|
||||||
|
assert.equal(effectiveRetentionMs(86_400_000), 86_400_000);
|
||||||
|
assert.equal(effectiveRetentionMs(0), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(-5), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs('86400000'), undefined);
|
||||||
|
assert.equal(effectiveRetentionMs(Number.NaN), undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('retentionDeletesAllowed: no deletions in the first 24 h of a policy (#298)', () => {
|
||||||
|
const now = 1_000 * HOUR;
|
||||||
|
assert.equal(retentionDeletesAllowed(now - 2 * HOUR, now), false);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS + 1, now), false);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - RETENTION_DELETE_GRACE_MS, now), true);
|
||||||
|
assert.equal(retentionDeletesAllowed(now - 72 * HOUR, now), true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -20,13 +20,38 @@ export const RETENTION_PRESETS: RetentionPreset[] = [
|
|||||||
{ label: '1 Month', ms: 30 * DAY_MS },
|
{ label: '1 Month', ms: 30 * DAY_MS },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #298] A usable retention window, or `undefined`. Anything under
|
||||||
|
* RETENTION_MIN_MS is treated as off: a fat-fingered `max_lifetime: 1000` (set
|
||||||
|
* from any client by anyone with state power) used to blank every timeline and
|
||||||
|
* get opted-in users' messages redacted within a minute.
|
||||||
|
*/
|
||||||
|
export const effectiveRetentionMs = (maxLifetime: unknown): number | undefined =>
|
||||||
|
typeof maxLifetime === 'number' && Number.isFinite(maxLifetime) && maxLifetime >= RETENTION_MIN_MS
|
||||||
|
? maxLifetime
|
||||||
|
: undefined;
|
||||||
|
|
||||||
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
/** The room's active retention window in ms, or `undefined` when unset/disabled. */
|
||||||
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
export const getRoomRetentionMs = (room: Room): number | undefined => {
|
||||||
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
const event = room.currentState.getStateEvents(StateEvent.RoomRetention, '');
|
||||||
const ms = event?.getContent<RetentionContent>()?.max_lifetime;
|
return effectiveRetentionMs(event?.getContent<RetentionContent>()?.max_lifetime);
|
||||||
return typeof ms === 'number' && ms > 0 ? ms : undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [Gitea #298] Deletion grace: the auto-delete sweeper ignores a retention
|
||||||
|
* policy for this long after it was set, so a mistaken or malicious change can
|
||||||
|
* be reverted before anything is permanently redacted. (Hiding expired
|
||||||
|
* messages locally is not destructive and applies at once.)
|
||||||
|
*/
|
||||||
|
export const RETENTION_DELETE_GRACE_MS = DAY_MS;
|
||||||
|
|
||||||
|
export const retentionDeletesAllowed = (policySetAtMs: number, nowMs: number): boolean =>
|
||||||
|
nowMs - policySetAtMs >= RETENTION_DELETE_GRACE_MS;
|
||||||
|
|
||||||
|
/** When the room's current retention policy was set (its state event's ts). */
|
||||||
|
export const getRoomRetentionSetAt = (room: Room): number | undefined =>
|
||||||
|
room.currentState.getStateEvents(StateEvent.RoomRetention, '')?.getTs();
|
||||||
|
|
||||||
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
/** True when an event at `tsMs` has passed the `maxLifetimeMs` retention window. */
|
||||||
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
export const isExpired = (tsMs: number, maxLifetimeMs: number, nowMs: number): boolean =>
|
||||||
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
maxLifetimeMs > 0 && nowMs - tsMs > maxLifetimeMs;
|
||||||
|
|||||||
@@ -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
|
* 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). `persist()` grants based on engagement / PWA
|
* undecryptable to-device/media keys). Chromium/WebKit answer `persist()` at once
|
||||||
* install and shows no prompt; denial/absence is non-fatal.
|
* (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> => {
|
export const requestPersistentStorage = async (): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
if (!navigator.storage?.persist) return false;
|
if (!navigator.storage?.persist) return false;
|
||||||
@@ -39,7 +42,15 @@ 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.
|
||||||
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({
|
const indexedDBStore = new IndexedDBStore({
|
||||||
indexedDB: globalThis.indexedDB,
|
indexedDB: globalThis.indexedDB,
|
||||||
|
|||||||
+3
-2
@@ -42,7 +42,8 @@
|
|||||||
|
|
||||||
--font-emoji: 'Twemoji_DISABLED';
|
--font-emoji: 'Twemoji_DISABLED';
|
||||||
--font-flags: 'TwemojiFlags_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,
|
.dark-theme,
|
||||||
@@ -66,7 +67,7 @@
|
|||||||
--mx-uc-7: hsl(243, 100%, 80%);
|
--mx-uc-7: hsl(243, 100%, 80%);
|
||||||
--mx-uc-8: hsl(94, 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 {
|
html {
|
||||||
|
|||||||
+73
-64
@@ -87,97 +87,106 @@ function copyPdfWorker() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lotus ML noise suppression: ship the RNNoise worklet/wasm + our getUserMedia
|
// The denoise assets the fork loads from ./denoise/ at runtime, as
|
||||||
// shim alongside the vendored Element Call bundle, and inject the shim <script>
|
// [source, path under denoise/] pairs; `dir: true` entries are whole trees.
|
||||||
// into EC's index.html so it runs before EC captures the mic. Runs after
|
// Shared by the build copy and the dev server (Gitea #293).
|
||||||
// viteStaticCopy has populated dist/public/element-call (closeBundle order).
|
function denoiseSources() {
|
||||||
|
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
|
||||||
|
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
|
||||||
|
return {
|
||||||
|
// All bundled denoise assets are REQUIRED: every entry backs a model the
|
||||||
|
// UI can select (RNNoise, Speex) or the optional noise gate.
|
||||||
|
sapphi: [
|
||||||
|
[path.join(sapphi, 'rnnoise/workletProcessor.js'), 'rnnoiseWorklet.js'],
|
||||||
|
[path.join(sapphi, 'rnnoise.wasm'), 'rnnoise.wasm'],
|
||||||
|
[path.join(sapphi, 'rnnoise_simd.wasm'), 'rnnoise_simd.wasm'],
|
||||||
|
[path.join(sapphi, 'speex/workletProcessor.js'), 'speexWorklet.js'],
|
||||||
|
[path.join(sapphi, 'speex.wasm'), 'speex.wasm'],
|
||||||
|
[path.join(sapphi, 'noiseGate/workletProcessor.js'), 'noiseGateWorklet.js'],
|
||||||
|
],
|
||||||
|
// DTLN (@workadventure/noise-suppression): a self-contained ES module that
|
||||||
|
// resolves its own worklet, LiteRT WASM runtime and TFLite models via
|
||||||
|
// `import.meta.url`, so its whole dist/ tree is served intact.
|
||||||
|
dtln: [path.resolve('node_modules/@workadventure/noise-suppression/dist'), 'workadventure'],
|
||||||
|
// DeepFilterNet 3: the ESM (worklet inlined) plus the vendored wasm and ONNX
|
||||||
|
// model (CDN-free / Tauri-CSP safe), in the package's expected v2/ layout.
|
||||||
|
dfn: [
|
||||||
|
[
|
||||||
|
path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js'),
|
||||||
|
'deepfilternet/index.esm.js',
|
||||||
|
],
|
||||||
|
[path.join(dfnVendor, 'v2/pkg/df_bg.wasm'), 'deepfilternet/v2/pkg/df_bg.wasm'],
|
||||||
|
[
|
||||||
|
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
|
||||||
|
'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lotus ML noise suppression: ship the denoise worklets/wasm/models alongside
|
||||||
|
// the vendored Element Call bundle. Runs after viteStaticCopy has populated
|
||||||
|
// dist/public/element-call (closeBundle order). [Gitea #293] The dev server
|
||||||
|
// serves the same files at the same URLs, so `npm start` can test denoise.
|
||||||
function lotusDenoise() {
|
function lotusDenoise() {
|
||||||
|
const DEV_PREFIX = '/public/element-call/denoise/';
|
||||||
return {
|
return {
|
||||||
name: 'lotus-denoise',
|
name: 'lotus-denoise',
|
||||||
|
configureServer(server) {
|
||||||
|
const { sapphi, dtln, dfn } = denoiseSources();
|
||||||
|
const files = new Map([...sapphi, ...dfn].map(([src, rel]) => [rel, src]));
|
||||||
|
const types = { '.js': 'text/javascript', '.wasm': 'application/wasm' };
|
||||||
|
server.middlewares.use((req, res, next) => {
|
||||||
|
const url = (req.url ?? '').split('?')[0];
|
||||||
|
if (!url.startsWith(DEV_PREFIX)) return next();
|
||||||
|
const rel = decodeURIComponent(url.slice(DEV_PREFIX.length));
|
||||||
|
let file = files.get(rel);
|
||||||
|
if (!file && rel.startsWith(`${dtln[1]}/`)) {
|
||||||
|
const candidate = path.resolve(dtln[0], rel.slice(dtln[1].length + 1));
|
||||||
|
if (candidate.startsWith(dtln[0] + path.sep)) file = candidate;
|
||||||
|
}
|
||||||
|
if (!file || !fs.existsSync(file)) return next();
|
||||||
|
res.setHeader('Content-Type', types[path.extname(file)] ?? 'application/octet-stream');
|
||||||
|
fs.createReadStream(file).pipe(res);
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
},
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
const ecDir = path.resolve('dist/public/element-call');
|
const ecDir = path.resolve('dist/public/element-call');
|
||||||
if (!fs.existsSync(ecDir)) return;
|
if (!fs.existsSync(ecDir)) return;
|
||||||
|
|
||||||
const denoiseDir = path.join(ecDir, 'denoise');
|
const denoiseDir = path.join(ecDir, 'denoise');
|
||||||
fs.mkdirSync(denoiseDir, { recursive: true });
|
fs.mkdirSync(denoiseDir, { recursive: true });
|
||||||
|
const { sapphi, dtln, dfn } = denoiseSources();
|
||||||
|
|
||||||
const sapphi = path.resolve('node_modules/@sapphi-red/web-noise-suppressor/dist');
|
// A missing source means a partial/changed install would otherwise
|
||||||
// All bundled denoise assets are REQUIRED: every entry backs a model the
|
// silently ship a broken ML feature (worklet 404 -> raw mic), so we fail
|
||||||
// UI can select (RNNoise, Speex) or the optional noise gate. A missing
|
// the build instead.
|
||||||
// source means a partial/changed install would otherwise silently ship a
|
const missing = sapphi.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
|
||||||
// broken ML feature (worklet 404 -> raw mic), so we fail the build instead.
|
|
||||||
const assets = [
|
|
||||||
[
|
|
||||||
path.join(sapphi, 'rnnoise/workletProcessor.js'),
|
|
||||||
path.join(denoiseDir, 'rnnoiseWorklet.js'),
|
|
||||||
],
|
|
||||||
[path.join(sapphi, 'rnnoise.wasm'), path.join(denoiseDir, 'rnnoise.wasm')],
|
|
||||||
[path.join(sapphi, 'rnnoise_simd.wasm'), path.join(denoiseDir, 'rnnoise_simd.wasm')],
|
|
||||||
[path.join(sapphi, 'speex/workletProcessor.js'), path.join(denoiseDir, 'speexWorklet.js')],
|
|
||||||
[path.join(sapphi, 'speex.wasm'), path.join(denoiseDir, 'speex.wasm')],
|
|
||||||
[
|
|
||||||
path.join(sapphi, 'noiseGate/workletProcessor.js'),
|
|
||||||
path.join(denoiseDir, 'noiseGateWorklet.js'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
const missing = assets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`,
|
`[lotus-denoise] Required denoise asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${missing.join('\n ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
assets.forEach(([s, d]) => fs.copyFileSync(s, d));
|
sapphi.forEach(([s, rel]) => fs.copyFileSync(s, path.join(denoiseDir, rel)));
|
||||||
|
|
||||||
// DTLN (@workadventure/noise-suppression): unlike the flat sapphi
|
const dtlnEntry = path.join(dtln[0], 'audio-worklet.js');
|
||||||
// worklets, this package is a self-contained ES module that resolves its
|
|
||||||
// own AudioWorklet processor, LiteRT WASM runtime and TFLite models via
|
|
||||||
// `import.meta.url`. So we copy its whole dist/ tree intact to
|
|
||||||
// denoise/workadventure/ (preserving assets/ + vendor/) and let the shim
|
|
||||||
// dynamic-import denoise/workadventure/audio-worklet.js at runtime. The
|
|
||||||
// entry module is required — fail the build if the install is broken.
|
|
||||||
const dtlnSrc = path.resolve('node_modules/@workadventure/noise-suppression/dist');
|
|
||||||
const dtlnEntry = path.join(dtlnSrc, 'audio-worklet.js');
|
|
||||||
if (!fs.existsSync(dtlnEntry)) {
|
if (!fs.existsSync(dtlnEntry)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` +
|
`[lotus-denoise] DTLN entry missing (${dtlnEntry}) — build aborted. ` +
|
||||||
'Run `npm ci` to install @workadventure/noise-suppression.',
|
'Run `npm ci` to install @workadventure/noise-suppression.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
fs.cpSync(dtlnSrc, path.join(denoiseDir, 'workadventure'), { recursive: true });
|
fs.cpSync(dtln[0], path.join(denoiseDir, dtln[1]), { recursive: true });
|
||||||
|
|
||||||
// DeepFilterNet 3 (deepfilternet3-noise-filter): the npm package ships only
|
const dfnMissing = dfn.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
|
||||||
// its ESM (index.esm.js) with the AudioWorklet processor + wasm-bindgen glue
|
|
||||||
// INLINED as a string (loaded via a Blob URL, no CDN for the worklet). The
|
|
||||||
// only runtime CDN fetches are its single-threaded `df_bg.wasm` and the
|
|
||||||
// ONNX `DeepFilterNet3_onnx.tar.gz` model — which we VENDOR locally (under
|
|
||||||
// build/denoise-vendor/deepfilternet/) and serve, overriding the package's
|
|
||||||
// cdnUrl to our self-hosted base. This keeps the feature CDN-free / Tauri-CSP
|
|
||||||
// safe. We copy the ESM (the shim dynamic-imports it, mirroring DTLN) plus
|
|
||||||
// the vendored assets, preserving the package's expected v2/... layout. All
|
|
||||||
// are required — a missing entry means a broken install, so fail the build.
|
|
||||||
const dfnEsm = path.resolve('node_modules/deepfilternet3-noise-filter/dist/index.esm.js');
|
|
||||||
const dfnVendor = path.resolve('build/denoise-vendor/deepfilternet');
|
|
||||||
const dfnAssets = [
|
|
||||||
[dfnEsm, path.join(denoiseDir, 'deepfilternet/index.esm.js')],
|
|
||||||
[
|
|
||||||
path.join(dfnVendor, 'v2/pkg/df_bg.wasm'),
|
|
||||||
path.join(denoiseDir, 'deepfilternet/v2/pkg/df_bg.wasm'),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
path.join(dfnVendor, 'v2/models/DeepFilterNet3_onnx.tar.gz'),
|
|
||||||
path.join(denoiseDir, 'deepfilternet/v2/models/DeepFilterNet3_onnx.tar.gz'),
|
|
||||||
],
|
|
||||||
];
|
|
||||||
const dfnMissing = dfnAssets.filter(([s]) => !fs.existsSync(s)).map(([s]) => s);
|
|
||||||
if (dfnMissing.length > 0) {
|
if (dfnMissing.length > 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid ' +
|
`[lotus-denoise] DeepFilterNet 3 asset(s) missing — build aborted to avoid shipping a broken ML feature:\n ${dfnMissing.join('\n ')}\n(Run \`npm ci\`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)`,
|
||||||
'shipping a broken ML feature:\n ' +
|
|
||||||
dfnMissing.join('\n ') +
|
|
||||||
'\n(Run `npm ci`; the vendored wasm/model live under build/denoise-vendor/deepfilternet/.)',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
dfnAssets.forEach(([s, d]) => {
|
dfn.forEach(([s, rel]) => {
|
||||||
|
const d = path.join(denoiseDir, rel);
|
||||||
fs.mkdirSync(path.dirname(d), { recursive: true });
|
fs.mkdirSync(path.dirname(d), { recursive: true });
|
||||||
fs.copyFileSync(s, d);
|
fs.copyFileSync(s, d);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user