This commit is contained in:
Sumner Evans 2025-04-14 01:58:03 +00:00 committed by GitHub
commit 40f7437c35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 427 additions and 107 deletions

View file

@ -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{},
}
}

View file

@ -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) {

View file

@ -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),

View file

@ -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) {
@ -293,6 +293,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 {

View file

@ -71,6 +71,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)
@ -155,6 +156,9 @@ func (h *HiClient) SendMessage(
}
}
}
if urlPreviews != nil {
content.BeeperLinkPreviews = *urlPreviews
}
if relatesTo != nil {
if relatesTo.Type == event.RelReplace {
contentCopy := content

View file

@ -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 {

View file

@ -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?",

View file

@ -85,4 +85,12 @@ div.message-composer {
}
}
}
> div.url-previews {
display: flex;
flex-direction: row;
gap: 1rem;
overflow-x: auto;
margin: 0 0.5rem;
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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"

View file

@ -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;
}
}
}
}

View 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;
}
}
}

View 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)