Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc42b32d4 | ||
|
|
906a034e26 | ||
|
|
b855eb505d | ||
|
|
cc8e7444f6 | ||
|
|
d7ea408fd0 | ||
|
|
d3f01b5116 | ||
|
|
f84c0d6e9e | ||
|
|
1c62fb4722 | ||
|
|
a6c7333ea3 | ||
|
|
f0b7400d1b | ||
|
|
a86947be48 | ||
|
|
b6f9af3f58 |
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
setRoomNotificationPreference,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import { clearMuteTimer, scheduleMuteTimer } from './muteTimers';
|
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
|
||||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
||||||
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||||
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
import { InviteUserPrompt } from '../../components/invite-user-prompt';
|
||||||
@@ -381,10 +381,9 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
prevMode,
|
prevMode,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
if (durationMs !== null) {
|
if (durationMs !== null) {
|
||||||
await scheduleMuteTimer(mx, room.roomId, durationMs).catch(() => {});
|
scheduleMuteTimer(room.roomId, durationMs, () => {
|
||||||
} else {
|
unmuteRoom(mx, room.roomId);
|
||||||
// An indefinite mute replaces any timed one.
|
});
|
||||||
await clearMuteTimer(mx, room.roomId);
|
|
||||||
}
|
}
|
||||||
requestClose();
|
requestClose();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { muteTimersContent, parseMuteTimers, shouldResetMuteOnUnmute } from './muteTimers';
|
import { shouldResetMuteOnUnmute } from './muteTimers';
|
||||||
|
|
||||||
test('resets to Unset when the room is still Mute at expiry', () => {
|
test('resets to Unset when the room is still Mute at expiry', () => {
|
||||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
|
||||||
@@ -18,16 +18,3 @@ test('does not reset when the user switched to Special messages during the mute
|
|||||||
test('does not reset when the mode is already Unset', () => {
|
test('does not reset when the mode is already Unset', () => {
|
||||||
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
|
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,
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
|
|
||||||
// Legacy localStorage key (before #333); migrated into account data once.
|
// localStorage key for timed mute timers
|
||||||
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||||
|
|
||||||
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||||
@@ -16,62 +16,19 @@ export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
|||||||
|
|
||||||
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||||
|
|
||||||
/** Pure: the timers in an `io.lotus.mute_timers` account data content. */
|
export function loadMuteTimers(): MuteTimerEntry[] {
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 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 {
|
try {
|
||||||
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
legacy = Array.isArray(parsed)
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
? parsed.filter(
|
|
||||||
(e): e is MuteTimerEntry =>
|
|
||||||
!!e && typeof e.roomId === 'string' && typeof e.unmuteAt === 'number',
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
} catch {
|
} catch {
|
||||||
legacy = [];
|
return [];
|
||||||
}
|
|
||||||
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 */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||||
|
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||||
|
}
|
||||||
|
|
||||||
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
// Pure decision for the unmute guard: a timed mute should only be reset back to
|
||||||
// Unset if the room's notification mode is still Mute at expiry time. If the user
|
// Unset if the room's notification mode is still Mute at expiry time. If the user
|
||||||
// manually changed it (e.g. to All messages) while the timer was pending, leave
|
// manually changed it (e.g. to All messages) while the timer was pending, leave
|
||||||
@@ -120,30 +77,12 @@ export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void
|
|||||||
RoomNotificationMode.Mute,
|
RoomNotificationMode.Mute,
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
await clearMuteTimer(mx, roomId);
|
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Drop a room's timer (e.g. it was muted indefinitely or unmuted by hand). */
|
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||||
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 unmuteAt = Date.now() + durationMs;
|
||||||
const existing = loadMuteTimers(mx).filter((e) => e.roomId !== roomId);
|
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||||
await saveMuteTimers(mx, [...existing, { roomId, unmuteAt }]);
|
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||||
|
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -725,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>
|
||||||
@@ -865,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) => {
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { shouldShowScreenshareNotice } from './useScreenshareNotices';
|
||||||
|
|
||||||
|
test('shouldShowScreenshareNotice: no "window was closed" when an admin turned sharing off (#297)', () => {
|
||||||
|
assert.equal(shouldShowScreenshareNotice('ended', false), false);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('ended', true), true);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('no-frames', false), true);
|
||||||
|
assert.equal(shouldShowScreenshareNotice('alone', true), true);
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { Icons } from 'folds';
|
import { Icons } from 'folds';
|
||||||
import { CallEmbed } from '../plugins/call';
|
import { CallEmbed } from '../plugins/call';
|
||||||
import { toastQueueAtom } from '../state/toast';
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
import { useRoomCallPolicy } from './useRoomCallPolicy';
|
||||||
|
|
||||||
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
export type ScreenshareNoticeKind = 'ended' | 'no-frames' | 'alone';
|
||||||
|
|
||||||
@@ -19,18 +20,32 @@ export const describeScreenshareNotice = (kind: ScreenshareNoticeKind): string |
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Pure: a policy-forced stop is explained by its own toast (#297). */
|
||||||
|
export const shouldShowScreenshareNotice = (
|
||||||
|
kind: ScreenshareNoticeKind,
|
||||||
|
allowScreenshare: boolean,
|
||||||
|
): boolean => !(kind === 'ended' && !allowScreenshare);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
* [Gitea EC#39] Toast the fork's io.lotus.screenshare_notice: shared window
|
||||||
* closed, no frames for a while, or a long share with nobody watching.
|
* closed, no frames for a while, or a long share with nobody watching.
|
||||||
*/
|
*/
|
||||||
export function useScreenshareNotices(embed: CallEmbed): void {
|
export function useScreenshareNotices(embed: CallEmbed): void {
|
||||||
const setToast = useSetAtom(toastQueueAtom);
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
// [Gitea #297] When a room admin turns screen sharing off, the share ends
|
||||||
|
// and the fork reports `ended` ("the shared window was closed"), which is
|
||||||
|
// wrong; useCallPolicyRevokedToast already says what happened.
|
||||||
|
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||||
|
const allowRef = useRef(allowScreenshare);
|
||||||
|
allowRef.current = allowScreenshare;
|
||||||
useEffect(
|
useEffect(
|
||||||
() =>
|
() =>
|
||||||
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
embed.listenAction<{ data?: { kind?: ScreenshareNoticeKind } }>(
|
||||||
'io.lotus.screenshare_notice',
|
'io.lotus.screenshare_notice',
|
||||||
(evt) => {
|
(evt) => {
|
||||||
const body = describeScreenshareNotice(evt.detail?.data?.kind as ScreenshareNoticeKind);
|
const kind = evt.detail?.data?.kind as ScreenshareNoticeKind;
|
||||||
|
if (!shouldShowScreenshareNotice(kind, allowRef.current)) return;
|
||||||
|
const body = describeScreenshareNotice(kind);
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
setToast({
|
setToast({
|
||||||
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
id: `screenshare-${evt.detail?.data?.kind}-${Date.now()}`,
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
|||||||
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { presenceStateFromSetting, usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
import {
|
import {
|
||||||
MAX_MUTE_TIMEOUT_MS,
|
MAX_MUTE_TIMEOUT_MS,
|
||||||
|
MuteTimerEntry,
|
||||||
loadMuteTimers,
|
loadMuteTimers,
|
||||||
migrateLocalMuteTimers,
|
|
||||||
unmuteRoom,
|
unmuteRoom,
|
||||||
} from '../../features/room-nav/muteTimers';
|
} from '../../features/room-nav/muteTimers';
|
||||||
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
|
||||||
@@ -91,7 +91,6 @@ import {
|
|||||||
shouldNotifyThreadReply,
|
shouldNotifyThreadReply,
|
||||||
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
|
||||||
} from '../../utils/threadNotifications';
|
} from '../../utils/threadNotifications';
|
||||||
import { AccountDataEvent } from '../../../types/matrix/accountData';
|
|
||||||
|
|
||||||
// Grace period after the initial sync settles before invite notifications arm, so
|
// 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.
|
// the async invite-atom population lands first and isn't mistaken for new invites.
|
||||||
@@ -370,35 +369,30 @@ function PresenceUpdater() {
|
|||||||
function MuteTimerRestore() {
|
function MuteTimerRestore() {
|
||||||
const mx = useMatrixClient();
|
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(() => {
|
useEffect(() => {
|
||||||
migrateLocalMuteTimers(mx).catch(() => {});
|
const timers = loadMuteTimers();
|
||||||
}, [mx]);
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
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 () => {
|
return () => {
|
||||||
handles.forEach(clearTimeout);
|
handles.forEach(clearTimeout);
|
||||||
mx.removeListener(ClientEvent.AccountData, onAccountData);
|
|
||||||
};
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -251,8 +252,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.
|
||||||
|
|||||||
@@ -64,10 +64,9 @@ const clearMsgDrafts = (): void => {
|
|||||||
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
* `lotus-status-expiry-*`, used to be preserved; since [Gitea #204] they are
|
||||||
* swept with the rest — the server-side presence status survives, so a
|
* swept with the rest — the server-side presence status survives, so a
|
||||||
* re-login loses nothing)
|
* re-login loses nothing)
|
||||||
* - low-sensitivity UI/metadata residue (collapsed nav/space categories,
|
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
||||||
* `cinny_oidc_dynamic_clients`) is treated as preferences, not swept here.
|
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
||||||
* Timed mutes moved to account data in [Gitea #333]; a leftover local
|
* preferences, not swept here
|
||||||
* `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
|
* [Gitea #204] The local mirror of the user's status message (+ its expiry) is
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export enum AccountDataEvent {
|
|||||||
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
// [Gitea #104] Synced subset of the Lotus Settings object (utils/settingsSync).
|
||||||
LotusSettings = 'io.lotus.settings',
|
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',
|
SecretStorageDefaultKey = 'm.secret_storage.default_key',
|
||||||
|
|
||||||
CrossSigningMaster = 'm.cross_signing.master',
|
CrossSigningMaster = 'm.cross_signing.master',
|
||||||
|
|||||||
Reference in New Issue
Block a user