Merge remote-tracking branch 'origin/lotus' into fix/call-disconnected-bar
# Conflicts: # src/app/components/CallEmbedProvider.tsx
This commit is contained in:
@@ -73,7 +73,7 @@ import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { useCallPreferences } from '../state/hooks/callPreferences';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberName } from '../utils/room';
|
||||
import { getStateEvent, getMemberName, isFromFilteredTimelineSet } from '../utils/room';
|
||||
import { declinedRing, hasActiveCallMembership, isRingableRoom } from '../utils/callRing';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
@@ -87,9 +87,12 @@ import { webRTCSupported } from '../utils/rtc';
|
||||
import { CallsUnavailableMessage } from '../features/call/CallsUnavailable';
|
||||
import { zIndices } from '../styles/zIndex';
|
||||
import { useReconnectWhenSynced } from '../hooks/useCallError';
|
||||
import { useDenoiseNotices } from '../hooks/useDenoiseNotices';
|
||||
|
||||
const PIP_MIN_W = 200;
|
||||
const PIP_MIN_H = 112;
|
||||
/** Default PiP top: just below the 64px-ish room header. */
|
||||
const PIP_DEFAULT_TOP = 72;
|
||||
|
||||
type Corner = 'se' | 'sw' | 'ne' | 'nw';
|
||||
|
||||
@@ -112,6 +115,8 @@ type IncomingCallInfo = {
|
||||
intent?: string;
|
||||
notificationType: RTCNotificationType;
|
||||
refEventId: string;
|
||||
/** [Gitea #313] The rtc.notification event itself: what a decline must reference (MSC4310). */
|
||||
notificationEventId?: string;
|
||||
};
|
||||
type IncomingCallProps = {
|
||||
dm: boolean;
|
||||
@@ -284,7 +289,9 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
fill="Soft"
|
||||
size="400"
|
||||
radii="400"
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
onClick={() =>
|
||||
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||
}
|
||||
before={<Icon size="200" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B400">
|
||||
@@ -456,7 +463,9 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
size="300"
|
||||
radii="300"
|
||||
outlined
|
||||
onClick={() => (dm ? onReject(room, info.refEventId) : onIgnore())}
|
||||
onClick={() =>
|
||||
dm ? onReject(room, info.notificationEventId ?? info.refEventId) : onIgnore()
|
||||
}
|
||||
before={<Icon size="100" src={Icons.Cross} filled />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
@@ -497,6 +506,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleTimelineEvent: EventTimelineSetHandlerMap[RoomEvent.Timeline] = useCallback(
|
||||
async (event, room, toStartOfTimeline, removed, data) => {
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
// only process rtc notification reference events.
|
||||
// we do not want to wait to decrypt all events.
|
||||
if (event.getRelation()?.rel_type !== RelationType.Reference) return;
|
||||
@@ -597,6 +607,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
: undefined,
|
||||
notificationType,
|
||||
refEventId,
|
||||
notificationEventId: event.getId(),
|
||||
};
|
||||
|
||||
// [Gitea #312] Don't ring for a call that is already over or handled: after
|
||||
@@ -605,7 +616,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
// 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)) {
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) {
|
||||
@@ -614,7 +628,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
}
|
||||
setTimeout(() => {
|
||||
if (Date.now() >= senderTs + lifetime) return;
|
||||
if (declinedRing(room, refEventId, myUserId) || hasActiveCallMembership(room, myUserId)) {
|
||||
if (
|
||||
declinedRing(room, [refEventId, event.getId()], myUserId) ||
|
||||
hasActiveCallMembership(room, myUserId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (hasActiveCallMembership(room, sender)) setCallInfo(info);
|
||||
@@ -684,9 +701,13 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
data,
|
||||
) => {
|
||||
if (eventRoom?.roomId !== room.roomId || !data.liveEvent) return;
|
||||
if (isFromFilteredTimelineSet(data)) return; // [Gitea #259]
|
||||
if (event.getType() === EventType.RTCDecline && event.getSender() === myUserId) {
|
||||
const related = event.getRelation()?.event_id;
|
||||
if (!related || related === refEventId) dismiss(); // declined elsewhere
|
||||
// [Gitea #313] Correct clients reference the notification; older Lotus
|
||||
// builds referenced the call membership. Accept both.
|
||||
if (!related || related === refEventId || related === callInfo.notificationEventId)
|
||||
dismiss(); // declined elsewhere
|
||||
}
|
||||
};
|
||||
session.on(MatrixRTCSessionEvent.MembershipsChanged, checkMemberships);
|
||||
@@ -795,6 +816,7 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
|
||||
useCallEndedToast(embed);
|
||||
useScreenshareNotices(embed);
|
||||
useReconnectWhenSynced(embed.control);
|
||||
useDenoiseNotices(embed);
|
||||
// [Gitea #43] The in-frame screenshare button follows the same room policy
|
||||
// as the host bar's (hidden where the server would refuse the share).
|
||||
const { allowScreenshare } = useRoomCallPolicy(embed.room);
|
||||
@@ -1048,8 +1070,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
el.style.left = `${Math.max(0, Math.min(savedPos.left, window.innerWidth - 280))}px`;
|
||||
el.style.top = `${Math.max(0, Math.min(savedPos.top, window.innerHeight - 158))}px`;
|
||||
} else {
|
||||
el.style.left = `${window.innerWidth - 280 - 16}px`;
|
||||
el.style.top = `${window.innerHeight - 158 - 72}px`;
|
||||
// [Gitea #323] First use: top-right under the room header, over the
|
||||
// timeline. Bottom-right sat on the composer (Send / Emoji, and on
|
||||
// phones the whole message box), so typing landed on the call.
|
||||
el.style.left = `${Math.max(0, window.innerWidth - 280 - 16)}px`;
|
||||
el.style.top = `${PIP_DEFAULT_TOP}px`;
|
||||
}
|
||||
el.style.width = '280px';
|
||||
el.style.height = '158px';
|
||||
|
||||
@@ -26,6 +26,10 @@ import { useModalStyle } from '../hooks/useModalStyle';
|
||||
export function CloseBehaviorPrompt() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [launchOnLogin, setLaunchOnLogin] = useState(false);
|
||||
// [cinny-desktop #37] Stock GNOME has no tray host: "keep running in the
|
||||
// tray" would hide the window with no icon to bring it back. Older desktop
|
||||
// builds lack the command; assume a tray then (the old behaviour).
|
||||
const [hasTray, setHasTray] = useState(true);
|
||||
const modalStyle = useModalStyle(420);
|
||||
|
||||
// Tells the native side we're listening (see get_close_behavior).
|
||||
@@ -34,7 +38,17 @@ export function CloseBehaviorPrompt() {
|
||||
tauriInvoke()?.('get_close_behavior').catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
useTauriEvent('lotus-close-requested', () => setOpen(true));
|
||||
useTauriEvent('lotus-close-requested', () => {
|
||||
const ask = tauriInvoke()?.('has_tray_host');
|
||||
if (!ask) {
|
||||
setOpen(true);
|
||||
return;
|
||||
}
|
||||
ask
|
||||
.then((v) => setHasTray(v !== false))
|
||||
.catch(() => setHasTray(true))
|
||||
.finally(() => setOpen(true));
|
||||
});
|
||||
|
||||
const choose = useCallback(
|
||||
(value: 'tray' | 'quit') => {
|
||||
@@ -51,7 +65,7 @@ export function CloseBehaviorPrompt() {
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#close-behavior-keep',
|
||||
initialFocus: hasTray ? '#close-behavior-keep' : '#close-behavior-quit',
|
||||
// Dismissing without choosing keeps the window open; the next close
|
||||
// asks again.
|
||||
onDeactivate: () => setOpen(false),
|
||||
@@ -80,8 +94,9 @@ export function CloseBehaviorPrompt() {
|
||||
</Header>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
|
||||
<Text priority="400" id="close-behavior-body">
|
||||
Lotus Chat can keep running in the system tray when you close this window, so
|
||||
messages, notifications and calls still reach you. Open it again from the tray icon.
|
||||
{hasTray
|
||||
? 'Lotus Chat can keep running in the system tray when you close this window, so messages, notifications and calls still reach you. Open it again from the tray icon.'
|
||||
: "Your desktop doesn't show tray icons, so if Lotus Chat keeps running after you close this window, the only way back is to launch it again."}
|
||||
</Text>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Checkbox
|
||||
@@ -95,12 +110,43 @@ export function CloseBehaviorPrompt() {
|
||||
<Text size="T300">Start Lotus Chat when I sign in to my computer</Text>
|
||||
</Box>
|
||||
<Box direction="Column" gap="200">
|
||||
<Button id="close-behavior-keep" variant="Primary" onClick={() => choose('tray')}>
|
||||
<Text size="B400">Keep running in the tray</Text>
|
||||
</Button>
|
||||
<Button variant="Secondary" fill="Soft" onClick={() => choose('quit')}>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
{hasTray ? (
|
||||
<>
|
||||
<Button
|
||||
id="close-behavior-keep"
|
||||
variant="Primary"
|
||||
onClick={() => choose('tray')}
|
||||
>
|
||||
<Text size="B400">Keep running in the tray</Text>
|
||||
</Button>
|
||||
<Button
|
||||
id="close-behavior-quit"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={() => choose('quit')}
|
||||
>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
id="close-behavior-quit"
|
||||
variant="Primary"
|
||||
onClick={() => choose('quit')}
|
||||
>
|
||||
<Text size="B400">Quit when I close the window</Text>
|
||||
</Button>
|
||||
<Button
|
||||
id="close-behavior-keep"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
onClick={() => choose('tray')}
|
||||
>
|
||||
<Text size="B400">Keep running in the background</Text>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<Text size="T200" priority="300">
|
||||
You can change this in Settings → General.
|
||||
|
||||
@@ -44,6 +44,9 @@ export function ConsentRequiredPrompt() {
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [blockedCount, setBlockedCount] = useState(0);
|
||||
const snoozedUntil = useRef(0);
|
||||
// [Gitea #311] Closing via Retry also deactivates the focus trap; that must
|
||||
// not snooze, or a retry that fails again (terms not accepted yet) is silent.
|
||||
const closingForRetry = useRef(false);
|
||||
const blocked = useRef(new Map<MatrixEvent, Room>());
|
||||
const modalStyle = useModalStyle(440);
|
||||
const server = mx.getDomain() ?? 'Your homeserver';
|
||||
@@ -74,6 +77,8 @@ export function ConsentRequiredPrompt() {
|
||||
}, [consentUri]);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
closingForRetry.current = true;
|
||||
snoozedUntil.current = 0;
|
||||
setOpen(false);
|
||||
setOpened(false);
|
||||
const pending = Array.from(blocked.current.entries());
|
||||
@@ -97,7 +102,13 @@ export function ConsentRequiredPrompt() {
|
||||
focusTrapOptions={{
|
||||
initialFocus: '#consent-review',
|
||||
fallbackFocus: '#consent-later',
|
||||
onDeactivate: later,
|
||||
onDeactivate: () => {
|
||||
if (closingForRetry.current) {
|
||||
closingForRetry.current = false;
|
||||
return;
|
||||
}
|
||||
later();
|
||||
},
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: true,
|
||||
}}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
import React, { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
import { Dialog, Header, config, Box, Text, Button, Spinner, color } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback';
|
||||
@@ -9,6 +9,8 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../hooks/useModalStyle';
|
||||
import { useCrossSigningActive } from '../hooks/useCrossSigning';
|
||||
import { InfoCard } from './info-card';
|
||||
import { ScheduledMessage } from '../state/scheduledMessages';
|
||||
import { cancelScheduledMessage, fetchScheduledMessages } from '../utils/scheduledMessages';
|
||||
import {
|
||||
useDeviceVerificationStatus,
|
||||
VerificationStatus,
|
||||
@@ -36,17 +38,36 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
|
||||
const [callEmbed, setCallEmbed] = useAtom(callEmbedAtom);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, []>(
|
||||
useCallback(async () => {
|
||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||
// everyone else sees you "in call" until it times out.
|
||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||
await hangupCallAndWait(mx, callEmbed);
|
||||
setCallEmbed(undefined);
|
||||
}
|
||||
await logoutClient(mx);
|
||||
}, [mx, callEmbed, setCallEmbed]),
|
||||
// [Gitea #322] Scheduled messages live on the server and still send after
|
||||
// logout, where no client would show them. Say so, and offer to cancel.
|
||||
const [scheduled, setScheduled] = useState<ScheduledMessage[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
fetchScheduledMessages(mx)
|
||||
.then((list) => alive && setScheduled(list))
|
||||
.catch(() => undefined);
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [mx]);
|
||||
|
||||
const [logoutState, logout] = useAsyncCallback<void, Error, [boolean?]>(
|
||||
useCallback(
|
||||
async (cancelScheduled?: boolean) => {
|
||||
if (cancelScheduled) {
|
||||
await Promise.all(scheduled.map((m) => cancelScheduledMessage(mx, m.delayId)));
|
||||
}
|
||||
// [Gitea #29] Logging out mid-call must hang up first, or the MatrixRTC
|
||||
// membership (expires: 4 h) stays behind as a ghost participant and
|
||||
// everyone else sees you "in call" until it times out.
|
||||
if (callEmbed && callEmbed.joined && !callEmbed.disposed) {
|
||||
await hangupCallAndWait(mx, callEmbed);
|
||||
setCallEmbed(undefined);
|
||||
}
|
||||
await logoutClient(mx);
|
||||
},
|
||||
[mx, callEmbed, setCallEmbed, scheduled],
|
||||
),
|
||||
);
|
||||
|
||||
const ongoingLogout = logoutState.status === AsyncStatus.Loading;
|
||||
@@ -93,6 +114,13 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
description="Enable device verification or export your encrypted data from settings to avoid losing access to your messages."
|
||||
/>
|
||||
))}
|
||||
{scheduled.length > 0 && (
|
||||
<InfoCard
|
||||
variant="Warning"
|
||||
title={`${scheduled.length} scheduled message${scheduled.length === 1 ? '' : 's'}`}
|
||||
description="Scheduled messages still send after you log out. Cancel them now if you don't want them sent."
|
||||
/>
|
||||
)}
|
||||
<Text priority="400">You’re about to log out. Are you sure?</Text>
|
||||
{logoutState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
@@ -102,12 +130,24 @@ export const LogoutDialog = forwardRef<HTMLDivElement, LogoutDialogProps>(
|
||||
<Box direction="Column" gap="200">
|
||||
<Button
|
||||
variant="Critical"
|
||||
onClick={logout}
|
||||
onClick={() => logout()}
|
||||
disabled={ongoingLogout}
|
||||
before={ongoingLogout && <Spinner variant="Critical" fill="Solid" size="200" />}
|
||||
>
|
||||
<Text size="B400">Logout</Text>
|
||||
<Text size="B400">
|
||||
{scheduled.length > 0 ? 'Logout and keep scheduled messages' : 'Logout'}
|
||||
</Text>
|
||||
</Button>
|
||||
{scheduled.length > 0 && (
|
||||
<Button
|
||||
variant="Critical"
|
||||
fill="Soft"
|
||||
onClick={() => logout(true)}
|
||||
disabled={ongoingLogout}
|
||||
>
|
||||
<Text size="B400">Cancel scheduled messages and logout</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="Secondary" fill="Soft" onClick={handleClose} disabled={ongoingLogout}>
|
||||
<Text size="B400">Cancel</Text>
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
import { useEmojiGroupIcons } from './useEmojiGroupIcons';
|
||||
import { preventScrollWithArrowKey, stopPropagation } from '../../utils/keyboard';
|
||||
import { preventScrollWithArrowKey } from '../../utils/keyboard';
|
||||
import { useRelevantImagePacks } from '../../hooks/useImagePacks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRecentEmoji } from '../../hooks/useRecentEmoji';
|
||||
@@ -612,7 +612,13 @@ export function EmojiBoard({
|
||||
!editableActiveElement() && isKeyHotkey(['arrowdown', 'arrowright'], evt),
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
!editableActiveElement() && isKeyHotkey(['arrowup', 'arrowleft'], evt),
|
||||
escapeDeactivates: stopPropagation,
|
||||
// [Gitea #304] The board opens with focus in its search field, where
|
||||
// the shared stopPropagation helper ignores Escape. Always close here,
|
||||
// without letting the key also close the room/modal underneath.
|
||||
escapeDeactivates: (evt: KeyboardEvent) => {
|
||||
evt.stopPropagation();
|
||||
return true;
|
||||
},
|
||||
}}
|
||||
>
|
||||
<EmojiBoardLayout
|
||||
|
||||
@@ -16,7 +16,7 @@ import { SettingTile } from '../setting-tile';
|
||||
import { UsageSwitcher } from './UsageSwitcher';
|
||||
import { ImagePackProfile, ImagePackProfileEdit } from './PackMeta';
|
||||
import * as css from './style.css';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
import { UploadSuccess } from '../../state/upload';
|
||||
import { getImageInfo, TUploadContent } from '../../utils/matrix';
|
||||
@@ -60,7 +60,7 @@ export const ImagePackContent = as<'div', ImagePackContentProps>(
|
||||
[imagePack, savedImages, uploadedImages],
|
||||
);
|
||||
|
||||
const pickFiles = useFilePicker(
|
||||
const pickFiles = useStrippedFilePicker(
|
||||
useCallback(
|
||||
(pickedFiles: File[]) => {
|
||||
const uniqueFiles = pickedFiles.map((file) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { nameInitials } from '../../utils/common';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { LINKIFY_OPTS } from '../../plugins/react-custom-html-parser';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { useFilePicker } from '../../hooks/useFilePicker';
|
||||
import { useStrippedFilePicker } from '../../hooks/useStrippedFilePicker';
|
||||
import { useObjectURL } from '../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../upload-card';
|
||||
@@ -111,7 +111,7 @@ export function ImagePackProfileEdit({ meta, onCancel, onSave }: ImagePackProfil
|
||||
return undefined;
|
||||
}, [imageFile]);
|
||||
|
||||
const pickFile = useFilePicker(setImageFile, false);
|
||||
const pickFile = useStrippedFilePicker(setImageFile, false);
|
||||
|
||||
const handleRemoveUpload = useCallback(() => {
|
||||
setImageFile(undefined);
|
||||
|
||||
@@ -51,9 +51,10 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { BreakWord } from '../../styles/Text.css';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
import { getMatrixToRoom } from '../../plugins/matrix-to';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useLotusShareBase } from '../../hooks/useLotusLinkBase';
|
||||
import { getLotusRoomPermalink, getLotusSpacePermalink } from '../../plugins/lotus-permalink';
|
||||
|
||||
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
|
||||
limit: 1000,
|
||||
@@ -75,10 +76,14 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
|
||||
const [linkCopied, setLinkCopied] = useState(false);
|
||||
const [showQr, setShowQr] = useState(false);
|
||||
|
||||
// [Gitea #285] A link into this deployment; matrix.to can't open Lotus Chat.
|
||||
const lotusBase = useLotusShareBase();
|
||||
const inviteUrl = (() => {
|
||||
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
|
||||
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
|
||||
return getMatrixToRoom(roomIdOrAlias, viaServers);
|
||||
return room.isSpaceRoom()
|
||||
? getLotusSpacePermalink(lotusBase, roomIdOrAlias, viaServers)
|
||||
: getLotusRoomPermalink(lotusBase, roomIdOrAlias, undefined, viaServers);
|
||||
})();
|
||||
|
||||
const handleCopyLink = () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { parseGeoUri, scaleYDimension } from '../../utils/common';
|
||||
import { formatAccuracy } from '../../utils/geoShare';
|
||||
import { Attachment, AttachmentBox, AttachmentContent, AttachmentHeader } from './attachment';
|
||||
import { FileHeader, FileDownloadButton } from './FileHeader';
|
||||
import { previewUrlsFor } from '../../utils/linkSafety';
|
||||
|
||||
// ≈ 20 lines; in rem so a larger font setting doesn't show fewer lines (Gitea #343).
|
||||
const COLLAPSE_MAX_HEIGHT_REM = 20;
|
||||
@@ -343,7 +344,13 @@ export function MText({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||
const urls = urlsMatch
|
||||
? previewUrlsFor(
|
||||
[...new Set(urlsMatch)],
|
||||
typeof customBody === 'string' ? customBody : undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -401,7 +408,13 @@ export function MEmote({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||
const urls = urlsMatch
|
||||
? previewUrlsFor(
|
||||
[...new Set(urlsMatch)],
|
||||
typeof customBody === 'string' ? customBody : undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -458,7 +471,13 @@ export function MNotice({
|
||||
if (typeof body !== 'string') return <BrokenContent />;
|
||||
const trimmedBody = trimReplyFromBody(body);
|
||||
const urlsMatch = renderUrlsPreview && trimmedBody.match(URL_REG);
|
||||
const urls = urlsMatch ? [...new Set(urlsMatch)] : undefined;
|
||||
// [Gitea #286] no genuine card for a site a deceptive link only pretends to be
|
||||
const urls = urlsMatch
|
||||
? previewUrlsFor(
|
||||
[...new Set(urlsMatch)],
|
||||
typeof customBody === 'string' ? customBody : undefined,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, MatrixEventEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMemberName, sendRoomEvent } from '../../../utils/room';
|
||||
import {
|
||||
@@ -99,7 +99,21 @@ export function PollContent({
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const roomId = room.roomId;
|
||||
const eventId = mEvent.getId();
|
||||
// [Gitea #284] A poll you just created starts with its local-echo id
|
||||
// (`~!room:txn`); the SDK swaps in the real `$event` id once it's sent but
|
||||
// doesn't re-render us. Track the id so votes and the tally use the real one.
|
||||
const [eventId, setEventId] = useState(() => mEvent.getId());
|
||||
useEffect(() => {
|
||||
const sync = () => setEventId(mEvent.getId());
|
||||
sync();
|
||||
mEvent.on(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||
mEvent.on(MatrixEventEvent.Status, sync);
|
||||
return () => {
|
||||
mEvent.off(MatrixEventEvent.LocalEventIdReplaced, sync);
|
||||
mEvent.off(MatrixEventEvent.Status, sync);
|
||||
};
|
||||
}, [mEvent]);
|
||||
const isLocalEcho = !!eventId?.startsWith('~');
|
||||
const senderId = mEvent.getSender();
|
||||
|
||||
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||
@@ -192,8 +206,9 @@ export function PollContent({
|
||||
const isMultiple = maxSelections > 1;
|
||||
|
||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||
const canVote = !!roomId && !!eventId && !isEnded;
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
const canVote = !!roomId && !!eventId && !isLocalEcho && !isEnded;
|
||||
const canEnd =
|
||||
!!eventId && !isLocalEcho && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total, voters } = tally;
|
||||
@@ -219,25 +234,34 @@ export function PollContent({
|
||||
setPending(next);
|
||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||
// accepts both namespaces).
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
// The SDK can throw synchronously (e.g. a relation to a pending event).
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.response', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
} catch {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndPoll = () => {
|
||||
if (!roomId || !eventId || ending) return;
|
||||
setEnding(true);
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
try {
|
||||
sendRoomEvent(mx, roomId, null, 'm.poll.end', {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
} catch {
|
||||
setEnding(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||
|
||||
@@ -43,7 +43,9 @@ export function SettingTile({ title, description, before, after, children }: Set
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text className={BreakWord} size="T200" priority="300">
|
||||
// [Gitea #291] A <div>, not a <p>: many tiles pass blocks (badges,
|
||||
// <Box>, <Text>) as the description, which is invalid inside a <p>.
|
||||
<Text as="div" className={BreakWord} size="T200" priority="300">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
@@ -310,9 +310,13 @@ export function UploadCardRenderer({
|
||||
const { file } = upload;
|
||||
const fileSizeExceeded = file.size >= allowSize;
|
||||
|
||||
if (upload.status === UploadStatus.Idle && !fileSizeExceeded) {
|
||||
startUpload();
|
||||
}
|
||||
// [Gitea #291] Start in an effect, not during render: the desktop taskbar
|
||||
// progress hook subscribes to the upload atoms, so a render-time start
|
||||
// updated another component mid-render (React warning on every image send).
|
||||
const idle = upload.status === UploadStatus.Idle;
|
||||
useEffect(() => {
|
||||
if (idle && !fileSizeExceeded) startUpload();
|
||||
}, [idle, fileSizeExceeded, startUpload]);
|
||||
|
||||
const handleSpoiler = (marked: boolean) => {
|
||||
setMetadata(fileItem, { ...metadata, markedAsSpoiler: marked });
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
GIF_PREVIEW_MAX_WIDTH,
|
||||
gifPreviewBox,
|
||||
} from '../../utils/gifPreviewSize';
|
||||
import { sameProvider } from './embedProvider';
|
||||
|
||||
const linkStyles = { color: color.Success.Main };
|
||||
|
||||
@@ -2497,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||
if (embed) return embed;
|
||||
const ogUrl = prev['og:url'];
|
||||
return typeof ogUrl === 'string' && ogUrl !== url
|
||||
// [Gitea #326] …but only from an og:url on the posted link's own
|
||||
// provider: the page sets og:url, so any site could otherwise show up
|
||||
// as a playable YouTube (etc.) card that links to itself.
|
||||
return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl)
|
||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sameProvider } from './embedProvider';
|
||||
|
||||
test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => {
|
||||
const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ';
|
||||
assert.equal(sameProvider('https://evil.example/free-gift', yt), false);
|
||||
assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false);
|
||||
assert.equal(sameProvider('https://youtu.be/abc', yt), true);
|
||||
assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true);
|
||||
assert.equal(
|
||||
sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'),
|
||||
true,
|
||||
);
|
||||
assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true);
|
||||
assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true);
|
||||
assert.equal(
|
||||
sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'),
|
||||
true,
|
||||
);
|
||||
assert.equal(sameProvider('not a url', yt), false);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* [Gitea #326] A page's og:url is chosen by the page itself, so a phishing
|
||||
* link could serve `og:url = youtube.com/watch?v=…` and render as a playable
|
||||
* YouTube card linking to the attacker. Only trust an og:url on the same
|
||||
* provider as the link actually posted (including the provider's own
|
||||
* short-link domains).
|
||||
*/
|
||||
const PROVIDER_ALIASES: Record<string, string> = {
|
||||
'youtu.be': 'youtube.com',
|
||||
'youtube-nocookie.com': 'youtube.com',
|
||||
'spotify.link': 'spotify.com',
|
||||
'redd.it': 'reddit.com',
|
||||
'x.com': 'twitter.com',
|
||||
'twitch.tv': 'twitch.tv',
|
||||
'apple.co': 'apple.com',
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure: the provider a host belongs to (last two labels, aliases folded). Hosts
|
||||
* under two-part public suffixes (`*.co.uk`) share a value, which is harmless
|
||||
* here: no embeddable provider lives under one.
|
||||
*/
|
||||
export const providerOf = (host: string): string => {
|
||||
const parts = host.toLowerCase().replace(/\.$/, '').split('.');
|
||||
const base = parts.slice(-2).join('.');
|
||||
return PROVIDER_ALIASES[base] ?? base;
|
||||
};
|
||||
|
||||
/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */
|
||||
export function sameProvider(url: string, ogUrl: string): boolean {
|
||||
try {
|
||||
return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user