1
0
Fork 0
forked from Mirrors/gomuks

web/timeline: render MSC4095 URL previews

This commit implements rending of MSC4095[1] bundled URL previews and
includes a preference for disabling rendering of the previews.

[1]: https://github.com/matrix-org/matrix-spec-proposals/pull/4095

Signed-off-by: Sumner Evans <me@sumnerevans.com>
This commit is contained in:
Sumner Evans 2024-12-20 15:44:00 -07:00
parent 1ff9ba241a
commit 4c4744eba8
No known key found for this signature in database
6 changed files with 174 additions and 0 deletions

View file

@ -411,6 +411,22 @@ func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID datab
} else if content.GetInfo().ThumbnailURL != "" { } else if content.GetInfo().ThumbnailURL != "" {
h.addMediaCache(ctx, rowID, content.Info.ThumbnailURL, nil, content.Info.ThumbnailInfo, "") h.addMediaCache(ctx, rowID, content.Info.ThumbnailURL, nil, content.Info.ThumbnailInfo, "")
} }
for _, image := range content.BeeperGalleryImages {
h.cacheMedia(ctx, &event.Event{
Type: event.EventMessage,
Content: event.Content{Parsed: image},
}, rowID)
}
for _, preview := range content.BeeperLinkPreviews {
info := &event.FileInfo{MimeType: preview.ImageType}
if preview.ImageEncryption != nil {
h.addMediaCache(ctx, rowID, preview.ImageEncryption.URL, preview.ImageEncryption, info, "")
} else if preview.ImageURL != "" {
h.addMediaCache(ctx, rowID, preview.ImageURL, nil, info, "")
}
}
case event.StateRoomAvatar: case event.StateRoomAvatar:
_ = evt.Content.ParseRaw(evt.Type) _ = evt.Content.ParseRaw(evt.Type)
content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent) content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent)

View file

@ -140,6 +140,19 @@ export interface ContentWarning {
description?: string description?: string
} }
export interface URLPreview {
matched_url: string
"beeper:image:encryption"?: EncryptedFile
"matrix:image:size": number
"og:image"?: ContentURI
"og:url": string
"og:image:width"?: number
"og:image:height"?: number
"og:image:type"?: string
"og:title"?: string
"og:description"?: string
}
export interface BaseMessageEventContent { export interface BaseMessageEventContent {
msgtype: string msgtype: string
body: string body: string
@ -150,6 +163,8 @@ export interface BaseMessageEventContent {
"town.robin.msc3725.content_warning"?: ContentWarning "town.robin.msc3725.content_warning"?: ContentWarning
"page.codeberg.everypizza.msc4193.spoiler"?: boolean "page.codeberg.everypizza.msc4193.spoiler"?: boolean
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string "page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[]
} }
export interface TextMessageEventContent extends BaseMessageEventContent { export interface TextMessageEventContent extends BaseMessageEventContent {

View file

@ -102,6 +102,12 @@ export const preferences = {
allowedContexts: anyContext, allowedContexts: anyContext,
defaultValue: true, defaultValue: true,
}), }),
render_url_previews: new Preference<boolean>({
displayName: "Render URL previews",
description: "Whether to render MSC4095 URL previews in the room timeline.",
allowedContexts: anyContext,
defaultValue: true,
}),
show_date_separators: new Preference<boolean>({ show_date_separators: new Preference<boolean>({
displayName: "Show date separators", displayName: "Show date separators",
description: "Whether messages in different days should have a date separator between them in the room timeline.", description: "Whether messages in different days should have a date separator between them in the room timeline.",

View file

@ -25,6 +25,7 @@ import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
import ReadReceipts from "./ReadReceipts.tsx" import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx" import { ReplyIDBody } from "./ReplyBody.tsx"
import URLPreviews from "./URLPreviews.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content" import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu" import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "@/icons/error.svg?react" import ErrorIcon from "@/icons/error.svg?react"
@ -196,6 +197,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
/> : null} /> : null}
<ContentErrorBoundary> <ContentErrorBoundary>
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/> <BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
<URLPreviews room={roomCtx.store} event={evt}/>
</ContentErrorBoundary> </ContentErrorBoundary>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null} {evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div> </div>

View file

@ -0,0 +1,48 @@
div.url-previews {
display: flex;
flex-direction: row;
gap: 1rem;
overflow-x: scroll;
> div.url-preview {
margin: 0.5rem 0;
border-radius: 0.5rem;
background-color: var(--pill-background-color);
border: 1px solid var(--border-color);
div.title {
margin: 0.5rem 0.5rem 0 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 1;
line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
div.description {
margin: 0 0.5rem 0.5rem 0.5rem;
display: -webkit-box;
-webkit-line-clamp: 3;
line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--semisecondary-text-color);
}
> div.media-container {
border-radius: 0 0 0.5rem 0.5rem;
}
&.inline {
display: flex;
flex-direction: row;
min-width: 320px;
max-width: 320px;
> div.media-container {
border-radius: none;
margin: 0.5rem;
}
}
}
}

View file

@ -0,0 +1,87 @@
// 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 { getEncryptedMediaURL, getMediaURL } from "@/api/media"
import { RoomStateStore, usePreference } from "@/api/statestore"
import { MemDBEvent, URLPreview } from "@/api/types"
import { ImageContainerSize, calculateMediaSize } from "@/util/mediasize"
import ClientContext from "../ClientContext"
import "./URLPreviews.css"
const URLPreviews = ({ 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 URLPreview[]
if (!previews) {
return null
}
return <div className="url-previews">
{previews
.filter(p => p["og:title"] || p["og:image"] || p["beeper:image:encryption"])
.map(p => {
const mediaURL = p["beeper:image:encryption"]
? getEncryptedMediaURL(p["beeper:image:encryption"].url)
: getMediaURL(p["og:image"])
const aspectRatio = (p["og:image:width"] ?? 1) / (p["og:image:height"] ?? 1)
let containerSize: ImageContainerSize | undefined
let inline = false
if (aspectRatio < 1.2) {
containerSize = { width: 70, height: 70 }
inline = true
}
const style = calculateMediaSize(p["og:image:width"], p["og:image:height"], containerSize)
const title = p["og:title"] ?? p["og:url"] ?? p.matched_url
return <div
className={inline ? "url-preview inline" : "url-preview"}
style={inline ? {} : { width: style.container.width }}>
{mediaURL && inline && <div className="media-container" style={style.container}>
<img
loading="lazy"
style={style.media}
src={mediaURL}
alt={p["og:title"]}
title={p["og:title"]}
/>
</div>}
<div className="title-description">
<div className="title">
<a href={p.matched_url} title={title} target="_blank"><b>{title}</b></a>
</div>
<div className="description">{p["og:description"]}</div>
</div>
{mediaURL && !inline && <div className="media-container" style={style.container}>
<img
loading="lazy"
style={style.media}
src={mediaURL}
alt={p["og:title"]}
title={p["og:title"]}
/>
</div>}
</div>
})}
</div>
}
export default React.memo(URLPreviews)