Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
78dcf9ee9d | ||
|
|
3418a8865d | ||
|
|
5d2d7ca63c | ||
|
|
1a0e581f21 | ||
|
|
2fff7a5388 | ||
|
|
8ea03ab9b6 | ||
|
|
a1223ba5cf | ||
|
|
400545c79a | ||
|
|
997df415dd | ||
|
|
8bfb8737d6 | ||
|
|
72277399f0 | ||
|
|
d223f847c6 | ||
|
|
c6aac574ee | ||
|
|
53ac87befe | ||
|
|
97d2d195c3 |
Generated
+4
-4
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2695,9 +2695,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.26",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.26/element-call-embedded-0.25.0-lotus.26.tgz",
|
||||
"integrity": "sha512-u0gFV/ne61MHVkl2JFY6TjC0C/vY2oYTz6dFaDtAmdLUBH6gU+6ar0QOr5xs9bhr4+HT8CSBibU8mLTR+ByjKA==",
|
||||
"version": "0.25.0-lotus.27",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.27/element-call-embedded-0.25.0-lotus.27.tgz",
|
||||
"integrity": "sha512-24ZAvGR/t0C6LWFygZkvtk7HalPUvjERDWWkxpJJf4AZCDOJGuhoOuBrjmO+tUGwIrgLOTN6EFAWSaPpTU9rlQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.13.0",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.26",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.27",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -34,7 +34,8 @@ export function SettingTile({ title, description, before, after, children }: Set
|
||||
});
|
||||
|
||||
return (
|
||||
<Box alignItems="Center" gap="300">
|
||||
// data-setting-tile: lets a group card draw dividers between tiles only (#273).
|
||||
<Box alignItems="Center" gap="300" data-setting-tile="">
|
||||
{before && <Box shrink="No">{before}</Box>}
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
{title && (
|
||||
|
||||
@@ -44,6 +44,7 @@ import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { pttActiveAtom } from '../../hooks/useCallHotkeys';
|
||||
import { SCREENSHARE_AUDIO_STATE } from '../../plugins/call/CallControl';
|
||||
import { CallSoundboard } from './CallSoundboard';
|
||||
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
|
||||
import { ScreenshareConfirm } from './ScreenshareConfirm';
|
||||
@@ -90,6 +91,21 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
|
||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||
useCallControlState(callEmbed.control);
|
||||
// [Gitea #277] Only offer "Mute Screenshare Audio" while a remote share's
|
||||
// audio is playing, or while it is muted (so a hidden mute can't silently
|
||||
// swallow the next share).
|
||||
const [remoteScreenshareAudio, setRemoteScreenshareAudio] = useState(
|
||||
callEmbed.control.remoteScreenshareAudio,
|
||||
);
|
||||
useEffect(() => {
|
||||
const onState = () => setRemoteScreenshareAudio(callEmbed.control.remoteScreenshareAudio);
|
||||
onState();
|
||||
callEmbed.control.on(SCREENSHARE_AUDIO_STATE, onState);
|
||||
return () => {
|
||||
callEmbed.control.off(SCREENSHARE_AUDIO_STATE, onState);
|
||||
};
|
||||
}, [callEmbed]);
|
||||
const showScreenshareAudio = remoteScreenshareAudio || screenshareAudioMuted;
|
||||
const micLevel = useCallMicLevel(callEmbed);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
@@ -122,6 +138,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
};
|
||||
const showScreenshare = allowScreenshare || screenshare;
|
||||
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
|
||||
// [Gitea #265] Phones can't share their screen from a browser; don't offer it.
|
||||
const canShareScreen = typeof navigator.mediaDevices?.getDisplayMedia === 'function';
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
@@ -166,6 +184,111 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
return () => clearTimeout(id);
|
||||
}, [hangupState.status, callEmbed, setCallEmbed]);
|
||||
|
||||
const moreMenu = (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||
{compact && showScreenshare && screenshareMode !== 'hidden' && canShareScreen && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setCords(undefined);
|
||||
handleScreenshareToggle();
|
||||
}}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{screenshare ? 'Stop Screenshare' : 'Share Screen'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{compact && showScreenshareAudio && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setCords(undefined);
|
||||
callEmbed.control.toggleScreenshareAudio();
|
||||
}}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{screenshareAudioMuted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{compact && !!document.fullscreenEnabled && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
setCords(undefined);
|
||||
handleFullscreen();
|
||||
}}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSpotlightClick}>
|
||||
<Text size="B300" truncate>
|
||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" variant="Surface" radii="300" onClick={handleReactionsClick}>
|
||||
<Text size="B300" truncate>
|
||||
Reactions
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem size="300" variant="Surface" radii="300" onClick={handleSettingsClick}>
|
||||
<Text size="B300" truncate>
|
||||
Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={handleOpenMenu}
|
||||
outlined
|
||||
aria-label="More options"
|
||||
aria-expanded={!!cords}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
);
|
||||
|
||||
const hangupIcon = exiting ? (
|
||||
<Spinner variant="Critical" fill="Solid" size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.PhoneDown} size="200" filled />
|
||||
);
|
||||
|
||||
const pttKeyLabel = pttKey === 'Space' ? 'SPACE' : pttKey.replace('Key', '').replace('Digit', '');
|
||||
|
||||
return (
|
||||
@@ -204,144 +327,114 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
}}
|
||||
onCancel={() => setShareConfirm(false)}
|
||||
/>
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
gap="400"
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="Wrap"
|
||||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={handleMicrophoneToggle}
|
||||
level={micLevel}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
</Box>
|
||||
{!compact && showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
{compact ? (
|
||||
// [Gitea #265] Phones / narrow windows: ONE row. Screenshare, its audio
|
||||
// mute and fullscreen live in More (they rarely matter on a phone), and
|
||||
// End is icon-only. The old stacked layout took four rows (~40% of a
|
||||
// portrait phone).
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
gap="200"
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="NoWrap"
|
||||
>
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={handleMicrophoneToggle}
|
||||
level={micLevel}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
{moreMenu}
|
||||
<Button
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={hangup}
|
||||
disabled={exiting}
|
||||
aria-label="End Call"
|
||||
style={{ paddingInline: config.space.S300 }}
|
||||
>
|
||||
{hangupIcon}
|
||||
</Button>
|
||||
</SequenceCard>
|
||||
) : (
|
||||
<SequenceCard
|
||||
className={css.ControlCard}
|
||||
variant="SurfaceVariant"
|
||||
gap="400"
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="Wrap"
|
||||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
{/* Show a forbidden control while its track is still live so the
|
||||
user can stop it; once stopped it hides and can't be restarted. */}
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
{screenshareMode !== 'hidden' && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
/>
|
||||
)}
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
control since they're the same concern. */}
|
||||
<ScreenshareAudioButton
|
||||
muted={screenshareAudioMuted}
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
<MicrophoneButton
|
||||
enabled={microphone}
|
||||
onToggle={handleMicrophoneToggle}
|
||||
level={micLevel}
|
||||
/>
|
||||
<SoundButton enabled={sound} onToggle={() => callEmbed.control.toggleSound()} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{!compact && <ControlDivider />}
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Center"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleSpotlightClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{spotlight ? 'Grid View' : 'Spotlight View'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleReactionsClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
Reactions
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
size="300"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
onClick={handleSettingsClick}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
Settings
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
onClick={handleOpenMenu}
|
||||
outlined
|
||||
aria-label="More options"
|
||||
aria-expanded={!!cords}
|
||||
aria-haspopup="menu"
|
||||
{showVideoGroup && <ControlDivider />}
|
||||
{showVideoGroup && (
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
{/* Show a forbidden control while its track is still live so the
|
||||
user can stop it; once stopped it hides and can't be restarted. */}
|
||||
{showCamera && <VideoButton enabled={video} onToggle={handleVideoToggle} />}
|
||||
{showScreenshare && (
|
||||
<>
|
||||
{screenshareMode !== 'hidden' && (
|
||||
<ScreenShareButton
|
||||
enabled={screenshare}
|
||||
onToggle={() => handleScreenshareToggle()}
|
||||
/>
|
||||
)}
|
||||
{/* Mute-screenshare-audio sits directly next to the screenshare
|
||||
control since they're the same concern. */}
|
||||
{showScreenshareAudio && (
|
||||
<ScreenshareAudioButton
|
||||
muted={screenshareAudioMuted}
|
||||
onToggle={() => callEmbed.control.toggleScreenshareAudio()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{!!document.fullscreenEnabled && (
|
||||
<FullscreenButton isFullscreen={isFullscreen} onToggle={handleFullscreen} />
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<ControlDivider />
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes">
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
<ChatButton />
|
||||
{soundboardEnabled && <CallSoundboard callEmbed={callEmbed} />}
|
||||
{moreMenu}
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<Button
|
||||
style={{ minWidth: toRem(88) }}
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
onClick={hangup}
|
||||
before={hangupIcon}
|
||||
disabled={exiting}
|
||||
>
|
||||
<Icon size="400" src={Icons.VerticalDots} />
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
<Text size="B400">End</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column">
|
||||
<Button
|
||||
style={{ minWidth: toRem(88) }}
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
onClick={hangup}
|
||||
before={
|
||||
exiting ? (
|
||||
<Spinner variant="Critical" fill="Solid" size="200" />
|
||||
) : (
|
||||
<Icon src={Icons.PhoneDown} size="200" filled />
|
||||
)
|
||||
}
|
||||
disabled={exiting}
|
||||
>
|
||||
<Text size="B400">End</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</SequenceCard>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,10 +38,14 @@ export function Soundboard({ requestClose }: SoundboardProps) {
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<Box direction="Column" gap="200">
|
||||
<Text size="L400">This room / space (shared)</Text>
|
||||
<Text size="L400">
|
||||
{room?.isSpaceRoom() ? 'This space (shared)' : 'This room (shared)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Clips here are shared with everyone, and inherited by every room under this space
|
||||
— just like emoji/sticker packs. Only members with permission can edit.
|
||||
{/* [Gitea #279] Only a space's clips are inherited by rooms under it. */}
|
||||
{room?.isSpaceRoom()
|
||||
? 'Clips here are shared with everyone, and inherited by every room under this space — just like emoji/sticker packs. Only members with permission can edit.'
|
||||
: 'Clips here are shared with everyone in this room — just like emoji/sticker packs. Only members with permission can edit.'}
|
||||
</Text>
|
||||
{room && <RoomSoundboardPack room={room} stateKey="" />}
|
||||
</Box>
|
||||
|
||||
@@ -94,10 +94,15 @@ export function OutboxFeature() {
|
||||
OFFLINE.has(mx.getSyncState()) ||
|
||||
(typeof navigator !== 'undefined' && navigator.onLine === false);
|
||||
|
||||
const persist = (next: OutboxEntry[]) => {
|
||||
if (next === entries) return;
|
||||
// [Gitea #268] Read-modify-write: another tab may have queued or cleared
|
||||
// entries since we last looked, and saving our own copy over the key
|
||||
// would drop theirs. Entries are added/removed by txnId, so applying our
|
||||
// change to the stored list merges cleanly.
|
||||
const persist = (update: (list: OutboxEntry[]) => OutboxEntry[]) => {
|
||||
const stored = loadOutbox(userId);
|
||||
const next = update(stored);
|
||||
entries = next;
|
||||
saveOutbox(userId, entries);
|
||||
if (next !== stored) saveOutbox(userId, next);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -123,8 +128,8 @@ export function OutboxFeature() {
|
||||
if (!shouldKeepInOutbox(event.getType(), event.getContent())) return;
|
||||
live.set(txnId, { event, room });
|
||||
if (event.status === EventStatus.NOT_SENT) scheduleBlipRetry(event);
|
||||
persist(
|
||||
withEntry(entries, {
|
||||
persist((list) =>
|
||||
withEntry(list, {
|
||||
txnId,
|
||||
roomId: room.roomId,
|
||||
threadId: event.threadRootId ?? null,
|
||||
@@ -138,7 +143,7 @@ export function OutboxFeature() {
|
||||
// SENT, CANCELLED, or the remote echo replaced it (status null).
|
||||
live.delete(txnId);
|
||||
autoRetries.delete(txnId);
|
||||
persist(withoutEntry(entries, txnId));
|
||||
persist((list) => withoutEntry(list, txnId));
|
||||
};
|
||||
|
||||
/** Re-send network failures, oldest first; a room stops at its first failure. */
|
||||
@@ -191,7 +196,7 @@ export function OutboxFeature() {
|
||||
return !!room && isDelivered(room, entry.txnId);
|
||||
},
|
||||
);
|
||||
plan.drop.forEach((entry) => persist(withoutEntry(entries, entry.txnId)));
|
||||
plan.drop.forEach((entry) => persist((list) => withoutEntry(list, entry.txnId)));
|
||||
plan.restore.forEach((entry) => {
|
||||
const room = mx.getRoom(entry.roomId);
|
||||
if (!room || live.has(entry.txnId)) return;
|
||||
|
||||
@@ -401,16 +401,19 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
{displayName}
|
||||
</Text>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
width: barWidth,
|
||||
background: color.Primary.Main,
|
||||
borderRadius: config.radii.R300,
|
||||
transition: 'width 0.3s ease',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
{/* [Gitea #270] The bar's % width is of its own track,
|
||||
not of the row, so the count always fits beside it. */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 6,
|
||||
width: barWidth,
|
||||
background: color.Primary.Main,
|
||||
borderRadius: config.radii.R300,
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{count}
|
||||
</Text>
|
||||
|
||||
@@ -20,7 +20,12 @@ import { IContent } from 'matrix-js-sdk';
|
||||
import { useTimestampFormatter } from '../../hooks/useTimestampFormatter';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
import {
|
||||
describeScheduleError,
|
||||
MAX_SCHEDULE_AHEAD_MS,
|
||||
scheduleMessage,
|
||||
SCHEDULE_TOO_FAR_TEXT,
|
||||
} from '../../utils/scheduledMessages';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import {
|
||||
toLocalDate,
|
||||
@@ -121,7 +126,13 @@ export function ScheduleMessageModal({
|
||||
return;
|
||||
}
|
||||
const diffMs = sendAt.getTime() - Date.now();
|
||||
if (diffMs < 60_000) {
|
||||
// [Gitea #275] Say so as soon as a too-late time is picked, not on submit.
|
||||
const tooFar = diffMs > MAX_SCHEDULE_AHEAD_MS;
|
||||
setError((e) => {
|
||||
if (tooFar) return SCHEDULE_TOO_FAR_TEXT;
|
||||
return e === SCHEDULE_TOO_FAR_TEXT ? null : e;
|
||||
});
|
||||
if (diffMs < 60_000 || tooFar) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
@@ -149,6 +160,10 @@ export function ScheduleMessageModal({
|
||||
setError('Scheduled time must be at least 1 minute in the future.');
|
||||
return;
|
||||
}
|
||||
if (diffMs > MAX_SCHEDULE_AHEAD_MS) {
|
||||
setError(SCHEDULE_TOO_FAR_TEXT);
|
||||
return;
|
||||
}
|
||||
if (!messageText.trim()) {
|
||||
setError('Please enter a message to schedule.');
|
||||
return;
|
||||
@@ -162,7 +177,7 @@ export function ScheduleMessageModal({
|
||||
onScheduled(delayId, sendAt.getTime(), content);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to schedule message.');
|
||||
setError(describeScheduleError(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
@@ -247,6 +262,8 @@ export function ScheduleMessageModal({
|
||||
<input
|
||||
id="schedule-date"
|
||||
type="date"
|
||||
min={toLocalDate(new Date())}
|
||||
max={toLocalDate(new Date(Date.now() + MAX_SCHEDULE_AHEAD_MS))}
|
||||
value={dateValue}
|
||||
onChange={(e) => setDateValue(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
@@ -294,7 +311,8 @@ export function ScheduleMessageModal({
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
(dateValue || timeValue) && (
|
||||
(dateValue || timeValue) &&
|
||||
error !== SCHEDULE_TOO_FAR_TEXT && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Must be at least 1 minute in the future
|
||||
</Text>
|
||||
|
||||
@@ -1137,25 +1137,31 @@ export const Message = React.memo(
|
||||
</AvatarBase>
|
||||
);
|
||||
|
||||
// [Gitea #269] The content column shrinks to the message's width; the
|
||||
// receipt pill / delivery status sit OUTSIDE it, in a full-width column,
|
||||
// so their `margin-left: auto` reaches the row's right edge instead of
|
||||
// wherever the text happens to end.
|
||||
const msgContentJSX = (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
{reply}
|
||||
{edit && onEditId ? (
|
||||
<MessageEditor
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100vw',
|
||||
}}
|
||||
roomId={room.roomId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={() => onEditId()}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{reactions}
|
||||
<Box direction="Column" style={{ maxWidth: '100%', flexGrow: 1, minWidth: 0 }}>
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
{reply}
|
||||
{edit && onEditId ? (
|
||||
<MessageEditor
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100vw',
|
||||
}}
|
||||
roomId={room.roomId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={() => onEditId()}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{reactions}
|
||||
</Box>
|
||||
{readReceiptUsers.length > 0 && (
|
||||
<ReadReceiptAvatars
|
||||
room={room}
|
||||
|
||||
@@ -531,7 +531,7 @@ export const RoomPinMenu = forwardRef<HTMLDivElement, RoomPinMenuProps>(
|
||||
No Pinned Messages
|
||||
</Text>
|
||||
<Text size="T400" align="Center">
|
||||
Users with sufficient power level can pin a messages from its context menu.
|
||||
Users with sufficient power level can pin a message from its context menu.
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
import pkg from '../../../../../package.json';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { tauriInvoke } from '../../../hooks/useTauri';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-logo.png');
|
||||
|
||||
@@ -78,6 +79,51 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
||||
type AboutProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
/**
|
||||
* [Gitea #276] Which build someone is on: the web deploy's commit and build
|
||||
* date (stamped in by vite.config.js), plus the desktop app's own version from
|
||||
* Tauri, the number a desktop or updater bug report needs.
|
||||
*/
|
||||
function BuildInfo() {
|
||||
const [desktopVersion, setDesktopVersion] = useState<string>();
|
||||
useEffect(() => {
|
||||
tauriInvoke()?.('plugin:app|version')
|
||||
.then((v) => {
|
||||
if (typeof v === 'string') setDesktopVersion(v);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}, []);
|
||||
const commit = __LOTUS_BUILD_COMMIT__;
|
||||
const built = new Date(__LOTUS_BUILD_DATE__);
|
||||
const builtText = Number.isNaN(built.getTime())
|
||||
? undefined
|
||||
: built.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
|
||||
return (
|
||||
<Box direction="Column" gap="0">
|
||||
{desktopVersion && (
|
||||
<Text size="T200" priority="300">
|
||||
Desktop {desktopVersion}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T200" priority="300">
|
||||
Web build{' '}
|
||||
{commit !== 'unknown' ? (
|
||||
<a
|
||||
href={`https://code.lotusguild.org/LotusGuild/cinny/commit/${commit}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
>
|
||||
{commit}
|
||||
</a>
|
||||
) : (
|
||||
commit
|
||||
)}
|
||||
{builtText && ` · built ${builtText}`}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function About({ requestClose }: AboutProps) {
|
||||
const mx = useMatrixClient();
|
||||
const { support: serverSupport, loading: supportLoading } = useServerSupport();
|
||||
@@ -117,6 +163,7 @@ export function About({ requestClose }: AboutProps) {
|
||||
<Text size="T200">v{pkg.version}</Text>
|
||||
</Box>
|
||||
<Text>A Matrix client for Lotus Guild.</Text>
|
||||
<BuildInfo />
|
||||
</Box>
|
||||
|
||||
<Box gap="200" wrap="Wrap">
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import React, { ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||
import { Avatar, Box, Button, Chip, Icon, Icons, Input, Text, Spinner, color } from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { useUserProfile } from '../../../hooks/useUserProfile';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import {
|
||||
@@ -14,6 +19,8 @@ import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
const CELL_SIZE = 72;
|
||||
// Matches AvatarDecoration's default inset, so the preview looks like the timeline.
|
||||
const PREVIEW_INSET = 8;
|
||||
|
||||
function DecorationPreviewCell({
|
||||
slug,
|
||||
@@ -86,6 +93,12 @@ function categoryOf(slug: string | null): string {
|
||||
export function ProfileDecoration() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const profile = useUserProfile(userId);
|
||||
const myName = profile.displayName ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const myAvatarUrl = profile.avatarUrl
|
||||
? (mxcUrlToHttp(mx, profile.avatarUrl, useAuthentication, 96, 96, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
@@ -198,6 +211,10 @@ export function ProfileDecoration() {
|
||||
<Box direction="Column" gap="300">
|
||||
{/* Current selection preview */}
|
||||
<Box alignItems="Center" gap="300">
|
||||
{/* [Gitea #272] Your own avatar with the decoration overlaid, as the
|
||||
timeline draws it (AvatarDecoration: the image is the avatar plus
|
||||
an 8px margin on every side). With no decoration the circle gets
|
||||
a visible dashed outline instead of vanishing into the card. */}
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
@@ -205,10 +222,29 @@ export function ProfileDecoration() {
|
||||
height: CELL_SIZE,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
background: color.SurfaceVariant.Container,
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box',
|
||||
border: selected
|
||||
? 'none'
|
||||
: `2px dashed color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 45%, transparent)`,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
size="500"
|
||||
radii="Pill"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: PREVIEW_INSET,
|
||||
width: CELL_SIZE - PREVIEW_INSET * 2,
|
||||
height: CELL_SIZE - PREVIEW_INSET * 2,
|
||||
}}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={myAvatarUrl}
|
||||
alt=""
|
||||
renderFallback={() => <Text size="H5">{nameInitials(myName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
{selected && (
|
||||
<img
|
||||
src={decorationUrl(selected)}
|
||||
@@ -220,6 +256,7 @@ export function ProfileDecoration() {
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
zIndex: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -111,7 +111,8 @@ import {
|
||||
translationUnavailableText,
|
||||
} from '../../../utils/translation/langUtils';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SequenceCardStyle, SettingTileGroup } from '../styles.css';
|
||||
import { ScreenSize, useScreenSize } from '../../../hooks/useScreenSize';
|
||||
import { UpdateProgress, useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import {
|
||||
describeUpdateError,
|
||||
@@ -1598,6 +1599,7 @@ function Privacy() {
|
||||
// [Gitea #104] Sync toggle + the two manual actions. The toggle is itself
|
||||
// device-local (never synced) so switching it off here sticks.
|
||||
function SettingsSyncSection() {
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const [settingsSync, setSettingsSync] = useSetting(settingsAtom, 'settingsSync');
|
||||
const { pushNow, clearRemote } = useSettingsSyncActions();
|
||||
const [busy, setBusy] = useState<'push' | 'clear' | null>(null);
|
||||
@@ -1621,6 +1623,34 @@ function SettingsSyncSection() {
|
||||
}
|
||||
};
|
||||
|
||||
const syncButtons = (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
|
||||
onClick={() => run('push')}
|
||||
>
|
||||
<Text size="B300">Push now</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
|
||||
onClick={() => run('clear')}
|
||||
>
|
||||
<Text size="B300">Clear synced copy</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Sync</Text>
|
||||
@@ -1636,34 +1666,12 @@ function SettingsSyncSection() {
|
||||
<SettingTile
|
||||
title="Use This Device's Settings Everywhere"
|
||||
description="Push this device's current preferences so every other device adopts them, even ones that changed something more recently."
|
||||
after={
|
||||
<Box gap="200">
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
outlined
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'push' ? <Spinner size="100" variant="Secondary" /> : undefined}
|
||||
onClick={() => run('push')}
|
||||
>
|
||||
<Text size="B300">Push now</Text>
|
||||
</Button>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={busy !== null}
|
||||
before={busy === 'clear' ? <Spinner size="100" variant="Critical" /> : undefined}
|
||||
onClick={() => run('clear')}
|
||||
>
|
||||
<Text size="B300">Clear synced copy</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
// [Gitea #273] On phones the two buttons squeezed the text into a
|
||||
// narrow column; put them under the description there.
|
||||
after={isMobile ? undefined : syncButtons}
|
||||
>
|
||||
{isMobile && <Box style={{ paddingTop: config.space.S100 }}>{syncButtons}</Box>}
|
||||
</SettingTile>
|
||||
{note && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1800,7 +1808,8 @@ function Calls() {
|
||||
title="Noise Suppression"
|
||||
description={
|
||||
<Box direction="Column" gap="200">
|
||||
<Text>
|
||||
{/* [Gitea #271] T200 like every other setting description. */}
|
||||
<Text size="T200">
|
||||
Filter background noise from your mic during calls. Browser-native uses the built-in
|
||||
WebRTC suppressor (Google NSNet2). ML runs a dedicated model for stronger removal.
|
||||
</Text>
|
||||
@@ -2033,7 +2042,7 @@ function Calls() {
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
@@ -2089,7 +2098,12 @@ function Calls() {
|
||||
description={
|
||||
deafenBind.error ??
|
||||
(deafenHotkey
|
||||
? `Press ${keyLabel(deafenKey)} during a call to mute your speakers. Press Escape to cancel rebind.${
|
||||
? `${
|
||||
// [Gitea #279] The Escape hint only applies while rebinding.
|
||||
deafenBind.listening
|
||||
? 'Press a key to bind it, or Escape to cancel.'
|
||||
: `Press ${keyLabel(deafenKey)} during a call to mute your speakers.`
|
||||
}${
|
||||
!isSafeGlobalToggleKey(deafenKey)
|
||||
? ` A letter, digit or Space only works in the call view — on a chat screen typing takes priority${
|
||||
isTauriEnv() && globalCallHotkeys ? ' and other apps are never watched' : ''
|
||||
@@ -2130,7 +2144,7 @@ function Calls() {
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
@@ -2264,7 +2278,11 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SequenceCard
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<SettingTile
|
||||
title="Soundboard"
|
||||
description="Show a soundboard button in the call bar. Upload short audio clips (like custom emojis) to play them into the call. Clips sync across your devices."
|
||||
@@ -2751,7 +2769,11 @@ function Messages() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SequenceCard
|
||||
className={`${SequenceCardStyle} ${SettingTileGroup}`}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
>
|
||||
<SettingTile
|
||||
title="Translate Messages Into"
|
||||
description={
|
||||
|
||||
@@ -1,6 +1,18 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { config } from 'folds';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config } from 'folds';
|
||||
|
||||
export const SequenceCardStyle = style({
|
||||
padding: config.space.S300,
|
||||
});
|
||||
|
||||
/**
|
||||
* [Gitea #273] Several settings share one card; without a separator the next
|
||||
* tile's title read like part of the previous description. A thin line goes
|
||||
* between tiles only (not between a tile and its own slider or note).
|
||||
*/
|
||||
export const SettingTileGroup = style({});
|
||||
// globalStyle: vanilla-extract's `selectors` may only target the element itself.
|
||||
globalStyle(`${SettingTileGroup} > [data-setting-tile] ~ [data-setting-tile]`, {
|
||||
borderTop: `1px solid color-mix(in srgb, ${color.SurfaceVariant.OnContainer} 14%, transparent)`,
|
||||
paddingTop: config.space.S400,
|
||||
});
|
||||
|
||||
@@ -65,12 +65,12 @@ export function SyncStatus({ mx }: SyncStatusProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(stateData.current === SyncState.Prepared ||
|
||||
stateData.current === SyncState.Syncing ||
|
||||
stateData.current === SyncState.Catchup) &&
|
||||
stateData.previous !== SyncState.Syncing
|
||||
) {
|
||||
// [Gitea #274] Only while catching up after a drop. Upstream also showed it
|
||||
// for PREPARED and for the first SYNCING after PREPARED/RECONNECTING, i.e.
|
||||
// until the NEXT /sync returned: on a quiet account that long-polls ~30 s
|
||||
// while the app already works (a SYNCING means a sync just succeeded).
|
||||
// A real failure still surfaces as Reconnecting / Connection lost.
|
||||
if (stateData.current === SyncState.Catchup) {
|
||||
return (
|
||||
<Box direction="Column" shrink="No">
|
||||
<Box
|
||||
|
||||
@@ -57,6 +57,15 @@ export function parseForkHotkeyReport(data: unknown): ForkHotkeyReport | null {
|
||||
|
||||
export const CALL_ERROR_CHANGED = 'call_error_changed';
|
||||
|
||||
export const SCREENSHARE_AUDIO_STATE = 'screenshare_audio_state';
|
||||
|
||||
/** Pure: parse the fork's `io.lotus.screenshare_audio_state` (#277). */
|
||||
export function parseScreenshareAudioState(data: unknown): boolean | undefined {
|
||||
if (typeof data !== 'object' || data === null) return undefined;
|
||||
const { present } = data as { present?: unknown };
|
||||
return typeof present === 'boolean' ? present : undefined;
|
||||
}
|
||||
|
||||
export type AudioOutputOption = { id: string; label: string };
|
||||
export const AUDIO_OUTPUT_STATE = 'audio_output_state';
|
||||
|
||||
@@ -281,6 +290,22 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
this.emit(AUDIO_OUTPUT_STATE);
|
||||
}
|
||||
|
||||
// [Gitea #277] Whether a remote screenshare's audio is being received (the
|
||||
// fork's `io.lotus.screenshare_audio_state`), so the call bar only offers
|
||||
// "Mute Screenshare Audio" when there is something to mute.
|
||||
private _remoteScreenshareAudio = false;
|
||||
|
||||
public get remoteScreenshareAudio(): boolean {
|
||||
return this._remoteScreenshareAudio;
|
||||
}
|
||||
|
||||
public onScreenshareAudioState(data: unknown) {
|
||||
const present = parseScreenshareAudioState(data);
|
||||
if (present === undefined || present === this._remoteScreenshareAudio) return;
|
||||
this._remoteScreenshareAudio = present;
|
||||
this.emit(SCREENSHARE_AUDIO_STATE);
|
||||
}
|
||||
|
||||
private sendAudioOutput(deviceId: string): void {
|
||||
if (!this.joined) return;
|
||||
this.call.transport.send('io.lotus.set_audio_output', { deviceId }).catch(() => undefined);
|
||||
|
||||
@@ -465,6 +465,12 @@ export class CallEmbed {
|
||||
this.control.onAudioOutputState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||
}),
|
||||
);
|
||||
// [Gitea #277] Remote screenshare audio being received, or not.
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.screenshare_audio_state', (evt) => {
|
||||
this.control.onScreenshareAudioState((evt.detail as { data?: unknown } | undefined)?.data);
|
||||
}),
|
||||
);
|
||||
// [Gitea #43] PTT / deafen keys pressed while focus is inside the frame.
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.hotkey', (evt) => {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseScreenshareAudioState } from './CallControl';
|
||||
|
||||
test('parseScreenshareAudioState: reads present, ignores anything else (#277)', () => {
|
||||
assert.equal(parseScreenshareAudioState({ present: true }), true);
|
||||
assert.equal(parseScreenshareAudioState({ present: false }), false);
|
||||
assert.equal(parseScreenshareAudioState({ present: 'yes' }), undefined);
|
||||
assert.equal(parseScreenshareAudioState({}), undefined);
|
||||
assert.equal(parseScreenshareAudioState(null), undefined);
|
||||
assert.equal(parseScreenshareAudioState('present'), undefined);
|
||||
});
|
||||
@@ -179,3 +179,15 @@ test('groupScheduledByRoom: groups and sorts by sendAt', () => {
|
||||
);
|
||||
assert.equal(g.get('!2')?.length, 1);
|
||||
});
|
||||
|
||||
test('[#275] the server limit maps to friendly text; other errors keep theirs', async () => {
|
||||
const { describeScheduleError, SCHEDULE_TOO_FAR_TEXT, MAX_SCHEDULE_AHEAD_MS } =
|
||||
await import('./scheduledMessages');
|
||||
assert.equal(MAX_SCHEDULE_AHEAD_MS, 86_400_000);
|
||||
const tooLarge = Object.assign(new Error('The requested delay (172800000ms) exceeds…'), {
|
||||
errcode: 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE',
|
||||
});
|
||||
assert.equal(describeScheduleError(tooLarge), SCHEDULE_TOO_FAR_TEXT);
|
||||
assert.equal(describeScheduleError(new Error('Boom')), 'Boom');
|
||||
assert.equal(describeScheduleError('nope'), 'Failed to schedule message.');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { IContent, MatrixClient, Method } from 'matrix-js-sdk';
|
||||
import type { ScheduledMessage } from '../state/scheduledMessages';
|
||||
|
||||
/**
|
||||
* [Gitea #275] Production Synapse caps delayed events at
|
||||
* `max_event_delay_duration: 24h`; a later time is rejected with
|
||||
* ORG.MATRIX.MSC4140_DELAY_TOO_LARGE. The dialog enforces the same limit and
|
||||
* the server's error maps to the same text, in case the limit changes.
|
||||
*/
|
||||
export const MAX_SCHEDULE_AHEAD_MS = 24 * 60 * 60 * 1000;
|
||||
export const SCHEDULE_TOO_FAR_TEXT = 'You can schedule up to 24 hours ahead.';
|
||||
|
||||
/** A user-facing sentence for a failed schedule. */
|
||||
export const describeScheduleError = (err: unknown): string => {
|
||||
const errcode = (err as { errcode?: unknown } | null)?.errcode;
|
||||
if (errcode === 'ORG.MATRIX.MSC4140_DELAY_TOO_LARGE') return SCHEDULE_TOO_FAR_TEXT;
|
||||
return err instanceof Error ? err.message : 'Failed to schedule message.';
|
||||
};
|
||||
|
||||
/**
|
||||
* Schedule a message via MSC4140 (delayed messages).
|
||||
* Synapse implementation: pass org.matrix.msc4140.delay on the standard
|
||||
|
||||
@@ -109,3 +109,10 @@ describe('stripTrackingParamsInHtml', () => {
|
||||
assert.equal(stripTrackingParamsInHtml(html), html);
|
||||
});
|
||||
});
|
||||
|
||||
it('[#280] Reddit keeps context= (it shows the parent comments) but drops share_id', () => {
|
||||
assert.equal(
|
||||
stripTrackingParams('https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3&share_id=1'),
|
||||
'https://www.reddit.com/r/x/comments/abc/def/xyz/?context=3',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -103,7 +103,9 @@ const HOST_PARAMS: Array<{ hosts: string[]; params: string[]; prefixes?: string[
|
||||
},
|
||||
{
|
||||
hosts: ['reddit.com'],
|
||||
params: ['share_id', 'rdt', 'context'],
|
||||
// [Gitea #280] Not `context`: on a comment permalink, ?context=N shows the
|
||||
// N parent comments, i.e. the conversation the sender meant to share.
|
||||
params: ['share_id', 'rdt'],
|
||||
},
|
||||
{
|
||||
hosts: ['tiktok.com'],
|
||||
|
||||
Vendored
+4
@@ -1,5 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// [Gitea #276] Set by vite.config.js `define`.
|
||||
declare const __LOTUS_BUILD_COMMIT__: string;
|
||||
declare const __LOTUS_BUILD_DATE__: string;
|
||||
|
||||
declare module 'browser-encrypt-attachment' {
|
||||
export interface EncryptedAttachmentInfo {
|
||||
v: string;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import fs from 'fs';
|
||||
import { execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
import buildConfig from './build.config';
|
||||
|
||||
@@ -272,8 +273,26 @@ const vendorChunks = (id) => {
|
||||
if (id.includes('node_modules/chroma-js')) return 'chroma';
|
||||
};
|
||||
|
||||
// [Gitea #276] Which build is this? The short commit and build time are shown
|
||||
// in Settings → About (web deploys change many times a day while the package
|
||||
// version stays the same). LOTUS_BUILD_COMMIT overrides; no git → "unknown".
|
||||
const buildCommit = (() => {
|
||||
if (process.env.LOTUS_BUILD_COMMIT) return process.env.LOTUS_BUILD_COMMIT.slice(0, 12);
|
||||
try {
|
||||
return execSync('git rev-parse --short=8 HEAD', { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
|
||||
export default defineConfig({
|
||||
appType: 'spa',
|
||||
define: {
|
||||
__LOTUS_BUILD_COMMIT__: JSON.stringify(buildCommit),
|
||||
__LOTUS_BUILD_DATE__: JSON.stringify(new Date().toISOString()),
|
||||
},
|
||||
publicDir: false,
|
||||
base: buildConfig.base,
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user