From 4c4744eba87a6c467928d7f59284cf8f911bb5cc Mon Sep 17 00:00:00 2001 From: Sumner Evans Date: Fri, 20 Dec 2024 15:44:00 -0700 Subject: [PATCH] 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 --- pkg/hicli/sync.go | 16 ++++ web/src/api/types/mxtypes.ts | 15 ++++ web/src/api/types/preferences/preferences.ts | 6 ++ web/src/ui/timeline/TimelineEvent.tsx | 2 + web/src/ui/timeline/URLPreviews.css | 48 +++++++++++ web/src/ui/timeline/URLPreviews.tsx | 87 ++++++++++++++++++++ 6 files changed, 174 insertions(+) create mode 100644 web/src/ui/timeline/URLPreviews.css create mode 100644 web/src/ui/timeline/URLPreviews.tsx diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index fd6aea1f..4c8a6b52 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -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) diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 78e6146c..45fe52ca 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -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 @@ -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 { diff --git a/web/src/api/types/preferences/preferences.ts b/web/src/api/types/preferences/preferences.ts index 6952424c..4d55ac3a 100644 --- a/web/src/api/types/preferences/preferences.ts +++ b/web/src/api/types/preferences/preferences.ts @@ -102,6 +102,12 @@ export const preferences = { allowedContexts: anyContext, defaultValue: true, }), + render_url_previews: new Preference({ + displayName: "Render URL previews", + description: "Whether to render MSC4095 URL previews in the room timeline.", + allowedContexts: anyContext, + defaultValue: true, + }), show_date_separators: new Preference({ displayName: "Show date separators", description: "Whether messages in different days should have a date separator between them in the room timeline.", diff --git a/web/src/ui/timeline/TimelineEvent.tsx b/web/src/ui/timeline/TimelineEvent.tsx index c79b63c9..18a63e39 100644 --- a/web/src/ui/timeline/TimelineEvent.tsx +++ b/web/src/ui/timeline/TimelineEvent.tsx @@ -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" @@ -196,6 +197,7 @@ const TimelineEvent = ({ evt, prevEvt, disableMenu }: TimelineEventProps) => { /> : null} + {evt.reactions ? : null} diff --git a/web/src/ui/timeline/URLPreviews.css b/web/src/ui/timeline/URLPreviews.css new file mode 100644 index 00000000..aee97098 --- /dev/null +++ b/web/src/ui/timeline/URLPreviews.css @@ -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; + } + } + } +} diff --git a/web/src/ui/timeline/URLPreviews.tsx b/web/src/ui/timeline/URLPreviews.tsx new file mode 100644 index 00000000..32b0cb25 --- /dev/null +++ b/web/src/ui/timeline/URLPreviews.tsx @@ -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 . +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
+ {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
+ {mediaURL && inline &&
+ {p["og:title"]} +
} +
+
+ {title} +
+
{p["og:description"]}
+
+ {mediaURL && !inline &&
+ {p["og:title"]} +
} +
+ })} +
+} + +export default React.memo(URLPreviews)