mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(media-proxy): sign attachment URLs and gate origins (#2830)
This commit is contained in:
1 parent
025c01ab13
commit
522cf08e61
108 files changed
+10148
-441
No files matched your search
@@ -4,6 +4,7 @@ import {ME} from '@fluxer/constants/src/AppConstants';
|
||||
|
||||
export const Endpoints = {
|
||||
INSTANCE: '/instance',
|
||||
ATTACHMENTS_REFRESH_URLS: '/attachments/refresh-urls',
|
||||
AUTH_LOGIN: '/auth/login',
|
||||
AUTH_LOGIN_MFA_TOTP: '/auth/login/mfa/totp',
|
||||
AUTH_LOGIN_MFA_WEBAUTHN_OPTIONS: '/auth/login/mfa/webauthn/authentication-options',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {useAnimatedMediaVideoPlayback} from '@app/features/app/hooks/useAnimated
|
||||
import {useShouldAnimate} from '@app/features/app/hooks/useShouldAnimate';
|
||||
import styles from '@app/features/channel/components/AutocompleteEmoji.module.css';
|
||||
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import {MusicNoteIcon} from '@phosphor-icons/react';
|
||||
import {observer} from 'mobx-react-lite';
|
||||
@@ -32,6 +33,7 @@ export const AutocompleteMemePreview = observer(({meme}: {meme: FavoriteMeme}) =
|
||||
const isVideoMeme = contentType.startsWith('video/');
|
||||
const isAnimatedImageMeme = !isVideoMeme && !isAudioMeme && contentType.includes('gif');
|
||||
const motionAllowed = useShouldAnimate({kind: 'gif', isAnimated: isVideoMeme || isAnimatedImageMeme});
|
||||
const memeUrl = AttachmentUrlRefresher.fresh(meme.url, {refreshUnsigned: true});
|
||||
if (isAudioMeme) {
|
||||
return (
|
||||
<div className={styles.audioIconWrapper} data-flx="channel.autocomplete-meme-preview.audio-icon-wrapper">
|
||||
@@ -44,16 +46,14 @@ export const AutocompleteMemePreview = observer(({meme}: {meme: FavoriteMeme}) =
|
||||
);
|
||||
}
|
||||
if (isVideoMeme && motionAllowed) {
|
||||
return (
|
||||
<AutocompleteMemeVideo src={meme.url} data-flx="channel.autocomplete-meme-preview.autocomplete-meme-video" />
|
||||
);
|
||||
return <AutocompleteMemeVideo src={memeUrl} data-flx="channel.autocomplete-meme-preview.autocomplete-meme-video" />;
|
||||
}
|
||||
const needsStillFrame = (isVideoMeme || isAnimatedImageMeme) && !motionAllowed;
|
||||
return (
|
||||
<img
|
||||
draggable={false}
|
||||
className={styles.memeIcon}
|
||||
src={needsStillFrame ? buildStaticGifPreviewURL(meme.url) : meme.url}
|
||||
src={needsStillFrame ? buildStaticGifPreviewURL(memeUrl) : memeUrl}
|
||||
alt={meme.name}
|
||||
aria-hidden={true}
|
||||
data-flx="channel.autocomplete-meme-preview.meme-icon"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {splitMediaAndFileAttachments} from '@app/features/channel/components/MessageAttachmentUtils';
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {mapAttachmentsWithExpiry} from '@app/features/messaging/utils/AttachmentExpiryUtils';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import type {MessageAttachment} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
@@ -13,11 +14,25 @@ interface AttachmentRenderingState {
|
||||
shouldUseMosaic: boolean;
|
||||
}
|
||||
|
||||
function freshAttachmentUrl(url: string | null): string | null {
|
||||
return url === null ? null : AttachmentUrlRefresher.fresh(url);
|
||||
}
|
||||
|
||||
export function withFreshAttachmentUrls(attachment: MessageAttachment): MessageAttachment {
|
||||
const url = freshAttachmentUrl(attachment.url);
|
||||
const proxyUrl = freshAttachmentUrl(attachment.proxy_url);
|
||||
if (url === attachment.url && proxyUrl === attachment.proxy_url) return attachment;
|
||||
return {...attachment, url, proxy_url: proxyUrl};
|
||||
}
|
||||
|
||||
export const getAttachmentRenderingState = (
|
||||
snapshotAttachments?: ReadonlyArray<MessageAttachment> | null,
|
||||
): AttachmentRenderingState => {
|
||||
const attachments = snapshotAttachments ?? [];
|
||||
const expiryApplied = mapAttachmentsWithExpiry(attachments, DeveloperOptions.mockAttachmentStates);
|
||||
const expiryApplied = mapAttachmentsWithExpiry(attachments, DeveloperOptions.mockAttachmentStates).map((entry) => ({
|
||||
isExpired: entry.isExpired,
|
||||
attachment: withFreshAttachmentUrls(entry.attachment),
|
||||
}));
|
||||
const enrichedAttachments = expiryApplied.map((entry) => entry.attachment);
|
||||
const activeAttachments = expiryApplied.filter((entry) => !entry.isExpired).map((entry) => entry.attachment);
|
||||
const {mediaAttachments} = splitMediaAndFileAttachments(activeAttachments);
|
||||
|
||||
@@ -20,6 +20,7 @@ import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
|
||||
import {AUDIO_DESCRIPTOR, GIFS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useNearViewport} from '@app/features/messaging/hooks/useNearViewport';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import {DeleteIcon, EditIcon} from '@app/features/ui/action_menu/ContextMenuIcons';
|
||||
@@ -428,7 +429,8 @@ const GridItem = observer(
|
||||
const isAudio = contentType.startsWith('audio/');
|
||||
const isGifImage = !isVideo && contentType.toLowerCase().includes('gif');
|
||||
const shouldAnimateGif = useShouldAnimate({kind: 'gif', isAnimated: !isAudio && (isVideo || isGifImage)});
|
||||
const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(url) : url;
|
||||
const memeUrl = AttachmentUrlRefresher.fresh(url, {refreshUnsigned: true});
|
||||
const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(memeUrl) : memeUrl;
|
||||
const videoPlaybackAllowed = useAnimatedMediaVideoPlayback(videoRef, {
|
||||
enabled: isVisible && !isAudio && isVideo,
|
||||
shouldPlay: shouldAnimateGif,
|
||||
@@ -471,7 +473,7 @@ const GridItem = observer(
|
||||
disablePictureInPicture={true}
|
||||
disableRemotePlayback={true}
|
||||
preload={shouldAnimateGif ? 'auto' : 'metadata'}
|
||||
src={url}
|
||||
src={memeUrl}
|
||||
data-flx="channel.mobile-memes-picker.grid-item.gif"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,7 @@ import Channels from '@app/features/channel/state/Channels';
|
||||
import Guilds from '@app/features/guild/state/Guilds';
|
||||
import {SUPPRESS_EMBEDS_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import * as MessageCommands from '@app/features/messaging/commands/MessageCommands';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {getEmbedMediaDimensions} from '@app/features/messaging/utils/MediaDimensionConfig';
|
||||
import {buildAnimatedImageProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import {buildMessageEmbedCopyText} from '@app/features/messaging/utils/MessageCopyTextUtils';
|
||||
@@ -39,6 +40,7 @@ import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import MobileLayout from '@app/features/ui/state/MobileLayout';
|
||||
import {MessageAttachmentFlags, MessageEmbedTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildOperations} from '@fluxer/constants/src/GuildConstants';
|
||||
import type {EmbedMedia, MessageEmbed} from '@fluxer/schema/src/domains/message/EmbedSchemas';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
import {XIcon} from '@phosphor-icons/react';
|
||||
import {clsx} from 'clsx';
|
||||
@@ -47,7 +49,29 @@ import type React from 'react';
|
||||
import {type FC, useCallback, useMemo} from 'react';
|
||||
|
||||
const mediaFocusRingClass = messageStyles.mediaFocusRing;
|
||||
export const Embed: FC<EmbedProps> = observer(({embed, message, embedIndex, contextualEmbeds, onDelete, isPreview}) => {
|
||||
|
||||
function withFreshEmbedMediaUrls(media: EmbedMedia | undefined): EmbedMedia | undefined {
|
||||
if (!media) return media;
|
||||
const url = AttachmentUrlRefresher.fresh(media.url);
|
||||
const proxyUrl = media.proxy_url === undefined ? undefined : AttachmentUrlRefresher.fresh(media.proxy_url);
|
||||
if (url === media.url && proxyUrl === media.proxy_url) return media;
|
||||
return {...media, url, proxy_url: proxyUrl};
|
||||
}
|
||||
|
||||
function withFreshEmbedUrls(embed: MessageEmbed): MessageEmbed {
|
||||
const image = withFreshEmbedMediaUrls(embed.image);
|
||||
const thumbnail = withFreshEmbedMediaUrls(embed.thumbnail);
|
||||
const video = withFreshEmbedMediaUrls(embed.video);
|
||||
const audio = withFreshEmbedMediaUrls(embed.audio);
|
||||
if (image === embed.image && thumbnail === embed.thumbnail && video === embed.video && audio === embed.audio) {
|
||||
return embed;
|
||||
}
|
||||
return {...embed, image, thumbnail, video, audio};
|
||||
}
|
||||
|
||||
export const Embed: FC<EmbedProps> = observer((props: EmbedProps) => {
|
||||
const {message, embedIndex, contextualEmbeds, onDelete, isPreview} = props;
|
||||
const embed = withFreshEmbedUrls(props.embed);
|
||||
const {i18n} = useLingui();
|
||||
const {enabled: isMobile} = MobileLayout;
|
||||
const channel = Channels.getChannel(message.channelId);
|
||||
|
||||
+5
-57
@@ -9,6 +9,10 @@ import {CsvAttachmentTablePanel} from '@app/features/channel/components/embeds/a
|
||||
import {TextualAttachmentCodePanel} from '@app/features/channel/components/embeds/attachments/TextualAttachmentCodePanel';
|
||||
import styles from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreview.module.css';
|
||||
import {TextualAttachmentPreviewBottomSheet} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewBottomSheet';
|
||||
import {
|
||||
fetchTextualPreviewText,
|
||||
PreviewSizeLimitError,
|
||||
} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewFetch';
|
||||
import {TextualAttachmentPreviewFooter} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewFooter';
|
||||
import {TextualAttachmentPreviewModal} from '@app/features/channel/components/embeds/attachments/TextualAttachmentPreviewModal';
|
||||
import {
|
||||
@@ -45,56 +49,6 @@ import {type MouseEvent, useCallback, useEffect, useMemo, useState} from 'react'
|
||||
|
||||
const logger = new Logger('TextualAttachmentPreview');
|
||||
|
||||
class PreviewSizeLimitError extends Error {
|
||||
constructor() {
|
||||
super('Attachment preview exceeds the size limit');
|
||||
this.name = 'PreviewSizeLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
async function readPreviewText(response: Response): Promise<string> {
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength !== null) {
|
||||
const parsedContentLength = Number(contentLength);
|
||||
if (Number.isFinite(parsedContentLength) && parsedContentLength > TEXT_PREVIEW_MAX_BYTES) {
|
||||
throw new PreviewSizeLimitError();
|
||||
}
|
||||
}
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error('Attachment preview response has no readable body');
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > TEXT_PREVIEW_MAX_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new PreviewSizeLimitError();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
export const TextualAttachmentPreview = observer(function TextualAttachmentPreview({
|
||||
attachment,
|
||||
spoilerHidden = false,
|
||||
@@ -146,13 +100,7 @@ export const TextualAttachmentPreview = observer(function TextualAttachmentPrevi
|
||||
setStatus('loading');
|
||||
setPreviewError(null);
|
||||
const controller = new AbortController();
|
||||
fetch(attachment.url, {signal: controller.signal})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Attachment preview request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
return readPreviewText(response);
|
||||
})
|
||||
fetchTextualPreviewText(attachment.url, controller.signal)
|
||||
.then((value) => {
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {TEXT_PREVIEW_MAX_BYTES} from '@app/features/messaging/utils/AttachmentPreviewUtils';
|
||||
|
||||
const SIGNATURE_REFUSAL_STATUSES = new Set([401, 403, 404, 410]);
|
||||
|
||||
export class PreviewSizeLimitError extends Error {
|
||||
constructor() {
|
||||
super('Attachment preview exceeds the size limit');
|
||||
this.name = 'PreviewSizeLimitError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAttachmentSignatureRefusal(status: number): boolean {
|
||||
return SIGNATURE_REFUSAL_STATUSES.has(status);
|
||||
}
|
||||
|
||||
export async function readPreviewText(response: Response): Promise<string> {
|
||||
const contentLength = response.headers.get('content-length');
|
||||
if (contentLength !== null) {
|
||||
const parsedContentLength = Number(contentLength);
|
||||
if (Number.isFinite(parsedContentLength) && parsedContentLength > TEXT_PREVIEW_MAX_BYTES) {
|
||||
throw new PreviewSizeLimitError();
|
||||
}
|
||||
}
|
||||
const body = response.body;
|
||||
if (!body) {
|
||||
throw new Error('Attachment preview response has no readable body');
|
||||
}
|
||||
const reader = body.getReader();
|
||||
const chunks: Array<Uint8Array> = [];
|
||||
let totalBytes = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
if (!value) {
|
||||
continue;
|
||||
}
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > TEXT_PREVIEW_MAX_BYTES) {
|
||||
await reader.cancel().catch(() => undefined);
|
||||
throw new PreviewSizeLimitError();
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
const bytes = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
}
|
||||
|
||||
function requestFailed(response: Response): Error {
|
||||
return new Error(`Attachment preview request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
export async function fetchTextualPreviewText(url: string, signal: AbortSignal): Promise<string> {
|
||||
const target = await AttachmentUrlRefresher.refresh(url);
|
||||
const response = await fetch(target, {signal});
|
||||
if (response.ok) {
|
||||
return readPreviewText(response);
|
||||
}
|
||||
if (!isAttachmentSignatureRefusal(response.status)) {
|
||||
throw requestFailed(response);
|
||||
}
|
||||
const refreshed = await AttachmentUrlRefresher.refresh(url, {force: true});
|
||||
if (refreshed === target) {
|
||||
throw requestFailed(response);
|
||||
}
|
||||
const retried = await fetch(refreshed, {signal});
|
||||
if (!retried.ok) {
|
||||
throw requestFailed(retried);
|
||||
}
|
||||
return readPreviewText(retried);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {safePause, safePlay} from '@app/features/channel/components/GifVideoPool
|
||||
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
|
||||
import type {Channel} from '@app/features/channel/models/Channel';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
import {useMatureMedia} from '@app/features/messaging/hooks/useMatureMedia';
|
||||
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
|
||||
@@ -389,6 +390,7 @@ export const EmbedGifv: FC<
|
||||
const messageViewContext = useMaybeMessageViewContext();
|
||||
const mediaCalculator = useEmbedMediaCalculator();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const handleVideoError = useAttachmentRefreshOnError(videoProxyURL);
|
||||
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null);
|
||||
const {isPointerInside, hasFocusInside} = useMediaSurfaceInteraction(containerElement);
|
||||
const {shouldBlur, gateReason, canReveal, reveal: revealSensitiveMedia} = useMatureMedia(nsfw, channelId);
|
||||
@@ -632,6 +634,7 @@ export const EmbedGifv: FC<
|
||||
height={dimensions.height}
|
||||
tabIndex={-1}
|
||||
aria-label={i18n._(ANIMATED_GIF_VIDEO_DESCRIPTOR)}
|
||||
onError={handleVideoError}
|
||||
data-embed-media="gifv"
|
||||
data-flx="channel.embeds.media.embed-gifv.video-element"
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,7 @@ import styles from '@app/features/channel/components/embeds/media/VoiceMessagePl
|
||||
import {useMaybeMessageViewContext} from '@app/features/channel/components/MessageViewContext';
|
||||
import {PAUSE_DESCRIPTOR, PLAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {getCachedNumberFormat} from '@app/features/i18n/utils/IntlCache';
|
||||
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
|
||||
import {buildMediaProxyURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
@@ -119,6 +120,7 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
|
||||
const {i18n} = useLingui();
|
||||
const messageViewContext = useMaybeMessageViewContext();
|
||||
const effectiveSrc = buildMediaProxyURL(src);
|
||||
const handleMediaError = useAttachmentRefreshOnError(effectiveSrc);
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const [wantsMetadata, setWantsMetadata] = useState(false);
|
||||
const [prePlayCurrentTime, setPrePlayCurrentTime] = useState(0);
|
||||
@@ -324,6 +326,7 @@ const VoiceMessagePlayer: React.FC<VoiceMessagePlayerProps> = observer(
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={hasStarted || wantsMetadata ? effectiveSrc : undefined}
|
||||
preload={wantsMetadata ? 'metadata' : 'none'}
|
||||
onError={handleMediaError}
|
||||
data-flx="channel.embeds.media.voice-message-player.audio"
|
||||
>
|
||||
<track kind="captions" data-flx="channel.embeds.media.voice-message-player.track" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
|
||||
import {inferFormatContentType, PREVIEW_FORMAT_PRIORITY} from '@fluxer/schema/src/domains/gif/GifMediaFormatKeys';
|
||||
|
||||
export interface FavoriteGifMediaFormat {
|
||||
@@ -107,16 +108,36 @@ export function pickCanonicalPreviewFormat(
|
||||
|
||||
export const STORED_PREVIEW_DEVICE_PIXEL_RATIO = 2;
|
||||
|
||||
export function stripFavoriteGifEntrySignatures(entry: FavoriteGifEntry): FavoriteGifEntry {
|
||||
const url = stripAttachmentSignature(entry.url);
|
||||
const proxyUrl = stripAttachmentSignature(entry.proxy_url);
|
||||
const media: Record<string, FavoriteGifMediaFormat> = {};
|
||||
let mediaChanged = false;
|
||||
for (const [key, format] of Object.entries(entry.media)) {
|
||||
const src = stripAttachmentSignature(format.src);
|
||||
const proxySrc = stripAttachmentSignature(format.proxy_src);
|
||||
if (src === format.src && proxySrc === format.proxy_src) {
|
||||
media[key] = format;
|
||||
continue;
|
||||
}
|
||||
mediaChanged = true;
|
||||
media[key] = {...format, src, proxy_src: proxySrc};
|
||||
}
|
||||
if (url === entry.url && proxyUrl === entry.proxy_url && !mediaChanged) return entry;
|
||||
return {...entry, url, proxy_url: proxyUrl, media};
|
||||
}
|
||||
|
||||
export function slimFavoriteGifEntry(entry: FavoriteGifEntry): FavoriteGifEntry {
|
||||
const best = pickBestPreviewFormat(entry.media, 'any', {
|
||||
const stripped = stripFavoriteGifEntrySignatures(entry);
|
||||
const best = pickBestPreviewFormat(stripped.media, 'any', {
|
||||
cssWidth: PREVIEW_TILE_CSS_WIDTH,
|
||||
devicePixelRatio: STORED_PREVIEW_DEVICE_PIXEL_RATIO,
|
||||
});
|
||||
if (best == null) {
|
||||
return Object.keys(entry.media).length === 0 ? entry : {...entry, media: {}};
|
||||
return Object.keys(stripped.media).length === 0 ? stripped : {...stripped, media: {}};
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
...stripped,
|
||||
proxy_url: best.format.proxy_src,
|
||||
width: best.format.width,
|
||||
height: best.format.height,
|
||||
|
||||
@@ -16,6 +16,7 @@ import FavoriteGif from '@app/features/expressions/state/FavoriteGif';
|
||||
import FavoriteMemes from '@app/features/expressions/state/FavoriteMemes';
|
||||
import * as GifSlugUtils from '@app/features/expressions/utils/GifSlugUtils';
|
||||
import {FAVORITES_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import QuickSwitcher from '@app/features/search/state/QuickSwitcher';
|
||||
import {msg} from '@lingui/core/macro';
|
||||
import {useLingui} from '@lingui/react/macro';
|
||||
@@ -57,6 +58,7 @@ export const GifPickerGrid = observer(
|
||||
const favoriteGifs = FavoriteGif.favoriteGifs;
|
||||
const favoriteGifsVersion = favoriteGifs.length;
|
||||
const useSavedMediaForGifFavorites = FavoriteGif.saveGifFavoritesAsSavedMedia;
|
||||
const attachmentUrlRevision = AttachmentUrlRefresher.revision;
|
||||
const data: Array<GifPickerGridItemData> = useMemo(() => {
|
||||
return buildGifPickerGridData({
|
||||
surface: store.isShowingFavorites ? 'favorites' : store.isShowingFeatured ? 'featured' : 'results',
|
||||
@@ -88,6 +90,7 @@ export const GifPickerGrid = observer(
|
||||
selectGif,
|
||||
store.featuredFavoritePreviewSeed,
|
||||
i18n.locale,
|
||||
attachmentUrlRevision,
|
||||
]);
|
||||
const itemKeys = useMemo(() => data.filter((item) => item.type !== 'skeleton').map((item) => item.key), [data]);
|
||||
const itemByKey = useMemo(() => new Map(data.map((item) => [item.key, item])), [data]);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type {GifPickerGridItemData} from '@app/features/channel/components/pickers/gif/GifPickerTypes';
|
||||
import type {Gif, GifFeatured} from '@app/features/expressions/commands/GifCommands';
|
||||
import * as GifSlugUtils from '@app/features/expressions/utils/GifSlugUtils';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
|
||||
const CATEGORY_TILE_WIDTH = 200;
|
||||
const CATEGORY_TILE_HEIGHT = 96;
|
||||
@@ -34,6 +35,10 @@ export interface BuildGifPickerGridDataInput {
|
||||
trendingTitle: string;
|
||||
}
|
||||
|
||||
function freshStoredUrl(url: string): string {
|
||||
return AttachmentUrlRefresher.fresh(url, {refreshUnsigned: true});
|
||||
}
|
||||
|
||||
export function buildSkeletonGifPickerItems(count: number): Array<GifPickerGridItemData> {
|
||||
return Array.from({length: count}, (_, i) => ({
|
||||
type: 'skeleton',
|
||||
@@ -68,9 +73,9 @@ function buildFavoriteGifItems(
|
||||
for (let index = favoriteGifs.length - 1; index >= 0; index -= 1) {
|
||||
const entry = favoriteGifs[index];
|
||||
const best = pickBestPreviewFormat(entry.media);
|
||||
const fallbackSrc = GifSlugUtils.isUsableMediaSource(entry.proxy_url) ? entry.proxy_url : '';
|
||||
const previewSrc = best?.format.src ?? fallbackSrc;
|
||||
const previewProxySrc = best?.format.proxy_src ?? fallbackSrc;
|
||||
const fallbackSrc = GifSlugUtils.isUsableMediaSource(entry.proxy_url) ? freshStoredUrl(entry.proxy_url) : '';
|
||||
const previewSrc = best ? freshStoredUrl(best.format.src) : fallbackSrc;
|
||||
const previewProxySrc = best ? freshStoredUrl(best.format.proxy_src) : fallbackSrc;
|
||||
const previewWidth = best?.format.width ?? entry.width;
|
||||
const previewHeight = best?.format.height ?? entry.height;
|
||||
const previewContentType = best ? inferFormatContentType(best.key) : entry.content_type;
|
||||
@@ -107,14 +112,21 @@ function buildFeaturedItems(input: BuildGifPickerGridDataInput): Array<GifPicker
|
||||
const favoriteGifPreview = pickBestPreviewFormat(favoriteGifPreviewEntry?.media);
|
||||
const favoriteMemeCandidate =
|
||||
gifvMemes.length > 0 ? (gifvMemes[Math.floor(input.featuredFavoritePreviewSeed * gifvMemes.length)] ?? null) : null;
|
||||
const favoriteMemePreview = favoriteMemeCandidate?.url ?? '';
|
||||
const favoriteMemePreview = favoriteMemeCandidate ? freshStoredUrl(favoriteMemeCandidate.url) : '';
|
||||
const usesFavoriteMemePreview = input.useSavedMediaForGifFavorites && input.favoriteGifs.length === 0;
|
||||
const favoriteTilePreview = usesFavoriteMemePreview
|
||||
? favoriteMemePreview
|
||||
: favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
: freshStoredUrl(
|
||||
favoriteGifPreview?.format.src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '',
|
||||
);
|
||||
const favoriteTileProxyPreview = usesFavoriteMemePreview
|
||||
? favoriteTilePreview
|
||||
: favoriteGifPreview?.format.proxy_src || favoriteGifPreviewEntry?.proxy_url || favoriteGifPreviewEntry?.url || '';
|
||||
: freshStoredUrl(
|
||||
favoriteGifPreview?.format.proxy_src ||
|
||||
favoriteGifPreviewEntry?.proxy_url ||
|
||||
favoriteGifPreviewEntry?.url ||
|
||||
'',
|
||||
);
|
||||
const favoriteTileContentType = (() => {
|
||||
if (usesFavoriteMemePreview) return favoriteMemeCandidate?.contentType ?? '';
|
||||
if (favoriteGifPreview) return inferFormatContentType(favoriteGifPreview.key);
|
||||
|
||||
@@ -13,6 +13,7 @@ import * as FavoriteMemeCommands from '@app/features/expressions/commands/Favori
|
||||
import {EditFavoriteMemeModal} from '@app/features/expressions/components/modals/EditFavoriteMemeModal';
|
||||
import type {FavoriteMeme} from '@app/features/expressions/models/FavoriteMeme';
|
||||
import {isKeyboardActivationKey} from '@app/features/input/utils/KeyboardUtils';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {buildMediaProxyURL, buildStaticGifPreviewURL} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import {ComponentBus} from '@app/features/platform/utils/ComponentBus';
|
||||
import * as ModalCommands from '@app/features/ui/commands/ModalCommands';
|
||||
@@ -113,10 +114,14 @@ export const MemeGridItem = observer(
|
||||
isHovering: isVideoPreviewActive,
|
||||
});
|
||||
const shouldRenderVideoPreview = !isAudio && isVideo && isVideoPreviewActive && shouldAnimateGif;
|
||||
const thumbnailSrc = isGifImage && !shouldAnimateGif ? buildStaticGifPreviewURL(meme.url) : meme.url;
|
||||
const memeUrl = AttachmentUrlRefresher.fresh(meme.url, {refreshUnsigned: true});
|
||||
const thumbnailSrc =
|
||||
isGifImage && !shouldAnimateGif
|
||||
? AttachmentUrlRefresher.fresh(buildStaticGifPreviewURL(meme.url), {refreshUnsigned: true})
|
||||
: memeUrl;
|
||||
const videoPreviewStartTime = meme.duration && meme.duration > 0 ? meme.duration / 2 : null;
|
||||
usePooledVideo({
|
||||
src: shouldRenderVideoPreview ? meme.url : null,
|
||||
src: shouldRenderVideoPreview ? memeUrl : null,
|
||||
containerRef: videoContainerRef,
|
||||
videoPool,
|
||||
autoPlay: shouldRenderVideoPreview,
|
||||
@@ -219,7 +224,7 @@ export const MemeGridItem = observer(
|
||||
data-flx="channel.pickers.memes.meme-grid-item.div--2"
|
||||
>
|
||||
<VideoPoster
|
||||
src={meme.url}
|
||||
src={memeUrl}
|
||||
placeholder={meme.placeholder}
|
||||
data-flx="channel.pickers.memes.meme-grid-item.video-poster"
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
type FavoriteGifEntry,
|
||||
type FavoriteGifMediaFormat,
|
||||
slimFavoriteGifEntry,
|
||||
stripFavoriteGifEntrySignatures,
|
||||
} from '@app/features/channel/components/pickers/gif/FavoriteGifTypes';
|
||||
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
|
||||
import {makeSyncedField} from '@app/features/user/state/SyncedField';
|
||||
import {FAVORITE_GIF_MAX_ENCODED_BYTES} from '@app/features/user/state/SyncedFieldBudget';
|
||||
import type {FavoriteGifMediaFormat as FavoriteGifMediaFormatProto} from '@fluxer/schema/src/gen/fluxer/user/preferences/v1/pickers_pb';
|
||||
@@ -91,25 +93,29 @@ class FavoriteGif {
|
||||
}
|
||||
|
||||
hasUrl(url: string): boolean {
|
||||
return this.favoriteGifs.some((entry) => entry.url === url);
|
||||
const target = stripAttachmentSignature(url);
|
||||
return this.favoriteGifs.some((entry) => stripAttachmentSignature(entry.url) === target);
|
||||
}
|
||||
|
||||
findByUrl(url: string): FavoriteGifEntry | null {
|
||||
return this.favoriteGifs.find((entry) => entry.url === url) ?? null;
|
||||
const target = stripAttachmentSignature(url);
|
||||
return this.favoriteGifs.find((entry) => stripAttachmentSignature(entry.url) === target) ?? null;
|
||||
}
|
||||
|
||||
addEntry(entry: FavoriteGifEntry): void {
|
||||
if (this.hasUrl(entry.url)) return;
|
||||
this.favoriteGifs = [...this.favoriteGifs, entry];
|
||||
const stored = stripFavoriteGifEntrySignatures(entry);
|
||||
if (this.hasUrl(stored.url)) return;
|
||||
this.favoriteGifs = [...this.favoriteGifs, stored];
|
||||
}
|
||||
|
||||
removeByUrl(url: string): void {
|
||||
if (!this.hasUrl(url)) return;
|
||||
this.favoriteGifs = this.favoriteGifs.filter((entry) => entry.url !== url);
|
||||
const target = stripAttachmentSignature(url);
|
||||
this.favoriteGifs = this.favoriteGifs.filter((entry) => stripAttachmentSignature(entry.url) !== target);
|
||||
}
|
||||
|
||||
replaceAll(entries: ReadonlyArray<FavoriteGifEntry>): void {
|
||||
this.favoriteGifs = [...entries];
|
||||
this.favoriteGifs = entries.map(stripFavoriteGifEntrySignatures);
|
||||
}
|
||||
|
||||
setSaveGifFavoritesAsSavedMedia(value: boolean): void {
|
||||
|
||||
@@ -28,6 +28,7 @@ import {selectGuildActivationTarget} from '@app/features/gateway/transport/Guild
|
||||
import GuildMatureContentAgree from '@app/features/guild/state/GuildMatureContentAgree';
|
||||
import GuildMembers from '@app/features/member/state/GuildMembers';
|
||||
import MemberSearch from '@app/features/member/state/MemberSearch';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import Messages from '@app/features/messaging/state/MessagingMessages';
|
||||
import Navigation from '@app/features/navigation/state/Navigation';
|
||||
import SelectedGuild from '@app/features/navigation/state/SelectedGuild';
|
||||
@@ -587,6 +588,7 @@ class GatewayConnection {
|
||||
Presence.handleSessionInvalidated();
|
||||
Messages.handleSessionInvalidated();
|
||||
FavoriteMemes.reset();
|
||||
AttachmentUrlRefresher.reset();
|
||||
GuildMatureContentAgree.reset();
|
||||
Initialization.reset();
|
||||
MemberSearch.handleLogout();
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type RendererProps,
|
||||
} from '@app/features/messaging/components/markdown/renderers/RendererTypes';
|
||||
import {ExternalLinkWarningModal} from '@app/features/messaging/components/modals/ExternalLinkWarningModal';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import {goToMessage} from '@app/features/messaging/utils/MessageNavigator';
|
||||
import type {LinkNode} from '@app/features/messaging/utils/markdown/parser/Nodes';
|
||||
@@ -889,12 +890,16 @@ export const LinkRenderer = observer(function LinkRenderer({
|
||||
logger.warn('Invalid URL in link:', url);
|
||||
}
|
||||
}
|
||||
const href = AttachmentUrlRefresher.fresh(url);
|
||||
const warmAttachmentUrl = () => AttachmentUrlRefresher.warm(url);
|
||||
return (
|
||||
<FocusRing key={id} offset={-2} data-flx="messaging.markdown.renderers.link-renderer.focus-ring--2">
|
||||
<a
|
||||
href={url}
|
||||
href={href}
|
||||
target={isInternal ? undefined : '_blank'}
|
||||
rel={isInternal ? undefined : 'noopener noreferrer'}
|
||||
onPointerEnter={warmAttachmentUrl}
|
||||
onFocus={warmAttachmentUrl}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (handleClick) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {getMediaViewerPortalRoot} from '@app/features/messaging/components/modal
|
||||
import {useDeleteAttachment} from '@app/features/messaging/hooks/useDeleteAttachment';
|
||||
import {useMediaFavorite} from '@app/features/messaging/hooks/useMediaFavorite';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {formatAttachmentDate} from '@app/features/messaging/utils/AttachmentExpiryUtils';
|
||||
import {openExternalUrlWithWarning} from '@app/features/messaging/utils/ExternalLinkUtils';
|
||||
import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils';
|
||||
@@ -273,13 +274,20 @@ const MobileMediaOptionsSheet: FC<MobileMediaOptionsSheetProps> = observer(funct
|
||||
/>
|
||||
);
|
||||
});
|
||||
function withFreshViewerItemUrls(item: MediaViewerItem): MediaViewerItem {
|
||||
if (!item) return item;
|
||||
const src = AttachmentUrlRefresher.fresh(item.src);
|
||||
const originalSrc = AttachmentUrlRefresher.fresh(item.originalSrc);
|
||||
if (src === item.src && originalSrc === item.originalSrc) return item;
|
||||
return {...item, src, originalSrc};
|
||||
}
|
||||
const MediaViewerModalComponent: FC = observer(() => {
|
||||
const {i18n} = useLingui();
|
||||
const {isOpen, items, currentIndex, channelId, messageId, message, sourceChannel, allowAttachmentDelete} =
|
||||
MediaViewer;
|
||||
const {enabled: isMobile} = MobileLayout;
|
||||
const [isMediaMenuOpen, setIsMediaMenuOpen] = useState(false);
|
||||
const currentItem = items[currentIndex];
|
||||
const currentItem = withFreshViewerItemUrls(items[currentIndex]);
|
||||
const currentGifvIsActualGif = currentItem != null && isGifvRenderedAsImage(currentItem);
|
||||
useBottomSheetBackHandler(isOpen, MediaViewerCommands.closeMediaViewer);
|
||||
useEffect(() => {
|
||||
@@ -296,7 +304,10 @@ const MediaViewerModalComponent: FC = observer(() => {
|
||||
count === 2 ? [(currentIndex + 1) % count] : [(currentIndex - 1 + count) % count, (currentIndex + 1) % count];
|
||||
const unpinCallbacks = adjacentIndices.map((index) => {
|
||||
const item = items[index];
|
||||
if (!item || !isViewerImageItem(item) || item.src.startsWith('blob:')) return () => {};
|
||||
if (!item) return () => {};
|
||||
AttachmentUrlRefresher.warm(item.src);
|
||||
AttachmentUrlRefresher.warm(item.originalSrc);
|
||||
if (!isViewerImageItem(item) || item.src.startsWith('blob:')) return () => {};
|
||||
return ImageCacheUtils.pinImage(buildViewerMediaURL(item));
|
||||
});
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {useCallback, useRef} from 'react';
|
||||
|
||||
export function useAttachmentRefreshOnError(src: string | undefined): () => void {
|
||||
const refreshedSourceRef = useRef<string | null>(null);
|
||||
return useCallback(() => {
|
||||
if (src === undefined || src.length === 0) return;
|
||||
if (refreshedSourceRef.current === src) return;
|
||||
refreshedSourceRef.current = src;
|
||||
void AttachmentUrlRefresher.refresh(src, {force: true});
|
||||
}, [src]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import DeveloperOptions from '@app/features/devtools/state/DeveloperOptions';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import * as ImageCacheUtils from '@app/features/messaging/utils/ImageCacheUtils';
|
||||
import {decodeThumbHashDataURL} from '@app/features/messaging/utils/ThumbHashUtils';
|
||||
import {type SyntheticEvent, useCallback, useLayoutEffect, useMemo, useRef, useState} from 'react';
|
||||
@@ -123,6 +124,7 @@ export function useMediaLoading(
|
||||
const {enabled = true} = options;
|
||||
const shouldForcePlaceholder = DeveloperOptions.forceRenderPlaceholders || DeveloperOptions.forceMediaLoading;
|
||||
const mediaElementRef = useRef<MediaLoadingElement | null>(null);
|
||||
const refreshedSourceRef = useRef<string | null>(null);
|
||||
const loadEnabled = enabled && src.length > 0 && !shouldForcePlaceholder;
|
||||
const sourceIdentity = useMemo<MediaLoadingSourceIdentity>(() => ({src, loadEnabled}), [loadEnabled, src]);
|
||||
const committedSourceIdentityRef = useRef(sourceIdentity);
|
||||
@@ -184,6 +186,10 @@ export function useMediaLoading(
|
||||
if (mediaElementRef.current !== element) return;
|
||||
if (!mediaElementMatchesSource(element, src)) return;
|
||||
ImageCacheUtils.forgetImage(src);
|
||||
if (refreshedSourceRef.current !== src) {
|
||||
refreshedSourceRef.current = src;
|
||||
void AttachmentUrlRefresher.refresh(src, {force: true});
|
||||
}
|
||||
setSourceState((currentState) => {
|
||||
if (currentState.src === src && currentState.loadEnabled === loadEnabled && currentState.loaded) {
|
||||
return {...currentState, cached: false};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
|
||||
import {observeIntersection} from '@app/features/platform/utils/SharedIntersectionObserver';
|
||||
import {LRUCache} from 'lru-cache';
|
||||
import {createContext, useCallback, useContext, useEffect, useState} from 'react';
|
||||
@@ -23,6 +24,11 @@ export function resolveObserverRoot(resolve: ScrollSurfaceResolver, element: Ele
|
||||
return surface;
|
||||
}
|
||||
|
||||
export function resolveViewportKey(rememberKey: string | null | undefined): string | null {
|
||||
if (!rememberKey) return null;
|
||||
return stripAttachmentSignature(rememberKey);
|
||||
}
|
||||
|
||||
interface UseNearViewportOptions {
|
||||
disabled?: boolean;
|
||||
rememberKey?: string | null;
|
||||
@@ -37,8 +43,9 @@ export function useNearViewport<T extends Element>({
|
||||
threshold = 0,
|
||||
}: UseNearViewportOptions = {}): {ref: (node: T | null) => void; isNearViewport: boolean} {
|
||||
const resolveScrollSurface = useContext(NearViewportSurfaceContext);
|
||||
const viewportKey = resolveViewportKey(rememberKey);
|
||||
const loadImmediately = disabled || typeof IntersectionObserver === 'undefined';
|
||||
const wasRemembered = rememberKey ? rememberedViewportKeys.has(rememberKey) : false;
|
||||
const wasRemembered = viewportKey ? rememberedViewportKeys.has(viewportKey) : false;
|
||||
const [element, setElement] = useState<T | null>(null);
|
||||
const [isNearViewport, setIsNearViewport] = useState(loadImmediately || wasRemembered);
|
||||
const ref = useCallback((node: T | null) => {
|
||||
@@ -50,13 +57,13 @@ export function useNearViewport<T extends Element>({
|
||||
}
|
||||
}, [disabled]);
|
||||
useEffect(() => {
|
||||
if (!isNearViewport || !rememberKey) return;
|
||||
if (!isNearViewport || !viewportKey) return;
|
||||
if (disabled || typeof IntersectionObserver === 'undefined') return;
|
||||
rememberedViewportKeys.set(rememberKey, true);
|
||||
}, [disabled, isNearViewport, rememberKey]);
|
||||
rememberedViewportKeys.set(viewportKey, true);
|
||||
}, [disabled, isNearViewport, viewportKey]);
|
||||
useEffect(() => {
|
||||
if (disabled || isNearViewport || !element) return undefined;
|
||||
if (rememberKey && rememberedViewportKeys.has(rememberKey)) {
|
||||
if (viewportKey && rememberedViewportKeys.has(viewportKey)) {
|
||||
setIsNearViewport(true);
|
||||
return undefined;
|
||||
}
|
||||
@@ -70,14 +77,14 @@ export function useNearViewport<T extends Element>({
|
||||
(entry) => {
|
||||
if (!entry.isIntersecting && entry.intersectionRatio <= 0) return;
|
||||
stopObserving();
|
||||
if (rememberKey) {
|
||||
rememberedViewportKeys.set(rememberKey, true);
|
||||
if (viewportKey) {
|
||||
rememberedViewportKeys.set(viewportKey, true);
|
||||
}
|
||||
setIsNearViewport(true);
|
||||
},
|
||||
{root: resolveObserverRoot(resolveScrollSurface, element), rootMargin, threshold},
|
||||
);
|
||||
return stopObserving;
|
||||
}, [disabled, element, isNearViewport, rememberKey, resolveScrollSurface, rootMargin, threshold]);
|
||||
}, [disabled, element, isNearViewport, viewportKey, resolveScrollSurface, rootMargin, threshold]);
|
||||
return {ref, isNearViewport};
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Endpoints} from '@app/features/app/constants/Endpoints';
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import {
|
||||
ATTACHMENT_URL_REFRESH_MARGIN_MS,
|
||||
applyAttachmentSignature,
|
||||
attachmentCacheKey,
|
||||
attachmentUrlNeedsRefresh,
|
||||
isAttachmentCdnUrl,
|
||||
readAttachmentSignatureFields,
|
||||
readAttachmentUrlSignature,
|
||||
stripAttachmentSignature,
|
||||
} from '@app/features/messaging/utils/AttachmentCdnUrl';
|
||||
import {resolveRetryAfterMs} from '@app/features/messaging/utils/RetryAfterUtils';
|
||||
import {http} from '@app/features/platform/transport/RestTransport';
|
||||
import {HttpError} from '@app/features/platform/types/EndpointError';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
import type {RefreshAttachmentUrlsResponse} from '@fluxer/schema/src/domains/message/AttachmentSchemas';
|
||||
import {makeAutoObservable, runInAction, untracked} from 'mobx';
|
||||
|
||||
const logger = new Logger('AttachmentUrlRefresher');
|
||||
|
||||
const FLUSH_WINDOW_MS = 16;
|
||||
const MAX_URLS_PER_REQUEST = 50;
|
||||
const UNSIGNED_RESULT_COOLDOWN_MS = 600_000;
|
||||
const TRANSPORT_ERROR_COOLDOWN_MS = 30_000;
|
||||
const RATE_LIMIT_FALLBACK_COOLDOWN_MS = 10_000;
|
||||
|
||||
type CacheEntry = {kind: 'signature'; fields: string; goodUntilMs: number} | {kind: 'blocked'; untilMs: number};
|
||||
|
||||
interface QueuedRequest {
|
||||
url: string;
|
||||
resolvers: Array<(fields: string | null) => void>;
|
||||
}
|
||||
|
||||
class AttachmentUrlRefresher {
|
||||
entries = new Map<string, CacheEntry>();
|
||||
readonly queued = new Map<string, QueuedRequest>();
|
||||
readonly inFlight = new Map<string, Promise<string | null>>();
|
||||
flushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
cooldownUntilMs = 0;
|
||||
generation = 0;
|
||||
revision = 0;
|
||||
|
||||
constructor() {
|
||||
makeAutoObservable(
|
||||
this,
|
||||
{
|
||||
queued: false,
|
||||
inFlight: false,
|
||||
flushTimer: false,
|
||||
cooldownUntilMs: false,
|
||||
generation: false,
|
||||
},
|
||||
{autoBind: true},
|
||||
);
|
||||
}
|
||||
|
||||
fresh(url: string, options: {refreshUnsigned?: boolean} = {}): string {
|
||||
if (!url || !isAttachmentCdnUrl(url, RuntimeConfig.mediaEndpoint)) return url;
|
||||
const signature = readAttachmentUrlSignature(url);
|
||||
const nowMs = Date.now();
|
||||
if (signature !== null && signature.expiresAtMs > nowMs + ATTACHMENT_URL_REFRESH_MARGIN_MS) return url;
|
||||
const key = attachmentCacheKey(url);
|
||||
if (key === null) return url;
|
||||
const entry = this.entries.get(key);
|
||||
if (entry?.kind === 'signature' && entry.goodUntilMs > nowMs) {
|
||||
return applyAttachmentSignature(url, entry.fields);
|
||||
}
|
||||
if (signature === null && options.refreshUnsigned !== true) return url;
|
||||
if (entry?.kind === 'blocked' && entry.untilMs > nowMs) return url;
|
||||
this.enqueue(key, url, nowMs);
|
||||
return url;
|
||||
}
|
||||
|
||||
warm(url: string): void {
|
||||
if (!url || !isAttachmentCdnUrl(url, RuntimeConfig.mediaEndpoint)) return;
|
||||
const nowMs = Date.now();
|
||||
if (!attachmentUrlNeedsRefresh(url, nowMs)) return;
|
||||
const key = attachmentCacheKey(url);
|
||||
if (key === null) return;
|
||||
const entry = untracked(() => this.entries.get(key));
|
||||
if (entry?.kind === 'signature' && entry.goodUntilMs > nowMs) return;
|
||||
if (entry?.kind === 'blocked' && entry.untilMs > nowMs) return;
|
||||
this.enqueue(key, url, nowMs);
|
||||
}
|
||||
|
||||
async refresh(url: string, options: {force?: boolean} = {}): Promise<string> {
|
||||
if (!url || !isAttachmentCdnUrl(url, RuntimeConfig.mediaEndpoint)) return url;
|
||||
const key = attachmentCacheKey(url);
|
||||
if (key === null) return url;
|
||||
const nowMs = Date.now();
|
||||
const entry = untracked(() => this.entries.get(key));
|
||||
if (entry?.kind === 'signature' && entry.goodUntilMs > nowMs) {
|
||||
return applyAttachmentSignature(url, entry.fields);
|
||||
}
|
||||
if (options.force !== true) {
|
||||
if (entry?.kind === 'blocked' && entry.untilMs > nowMs) return url;
|
||||
if (!attachmentUrlNeedsRefresh(url, nowMs)) return url;
|
||||
}
|
||||
const fields = await this.request(key, url, nowMs);
|
||||
return fields === null ? url : applyAttachmentSignature(url, fields);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.generation += 1;
|
||||
this.revision += 1;
|
||||
this.entries = new Map();
|
||||
for (const request of this.queued.values()) {
|
||||
for (const resolve of request.resolvers) resolve(null);
|
||||
}
|
||||
this.queued.clear();
|
||||
this.inFlight.clear();
|
||||
this.cooldownUntilMs = 0;
|
||||
if (this.flushTimer !== null) {
|
||||
clearTimeout(this.flushTimer);
|
||||
this.flushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private forgetExpired(nowMs: number): void {
|
||||
for (const [key, entry] of this.entries) {
|
||||
const untilMs = entry.kind === 'signature' ? entry.goodUntilMs : entry.untilMs;
|
||||
if (untilMs <= nowMs) this.entries.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private enqueue(key: string, url: string, nowMs: number): void {
|
||||
if (this.inFlight.has(key)) return;
|
||||
if (nowMs < this.cooldownUntilMs) return;
|
||||
if (this.queued.has(key)) return;
|
||||
this.queued.set(key, {url: stripAttachmentSignature(url), resolvers: []});
|
||||
this.scheduleFlush();
|
||||
}
|
||||
|
||||
private request(key: string, url: string, nowMs: number): Promise<string | null> {
|
||||
const existing = this.inFlight.get(key);
|
||||
if (existing) return existing;
|
||||
if (nowMs < this.cooldownUntilMs) return Promise.resolve(null);
|
||||
const queued = this.queued.get(key) ?? {url: stripAttachmentSignature(url), resolvers: []};
|
||||
this.queued.set(key, queued);
|
||||
this.scheduleFlush();
|
||||
return new Promise<string | null>((resolve) => {
|
||||
queued.resolvers.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleFlush(): void {
|
||||
if (this.flushTimer !== null) return;
|
||||
this.flushTimer = setTimeout(() => {
|
||||
this.flushTimer = null;
|
||||
void this.flush();
|
||||
}, FLUSH_WINDOW_MS);
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
if (this.queued.size === 0) return;
|
||||
const nowMs = Date.now();
|
||||
if (nowMs < this.cooldownUntilMs) {
|
||||
this.drainQueue();
|
||||
return;
|
||||
}
|
||||
const batch = Array.from(this.queued.entries()).slice(0, MAX_URLS_PER_REQUEST);
|
||||
for (const [key] of batch) this.queued.delete(key);
|
||||
if (this.queued.size > 0) this.scheduleFlush();
|
||||
const generation = this.generation;
|
||||
const send = this.send(batch, generation);
|
||||
for (const [key] of batch) {
|
||||
this.inFlight.set(
|
||||
key,
|
||||
send.then((outcome) => outcome.get(key) ?? null),
|
||||
);
|
||||
}
|
||||
const outcome = await send;
|
||||
for (const [key, request] of batch) {
|
||||
if (generation === this.generation) this.inFlight.delete(key);
|
||||
const fields = outcome.get(key) ?? null;
|
||||
for (const resolve of request.resolvers) resolve(fields);
|
||||
}
|
||||
}
|
||||
|
||||
private async send(batch: Array<[string, QueuedRequest]>, generation: number): Promise<Map<string, string>> {
|
||||
const resolved = new Map<string, string>();
|
||||
try {
|
||||
const response = await http.post<RefreshAttachmentUrlsResponse>(Endpoints.ATTACHMENTS_REFRESH_URLS, {
|
||||
body: {attachment_urls: batch.map(([, request]) => request.url)},
|
||||
});
|
||||
if (generation !== this.generation) return resolved;
|
||||
const byOriginal = new Map(
|
||||
(response.body?.refreshed_urls ?? []).map((entry) => [entry.original, entry.refreshed]),
|
||||
);
|
||||
const nowMs = Date.now();
|
||||
const updates: Array<[string, CacheEntry]> = [];
|
||||
for (const [key, request] of batch) {
|
||||
const refreshed = byOriginal.get(request.url);
|
||||
const fields = refreshed === undefined ? null : readAttachmentSignatureFields(refreshed);
|
||||
const expiry = refreshed === undefined ? null : readAttachmentUrlSignature(refreshed);
|
||||
if (fields === null || expiry === null) {
|
||||
updates.push([key, {kind: 'blocked', untilMs: nowMs + UNSIGNED_RESULT_COOLDOWN_MS}]);
|
||||
continue;
|
||||
}
|
||||
resolved.set(key, fields);
|
||||
updates.push([
|
||||
key,
|
||||
{kind: 'signature', fields, goodUntilMs: expiry.expiresAtMs - ATTACHMENT_URL_REFRESH_MARGIN_MS},
|
||||
]);
|
||||
}
|
||||
runInAction(() => {
|
||||
this.forgetExpired(nowMs);
|
||||
for (const [key, entry] of updates) this.entries.set(key, entry);
|
||||
this.revision += 1;
|
||||
});
|
||||
return resolved;
|
||||
} catch (error) {
|
||||
if (generation !== this.generation) return resolved;
|
||||
const nowMs = Date.now();
|
||||
if (error instanceof HttpError && error.status === 429) {
|
||||
this.cooldownUntilMs = nowMs + (resolveRetryAfterMs(error) ?? RATE_LIMIT_FALLBACK_COOLDOWN_MS);
|
||||
return resolved;
|
||||
}
|
||||
logger.warn('Failed to refresh attachment URLs', error);
|
||||
runInAction(() => {
|
||||
this.forgetExpired(nowMs);
|
||||
for (const [key] of batch) {
|
||||
this.entries.set(key, {kind: 'blocked', untilMs: nowMs + TRANSPORT_ERROR_COOLDOWN_MS});
|
||||
}
|
||||
this.revision += 1;
|
||||
});
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
|
||||
private drainQueue(): void {
|
||||
for (const request of this.queued.values()) {
|
||||
for (const resolve of request.resolvers) resolve(null);
|
||||
}
|
||||
this.queued.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export default new AttachmentUrlRefresher();
|
||||
@@ -0,0 +1,198 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
export const ATTACHMENT_URL_REFRESH_MARGIN_MS = 3_600_000;
|
||||
|
||||
export interface EndpointInfo {
|
||||
basePath: string;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
export interface AttachmentUrlSignature {
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
const ATTACHMENT_PATH_SEGMENT = '/attachments/';
|
||||
const WINDOW_HEX_PATTERN = /^[0-9a-f]{8}$/u;
|
||||
const MAC_HEX_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
const DIGITS_PATTERN = /^[0-9]+$/u;
|
||||
const PLUS_PATTERN = /\+/gu;
|
||||
const DATA_PACKAGE_USE_CASE = 'dp';
|
||||
const DATA_PACKAGE_EXPIRY = '0';
|
||||
|
||||
export function parseEndpoint(endpoint: string): EndpointInfo | null {
|
||||
if (!endpoint) return null;
|
||||
try {
|
||||
const parsedEndpoint = new URL(endpoint);
|
||||
const basePath =
|
||||
parsedEndpoint.pathname.length > 1 && parsedEndpoint.pathname.endsWith('/')
|
||||
? parsedEndpoint.pathname.slice(0, -1)
|
||||
: parsedEndpoint.pathname || '/';
|
||||
return {
|
||||
basePath,
|
||||
origin: parsedEndpoint.origin,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrlOnEndpoint(targetUrl: URL, endpoint: EndpointInfo): boolean {
|
||||
if (targetUrl.origin !== endpoint.origin) return false;
|
||||
if (endpoint.basePath === '/') return true;
|
||||
return targetUrl.pathname === endpoint.basePath || targetUrl.pathname.startsWith(`${endpoint.basePath}/`);
|
||||
}
|
||||
|
||||
export function isAttachmentCdnUrl(url: string, mediaEndpoint: string): boolean {
|
||||
if (!url.includes(ATTACHMENT_PATH_SEGMENT)) return false;
|
||||
const endpoint = parseEndpoint(mediaEndpoint);
|
||||
if (endpoint === null) return false;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (parsed.username !== '' || parsed.password !== '') return false;
|
||||
if (parsed.origin !== endpoint.origin) return false;
|
||||
const prefix = endpoint.basePath === '/' ? ATTACHMENT_PATH_SEGMENT : `${endpoint.basePath}${ATTACHMENT_PATH_SEGMENT}`;
|
||||
if (!parsed.pathname.startsWith(prefix)) return false;
|
||||
const segments = parsed.pathname.slice(prefix.length).split('/');
|
||||
if (segments.length < 3) return false;
|
||||
const [channelId, attachmentId] = segments;
|
||||
return (
|
||||
DIGITS_PATTERN.test(channelId) &&
|
||||
DIGITS_PATTERN.test(attachmentId) &&
|
||||
segments.slice(2).every((segment) => segment.length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
export function attachmentCacheKey(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}${parsed.pathname}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface SplitUrl {
|
||||
base: string;
|
||||
query: string;
|
||||
fragment: string;
|
||||
}
|
||||
|
||||
function splitUrl(url: string): SplitUrl {
|
||||
const fragmentIndex = url.indexOf('#');
|
||||
const head = fragmentIndex < 0 ? url : url.slice(0, fragmentIndex);
|
||||
const fragment = fragmentIndex < 0 ? '' : url.slice(fragmentIndex);
|
||||
const queryIndex = head.indexOf('?');
|
||||
if (queryIndex < 0) return {base: head, query: '', fragment};
|
||||
return {base: head.slice(0, queryIndex), query: head.slice(queryIndex + 1), fragment};
|
||||
}
|
||||
|
||||
function formDecodeName(name: string): string {
|
||||
try {
|
||||
return decodeURIComponent(name.replace(PLUS_PATTERN, ' '));
|
||||
} catch {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
function fieldName(field: string): string {
|
||||
const separator = field.indexOf('=');
|
||||
return formDecodeName(separator < 0 ? field : field.slice(0, separator));
|
||||
}
|
||||
|
||||
function fieldValue(field: string): string {
|
||||
const separator = field.indexOf('=');
|
||||
return separator < 0 ? '' : field.slice(separator + 1);
|
||||
}
|
||||
|
||||
type SignatureFieldName = 'ex' | 'is' | 'hm' | 'uc';
|
||||
|
||||
function isSignatureFieldName(name: string): name is SignatureFieldName {
|
||||
return name === 'ex' || name === 'is' || name === 'hm' || name === 'uc';
|
||||
}
|
||||
|
||||
function preservedFields(query: string): Array<string> {
|
||||
if (query.length === 0) return [];
|
||||
return query.split('&').filter((field) => {
|
||||
if (field.length === 0 || field === '=') return false;
|
||||
return !isSignatureFieldName(fieldName(field));
|
||||
});
|
||||
}
|
||||
|
||||
interface ScannedSignature {
|
||||
ex: Array<string>;
|
||||
is: Array<string>;
|
||||
hm: Array<string>;
|
||||
uc: Array<string>;
|
||||
}
|
||||
|
||||
function scanSignatureFields(url: string): ScannedSignature {
|
||||
const found: ScannedSignature = {ex: [], is: [], hm: [], uc: []};
|
||||
const {query} = splitUrl(url);
|
||||
if (query.length === 0) return found;
|
||||
for (const field of query.split('&')) {
|
||||
if (field.length === 0) continue;
|
||||
const name = fieldName(field);
|
||||
if (!isSignatureFieldName(name)) continue;
|
||||
found[name].push(fieldValue(field));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
interface AttachmentSignatureFields {
|
||||
ex: string;
|
||||
is: string;
|
||||
hm: string;
|
||||
uc: string;
|
||||
expiresAtMs: number;
|
||||
}
|
||||
|
||||
function parseAttachmentSignature(url: string): AttachmentSignatureFields | null {
|
||||
const found = scanSignatureFields(url);
|
||||
if (found.ex.length !== 1 || found.is.length !== 1 || found.hm.length !== 1 || found.uc.length > 1) return null;
|
||||
const ex = found.ex[0];
|
||||
const is = found.is[0];
|
||||
const hm = found.hm[0];
|
||||
if (!WINDOW_HEX_PATTERN.test(is) || !MAC_HEX_PATTERN.test(hm)) return null;
|
||||
if (found.uc.length === 1) {
|
||||
if (found.uc[0] !== DATA_PACKAGE_USE_CASE || ex !== DATA_PACKAGE_EXPIRY) return null;
|
||||
return {ex, is, hm, uc: DATA_PACKAGE_USE_CASE, expiresAtMs: Number.POSITIVE_INFINITY};
|
||||
}
|
||||
if (!WINDOW_HEX_PATTERN.test(ex)) return null;
|
||||
return {ex, is, hm, uc: '', expiresAtMs: Number.parseInt(ex, 16) * 1000};
|
||||
}
|
||||
|
||||
export function readAttachmentUrlSignature(url: string): AttachmentUrlSignature | null {
|
||||
const signature = parseAttachmentSignature(url);
|
||||
return signature === null ? null : {expiresAtMs: signature.expiresAtMs};
|
||||
}
|
||||
|
||||
export function readAttachmentSignatureFields(url: string): string | null {
|
||||
const signature = parseAttachmentSignature(url);
|
||||
if (signature === null) return null;
|
||||
const fields = `ex=${signature.ex}&is=${signature.is}&hm=${signature.hm}`;
|
||||
return signature.uc === '' ? fields : `${fields}&uc=${signature.uc}`;
|
||||
}
|
||||
|
||||
export function stripAttachmentSignature(url: string): string {
|
||||
const {base, query, fragment} = splitUrl(url);
|
||||
const preserved = preservedFields(query);
|
||||
if (preserved.length === 0) return `${base}${fragment}`;
|
||||
return `${base}?${preserved.join('&')}${fragment}`;
|
||||
}
|
||||
|
||||
export function applyAttachmentSignature(url: string, signatureFields: string): string {
|
||||
const {base, query, fragment} = splitUrl(url);
|
||||
const preserved = preservedFields(query);
|
||||
if (preserved.length === 0) return `${base}?${signatureFields}${fragment}`;
|
||||
return `${base}?${signatureFields}&${preserved.join('&')}${fragment}`;
|
||||
}
|
||||
|
||||
export function attachmentUrlNeedsRefresh(url: string, nowMs: number): boolean {
|
||||
const signature = readAttachmentUrlSignature(url);
|
||||
if (signature === null) return true;
|
||||
return signature.expiresAtMs <= nowMs + ATTACHMENT_URL_REFRESH_MARGIN_MS;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import RuntimeConfig from '@app/features/app/state/RuntimeConfig';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {isUrlOnEndpoint, parseEndpoint} from '@app/features/messaging/utils/AttachmentCdnUrl';
|
||||
import {isMobileOrTabletUserAgent} from '@app/features/platform/notifications/NotificationAlertOptions';
|
||||
import {supportsShowSaveFilePicker} from '@app/features/platform/types/Browser';
|
||||
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||
@@ -10,34 +12,6 @@ const logger = new Logger('FileDownloadUtils');
|
||||
|
||||
type MediaType = 'image' | 'gif' | 'video' | 'audio' | 'file';
|
||||
|
||||
interface EndpointInfo {
|
||||
basePath: string;
|
||||
origin: string;
|
||||
}
|
||||
|
||||
function parseEndpoint(endpoint: string): EndpointInfo | null {
|
||||
if (!endpoint) return null;
|
||||
try {
|
||||
const parsedEndpoint = new URL(endpoint);
|
||||
const basePath =
|
||||
parsedEndpoint.pathname.length > 1 && parsedEndpoint.pathname.endsWith('/')
|
||||
? parsedEndpoint.pathname.slice(0, -1)
|
||||
: parsedEndpoint.pathname || '/';
|
||||
return {
|
||||
basePath,
|
||||
origin: parsedEndpoint.origin,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isUrlOnEndpoint(targetUrl: URL, endpoint: EndpointInfo): boolean {
|
||||
if (targetUrl.origin !== endpoint.origin) return false;
|
||||
if (endpoint.basePath === '/') return true;
|
||||
return targetUrl.pathname === endpoint.basePath || targetUrl.pathname.startsWith(`${endpoint.basePath}/`);
|
||||
}
|
||||
|
||||
function appendMediaProxyDownloadParam(src: string): string {
|
||||
let parsedSrc: URL;
|
||||
try {
|
||||
@@ -221,23 +195,21 @@ function downloadViaAnchor(src: string, suggestedName: string, options?: {append
|
||||
|
||||
export async function downloadFile(src: string, type: MediaType, providedFilename?: string): Promise<void> {
|
||||
if (!src) return;
|
||||
const suggestedName = deriveSuggestedName(src, type, providedFilename);
|
||||
const target = await AttachmentUrlRefresher.refresh(src);
|
||||
if (isElectron()) {
|
||||
try {
|
||||
const outcome = await downloadWithNative({
|
||||
url: src,
|
||||
suggestedName: deriveSuggestedName(src, type, providedFilename),
|
||||
});
|
||||
const outcome = await downloadWithNative({url: target, suggestedName});
|
||||
if (outcome !== 'unavailable') return;
|
||||
} catch (error) {
|
||||
logger.warn('Native download failed', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const suggestedName = deriveSuggestedName(src, type, providedFilename);
|
||||
if (await downloadViaFileSystemAccess(src, suggestedName, type)) return;
|
||||
if (await downloadViaFetchBlob(src, suggestedName)) return;
|
||||
if (downloadViaAnchor(src, suggestedName)) return;
|
||||
await openExternalUrl(appendMediaProxyDownloadParam(src));
|
||||
if (await downloadViaFileSystemAccess(target, suggestedName, type)) return;
|
||||
if (await downloadViaFetchBlob(target, suggestedName)) return;
|
||||
if (downloadViaAnchor(target, suggestedName)) return;
|
||||
await openExternalUrl(appendMediaProxyDownloadParam(target));
|
||||
}
|
||||
|
||||
export function createDownloadHandler(src: string, type: MediaType, providedFilename?: string) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {stripAttachmentSignature} from '@app/features/messaging/utils/AttachmentCdnUrl';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
import UserSettings from '@app/features/user/state/UserSettings';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
@@ -49,7 +50,7 @@ export function canonicalizeMediaUrl(url: string | null | undefined): string | n
|
||||
if (youtubeId) {
|
||||
return `youtube:${youtubeId}`;
|
||||
}
|
||||
return parsed.href.replace(/\/$/, '');
|
||||
return stripAttachmentSignature(parsed.href).replace(/\/$/, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {EditAltTextModal} from '@app/features/messaging/components/modals/EditAltTextModal';
|
||||
import type {Message} from '@app/features/messaging/models/MessagingMessage';
|
||||
import AttachmentUrlRefresher from '@app/features/messaging/state/AttachmentUrlRefresher';
|
||||
import {createDownloadHandler} from '@app/features/messaging/utils/FileDownloadUtils';
|
||||
import {buildMediaProxyURL, stripMediaProxyParams} from '@app/features/messaging/utils/MediaProxyUtils';
|
||||
import Permission from '@app/features/permissions/state/Permission';
|
||||
@@ -429,17 +430,18 @@ export async function copyMediaToClipboard({
|
||||
);
|
||||
return;
|
||||
}
|
||||
const freshSrc = await AttachmentUrlRefresher.refresh(originalSrc);
|
||||
if (type === 'file') {
|
||||
await TextCopyCommands.copy(i18n, originalSrc, true);
|
||||
await TextCopyCommands.copy(i18n, freshSrc, true);
|
||||
ToastCommands.createToast({type: 'success', children: i18n._(LINK_COPIED_TO_CLIPBOARD_DESCRIPTOR)});
|
||||
return;
|
||||
}
|
||||
const baseProxyURL = proxyURL ? stripMediaProxyParams(proxyURL) : null;
|
||||
const baseProxyURL = proxyURL ? await AttachmentUrlRefresher.refresh(stripMediaProxyParams(proxyURL)) : null;
|
||||
const clipboardFileMediaType = getClipboardFileMediaType(type);
|
||||
if (clipboardFileMediaType) {
|
||||
const electronApi = getElectronAPI();
|
||||
if (!electronApi?.clipboardWriteFile) {
|
||||
await TextCopyCommands.copy(i18n, originalSrc, true);
|
||||
await TextCopyCommands.copy(i18n, freshSrc, true);
|
||||
ToastCommands.createToast({type: 'success', children: i18n._(URL_COPIED_TO_CLIPBOARD_DESCRIPTOR)});
|
||||
return;
|
||||
}
|
||||
@@ -451,7 +453,7 @@ export async function copyMediaToClipboard({
|
||||
timeout: 0,
|
||||
});
|
||||
const result = await electronApi.clipboardWriteFile({
|
||||
url: baseProxyURL || originalSrc,
|
||||
url: baseProxyURL || freshSrc,
|
||||
suggestedName: defaultName,
|
||||
mediaType: clipboardFileMediaType,
|
||||
});
|
||||
@@ -470,7 +472,7 @@ export async function copyMediaToClipboard({
|
||||
const urlsToTry: Array<string> = [];
|
||||
if (baseProxyURL) urlsToTry.push(buildMediaProxyURL(baseProxyURL, {format: 'png'}));
|
||||
if (baseProxyURL) urlsToTry.push(baseProxyURL);
|
||||
urlsToTry.push(originalSrc);
|
||||
urlsToTry.push(freshSrc);
|
||||
let toastId: string | null = null;
|
||||
try {
|
||||
toastId = ToastCommands.createToast({
|
||||
@@ -494,7 +496,7 @@ export async function copyMediaToClipboard({
|
||||
} catch (error) {
|
||||
logger.error('Failed to copy image to clipboard:', error);
|
||||
if (toastId) ToastCommands.destroyToast(toastId);
|
||||
await TextCopyCommands.copy(i18n, originalSrc, true);
|
||||
await TextCopyCommands.copy(i18n, freshSrc, true);
|
||||
ToastCommands.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(URL_COPIED_TO_CLIPBOARD_DESCRIPTOR),
|
||||
@@ -511,7 +513,7 @@ export async function copyMediaLinkToClipboard({i18n, originalSrc}: {i18n: I18n;
|
||||
);
|
||||
return;
|
||||
}
|
||||
await TextCopyCommands.copy(i18n, originalSrc, true);
|
||||
await TextCopyCommands.copy(i18n, await AttachmentUrlRefresher.refresh(originalSrc), true);
|
||||
ToastCommands.createToast({
|
||||
type: 'success',
|
||||
children: i18n._(LINK_COPIED_TO_CLIPBOARD_DESCRIPTOR),
|
||||
|
||||
@@ -196,20 +196,26 @@ const getSafeExternalUrl = (href: string | null): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
async function refreshAttachmentUrl(url: string): Promise<string> {
|
||||
const {default: AttachmentUrlRefresher} = await import('@app/features/messaging/state/AttachmentUrlRefresher');
|
||||
return AttachmentUrlRefresher.refresh(url);
|
||||
}
|
||||
|
||||
export async function openExternalUrl(url: string, target: string = '_blank') {
|
||||
const safeUrl = getSafeExternalUrl(url);
|
||||
if (!safeUrl) return;
|
||||
const refreshedUrl = await refreshAttachmentUrl(safeUrl);
|
||||
const electronApi = getElectronAPI();
|
||||
if (electronApi) {
|
||||
try {
|
||||
await electronApi.openExternal(safeUrl);
|
||||
await electronApi.openExternal(refreshedUrl);
|
||||
return;
|
||||
} catch (error) {
|
||||
logger.error(' Failed to open external URL via Electron', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.open(safeUrl, target, 'noopener,noreferrer');
|
||||
window.open(refreshedUrl, target, 'noopener,noreferrer');
|
||||
}
|
||||
|
||||
interface ExternalLinkClickEvent {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {PAUSE_DESCRIPTOR, PLAY_DESCRIPTOR} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
import {Tooltip} from '@app/features/ui/tooltip/Tooltip';
|
||||
import styles from '@app/features/voice/components/media_player/AudioPlayer.module.css';
|
||||
@@ -77,6 +78,7 @@ export function AudioPlayer({
|
||||
});
|
||||
const {volume, isMuted, setVolume, toggleMute} = useMediaVolume({mediaRef});
|
||||
const {escalateToMetadata, sourceAttribute, preloadAttribute} = useMetadataPreload(src, hasStarted);
|
||||
const handleMediaError = useAttachmentRefreshOnError(src);
|
||||
useEffect(() => {
|
||||
if (hasStarted && pendingPlayRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
@@ -210,6 +212,7 @@ export function AudioPlayer({
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={sourceAttribute}
|
||||
preload={preloadAttribute}
|
||||
onError={handleMediaError}
|
||||
data-flx="voice.media-player.audio-player.audio"
|
||||
/>
|
||||
{title && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
PLAY_DESCRIPTOR,
|
||||
REMOVE_FROM_FAVORITES_DESCRIPTOR,
|
||||
} from '@app/features/i18n/utils/CommonMessageDescriptors';
|
||||
import {useAttachmentRefreshOnError} from '@app/features/messaging/hooks/useAttachmentRefreshOnError';
|
||||
import {formatFileSize} from '@app/features/messaging/utils/FileUtils';
|
||||
import {remFromPx} from '@app/features/theme/layout/RemFromPx';
|
||||
import FocusRing from '@app/features/ui/focus_ring/FocusRing';
|
||||
@@ -111,6 +112,7 @@ export function InlineAudioPlayer({
|
||||
mediaRef,
|
||||
});
|
||||
const {escalateToMetadata, sourceAttribute, preloadAttribute} = useMetadataPreload(src, hasStarted);
|
||||
const handleMediaError = useAttachmentRefreshOnError(src);
|
||||
const displayDuration = duration > 0 ? duration : (initialDuration ?? 0);
|
||||
useLayoutEffect(() => {
|
||||
const media = mediaRef.current;
|
||||
@@ -205,6 +207,7 @@ export function InlineAudioPlayer({
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={sourceAttribute}
|
||||
preload={preloadAttribute}
|
||||
onError={handleMediaError}
|
||||
data-flx="voice.media-player.inline-audio-player.audio"
|
||||
>
|
||||
<track kind="captions" data-flx="voice.media-player.inline-audio-player.track" />
|
||||
@@ -332,6 +335,7 @@ export function InlineAudioPlayer({
|
||||
ref={mediaRef as React.RefObject<HTMLAudioElement>}
|
||||
src={sourceAttribute}
|
||||
preload={preloadAttribute}
|
||||
onError={handleMediaError}
|
||||
data-flx="voice.media-player.inline-audio-player.audio--2"
|
||||
>
|
||||
<track kind="captions" data-flx="voice.media-player.inline-audio-player.track--2" />
|
||||
|
||||
Reference in new issue
Block a user