From 81350c7cfb217349d0d73b6250faaf05c054fd08 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 10 Oct 2025 10:43:48 +0200 Subject: [PATCH] Add support for displaying link previews for Admin UI (#35958) --- app/helpers/statuses_helper.rb | 14 +++++ app/javascript/entrypoints/admin.tsx | 41 ++++++++++++++ app/javascript/styles/mastodon/admin.scss | 55 +++++++++++++++++++ app/models/status_edit.rb | 4 ++ .../admin/shared/_preview_card.html.haml | 30 ++++++++++ .../shared/_status_attachments.html.haml | 3 + config/locales/en.yml | 7 +++ 7 files changed, 154 insertions(+) create mode 100644 app/views/admin/shared/_preview_card.html.haml diff --git a/app/helpers/statuses_helper.rb b/app/helpers/statuses_helper.rb index 9cf64d09b..68e9b1304 100644 --- a/app/helpers/statuses_helper.rb +++ b/app/helpers/statuses_helper.rb @@ -57,6 +57,20 @@ module StatusesHelper components.compact_blank.join("\n\n") end + # This logic should be kept in sync with https://github.com/mastodon/mastodon/blob/425311e1d95c8a64ddac6c724fca247b8b893a82/app/javascript/mastodon/features/status/components/card.jsx#L160 + def preview_card_aspect_ratio_classname(preview_card) + interactive = preview_card.type == 'video' + large_image = (preview_card.image.present? && preview_card.width > preview_card.height) || interactive + + if large_image && interactive + 'status-card__image--video' + elsif large_image + 'status-card__image--large' + else + 'status-card__image--normal' + end + end + def visibility_icon(status) VISIBLITY_ICONS[status.visibility.to_sym] end diff --git a/app/javascript/entrypoints/admin.tsx b/app/javascript/entrypoints/admin.tsx index a60778f0c..af9309d34 100644 --- a/app/javascript/entrypoints/admin.tsx +++ b/app/javascript/entrypoints/admin.tsx @@ -1,6 +1,7 @@ import { createRoot } from 'react-dom/client'; import Rails from '@rails/ujs'; +import { decode, ValidationError } from 'blurhash'; import ready from '../mastodon/ready'; @@ -362,6 +363,46 @@ ready(() => { document.querySelectorAll('[data-admin-component]').forEach((element) => { void mountReactComponent(element); }); + + document + .querySelectorAll('canvas[data-blurhash]') + .forEach((canvas) => { + const blurhash = canvas.dataset.blurhash; + if (blurhash) { + try { + // decode returns a Uint8ClampedArray not Uint8ClampedArray + const pixels = decode( + blurhash, + 32, + 32, + ) as Uint8ClampedArray; + const ctx = canvas.getContext('2d'); + const imageData = new ImageData(pixels, 32, 32); + + ctx?.putImageData(imageData, 0, 0); + } catch (err) { + if (err instanceof ValidationError) { + // ignore blurhash validation errors + return; + } + + throw err; + } + } + }); + + document + .querySelectorAll('.preview-card') + .forEach((previewCard) => { + const spoilerButton = previewCard.querySelector('.spoiler-button'); + if (!spoilerButton) { + return; + } + + spoilerButton.addEventListener('click', () => { + previewCard.classList.toggle('preview-card--image-visible'); + }); + }); }).catch((reason: unknown) => { throw reason; }); diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index e2a3f0c0a..4299004df 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1969,6 +1969,61 @@ a.sparkline { display: list-item; } } + + .preview-card { + position: relative; + max-width: 566px; + + .status-card__image { + &--video { + aspect-ratio: 16 / 9; + } + + &--large { + aspect-ratio: 1.91 / 1; + } + + aspect-ratio: 1; + } + + .spoiler-button__overlay__label { + outline: 1px solid var(--media-outline-color); + } + + .hide-button { + // Toggled to appear when the preview-card is unblurred: + display: none; + position: absolute; + top: 5px; + right: 5px; + color: $white; + border: 0; + outline: 1px solid var(--media-outline-color); + background-color: color.change($black, $alpha: 0.45); + backdrop-filter: $backdrop-blur-filter; + padding: 3px 12px; + border-radius: 99px; + font-size: 14px; + font-weight: 700; + line-height: 20px; + + &:hover, + &:focus { + background-color: color.change($black, $alpha: 0.9); + } + } + + &.preview-card--image-visible { + .hide-button { + display: block; + } + + .spoiler-button__overlay, + .status-card__image-preview { + display: none; + } + } + } } .admin { diff --git a/app/models/status_edit.rb b/app/models/status_edit.rb index d99591d79..060866e50 100644 --- a/app/models/status_edit.rb +++ b/app/models/status_edit.rb @@ -52,6 +52,10 @@ class StatusEdit < ApplicationRecord underlying_quote end + def with_preview_card? + false + end + def with_media? ordered_media_attachments.any? end diff --git a/app/views/admin/shared/_preview_card.html.haml b/app/views/admin/shared/_preview_card.html.haml new file mode 100644 index 000000000..c4796dc59 --- /dev/null +++ b/app/views/admin/shared/_preview_card.html.haml @@ -0,0 +1,30 @@ +/# locals: (preview_card:) + +.preview-card + .status-card.expanded + .status-card__image{ class: preview_card_aspect_ratio_classname(preview_card) } + .spoiler-button + %button.hide-button{ type: 'button' }= t('link_preview.potentially_sensitive_content.hide_button') + %button.spoiler-button__overlay{ type: 'button' } + %span.spoiler-button__overlay__label + %span= t('link_preview.potentially_sensitive_content.label') + %span.spoiler-button__overlay__action + %span= t('link_preview.potentially_sensitive_content.action') + %canvas.status-card__image-preview{ 'data-blurhash': preview_card.blurhash, width: 32, height: 32 } + = image_tag preview_card.image.url, alt: '', class: 'status-card__image-image' + = link_to preview_card.url, target: '_blank', rel: 'noopener', data: { confirm: t('link_preview.potentially_sensitive_content.confirm_visit') } do + .status-card__content{ dir: 'auto' } + %span.status-card__host + %span{ lang: preview_card.language } + = preview_card.provider_name + - if preview_card.published_at + ยท + %time.relative-formatted{ datetime: preview_card.published_at.iso8601, title: l(preview_card.published_at) }= l(preview_card.published_at) + %strong.status-card__title{ title: preview_card.title, lang: preview_card.language } + = preview_card.title + - if preview_card.author_name.present? + %span.status-card__author + = t('link_preview.author_html', name: content_tag(:strong, preview_card.author_name)) + - else + %span.status-card__description{ lang: preview_card.language } + = preview_card.description diff --git a/app/views/admin/shared/_status_attachments.html.haml b/app/views/admin/shared/_status_attachments.html.haml index d34a4221d..8fca4add5 100644 --- a/app/views/admin/shared/_status_attachments.html.haml +++ b/app/views/admin/shared/_status_attachments.html.haml @@ -13,6 +13,9 @@ %button.button.button-secondary{ disabled: true } = t('polls.vote') +- if status.with_preview_card? + = render partial: 'admin/shared/preview_card', locals: { preview_card: status.preview_card } + - if status.with_media? - if status.ordered_media_attachments.first.video? = render_video_component(status, visible: false) diff --git a/config/locales/en.yml b/config/locales/en.yml index 91b5b23bd..025ad1ea0 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1591,6 +1591,13 @@ en: expires_at: Expires uses: Uses title: Invite people + link_preview: + author_html: By %{name} + potentially_sensitive_content: + action: Click to show + confirm_visit: Are you sure you wish to open this link? + hide_button: Hide + label: Potentially sensitive content lists: errors: limit: You have reached the maximum number of lists