From 252d9650f8b01cd93d975fc3d20440c2446dbf5d Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 2 Aug 2026 22:48:24 +0200 Subject: [PATCH 01/25] embed bundling ui --- package.json | 3 +- pnpm-lock.yaml | 3 + src-tauri/capabilities/default.json | 2 +- src/app/components/icons/phosphor.tsx | 2 + .../upload-card/EmbedCardRenderer.tsx | 118 ++++++++ src/app/components/upload-card/index.ts | 1 + src/app/features/room/RoomInput.tsx | 83 +++++- src/app/state/bundle.ts | 257 ++++++++++++++++++ src/app/state/list.ts | 12 +- src/app/state/room/roomInputDrafts.ts | 13 +- 10 files changed, 486 insertions(+), 8 deletions(-) create mode 100644 src/app/components/upload-card/EmbedCardRenderer.tsx create mode 100644 src/app/state/bundle.ts diff --git a/package.json b/package.json index 1cc987ce9f..4bc46ab23a 100644 --- a/package.json +++ b/package.json @@ -160,7 +160,8 @@ "vite-plugin-svgr": "4.5.0", "vite-plugin-top-level-await": "^1.6.0", "vitest": "^4.1.9", - "wrangler": "^4.106.0" + "wrangler": "^4.106.0", + "htmlparser2": "^12.0.0" }, "devEngines": { "runtime": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ca5c94739..489053d32d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -338,6 +338,9 @@ importers: cloudflared: specifier: ^0.7.1 version: 0.7.1 + htmlparser2: + specifier: ^12.0.0 + version: 12.0.0 jsdom: specifier: ^29.1.1 version: 29.1.1(@noble/hashes@2.2.0) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4717beb3b4..8660d82489 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -30,7 +30,7 @@ "clipboard-manager:allow-write-image", { "identifier": "http:default", - "allow": [{ "url": "https://*" }] + "allow": [{ "url": "https://*", "url": "http://" }] }, { "identifier": "opener:allow-open-url", diff --git a/src/app/components/icons/phosphor.tsx b/src/app/components/icons/phosphor.tsx index e88273dd15..71407fb9d4 100644 --- a/src/app/components/icons/phosphor.tsx +++ b/src/app/components/icons/phosphor.tsx @@ -126,6 +126,7 @@ import { VideoCameraSlashIcon, WarningIcon, XIcon, + CircleNotchIcon, } from '@phosphor-icons/react'; import type { ComponentType, ReactNode } from 'react'; import { useLayoutEffect } from 'react'; @@ -436,4 +437,5 @@ export { VideoCameraSlashIcon as VideoCameraSlash, WarningIcon as Warning, XIcon as X, + CircleNotchIcon as CircleNotch, }; diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx new file mode 100644 index 0000000000..0672d05f38 --- /dev/null +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -0,0 +1,118 @@ +import {useMatrixClient} from "$hooks/useMatrixClient.ts"; +import {useMediaConfig} from "$hooks/useMediaConfig.ts"; +import {roomEmbedAtomFamily} from "$state/room/roomInputDrafts.ts"; +import {useMemo} from "react"; +import type {Opts as LinkifyOpts} from "linkifyjs"; +import {LINKIFY_OPTS} from "$plugins/react-custom-html-parser.tsx"; +import {useSpoilerClickHandler} from "$hooks/useSpoilerClickHandler.ts"; +import {useMediaAuthentication} from "$hooks/useMediaAuthentication.ts"; +import {useSettingsLinkBaseUrl} from "$features/settings/useSettingsLinkBaseUrl.ts"; +import {useSetting} from "$state/hooks/settings.ts"; +import {settingsAtom} from "$state/settings.ts"; +import {UploadCard} from "$components/upload-card/UploadCard.tsx"; +import {Box, color, IconButton, Text, toRem} from "folds"; +import {Check, Image, sizedIcon, X, CircleNotch} from "$components/icons/phosphor.tsx"; +import {EmbedStatus, useBindEmbedAtom} from "$state/bundle.ts"; + + +type EmbedCardRendererProps = { + url: string +} + +export function EmbedCardRenderer({url}: Readonly) { + const mx = useMatrixClient(); + const mediaConfig = useMediaConfig(); + const allowSize = mediaConfig['m.upload.size'] || Infinity; + + + + const linkifyOpts = useMemo(() => ({ ...LINKIFY_OPTS }), []); + + const spoilerClickHandler = useSpoilerClickHandler(); + const useAuthentication = useMediaAuthentication(); + const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); + const [incomingInlineImagesDefaultHeight] = useSetting( + settingsAtom, + 'incomingInlineImagesDefaultHeight' + ); + const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); + const embedAtom = roomEmbedAtomFamily(url); + const { embed, startEmbed, cancelEmbed } = useBindEmbedAtom(mx, embedAtom); + + if (embed.status === EmbedStatus.Idle) { + startEmbed(); + } + + return ( + + } + after={ + <> + + + {sizedIcon(X, '200')} + + + } + bottom={ + <> + {embed.status == EmbedStatus.Idle && <>{"Preview here"}} + {embed.status == EmbedStatus.Loading && + <> + + + + + + + {embed.preview?.title} + + + {embed.preview?.description || "No Description"} + + + + + + } + {embed.status == EmbedStatus.Error && + <> + Failed to generate + + } + + + + } + > + + {url} + + {embed.status == EmbedStatus.Success && + sizedIcon(Check, '100', { style: { color: color.Success.Main } })} + + {embed.status == EmbedStatus.Error && + sizedIcon(X, '100', { style: { color: color.Critical.Main } })} + + {(embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) && + sizedIcon(CircleNotch, '100', { style: { color: color.Primary.Main, animation: "spin 1s infinite linear" } })} + + ); +} \ No newline at end of file diff --git a/src/app/components/upload-card/index.ts b/src/app/components/upload-card/index.ts index e014ab8a8e..919a9a289a 100644 --- a/src/app/components/upload-card/index.ts +++ b/src/app/components/upload-card/index.ts @@ -1,3 +1,4 @@ export * from './UploadCard'; export * from './UploadCardRenderer'; export * from './CompactUploadCardRenderer'; +export * from './EmbedCardRenderer' \ No newline at end of file diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ed8b3ae4ca..50a28b7d1a 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -25,7 +25,7 @@ import { MatrixError } from '$types/matrix-sdk'; import { EventType, MsgType, RelationType } from '$types/matrix-sdk'; import { ReactEditor } from 'slate-react'; import { Editor, Point, Range, Transforms } from 'slate'; -import type { RectCords } from 'folds'; +import {RectCords, Switch} from 'folds'; import { Box, color, @@ -92,14 +92,15 @@ import { useTypingStatusUpdater } from '$hooks/useTypingStatusUpdater'; import { useFilePicker } from '$hooks/useFilePicker'; import { useFilePasteHandler } from '$hooks/useFilePasteHandler'; import { useFileDropZone } from '$hooks/useFileDrop'; -import type { TUploadItem, TUploadMetadata, IReplyDraft } from '$state/room/roomInputDrafts'; +import {TUploadItem, TUploadMetadata, IReplyDraft, TEmbeddItem} from '$state/room/roomInputDrafts'; import { roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, + roomIdToEmbeddItemsAtomFamily } from '$state/room/roomInputDrafts'; -import { UploadCardRenderer } from '$components/upload-card'; +import {UploadCardRenderer, EmbedCardRenderer, UploadCard} from '$components/upload-card'; import type { UploadBoardImperativeHandlers } from '$components/upload-board'; import { UploadBoard, UploadBoardContent, UploadBoardHeader } from '$components/upload-board'; import type { Upload, UploadSuccess } from '$state/upload'; @@ -176,7 +177,7 @@ import { Smiley, Sticker, Stop, - X, + X, sizedIcon, Check, } from '$components/icons/phosphor'; import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; import { ErrorCode } from '../../cs-errorcode'; @@ -369,6 +370,10 @@ export const RoomInput = forwardRef( const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey)); const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey)); + + const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(draftKey)); + const [embedsEnabled, setEmbedsEnabled] = useState(false); + const [uploadBoard, setUploadBoard] = useState(true); const [uploadSending, setUploadSending] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); @@ -501,6 +506,7 @@ export const RoomInput = forwardRef( const lastEncryptionPreparationAt = useRef(0); const handleEditorChange = useCallback(() => { setHasText(!isEmptyEditor(editor)); + checkForEmbedables(Editor.string(editor, [])); if (!room.hasEncryptionStateEvent()) return; const now = Date.now(); @@ -1705,6 +1711,34 @@ export const RoomInput = forwardRef( [editor, sendTypingStatus, hideActivity] ); + const checkForEmbedables = (text: string) => { + //from https://regex101.com/r/3fYy3x/1 + const URL_REGEX = RegExp(/http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm) + const urls = Array.from(text.matchAll(URL_REGEX) + //dont include the url if its in angled braces like cant do this in regex because braces are context free (yay computer science) + .filter((e) => !(e.index > 0 && text[e.index - 1] == '<' && text[e.index + e[0].length] == '>') ) + .map((e) => e[0])); + if (urls.length > 0) { + setUploadBoard(true); + const embedItems = urls.map((url): TEmbeddItem => ({ + url: url + })); + setEmbedLinks({ + type: 'CLEAR' + }); + setEmbedLinks({ + type: "PUT", + item: embedItems + }) + + } else { + setEmbedLinks({ + type: 'CLEAR' + }); + setEmbedsEnabled(false); + } + }; + const handleEmoticonSelect = (key: string, shortcode: string) => { const emoticonEl = createEmoticonElement(key, shortcode); if (autocompleteQuery) { @@ -1922,6 +1956,47 @@ export const RoomInput = forwardRef( } /> ))} + + {Array.from(embedLinks).length > 0 && (<>} + after={ + //fake close button to keep box size +
+ + {sizedIcon(X, '200')} + + +
} + bottom={<> + + + Bundled
Embeds +
+ + +
+ + } + > + + {"Links found"} + +
)} + {embedsEnabled && Array.from(embedLinks) + .toReversed() + .map((link) => ( + + + ))} )} diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts new file mode 100644 index 0000000000..219de12735 --- /dev/null +++ b/src/app/state/bundle.ts @@ -0,0 +1,257 @@ +import { atom, useAtom } from 'jotai'; +import { atomFamily } from 'jotai/utils'; +import type { MatrixClient, MatrixError } from '$types/matrix-sdk'; +import { useCallback } from 'react'; +import { useThrottle } from '$hooks/useThrottle'; +import {IPreviewUrlResponse} from "matrix-js-sdk"; +import type {UploadProgress, UploadResponse} from "$types/matrix-sdk.ts"; +import {isTauri} from "@tauri-apps/api/core"; +import { fetch as taurifetch} from '@tauri-apps/plugin-http'; +import { parseDocument, DomUtils } from 'htmlparser2'; +import { Element } from 'domhandler'; +export type EmbedPreview = { + title: string, + type: string, + description?: string, + imageMxc?: string, +} + +export enum EmbedStatus { + Idle = 'idle', + Loading = 'loading', + Success = 'success', + Error = 'error', +} + +export type EmbedIdle = { + url: string; + status: EmbedStatus.Idle; +}; + +export type EmbedLoading = { + url: string; + status: EmbedStatus.Loading; + preview?: EmbedPreview; + promise: Promise; +}; + +export type EmbedSuccess = { + url: string; + status: EmbedStatus.Success; + preview: EmbedPreview; + data: IPreviewUrlResponse; +}; + +export type EmbedError = { + url: string; + status: EmbedStatus.Error; + reason: EmbedErrorReason; +}; + +export type Embed = EmbedIdle | EmbedLoading | EmbedSuccess | EmbedError; + +type EmbedAtomAction = + | { + promise: Promise; +} + | { + progress: EmbedPreview | undefined; +} + | { + data: IPreviewUrlResponse; + progress: EmbedPreview; +} + | { + error: EmbedErrorReason; +}; + +export enum EmbedErrorReason { + RequestFailed, + NoOgData, +} + +export type fetchEmbedOptions = { + onPromise?: (promise: Promise) => void; + onProgress?: (progress: EmbedPreview) => void; + onSuccess: (embed: IPreviewUrlResponse, progress: EmbedPreview) => void; + onError: (reason: EmbedErrorReason) => void; +}; + +const hasProperty = (dom: Element, value: string) => { + return value === dom.attribs?.property +}; + +const parseOg = ( html: string ) => { + const dom = parseDocument(html); + let head = DomUtils.getElementsByTagName("head", dom); + let metaTags = DomUtils.getElementsByTagName("meta", head); + + + let alternatives: Record = { + "og:title": [["name", "title"]], + "og:description": [["name", "description"]], + } + + let requiredTags = [ "og:title", "og:type","og:url"] + let optionalStringTags = ["og:image", "og:image:type", "og:description", "og:image:alt"]; + let optionalNumberTags = ["og:image:height", "og:image:width"] + let result: IPreviewUrlResponse = {} + for (const ogTag of requiredTags) { + let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) + if (!tag || !tag.attribs?.content) { + let altList = alternatives[ogTag] + if (!altList) + return null + while (!tag || !tag.attribs?.content) { + let next = altList.pop() + if (!next) + return null + tag = DomUtils.findOne((e) => e.attribs?.[(next[0])] == next[1], metaTags) + } + } + result[ogTag] = tag.attribs?.content + } + for (const ogTag of optionalStringTags) { + let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) + result[ogTag] = tag?.attribs?.content + } + for (const ogTag of optionalNumberTags) { + let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) + if (!tag?.attribs?.content) + continue; + result[ogTag] = Number.parseInt(tag?.attribs?.content) + } + + return result +} + +const fetchEmbed = async ( + url: string, + mx: MatrixClient, + fetchEmbedOptions: fetchEmbedOptions) => { + if (!fetchEmbedOptions.onProgress) + fetchEmbedOptions.onProgress = (_) => {}; + if (!fetchEmbedOptions.onPromise) + fetchEmbedOptions.onPromise = (_) => {} + //TODO filter local ips + let fetchResponse; + try { + let fetchPromise = isTauri() ? taurifetch(url) : fetch(url) + fetchEmbedOptions.onPromise(fetchPromise); + fetchResponse = await fetchPromise; + + } catch (e) { + console.error(e); + fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + return + } + if (fetchResponse.ok) { + if (fetchResponse.headers.get('content-type')?.split(";")[0] == 'text/html') { + let prelimEmbed = parseOg(await fetchResponse.text()) + if (!prelimEmbed) { + fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); + return + } + + let preview: EmbedPreview = {description: prelimEmbed?.["og:description"], title: prelimEmbed?.["og:title"], type: prelimEmbed?.["og:type"]} + fetchEmbedOptions.onProgress(preview) + + } else { + fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); + } + } else { + fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + } + + + + +} + +export const createEmbedAtom = (url: string) => { + const baseEmbedAtom = atom({ + url, + status: EmbedStatus.Idle, + }); + return atom( + (get) => get(baseEmbedAtom), + (get, set, update) => { + const embedState = get(baseEmbedAtom); + if ('promise' in update) { + set(baseEmbedAtom, { + status: EmbedStatus.Loading, + url, + promise: update.promise, + }); + return; + } + if ('progress' in update && embedState.status === EmbedStatus.Loading) { + set(baseEmbedAtom, { + ...embedState, + preview: update.progress, + }); + return; + } + if ('data' in update) { + set(baseEmbedAtom, { + status: EmbedStatus.Success, + url, + data: update.data, + preview: update.progress, + }); + return; + } + if ('error' in update) { + set(baseEmbedAtom, { + status: EmbedStatus.Error, + url, + reason: update.error, + }); + } + } + ); +}; +export type TEmbedAtom = ReturnType; + +export const useBindEmbedAtom = ( + mx: MatrixClient, + embedAtom: TEmbedAtom +) => { + const [embed, setEmbed] = useAtom(embedAtom); + const { url } = embed; + + + + const startEmbed = useCallback( + () => + fetchEmbed(url, mx, { + onSuccess: (data, progress) => setEmbed({ data, progress }), + onError: (error) => setEmbed({ error }), + onProgress: (progress) => setEmbed({ progress }), + onPromise: promise => setEmbed({ promise }) + }), + [url, mx ] + ); + + const cancelEmbed = useCallback(() => { + if (embed.status === EmbedStatus.Loading) { + //TODO + } + }, [mx, embed]); + + return { + embed, + startEmbed, + cancelEmbed, + }; +}; + +export const createEmbedAtomFamily = () => + atomFamily(createEmbedAtom); +export type TEmbedAtomFamily = ReturnType; + +export const createEmbedFamilyObserverAtom = ( + uploadFamily: TEmbedAtomFamily, + uploads: string[] +) => atom((get) => uploads.map((upload) => get(uploadFamily(upload)))); +export type TEmbedFamilyObserverAtom = ReturnType; diff --git a/src/app/state/list.ts b/src/app/state/list.ts index 2749f70bdb..265f96324e 100644 --- a/src/app/state/list.ts +++ b/src/app/state/list.ts @@ -13,13 +13,23 @@ type ListAction = | { type: 'DELETE'; item: T | T[]; - }; + } + | { + type: 'CLEAR'; +}; export const createListAtom = () => { const baseListAtom = atom([]); return atom], undefined>( (get) => get(baseListAtom), (get, set, action) => { + if (action.type === 'CLEAR') { + set( + baseListAtom, + [] + ); + return; + } const items = get(baseListAtom); const newItems = Array.isArray(action.item) ? action.item : [action.item]; if (action.type === 'DELETE') { diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 10525f64a5..7b08aa2147 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -4,8 +4,9 @@ import type { Descendant } from 'slate'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IEventRelation } from '$types/matrix-sdk'; import type { TUploadContent } from '$utils/matrix'; -import { createUploadAtomFamily } from '$state/upload'; +import {createUploadAtom, createUploadAtomFamily, TUploadAtom} from '$state/upload'; import { createListAtom } from '$state/list'; +import {createEmbedAtomFamily} from "$state/bundle.ts"; export type TUploadMetadata = { markedAsSpoiler: boolean; @@ -30,6 +31,16 @@ export const roomIdToUploadItemsAtomFamily = atomFamily export const roomUploadAtomFamily = createUploadAtomFamily(); + +export type TEmbeddItem = { + url: string; +}; + +type TEmbeddListAtom = ReturnType>; + +export const roomIdToEmbeddItemsAtomFamily = atomFamily(createListAtom); +export const roomEmbedAtomFamily = createEmbedAtomFamily(); + const createMsgDraftAtom = () => atom([]); type TMsgDraftAtom = ReturnType; export const roomIdToMsgDraftAtomFamily = atomFamily(() => From 43abe1fff08c3a0bfcdf54bcc8f8b4605d43fb4e Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 3 Aug 2026 21:22:05 +0200 Subject: [PATCH 02/25] very hacky but working sending of bundled embeds --- .../upload-card/EmbedCardRenderer.tsx | 91 ++++-- src/app/features/room/RoomInput.tsx | 297 +++++++++--------- src/app/state/bundle.ts | 108 +++++-- src/app/state/room/roomInputDrafts.ts | 1 + 4 files changed, 316 insertions(+), 181 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index 0672d05f38..9bb68ae566 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -11,22 +11,61 @@ import {useSetting} from "$state/hooks/settings.ts"; import {settingsAtom} from "$state/settings.ts"; import {UploadCard} from "$components/upload-card/UploadCard.tsx"; import {Box, color, IconButton, Text, toRem} from "folds"; -import {Check, Image, sizedIcon, X, CircleNotch} from "$components/icons/phosphor.tsx"; -import {EmbedStatus, useBindEmbedAtom} from "$state/bundle.ts"; +import {Check, CircleNotch, File as FileIcon, Image, sizedIcon, X} from "$components/icons/phosphor.tsx"; +import {EmbedErrorReason, EmbedStatus, useBindEmbedAtom} from "$state/bundle.ts"; +import {Image as MediaImage} from "$components/media"; +import {useObjectURL} from "$hooks/useObjectURL.ts"; +import {isImageMimeType} from "$utils/mimeTypes.ts"; +import {IPreviewUrlResponse} from "matrix-js-sdk"; type EmbedCardRendererProps = { - url: string + url: string, + successCallback: (result: IPreviewUrlResponse) => void, } -export function EmbedCardRenderer({url}: Readonly) { +function prettyError(error: EmbedErrorReason) { + switch (error) { + //TODO localize + case EmbedErrorReason.RequestFailed: + return "Failed to fetch url" + case EmbedErrorReason.NoOgData: + return "Url doesnt provide previews" + + } +} + +interface MediaProps { + data: Blob +} + +function Media({data}: Readonly) { + const fileUrl = useObjectURL(data); + if (isImageMimeType(data.type)) { + return ( + + ); + } + + return (); + + +} + +export function EmbedCardRenderer({url, successCallback}: Readonly) { const mx = useMatrixClient(); const mediaConfig = useMediaConfig(); const allowSize = mediaConfig['m.upload.size'] || Infinity; - - const linkifyOpts = useMemo(() => ({ ...LINKIFY_OPTS }), []); + const linkifyOpts = useMemo(() => ({...LINKIFY_OPTS}), []); const spoilerClickHandler = useSpoilerClickHandler(); const useAuthentication = useMediaAuthentication(); @@ -37,17 +76,17 @@ export function EmbedCardRenderer({url}: Readonly) { ); const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); const embedAtom = roomEmbedAtomFamily(url); - const { embed, startEmbed, cancelEmbed } = useBindEmbedAtom(mx, embedAtom); + const {embed, startEmbed, cancelEmbed} = useBindEmbedAtom(mx, embedAtom); if (embed.status === EmbedStatus.Idle) { - startEmbed(); + startEmbed(successCallback); } return ( } @@ -67,13 +106,15 @@ export function EmbedCardRenderer({url}: Readonly) { bottom={ <> {embed.status == EmbedStatus.Idle && <>{"Preview here"}} - {embed.status == EmbedStatus.Loading && + {(embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Success) && <> - - - + + + {embed.preview?.media && } + {!(embed.preview?.media) && } - + {embed.preview?.title} @@ -86,23 +127,23 @@ export function EmbedCardRenderer({url}: Readonly) { } {embed.status == EmbedStatus.Error && - <> - Failed to generate - + + {prettyError(embed.reason)} + } } > - + {url} {embed.status == EmbedStatus.Success && - sizedIcon(Check, '100', { style: { color: color.Success.Main } })} + sizedIcon(Check, '100', {style: {color: color.Success.Main}})} {embed.status == EmbedStatus.Error && - sizedIcon(X, '100', { style: { color: color.Critical.Main } })} + sizedIcon(X, '100', {style: {color: color.Critical.Main}})} + {embed.status == EmbedStatus.Loading && + {embed.progress} + } {(embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) && - sizedIcon(CircleNotch, '100', { style: { color: color.Primary.Main, animation: "spin 1s infinite linear" } })} + sizedIcon(CircleNotch, '100', { + style: { + color: color.Primary.Main, + animation: "spin 1s infinite linear" + } + })} ); } \ No newline at end of file diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 50a28b7d1a..4b81af33c0 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1,31 +1,19 @@ -import type { KeyboardEventHandler, MouseEvent, ReactElement, RefObject } from 'react'; -import { - forwardRef, - Fragment, - lazy, - Suspense, - useCallback, - useEffect, - useRef, - useState, - useMemo, -} from 'react'; -import { useAtom, useAtomValue, useSetAtom } from 'jotai'; - -import { isKeyHotkey } from 'is-hotkey'; +import type {KeyboardEventHandler, MouseEvent, ReactElement, RefObject} from 'react'; +import {forwardRef, Fragment, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState,} from 'react'; +import {useAtom, useAtomValue, useSetAtom} from 'jotai'; + +import {isKeyHotkey} from 'is-hotkey'; import type { IContent, + IEventRelation, MatrixEvent, Room, - IEventRelation, RoomMessageEventContent, StickerEventContent, } from '$types/matrix-sdk'; -import { MatrixError } from '$types/matrix-sdk'; -import { EventType, MsgType, RelationType } from '$types/matrix-sdk'; -import { ReactEditor } from 'slate-react'; -import { Editor, Point, Range, Transforms } from 'slate'; -import {RectCords, Switch} from 'folds'; +import {EventType, MatrixError, MsgType, RelationType} from '$types/matrix-sdk'; +import {ReactEditor} from 'slate-react'; +import {Editor, Point, Range, Transforms} from 'slate'; import { Box, color, @@ -38,115 +26,110 @@ import { OverlayBackdrop, OverlayCenter, PopOut, + RectCords, Scroll, Spinner, + Switch, Text, - toRem, + toRem } from 'folds'; -import { useMatrixClient } from '$hooks/useMatrixClient'; -import type { AutocompleteQuery } from '$components/editor'; +import {useMatrixClient} from '$hooks/useMatrixClient'; +import type {AutocompleteQuery} from '$components/editor'; import { + ANYWHERE_AUTOCOMPLETE_PREFIXES, AutocompletePrefix, + BEGINNING_AUTOCOMPLETE_PREFIXES, + BlockType, createEmoticonElement, CustomEditor, customHtmlEqualsPlainText, + EmoticonAutocomplete, + focusEditor, getAutocompleteQuery, + getBeginCommand, + getLinks, + getMentions, getPrevWorldRange, + isEmptyEditor, + MarkdownFormattingToolbarBottom, + MarkdownFormattingToolbarToggle, + moveCursor, + replaceWithElement, resetEditor, + resetEditorHistory, RoomMentionAutocomplete, toMatrixCustomHTML, toPlainText, + trimCommand, trimCustomHtml, UserMentionAutocomplete, - EmoticonAutocomplete, - moveCursor, - resetEditorHistory, - isEmptyEditor, - getBeginCommand, - trimCommand, - getMentions, - ANYWHERE_AUTOCOMPLETE_PREFIXES, - BEGINNING_AUTOCOMPLETE_PREFIXES, - getLinks, - MarkdownFormattingToolbarBottom, - MarkdownFormattingToolbarToggle, - focusEditor, - replaceWithElement, - BlockType, } from '$components/editor'; -import { stripMarkdownEscapesForHiddenPreviews } from './message/hiddenLinkPreviews'; -import { plainToEditorInput } from '$components/editor/input'; -import type { GifData } from '$components/emoji-board'; -import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; -import { UseStateProvider } from '$components/UseStateProvider'; -import type { TUploadContent } from '$utils/matrix'; -import { - cancelUploadContent, - encryptFile, - getImageInfo, - mxcUrlToHttp, - toggleReaction, -} from '$utils/matrix'; -import { useTypingStatusUpdater } from '$hooks/useTypingStatusUpdater'; -import { useFilePicker } from '$hooks/useFilePicker'; -import { useFilePasteHandler } from '$hooks/useFilePasteHandler'; -import { useFileDropZone } from '$hooks/useFileDrop'; -import {TUploadItem, TUploadMetadata, IReplyDraft, TEmbeddItem} from '$state/room/roomInputDrafts'; +import {stripMarkdownEscapesForHiddenPreviews} from './message/hiddenLinkPreviews'; +import {plainToEditorInput} from '$components/editor/input'; +import type {GifData} from '$components/emoji-board'; +import {EmojiBoard, EmojiBoardTab} from '$components/emoji-board'; +import {UseStateProvider} from '$components/UseStateProvider'; +import type {TUploadContent} from '$utils/matrix'; +import {cancelUploadContent, encryptFile, getImageInfo, mxcUrlToHttp, toggleReaction,} from '$utils/matrix'; +import {useTypingStatusUpdater} from '$hooks/useTypingStatusUpdater'; +import {useFilePicker} from '$hooks/useFilePicker'; +import {useFilePasteHandler} from '$hooks/useFilePasteHandler'; +import {useFileDropZone} from '$hooks/useFileDrop'; import { + IReplyDraft, + roomEmbedAtomFamily, + roomIdToEmbeddItemsAtomFamily, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, - roomIdToEmbeddItemsAtomFamily + TEmbeddItem, + TUploadItem, + TUploadMetadata } from '$state/room/roomInputDrafts'; -import {UploadCardRenderer, EmbedCardRenderer, UploadCard} from '$components/upload-card'; -import type { UploadBoardImperativeHandlers } from '$components/upload-board'; -import { UploadBoard, UploadBoardContent, UploadBoardHeader } from '$components/upload-board'; -import type { Upload, UploadSuccess } from '$state/upload'; -import { UploadStatus, createUploadFamilyObserverAtom } from '$state/upload'; -import { loadImageElementFromMediaUrl } from '$utils/dom'; -import { isImageMimeType, safeUploadFile } from '$utils/mimeTypes'; -import { fulfilledPromiseSettledResult } from '$utils/common'; -import { useSetting } from '$state/hooks/settings'; -import type { EditorButtonId } from '$state/settings'; -import { settingsAtom } from '$state/settings'; -import { matchesShortcut } from '../../keyboard/shortcuts'; -import { getEditedEvent, getMentionContent, getThreadReplyEvents } from '$utils/room/relations'; -import { buildReplacementContent } from './buildReplacementContent'; -import { htmlToMarkdown } from '$plugins/markdown'; -import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands'; -import { isMobileOrTablet } from '$utils/platform'; -import { Reply, ThreadIndicator } from '$components/message'; -import { roomToParentsAtom } from '$state/room/roomToParents'; -import { nicknamesAtom } from '$state/nicknames'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; -import { useImagePackRooms } from '$hooks/useImagePackRooms'; -import { useComposingCheck } from '$hooks/useComposingCheck'; -import { createLogger } from '$utils/debug'; -import { createDebugLogger } from '$utils/debugLogger'; +import {EmbedCardRenderer, UploadCard, UploadCardRenderer} from '$components/upload-card'; +import type {UploadBoardImperativeHandlers} from '$components/upload-board'; +import {UploadBoard, UploadBoardContent, UploadBoardHeader} from '$components/upload-board'; +import type {Upload, UploadSuccess} from '$state/upload'; +import {createUploadFamilyObserverAtom, UploadStatus} from '$state/upload'; +import {loadImageElementFromMediaUrl} from '$utils/dom'; +import {isImageMimeType, safeUploadFile} from '$utils/mimeTypes'; +import {fulfilledPromiseSettledResult} from '$utils/common'; +import {useSetting} from '$state/hooks/settings'; +import type {EditorButtonId} from '$state/settings'; +import {settingsAtom} from '$state/settings'; +import {matchesShortcut} from '../../keyboard/shortcuts'; +import {getEditedEvent, getMentionContent, getThreadReplyEvents} from '$utils/room/relations'; +import {buildReplacementContent} from './buildReplacementContent'; +import {htmlToMarkdown} from '$plugins/markdown'; +import {Command, SHRUG, TABLEFLIP, UNFLIP, useCommands} from '$hooks/useCommands'; +import {isMobileOrTablet} from '$utils/platform'; +import {Reply, ThreadIndicator} from '$components/message'; +import {roomToParentsAtom} from '$state/room/roomToParents'; +import {nicknamesAtom} from '$state/nicknames'; +import {useMediaAuthentication} from '$hooks/useMediaAuthentication'; +import {useImagePackRooms} from '$hooks/useImagePackRooms'; +import {useComposingCheck} from '$hooks/useComposingCheck'; +import {createLogger} from '$utils/debug'; +import {createDebugLogger} from '$utils/debugLogger'; import FocusTrap from 'focus-trap-react'; -import { useQueryClient } from '@tanstack/react-query'; +import {useQueryClient} from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import { delayedEventsSupportedAtom, - roomIdToScheduledTimeAtomFamily, roomIdToEditingScheduledDelayIdAtomFamily, + roomIdToScheduledTimeAtomFamily, serverMaxDelayMsAtom, } from '$state/scheduledMessages'; -import { - sendDelayedMessage, - sendDelayedMessageE2EE, - computeDelayMs, - cancelDelayedEvent, -} from '$utils/delayedEvents'; -import { timeHourMinute, timeDayMonthYear, daysToMs } from '$utils/time'; -import { stopPropagation } from '$utils/keyboard'; - -import { usePowerLevelsContext } from '$hooks/usePowerLevels'; -import { useRoomCreators } from '$hooks/useRoomCreators'; -import { useRoomPermissions } from '$hooks/useRoomPermissions'; -import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; +import {cancelDelayedEvent, computeDelayMs, sendDelayedMessage, sendDelayedMessageE2EE,} from '$utils/delayedEvents'; +import {daysToMs, timeDayMonthYear, timeHourMinute} from '$utils/time'; +import {stopPropagation} from '$utils/keyboard'; + +import {usePowerLevelsContext} from '$hooks/usePowerLevels'; +import {useRoomCreators} from '$hooks/useRoomCreators'; +import {useRoomPermissions} from '$hooks/useRoomPermissions'; +import {AutocompleteNotice} from '$components/editor/autocomplete/AutocompleteNotice'; import { convertPerMessageProfileToBeeperFormat, getCurrentlyUsedPerMessageProfileForAccount, @@ -164,6 +147,7 @@ import { composerIcon, dropzoneIcon, File as FileIcon, + getPhosphorIconSize, Gif, Image as ImageIcon, ListBullets, @@ -172,53 +156,51 @@ import { Microphone, PaperPlaneTilt, PencilSimple, - getPhosphorIconSize, PlusCircle, + sizedIcon, Smiley, Sticker, Stop, - X, sizedIcon, Check, + X, } from '$components/icons/phosphor'; -import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; -import { ErrorCode } from '../../cs-errorcode'; -import { sanitizeText } from '$utils/sanitize'; -import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; -import { PKitProxyMessageHandler } from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; -import type { IGenericMSC4459, MSC4459ImagePackReference } from '$types/matrix/common'; -import { - getImagePackReferencesForMxc, - getImagePackReferencesForMxcWrappedInMap, -} from '$utils/msc4459helper'; -import { ImageUsage } from '$plugins/custom-emoji'; -import { getPackImageInfo } from '$plugins/custom-emoji/utils'; -import { SerializableMap } from '$types/wrapper/SerializableMap'; -import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; +import {getSupportedAudioExtension} from '$plugins/voice-recorder-kit/supportedCodec'; +import {ErrorCode} from '../../cs-errorcode'; +import {sanitizeText} from '$utils/sanitize'; +import {PKitCommandMessageHandler} from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; +import {PKitProxyMessageHandler} from '$plugins/pluralkit-handler/PKitProxyMessageHandler'; +import type {IGenericMSC4459, MSC4459ImagePackReference} from '$types/matrix/common'; +import {getImagePackReferencesForMxc, getImagePackReferencesForMxcWrappedInMap,} from '$utils/msc4459helper'; +import {ImageUsage} from '$plugins/custom-emoji'; +import {getPackImageInfo} from '$plugins/custom-emoji/utils'; +import {SerializableMap} from '$types/wrapper/SerializableMap'; +import {useSettingsLinkBaseUrl} from '$features/settings/useSettingsLinkBaseUrl'; import * as messageCss from '$features/room/message/styles.css'; -import { AttachmentContent } from '$components/attachment-sheet/AttachmentContent'; -import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal'; -import { SchedulePickerDialog } from './schedule-send'; +import {AttachmentContent} from '$components/attachment-sheet/AttachmentContent'; +import {MobileSwipeDownModal} from '$components/MobileSwipeDownModal'; +import {SchedulePickerDialog} from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { + buildGalleryContent, getAudioMsgContent, getFileMsgContent, + getGalleryItemContent, + getGifMsgContent, getImageMsgContent, getVideoMsgContent, - getGifMsgContent, - buildGalleryContent, - getGalleryItemContent, } from './msgContent'; -import { outgoingMessageTransforms } from './outgoingMessageTransforms'; -import { getSendableKlipyMxcUrl } from '$utils/klipy'; -import { CommandAutocomplete } from './CommandAutocomplete'; -import type { - AudioMessageRecorderHandle, - AudioRecordingCompletePayload, -} from './AudioMessageRecorder'; -import { AudioMessageRecorder } from './AudioMessageRecorder'; +import {outgoingMessageTransforms} from './outgoingMessageTransforms'; +import {getSendableKlipyMxcUrl} from '$utils/klipy'; +import {CommandAutocomplete} from './CommandAutocomplete'; +import type {AudioMessageRecorderHandle, AudioRecordingCompletePayload,} from './AudioMessageRecorder'; +import {AudioMessageRecorder} from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; -import { PollDialog } from './poll-modals'; -import { useClientConfig } from '$hooks/useClientConfig'; -import { PersistentPersonaPicker, type PersonaPickerTab } from './persona-picker/PersonaPicker.tsx'; +import {PollDialog} from './poll-modals'; +import {useClientConfig} from '$hooks/useClientConfig'; +import {PersistentPersonaPicker, type PersonaPickerTab} from './persona-picker/PersonaPicker.tsx'; +import {EmbedStatus, useBindEmbedAtom} from "$state/bundle.ts"; +import {IPreviewUrlResponse} from "matrix-js-sdk"; + +const embedmap: Map = new Map(); const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -1031,6 +1013,18 @@ export const RoomInput = forwardRef( await handleSendContents(contents); }; + /* const handleSendEmbeds = async (embeds: Embed[]) { + if (embedsEnabled) { + for (const embedLink of embeds) { + const embedAtom = roomEmbedAtomFamily(embedLink.url); + const {embed} = useBindEmbedAtom(mx, embedAtom); + if (embed.status == EmbedStatus.Success) { + em + } + } + } + }*/ + const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery((prev) => { if (prev !== undefined) { @@ -1141,6 +1135,8 @@ export const RoomInput = forwardRef( return; } + + const commandName = getBeginCommand(editor); /** * a map of regex patterns to replace nicknames with, @@ -1366,13 +1362,25 @@ export const RoomInput = forwardRef( content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsedRef.current.toJSON(); - const links = getLinks(serializedChildren); + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; - links?.forEach((link) => - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ - matched_url: link, - }) - ); + if (embedsEnabled) { + for (const embedLink of embedLinks) { + const result = embedmap.get(embedLink.url) + if (result) { + result["matched_url"] = embedLink.url; + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(result); + } + } + } else { + const links = getLinks(serializedChildren); + links?.forEach((link) => { + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ + matched_url: link, + })} + ); + } + if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) { content.format = 'org.matrix.custom.html'; @@ -1570,6 +1578,9 @@ export const RoomInput = forwardRef( getEditingContent, onCancelEdit, latchedPersona, + embedsEnabled, + embedLinks, + embedmap ]); const handleKeyDown: KeyboardEventHandler = useCallback( @@ -1916,7 +1927,7 @@ export const RoomInput = forwardRef( forceMultilineLayout={showAudioRecorder} top={ <> - {selectedFiles.length > 0 && ( + {(selectedFiles.length > 0 || embedLinks.length > 0) && ( ( /> } > - {uploadBoard && ( + {(uploadBoard) && ( {Array.from(selectedFiles) @@ -1957,7 +1968,9 @@ export const RoomInput = forwardRef( /> ))} - {Array.from(embedLinks).length > 0 && (<> 0 ) && (<> + {selectedFiles.length > 0 && } + ( .toReversed() .map((link) => ( - + { + embedmap.set(link.url, result); + }}/> ))} diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 219de12735..60374859f0 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -9,11 +9,20 @@ import {isTauri} from "@tauri-apps/api/core"; import { fetch as taurifetch} from '@tauri-apps/plugin-http'; import { parseDocument, DomUtils } from 'htmlparser2'; import { Element } from 'domhandler'; +import {object} from "badwords-list"; +import {uploadContent, uploadContentToServer} from "$utils/matrix.ts"; +import {useObjectURL} from "$hooks/useObjectURL.ts"; + +export enum EmbedPreviewType { + TitleDescription, + MediaOnly, + TitleDescriptionMedia +} export type EmbedPreview = { title: string, - type: string, + type: EmbedPreviewType, description?: string, - imageMxc?: string, + media?: Blob, } export enum EmbedStatus { @@ -23,6 +32,12 @@ export enum EmbedStatus { Error = 'error', } +export enum LoadingStatus { + Html = 'fetching html', + MediaDown = 'downloading media ', + MediaUp = 'uploading media' +} + export type EmbedIdle = { url: string; status: EmbedStatus.Idle; @@ -31,6 +46,7 @@ export type EmbedIdle = { export type EmbedLoading = { url: string; status: EmbedStatus.Loading; + progress: LoadingStatus; preview?: EmbedPreview; promise: Promise; }; @@ -55,11 +71,12 @@ type EmbedAtomAction = promise: Promise; } | { - progress: EmbedPreview | undefined; + progress: LoadingStatus; + preview: EmbedPreview | undefined; } | { data: IPreviewUrlResponse; - progress: EmbedPreview; + preview: EmbedPreview; } | { error: EmbedErrorReason; @@ -72,7 +89,7 @@ export enum EmbedErrorReason { export type fetchEmbedOptions = { onPromise?: (promise: Promise) => void; - onProgress?: (progress: EmbedPreview) => void; + onProgress?: (preview: EmbedPreview, progress: LoadingStatus) => void; onSuccess: (embed: IPreviewUrlResponse, progress: EmbedPreview) => void; onError: (reason: EmbedErrorReason) => void; }; @@ -95,7 +112,7 @@ const parseOg = ( html: string ) => { let requiredTags = [ "og:title", "og:type","og:url"] let optionalStringTags = ["og:image", "og:image:type", "og:description", "og:image:alt"]; let optionalNumberTags = ["og:image:height", "og:image:width"] - let result: IPreviewUrlResponse = {} + let result = new Map(); for (const ogTag of requiredTags) { let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) if (!tag || !tag.attribs?.content) { @@ -109,25 +126,33 @@ const parseOg = ( html: string ) => { tag = DomUtils.findOne((e) => e.attribs?.[(next[0])] == next[1], metaTags) } } - result[ogTag] = tag.attribs?.content + result.set(ogTag, tag.attribs?.content); } for (const ogTag of optionalStringTags) { let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) - result[ogTag] = tag?.attribs?.content + result.set(ogTag, tag?.attribs?.content); } for (const ogTag of optionalNumberTags) { let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) if (!tag?.attribs?.content) continue; - result[ogTag] = Number.parseInt(tag?.attribs?.content) + result.set(ogTag, Number.parseInt(tag?.attribs?.content)); } return result } + +function fetchWrapper(url: string) { + return isTauri() ? taurifetch(url) : fetch(url) +} + + + const fetchEmbed = async ( url: string, mx: MatrixClient, + successCallback: (result: IPreviewUrlResponse) => void, fetchEmbedOptions: fetchEmbedOptions) => { if (!fetchEmbedOptions.onProgress) fetchEmbedOptions.onProgress = (_) => {}; @@ -136,7 +161,7 @@ const fetchEmbed = async ( //TODO filter local ips let fetchResponse; try { - let fetchPromise = isTauri() ? taurifetch(url) : fetch(url) + let fetchPromise = fetchWrapper(url) fetchEmbedOptions.onPromise(fetchPromise); fetchResponse = await fetchPromise; @@ -153,8 +178,51 @@ const fetchEmbed = async ( return } - let preview: EmbedPreview = {description: prelimEmbed?.["og:description"], title: prelimEmbed?.["og:title"], type: prelimEmbed?.["og:type"]} - fetchEmbedOptions.onProgress(preview) + let supportedMediaTags = ["og:image"]; + const hasSupportedMedia = prelimEmbed.keys().some(item => supportedMediaTags.includes(item)) + + + let preview: EmbedPreview = { + description: prelimEmbed.get("og:description"), + title: prelimEmbed.get("og:title"), + type: hasSupportedMedia ? EmbedPreviewType.TitleDescriptionMedia : EmbedPreviewType.TitleDescription + } + fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown) + + let embedRecord: IPreviewUrlResponse = Object.fromEntries(prelimEmbed) + if (!hasSupportedMedia) { + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + } else { + let keyArray = prelimEmbed.keys().toArray(); + if (embedRecord["og:image"] && embedRecord["og:image:type"]) { + const response = await fetchWrapper(embedRecord["og:image"]) + const imgData = await response.blob(); + fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaUp) + preview.media = imgData; + await uploadContent(mx, new File([imgData], "mediaPreview"), { + onError: _ => { + preview.type = EmbedPreviewType.TitleDescription; + for (const e of keyArray) { + if (e.startsWith("og:image")) { + embedRecord[e] = undefined; + } + } + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + }, + onSuccess: (mxc) => { + embedRecord["og:image"] = mxc; + embedRecord["matrix:image:size"] = imgData.size + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + } + }) + + + } + } + } else { fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); @@ -181,6 +249,7 @@ export const createEmbedAtom = (url: string) => { set(baseEmbedAtom, { status: EmbedStatus.Loading, url, + progress: LoadingStatus.Html, promise: update.promise, }); return; @@ -188,7 +257,8 @@ export const createEmbedAtom = (url: string) => { if ('progress' in update && embedState.status === EmbedStatus.Loading) { set(baseEmbedAtom, { ...embedState, - preview: update.progress, + progress: update.progress, + preview: update.preview, }); return; } @@ -197,7 +267,7 @@ export const createEmbedAtom = (url: string) => { status: EmbedStatus.Success, url, data: update.data, - preview: update.progress, + preview: update.preview, }); return; } @@ -215,7 +285,7 @@ export type TEmbedAtom = ReturnType; export const useBindEmbedAtom = ( mx: MatrixClient, - embedAtom: TEmbedAtom + embedAtom: TEmbedAtom, ) => { const [embed, setEmbed] = useAtom(embedAtom); const { url } = embed; @@ -223,11 +293,11 @@ export const useBindEmbedAtom = ( const startEmbed = useCallback( - () => - fetchEmbed(url, mx, { - onSuccess: (data, progress) => setEmbed({ data, progress }), + (successCallback: (result: IPreviewUrlResponse) => void,) => + fetchEmbed(url, mx, successCallback, { + onSuccess: (data, progress) => setEmbed({ data, preview: progress }), onError: (error) => setEmbed({ error }), - onProgress: (progress) => setEmbed({ progress }), + onProgress: (preview, progress) => setEmbed({ preview, progress }), onPromise: promise => setEmbed({ promise }) }), [url, mx ] diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 7b08aa2147..6c5fa4b982 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -7,6 +7,7 @@ import type { TUploadContent } from '$utils/matrix'; import {createUploadAtom, createUploadAtomFamily, TUploadAtom} from '$state/upload'; import { createListAtom } from '$state/list'; import {createEmbedAtomFamily} from "$state/bundle.ts"; +import {IPreviewUrlResponse} from "matrix-js-sdk"; export type TUploadMetadata = { markedAsSpoiler: boolean; From 4cf667a6e53cac6aeaefac3edf82ba1894d7f391 Mon Sep 17 00:00:00 2001 From: Florian Date: Tue, 4 Aug 2026 13:59:21 +0200 Subject: [PATCH 03/25] encrypt embeds in encrypted rooms --- .../upload-card/EmbedCardRenderer.tsx | 13 +- src/app/features/room/RoomInput.tsx | 19 +-- src/app/state/bundle.ts | 130 ++++++++++++------ src/unstable/prefixes/misc.ts | 3 + 4 files changed, 102 insertions(+), 63 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index 9bb68ae566..ab7d333713 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -12,7 +12,7 @@ import {settingsAtom} from "$state/settings.ts"; import {UploadCard} from "$components/upload-card/UploadCard.tsx"; import {Box, color, IconButton, Text, toRem} from "folds"; import {Check, CircleNotch, File as FileIcon, Image, sizedIcon, X} from "$components/icons/phosphor.tsx"; -import {EmbedErrorReason, EmbedStatus, useBindEmbedAtom} from "$state/bundle.ts"; +import {EmbedErrorReason, EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; import {Image as MediaImage} from "$components/media"; import {useObjectURL} from "$hooks/useObjectURL.ts"; import {isImageMimeType} from "$utils/mimeTypes.ts"; @@ -21,12 +21,17 @@ import {IPreviewUrlResponse} from "matrix-js-sdk"; type EmbedCardRendererProps = { url: string, - successCallback: (result: IPreviewUrlResponse) => void, + successCallback: (result: FixedPreviewUrlResponse) => void, + encrypt: boolean, } function prettyError(error: EmbedErrorReason) { switch (error) { //TODO localize + case EmbedErrorReason.UploadFailed: + return "Failed to upload"; + case EmbedErrorReason.EncryptFailed: + return "Failed to encrypt"; case EmbedErrorReason.RequestFailed: return "Failed to fetch url" case EmbedErrorReason.NoOgData: @@ -59,7 +64,7 @@ function Media({data}: Readonly) { } -export function EmbedCardRenderer({url, successCallback}: Readonly) { +export function EmbedCardRenderer({url, successCallback, encrypt}: Readonly) { const mx = useMatrixClient(); const mediaConfig = useMediaConfig(); const allowSize = mediaConfig['m.upload.size'] || Infinity; @@ -76,7 +81,7 @@ export function EmbedCardRenderer({url, successCallback}: Readonly = new Map(); +import {EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; +const embedmap: Map = new Map(); const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -1013,17 +1011,6 @@ export const RoomInput = forwardRef( await handleSendContents(contents); }; - /* const handleSendEmbeds = async (embeds: Embed[]) { - if (embedsEnabled) { - for (const embedLink of embeds) { - const embedAtom = roomEmbedAtomFamily(embedLink.url); - const {embed} = useBindEmbedAtom(mx, embedAtom); - if (embed.status == EmbedStatus.Success) { - em - } - } - } - }*/ const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery((prev) => { @@ -2008,7 +1995,7 @@ export const RoomInput = forwardRef( .toReversed() .map((link) => ( - { + { embedmap.set(link.url, result); }}/> ))} diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 60374859f0..83c25fc6c7 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -4,14 +4,19 @@ import type { MatrixClient, MatrixError } from '$types/matrix-sdk'; import { useCallback } from 'react'; import { useThrottle } from '$hooks/useThrottle'; import {IPreviewUrlResponse} from "matrix-js-sdk"; -import type {UploadProgress, UploadResponse} from "$types/matrix-sdk.ts"; import {isTauri} from "@tauri-apps/api/core"; import { fetch as taurifetch} from '@tauri-apps/plugin-http'; import { parseDocument, DomUtils } from 'htmlparser2'; import { Element } from 'domhandler'; -import {object} from "badwords-list"; -import {uploadContent, uploadContentToServer} from "$utils/matrix.ts"; -import {useObjectURL} from "$hooks/useObjectURL.ts"; +import {encryptFile, uploadContent} from "$utils/matrix.ts"; +import {isImageMimeType} from "$utils/mimeTypes.ts"; +import {MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME} from "$unstable/prefixes/misc.ts"; +import { EncryptedAttachmentInfo } from "browser-encrypt-attachment"; +export type FixedPreviewUrlResponse = { + [p: string]: string | number | undefined | Record; + "og:image"?: string + MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME?: Record; +} & Omit>; export enum EmbedPreviewType { TitleDescription, @@ -35,7 +40,8 @@ export enum EmbedStatus { export enum LoadingStatus { Html = 'fetching html', MediaDown = 'downloading media ', - MediaUp = 'uploading media' + MediaUp = 'uploading media', + Encrypt = 'encrypting media', } export type EmbedIdle = { @@ -55,7 +61,7 @@ export type EmbedSuccess = { url: string; status: EmbedStatus.Success; preview: EmbedPreview; - data: IPreviewUrlResponse; + data: FixedPreviewUrlResponse; }; export type EmbedError = { @@ -75,7 +81,7 @@ type EmbedAtomAction = preview: EmbedPreview | undefined; } | { - data: IPreviewUrlResponse; + data: FixedPreviewUrlResponse; preview: EmbedPreview; } | { @@ -85,12 +91,14 @@ type EmbedAtomAction = export enum EmbedErrorReason { RequestFailed, NoOgData, + UploadFailed, + EncryptFailed, } export type fetchEmbedOptions = { onPromise?: (promise: Promise) => void; onProgress?: (preview: EmbedPreview, progress: LoadingStatus) => void; - onSuccess: (embed: IPreviewUrlResponse, progress: EmbedPreview) => void; + onSuccess: (embed: FixedPreviewUrlResponse, progress: EmbedPreview) => void; onError: (reason: EmbedErrorReason) => void; }; @@ -98,7 +106,7 @@ const hasProperty = (dom: Element, value: string) => { return value === dom.attribs?.property }; -const parseOg = ( html: string ) => { +const parseOg = (html: string) => { const dom = parseDocument(html); let head = DomUtils.getElementsByTagName("head", dom); let metaTags = DomUtils.getElementsByTagName("meta", head); @@ -109,7 +117,7 @@ const parseOg = ( html: string ) => { "og:description": [["name", "description"]], } - let requiredTags = [ "og:title", "og:type","og:url"] + let requiredTags = ["og:title", "og:type", "og:url"] let optionalStringTags = ["og:image", "og:image:type", "og:description", "og:image:alt"]; let optionalNumberTags = ["og:image:height", "og:image:width"] let result = new Map(); @@ -152,18 +160,21 @@ function fetchWrapper(url: string) { const fetchEmbed = async ( url: string, mx: MatrixClient, - successCallback: (result: IPreviewUrlResponse) => void, + encrypted: boolean, + successCallback: (result: FixedPreviewUrlResponse) => void, fetchEmbedOptions: fetchEmbedOptions) => { if (!fetchEmbedOptions.onProgress) - fetchEmbedOptions.onProgress = (_) => {}; + fetchEmbedOptions.onProgress = (_) => { + }; if (!fetchEmbedOptions.onPromise) - fetchEmbedOptions.onPromise = (_) => {} + fetchEmbedOptions.onPromise = (_) => { + } //TODO filter local ips let fetchResponse; try { - let fetchPromise = fetchWrapper(url) - fetchEmbedOptions.onPromise(fetchPromise); - fetchResponse = await fetchPromise; + let fetchPromise = fetchWrapper(url) + fetchEmbedOptions.onPromise(fetchPromise); + fetchResponse = await fetchPromise; } catch (e) { console.error(e); @@ -171,6 +182,55 @@ const fetchEmbed = async ( return } if (fetchResponse.ok) { + const pushImage = async (imgData: Blob, preview: EmbedPreview, embedRecord: FixedPreviewUrlResponse) => { + preview.media = imgData; + let encInfo: EncryptedAttachmentInfo; + if (encrypted) { + fetchEmbedOptions.onProgress?.(preview, LoadingStatus.Encrypt) + try { + let encryptResult = await encryptFile(imgData) + imgData = encryptResult.file + encInfo = encryptResult.encInfo + } catch (e) { + fetchEmbedOptions.onError(EmbedErrorReason.EncryptFailed); + return; + } + + } + fetchEmbedOptions.onProgress?.(preview, LoadingStatus.MediaUp) + await uploadContent(mx, new File([imgData], "mediaPreview"), { + onError: _ => { + if (preview.type == EmbedPreviewType.MediaOnly) { + fetchEmbedOptions.onError(EmbedErrorReason.UploadFailed) + return; + } + preview.type = EmbedPreviewType.TitleDescription; + for (const e in embedRecord) { + if (e.startsWith("og:image")) { + embedRecord[e] = undefined; + } + } + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + }, + onSuccess: (mxc) => { + if (encrypted) { + embedRecord[MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME] = { + ...encInfo, + url: mxc, + }; + embedRecord["matrix:image:size"] = imgData.size + embedRecord["og:image"] = undefined + } else { + embedRecord["og:image"] = mxc; + embedRecord["matrix:image:size"] = imgData.size + } + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + } + }) + } + if (fetchResponse.headers.get('content-type')?.split(";")[0] == 'text/html') { let prelimEmbed = parseOg(await fetchResponse.text()) if (!prelimEmbed) { @@ -189,41 +249,24 @@ const fetchEmbed = async ( } fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown) - let embedRecord: IPreviewUrlResponse = Object.fromEntries(prelimEmbed) + let embedRecord: FixedPreviewUrlResponse = Object.fromEntries(prelimEmbed) if (!hasSupportedMedia) { fetchEmbedOptions.onSuccess(embedRecord, preview) successCallback(embedRecord) } else { - let keyArray = prelimEmbed.keys().toArray(); if (embedRecord["og:image"] && embedRecord["og:image:type"]) { const response = await fetchWrapper(embedRecord["og:image"]) const imgData = await response.blob(); - fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaUp) - preview.media = imgData; - await uploadContent(mx, new File([imgData], "mediaPreview"), { - onError: _ => { - preview.type = EmbedPreviewType.TitleDescription; - for (const e of keyArray) { - if (e.startsWith("og:image")) { - embedRecord[e] = undefined; - } - } - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - }, - onSuccess: (mxc) => { - embedRecord["og:image"] = mxc; - embedRecord["matrix:image:size"] = imgData.size - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - } - }) - - + await pushImage(imgData, preview, embedRecord); } } + } else if (isImageMimeType(fetchResponse.headers.get('content-type')?.split(";")[0] || "")) { + //todo + fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); + + } else { fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); } @@ -286,6 +329,7 @@ export type TEmbedAtom = ReturnType; export const useBindEmbedAtom = ( mx: MatrixClient, embedAtom: TEmbedAtom, + encrypt: boolean ) => { const [embed, setEmbed] = useAtom(embedAtom); const { url } = embed; @@ -293,14 +337,14 @@ export const useBindEmbedAtom = ( const startEmbed = useCallback( - (successCallback: (result: IPreviewUrlResponse) => void,) => - fetchEmbed(url, mx, successCallback, { + (successCallback: (result: FixedPreviewUrlResponse) => void,) => + fetchEmbed(url, mx, encrypt, successCallback, { onSuccess: (data, progress) => setEmbed({ data, preview: progress }), onError: (error) => setEmbed({ error }), onProgress: (preview, progress) => setEmbed({ preview, progress }), onPromise: promise => setEmbed({ promise }) }), - [url, mx ] + [url, mx, encrypt ] ); const cancelEmbed = useCallback(() => { diff --git a/src/unstable/prefixes/misc.ts b/src/unstable/prefixes/misc.ts index 09fc536d83..f6c69d9776 100644 --- a/src/unstable/prefixes/misc.ts +++ b/src/unstable/prefixes/misc.ts @@ -4,6 +4,9 @@ export const MATRIX_UNSTABLE_PROFILE_BANNER_PROPERTY_NAME = 'chat.commet.profile_banner'; export const MATRIX_COMMET_UNSTABLE_PROFILE_COLOR_SCHEME_PROPERTY_NAME = 'chat.commet.profile_color_scheme'; +///opengraph field for encrypted embeds +export const MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME = 'beeper:image:encryption' + /** * @deprecated */ From 0df720e0139e7792fda8037f092f54bef0b4b953 Mon Sep 17 00:00:00 2001 From: Florian Date: Wed, 5 Aug 2026 04:31:44 +0200 Subject: [PATCH 04/25] embed configuration and using the homeserver endpoint --- .../upload-card/EmbedCardRenderer.tsx | 5 +- src/app/features/room/RoomInput.tsx | 35 +++- src/app/features/settings/general/General.tsx | 26 +++ src/app/state/bundle.ts | 161 ++++++++++++------ src/app/state/settings.ts | 7 + 5 files changed, 173 insertions(+), 61 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index ab7d333713..cd3b706d2e 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -1,7 +1,7 @@ import {useMatrixClient} from "$hooks/useMatrixClient.ts"; import {useMediaConfig} from "$hooks/useMediaConfig.ts"; import {roomEmbedAtomFamily} from "$state/room/roomInputDrafts.ts"; -import {useMemo} from "react"; +import {type KeyboardEventHandler, useCallback, useMemo} from "react"; import type {Opts as LinkifyOpts} from "linkifyjs"; import {LINKIFY_OPTS} from "$plugins/react-custom-html-parser.tsx"; import {useSpoilerClickHandler} from "$hooks/useSpoilerClickHandler.ts"; @@ -80,8 +80,9 @@ export function EmbedCardRenderer({url, successCallback, encrypt}: Readonly = new Map(); +import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; +import {MatrixClient} from "matrix-js-sdk"; +const embedmap: Map = new Map(); const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -274,6 +275,8 @@ interface RoomInputProps { onCancelEdit?: () => void; } + + export const RoomInput = forwardRef( ( { @@ -1349,9 +1352,9 @@ export const RoomInput = forwardRef( content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsedRef.current.toJSON(); - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; - if (embedsEnabled) { + //check setting again here, just in case it was disabled while the message got composed + if (embedsEnabled && generateBundles) { for (const embedLink of embedLinks) { const result = embedmap.get(embedLink.url) if (result) { @@ -1569,6 +1572,8 @@ export const RoomInput = forwardRef( embedLinks, embedmap ]); + const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); + const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -1710,6 +1715,9 @@ export const RoomInput = forwardRef( ); const checkForEmbedables = (text: string) => { + if (!generateBundles) { + return; + } //from https://regex101.com/r/3fYy3x/1 const URL_REGEX = RegExp(/http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm) const urls = Array.from(text.matchAll(URL_REGEX) @@ -1718,6 +1726,10 @@ export const RoomInput = forwardRef( .map((e) => e[0])); if (urls.length > 0) { setUploadBoard(true); + urls.forEach((u) => { + if (!embedmap.has(u)) + embedmap.set(u, null) + }) const embedItems = urls.map((url): TEmbeddItem => ({ url: url })); @@ -1737,6 +1749,13 @@ export const RoomInput = forwardRef( } }; + const handleEmbedsToggle = (state: boolean) => { + setEmbedsEnabled(state) + for (const param of roomEmbedAtomFamily.getParams()) { + roomEmbedAtomFamily.remove(param); + } + } + const handleEmoticonSelect = (key: string, shortcode: string) => { const emoticonEl = createEmoticonElement(key, shortcode); if (autocompleteQuery) { @@ -1914,7 +1933,7 @@ export const RoomInput = forwardRef( forceMultilineLayout={showAudioRecorder} top={ <> - {(selectedFiles.length > 0 || embedLinks.length > 0) && ( + {(selectedFiles.length > 0 || (embedLinks.length > 0 && generateBundles)) && ( ( /> ))} - {(embedLinks.length > 0 ) && (<> + {(embedLinks.length > 0 ) && generateBundles && (<> {selectedFiles.length > 0 && } ( Bundled
Embeds - +
} @@ -1995,7 +2014,7 @@ export const RoomInput = forwardRef( .toReversed() .map((link) => ( - { + { embedmap.set(link.url, result); }}/> ))} diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index 92951c0bad..ea5aa6cd18 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1026,6 +1026,9 @@ function Embeds() { const [urlPreview, setUrlPreview] = useSetting(settingsAtom, 'urlPreview'); const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [clientUrlPreview, setClientUrlPreview] = useSetting(settingsAtom, 'clientUrlPreview'); + const [generateBundles, setGenerateBundles] = useSetting(settingsAtom, 'generateBundles'); + const [bundleUseHomeserver, setBundleUseHomeserver] = useSetting(settingsAtom, 'useHomeserverForBundles'); + const [encryptBundledMedia, setEncryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); const [showInteractiveMap, setShowInteractiveMap] = useSetting( settingsAtom, 'showInteractiveMap' @@ -1060,6 +1063,29 @@ function Embeds() { value={bundledPreview} onChange={setBundledPreview} /> + + {generateBundles && + <> + + } ; "og:image"?: string @@ -161,15 +164,117 @@ const fetchEmbed = async ( url: string, mx: MatrixClient, encrypted: boolean, + bundleUseHomeserver: boolean, successCallback: (result: FixedPreviewUrlResponse) => void, fetchEmbedOptions: fetchEmbedOptions) => { + + + const useAuthentication = useMediaAuthentication(); if (!fetchEmbedOptions.onProgress) fetchEmbedOptions.onProgress = (_) => { }; if (!fetchEmbedOptions.onPromise) fetchEmbedOptions.onPromise = (_) => { } + const pushImage = async (imgData: Blob, preview: EmbedPreview, embedRecord: FixedPreviewUrlResponse) => { + preview.media = imgData; + let encInfo: EncryptedAttachmentInfo; + if (encrypted) { + fetchEmbedOptions.onProgress?.(preview, LoadingStatus.Encrypt) + try { + let encryptResult = await encryptFile(imgData) + imgData = encryptResult.file + encInfo = encryptResult.encInfo + } catch (e) { + fetchEmbedOptions.onError(EmbedErrorReason.EncryptFailed); + return; + } + + } + fetchEmbedOptions.onProgress?.(preview, LoadingStatus.MediaUp) + await uploadContent(mx, new File([imgData], "mediaPreview"), { + onError: _ => { + if (preview.type == EmbedPreviewType.MediaOnly) { + fetchEmbedOptions.onError(EmbedErrorReason.UploadFailed) + return; + } + preview.type = EmbedPreviewType.TitleDescription; + for (const e in embedRecord) { + if (e.startsWith("og:image")) { + embedRecord[e] = undefined; + } + } + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + }, + onSuccess: (mxc) => { + if (encrypted) { + embedRecord[MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME] = { + ...encInfo, + url: mxc, + }; + embedRecord["matrix:image:size"] = imgData.size + embedRecord["og:image"] = undefined + } else { + embedRecord["og:image"] = mxc; + embedRecord["matrix:image:size"] = imgData.size + } + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) + } + }) + } //TODO filter local ips + if (bundleUseHomeserver) { + var response: IPreviewUrlResponse + try { + const promise = mx.getUrlPreview(url, Date.now()); + fetchEmbedOptions.onPromise(promise); + response = await promise; + } catch (e) { + fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + return; + } + const imageUrl = mxcUrlToHttp( + mx, + response['og:image'] || '', + useAuthentication, + 256, + 256, + 'scale', + false + ); + let preview: EmbedPreview = { + description: response['og:description'], + title: response['og:title'], + type: EmbedPreviewType.TitleDescription + } + if (!imageUrl) { + fetchEmbedOptions.onSuccess(response, preview); + successCallback(response); + return; + } + + let fetchPromise = fetch(imageUrl) + fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown); + let fetchResponse = await (await fetchPromise).blob().catch(_ => null); + if (!fetchResponse) { + fetchEmbedOptions.onSuccess(response, preview); + successCallback(response); + return; + } + preview.media = fetchResponse; + preview.type = EmbedPreviewType.TitleDescriptionMedia; + if(encrypted) { + pushImage(fetchResponse, preview, response) + } else { + fetchEmbedOptions.onSuccess(response, preview); + successCallback(response); + + } + return; + + } let fetchResponse; try { let fetchPromise = fetchWrapper(url) @@ -182,54 +287,7 @@ const fetchEmbed = async ( return } if (fetchResponse.ok) { - const pushImage = async (imgData: Blob, preview: EmbedPreview, embedRecord: FixedPreviewUrlResponse) => { - preview.media = imgData; - let encInfo: EncryptedAttachmentInfo; - if (encrypted) { - fetchEmbedOptions.onProgress?.(preview, LoadingStatus.Encrypt) - try { - let encryptResult = await encryptFile(imgData) - imgData = encryptResult.file - encInfo = encryptResult.encInfo - } catch (e) { - fetchEmbedOptions.onError(EmbedErrorReason.EncryptFailed); - return; - } - } - fetchEmbedOptions.onProgress?.(preview, LoadingStatus.MediaUp) - await uploadContent(mx, new File([imgData], "mediaPreview"), { - onError: _ => { - if (preview.type == EmbedPreviewType.MediaOnly) { - fetchEmbedOptions.onError(EmbedErrorReason.UploadFailed) - return; - } - preview.type = EmbedPreviewType.TitleDescription; - for (const e in embedRecord) { - if (e.startsWith("og:image")) { - embedRecord[e] = undefined; - } - } - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - }, - onSuccess: (mxc) => { - if (encrypted) { - embedRecord[MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME] = { - ...encInfo, - url: mxc, - }; - embedRecord["matrix:image:size"] = imgData.size - embedRecord["og:image"] = undefined - } else { - embedRecord["og:image"] = mxc; - embedRecord["matrix:image:size"] = imgData.size - } - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - } - }) - } if (fetchResponse.headers.get('content-type')?.split(";")[0] == 'text/html') { let prelimEmbed = parseOg(await fetchResponse.text()) @@ -329,7 +387,8 @@ export type TEmbedAtom = ReturnType; export const useBindEmbedAtom = ( mx: MatrixClient, embedAtom: TEmbedAtom, - encrypt: boolean + encrypt: boolean, + useHomeserver: boolean, ) => { const [embed, setEmbed] = useAtom(embedAtom); const { url } = embed; @@ -338,13 +397,13 @@ export const useBindEmbedAtom = ( const startEmbed = useCallback( (successCallback: (result: FixedPreviewUrlResponse) => void,) => - fetchEmbed(url, mx, encrypt, successCallback, { + fetchEmbed(url, mx, encrypt, useHomeserver, successCallback, { onSuccess: (data, progress) => setEmbed({ data, preview: progress }), onError: (error) => setEmbed({ error }), onProgress: (preview, progress) => setEmbed({ preview, progress }), onPromise: promise => setEmbed({ promise }) }), - [url, mx, encrypt ] + [url, mx, encrypt, useHomeserver ] ); const cancelEmbed = useCallback(() => { diff --git a/src/app/state/settings.ts b/src/app/state/settings.ts index 544097fd18..ee6639e7c4 100644 --- a/src/app/state/settings.ts +++ b/src/app/state/settings.ts @@ -158,6 +158,10 @@ export interface Settings { showInteractiveMap: boolean; showEncInteractiveMap: boolean; + generateBundles: boolean; + useHomeserverForBundles: boolean; + encryptBundledMedia: boolean; + usePushNotifications: boolean; useUnifiedPush: boolean; useInAppNotifications: boolean; @@ -328,6 +332,9 @@ export const defaultSettings: Settings = { enableGifPicker: true, showInteractiveMap: true, showEncInteractiveMap: false, + generateBundles: true, + useHomeserverForBundles: false, + encryptBundledMedia: true, showHiddenEvents: false, showTombstoneEvents: true, hiddenEventEdits: true, From 57cfaf9c2d6f1f841562e5e7716c7889f7ecb2e5 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 6 Aug 2026 04:01:49 +0200 Subject: [PATCH 05/25] fix broken tauri capabilities --- src-tauri/capabilities/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 8660d82489..f1c7eb6d1a 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -30,7 +30,7 @@ "clipboard-manager:allow-write-image", { "identifier": "http:default", - "allow": [{ "url": "https://*", "url": "http://" }] + "allow": [{ "url": "http://*:*" }, { "url": "https://*" }] }, { "identifier": "opener:allow-open-url", From 9ec305d84e7ea6f815b5ab9db9d6d08371bcb088 Mon Sep 17 00:00:00 2001 From: Florian Date: Thu, 6 Aug 2026 05:25:55 +0200 Subject: [PATCH 06/25] dont get stuck on images without size --- src/app/state/bundle.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 2ff2ab562b..eba1fa8148 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -312,11 +312,14 @@ const fetchEmbed = async ( fetchEmbedOptions.onSuccess(embedRecord, preview) successCallback(embedRecord) } else { - if (embedRecord["og:image"] && embedRecord["og:image:type"]) { + if (embedRecord["og:image"]) { const response = await fetchWrapper(embedRecord["og:image"]) const imgData = await response.blob(); await pushImage(imgData, preview, embedRecord); } + //other media types + preview.type = EmbedPreviewType.TitleDescription; + fetchEmbedOptions.onSuccess(embedRecord, preview) } From 2d55844dc2e177eda3e0e57d589f83c4bb29172b Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 02:44:03 +0200 Subject: [PATCH 07/25] embed preview card without images --- src/app/components/upload-card/EmbedCardRenderer.tsx | 11 +++++++++-- src/app/state/bundle.ts | 1 + 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index cd3b706d2e..07ead1e0ab 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -12,7 +12,13 @@ import {settingsAtom} from "$state/settings.ts"; import {UploadCard} from "$components/upload-card/UploadCard.tsx"; import {Box, color, IconButton, Text, toRem} from "folds"; import {Check, CircleNotch, File as FileIcon, Image, sizedIcon, X} from "$components/icons/phosphor.tsx"; -import {EmbedErrorReason, EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; +import { + EmbedErrorReason, + EmbedPreviewType, + EmbedStatus, + FixedPreviewUrlResponse, + useBindEmbedAtom +} from "$state/bundle.ts"; import {Image as MediaImage} from "$components/media"; import {useObjectURL} from "$hooks/useObjectURL.ts"; import {isImageMimeType} from "$utils/mimeTypes.ts"; @@ -115,11 +121,12 @@ export function EmbedCardRenderer({url, successCallback, encrypt}: Readonly + {embed.preview?.type == EmbedPreviewType.TitleDescriptionMedia && {embed.preview?.media && } {!(embed.preview?.media) && } - + } {embed.preview?.title} diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index eba1fa8148..7e34f4c300 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -320,6 +320,7 @@ const fetchEmbed = async ( //other media types preview.type = EmbedPreviewType.TitleDescription; fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) } From 4bd7511ebd37b50ac5a2ed9a61541291e1e665e7 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 03:11:58 +0200 Subject: [PATCH 08/25] some embed preview cleanup --- src/app/features/room/RoomInput.tsx | 30 ++++++++++++----------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 42e56a6024..28ca9701d1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -197,9 +197,7 @@ import * as prefix from '$unstable/prefixes'; import {PollDialog} from './poll-modals'; import {useClientConfig} from '$hooks/useClientConfig'; import {PersistentPersonaPicker, type PersonaPickerTab} from './persona-picker/PersonaPicker.tsx'; -import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; -import {MatrixClient} from "matrix-js-sdk"; -const embedmap: Map = new Map(); +import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse} from "$state/bundle.ts"; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -356,6 +354,8 @@ export const RoomInput = forwardRef( const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(draftKey)); const [embedsEnabled, setEmbedsEnabled] = useState(false); + const embedobserver = createEmbedFamilyObserverAtom(roomEmbedAtomFamily, embedLinks.map(e => e.url)); + const embeds = useAtomValue(embedobserver); const [uploadBoard, setUploadBoard] = useState(true); const [uploadSending, setUploadSending] = useState(false); @@ -1351,15 +1351,15 @@ export const RoomInput = forwardRef( content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsedRef.current.toJSON(); - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; //check setting again here, just in case it was disabled while the message got composed if (embedsEnabled && generateBundles) { - for (const embedLink of embedLinks) { - const result = embedmap.get(embedLink.url) - if (result) { - result["matched_url"] = embedLink.url; - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(result); + for (const embed of embeds) { + if (embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) + return + if (embed.status == EmbedStatus.Success) { + embed.data["matched_url"] = embed.url; + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(embed.data); } } } else { @@ -1570,7 +1570,7 @@ export const RoomInput = forwardRef( latchedPersona, embedsEnabled, embedLinks, - embedmap + embeds ]); const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); @@ -1726,10 +1726,7 @@ export const RoomInput = forwardRef( .map((e) => e[0])); if (urls.length > 0) { setUploadBoard(true); - urls.forEach((u) => { - if (!embedmap.has(u)) - embedmap.set(u, null) - }) + const embedItems = urls.map((url): TEmbeddItem => ({ url: url })); @@ -2011,12 +2008,9 @@ export const RoomInput = forwardRef( )} {embedsEnabled && Array.from(embedLinks) - .toReversed() .map((link) => ( - { - embedmap.set(link.url, result); - }}/> + {}}/> ))} From 3c166c72e47527c575c8349521b20ec1c520e417 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 16:25:07 +0200 Subject: [PATCH 09/25] bundled embeds: workaround for tauri fetch not adding file type to blob --- .../upload-card/EmbedCardRenderer.tsx | 9 +++--- src/app/state/bundle.ts | 31 ++++++++++++++----- 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index 07ead1e0ab..e8437a8919 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -47,12 +47,13 @@ function prettyError(error: EmbedErrorReason) { } interface MediaProps { - data: Blob + data: Blob, + type: string } -function Media({data}: Readonly) { +function Media({data, type}: Readonly) { const fileUrl = useObjectURL(data); - if (isImageMimeType(data.type)) { + if (isImageMimeType(type)) { return ( {embed.preview?.type == EmbedPreviewType.TitleDescriptionMedia && - {embed.preview?.media && } + {embed.preview?.media && } {!(embed.preview?.media) && } } diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 7e34f4c300..2b6268f702 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -31,6 +31,7 @@ export type EmbedPreview = { type: EmbedPreviewType, description?: string, media?: Blob, + mediaType?: string } export enum EmbedStatus { @@ -257,16 +258,22 @@ const fetchEmbed = async ( let fetchPromise = fetch(imageUrl) fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown); - let fetchResponse = await (await fetchPromise).blob().catch(_ => null); - if (!fetchResponse) { + let fetchResponse = (await fetchPromise) + let fetchBlob = await fetchResponse.blob().catch(_ => null); + if (!fetchBlob) { fetchEmbedOptions.onSuccess(response, preview); successCallback(response); return; } - preview.media = fetchResponse; + preview.media = fetchBlob; + if (fetchBlob.type.length > 0) { + preview.mediaType == fetchBlob.type + } else if (fetchResponse.headers.get('content-type')?.split(";")[0]) { + preview.mediaType = fetchResponse.headers.get('content-type')?.split(";")[0]; + } preview.type = EmbedPreviewType.TitleDescriptionMedia; if(encrypted) { - pushImage(fetchResponse, preview, response) + await pushImage(fetchBlob, preview, response) } else { fetchEmbedOptions.onSuccess(response, preview); successCallback(response); @@ -312,15 +319,23 @@ const fetchEmbed = async ( fetchEmbedOptions.onSuccess(embedRecord, preview) successCallback(embedRecord) } else { + new Response if (embedRecord["og:image"]) { const response = await fetchWrapper(embedRecord["og:image"]) const imgData = await response.blob(); + preview.media = imgData; + if (imgData.type.length > 0) { + preview.mediaType == imgData.type + } else if (response.headers.get('content-type')?.split(";")[0]) { + preview.mediaType = response.headers.get('content-type')?.split(";")[0]; + } await pushImage(imgData, preview, embedRecord); + } else { + //other media types + preview.type = EmbedPreviewType.TitleDescription; + fetchEmbedOptions.onSuccess(embedRecord, preview) + successCallback(embedRecord) } - //other media types - preview.type = EmbedPreviewType.TitleDescription; - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) } From 4706c46b321bdba4b331b712150c0ad26923e919 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 16:31:59 +0200 Subject: [PATCH 10/25] Revert "some embed preview cleanup" This reverts commit 4bd7511ebd37b50ac5a2ed9a61541291e1e665e7. --- src/app/features/room/RoomInput.tsx | 30 +++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 28ca9701d1..42e56a6024 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -197,7 +197,9 @@ import * as prefix from '$unstable/prefixes'; import {PollDialog} from './poll-modals'; import {useClientConfig} from '$hooks/useClientConfig'; import {PersistentPersonaPicker, type PersonaPickerTab} from './persona-picker/PersonaPicker.tsx'; -import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse} from "$state/bundle.ts"; +import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; +import {MatrixClient} from "matrix-js-sdk"; +const embedmap: Map = new Map(); const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -354,8 +356,6 @@ export const RoomInput = forwardRef( const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(draftKey)); const [embedsEnabled, setEmbedsEnabled] = useState(false); - const embedobserver = createEmbedFamilyObserverAtom(roomEmbedAtomFamily, embedLinks.map(e => e.url)); - const embeds = useAtomValue(embedobserver); const [uploadBoard, setUploadBoard] = useState(true); const [uploadSending, setUploadSending] = useState(false); @@ -1351,15 +1351,15 @@ export const RoomInput = forwardRef( content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsedRef.current.toJSON(); + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; //check setting again here, just in case it was disabled while the message got composed if (embedsEnabled && generateBundles) { - for (const embed of embeds) { - if (embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) - return - if (embed.status == EmbedStatus.Success) { - embed.data["matched_url"] = embed.url; - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(embed.data); + for (const embedLink of embedLinks) { + const result = embedmap.get(embedLink.url) + if (result) { + result["matched_url"] = embedLink.url; + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(result); } } } else { @@ -1570,7 +1570,7 @@ export const RoomInput = forwardRef( latchedPersona, embedsEnabled, embedLinks, - embeds + embedmap ]); const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); @@ -1726,7 +1726,10 @@ export const RoomInput = forwardRef( .map((e) => e[0])); if (urls.length > 0) { setUploadBoard(true); - + urls.forEach((u) => { + if (!embedmap.has(u)) + embedmap.set(u, null) + }) const embedItems = urls.map((url): TEmbeddItem => ({ url: url })); @@ -2008,9 +2011,12 @@ export const RoomInput = forwardRef( )} {embedsEnabled && Array.from(embedLinks) + .toReversed() .map((link) => ( - {}}/> + { + embedmap.set(link.url, result); + }}/> ))} From 59f38f54e1f6138c999df49891ae7b6625253b38 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 17:23:42 +0200 Subject: [PATCH 11/25] Reapply "some embed preview cleanup" This reverts commit 4706c46b321bdba4b331b712150c0ad26923e919. --- src/app/features/room/RoomInput.tsx | 30 ++++++++++++----------------- 1 file changed, 12 insertions(+), 18 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 42e56a6024..28ca9701d1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -197,9 +197,7 @@ import * as prefix from '$unstable/prefixes'; import {PollDialog} from './poll-modals'; import {useClientConfig} from '$hooks/useClientConfig'; import {PersistentPersonaPicker, type PersonaPickerTab} from './persona-picker/PersonaPicker.tsx'; -import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse, useBindEmbedAtom} from "$state/bundle.ts"; -import {MatrixClient} from "matrix-js-sdk"; -const embedmap: Map = new Map(); +import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse} from "$state/bundle.ts"; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -356,6 +354,8 @@ export const RoomInput = forwardRef( const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(draftKey)); const [embedsEnabled, setEmbedsEnabled] = useState(false); + const embedobserver = createEmbedFamilyObserverAtom(roomEmbedAtomFamily, embedLinks.map(e => e.url)); + const embeds = useAtomValue(embedobserver); const [uploadBoard, setUploadBoard] = useState(true); const [uploadSending, setUploadSending] = useState(false); @@ -1351,15 +1351,15 @@ export const RoomInput = forwardRef( content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsedRef.current.toJSON(); - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; //check setting again here, just in case it was disabled while the message got composed if (embedsEnabled && generateBundles) { - for (const embedLink of embedLinks) { - const result = embedmap.get(embedLink.url) - if (result) { - result["matched_url"] = embedLink.url; - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(result); + for (const embed of embeds) { + if (embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) + return + if (embed.status == EmbedStatus.Success) { + embed.data["matched_url"] = embed.url; + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(embed.data); } } } else { @@ -1570,7 +1570,7 @@ export const RoomInput = forwardRef( latchedPersona, embedsEnabled, embedLinks, - embedmap + embeds ]); const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); @@ -1726,10 +1726,7 @@ export const RoomInput = forwardRef( .map((e) => e[0])); if (urls.length > 0) { setUploadBoard(true); - urls.forEach((u) => { - if (!embedmap.has(u)) - embedmap.set(u, null) - }) + const embedItems = urls.map((url): TEmbeddItem => ({ url: url })); @@ -2011,12 +2008,9 @@ export const RoomInput = forwardRef( )} {embedsEnabled && Array.from(embedLinks) - .toReversed() .map((link) => ( - { - embedmap.set(link.url, result); - }}/> + {}}/> ))} From 49e472c2dea996fd62ad161ecab9edeb73853f8f Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 17:44:52 +0200 Subject: [PATCH 12/25] do the embed preview cleanup right this time --- src/app/features/room/RoomInput.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 28ca9701d1..6cbda3d381 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1,6 +1,6 @@ import type {KeyboardEventHandler, MouseEvent, ReactElement, RefObject} from 'react'; import {forwardRef, Fragment, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState,} from 'react'; -import {useAtom, useAtomValue, useSetAtom} from 'jotai'; +import {useAtom, useAtomValue, useSetAtom, useStore} from 'jotai'; import {isKeyHotkey} from 'is-hotkey'; import type { @@ -354,9 +354,7 @@ export const RoomInput = forwardRef( const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(draftKey)); const [embedsEnabled, setEmbedsEnabled] = useState(false); - const embedobserver = createEmbedFamilyObserverAtom(roomEmbedAtomFamily, embedLinks.map(e => e.url)); - const embeds = useAtomValue(embedobserver); - + const store = useStore(); const [uploadBoard, setUploadBoard] = useState(true); const [uploadSending, setUploadSending] = useState(false); const [uploadBusy, setUploadBusy] = useState(false); @@ -1354,6 +1352,9 @@ export const RoomInput = forwardRef( content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; //check setting again here, just in case it was disabled while the message got composed if (embedsEnabled && generateBundles) { + const embedUrls = embedLinks.map((e) => e.url); + const embedObserver = createEmbedFamilyObserverAtom(roomEmbedAtomFamily, embedUrls); + const embeds = store.get(embedObserver); for (const embed of embeds) { if (embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) return @@ -1569,8 +1570,7 @@ export const RoomInput = forwardRef( onCancelEdit, latchedPersona, embedsEnabled, - embedLinks, - embeds + embedLinks ]); const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); From a93ac5f5f6ebe339b565364a9e3ab386f7e73fdb Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 17:48:30 +0200 Subject: [PATCH 13/25] bruh --- src/app/state/bundle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 2b6268f702..f09a939b9b 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -325,7 +325,7 @@ const fetchEmbed = async ( const imgData = await response.blob(); preview.media = imgData; if (imgData.type.length > 0) { - preview.mediaType == imgData.type + preview.mediaType = imgData.type } else if (response.headers.get('content-type')?.split(";")[0]) { preview.mediaType = response.headers.get('content-type')?.split(";")[0]; } From edcd8b928a806dacc7984586ca205e2b8a2c16f8 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 18:08:09 +0200 Subject: [PATCH 14/25] remove unused cancel button from embed cards --- .../components/upload-card/EmbedCardRenderer.tsx | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index e8437a8919..c6ebb8ef9d 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -103,19 +103,6 @@ export function EmbedCardRenderer({url, successCallback, encrypt}: Readonly } - after={ - <> - - - {sizedIcon(X, '200')} - - - } bottom={ <> {embed.status == EmbedStatus.Idle && <>{"Preview here"}} From 7ec4e552d22f10ffc42efa616a601b63b5f95e4b Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 19:12:33 +0200 Subject: [PATCH 15/25] fix all the stuff broken from the merge --- src/app/features/room/RoomInput.tsx | 50 ++++++++++++------------ src/app/features/room/composerMessage.ts | 49 ++++++++++++++++++++--- 2 files changed, 70 insertions(+), 29 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ad71426c72..61d083b42b 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -25,7 +25,7 @@ import { Menu, MenuItem, OverlayBackdrop, - OverlayCenter, + OverlayCenter, RectCords, Scroll, Spinner, Switch, @@ -35,45 +35,31 @@ import { import { Overlay, PopOut } from '$components/overlay-stack'; import {useMatrixClient} from '$hooks/useMatrixClient'; -import type {AutocompleteQuery} from '$components/editor'; import { ANYWHERE_AUTOCOMPLETE_PREFIXES, + AutocompleteQuery, + BEGINNING_AUTOCOMPLETE_PREFIXES, EmoticonAutocomplete, focusEditor, + isEmptyEditor, + MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, moveCursor, replaceWithElement, resetEditorHistory +} from '$components/editor'; +import { AutocompletePrefix, - BEGINNING_AUTOCOMPLETE_PREFIXES, BlockType, createEmoticonElement, CustomEditor, customHtmlEqualsPlainText, - EmoticonAutocomplete, - focusEditor, getAutocompleteQuery, getBeginCommand, getLinks, getMentions, getPrevWorldRange, - isEmptyEditor, - MarkdownFormattingToolbarBottom, - MarkdownFormattingToolbarToggle, - moveCursor, - replaceWithElement, resetEditor, - resetEditorHistory, RoomMentionAutocomplete, toMatrixCustomHTML, toPlainText, trimCommand, trimCustomHtml, UserMentionAutocomplete, - EmoticonAutocomplete, - moveCursor, - resetEditorHistory, - isEmptyEditor, - ANYWHERE_AUTOCOMPLETE_PREFIXES, - BEGINNING_AUTOCOMPLETE_PREFIXES, - MarkdownFormattingToolbarBottom, - MarkdownFormattingToolbarToggle, - focusEditor, - replaceWithElement, } from '$components/editor'; import {stripMarkdownEscapesForHiddenPreviews} from './message/hiddenLinkPreviews'; import {plainToEditorInput} from '$components/editor/input'; @@ -126,7 +112,7 @@ import {useQueryClient} from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import { delayedEventsSupportedAtom, - getScheduledMessageStateKey, + getScheduledMessageStateKey, roomIdToEditingScheduledDelayIdAtomFamily, roomIdToScheduledTimeAtomFamily, serverMaxDelayMsAtom, } from '$state/scheduledMessages'; @@ -384,7 +370,7 @@ export const RoomInput = forwardRef( replyDraftRef.current = replyDraft; - const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(draftKey)); + const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(roomId)); const [embedsEnabled, setEmbedsEnabled] = useState(false); const store = useStore(); const [uploadBoard, setUploadBoard] = useState(true); @@ -1506,7 +1492,6 @@ export const RoomInput = forwardRef( if (isLive()) setUploadSending(false); } } - const outgoing = await buildOutgoingMessage(submission.children, { mx, room, @@ -1524,9 +1509,20 @@ export const RoomInput = forwardRef( latchedPersona, isPKCommand: (text) => PKitCommandMessageHandler.isPKCommand(text), imagePacksUsed: imagePacksUsedRef.current, + embedLinks, + embedsEnabled, + generateBundles, + store }); if (outgoing.kind === 'empty') return; + if (outgoing.kind === 'failed') { + if (isLive()) { + setSendError(outgoing.reason); + } + restoreSubmission(submission); + return; + } if (outgoing.kind === 'quickReact') { handleQuickReact(outgoing.key); return; @@ -1715,6 +1711,7 @@ export const RoomInput = forwardRef( pmpLatchingEnable, pmpNoFallback, latchedPersona, + embedsEnabled, ] ); @@ -1731,6 +1728,11 @@ export const RoomInput = forwardRef( ); }, [editingEvent, executeSubmit, isMobile, takeSubmission]); + + const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); + const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); + + const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { const autocompleteMenu = document.querySelector('[data-autocomplete-menu]'); diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index 77d156cd3c..1a36ba457c 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -16,7 +16,12 @@ import { } from '$components/editor'; import { sanitizeText } from '$utils/sanitize'; import { getMentionContent } from '$utils/room/relations'; -import type { IReplyDraft } from '$state/room/roomInputDrafts'; +import { + IReplyDraft, + roomEmbedAtomFamily, + roomIdToEmbeddItemsAtomFamily, + TEmbeddItem +} from '$state/room/roomInputDrafts'; import { ProfileCatalog } from '$app/persona/catalog'; import type { PerMessageProfileMsc4461 } from '$app/persona'; import { convertPerMessageProfileToBeeperFormat } from '$app/persona/projection'; @@ -28,6 +33,11 @@ import { buildReplacementContent } from './buildReplacementContent'; import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { SerializableMap } from '$types/wrapper/SerializableMap'; +import {useAtom, useStore} from "jotai"; +import {useState} from "react"; +import {useSetting} from "$state/hooks/settings.ts"; +import {settingsAtom} from "$state/settings.ts"; +import {createEmbedFamilyObserverAtom, EmbedStatus} from "$state/bundle.ts"; export type MessageContent = IContent & Pick; @@ -41,7 +51,8 @@ export type OutgoingMessage = | { kind: 'pkCommand'; plainText: string } | { kind: 'command'; command: Command; plainText: string; customHtml: string } | { kind: 'gifSearch'; query: string } - | { kind: 'empty' }; + | { kind: 'empty' } + | { kind: 'failed'; reason: string }; export interface BuildOutgoingMessageDeps { mx: MatrixClient; @@ -62,8 +73,17 @@ export interface BuildOutgoingMessageDeps { latchedPersona: PerMessageProfileMsc4461 | undefined; isPKCommand: (plainText: string) => boolean; imagePacksUsed: SerializableMap; + embedLinks: TEmbeddItem[]; + embedsEnabled: boolean; + generateBundles: boolean; + store: any } + + + + + const resolveNickname = ( room: Room, nicknames: Record, @@ -248,9 +268,28 @@ export async function buildOutgoingMessage( const content: MessageContent = { msgtype: msgType, body: plainText }; content['m.mentions'] = getMentionContent(Array.from(mentionData.users), mentionData.room); content[prefix.MATRIX_UNSTABLE_IMAGE_SOURCE_PACK_PROPERTY_NAME] = imagePacksUsed.toJSON(); - content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = ( - getLinks(serializedChildren) ?? [] - ).map((matched_url) => ({ matched_url })); + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME] = []; + //check setting again here, just in case it was disabled while the message got composed + if (deps.embedsEnabled && deps.generateBundles) { + const embedUrls = deps.embedLinks.map((e) => e.url); + const embedObserver = createEmbedFamilyObserverAtom(roomEmbedAtomFamily, embedUrls); + const embeds = deps.store.get(embedObserver); + for (const embed of embeds) { + if (embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) + return {kind: "failed", reason: "bundled embeds not ready"}; + if (embed.status == EmbedStatus.Success) { + embed.data["matched_url"] = embed.url; + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(embed.data); + } + } + } else { + const links = getLinks(serializedChildren); + links?.forEach((link) => { + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ + matched_url: link, + })} + ); + } if (replyDraft || !customHtmlEqualsPlainText(customHtml, plainText)) { content.format = 'org.matrix.custom.html'; From cca69b1a496291dfffc4be2964a84b4765c16862 Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 19:16:00 +0200 Subject: [PATCH 16/25] bundled embeds: still add matched_url fields for unsucessful embeds --- src/app/features/room/composerMessage.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index 1a36ba457c..67d05ad40f 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -280,6 +280,10 @@ export async function buildOutgoingMessage( if (embed.status == EmbedStatus.Success) { embed.data["matched_url"] = embed.url; content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(embed.data); + } else { + content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ + matched_url: embed.url, + }) } } } else { From 0aceef87fbbeab0a183dc1be75821452ede8348a Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 21:07:26 +0200 Subject: [PATCH 17/25] bundled embeds: testcase --- src/app/features/room/composerMessage.test.ts | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts index aee137f90c..5a16a577a7 100644 --- a/src/app/features/room/composerMessage.test.ts +++ b/src/app/features/room/composerMessage.test.ts @@ -1,10 +1,13 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { BlockType, plainToEditorInput } from '$components/editor'; -import { Command, SHRUG } from '$hooks/useCommands'; -import type { MatrixClient, Room } from '$types/matrix-sdk'; -import { SerializableMap } from '$types/wrapper/SerializableMap'; -import type { MSC4459ImagePackReference } from '$types/matrix/common'; -import type { PerMessageProfileMsc4461 } from '$app/persona'; +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {BlockType, plainToEditorInput} from '$components/editor'; +import {Command, SHRUG} from '$hooks/useCommands'; +import type {MatrixClient, Room} from '$types/matrix-sdk'; +import {SerializableMap} from '$types/wrapper/SerializableMap'; +import type {MSC4459ImagePackReference} from '$types/matrix/common'; +import type {PerMessageProfileMsc4461} from '$app/persona'; +import {createStore, useAtom, useStore} from "jotai"; +import {createEmbedAtom, Embed, EmbedPreviewType, EmbedStatus, FixedPreviewUrlResponse} from "$state/bundle.ts"; +import {roomEmbedAtomFamily, roomIdToEmbeddItemsAtomFamily} from "$state/room/roomInputDrafts.ts"; const { profiles } = vi.hoisted(() => ({ profiles: { @@ -56,6 +59,10 @@ const commandInput = (command: Command, rest = '') => [ }, ]; + + +const store = createStore(); + const build = ( input: string | ReturnType, overrides: Partial[1]> = {} @@ -77,6 +84,10 @@ const build = ( latchedPersona: undefined, isPKCommand: () => false, imagePacksUsed: new SerializableMap(), + embedLinks: [], + embedsEnabled: false, + generateBundles: false, + store: store, ...overrides, }); @@ -200,6 +211,32 @@ describe('buildOutgoingMessage', () => { expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); }); + + it('generate bundled embeds', async () => { + let embedatom = roomEmbedAtomFamily("https://example.com/page"); + + let embed: Embed = { + url: "https://example.com/page", + status: EmbedStatus.Success, + preview: {title: "Example Title", type: EmbedPreviewType.TitleDescription}, + data: {"og:title": "Example Title", "og:type": "website", "og:url": "https://example.com/page"}, + } + + store.set(embedatom, embed) + + const result = await build('see https://example.com/page', + {embedLinks: [{url: "https://example.com/page"}], + embedsEnabled: true, + generateBundles: true + }); + if (result.kind !== 'message') throw new Error('expected a message'); + const previews = result.content['com.beeper.linkpreviews'] as + | { matched_url: string, "og:title": string, }[] + | undefined; + expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); + expect(previews?.map((preview) => preview["og:title"])).toContain('Example Title'); + }); + it('preserves the original per-message profile when editing', () => { const originalProfile = { id: 'original', displayname: 'Original' }; const edited = buildEditReplacement(plainToEditorInput('updated'), { From 996b48868fd47803580297ccd9e4ff09eb1b479d Mon Sep 17 00:00:00 2001 From: Florian Date: Sun, 9 Aug 2026 21:10:35 +0200 Subject: [PATCH 18/25] fun formater --- .../upload-card/EmbedCardRenderer.tsx | 311 ++++---- src/app/components/upload-card/index.ts | 2 +- src/app/features/room/RoomInput.tsx | 314 ++++---- src/app/features/room/composerMessage.test.ts | 61 +- src/app/features/room/composerMessage.ts | 29 +- src/app/features/settings/general/General.tsx | 42 +- src/app/state/bundle.ts | 722 +++++++++--------- src/app/state/list.ts | 15 +- src/app/state/room/roomInputDrafts.ts | 7 +- src/unstable/prefixes/misc.ts | 2 +- 10 files changed, 777 insertions(+), 728 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index c6ebb8ef9d..da36585455 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -1,168 +1,179 @@ -import {useMatrixClient} from "$hooks/useMatrixClient.ts"; -import {useMediaConfig} from "$hooks/useMediaConfig.ts"; -import {roomEmbedAtomFamily} from "$state/room/roomInputDrafts.ts"; -import {type KeyboardEventHandler, useCallback, useMemo} from "react"; -import type {Opts as LinkifyOpts} from "linkifyjs"; -import {LINKIFY_OPTS} from "$plugins/react-custom-html-parser.tsx"; -import {useSpoilerClickHandler} from "$hooks/useSpoilerClickHandler.ts"; -import {useMediaAuthentication} from "$hooks/useMediaAuthentication.ts"; -import {useSettingsLinkBaseUrl} from "$features/settings/useSettingsLinkBaseUrl.ts"; -import {useSetting} from "$state/hooks/settings.ts"; -import {settingsAtom} from "$state/settings.ts"; -import {UploadCard} from "$components/upload-card/UploadCard.tsx"; -import {Box, color, IconButton, Text, toRem} from "folds"; -import {Check, CircleNotch, File as FileIcon, Image, sizedIcon, X} from "$components/icons/phosphor.tsx"; +import { useMatrixClient } from '$hooks/useMatrixClient.ts'; +import { useMediaConfig } from '$hooks/useMediaConfig.ts'; +import { roomEmbedAtomFamily } from '$state/room/roomInputDrafts.ts'; +import { type KeyboardEventHandler, useCallback, useMemo } from 'react'; +import type { Opts as LinkifyOpts } from 'linkifyjs'; +import { LINKIFY_OPTS } from '$plugins/react-custom-html-parser.tsx'; +import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler.ts'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl.ts'; +import { useSetting } from '$state/hooks/settings.ts'; +import { settingsAtom } from '$state/settings.ts'; +import { UploadCard } from '$components/upload-card/UploadCard.tsx'; +import { Box, color, IconButton, Text, toRem } from 'folds'; import { - EmbedErrorReason, - EmbedPreviewType, - EmbedStatus, - FixedPreviewUrlResponse, - useBindEmbedAtom -} from "$state/bundle.ts"; -import {Image as MediaImage} from "$components/media"; -import {useObjectURL} from "$hooks/useObjectURL.ts"; -import {isImageMimeType} from "$utils/mimeTypes.ts"; -import {IPreviewUrlResponse} from "matrix-js-sdk"; - + Check, + CircleNotch, + File as FileIcon, + Image, + sizedIcon, + X, +} from '$components/icons/phosphor.tsx'; +import { + EmbedErrorReason, + EmbedPreviewType, + EmbedStatus, + FixedPreviewUrlResponse, + useBindEmbedAtom, +} from '$state/bundle.ts'; +import { Image as MediaImage } from '$components/media'; +import { useObjectURL } from '$hooks/useObjectURL.ts'; +import { isImageMimeType } from '$utils/mimeTypes.ts'; +import { IPreviewUrlResponse } from 'matrix-js-sdk'; type EmbedCardRendererProps = { - url: string, - successCallback: (result: FixedPreviewUrlResponse) => void, - encrypt: boolean, -} + url: string; + successCallback: (result: FixedPreviewUrlResponse) => void; + encrypt: boolean; +}; function prettyError(error: EmbedErrorReason) { - switch (error) { - //TODO localize - case EmbedErrorReason.UploadFailed: - return "Failed to upload"; - case EmbedErrorReason.EncryptFailed: - return "Failed to encrypt"; - case EmbedErrorReason.RequestFailed: - return "Failed to fetch url" - case EmbedErrorReason.NoOgData: - return "Url doesnt provide previews" - - } + switch (error) { + //TODO localize + case EmbedErrorReason.UploadFailed: + return 'Failed to upload'; + case EmbedErrorReason.EncryptFailed: + return 'Failed to encrypt'; + case EmbedErrorReason.RequestFailed: + return 'Failed to fetch url'; + case EmbedErrorReason.NoOgData: + return 'Url doesnt provide previews'; + } } interface MediaProps { - data: Blob, - type: string + data: Blob; + type: string; } -function Media({data, type}: Readonly) { - const fileUrl = useObjectURL(data); - if (isImageMimeType(type)) { - return ( - - ); - } - - return (); - - -} - -export function EmbedCardRenderer({url, successCallback, encrypt}: Readonly) { - const mx = useMatrixClient(); - const mediaConfig = useMediaConfig(); - const allowSize = mediaConfig['m.upload.size'] || Infinity; - - - const linkifyOpts = useMemo(() => ({...LINKIFY_OPTS}), []); - - const spoilerClickHandler = useSpoilerClickHandler(); - const useAuthentication = useMediaAuthentication(); - const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); - const [incomingInlineImagesDefaultHeight] = useSetting( - settingsAtom, - 'incomingInlineImagesDefaultHeight' - ); - const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); - const [bundleUseHomeserver] = useSetting(settingsAtom, 'useHomeserverForBundles'); - const embedAtom = roomEmbedAtomFamily(url); - const {embed, startEmbed, cancelEmbed} = useBindEmbedAtom(mx, embedAtom, encrypt, bundleUseHomeserver); - - if (embed.status === EmbedStatus.Idle) { - startEmbed(successCallback); - } - +function Media({ data, type }: Readonly) { + const fileUrl = useObjectURL(data); + if (isImageMimeType(type)) { return ( - - } - bottom={ - <> - {embed.status == EmbedStatus.Idle && <>{"Preview here"}} - {(embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Success) && - <> - - {embed.preview?.type == EmbedPreviewType.TitleDescriptionMedia && - - {embed.preview?.media && } - {!(embed.preview?.media) && } - } - - - {embed.preview?.title} - - - {embed.preview?.description || "No Description"} - - - - - - } - {embed.status == EmbedStatus.Error && - - {prettyError(embed.reason)} - - } - + + ); + } - - } - > - - {url} - - {embed.status == EmbedStatus.Success && - sizedIcon(Check, '100', {style: {color: color.Success.Main}})} + return ; +} - {embed.status == EmbedStatus.Error && - sizedIcon(X, '100', {style: {color: color.Critical.Main}})} - - {embed.status == EmbedStatus.Loading && - {embed.progress} - } - {(embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) && - sizedIcon(CircleNotch, '100', { - style: { - color: color.Primary.Main, - animation: "spin 1s infinite linear" - } - })} - - ); -} \ No newline at end of file + + {embed.status == EmbedStatus.Loading && ( + + {embed.progress} + + )} + {(embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) && + sizedIcon(CircleNotch, '100', { + style: { + color: color.Primary.Main, + animation: 'spin 1s infinite linear', + }, + })} + + ); +} diff --git a/src/app/components/upload-card/index.ts b/src/app/components/upload-card/index.ts index 919a9a289a..d93108fae8 100644 --- a/src/app/components/upload-card/index.ts +++ b/src/app/components/upload-card/index.ts @@ -1,4 +1,4 @@ export * from './UploadCard'; export * from './UploadCardRenderer'; export * from './CompactUploadCardRenderer'; -export * from './EmbedCardRenderer' \ No newline at end of file +export * from './EmbedCardRenderer'; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 61d083b42b..230154a307 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -1,8 +1,24 @@ -import type {KeyboardEventHandler, MouseEvent,PointerEvent, ReactElement, RefObject,} from 'react'; -import {forwardRef, Fragment, lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState,} from 'react'; -import {useAtom, useAtomValue, useSetAtom, useStore} from 'jotai'; - -import {isKeyHotkey} from 'is-hotkey'; +import type { + KeyboardEventHandler, + MouseEvent, + PointerEvent, + ReactElement, + RefObject, +} from 'react'; +import { + forwardRef, + Fragment, + lazy, + Suspense, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useAtom, useAtomValue, useSetAtom, useStore } from 'jotai'; + +import { isKeyHotkey } from 'is-hotkey'; import type { IContent, IEventRelation, @@ -12,10 +28,10 @@ import type { StickerEventContent, TimelineEvents, } from '$types/matrix-sdk'; -import {EventType, MatrixError, RelationType} from '$types/matrix-sdk'; +import { EventType, MatrixError, RelationType } from '$types/matrix-sdk'; import { M_POLL_START } from 'matrix-js-sdk'; -import {ReactEditor} from 'slate-react'; -import {Editor, Point, Range, Transforms} from 'slate'; +import { ReactEditor } from 'slate-react'; +import { Editor, Point, Range, Transforms } from 'slate'; import { Box, color, @@ -25,22 +41,29 @@ import { Menu, MenuItem, OverlayBackdrop, - OverlayCenter, RectCords, + OverlayCenter, + RectCords, Scroll, Spinner, Switch, Text, - toRem + toRem, } from 'folds'; import { Overlay, PopOut } from '$components/overlay-stack'; -import {useMatrixClient} from '$hooks/useMatrixClient'; +import { useMatrixClient } from '$hooks/useMatrixClient'; import { ANYWHERE_AUTOCOMPLETE_PREFIXES, AutocompleteQuery, - BEGINNING_AUTOCOMPLETE_PREFIXES, EmoticonAutocomplete, focusEditor, + BEGINNING_AUTOCOMPLETE_PREFIXES, + EmoticonAutocomplete, + focusEditor, isEmptyEditor, - MarkdownFormattingToolbarBottom, MarkdownFormattingToolbarToggle, moveCursor, replaceWithElement, resetEditorHistory + MarkdownFormattingToolbarBottom, + MarkdownFormattingToolbarToggle, + moveCursor, + replaceWithElement, + resetEditorHistory, } from '$components/editor'; import { AutocompletePrefix, @@ -61,17 +84,23 @@ import { trimCustomHtml, UserMentionAutocomplete, } from '$components/editor'; -import {stripMarkdownEscapesForHiddenPreviews} from './message/hiddenLinkPreviews'; -import {plainToEditorInput} from '$components/editor/input'; -import type {GifData} from '$components/emoji-board'; -import {EmojiBoard, EmojiBoardTab} from '$components/emoji-board'; -import {UseStateProvider} from '$components/UseStateProvider'; -import type {TUploadContent} from '$utils/matrix'; -import {cancelUploadContent, encryptFile, getImageInfo, mxcUrlToHttp, toggleReaction,} from '$utils/matrix'; -import {useTypingStatusUpdater} from '$hooks/useTypingStatusUpdater'; -import {useFilePicker} from '$hooks/useFilePicker'; -import {useFilePasteHandler} from '$hooks/useFilePasteHandler'; -import {useFileDropZone} from '$hooks/useFileDrop'; +import { stripMarkdownEscapesForHiddenPreviews } from './message/hiddenLinkPreviews'; +import { plainToEditorInput } from '$components/editor/input'; +import type { GifData } from '$components/emoji-board'; +import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board'; +import { UseStateProvider } from '$components/UseStateProvider'; +import type { TUploadContent } from '$utils/matrix'; +import { + cancelUploadContent, + encryptFile, + getImageInfo, + mxcUrlToHttp, + toggleReaction, +} from '$utils/matrix'; +import { useTypingStatusUpdater } from '$hooks/useTypingStatusUpdater'; +import { useFilePicker } from '$hooks/useFilePicker'; +import { useFilePasteHandler } from '$hooks/useFilePasteHandler'; +import { useFileDropZone } from '$hooks/useFileDrop'; import { IReplyDraft, roomEmbedAtomFamily, @@ -82,37 +111,38 @@ import { roomUploadAtomFamily, TEmbeddItem, TUploadItem, - TUploadMetadata + TUploadMetadata, } from '$state/room/roomInputDrafts'; -import {EmbedCardRenderer, UploadCard, UploadCardRenderer} from '$components/upload-card'; -import type {UploadBoardImperativeHandlers} from '$components/upload-board'; -import {UploadBoard, UploadBoardContent, UploadBoardHeader} from '$components/upload-board'; -import type {Upload, UploadSuccess} from '$state/upload'; -import {createUploadFamilyObserverAtom, UploadStatus} from '$state/upload'; -import {loadImageElementFromMediaUrl} from '$utils/dom'; -import {isImageMimeType, safeUploadFile} from '$utils/mimeTypes'; -import {useSetting} from '$state/hooks/settings'; -import type {EditorButtonId} from '$state/settings'; -import {settingsAtom} from '$state/settings'; -import {matchesShortcut} from '../../keyboard/shortcuts'; -import {getEditedEvent, getThreadReplyEvents} from '$utils/room/relations'; -import {htmlToMarkdown} from '$plugins/markdown'; -import {Command, useCommands} from '$hooks/useCommands'; -import {isMobileOrTablet, isMobileTauri} from '$utils/platform'; -import {Reply, ThreadIndicator} from '$components/message'; -import {roomToParentsAtom} from '$state/room/roomToParents'; -import {nicknamesAtom} from '$state/nicknames'; -import {useMediaAuthentication} from '$hooks/useMediaAuthentication'; -import {useImagePackRooms} from '$hooks/useImagePackRooms'; -import {useComposingCheck} from '$hooks/useComposingCheck'; -import {createLogger} from '$utils/debug'; -import {createDebugLogger} from '$utils/debugLogger'; +import { EmbedCardRenderer, UploadCard, UploadCardRenderer } from '$components/upload-card'; +import type { UploadBoardImperativeHandlers } from '$components/upload-board'; +import { UploadBoard, UploadBoardContent, UploadBoardHeader } from '$components/upload-board'; +import type { Upload, UploadSuccess } from '$state/upload'; +import { createUploadFamilyObserverAtom, UploadStatus } from '$state/upload'; +import { loadImageElementFromMediaUrl } from '$utils/dom'; +import { isImageMimeType, safeUploadFile } from '$utils/mimeTypes'; +import { useSetting } from '$state/hooks/settings'; +import type { EditorButtonId } from '$state/settings'; +import { settingsAtom } from '$state/settings'; +import { matchesShortcut } from '../../keyboard/shortcuts'; +import { getEditedEvent, getThreadReplyEvents } from '$utils/room/relations'; +import { htmlToMarkdown } from '$plugins/markdown'; +import { Command, useCommands } from '$hooks/useCommands'; +import { isMobileOrTablet, isMobileTauri } from '$utils/platform'; +import { Reply, ThreadIndicator } from '$components/message'; +import { roomToParentsAtom } from '$state/room/roomToParents'; +import { nicknamesAtom } from '$state/nicknames'; +import { useMediaAuthentication } from '$hooks/useMediaAuthentication'; +import { useImagePackRooms } from '$hooks/useImagePackRooms'; +import { useComposingCheck } from '$hooks/useComposingCheck'; +import { createLogger } from '$utils/debug'; +import { createDebugLogger } from '$utils/debugLogger'; import FocusTrap from 'focus-trap-react'; -import {useQueryClient} from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; import * as Sentry from '@sentry/react'; import { delayedEventsSupportedAtom, - getScheduledMessageStateKey, roomIdToEditingScheduledDelayIdAtomFamily, + getScheduledMessageStateKey, + roomIdToEditingScheduledDelayIdAtomFamily, roomIdToScheduledTimeAtomFamily, serverMaxDelayMsAtom, } from '$state/scheduledMessages'; @@ -126,10 +156,10 @@ import { roomScheduleCoordinator } from '$state/room/roomScheduleCoordinator'; import { timeHourMinute, timeDayMonthYear, daysToMs } from '$utils/time'; import { stopPropagation } from '$utils/keyboard'; -import {usePowerLevelsContext} from '$hooks/usePowerLevels'; -import {useRoomCreators} from '$hooks/useRoomCreators'; -import {useRoomPermissions} from '$hooks/useRoomPermissions'; -import {AutocompleteNotice} from '$components/editor/autocomplete/AutocompleteNotice'; +import { usePowerLevelsContext } from '$hooks/usePowerLevels'; +import { useRoomCreators } from '$hooks/useRoomCreators'; +import { useRoomPermissions } from '$hooks/useRoomPermissions'; +import { AutocompleteNotice } from '$components/editor/autocomplete/AutocompleteNotice'; import { setCurrentlyUsedPerMessageProfileIdForRoom } from '$hooks/usePerMessageProfile'; import type { PerMessageProfileMsc4461 } from '$app/persona'; import { ProfileCatalog } from '$app/persona/catalog'; @@ -159,20 +189,23 @@ import { Stop, X, } from '$components/icons/phosphor'; -import {getSupportedAudioExtension} from '$plugins/voice-recorder-kit/supportedCodec'; -import {ErrorCode} from '../../cs-errorcode'; -import {PKitCommandMessageHandler} from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; - -import type {IGenericMSC4459, MSC4459ImagePackReference} from '$types/matrix/common'; -import {getImagePackReferencesForMxc, getImagePackReferencesForMxcWrappedInMap,} from '$utils/msc4459helper'; -import {ImageUsage} from '$plugins/custom-emoji'; -import {getPackImageInfo} from '$plugins/custom-emoji/utils'; -import {SerializableMap} from '$types/wrapper/SerializableMap'; -import {useSettingsLinkBaseUrl} from '$features/settings/useSettingsLinkBaseUrl'; +import { getSupportedAudioExtension } from '$plugins/voice-recorder-kit/supportedCodec'; +import { ErrorCode } from '../../cs-errorcode'; +import { PKitCommandMessageHandler } from '$plugins/pluralkit-handler/PKitCommandMessageHandler'; + +import type { IGenericMSC4459, MSC4459ImagePackReference } from '$types/matrix/common'; +import { + getImagePackReferencesForMxc, + getImagePackReferencesForMxcWrappedInMap, +} from '$utils/msc4459helper'; +import { ImageUsage } from '$plugins/custom-emoji'; +import { getPackImageInfo } from '$plugins/custom-emoji/utils'; +import { SerializableMap } from '$types/wrapper/SerializableMap'; +import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl'; import * as messageCss from '$features/room/message/styles.css'; -import {AttachmentContent} from '$components/attachment-sheet/AttachmentContent'; -import {MobileSwipeDownModal} from '$components/MobileSwipeDownModal'; -import {SchedulePickerDialog} from './schedule-send'; +import { AttachmentContent } from '$components/attachment-sheet/AttachmentContent'; +import { MobileSwipeDownModal } from '$components/MobileSwipeDownModal'; +import { SchedulePickerDialog } from './schedule-send'; import * as css from './schedule-send/SchedulePickerDialog.css'; import { buildGalleryContent, @@ -184,10 +217,13 @@ import { getVideoMsgContent, } from './msgContent'; -import {getSendableKlipyMxcUrl} from '$utils/klipy'; -import {CommandAutocomplete} from './CommandAutocomplete'; -import type {AudioMessageRecorderHandle, AudioRecordingCompletePayload,} from './AudioMessageRecorder'; -import {AudioMessageRecorder} from './AudioMessageRecorder'; +import { getSendableKlipyMxcUrl } from '$utils/klipy'; +import { CommandAutocomplete } from './CommandAutocomplete'; +import type { + AudioMessageRecorderHandle, + AudioRecordingCompletePayload, +} from './AudioMessageRecorder'; +import { AudioMessageRecorder } from './AudioMessageRecorder'; import * as prefix from '$unstable/prefixes'; import { PollDialog } from './poll-modals'; import { useClientConfig } from '$hooks/useClientConfig'; @@ -195,7 +231,11 @@ import { PersistentPersonaPicker, type PersonaPickerTab } from './persona-picker import { createComposerController, type ComposerController } from './composerController'; import { buildEditReplacement, buildOutgoingMessage } from './composerMessage'; import { pickNativeFile } from './nativeFilePicker'; -import {createEmbedFamilyObserverAtom, EmbedStatus, FixedPreviewUrlResponse} from "$state/bundle.ts"; +import { + createEmbedFamilyObserverAtom, + EmbedStatus, + FixedPreviewUrlResponse, +} from '$state/bundle.ts'; const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) @@ -293,8 +333,6 @@ interface RoomInputProps { onCancelEdit?: () => void; } - - export const RoomInput = forwardRef( ( { @@ -369,7 +407,6 @@ export const RoomInput = forwardRef( const replyDraftRef = useRef(replyDraft); replyDraftRef.current = replyDraft; - const [embedLinks, setEmbedLinks] = useAtom(roomIdToEmbeddItemsAtomFamily(roomId)); const [embedsEnabled, setEmbedsEnabled] = useState(false); const store = useStore(); @@ -1392,7 +1429,6 @@ export const RoomInput = forwardRef( }); }; - const handleCloseAutocomplete = useCallback(() => { setAutocompleteQuery((prev) => { if (prev !== undefined) { @@ -1475,9 +1511,7 @@ export const RoomInput = forwardRef( if (selectedFiles.length > 0) { const uploads = uploadBoardHandlers.current?.getSendableUploads() ?? []; - - - const sendUpload = handleSendUploadRef.current; + const sendUpload = handleSendUploadRef.current; setUploadSending(true); try { if (await sendUpload(uploads, submission, isLive)) return; @@ -1512,7 +1546,7 @@ export const RoomInput = forwardRef( embedLinks, embedsEnabled, generateBundles, - store + store, }); if (outgoing.kind === 'empty') return; @@ -1728,11 +1762,9 @@ export const RoomInput = forwardRef( ); }, [editingEvent, executeSubmit, isMobile, takeSubmission]); - const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); - const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { const autocompleteMenu = document.querySelector('[data-autocomplete-menu]'); @@ -1848,39 +1880,47 @@ export const RoomInput = forwardRef( return; } //from https://regex101.com/r/3fYy3x/1 - const URL_REGEX = RegExp(/http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm) - const urls = Array.from(text.matchAll(URL_REGEX) + const URL_REGEX = RegExp( + /http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm + ); + const urls = Array.from( + text + .matchAll(URL_REGEX) //dont include the url if its in angled braces like cant do this in regex because braces are context free (yay computer science) - .filter((e) => !(e.index > 0 && text[e.index - 1] == '<' && text[e.index + e[0].length] == '>') ) - .map((e) => e[0])); + .filter( + (e) => !(e.index > 0 && text[e.index - 1] == '<' && text[e.index + e[0].length] == '>') + ) + .map((e) => e[0]) + ); if (urls.length > 0) { setUploadBoard(true); - const embedItems = urls.map((url): TEmbeddItem => ({ - url: url - })); + const embedItems = urls.map( + (url): TEmbeddItem => ({ + url: url, + }) + ); setEmbedLinks({ - type: 'CLEAR' + type: 'CLEAR', }); setEmbedLinks({ - type: "PUT", - item: embedItems - }) - + type: 'PUT', + item: embedItems, + }); } else { setEmbedLinks({ - type: 'CLEAR' + type: 'CLEAR', }); setEmbedsEnabled(false); } }; const handleEmbedsToggle = (state: boolean) => { - setEmbedsEnabled(state) - for (const param of roomEmbedAtomFamily.getParams()) { - roomEmbedAtomFamily.remove(param); - } - } + setEmbedsEnabled(state); + for (const param of roomEmbedAtomFamily.getParams()) { + roomEmbedAtomFamily.remove(param); + } + }; const handleEmoticonSelect = (key: string, shortcode: string) => { const emoticonEl = createEmoticonElement(key, shortcode); @@ -2097,7 +2137,7 @@ export const RoomInput = forwardRef( /> } > - {(uploadBoard) && ( + {uploadBoard && ( {Array.from(selectedFiles) @@ -2117,47 +2157,61 @@ export const RoomInput = forwardRef( /> ))} - {(embedLinks.length > 0 ) && generateBundles && (<> - {selectedFiles.length > 0 && } + {embedLinks.length > 0 && generateBundles && ( + <> + {selectedFiles.length > 0 && ( + + )} } after={ //fake close button to keep box size -
+
{sizedIcon(X, '200')} +
+ } + bottom={ + <> + + + Bundled
Embeds +
-
} - bottom={<> - - - Bundled
Embeds -
- - -
- - } - > - - {"Links found"} - -
)} - {embedsEnabled && Array.from(embedLinks) - .map((link) => ( - - {}}/> - ))} + +
+ + } + > + + {'Links found'} + + + + )} + {embedsEnabled && + Array.from(embedLinks).map((link) => ( + {}} + /> + ))}
)} diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts index 5a16a577a7..976ea5099f 100644 --- a/src/app/features/room/composerMessage.test.ts +++ b/src/app/features/room/composerMessage.test.ts @@ -1,13 +1,19 @@ -import {beforeEach, describe, expect, it, vi} from 'vitest'; -import {BlockType, plainToEditorInput} from '$components/editor'; -import {Command, SHRUG} from '$hooks/useCommands'; -import type {MatrixClient, Room} from '$types/matrix-sdk'; -import {SerializableMap} from '$types/wrapper/SerializableMap'; -import type {MSC4459ImagePackReference} from '$types/matrix/common'; -import type {PerMessageProfileMsc4461} from '$app/persona'; -import {createStore, useAtom, useStore} from "jotai"; -import {createEmbedAtom, Embed, EmbedPreviewType, EmbedStatus, FixedPreviewUrlResponse} from "$state/bundle.ts"; -import {roomEmbedAtomFamily, roomIdToEmbeddItemsAtomFamily} from "$state/room/roomInputDrafts.ts"; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { BlockType, plainToEditorInput } from '$components/editor'; +import { Command, SHRUG } from '$hooks/useCommands'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; +import { SerializableMap } from '$types/wrapper/SerializableMap'; +import type { MSC4459ImagePackReference } from '$types/matrix/common'; +import type { PerMessageProfileMsc4461 } from '$app/persona'; +import { createStore, useAtom, useStore } from 'jotai'; +import { + createEmbedAtom, + Embed, + EmbedPreviewType, + EmbedStatus, + FixedPreviewUrlResponse, +} from '$state/bundle.ts'; +import { roomEmbedAtomFamily, roomIdToEmbeddItemsAtomFamily } from '$state/room/roomInputDrafts.ts'; const { profiles } = vi.hoisted(() => ({ profiles: { @@ -59,8 +65,6 @@ const commandInput = (command: Command, rest = '') => [ }, ]; - - const store = createStore(); const build = ( @@ -211,30 +215,33 @@ describe('buildOutgoingMessage', () => { expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); }); - it('generate bundled embeds', async () => { - let embedatom = roomEmbedAtomFamily("https://example.com/page"); + let embedatom = roomEmbedAtomFamily('https://example.com/page'); let embed: Embed = { - url: "https://example.com/page", + url: 'https://example.com/page', status: EmbedStatus.Success, - preview: {title: "Example Title", type: EmbedPreviewType.TitleDescription}, - data: {"og:title": "Example Title", "og:type": "website", "og:url": "https://example.com/page"}, - } + preview: { title: 'Example Title', type: EmbedPreviewType.TitleDescription }, + data: { + 'og:title': 'Example Title', + 'og:type': 'website', + 'og:url': 'https://example.com/page', + }, + }; - store.set(embedatom, embed) + store.set(embedatom, embed); - const result = await build('see https://example.com/page', - {embedLinks: [{url: "https://example.com/page"}], - embedsEnabled: true, - generateBundles: true - }); + const result = await build('see https://example.com/page', { + embedLinks: [{ url: 'https://example.com/page' }], + embedsEnabled: true, + generateBundles: true, + }); if (result.kind !== 'message') throw new Error('expected a message'); const previews = result.content['com.beeper.linkpreviews'] as - | { matched_url: string, "og:title": string, }[] - | undefined; + | { matched_url: string; 'og:title': string }[] + | undefined; expect(previews?.map((preview) => preview.matched_url)).toContain('https://example.com/page'); - expect(previews?.map((preview) => preview["og:title"])).toContain('Example Title'); + expect(previews?.map((preview) => preview['og:title'])).toContain('Example Title'); }); it('preserves the original per-message profile when editing', () => { diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index 67d05ad40f..ea77ccc485 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -20,7 +20,7 @@ import { IReplyDraft, roomEmbedAtomFamily, roomIdToEmbeddItemsAtomFamily, - TEmbeddItem + TEmbeddItem, } from '$state/room/roomInputDrafts'; import { ProfileCatalog } from '$app/persona/catalog'; import type { PerMessageProfileMsc4461 } from '$app/persona'; @@ -33,11 +33,11 @@ import { buildReplacementContent } from './buildReplacementContent'; import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { SerializableMap } from '$types/wrapper/SerializableMap'; -import {useAtom, useStore} from "jotai"; -import {useState} from "react"; -import {useSetting} from "$state/hooks/settings.ts"; -import {settingsAtom} from "$state/settings.ts"; -import {createEmbedFamilyObserverAtom, EmbedStatus} from "$state/bundle.ts"; +import { useAtom, useStore } from 'jotai'; +import { useState } from 'react'; +import { useSetting } from '$state/hooks/settings.ts'; +import { settingsAtom } from '$state/settings.ts'; +import { createEmbedFamilyObserverAtom, EmbedStatus } from '$state/bundle.ts'; export type MessageContent = IContent & Pick; @@ -76,14 +76,9 @@ export interface BuildOutgoingMessageDeps { embedLinks: TEmbeddItem[]; embedsEnabled: boolean; generateBundles: boolean; - store: any + store: any; } - - - - - const resolveNickname = ( room: Room, nicknames: Record, @@ -276,14 +271,14 @@ export async function buildOutgoingMessage( const embeds = deps.store.get(embedObserver); for (const embed of embeds) { if (embed.status == EmbedStatus.Loading || embed.status == EmbedStatus.Idle) - return {kind: "failed", reason: "bundled embeds not ready"}; + return { kind: 'failed', reason: 'bundled embeds not ready' }; if (embed.status == EmbedStatus.Success) { - embed.data["matched_url"] = embed.url; + embed.data['matched_url'] = embed.url; content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push(embed.data); } else { content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ matched_url: embed.url, - }) + }); } } } else { @@ -291,8 +286,8 @@ export async function buildOutgoingMessage( links?.forEach((link) => { content[prefix.MATRIX_UNSTABLE_EMBEDDED_LINK_PREVIEW_PROPERTY_NAME].push({ matched_url: link, - })} - ); + }); + }); } if (replyDraft || !customHtmlEqualsPlainText(customHtml, plainText)) { diff --git a/src/app/features/settings/general/General.tsx b/src/app/features/settings/general/General.tsx index dc379ab2f0..639d7b89f3 100644 --- a/src/app/features/settings/general/General.tsx +++ b/src/app/features/settings/general/General.tsx @@ -1026,8 +1026,14 @@ function Embeds() { const [encUrlPreview, setEncUrlPreview] = useSetting(settingsAtom, 'encUrlPreview'); const [clientUrlPreview, setClientUrlPreview] = useSetting(settingsAtom, 'clientUrlPreview'); const [generateBundles, setGenerateBundles] = useSetting(settingsAtom, 'generateBundles'); - const [bundleUseHomeserver, setBundleUseHomeserver] = useSetting(settingsAtom, 'useHomeserverForBundles'); - const [encryptBundledMedia, setEncryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); + const [bundleUseHomeserver, setBundleUseHomeserver] = useSetting( + settingsAtom, + 'useHomeserverForBundles' + ); + const [encryptBundledMedia, setEncryptBundledMedia] = useSetting( + settingsAtom, + 'encryptBundledMedia' + ); const [showInteractiveMap, setShowInteractiveMap] = useSetting( settingsAtom, 'showInteractiveMap' @@ -1063,28 +1069,30 @@ function Embeds() { onChange={setBundledPreview} /> - {generateBundles && - <> + - - } + /> + + )} ; - "og:image"?: string - MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME?: Record; + [p: string]: string | number | undefined | Record; + 'og:image'?: string; + MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME?: Record; } & Omit>; export enum EmbedPreviewType { - TitleDescription, - MediaOnly, - TitleDescriptionMedia + TitleDescription, + MediaOnly, + TitleDescriptionMedia, } export type EmbedPreview = { - title: string, - type: EmbedPreviewType, - description?: string, - media?: Blob, - mediaType?: string -} + title: string; + type: EmbedPreviewType; + description?: string; + media?: Blob; + mediaType?: string; +}; export enum EmbedStatus { - Idle = 'idle', - Loading = 'loading', - Success = 'success', - Error = 'error', + Idle = 'idle', + Loading = 'loading', + Success = 'success', + Error = 'error', } export enum LoadingStatus { - Html = 'fetching html', - MediaDown = 'downloading media ', - MediaUp = 'uploading media', - Encrypt = 'encrypting media', + Html = 'fetching html', + MediaDown = 'downloading media ', + MediaUp = 'uploading media', + Encrypt = 'encrypting media', } export type EmbedIdle = { - url: string; - status: EmbedStatus.Idle; + url: string; + status: EmbedStatus.Idle; }; export type EmbedLoading = { - url: string; - status: EmbedStatus.Loading; - progress: LoadingStatus; - preview?: EmbedPreview; - promise: Promise; + url: string; + status: EmbedStatus.Loading; + progress: LoadingStatus; + preview?: EmbedPreview; + promise: Promise; }; export type EmbedSuccess = { - url: string; - status: EmbedStatus.Success; - preview: EmbedPreview; - data: FixedPreviewUrlResponse; + url: string; + status: EmbedStatus.Success; + preview: EmbedPreview; + data: FixedPreviewUrlResponse; }; export type EmbedError = { - url: string; - status: EmbedStatus.Error; - reason: EmbedErrorReason; + url: string; + status: EmbedStatus.Error; + reason: EmbedErrorReason; }; export type Embed = EmbedIdle | EmbedLoading | EmbedSuccess | EmbedError; type EmbedAtomAction = - | { - promise: Promise; -} - | { - progress: LoadingStatus; - preview: EmbedPreview | undefined; -} - | { - data: FixedPreviewUrlResponse; - preview: EmbedPreview; -} - | { - error: EmbedErrorReason; -}; + | { + promise: Promise; + } + | { + progress: LoadingStatus; + preview: EmbedPreview | undefined; + } + | { + data: FixedPreviewUrlResponse; + preview: EmbedPreview; + } + | { + error: EmbedErrorReason; + }; export enum EmbedErrorReason { - RequestFailed, - NoOgData, - UploadFailed, - EncryptFailed, + RequestFailed, + NoOgData, + UploadFailed, + EncryptFailed, } export type fetchEmbedOptions = { - onPromise?: (promise: Promise) => void; - onProgress?: (preview: EmbedPreview, progress: LoadingStatus) => void; - onSuccess: (embed: FixedPreviewUrlResponse, progress: EmbedPreview) => void; - onError: (reason: EmbedErrorReason) => void; + onPromise?: (promise: Promise) => void; + onProgress?: (preview: EmbedPreview, progress: LoadingStatus) => void; + onSuccess: (embed: FixedPreviewUrlResponse, progress: EmbedPreview) => void; + onError: (reason: EmbedErrorReason) => void; }; const hasProperty = (dom: Element, value: string) => { - return value === dom.attribs?.property + return value === dom.attribs?.property; }; const parseOg = (html: string) => { - const dom = parseDocument(html); - let head = DomUtils.getElementsByTagName("head", dom); - let metaTags = DomUtils.getElementsByTagName("meta", head); - - - let alternatives: Record = { - "og:title": [["name", "title"]], - "og:description": [["name", "description"]], - } - - let requiredTags = ["og:title", "og:type", "og:url"] - let optionalStringTags = ["og:image", "og:image:type", "og:description", "og:image:alt"]; - let optionalNumberTags = ["og:image:height", "og:image:width"] - let result = new Map(); - for (const ogTag of requiredTags) { - let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) - if (!tag || !tag.attribs?.content) { - let altList = alternatives[ogTag] - if (!altList) - return null - while (!tag || !tag.attribs?.content) { - let next = altList.pop() - if (!next) - return null - tag = DomUtils.findOne((e) => e.attribs?.[(next[0])] == next[1], metaTags) - } - } - result.set(ogTag, tag.attribs?.content); + const dom = parseDocument(html); + let head = DomUtils.getElementsByTagName('head', dom); + let metaTags = DomUtils.getElementsByTagName('meta', head); + + let alternatives: Record = { + 'og:title': [['name', 'title']], + 'og:description': [['name', 'description']], + }; + + let requiredTags = ['og:title', 'og:type', 'og:url']; + let optionalStringTags = ['og:image', 'og:image:type', 'og:description', 'og:image:alt']; + let optionalNumberTags = ['og:image:height', 'og:image:width']; + let result = new Map(); + for (const ogTag of requiredTags) { + let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags); + if (!tag || !tag.attribs?.content) { + let altList = alternatives[ogTag]; + if (!altList) return null; + while (!tag || !tag.attribs?.content) { + let next = altList.pop(); + if (!next) return null; + tag = DomUtils.findOne((e) => e.attribs?.[next[0]] == next[1], metaTags); + } } - for (const ogTag of optionalStringTags) { - let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) - result.set(ogTag, tag?.attribs?.content); - } - for (const ogTag of optionalNumberTags) { - let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags) - if (!tag?.attribs?.content) - continue; - result.set(ogTag, Number.parseInt(tag?.attribs?.content)); - } - - return result -} - + result.set(ogTag, tag.attribs?.content); + } + for (const ogTag of optionalStringTags) { + let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags); + result.set(ogTag, tag?.attribs?.content); + } + for (const ogTag of optionalNumberTags) { + let tag = DomUtils.findOne((e) => hasProperty(e, ogTag), metaTags); + if (!tag?.attribs?.content) continue; + result.set(ogTag, Number.parseInt(tag?.attribs?.content)); + } + + return result; +}; function fetchWrapper(url: string) { - return isTauri() ? taurifetch(url) : fetch(url) + return isTauri() ? taurifetch(url) : fetch(url); } - - const fetchEmbed = async ( - url: string, - mx: MatrixClient, - encrypted: boolean, - bundleUseHomeserver: boolean, - successCallback: (result: FixedPreviewUrlResponse) => void, - fetchEmbedOptions: fetchEmbedOptions) => { - - - const useAuthentication = useMediaAuthentication(); - if (!fetchEmbedOptions.onProgress) - fetchEmbedOptions.onProgress = (_) => { - }; - if (!fetchEmbedOptions.onPromise) - fetchEmbedOptions.onPromise = (_) => { - } - const pushImage = async (imgData: Blob, preview: EmbedPreview, embedRecord: FixedPreviewUrlResponse) => { - preview.media = imgData; - let encInfo: EncryptedAttachmentInfo; - if (encrypted) { - fetchEmbedOptions.onProgress?.(preview, LoadingStatus.Encrypt) - try { - let encryptResult = await encryptFile(imgData) - imgData = encryptResult.file - encInfo = encryptResult.encInfo - } catch (e) { - fetchEmbedOptions.onError(EmbedErrorReason.EncryptFailed); - return; - } - - } - fetchEmbedOptions.onProgress?.(preview, LoadingStatus.MediaUp) - await uploadContent(mx, new File([imgData], "mediaPreview"), { - onError: _ => { - if (preview.type == EmbedPreviewType.MediaOnly) { - fetchEmbedOptions.onError(EmbedErrorReason.UploadFailed) - return; - } - preview.type = EmbedPreviewType.TitleDescription; - for (const e in embedRecord) { - if (e.startsWith("og:image")) { - embedRecord[e] = undefined; - } - } - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - }, - onSuccess: (mxc) => { - if (encrypted) { - embedRecord[MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME] = { - ...encInfo, - url: mxc, - }; - embedRecord["matrix:image:size"] = imgData.size - embedRecord["og:image"] = undefined - } else { - embedRecord["og:image"] = mxc; - embedRecord["matrix:image:size"] = imgData.size - } - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - } - }) + url: string, + mx: MatrixClient, + encrypted: boolean, + bundleUseHomeserver: boolean, + successCallback: (result: FixedPreviewUrlResponse) => void, + fetchEmbedOptions: fetchEmbedOptions +) => { + const useAuthentication = useMediaAuthentication(); + if (!fetchEmbedOptions.onProgress) fetchEmbedOptions.onProgress = (_) => {}; + if (!fetchEmbedOptions.onPromise) fetchEmbedOptions.onPromise = (_) => {}; + const pushImage = async ( + imgData: Blob, + preview: EmbedPreview, + embedRecord: FixedPreviewUrlResponse + ) => { + preview.media = imgData; + let encInfo: EncryptedAttachmentInfo; + if (encrypted) { + fetchEmbedOptions.onProgress?.(preview, LoadingStatus.Encrypt); + try { + let encryptResult = await encryptFile(imgData); + imgData = encryptResult.file; + encInfo = encryptResult.encInfo; + } catch (e) { + fetchEmbedOptions.onError(EmbedErrorReason.EncryptFailed); + return; + } } - //TODO filter local ips - if (bundleUseHomeserver) { - var response: IPreviewUrlResponse - try { - const promise = mx.getUrlPreview(url, Date.now()); - fetchEmbedOptions.onPromise(promise); - response = await promise; - } catch (e) { - fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); - return; - } - const imageUrl = mxcUrlToHttp( - mx, - response['og:image'] || '', - useAuthentication, - 256, - 256, - 'scale', - false - ); - let preview: EmbedPreview = { - description: response['og:description'], - title: response['og:title'], - type: EmbedPreviewType.TitleDescription - } - if (!imageUrl) { - fetchEmbedOptions.onSuccess(response, preview); - successCallback(response); - return; - } - - let fetchPromise = fetch(imageUrl) - fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown); - let fetchResponse = (await fetchPromise) - let fetchBlob = await fetchResponse.blob().catch(_ => null); - if (!fetchBlob) { - fetchEmbedOptions.onSuccess(response, preview); - successCallback(response); - return; + fetchEmbedOptions.onProgress?.(preview, LoadingStatus.MediaUp); + await uploadContent(mx, new File([imgData], 'mediaPreview'), { + onError: (_) => { + if (preview.type == EmbedPreviewType.MediaOnly) { + fetchEmbedOptions.onError(EmbedErrorReason.UploadFailed); + return; } - preview.media = fetchBlob; - if (fetchBlob.type.length > 0) { - preview.mediaType == fetchBlob.type - } else if (fetchResponse.headers.get('content-type')?.split(";")[0]) { - preview.mediaType = fetchResponse.headers.get('content-type')?.split(";")[0]; + preview.type = EmbedPreviewType.TitleDescription; + for (const e in embedRecord) { + if (e.startsWith('og:image')) { + embedRecord[e] = undefined; + } } - preview.type = EmbedPreviewType.TitleDescriptionMedia; - if(encrypted) { - await pushImage(fetchBlob, preview, response) + fetchEmbedOptions.onSuccess(embedRecord, preview); + successCallback(embedRecord); + }, + onSuccess: (mxc) => { + if (encrypted) { + embedRecord[MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME] = { + ...encInfo, + url: mxc, + }; + embedRecord['matrix:image:size'] = imgData.size; + embedRecord['og:image'] = undefined; } else { - fetchEmbedOptions.onSuccess(response, preview); - successCallback(response); - + embedRecord['og:image'] = mxc; + embedRecord['matrix:image:size'] = imgData.size; } - return; - - } - let fetchResponse; + fetchEmbedOptions.onSuccess(embedRecord, preview); + successCallback(embedRecord); + }, + }); + }; + //TODO filter local ips + if (bundleUseHomeserver) { + var response: IPreviewUrlResponse; try { - let fetchPromise = fetchWrapper(url) - fetchEmbedOptions.onPromise(fetchPromise); - fetchResponse = await fetchPromise; - + const promise = mx.getUrlPreview(url, Date.now()); + fetchEmbedOptions.onPromise(promise); + response = await promise; } catch (e) { - console.error(e); - fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); - return + fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + return; + } + const imageUrl = mxcUrlToHttp( + mx, + response['og:image'] || '', + useAuthentication, + 256, + 256, + 'scale', + false + ); + let preview: EmbedPreview = { + description: response['og:description'], + title: response['og:title'], + type: EmbedPreviewType.TitleDescription, + }; + if (!imageUrl) { + fetchEmbedOptions.onSuccess(response, preview); + successCallback(response); + return; } - if (fetchResponse.ok) { - - - if (fetchResponse.headers.get('content-type')?.split(";")[0] == 'text/html') { - let prelimEmbed = parseOg(await fetchResponse.text()) - if (!prelimEmbed) { - fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); - return - } - - let supportedMediaTags = ["og:image"]; - const hasSupportedMedia = prelimEmbed.keys().some(item => supportedMediaTags.includes(item)) - - - let preview: EmbedPreview = { - description: prelimEmbed.get("og:description"), - title: prelimEmbed.get("og:title"), - type: hasSupportedMedia ? EmbedPreviewType.TitleDescriptionMedia : EmbedPreviewType.TitleDescription - } - fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown) - - let embedRecord: FixedPreviewUrlResponse = Object.fromEntries(prelimEmbed) - if (!hasSupportedMedia) { - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - } else { - new Response - if (embedRecord["og:image"]) { - const response = await fetchWrapper(embedRecord["og:image"]) - const imgData = await response.blob(); - preview.media = imgData; - if (imgData.type.length > 0) { - preview.mediaType = imgData.type - } else if (response.headers.get('content-type')?.split(";")[0]) { - preview.mediaType = response.headers.get('content-type')?.split(";")[0]; - } - await pushImage(imgData, preview, embedRecord); - } else { - //other media types - preview.type = EmbedPreviewType.TitleDescription; - fetchEmbedOptions.onSuccess(embedRecord, preview) - successCallback(embedRecord) - } - } - - - } else if (isImageMimeType(fetchResponse.headers.get('content-type')?.split(";")[0] || "")) { - //todo - fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); - + let fetchPromise = fetch(imageUrl); + fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown); + let fetchResponse = await fetchPromise; + let fetchBlob = await fetchResponse.blob().catch((_) => null); + if (!fetchBlob) { + fetchEmbedOptions.onSuccess(response, preview); + successCallback(response); + return; + } + preview.media = fetchBlob; + if (fetchBlob.type.length > 0) { + preview.mediaType == fetchBlob.type; + } else if (fetchResponse.headers.get('content-type')?.split(';')[0]) { + preview.mediaType = fetchResponse.headers.get('content-type')?.split(';')[0]; + } + preview.type = EmbedPreviewType.TitleDescriptionMedia; + if (encrypted) { + await pushImage(fetchBlob, preview, response); + } else { + fetchEmbedOptions.onSuccess(response, preview); + successCallback(response); + } + return; + } + let fetchResponse; + try { + let fetchPromise = fetchWrapper(url); + fetchEmbedOptions.onPromise(fetchPromise); + fetchResponse = await fetchPromise; + } catch (e) { + console.error(e); + fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + return; + } + if (fetchResponse.ok) { + if (fetchResponse.headers.get('content-type')?.split(';')[0] == 'text/html') { + let prelimEmbed = parseOg(await fetchResponse.text()); + if (!prelimEmbed) { + fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); + return; + } + + let supportedMediaTags = ['og:image']; + const hasSupportedMedia = prelimEmbed + .keys() + .some((item) => supportedMediaTags.includes(item)); + + let preview: EmbedPreview = { + description: prelimEmbed.get('og:description'), + title: prelimEmbed.get('og:title'), + type: hasSupportedMedia + ? EmbedPreviewType.TitleDescriptionMedia + : EmbedPreviewType.TitleDescription, + }; + fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown); + + let embedRecord: FixedPreviewUrlResponse = ( + Object.fromEntries(prelimEmbed) + ); + if (!hasSupportedMedia) { + fetchEmbedOptions.onSuccess(embedRecord, preview); + successCallback(embedRecord); + } else { + new Response(); + if (embedRecord['og:image']) { + const response = await fetchWrapper(embedRecord['og:image']); + const imgData = await response.blob(); + preview.media = imgData; + if (imgData.type.length > 0) { + preview.mediaType = imgData.type; + } else if (response.headers.get('content-type')?.split(';')[0]) { + preview.mediaType = response.headers.get('content-type')?.split(';')[0]; + } + await pushImage(imgData, preview, embedRecord); } else { - fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); + //other media types + preview.type = EmbedPreviewType.TitleDescription; + fetchEmbedOptions.onSuccess(embedRecord, preview); + successCallback(embedRecord); } + } + } else if (isImageMimeType(fetchResponse.headers.get('content-type')?.split(';')[0] || '')) { + //todo + fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); } else { - fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); } - - - - -} + } else { + fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); + } +}; export const createEmbedAtom = (url: string) => { - const baseEmbedAtom = atom({ - url, - status: EmbedStatus.Idle, - }); - return atom( - (get) => get(baseEmbedAtom), - (get, set, update) => { - const embedState = get(baseEmbedAtom); - if ('promise' in update) { - set(baseEmbedAtom, { - status: EmbedStatus.Loading, - url, - progress: LoadingStatus.Html, - promise: update.promise, - }); - return; - } - if ('progress' in update && embedState.status === EmbedStatus.Loading) { - set(baseEmbedAtom, { - ...embedState, - progress: update.progress, - preview: update.preview, - }); - return; - } - if ('data' in update) { - set(baseEmbedAtom, { - status: EmbedStatus.Success, - url, - data: update.data, - preview: update.preview, - }); - return; - } - if ('error' in update) { - set(baseEmbedAtom, { - status: EmbedStatus.Error, - url, - reason: update.error, - }); - } - } - ); + const baseEmbedAtom = atom({ + url, + status: EmbedStatus.Idle, + }); + return atom( + (get) => get(baseEmbedAtom), + (get, set, update) => { + const embedState = get(baseEmbedAtom); + if ('promise' in update) { + set(baseEmbedAtom, { + status: EmbedStatus.Loading, + url, + progress: LoadingStatus.Html, + promise: update.promise, + }); + return; + } + if ('progress' in update && embedState.status === EmbedStatus.Loading) { + set(baseEmbedAtom, { + ...embedState, + progress: update.progress, + preview: update.preview, + }); + return; + } + if ('data' in update) { + set(baseEmbedAtom, { + status: EmbedStatus.Success, + url, + data: update.data, + preview: update.preview, + }); + return; + } + if ('error' in update) { + set(baseEmbedAtom, { + status: EmbedStatus.Error, + url, + reason: update.error, + }); + } + } + ); }; export type TEmbedAtom = ReturnType; export const useBindEmbedAtom = ( - mx: MatrixClient, - embedAtom: TEmbedAtom, - encrypt: boolean, - useHomeserver: boolean, + mx: MatrixClient, + embedAtom: TEmbedAtom, + encrypt: boolean, + useHomeserver: boolean ) => { - const [embed, setEmbed] = useAtom(embedAtom); - const { url } = embed; - - - - const startEmbed = useCallback( - (successCallback: (result: FixedPreviewUrlResponse) => void,) => - fetchEmbed(url, mx, encrypt, useHomeserver, successCallback, { - onSuccess: (data, progress) => setEmbed({ data, preview: progress }), - onError: (error) => setEmbed({ error }), - onProgress: (preview, progress) => setEmbed({ preview, progress }), - onPromise: promise => setEmbed({ promise }) - }), - [url, mx, encrypt, useHomeserver ] - ); - - const cancelEmbed = useCallback(() => { - if (embed.status === EmbedStatus.Loading) { - //TODO - } - }, [mx, embed]); + const [embed, setEmbed] = useAtom(embedAtom); + const { url } = embed; + + const startEmbed = useCallback( + (successCallback: (result: FixedPreviewUrlResponse) => void) => + fetchEmbed(url, mx, encrypt, useHomeserver, successCallback, { + onSuccess: (data, progress) => setEmbed({ data, preview: progress }), + onError: (error) => setEmbed({ error }), + onProgress: (preview, progress) => setEmbed({ preview, progress }), + onPromise: (promise) => setEmbed({ promise }), + }), + [url, mx, encrypt, useHomeserver] + ); + + const cancelEmbed = useCallback(() => { + if (embed.status === EmbedStatus.Loading) { + //TODO + } + }, [mx, embed]); - return { - embed, - startEmbed, - cancelEmbed, - }; + return { + embed, + startEmbed, + cancelEmbed, + }; }; -export const createEmbedAtomFamily = () => - atomFamily(createEmbedAtom); +export const createEmbedAtomFamily = () => atomFamily(createEmbedAtom); export type TEmbedAtomFamily = ReturnType; -export const createEmbedFamilyObserverAtom = ( - uploadFamily: TEmbedAtomFamily, - uploads: string[] -) => atom((get) => uploads.map((upload) => get(uploadFamily(upload)))); +export const createEmbedFamilyObserverAtom = (uploadFamily: TEmbedAtomFamily, uploads: string[]) => + atom((get) => uploads.map((upload) => get(uploadFamily(upload)))); export type TEmbedFamilyObserverAtom = ReturnType; diff --git a/src/app/state/list.ts b/src/app/state/list.ts index 265f96324e..37d935e277 100644 --- a/src/app/state/list.ts +++ b/src/app/state/list.ts @@ -15,21 +15,18 @@ type ListAction = item: T | T[]; } | { - type: 'CLEAR'; -}; + type: 'CLEAR'; + }; export const createListAtom = () => { const baseListAtom = atom([]); return atom], undefined>( (get) => get(baseListAtom), (get, set, action) => { - if (action.type === 'CLEAR') { - set( - baseListAtom, - [] - ); - return; - } + if (action.type === 'CLEAR') { + set(baseListAtom, []); + return; + } const items = get(baseListAtom); const newItems = Array.isArray(action.item) ? action.item : [action.item]; if (action.type === 'DELETE') { diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 6c5fa4b982..7e215e4849 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -4,10 +4,10 @@ import type { Descendant } from 'slate'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IEventRelation } from '$types/matrix-sdk'; import type { TUploadContent } from '$utils/matrix'; -import {createUploadAtom, createUploadAtomFamily, TUploadAtom} from '$state/upload'; +import { createUploadAtom, createUploadAtomFamily, TUploadAtom } from '$state/upload'; import { createListAtom } from '$state/list'; -import {createEmbedAtomFamily} from "$state/bundle.ts"; -import {IPreviewUrlResponse} from "matrix-js-sdk"; +import { createEmbedAtomFamily } from '$state/bundle.ts'; +import { IPreviewUrlResponse } from 'matrix-js-sdk'; export type TUploadMetadata = { markedAsSpoiler: boolean; @@ -32,7 +32,6 @@ export const roomIdToUploadItemsAtomFamily = atomFamily export const roomUploadAtomFamily = createUploadAtomFamily(); - export type TEmbeddItem = { url: string; }; diff --git a/src/unstable/prefixes/misc.ts b/src/unstable/prefixes/misc.ts index f6c69d9776..db6d15d412 100644 --- a/src/unstable/prefixes/misc.ts +++ b/src/unstable/prefixes/misc.ts @@ -5,7 +5,7 @@ export const MATRIX_UNSTABLE_PROFILE_BANNER_PROPERTY_NAME = 'chat.commet.profile export const MATRIX_COMMET_UNSTABLE_PROFILE_COLOR_SCHEME_PROPERTY_NAME = 'chat.commet.profile_color_scheme'; ///opengraph field for encrypted embeds -export const MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME = 'beeper:image:encryption' +export const MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME = 'beeper:image:encryption'; /** * @deprecated From f86eb991f00884b2dc9a9e895b2bfa203783f248 Mon Sep 17 00:00:00 2001 From: Florian Date: Mon, 10 Aug 2026 00:39:55 +0200 Subject: [PATCH 19/25] fun formater, linter part 1 --- .../upload-card/EmbedCardRenderer.tsx | 27 +++---------------- src/app/features/room/RoomInput.tsx | 24 ++++++----------- src/app/features/room/composerMessage.test.ts | 8 +++--- src/app/features/room/composerMessage.ts | 11 +++----- src/app/state/bundle.ts | 13 ++------- src/app/state/room/roomInputDrafts.ts | 3 +-- 6 files changed, 20 insertions(+), 66 deletions(-) diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index da36585455..87ca999363 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -1,21 +1,14 @@ import { useMatrixClient } from '$hooks/useMatrixClient.ts'; import { useMediaConfig } from '$hooks/useMediaConfig.ts'; import { roomEmbedAtomFamily } from '$state/room/roomInputDrafts.ts'; -import { type KeyboardEventHandler, useCallback, useMemo } from 'react'; -import type { Opts as LinkifyOpts } from 'linkifyjs'; -import { LINKIFY_OPTS } from '$plugins/react-custom-html-parser.tsx'; -import { useSpoilerClickHandler } from '$hooks/useSpoilerClickHandler.ts'; -import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; -import { useSettingsLinkBaseUrl } from '$features/settings/useSettingsLinkBaseUrl.ts'; import { useSetting } from '$state/hooks/settings.ts'; import { settingsAtom } from '$state/settings.ts'; import { UploadCard } from '$components/upload-card/UploadCard.tsx'; -import { Box, color, IconButton, Text, toRem } from 'folds'; +import { Box, color, Text, toRem } from 'folds'; import { Check, CircleNotch, File as FileIcon, - Image, sizedIcon, X, } from '$components/icons/phosphor.tsx'; @@ -23,13 +16,12 @@ import { EmbedErrorReason, EmbedPreviewType, EmbedStatus, - FixedPreviewUrlResponse, + type FixedPreviewUrlResponse, useBindEmbedAtom, } from '$state/bundle.ts'; import { Image as MediaImage } from '$components/media'; import { useObjectURL } from '$hooks/useObjectURL.ts'; import { isImageMimeType } from '$utils/mimeTypes.ts'; -import { IPreviewUrlResponse } from 'matrix-js-sdk'; type EmbedCardRendererProps = { url: string; @@ -80,22 +72,9 @@ export function EmbedCardRenderer({ encrypt, }: Readonly) { const mx = useMatrixClient(); - const mediaConfig = useMediaConfig(); - const allowSize = mediaConfig['m.upload.size'] || Infinity; - - const linkifyOpts = useMemo(() => ({ ...LINKIFY_OPTS }), []); - - const spoilerClickHandler = useSpoilerClickHandler(); - const useAuthentication = useMediaAuthentication(); - const settingsLinkBaseUrl = useSettingsLinkBaseUrl(); - const [incomingInlineImagesDefaultHeight] = useSetting( - settingsAtom, - 'incomingInlineImagesDefaultHeight' - ); - const [incomingInlineImagesMaxHeight] = useSetting(settingsAtom, 'incomingInlineImagesMaxHeight'); const [bundleUseHomeserver] = useSetting(settingsAtom, 'useHomeserverForBundles'); const embedAtom = roomEmbedAtomFamily(url); - const { embed, startEmbed, cancelEmbed } = useBindEmbedAtom( + const { embed, startEmbed, } = useBindEmbedAtom( mx, embedAtom, encrypt, diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 230154a307..ed2298439d 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -42,7 +42,7 @@ import { MenuItem, OverlayBackdrop, OverlayCenter, - RectCords, + type RectCords, Scroll, Spinner, Switch, @@ -54,7 +54,7 @@ import { Overlay, PopOut } from '$components/overlay-stack'; import { useMatrixClient } from '$hooks/useMatrixClient'; import { ANYWHERE_AUTOCOMPLETE_PREFIXES, - AutocompleteQuery, + type AutocompleteQuery, BEGINNING_AUTOCOMPLETE_PREFIXES, EmoticonAutocomplete, focusEditor, @@ -67,20 +67,15 @@ import { } from '$components/editor'; import { AutocompletePrefix, - BlockType, createEmoticonElement, CustomEditor, customHtmlEqualsPlainText, getAutocompleteQuery, - getBeginCommand, - getLinks, - getMentions, getPrevWorldRange, resetEditor, RoomMentionAutocomplete, toMatrixCustomHTML, toPlainText, - trimCommand, trimCustomHtml, UserMentionAutocomplete, } from '$components/editor'; @@ -102,13 +97,15 @@ import { useFilePicker } from '$hooks/useFilePicker'; import { useFilePasteHandler } from '$hooks/useFilePasteHandler'; import { useFileDropZone } from '$hooks/useFileDrop'; import { - IReplyDraft, roomEmbedAtomFamily, roomIdToEmbeddItemsAtomFamily, roomIdToMsgDraftAtomFamily, roomIdToReplyDraftAtomFamily, roomIdToUploadItemsAtomFamily, roomUploadAtomFamily, +} from '$state/room/roomInputDrafts'; +import type { + IReplyDraft, TEmbeddItem, TUploadItem, TUploadMetadata, @@ -231,12 +228,6 @@ import { PersistentPersonaPicker, type PersonaPickerTab } from './persona-picker import { createComposerController, type ComposerController } from './composerController'; import { buildEditReplacement, buildOutgoingMessage } from './composerMessage'; import { pickNativeFile } from './nativeFilePicker'; -import { - createEmbedFamilyObserverAtom, - EmbedStatus, - FixedPreviewUrlResponse, -} from '$state/bundle.ts'; - const LocationDialog = lazy(() => import('./location-modal').then((module) => ({ default: module.LocationDialog })) ); @@ -1881,7 +1872,7 @@ export const RoomInput = forwardRef( } //from https://regex101.com/r/3fYy3x/1 const URL_REGEX = RegExp( - /http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._\+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_\+.~#?&\/\/=]*)/gm + /http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_+.~#?&/=]*)/gm ); const urls = Array.from( text @@ -2207,9 +2198,10 @@ export const RoomInput = forwardRef( {embedsEnabled && Array.from(embedLinks).map((link) => ( {}} + successCallback={() => {}} /> ))} diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts index 976ea5099f..9c04a7e108 100644 --- a/src/app/features/room/composerMessage.test.ts +++ b/src/app/features/room/composerMessage.test.ts @@ -5,15 +5,13 @@ import type { MatrixClient, Room } from '$types/matrix-sdk'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { PerMessageProfileMsc4461 } from '$app/persona'; -import { createStore, useAtom, useStore } from 'jotai'; +import { createStore} from 'jotai'; import { - createEmbedAtom, - Embed, + type Embed, EmbedPreviewType, EmbedStatus, - FixedPreviewUrlResponse, } from '$state/bundle.ts'; -import { roomEmbedAtomFamily, roomIdToEmbeddItemsAtomFamily } from '$state/room/roomInputDrafts.ts'; +import { roomEmbedAtomFamily } from '$state/room/roomInputDrafts.ts'; const { profiles } = vi.hoisted(() => ({ profiles: { diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index ea77ccc485..2ac59f130b 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -17,10 +17,9 @@ import { import { sanitizeText } from '$utils/sanitize'; import { getMentionContent } from '$utils/room/relations'; import { - IReplyDraft, + type IReplyDraft, roomEmbedAtomFamily, - roomIdToEmbeddItemsAtomFamily, - TEmbeddItem, + type TEmbeddItem, } from '$state/room/roomInputDrafts'; import { ProfileCatalog } from '$app/persona/catalog'; import type { PerMessageProfileMsc4461 } from '$app/persona'; @@ -33,10 +32,6 @@ import { buildReplacementContent } from './buildReplacementContent'; import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { SerializableMap } from '$types/wrapper/SerializableMap'; -import { useAtom, useStore } from 'jotai'; -import { useState } from 'react'; -import { useSetting } from '$state/hooks/settings.ts'; -import { settingsAtom } from '$state/settings.ts'; import { createEmbedFamilyObserverAtom, EmbedStatus } from '$state/bundle.ts'; export type MessageContent = IContent & Pick; @@ -76,7 +71,7 @@ export interface BuildOutgoingMessageDeps { embedLinks: TEmbeddItem[]; embedsEnabled: boolean; generateBundles: boolean; - store: any; + store: Store; } const resolveNickname = ( diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 0029f2b15d..81cd44dfb7 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -1,8 +1,7 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; -import type { MatrixClient, MatrixError } from '$types/matrix-sdk'; +import type { MatrixClient } from '$types/matrix-sdk'; import { useCallback } from 'react'; -import { useThrottle } from '$hooks/useThrottle'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { isTauri } from '@tauri-apps/api/core'; import { fetch as taurifetch } from '@tauri-apps/plugin-http'; @@ -12,8 +11,6 @@ import { encryptFile, mxcUrlToHttp, uploadContent } from '$utils/matrix.ts'; import { isImageMimeType } from '$utils/mimeTypes.ts'; import { MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME } from '$unstable/prefixes/misc.ts'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; -import { useSetting } from '$state/hooks/settings.ts'; -import { settingsAtom } from '$state/settings.ts'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; export type FixedPreviewUrlResponse = { [p: string]: string | number | undefined | Record; @@ -258,7 +255,7 @@ const fetchEmbed = async ( } preview.media = fetchBlob; if (fetchBlob.type.length > 0) { - preview.mediaType == fetchBlob.type; + preview.mediaType = fetchBlob.type; } else if (fetchResponse.headers.get('content-type')?.split(';')[0]) { preview.mediaType = fetchResponse.headers.get('content-type')?.split(';')[0]; } @@ -406,12 +403,6 @@ export const useBindEmbedAtom = ( [url, mx, encrypt, useHomeserver] ); - const cancelEmbed = useCallback(() => { - if (embed.status === EmbedStatus.Loading) { - //TODO - } - }, [mx, embed]); - return { embed, startEmbed, diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 7e215e4849..9fb22d8e07 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -4,10 +4,9 @@ import type { Descendant } from 'slate'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IEventRelation } from '$types/matrix-sdk'; import type { TUploadContent } from '$utils/matrix'; -import { createUploadAtom, createUploadAtomFamily, TUploadAtom } from '$state/upload'; +import { createUploadAtomFamily, } from '$state/upload'; import { createListAtom } from '$state/list'; import { createEmbedAtomFamily } from '$state/bundle.ts'; -import { IPreviewUrlResponse } from 'matrix-js-sdk'; export type TUploadMetadata = { markedAsSpoiler: boolean; From 5849c152c29d0739be324be0f56f9b22e1767663 Mon Sep 17 00:00:00 2001 From: florian Date: Mon, 10 Aug 2026 00:53:11 +0200 Subject: [PATCH 20/25] fix the css styling bug again --- src/app/components/RenderMessageContent.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/RenderMessageContent.tsx b/src/app/components/RenderMessageContent.tsx index e8e79190ff..f5c6c339cc 100644 --- a/src/app/components/RenderMessageContent.tsx +++ b/src/app/components/RenderMessageContent.tsx @@ -217,7 +217,7 @@ function RenderMessageContentInternal({ ), [urlPreview] ); - const messageUrlsPreview = urlPreview || themeChatSableWidgets ? renderUrlsPreview : undefined; + const messageUrlsPreview = urlPreview ? renderUrlsPreview : undefined; const messageBundlePreview = bundledPreview ? renderBundledPreviews : undefined; const renderCaption = () => { From 40418c7f33011eb015c54c56a815272d2f6c3ca3 Mon Sep 17 00:00:00 2001 From: florian Date: Mon, 10 Aug 2026 01:49:21 +0200 Subject: [PATCH 21/25] lint pass --- .../components/overlay-stack/OverlayStack.tsx | 2 +- .../upload-card/EmbedCardRenderer.tsx | 18 +--- src/app/features/room/RoomInput.tsx | 95 ++++++++++--------- src/app/features/room/composerMessage.test.ts | 11 +-- src/app/features/room/composerMessage.ts | 1 + src/app/state/bundle.ts | 62 ++++++------ src/app/state/room/roomInputDrafts.ts | 2 +- 7 files changed, 94 insertions(+), 97 deletions(-) diff --git a/src/app/components/overlay-stack/OverlayStack.tsx b/src/app/components/overlay-stack/OverlayStack.tsx index 5ba40c97e5..7e1b4f5a74 100644 --- a/src/app/components/overlay-stack/OverlayStack.tsx +++ b/src/app/components/overlay-stack/OverlayStack.tsx @@ -31,7 +31,7 @@ export function OverlayStackProvider({ children }: { children: ReactNode }) { setClaims((prev) => prev.some((claim) => claim.id === id) ? prev - : [...prev, { id, seq }].sort((a, b) => a.seq - b.seq) + : [...prev, { id, seq }].toSorted((a, b) => a.seq - b.seq) ), release: (id) => setClaims((prev) => prev.filter((claim) => claim.id !== id)), }), diff --git a/src/app/components/upload-card/EmbedCardRenderer.tsx b/src/app/components/upload-card/EmbedCardRenderer.tsx index 87ca999363..e6a552e1fc 100644 --- a/src/app/components/upload-card/EmbedCardRenderer.tsx +++ b/src/app/components/upload-card/EmbedCardRenderer.tsx @@ -1,17 +1,10 @@ import { useMatrixClient } from '$hooks/useMatrixClient.ts'; -import { useMediaConfig } from '$hooks/useMediaConfig.ts'; import { roomEmbedAtomFamily } from '$state/room/roomInputDrafts.ts'; import { useSetting } from '$state/hooks/settings.ts'; import { settingsAtom } from '$state/settings.ts'; import { UploadCard } from '$components/upload-card/UploadCard.tsx'; import { Box, color, Text, toRem } from 'folds'; -import { - Check, - CircleNotch, - File as FileIcon, - sizedIcon, - X, -} from '$components/icons/phosphor.tsx'; +import { Check, CircleNotch, File as FileIcon, sizedIcon, X } from '$components/icons/phosphor.tsx'; import { EmbedErrorReason, EmbedPreviewType, @@ -74,15 +67,10 @@ export function EmbedCardRenderer({ const mx = useMatrixClient(); const [bundleUseHomeserver] = useSetting(settingsAtom, 'useHomeserverForBundles'); const embedAtom = roomEmbedAtomFamily(url); - const { embed, startEmbed, } = useBindEmbedAtom( - mx, - embedAtom, - encrypt, - bundleUseHomeserver - ); + const { embed, startEmbed } = useBindEmbedAtom(mx, embedAtom, encrypt, bundleUseHomeserver); if (embed.status === EmbedStatus.Idle) { - startEmbed(successCallback); + startEmbed(successCallback).then(() => {}); } return ( diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index ed2298439d..4fcf73e3d9 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -679,6 +679,53 @@ export const RoomInput = forwardRef( setAutocompleteQuery(query); }, [editor]); + const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); + const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); + + const checkForEmbedables = useCallback( + (text: string) => { + if (!generateBundles) { + return; + } + //from https://regex101.com/r/3fYy3x/1 + const URL_REGEX = RegExp( + /http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_+.~#?&/=]*)/gm + ); + const urls = Array.from( + text + .matchAll(URL_REGEX) + //dont include the url if its in angled braces like cant do this in regex because braces are context free (yay computer science) + .filter( + (e) => + !(e.index > 0 && text[e.index - 1] == '<' && text[e.index + e[0].length] == '>') + ) + .map((e) => e[0]) + ); + if (urls.length > 0) { + setUploadBoard(true); + + const embedItems = urls.map( + (url): TEmbeddItem => ({ + url: url, + }) + ); + setEmbedLinks({ + type: 'CLEAR', + }); + setEmbedLinks({ + type: 'PUT', + item: embedItems, + }); + } else { + setEmbedLinks({ + type: 'CLEAR', + }); + setEmbedsEnabled(false); + } + }, + [setEmbedLinks, generateBundles] + ); + const handleEditorChange = useCallback(() => { setHasText(!isEmptyEditor(editor)); checkForEmbedables(Editor.string(editor, [])); @@ -690,7 +737,7 @@ export const RoomInput = forwardRef( lastEncryptionPreparationAt.current = now; mx.getCrypto()?.prepareToEncrypt(room); - }, [editor, detectAutocomplete, mx, room]); + }, [editor, detectAutocomplete, mx, room, checkForEmbedables]); const hasContent = hasText || selectedFiles.length > 0; const isComposing = useComposingCheck(); @@ -1737,6 +1784,9 @@ export const RoomInput = forwardRef( pmpNoFallback, latchedPersona, embedsEnabled, + store, + generateBundles, + embedLinks, ] ); @@ -1753,9 +1803,6 @@ export const RoomInput = forwardRef( ); }, [editingEvent, executeSubmit, isMobile, takeSubmission]); - const [generateBundles] = useSetting(settingsAtom, 'generateBundles'); - const [encryptBundledMedia] = useSetting(settingsAtom, 'encryptBundledMedia'); - const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { const autocompleteMenu = document.querySelector('[data-autocomplete-menu]'); @@ -1866,46 +1913,6 @@ export const RoomInput = forwardRef( [editor, sendTypingStatus, hideActivity, detectAutocomplete] ); - const checkForEmbedables = (text: string) => { - if (!generateBundles) { - return; - } - //from https://regex101.com/r/3fYy3x/1 - const URL_REGEX = RegExp( - /http[s]?:\/\/.(?:www\.)?[-a-zA-Z0-9@%._+~#=]{2,256}\.[a-z]{2,10}\b(?:[-a-zA-Z0-9@:%_+.~#?&/=]*)/gm - ); - const urls = Array.from( - text - .matchAll(URL_REGEX) - //dont include the url if its in angled braces like cant do this in regex because braces are context free (yay computer science) - .filter( - (e) => !(e.index > 0 && text[e.index - 1] == '<' && text[e.index + e[0].length] == '>') - ) - .map((e) => e[0]) - ); - if (urls.length > 0) { - setUploadBoard(true); - - const embedItems = urls.map( - (url): TEmbeddItem => ({ - url: url, - }) - ); - setEmbedLinks({ - type: 'CLEAR', - }); - setEmbedLinks({ - type: 'PUT', - item: embedItems, - }); - } else { - setEmbedLinks({ - type: 'CLEAR', - }); - setEmbedsEnabled(false); - } - }; - const handleEmbedsToggle = (state: boolean) => { setEmbedsEnabled(state); for (const param of roomEmbedAtomFamily.getParams()) { diff --git a/src/app/features/room/composerMessage.test.ts b/src/app/features/room/composerMessage.test.ts index 9c04a7e108..bb283a3a8c 100644 --- a/src/app/features/room/composerMessage.test.ts +++ b/src/app/features/room/composerMessage.test.ts @@ -5,13 +5,10 @@ import type { MatrixClient, Room } from '$types/matrix-sdk'; import { SerializableMap } from '$types/wrapper/SerializableMap'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { PerMessageProfileMsc4461 } from '$app/persona'; -import { createStore} from 'jotai'; -import { - type Embed, - EmbedPreviewType, - EmbedStatus, -} from '$state/bundle.ts'; +import { createStore } from 'jotai'; +import { type Embed, EmbedPreviewType, EmbedStatus } from '$state/bundle.ts'; import { roomEmbedAtomFamily } from '$state/room/roomInputDrafts.ts'; +import type { Store } from 'jotai/vanilla/store'; const { profiles } = vi.hoisted(() => ({ profiles: { @@ -63,7 +60,7 @@ const commandInput = (command: Command, rest = '') => [ }, ]; -const store = createStore(); +const store: Store = createStore(); const build = ( input: string | ReturnType, diff --git a/src/app/features/room/composerMessage.ts b/src/app/features/room/composerMessage.ts index 2ac59f130b..e646766b9e 100644 --- a/src/app/features/room/composerMessage.ts +++ b/src/app/features/room/composerMessage.ts @@ -33,6 +33,7 @@ import { Command, SHRUG, TABLEFLIP, UNFLIP } from '$hooks/useCommands'; import type { MSC4459ImagePackReference } from '$types/matrix/common'; import type { SerializableMap } from '$types/wrapper/SerializableMap'; import { createEmbedFamilyObserverAtom, EmbedStatus } from '$state/bundle.ts'; +import type { Store } from 'jotai/vanilla/store'; export type MessageContent = IContent & Pick; diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 81cd44dfb7..57c03c1946 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -2,21 +2,28 @@ import { atom, useAtom } from 'jotai'; import { atomFamily } from 'jotai/utils'; import type { MatrixClient } from '$types/matrix-sdk'; import { useCallback } from 'react'; -import { IPreviewUrlResponse } from 'matrix-js-sdk'; +import type { IPreviewUrlResponse } from 'matrix-js-sdk'; import { isTauri } from '@tauri-apps/api/core'; import { fetch as taurifetch } from '@tauri-apps/plugin-http'; import { parseDocument, DomUtils } from 'htmlparser2'; -import { Element } from 'domhandler'; +import type { Element } from 'domhandler'; import { encryptFile, mxcUrlToHttp, uploadContent } from '$utils/matrix.ts'; import { isImageMimeType } from '$utils/mimeTypes.ts'; import { MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME } from '$unstable/prefixes/misc.ts'; -import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; +import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { useMediaAuthentication } from '$hooks/useMediaAuthentication.ts'; export type FixedPreviewUrlResponse = { - [p: string]: string | number | undefined | Record; + [p: string]: + | string + | number + | undefined + | Record>; 'og:image'?: string; - MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME?: Record; -} & Omit>; + MATRIX_BUNDLED_EMBEDS_ENCRYPTED_PROPERTY_NAME?: Record< + string, + string | Record + >; +} & Omit>; export enum EmbedPreviewType { TitleDescription, @@ -55,7 +62,7 @@ export type EmbedLoading = { status: EmbedStatus.Loading; progress: LoadingStatus; preview?: EmbedPreview; - promise: Promise; + promise: Promise; }; export type EmbedSuccess = { @@ -75,7 +82,7 @@ export type Embed = EmbedIdle | EmbedLoading | EmbedSuccess | EmbedError; type EmbedAtomAction = | { - promise: Promise; + promise: Promise; } | { progress: LoadingStatus; @@ -97,7 +104,7 @@ export enum EmbedErrorReason { } export type fetchEmbedOptions = { - onPromise?: (promise: Promise) => void; + onPromise?: (promise: Promise) => void; onProgress?: (preview: EmbedPreview, progress: LoadingStatus) => void; onSuccess: (embed: FixedPreviewUrlResponse, progress: EmbedPreview) => void; onError: (reason: EmbedErrorReason) => void; @@ -151,17 +158,17 @@ function fetchWrapper(url: string) { return isTauri() ? taurifetch(url) : fetch(url); } -const fetchEmbed = async ( +const FetchEmbed = async ( url: string, mx: MatrixClient, encrypted: boolean, bundleUseHomeserver: boolean, successCallback: (result: FixedPreviewUrlResponse) => void, + useAuthentication: boolean, fetchEmbedOptions: fetchEmbedOptions ) => { - const useAuthentication = useMediaAuthentication(); - if (!fetchEmbedOptions.onProgress) fetchEmbedOptions.onProgress = (_) => {}; - if (!fetchEmbedOptions.onPromise) fetchEmbedOptions.onPromise = (_) => {}; + if (!fetchEmbedOptions.onProgress) fetchEmbedOptions.onProgress = () => {}; + if (!fetchEmbedOptions.onPromise) fetchEmbedOptions.onPromise = () => {}; const pushImage = async ( imgData: Blob, preview: EmbedPreview, @@ -175,14 +182,14 @@ const fetchEmbed = async ( let encryptResult = await encryptFile(imgData); imgData = encryptResult.file; encInfo = encryptResult.encInfo; - } catch (e) { + } catch { fetchEmbedOptions.onError(EmbedErrorReason.EncryptFailed); return; } } fetchEmbedOptions.onProgress?.(preview, LoadingStatus.MediaUp); await uploadContent(mx, new File([imgData], 'mediaPreview'), { - onError: (_) => { + onError: () => { if (preview.type == EmbedPreviewType.MediaOnly) { fetchEmbedOptions.onError(EmbedErrorReason.UploadFailed); return; @@ -220,7 +227,7 @@ const fetchEmbed = async ( const promise = mx.getUrlPreview(url, Date.now()); fetchEmbedOptions.onPromise(promise); response = await promise; - } catch (e) { + } catch { fetchEmbedOptions.onError(EmbedErrorReason.RequestFailed); return; } @@ -247,7 +254,7 @@ const fetchEmbed = async ( let fetchPromise = fetch(imageUrl); fetchEmbedOptions.onProgress(preview, LoadingStatus.MediaDown); let fetchResponse = await fetchPromise; - let fetchBlob = await fetchResponse.blob().catch((_) => null); + let fetchBlob = await fetchResponse.blob().catch(() => null); if (!fetchBlob) { fetchEmbedOptions.onSuccess(response, preview); successCallback(response); @@ -286,10 +293,8 @@ const fetchEmbed = async ( return; } - let supportedMediaTags = ['og:image']; - const hasSupportedMedia = prelimEmbed - .keys() - .some((item) => supportedMediaTags.includes(item)); + let supportedMediaTags = new Set(['og:image']); + const hasSupportedMedia = prelimEmbed.keys().some((item) => supportedMediaTags.has(item)); let preview: EmbedPreview = { description: prelimEmbed.get('og:description'), @@ -307,15 +312,14 @@ const fetchEmbed = async ( fetchEmbedOptions.onSuccess(embedRecord, preview); successCallback(embedRecord); } else { - new Response(); if (embedRecord['og:image']) { - const response = await fetchWrapper(embedRecord['og:image']); - const imgData = await response.blob(); + const imgResponse = await fetchWrapper(embedRecord['og:image']); + const imgData = await imgResponse.blob(); preview.media = imgData; if (imgData.type.length > 0) { preview.mediaType = imgData.type; - } else if (response.headers.get('content-type')?.split(';')[0]) { - preview.mediaType = response.headers.get('content-type')?.split(';')[0]; + } else if (imgResponse.headers.get('content-type')?.split(';')[0]) { + preview.mediaType = imgResponse.headers.get('content-type')?.split(';')[0]; } await pushImage(imgData, preview, embedRecord); } else { @@ -391,22 +395,22 @@ export const useBindEmbedAtom = ( ) => { const [embed, setEmbed] = useAtom(embedAtom); const { url } = embed; + const useAuth = useMediaAuthentication(); const startEmbed = useCallback( (successCallback: (result: FixedPreviewUrlResponse) => void) => - fetchEmbed(url, mx, encrypt, useHomeserver, successCallback, { + FetchEmbed(url, mx, encrypt, useHomeserver, successCallback, useAuth, { onSuccess: (data, progress) => setEmbed({ data, preview: progress }), onError: (error) => setEmbed({ error }), onProgress: (preview, progress) => setEmbed({ preview, progress }), onPromise: (promise) => setEmbed({ promise }), }), - [url, mx, encrypt, useHomeserver] + [url, mx, encrypt, useHomeserver, setEmbed, useAuth] ); return { embed, startEmbed, - cancelEmbed, }; }; diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 9fb22d8e07..946e576102 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -4,7 +4,7 @@ import type { Descendant } from 'slate'; import type { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import type { IEventRelation } from '$types/matrix-sdk'; import type { TUploadContent } from '$utils/matrix'; -import { createUploadAtomFamily, } from '$state/upload'; +import { createUploadAtomFamily } from '$state/upload'; import { createListAtom } from '$state/list'; import { createEmbedAtomFamily } from '$state/bundle.ts'; From ca3f58c9012b93415f72968167a6ce9026e400ae Mon Sep 17 00:00:00 2001 From: florian Date: Mon, 10 Aug 2026 02:06:41 +0200 Subject: [PATCH 22/25] tests pass --- src/app/features/room/RoomInput.test.tsx | 2 ++ src/app/features/settings/settingsLink.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/src/app/features/room/RoomInput.test.tsx b/src/app/features/room/RoomInput.test.tsx index f61c9674c9..761668ed6b 100644 --- a/src/app/features/room/RoomInput.test.tsx +++ b/src/app/features/room/RoomInput.test.tsx @@ -88,6 +88,7 @@ vi.mock('$state/room/roomInputDrafts', async () => { const { atom } = await import('jotai'); const msgDraftAtom = atom([]); const replyDraftAtom = atom(undefined); + const embedItemsAtom = atom([]); const uploadItemsAtom = atom([], (get, set, action) => { const update = action as any; if (Array.isArray(update)) { @@ -115,6 +116,7 @@ vi.mock('$state/room/roomInputDrafts', async () => { roomIdToMsgDraftAtomFamily: () => msgDraftAtom, roomIdToReplyDraftAtomFamily: () => replyDraftAtom, roomIdToUploadItemsAtomFamily: () => uploadItemsAtom, + roomIdToEmbeddItemsAtomFamily: () => embedItemsAtom, roomUploadAtomFamily: Object.assign(() => atom(undefined), { remove: vi.fn() }), roomIdToScheduledTimeAtomFamily: () => scheduledTimeAtom, roomIdToEditingScheduledDelayIdAtomFamily: () => editingScheduledDelayIdAtom, diff --git a/src/app/features/settings/settingsLink.ts b/src/app/features/settings/settingsLink.ts index 7db60f8e2a..af1afd0b34 100644 --- a/src/app/features/settings/settingsLink.ts +++ b/src/app/features/settings/settingsLink.ts @@ -26,6 +26,9 @@ export const settingsLinkFocusIdsBySection: Record Date: Mon, 10 Aug 2026 02:17:13 +0200 Subject: [PATCH 23/25] knope --- ...dont_rely_on_your_server_supporting_server_side_embeds.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/you_can_now_chose_to_generate_bundled_link_previews_for_your_message_msc4095_they_get_downloaded_and_attached_to_your_message_and_dont_rely_on_your_server_supporting_server_side_embeds.md diff --git a/.changeset/you_can_now_chose_to_generate_bundled_link_previews_for_your_message_msc4095_they_get_downloaded_and_attached_to_your_message_and_dont_rely_on_your_server_supporting_server_side_embeds.md b/.changeset/you_can_now_chose_to_generate_bundled_link_previews_for_your_message_msc4095_they_get_downloaded_and_attached_to_your_message_and_dont_rely_on_your_server_supporting_server_side_embeds.md new file mode 100644 index 0000000000..1da6b560e8 --- /dev/null +++ b/.changeset/you_can_now_chose_to_generate_bundled_link_previews_for_your_message_msc4095_they_get_downloaded_and_attached_to_your_message_and_dont_rely_on_your_server_supporting_server_side_embeds.md @@ -0,0 +1,5 @@ +--- +default: minor +--- + +# You can now chose to generate bundled link previews for your message (MSC4095). They get downloaded and attached to your message and dont rely on your server supporting server-side embeds. From 7169bafa757b7ea68c902ea761ebafb294e3620b Mon Sep 17 00:00:00 2001 From: florian Date: Mon, 10 Aug 2026 03:16:24 +0200 Subject: [PATCH 24/25] clarify todo --- src/app/state/bundle.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index 57c03c1946..ad45b1d986 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -330,7 +330,7 @@ const FetchEmbed = async ( } } } else if (isImageMimeType(fetchResponse.headers.get('content-type')?.split(';')[0] || '')) { - //todo + //TODO: decide if generating bundled previews for images (and other media potentially) is something we even want fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); } else { fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); From ebe4f8f54751ff170da9432efe4c46b474c91a52 Mon Sep 17 00:00:00 2001 From: florian Date: Mon, 10 Aug 2026 03:21:45 +0200 Subject: [PATCH 25/25] clarify todo --- src/app/state/bundle.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/state/bundle.ts b/src/app/state/bundle.ts index ad45b1d986..2667a39e00 100644 --- a/src/app/state/bundle.ts +++ b/src/app/state/bundle.ts @@ -220,7 +220,7 @@ const FetchEmbed = async ( }, }); }; - //TODO filter local ips + //TODO filter local ips. How to even do this on web? if (bundleUseHomeserver) { var response: IPreviewUrlResponse; try { @@ -330,7 +330,7 @@ const FetchEmbed = async ( } } } else if (isImageMimeType(fetchResponse.headers.get('content-type')?.split(';')[0] || '')) { - //TODO: decide if generating bundled previews for images (and other media potentially) is something we even want + //TODO: decide if generating bundled previews for image links (and other media potentially) is something we even want fetchEmbedOptions.onError(EmbedErrorReason.NoOgData); } else { fetchEmbedOptions.onError(EmbedErrorReason.NoOgData);