diff --git a/pkg/gomuks/gomuks.go b/pkg/gomuks/gomuks.go index 0b382d3..d3907a2 100644 --- a/pkg/gomuks/gomuks.go +++ b/pkg/gomuks/gomuks.go @@ -36,6 +36,8 @@ import ( "go.mau.fi/util/exzerolog" "go.mau.fi/util/ptr" "golang.org/x/net/http2" + "maunium.net/go/mautrix/event" + "maunium.net/go/mautrix/id" "go.mau.fi/gomuks/pkg/hicli" ) @@ -67,11 +69,19 @@ type Gomuks struct { stopChan chan struct{} EventBuffer *EventBuffer + + // Maps from temporary MXC URIs from by the media repository for URL + // previews to permanent MXC URIs suitable for sending in an inline preview + temporaryMXCToPermanent map[id.ContentURIString]id.ContentURIString + temporaryMXCToEncryptedFileInfo map[id.ContentURIString]*event.EncryptedFileInfo } func NewGomuks() *Gomuks { return &Gomuks{ stopChan: make(chan struct{}), + + temporaryMXCToPermanent: map[id.ContentURIString]id.ContentURIString{}, + temporaryMXCToEncryptedFileInfo: map[id.ContentURIString]*event.EncryptedFileInfo{}, } } diff --git a/pkg/gomuks/media.go b/pkg/gomuks/media.go index 1cd9d24..6d86a4b 100644 --- a/pkg/gomuks/media.go +++ b/pkg/gomuks/media.go @@ -450,22 +450,94 @@ func (gmx *Gomuks) DownloadMedia(w http.ResponseWriter, r *http.Request) { func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) { log := hlog.FromRequest(r) + encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt")) + content, err := gmx.cacheAndUploadMedia(r.Context(), r.Body, encrypt, r.URL.Query().Get("filename")) + if err != nil { + log.Err(err).Msg("Failed to upload media") + writeMaybeRespError(err, w) + return + } + exhttp.WriteJSONResponse(w, http.StatusOK, content) +} + +func (gmx *Gomuks) GetURLPreview(w http.ResponseWriter, r *http.Request) { + log := hlog.FromRequest(r) + url := r.URL.Query().Get("url") + if url == "" { + mautrix.MInvalidParam.WithMessage("URL must be provided to preview").Write(w) + return + } + linkPreview, err := gmx.Client.Client.GetURLPreview(r.Context(), url) + if err != nil { + log.Err(err).Msg("Failed to get URL preview") + writeMaybeRespError(err, w) + return + } + + preview := event.BeeperLinkPreview{ + LinkPreview: *linkPreview, + MatchedURL: url, + } + + if preview.ImageURL != "" { + encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt")) + + var content *event.MessageEventContent + + if encrypt { + if fileInfo, ok := gmx.temporaryMXCToEncryptedFileInfo[preview.ImageURL]; ok { + content = &event.MessageEventContent{File: fileInfo} + } + } else { + if mxc, ok := gmx.temporaryMXCToPermanent[preview.ImageURL]; ok { + content = &event.MessageEventContent{URL: mxc} + } + } + + if content == nil { + resp, err := gmx.Client.Client.Download(r.Context(), preview.ImageURL.ParseOrIgnore()) + if err != nil { + log.Err(err).Msg("Failed to download URL preview image") + writeMaybeRespError(err, w) + return + } + defer resp.Body.Close() + + content, err = gmx.cacheAndUploadMedia(r.Context(), resp.Body, encrypt, "") + if err != nil { + log.Err(err).Msg("Failed to upload URL preview image") + writeMaybeRespError(err, w) + return + } + + if encrypt { + gmx.temporaryMXCToEncryptedFileInfo[preview.ImageURL] = content.File + } else { + gmx.temporaryMXCToPermanent[preview.ImageURL] = content.URL + } + } + + preview.ImageURL = content.URL + preview.ImageEncryption = content.File + } + + exhttp.WriteJSONResponse(w, http.StatusOK, preview) +} + +func (gmx *Gomuks) cacheAndUploadMedia(ctx context.Context, reader io.Reader, encrypt bool, fileName string) (*event.MessageEventContent, error) { + log := zerolog.Ctx(ctx) tempFile, err := os.CreateTemp(gmx.TempDir, "upload-*") if err != nil { - log.Err(err).Msg("Failed to create temporary file") - mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create temp file: %v", err)).Write(w) - return + return nil, fmt.Errorf("failed to create temp file %w", err) } defer func() { _ = tempFile.Close() _ = os.Remove(tempFile.Name()) }() hasher := sha256.New() - _, err = io.Copy(tempFile, io.TeeReader(r.Body, hasher)) + _, err = io.Copy(tempFile, io.TeeReader(reader, hasher)) if err != nil { - log.Err(err).Msg("Failed to copy upload media to temporary file") - mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to copy media to temp file: %v", err)).Write(w) - return + return nil, fmt.Errorf("failed to copy upload media to temp file: %w", err) } _ = tempFile.Close() @@ -476,39 +548,29 @@ func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) { } else { err = os.MkdirAll(filepath.Dir(cachePath), 0700) if err != nil { - log.Err(err).Msg("Failed to create cache directory") - mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to create cache directory: %v", err)).Write(w) - return + return nil, fmt.Errorf("failed to create cache directory: %w", err) } err = os.Rename(tempFile.Name(), cachePath) if err != nil { - log.Err(err).Msg("Failed to rename temporary file") - mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to rename temp file: %v", err)).Write(w) - return + return nil, fmt.Errorf("failed to rename temp file: %w", err) } } cacheFile, err := os.Open(cachePath) if err != nil { - log.Err(err).Msg("Failed to open cache file") - mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to open cache file: %v", err)).Write(w) - return + return nil, fmt.Errorf("failed to open cache file: %w", err) } - msgType, info, defaultFileName, err := gmx.generateFileInfo(r.Context(), cacheFile) + msgType, info, defaultFileName, err := gmx.generateFileInfo(ctx, cacheFile) if err != nil { - log.Err(err).Msg("Failed to generate file info") - mautrix.MUnknown.WithMessage(fmt.Sprintf("Failed to generate file info: %v", err)).Write(w) - return + return nil, fmt.Errorf("failed to generate file info: %w", err) } - encrypt, _ := strconv.ParseBool(r.URL.Query().Get("encrypt")) if msgType == event.MsgVideo { - err = gmx.generateVideoThumbnail(r.Context(), cacheFile.Name(), encrypt, info) + err = gmx.generateVideoThumbnail(ctx, cacheFile.Name(), encrypt, info) if err != nil { log.Warn().Err(err).Msg("Failed to generate video thumbnail") } } - fileName := r.URL.Query().Get("filename") if fileName == "" { fileName = defaultFileName } @@ -518,13 +580,11 @@ func (gmx *Gomuks) UploadMedia(w http.ResponseWriter, r *http.Request) { Info: info, FileName: fileName, } - content.File, content.URL, err = gmx.uploadFile(r.Context(), checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName) + content.File, content.URL, err = gmx.uploadFile(ctx, checksum, cacheFile, encrypt, int64(info.Size), info.MimeType, fileName) if err != nil { - log.Err(err).Msg("Failed to upload media") - writeMaybeRespError(err, w) - return + return nil, fmt.Errorf("failed to upload media: %w", err) } - exhttp.WriteJSONResponse(w, http.StatusOK, content) + return content, nil } func (gmx *Gomuks) uploadFile(ctx context.Context, checksum []byte, cacheFile *os.File, encrypt bool, fileSize int64, mimeType, fileName string) (*event.EncryptedFileInfo, id.ContentURIString, error) { diff --git a/pkg/gomuks/server.go b/pkg/gomuks/server.go index 714b191..ebb65ce 100644 --- a/pkg/gomuks/server.go +++ b/pkg/gomuks/server.go @@ -57,6 +57,7 @@ func (gmx *Gomuks) CreateAPIRouter() http.Handler { api.HandleFunc("POST /keys/export/{room_id}", gmx.ExportKeys) api.HandleFunc("POST /keys/import", gmx.ImportKeys) api.HandleFunc("GET /codeblock/{style}", gmx.GetCodeblockCSS) + api.HandleFunc("GET /url_preview", gmx.GetURLPreview) return exhttp.ApplyMiddleware( api, hlog.NewHandler(*gmx.Log), diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 49bf4e1..9ae9c50 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -43,7 +43,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any }) case "send_message": return unmarshalAndCall(req.Data, func(params *sendMessageParams) (*database.Event, error) { - return h.SendMessage(ctx, params.RoomID, params.BaseContent, params.Extra, params.Text, params.RelatesTo, params.Mentions) + return h.SendMessage(ctx, params.RoomID, params.BaseContent, params.Extra, params.Text, params.RelatesTo, params.Mentions, params.URLPreviews) }) case "send_event": return unmarshalAndCall(req.Data, func(params *sendEventParams) (*database.Event, error) { @@ -280,12 +280,13 @@ type cancelRequestParams struct { } type sendMessageParams struct { - RoomID id.RoomID `json:"room_id"` - BaseContent *event.MessageEventContent `json:"base_content"` - Extra map[string]any `json:"extra"` - Text string `json:"text"` - RelatesTo *event.RelatesTo `json:"relates_to"` - Mentions *event.Mentions `json:"mentions"` + RoomID id.RoomID `json:"room_id"` + BaseContent *event.MessageEventContent `json:"base_content"` + Extra map[string]any `json:"extra"` + Text string `json:"text"` + RelatesTo *event.RelatesTo `json:"relates_to"` + Mentions *event.Mentions `json:"mentions"` + URLPreviews *[]*event.BeeperLinkPreview `json:"url_previews"` } type sendEventParams struct { diff --git a/pkg/hicli/send.go b/pkg/hicli/send.go index e10adbe..409615a 100644 --- a/pkg/hicli/send.go +++ b/pkg/hicli/send.go @@ -70,6 +70,7 @@ func (h *HiClient) SendMessage( text string, relatesTo *event.RelatesTo, mentions *event.Mentions, + urlPreviews *[]*event.BeeperLinkPreview, ) (*database.Event, error) { if text == "/discardsession" { err := h.CryptoStore.RemoveOutboundGroupSession(ctx, roomID) @@ -154,6 +155,9 @@ func (h *HiClient) SendMessage( } } } + if urlPreviews != nil { + content.BeeperLinkPreviews = *urlPreviews + } if relatesTo != nil { if relatesTo.Type == event.RelReplace { contentCopy := content diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 737b8de..631ec75 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -46,6 +46,7 @@ import type { RoomStateGUID, RoomSummary, TimelineRowID, + URLPreview, UserID, UserProfile, } from "./types" @@ -71,6 +72,7 @@ export interface SendMessageParams { media_path?: string relates_to?: RelatesTo mentions?: Mentions + url_previews?: URLPreview[] } export default abstract class RPCClient { diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 1bebaa4..d3ba258 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -47,6 +47,12 @@ export const preferences = { allowedContexts: anyContext, defaultValue: true, }), + send_bundled_url_previews: new Preference({ + displayName: "Send bundled URL previews", + description: "Should bundled URL previews be sent to other users?", + allowedContexts: anyContext, + defaultValue: true, + }), display_read_receipts: new Preference({ displayName: "Display read receipts", description: "Should read receipts be rendered in the timeline?", diff --git a/web/src/ui/composer/MessageComposer.css b/web/src/ui/composer/MessageComposer.css index 2032301..52723df 100644 --- a/web/src/ui/composer/MessageComposer.css +++ b/web/src/ui/composer/MessageComposer.css @@ -85,4 +85,12 @@ div.message-composer { } } } + + > div.url-previews { + display: flex; + flex-direction: row; + gap: 1rem; + overflow-x: auto; + margin: 0 0.5rem; + } } diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index e1c0039..e058d55 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -34,6 +34,7 @@ import type { MessageEventContent, RelatesTo, RoomID, + URLPreview as URLPreviewType, } from "@/api/types" import { PartialEmoji, emojiToMarkdown } from "@/util/emoji" import { isMobileDevice } from "@/util/ismobile.ts" @@ -47,6 +48,7 @@ import { keyToString } from "../keybindings.ts" import { ModalContext } from "../modal" import { useRoomContext } from "../roomview/roomcontext.ts" import { ReplyBody } from "../timeline/ReplyBody.tsx" +import URLPreview from "../urlpreview/URLPreview.tsx" import type { AutocompleteQuery } from "./Autocompleter.tsx" import { ComposerLocation, ComposerLocationValue, ComposerMedia } from "./ComposerMedia.tsx" import { charToAutocompleteType, emojiQueryRegex, getAutocompleter } from "./getAutocompleter.ts" @@ -63,6 +65,9 @@ export interface ComposerState { text: string media: MediaMessageEventContent | null location: ComposerLocationValue | null + previews: URLPreviewType[] + loadingPreviews: string[] + possiblePreviews: string[] replyTo: EventID | null silentReply: boolean explicitReplyInThread: boolean @@ -75,8 +80,11 @@ const MAX_TEXTAREA_ROWS = 10 const emptyComposer: ComposerState = { text: "", media: null, - replyTo: null, location: null, + previews: [], + loadingPreviews: [], + possiblePreviews: [], + replyTo: null, silentReply: false, explicitReplyInThread: false, startNewThread: false, @@ -182,13 +190,17 @@ const MessageComposer = () => { silentReply: false, explicitReplyInThread: false, startNewThread: false, + previews: + evt.content["m.url_previews"] ?? + evt.content["com.beeper.linkpreviews"] ?? + [], }) textInput.current?.focus() }, [room.roomID]) const canSend = Boolean(state.text || state.media || state.location) const onClickSend = (evt: React.FormEvent) => { evt.preventDefault() - if (!canSend || loadingMedia) { + if (!canSend || loadingMedia || state.loadingPreviews.length) { return } doSendMessage(state) @@ -259,6 +271,7 @@ const MessageComposer = () => { text: state.text, relates_to, mentions, + url_previews: state.previews, }).catch(err => window.alert("Failed to send message: " + err)) } const onComposerCaretChange = (evt: CaretEvent, newText?: string) => { @@ -414,6 +427,31 @@ const MessageComposer = () => { } evt.preventDefault() } + const resolvePreview = useCallback((url: string) => { + console.log("RESOLVE PREVIEW", url) + const encrypt = !!room.meta.current.encryption_event + setState(s => ({ loadingPreviews: [...s.loadingPreviews, url]})) + fetch(`_gomuks/url_preview?encrypt=${encrypt}&url=${encodeURIComponent(url)}`, { + method: "GET", + }) + .then(async res => { + const json = await res.json() + if (!res.ok) { + throw new Error(json.error) + } else { + setState(s => ({ + previews: [...s.previews, json], + loadingPreviews: s.loadingPreviews.filter(u => u !== url), + })) + } + }) + .catch(err => { + console.error("Error fetching preview for URL", url, err) + setState(s => ({ + loadingPreviews: s.loadingPreviews.filter(u => u !== url), + })) + }) + }, [room.meta]) // To ensure the cursor jumps to the end, do this in an effect rather than as the initial value of useState // To try to avoid the input bar flashing, use useLayoutEffect instead of useEffect useLayoutEffect(() => { @@ -463,6 +501,21 @@ const MessageComposer = () => { draftStore.set(room.roomID, state) } }, [roomCtx, room, state, editing]) + useEffect(() => { + if (!room.preferences.send_bundled_url_previews) { + setState({ previews: [], loadingPreviews: [], possiblePreviews: []}) + return + } + const urls = state.text.matchAll(/\bhttps?:\/\/[^\s/_*]+(?:\/\S*)?\b/gi) + .map(m => m[0]) + .filter(u => !u.startsWith("https://matrix.to")) + .toArray() + setState(s => ({ + previews: s.previews.filter(p => urls.includes(p.matched_url)), + loadingPreviews: s.loadingPreviews.filter(u => urls.includes(u)), + possiblePreviews: urls, + })) + }, [room.preferences, state.text]) const clearMedia = useCallback(() => setState({ media: null, location: null }), []) const onChangeLocation = useCallback((location: ComposerLocationValue) => setState({ location }), []) const closeReply = useCallback((evt: React.MouseEvent) => { @@ -616,6 +669,8 @@ const MessageComposer = () => { {body} {link} } + const possiblePreviewsNotLoadingOrPreviewed = state.possiblePreviews.filter( + url => !state.loadingPreviews.includes(url) && !state.previews.some(p => p.matched_url === url)) return <> {Autocompleter && autocomplete &&
{ room={room} client={client} location={state.location} onChange={onChangeLocation} clearLocation={clearMedia} />} + {state.previews.length || state.loadingPreviews || possiblePreviewsNotLoadingOrPreviewed + ?
+ {state.previews.map((preview, i) => setState(s => ({ previews: s.previews.filter((_, j) => j !== i) }))} + />)} + {state.loadingPreviews.map((previewURL, i) => + )} + {possiblePreviewsNotLoadingOrPreviewed.map((url, i) => + resolvePreview(url)} + />)} +
+ : null}
{!inlineButtons && }