mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
web/composer: send inline URL previews
Signed-off-by: Sumner Evans <me@sumnerevans.com>
This commit is contained in:
parent
769d60c459
commit
16e2b9811b
14 changed files with 427 additions and 107 deletions
|
@ -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{},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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) {
|
||||
|
@ -286,6 +286,7 @@ type sendMessageParams struct {
|
|||
Text string `json:"text"`
|
||||
RelatesTo *event.RelatesTo `json:"relates_to"`
|
||||
Mentions *event.Mentions `json:"mentions"`
|
||||
URLPreviews *[]*event.BeeperLinkPreview `json:"url_previews"`
|
||||
}
|
||||
|
||||
type sendEventParams struct {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -47,6 +47,12 @@ export const preferences = {
|
|||
allowedContexts: anyContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
send_bundled_url_previews: new Preference<boolean>({
|
||||
displayName: "Send bundled URL previews",
|
||||
description: "Should bundled URL previews be sent to other users?",
|
||||
allowedContexts: anyContext,
|
||||
defaultValue: true,
|
||||
}),
|
||||
display_read_receipts: new Preference<boolean>({
|
||||
displayName: "Display read receipts",
|
||||
description: "Should read receipts be rendered in the timeline?",
|
||||
|
|
|
@ -85,4 +85,12 @@ div.message-composer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div.url-previews {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<HTMLTextAreaElement>, 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}
|
||||
</div>
|
||||
}
|
||||
const possiblePreviewsNotLoadingOrPreviewed = state.possiblePreviews.filter(
|
||||
url => !state.loadingPreviews.includes(url) && !state.previews.some(p => p.matched_url === url))
|
||||
return <>
|
||||
{Autocompleter && autocomplete && <div className="autocompletions-wrapper"><Autocompleter
|
||||
params={autocomplete}
|
||||
|
@ -651,6 +706,25 @@ const MessageComposer = () => {
|
|||
room={room} client={client}
|
||||
location={state.location} onChange={onChangeLocation} clearLocation={clearMedia}
|
||||
/>}
|
||||
{state.previews.length || state.loadingPreviews || possiblePreviewsNotLoadingOrPreviewed
|
||||
? <div className="url-previews">
|
||||
{state.previews.map((preview, i) => <URLPreview
|
||||
key={i}
|
||||
url={preview.matched_url}
|
||||
preview={preview}
|
||||
clearPreview={() => setState(s => ({ previews: s.previews.filter((_, j) => j !== i) }))}
|
||||
/>)}
|
||||
{state.loadingPreviews.map((previewURL, i) =>
|
||||
<URLPreview key={i} url={previewURL} preview="loading"/>)}
|
||||
{possiblePreviewsNotLoadingOrPreviewed.map((url, i) =>
|
||||
<URLPreview
|
||||
key={i}
|
||||
url={url}
|
||||
preview="awaiting_user"
|
||||
startLoadingPreview={() => resolvePreview(url)}
|
||||
/>)}
|
||||
</div>
|
||||
: null}
|
||||
<div className="input-area">
|
||||
{!inlineButtons && <button className="show-more" onClick={openButtonsModal}><MoreIcon/></button>}
|
||||
<textarea
|
||||
|
@ -669,7 +743,7 @@ const MessageComposer = () => {
|
|||
{inlineButtons && makeAttachmentButtons()}
|
||||
{showSendButton && <button
|
||||
onClick={onClickSend}
|
||||
disabled={!canSend || loadingMedia}
|
||||
disabled={!canSend || loadingMedia || !!state.loadingPreviews.length}
|
||||
title="Send message"
|
||||
><SendIcon/></button>}
|
||||
<input
|
||||
|
|
|
@ -188,6 +188,13 @@ div.timeline-event {
|
|||
"avatar gap content status" auto
|
||||
/ var(--timeline-avatar-size) var(--timeline-avatar-gap) 1fr var(--timeline-status-size);
|
||||
}
|
||||
|
||||
div.url-previews {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
div.pinned-event > div.timeline-event {
|
||||
|
|
|
@ -16,8 +16,8 @@
|
|||
import React, { JSX, use, useState } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import { getAvatarThumbnailURL, getMediaURL, getUserColorIndex } from "@/api/media.ts"
|
||||
import { useRoomMember } from "@/api/statestore"
|
||||
import { MemDBEvent, UnreadType, UserProfile } from "@/api/types"
|
||||
import { RoomStateStore, usePreference, useRoomMember } from "@/api/statestore"
|
||||
import { MemDBEvent, URLPreview as URLPreviewType, UnreadType, UserProfile } from "@/api/types"
|
||||
import { isMobileDevice } from "@/util/ismobile.ts"
|
||||
import { getDisplayname, isEventID } from "@/util/validation.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
|
@ -25,10 +25,10 @@ import MainScreenContext from "../MainScreenContext.ts"
|
|||
import { EventFixedMenu, EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "../menu"
|
||||
import { ModalContext, NestableModalContext } from "../modal"
|
||||
import { useRoomContext } from "../roomview/roomcontext.ts"
|
||||
import URLPreview from "../urlpreview/URLPreview.tsx"
|
||||
import EventEditHistory from "./EventEditHistory.tsx"
|
||||
import ReadReceipts from "./ReadReceipts.tsx"
|
||||
import { ReplyIDBody } from "./ReplyBody.tsx"
|
||||
import URLPreviews from "./URLPreviews.tsx"
|
||||
import { ContentErrorBoundary, HiddenEvent, getBodyType, getPerMessageProfile, isSmallEvent } from "./content"
|
||||
import ErrorIcon from "@/icons/error.svg?react"
|
||||
import PendingIcon from "@/icons/pending.svg?react"
|
||||
|
@ -77,6 +77,25 @@ const EventSendStatus = ({ evt }: { evt: MemDBEvent }) => {
|
|||
}
|
||||
}
|
||||
|
||||
const EventURLPreviews = ({ event, room }: {
|
||||
room: RoomStateStore
|
||||
event: MemDBEvent
|
||||
}) => {
|
||||
const client = use(ClientContext)!
|
||||
const renderPreviews = usePreference(client.store, room, "render_url_previews")
|
||||
if (event.redacted_by || !renderPreviews) {
|
||||
return null
|
||||
}
|
||||
|
||||
const previews = (event.content["com.beeper.linkpreviews"] ?? event.content["m.url_previews"]) as URLPreviewType[]
|
||||
if (!previews) {
|
||||
return null
|
||||
}
|
||||
return <div className="url-previews">
|
||||
{previews.map((p, i) => <URLPreview key={i} url={p.matched_url} preview={p}/>)}
|
||||
</div>
|
||||
}
|
||||
|
||||
const TimelineEvent = ({
|
||||
evt, prevEvt, disableMenu, smallReplies, isFocused, editHistoryView,
|
||||
}: TimelineEventProps) => {
|
||||
|
@ -291,7 +310,7 @@ const TimelineEvent = ({
|
|||
{replyInMessage}
|
||||
<ContentErrorBoundary>
|
||||
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
|
||||
{!isSmallBodyType && <URLPreviews room={roomCtx.store} event={evt}/>}
|
||||
{!isSmallBodyType && <EventURLPreviews room={roomCtx.store} event={evt}/>}
|
||||
</ContentErrorBoundary>
|
||||
{(!editHistoryView && editEventTS) ? <div
|
||||
className="event-edited"
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
div.url-previews {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
overflow-x: auto;
|
||||
|
||||
> div.url-preview {
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--url-preview-background-color);
|
||||
border: 1px solid var(--url-preview-background-color);
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
|
||||
grid-template:
|
||||
"title" auto
|
||||
"description" auto
|
||||
"media" auto
|
||||
/ 1fr;
|
||||
|
||||
div.title {
|
||||
grid-area: title;
|
||||
margin: 0.5rem 0.5rem 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
div.description {
|
||||
grid-area: description;
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--semisecondary-text-color);
|
||||
}
|
||||
|
||||
> div.media-container {
|
||||
grid-area: media;
|
||||
border-radius: 0 0 .5rem .5rem;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
&.inline {
|
||||
grid-template:
|
||||
"media title" auto
|
||||
"media description" auto
|
||||
/ auto auto;
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
|
||||
> div.inline-media-wrapper {
|
||||
grid-area: media;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-color);
|
||||
border-radius: .5rem 0 0 .5rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
97
web/src/ui/urlpreview/URLPreview.css
Normal file
97
web/src/ui/urlpreview/URLPreview.css
Normal file
|
@ -0,0 +1,97 @@
|
|||
div.url-preview {
|
||||
margin: 0.5rem 0;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--url-preview-background-color);
|
||||
border: 1px solid var(--url-preview-background-color);
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.loading {
|
||||
padding: 1.5rem;
|
||||
height: fit-content;
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
grid-template:
|
||||
"title actions" auto
|
||||
"description description" auto
|
||||
"media media" auto
|
||||
/ 1fr;
|
||||
|
||||
div.title {
|
||||
grid-area: title;
|
||||
margin: 0.5rem;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font-weight: bold;
|
||||
|
||||
&.with-description {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
div.actions {
|
||||
grid-area: actions;
|
||||
margin: 0.5rem 0.5rem 0 0;
|
||||
}
|
||||
|
||||
div.load-preview-button {
|
||||
grid-area: description;
|
||||
margin: 0.5rem;
|
||||
|
||||
> button {
|
||||
svg {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
div.loading-preview-indicator {
|
||||
grid-area: description;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
|
||||
div.description {
|
||||
grid-area: description;
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
color: var(--semisecondary-text-color);
|
||||
}
|
||||
|
||||
> div.media-container {
|
||||
grid-area: media;
|
||||
border-radius: 0 0 .5rem .5rem;
|
||||
background-color: var(--background-color);
|
||||
}
|
||||
|
||||
&.inline {
|
||||
grid-template:
|
||||
"media title actions" auto
|
||||
"media description description" auto
|
||||
/ min-content auto 2rem;
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
max-height: 6rem;
|
||||
|
||||
> div.inline-media-wrapper {
|
||||
grid-area: media;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: var(--background-color);
|
||||
border-radius: .5rem 0 0 .5rem;
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
> div.description {
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
}
|
||||
}
|
96
web/src/ui/urlpreview/URLPreview.tsx
Normal file
96
web/src/ui/urlpreview/URLPreview.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
// gomuks - A Matrix client written in Go.
|
||||
// Copyright (C) 2024 Sumner Evans
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
import React, { use } from "react"
|
||||
import { ScaleLoader } from "react-spinners"
|
||||
import { getEncryptedMediaURL, getMediaURL } from "@/api/media"
|
||||
import { URLPreview as URLPreviewType } from "@/api/types"
|
||||
import { ImageContainerSize, calculateMediaSize } from "@/util/mediasize"
|
||||
import { LightboxContext } from "../modal"
|
||||
import DeleteIcon from "@/icons/delete.svg?react"
|
||||
import RefreshIcon from "@/icons/refresh.svg?react"
|
||||
import "./URLPreview.css"
|
||||
|
||||
const URLPreview = ({ url, preview, startLoadingPreview, clearPreview }: {
|
||||
url: string,
|
||||
preview: URLPreviewType | "awaiting_user" | "loading",
|
||||
startLoadingPreview?: () => void,
|
||||
clearPreview?: () => void,
|
||||
}) => {
|
||||
if (preview === "awaiting_user" || preview === "loading") {
|
||||
return <div key={url} className="url-preview inline"
|
||||
title={preview ==="awaiting_user"
|
||||
? `Load preview for ${url}?`
|
||||
: `Loading preview for ${url}`}
|
||||
>
|
||||
<div className="title">
|
||||
<a href={url} target="_blank" rel="noreferrer noopener">{url}</a>
|
||||
</div>
|
||||
{preview === "awaiting_user"
|
||||
? <div className="load-preview-button">
|
||||
<button onClick={startLoadingPreview}><RefreshIcon/> Load Preview</button>
|
||||
</div>
|
||||
: <div className="loading-preview-indicator">
|
||||
<ScaleLoader color="var(--primary-color)"/>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (!preview["og:title"] && !preview["og:image"] && !preview["beeper:image:encryption"]) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mediaURL = preview["beeper:image:encryption"]
|
||||
? getEncryptedMediaURL(preview["beeper:image:encryption"].url)
|
||||
: getMediaURL(preview["og:image"])
|
||||
const aspectRatio = (preview["og:image:width"] ?? 1) / (preview["og:image:height"] ?? 1)
|
||||
let containerSize: ImageContainerSize | undefined
|
||||
let inline = false
|
||||
if (aspectRatio < 1.2) {
|
||||
containerSize = { width: 80, height: 80 }
|
||||
inline = true
|
||||
}
|
||||
const style = calculateMediaSize(preview["og:image:width"], preview["og:image:height"], containerSize)
|
||||
|
||||
const previewingUrl = preview["og:url"] ?? preview.matched_url ?? url
|
||||
const title = preview["og:title"] ?? preview["og:url"] ?? previewingUrl
|
||||
const mediaContainer = <div className="media-container" style={style.container}>
|
||||
<img
|
||||
loading="lazy"
|
||||
style={style.media}
|
||||
src={mediaURL}
|
||||
onClick={use(LightboxContext)!}
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
return <div
|
||||
key={url}
|
||||
className={inline ? "url-preview inline" : "url-preview"}
|
||||
style={inline ? {} : { width: style.container.width }}
|
||||
>
|
||||
<div className="title">
|
||||
<a href={previewingUrl} title={title} target="_blank" rel="noreferrer noopener">{title}</a>
|
||||
</div>
|
||||
{clearPreview && <div className="actions">
|
||||
<button onClick={clearPreview}><DeleteIcon/></button>
|
||||
</div>}
|
||||
<div className="description">{preview["og:description"]}</div>
|
||||
{mediaURL && (inline
|
||||
? <div className="inline-media-wrapper">{mediaContainer}</div>
|
||||
: mediaContainer)}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default React.memo(URLPreview)
|
Loading…
Add table
Reference in a new issue