From 81350c7cfb217349d0d73b6250faaf05c054fd08 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 10 Oct 2025 10:43:48 +0200 Subject: [PATCH 1/6] 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 9cf64d09b4..68e9b13047 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 a60778f0c0..af9309d342 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 e2a3f0c0af..4299004df7 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 d99591d799..060866e50c 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 0000000000..c4796dc59c --- /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 d34a4221db..8fca4add52 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 91b5b23bd0..025ad1ea08 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 From 8898f120dc1b5ad0d543360541fe99b5c10fa77b Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 10 Oct 2025 10:55:41 +0200 Subject: [PATCH 2/6] Improve display of content warnings in Admin UI (#35935) --- app/javascript/styles/mastodon/admin.scss | 47 +++++++++++++++++-- .../admin/shared/_status_content.html.haml | 10 +++- config/locales/en.yml | 3 ++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/app/javascript/styles/mastodon/admin.scss b/app/javascript/styles/mastodon/admin.scss index 4299004df7..2e05284e28 100644 --- a/app/javascript/styles/mastodon/admin.scss +++ b/app/javascript/styles/mastodon/admin.scss @@ -1946,7 +1946,6 @@ a.sparkline { .status__card { padding: 15px; border-radius: 4px; - background: $ui-base-color; font-size: 15px; line-height: 20px; word-wrap: break-word; @@ -1965,8 +1964,50 @@ a.sparkline { .status__content { padding-top: 0; - summary { - display: list-item; + > details { + summary { + display: block; + box-sizing: border-box; + background: var(--nested-card-background); + color: var(--nested-card-text); + border: var(--nested-card-border); + border-radius: 8px; + padding: 8px 13px; + position: relative; + font-size: 15px; + line-height: 22px; + cursor: pointer; + + &::after { + content: attr(data-show, 'Show more'); + margin-top: 8px; + display: block; + font-size: 15px; + line-height: 20px; + color: $highlight-text-color; + cursor: pointer; + border: 0; + background: transparent; + padding: 0; + text-decoration: none; + font-weight: 500; + } + + &:hover, + &:focus-visible { + &::after { + text-decoration: underline !important; + } + } + } + + &[open] summary { + margin-bottom: 16px; + + &::after { + content: attr(data-hide, 'Hide post'); + } + } } } diff --git a/app/views/admin/shared/_status_content.html.haml b/app/views/admin/shared/_status_content.html.haml index aedd84bdd6..465696fe5e 100644 --- a/app/views/admin/shared/_status_content.html.haml +++ b/app/views/admin/shared/_status_content.html.haml @@ -1,8 +1,14 @@ .status__content>< - if status.spoiler_text.present? %details< - %summary>< - %strong> Content warning: #{prerender_custom_emojis(h(status.spoiler_text), status.emojis)} + %summary{ + data: { + show: t('statuses.content_warnings.show'), + hide: t('statuses.content_warnings.hide'), + } + }>< + %strong> + = prerender_custom_emojis(h(status.spoiler_text), status.emojis) = prerender_custom_emojis(status_content_format(status), status.emojis) = render partial: 'admin/shared/status_attachments', locals: { status: status.proper } - else diff --git a/config/locales/en.yml b/config/locales/en.yml index 025ad1ea08..e6604e9284 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1909,6 +1909,9 @@ en: other: "%{count} videos" boosted_from_html: Boosted from %{acct_link} content_warning: 'Content warning: %{warning}' + content_warnings: + hide: Hide post + show: Show more default_language: Same as interface language disallowed_hashtags: one: 'contained a disallowed hashtag: %{tags}' From 3f2ee09827503a47b5db765ad06bf4c8cf23088f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 10 Oct 2025 11:06:57 +0200 Subject: [PATCH 3/6] New Crowdin Translations (automated) (#36420) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/br.json | 9 ++++++++- config/locales/br.yml | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/app/javascript/mastodon/locales/br.json b/app/javascript/mastodon/locales/br.json index 79eb948ba8..3d87c0fd93 100644 --- a/app/javascript/mastodon/locales/br.json +++ b/app/javascript/mastodon/locales/br.json @@ -40,6 +40,9 @@ "account.featured_tags.last_status_never": "Embann ebet", "account.follow": "Heuliañ", "account.follow_back": "Heuliañ d'ho tro", + "account.follow_back_short": "Heuliañ d'ho tro", + "account.follow_request": "Reked d'ho heuliañ", + "account.follow_request_cancel": "Nullañ ar reked", "account.follow_request_cancel_short": "Nullañ", "account.followers": "Tud koumanantet", "account.followers.empty": "Den na heul an implijer·ez-mañ c'hoazh.", @@ -212,6 +215,7 @@ "confirmations.missing_alt_text.secondary": "Embann memes tra", "confirmations.missing_alt_text.title": "Ouzhpennañ an eiltestenn?", "confirmations.mute.confirm": "Kuzhat", + "confirmations.quiet_post_quote_info.got_it": "Mat eo", "confirmations.redraft.confirm": "Diverkañ ha skrivañ en-dro", "confirmations.redraft.title": "Diverkañ ha skrivañ an embann en-dro?", "confirmations.remove_from_followers.confirm": "Dilemel an heulier·ez", @@ -246,6 +250,7 @@ "domain_block_modal.block_account_instead": "Stankañ @{name} kentoc'h", "domain_block_modal.title": "Stankañ an domani?", "domain_pill.server": "Dafariad", + "domain_pill.their_handle": "H·ec'h anaouder:", "domain_pill.username": "Anv-implijer", "domain_pill.whats_in_a_handle": "Petra eo an anaouder?", "domain_pill.your_handle": "Hoc'h anaouder:", @@ -267,6 +272,7 @@ "emoji_button.search_results": "Disoc'hoù an enklask", "emoji_button.symbols": "Arouezioù", "emoji_button.travel": "Beajiñ & Lec'hioù", + "empty_column.account_featured_other.unknown": "N'eo ket bet lakaet netra en a-raok gant ar gont-mañ.", "empty_column.account_suspended": "Kont astalet", "empty_column.account_timeline": "Embannadur ebet amañ!", "empty_column.account_unavailable": "Profil dihegerz", @@ -493,6 +499,7 @@ "notifications.column_settings.admin.sign_up": "Enskrivadurioù nevez :", "notifications.column_settings.alert": "Kemennoù war ar burev", "notifications.column_settings.favourite": "Muiañ-karet:", + "notifications.column_settings.filter_bar.advanced": "Diskouez an holl rummadoù", "notifications.column_settings.follow": "Heulierien nevez:", "notifications.column_settings.follow_request": "Rekedoù heuliañ nevez:", "notifications.column_settings.group": "Strollañ", @@ -518,7 +525,7 @@ "notifications.group": "{count} a gemennoù", "notifications.mark_as_read": "Merkañ an holl kemennoù evel bezañ lennet", "notifications.permission_denied": "Kemennoù war ar burev n'int ket hegerz rak pedadenn aotren ar merdeer a zo bet nullet araok", - "notifications.permission_denied_alert": "Kemennoù wa ar burev na c'hellont ket bezañ lezelet, rak aotre ar merdeer a zo bet nac'het a-raok", + "notifications.permission_denied_alert": "Kemennoù war ar burev na c'hellont ket bezañ lezelet, rak aotre ar merdeer a zo bet nac'het a-raok", "notifications.permission_required": "Kemennoù war ar burev n'int ket hegerz abalamour d'an aotre rekis n'eo ket bet roet.", "notifications.policy.accept": "Asantiñ", "notifications.policy.accept_hint": "Diskouez er c’hemennoù", diff --git a/config/locales/br.yml b/config/locales/br.yml index eef6f11828..30f4a50fbc 100644 --- a/config/locales/br.yml +++ b/config/locales/br.yml @@ -373,6 +373,7 @@ br: manage_rules: Merañ reolennoù ar servijer title: Diwar-benn appearance: + preamble: Personelaat etrefas web Mastodon. title: Neuz content_retention: danger_zone: Takad dañjer @@ -777,12 +778,13 @@ br: account: Kont account_settings: Arventennoù ar gont aliases: Aliasoù ar gont + appearance: Neuz back: Distreiñ da vMastodon delete: Dilemel ar gont development: Diorren edit_profile: Kemmañ ar profil export: Ezporzhiañ - featured_tags: Gerioù-klik en a-raok + featured_tags: Penngerioù-klik import: Enporzhiañ import_and_export: Enporzhiañ hag ezporzhiañ notifications: Kemennoù dre bostel From 0219b7cad7d9ef800f82cc561571b70da040433f Mon Sep 17 00:00:00 2001 From: Claire Date: Fri, 10 Oct 2025 14:26:10 +0200 Subject: [PATCH 4/6] Add `result_count` to `Mastodon-Async-Refresh` header when needed (#36239) --- app/controllers/concerns/async_refreshes_concern.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/controllers/concerns/async_refreshes_concern.rb b/app/controllers/concerns/async_refreshes_concern.rb index 29122e16b5..2d0e9ff4ff 100644 --- a/app/controllers/concerns/async_refreshes_concern.rb +++ b/app/controllers/concerns/async_refreshes_concern.rb @@ -6,6 +6,9 @@ module AsyncRefreshesConcern def add_async_refresh_header(async_refresh, retry_seconds: 3) return unless async_refresh.running? - response.headers['Mastodon-Async-Refresh'] = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}" + value = "id=\"#{async_refresh.id}\", retry=#{retry_seconds}" + value += ", result_count=#{async_refresh.result_count}" unless async_refresh.result_count.nil? + + response.headers['Mastodon-Async-Refresh'] = value end end From adc0e151670f3f7d2c8a86f4f61b1d002133a576 Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 10 Oct 2025 10:43:48 +0200 Subject: [PATCH 5/6] [Glitch] Add support for displaying link previews for Admin UI Port 81350c7cfb217349d0d73b6250faaf05c054fd08 to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/entrypoints/admin.tsx | 41 ++++++++++++++ .../flavours/glitch/styles/admin.scss | 55 +++++++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/app/javascript/flavours/glitch/entrypoints/admin.tsx b/app/javascript/flavours/glitch/entrypoints/admin.tsx index 2b97ef08f4..0c2126e5a8 100644 --- a/app/javascript/flavours/glitch/entrypoints/admin.tsx +++ b/app/javascript/flavours/glitch/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 'flavours/glitch/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/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 82ea9e8b1e..5bf9a5a206 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -1990,6 +1990,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 { From 5dff3414ceb9a8ce9369acb1592ccd46834c52ea Mon Sep 17 00:00:00 2001 From: Emelia Smith Date: Fri, 10 Oct 2025 10:55:41 +0200 Subject: [PATCH 6/6] [Glitch] Improve display of content warnings in Admin UI Port 8898f120dc1b5ad0d543360541fe99b5c10fa77b to glitch-soc Signed-off-by: Claire --- .../flavours/glitch/styles/admin.scss | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/app/javascript/flavours/glitch/styles/admin.scss b/app/javascript/flavours/glitch/styles/admin.scss index 5bf9a5a206..fc4e50dfde 100644 --- a/app/javascript/flavours/glitch/styles/admin.scss +++ b/app/javascript/flavours/glitch/styles/admin.scss @@ -1967,7 +1967,6 @@ a.sparkline { .status__card { padding: 15px; border-radius: 4px; - background: $ui-base-color; font-size: 15px; line-height: 20px; word-wrap: break-word; @@ -1986,8 +1985,50 @@ a.sparkline { .status__content { padding-top: 0; - summary { - display: list-item; + > details { + summary { + display: block; + box-sizing: border-box; + background: var(--nested-card-background); + color: var(--nested-card-text); + border: var(--nested-card-border); + border-radius: 8px; + padding: 8px 13px; + position: relative; + font-size: 15px; + line-height: 22px; + cursor: pointer; + + &::after { + content: attr(data-show, 'Show more'); + margin-top: 8px; + display: block; + font-size: 15px; + line-height: 20px; + color: $highlight-text-color; + cursor: pointer; + border: 0; + background: transparent; + padding: 0; + text-decoration: none; + font-weight: 500; + } + + &:hover, + &:focus-visible { + &::after { + text-decoration: underline !important; + } + } + } + + &[open] summary { + margin-bottom: 16px; + + &::after { + content: attr(data-hide, 'Hide post'); + } + } } }