Skip to content

Commit

Permalink
web/timeline: render MSC4095 URL previews
Browse files Browse the repository at this point in the history
This commit implements rending of MSC4095[1] bundled URL previews and
includes a preference for disabling rendering of the previews.

[1]: matrix-org/matrix-spec-proposals#4095

Signed-off-by: Sumner Evans <[email protected]>
  • Loading branch information
sumnerevans committed Dec 21, 2024
1 parent 1ff9ba2 commit 4c4744e
Show file tree
Hide file tree
Showing 6 changed files with 174 additions and 0 deletions.
16 changes: 16 additions & 0 deletions pkg/hicli/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -411,6 +411,22 @@ func (h *HiClient) cacheMedia(ctx context.Context, evt *event.Event, rowID datab
} else if content.GetInfo().ThumbnailURL != "" {
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:
_ = evt.Content.ParseRaw(evt.Type)
content, ok := evt.Content.Parsed.(*event.RoomAvatarEventContent)
Expand Down
15 changes: 15 additions & 0 deletions web/src/api/types/mxtypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@ export interface ContentWarning {
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 {
msgtype: string
body: string
Expand All @@ -150,6 +163,8 @@ export interface BaseMessageEventContent {
"town.robin.msc3725.content_warning"?: ContentWarning
"page.codeberg.everypizza.msc4193.spoiler"?: boolean
"page.codeberg.everypizza.msc4193.spoiler.reason"?: string
"m.url_previews"?: URLPreview[]
"com.beeper.linkpreviews"?: URLPreview[]
}

export interface TextMessageEventContent extends BaseMessageEventContent {
Expand Down
6 changes: 6 additions & 0 deletions web/src/api/types/preferences/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ export const preferences = {
allowedContexts: anyContext,
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>({
displayName: "Show date separators",
description: "Whether messages in different days should have a date separator between them in the room timeline.",
Expand Down
2 changes: 2 additions & 0 deletions web/src/ui/timeline/TimelineEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts"
import ReadReceipts from "./ReadReceipts.tsx"
import { ReplyIDBody } from "./ReplyBody.tsx"
import URLPreviews from "./URLPreviews.tsx"
import { ContentErrorBoundary, HiddenEvent, getBodyType, isSmallEvent } from "./content"
import { EventFullMenu, EventHoverMenu, getModalStyleFromMouse } from "./menu"
import ErrorIcon from "@/icons/error.svg?react"
Expand Down Expand Up @@ -196,6 +197,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => {
/> : null}
<ContentErrorBoundary>
<BodyType room={roomCtx.store} sender={memberEvt} event={evt}/>
<URLPreviews room={roomCtx.store} event={evt}/>
</ContentErrorBoundary>
{evt.reactions ? <EventReactions reactions={evt.reactions}/> : null}
</div>
Expand Down
48 changes: 48 additions & 0 deletions web/src/ui/timeline/URLPreviews.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
}
87 changes: 87 additions & 0 deletions web/src/ui/timeline/URLPreviews.tsx
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 4c4744e

Please sign in to comment.