From 5bc7c4b7e880ee1456dc21987d8fba32e340d31e Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 19:17:03 +0200 Subject: [PATCH 01/11] Emoji: Fixes issue with handled link not correctly showing remote users (#36403) --- .../status/handled_link.stories.tsx | 29 +++++++++----- .../components/status/handled_link.tsx | 38 +++++++++---------- .../mastodon/components/status_content.jsx | 2 +- .../components/embedded_status_content.tsx | 5 +-- 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx index a45e33626a..71bf8eee63 100644 --- a/app/javascript/mastodon/components/status/handled_link.stories.tsx +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -1,19 +1,28 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller'; -import { accountFactoryState } from '@/testing/factories'; import { HoverCardController } from '../hover_card_controller'; import type { HandledLinkProps } from './handled_link'; import { HandledLink } from './handled_link'; +type HandledLinkStoryProps = Pick & { + mentionAccount: 'local' | 'remote' | 'none'; +}; + const meta = { title: 'Components/Status/HandledLink', - render(args) { + render({ mentionAccount, ...args }) { + let mention: HandledLinkProps['mention'] | undefined; + if (mentionAccount === 'local') { + mention = { id: '1', acct: 'testuser' }; + } else if (mentionAccount === 'remote') { + mention = { id: '2', acct: 'remoteuser@mastodon.social' }; + } return ( <> - + @@ -22,15 +31,16 @@ const meta = { args: { href: 'https://example.com/path/subpath?query=1#hash', text: 'https://example.com', + mentionAccount: 'none', }, - parameters: { - state: { - accounts: { - '1': accountFactoryState(), - }, + argTypes: { + mentionAccount: { + control: { type: 'select' }, + options: ['local', 'remote', 'none'], + defaultValue: 'none', }, }, -} satisfies Meta>; +} satisfies Meta; export default meta; @@ -47,6 +57,7 @@ export const Hashtag: Story = { export const Mention: Story = { args: { text: '@user', + mentionAccount: 'local', }, }; diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index 83262886e8..0f486b33e9 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -1,22 +1,25 @@ import { useCallback } from 'react'; import type { ComponentProps, FC } from 'react'; +import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import type { ApiMentionJSON } from '@/mastodon/api_types/statuses'; import type { OnElementHandler } from '@/mastodon/utils/html'; export interface HandledLinkProps { href: string; text: string; hashtagAccountId?: string; - mentionAccountId?: string; + mention?: Pick; } export const HandledLink: FC> = ({ href, text, hashtagAccountId, - mentionAccountId, + mention, + className, ...props }) => { // Handle hashtags @@ -24,8 +27,7 @@ export const HandledLink: FC> = ({ const hashtag = text.slice(1).trim(); return ( > = ({ #{hashtag} ); - } else if (text.startsWith('@')) { + } else if (text.startsWith('@') && mention) { // Handle mentions - const mention = text.slice(1).trim(); return ( - @{mention} + @{text.slice(1).trim()} ); } @@ -52,7 +52,7 @@ export const HandledLink: FC> = ({ // Non-absolute paths treated as internal links. if (href.startsWith('/')) { return ( - + {text} ); @@ -66,7 +66,7 @@ export const HandledLink: FC> = ({ {...props} href={href} title={href} - className='unhandled-link' + className={classNames('unhandled-link', className)} target='_blank' rel='noreferrer noopener' translate='no' @@ -83,15 +83,15 @@ export const HandledLink: FC> = ({ export const useElementHandledLink = ({ hashtagAccountId, - hrefToMentionAccountId, + hrefToMention, }: { hashtagAccountId?: string; - hrefToMentionAccountId?: (href: string) => string | undefined; + hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( (element, { key, ...props }) => { if (element instanceof HTMLAnchorElement) { - const mentionId = hrefToMentionAccountId?.(element.href); + const mention = hrefToMention?.(element.href); return ( ); } return undefined; }, - [hashtagAccountId, hrefToMentionAccountId], + [hashtagAccountId, hrefToMention], ); return { onElement }; }; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index ede98cc81a..54579a1134 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -213,7 +213,7 @@ class StatusContent extends PureComponent { href={element.href} text={element.innerText} hashtagAccountId={this.props.status.getIn(['account', 'id'])} - mentionAccountId={mention?.get('id')} + mention={mention?.toJSON()} key={key} /> ); diff --git a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx index 91c3abde38..b7dc998a47 100644 --- a/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx +++ b/app/javascript/mastodon/features/notifications_v2/components/embedded_status_content.tsx @@ -48,9 +48,8 @@ export const EmbeddedStatusContent: React.FC<{ ); const htmlHandlers = useElementHandledLink({ hashtagAccountId: status.get('account') as string | undefined, - hrefToMentionAccountId(href) { - const mention = mentions.find((item) => item.url === href); - return mention?.id; + hrefToMention(href) { + return mentions.find((item) => item.url === href); }, }); From 01526592451074ab45cfecfc70da4244aaad7257 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 9 Oct 2025 04:08:29 -0400 Subject: [PATCH 02/11] Use tag filter for pending tag count on admin dashboard (#36404) --- app/controllers/admin/dashboard_controller.rb | 8 +++++++- spec/system/admin/dashboard_spec.rb | 2 ++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index 5b0867dcfb..fe314daeca 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -9,10 +9,16 @@ module Admin @pending_appeals_count = Appeal.pending.async_count @pending_reports_count = Report.unresolved.async_count - @pending_tags_count = Tag.pending_review.async_count + @pending_tags_count = pending_tags.async_count @pending_users_count = User.pending.async_count @system_checks = Admin::SystemCheck.perform(current_user) @time_period = (29.days.ago.to_date...Time.now.utc.to_date) end + + private + + def pending_tags + ::Trends::TagFilter.new(status: :pending_review).results + end end end diff --git a/spec/system/admin/dashboard_spec.rb b/spec/system/admin/dashboard_spec.rb index 06d31cde44..d0cedd2ed1 100644 --- a/spec/system/admin/dashboard_spec.rb +++ b/spec/system/admin/dashboard_spec.rb @@ -9,6 +9,7 @@ RSpec.describe 'Admin Dashboard' do before do stub_system_checks Fabricate :software_update + Fabricate :tag, requested_review_at: 5.minutes.ago sign_in(user) end @@ -18,6 +19,7 @@ RSpec.describe 'Admin Dashboard' do expect(page) .to have_title(I18n.t('admin.dashboard.title')) .and have_content(I18n.t('admin.system_checks.software_version_patch_check.message_html')) + .and have_content('0 pending hashtags') end private From ba70dcf827ccd97117f146665c02b7310d367e2f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 10:25:26 +0200 Subject: [PATCH 03/11] Update docker.io/ruby Docker tag to v3.4.7 (#36407) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index f2164ffd94..ad8150552a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ ARG BASE_REGISTRY="docker.io" # Ruby image to use for base image, change with [--build-arg RUBY_VERSION="3.4.x"] # renovate: datasource=docker depName=docker.io/ruby -ARG RUBY_VERSION="3.4.6" +ARG RUBY_VERSION="3.4.7" # # Node.js version to use in base image, change with [--build-arg NODE_MAJOR_VERSION="20"] # renovate: datasource=node-version depName=node ARG NODE_MAJOR_VERSION="22" From a459ccf616d3573c9837c29636286ffceb176382 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:12:27 +0200 Subject: [PATCH 04/11] New Crowdin Translations (automated) (#36406) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/sq.json | 22 ++++++++++++++++++++++ config/locales/simple_form.sq.yml | 12 ++++++++++++ config/locales/sq.yml | 16 ++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json index 059a97b0f3..8e76cd8fe0 100644 --- a/app/javascript/mastodon/locales/sq.json +++ b/app/javascript/mastodon/locales/sq.json @@ -113,6 +113,7 @@ "alt_text_modal.describe_for_people_with_visual_impairments": "Përshkruajeni këtë për persona me mangësi shikimi…", "alt_text_modal.done": "U bë", "announcement.announcement": "Lajmërim", + "annual_report.summary.archetype.oracle": "Orakulli", "annual_report.summary.followers.followers": "ndjekës", "annual_report.summary.followers.total": "{count} gjithsej", "annual_report.summary.here_it_is": "Ja {year} juaj e shqyrtuar:", @@ -299,6 +300,7 @@ "domain_pill.your_handle": "Targa juaj:", "domain_pill.your_server": "Shtëpia juaj dixhitale, kur gjenden krejt postimet tuaja. S’ju pëlqen kjo këtu? Shpërngulni shërbyes kur të doni dhe sillni edhe ndjekësit tuaj.", "domain_pill.your_username": "Identifikuesi juja unik në këtë shërbyes. Është e mundur të gjenden përdorues me të njëjtin emër përdoruesi në shërbyes të ndryshëm.", + "dropdown.empty": "Përzgjidhni një mundësi", "embed.instructions": "Trupëzojeni këtë gjendje në sajtin tuaj duke kopjuar kodin më poshtë.", "embed.preview": "Ja si do të duket:", "emoji_button.activity": "Veprimtari", @@ -568,6 +570,8 @@ "navigation_bar.follows_and_followers": "Ndjekje dhe ndjekës", "navigation_bar.import_export": "Importim dhe eksportim", "navigation_bar.lists": "Lista", + "navigation_bar.live_feed_local": "Pryrje e atypëratyshme (vendore)", + "navigation_bar.live_feed_public": "Prurje e atypëratyshme (publike)", "navigation_bar.logout": "Dalje", "navigation_bar.moderation": "Moderim", "navigation_bar.more": "Më tepër", @@ -737,11 +741,18 @@ "privacy.private.short": "Ndjekës", "privacy.public.long": "Cilido që hyn e del në Mastodon", "privacy.public.short": "Publik", + "privacy.quote.anyone": "{visibility}, mund të citojë cilido", + "privacy.quote.disabled": "{visibility}, citimet janë çaktivizuar", + "privacy.quote.limited": "{visibility}, citime të kufizuara", "privacy.unlisted.additional": "Ky sillet saktësisht si publik, vetëm se postimi s’do të shfaqet në prurje të drejtpërdrejta, ose në hashtag-ë, te eksploroni, apo kërkim në Mastodon, edhe kur keni zgjedhur të jetë për tërë llogarinë.", "privacy.unlisted.long": "Fshehur nga përfundime kërkimi në Mastodon, rrjedha kohore gjërash në modë dhe publike", "privacy.unlisted.short": "Publik i qetë", "privacy_policy.last_updated": "Përditësuar së fundi më {date}", "privacy_policy.title": "Rregulla Privatësie", + "quote_error.poll": "Me pyetësorët nuk lejohet citim.", + "quote_error.quote": "Lejohet vetëm një citim në herë.", + "quote_error.unauthorized": "S’jen i autorizuar ta citoni këtë postim.", + "quote_error.upload": "Me bashkëngjitjet media nuk lejohet citim.", "recommended": "E rekomanduar", "refresh": "Rifreskoje", "regeneration_indicator.please_stand_by": "Ju lutemi, mos u largoni.", @@ -851,6 +862,7 @@ "status.admin_account": "Hap ndërfaqe moderimi për @{name}", "status.admin_domain": "Hap ndërfaqe moderimi për {domain}", "status.admin_status": "Hape këtë mesazh te ndërfaqja e moderimit", + "status.all_disabled": "Përforcimet dhe citime janë të çaktivizuara", "status.block": "Blloko @{name}", "status.bookmark": "Faqeruaje", "status.cancel_reblog_private": "Shpërforcojeni", @@ -889,6 +901,8 @@ "status.mute_conversation": "Heshtoje bisedën", "status.open": "Zgjeroje këtë mesazh", "status.pin": "Fiksoje në profil", + "status.quote": "Citojeni", + "status.quote.cancel": "Anuloje citimin", "status.quote_error.filtered": "Fshehur për shkak të njërit nga filtrat tuaj", "status.quote_error.limited_account_hint.action": "Shfaqe, sido qoftë", "status.quote_error.limited_account_hint.title": "Kjo llogari është fshehur nga moderatorët e {domain}.", @@ -899,7 +913,9 @@ "status.quote_followers_only": "Këtë postim mund ta citojnë vetëm ndjekës", "status.quote_manual_review": "Autori do ta shqyrtojë dorazi", "status.quote_noun": "Citim", + "status.quote_policy_change": "Ndryshoni cilët mund të citojnë", "status.quote_post_author": "U citua një postim nga @{name}", + "status.quote_private": "Postimet private s’mund të citohen", "status.quotes": "{count, plural, one {citim} other {citime}}", "status.quotes.empty": "Këtë postim ende s’e ka cituar kush. Kur dikush ta bëjë, do të shfaqet këtu.", "status.quotes.local_other_disclaimer": "Citimet e hedhura poshtë nga autori s’do të shfaqen.", @@ -959,6 +975,7 @@ "upload_button.label": "Shtoni figura, një video ose një kartelë audio", "upload_error.limit": "U tejkalua kufi ngarkimi kartelash.", "upload_error.poll": "Me pyetësorët s’lejohet ngarkim kartelash.", + "upload_error.quote": "Nuk lejohet ngarkim kartelash me citime.", "upload_form.drag_and_drop.instructions": "Që të merrni një bashkëngjitje media, shtypni tastin Space ose Enter. Teksa bëhet tërheqje, përdorni tastet shigjetë për ta shpënë bashkëngjitjen media në cilëndo drejtori që doni. Shtypni sërish Space ose Enter që të lihet bashkëngjitja media në pozicionin e vet të ri, ose shtypni Esc, që të anulohet veprimi.", "upload_form.drag_and_drop.on_drag_cancel": "Tërheqja u anulua. Bashkëngjitja media {item} u la.", "upload_form.drag_and_drop.on_drag_end": "Bashkëngjitja media {item} u la.", @@ -982,13 +999,18 @@ "video.unmute": "Hiqi heshtimin", "video.volume_down": "Ulje volumi", "video.volume_up": "Ngritje volumi", + "visibility_modal.button_title": "Caktoni dukshmëri", + "visibility_modal.header": "Dukshmëri dhe ndërveprim", "visibility_modal.helper.direct_quoting": "Përmendje private të krijuara në Mastodon s’mund të citohen nga të tjerë.", "visibility_modal.helper.privacy_editing": "Dukshmëria s’mund të ndryshohet pasi postimi botohet.", "visibility_modal.helper.privacy_private_self_quote": "Citimet nga ju vetë të postime private s’mund të bëhen publike.", "visibility_modal.helper.private_quoting": "Postime vetëm për ndjekësit, të krijuara në Mastodon s’mund të citohen nga të tjerë.", + "visibility_modal.helper.unlisted_quoting": "Kur njerëzit ju citojnë, nga rrjedha kohore e gjërave në modë do të kalohen si të fshehura edhe postimet e tyre.", "visibility_modal.instructions": "Kontrolloni cilët mund të ndërveprojnë me këtë postim. Rregullime mund të aplikooni edhe mbi krejt postimet e ardshme, që nga Parapëlqime > Parazgjedhje postimi.", "visibility_modal.privacy_label": "Dukshmëri", + "visibility_modal.quote_followers": "Vetëm ndjekës", "visibility_modal.quote_label": "Cilët mund të citojnë", "visibility_modal.quote_nobody": "Thjesht unë", + "visibility_modal.quote_public": "Cilido", "visibility_modal.save": "Ruaje" } diff --git a/config/locales/simple_form.sq.yml b/config/locales/simple_form.sq.yml index db975ec65a..15d9e09e29 100644 --- a/config/locales/simple_form.sq.yml +++ b/config/locales/simple_form.sq.yml @@ -148,6 +148,9 @@ sq: min_age: S’duhet të jetë nën moshën minimum të domosdoshme nga ligjet në juridiksionin tuaj. user: chosen_languages: Në iu vëntë shenjë, te rrjedha kohore publike do të shfaqen vetëm mesazhe në gjuhët e përzgjedhura + date_of_birth: + one: Na duhet të sigurohemi se jeni të paktën %{count} që të përdorni %{domain}. S’do ta depozitojmë këtë. + other: Na duhet të sigurohemi se jeni të paktën %{count} që të përdorni %{domain}. S’do ta depozitojmë këtë. role: Roli kontrollon cilat leje ka përdoruesi. user_role: color: Ngjyrë për t’u përdorur për rolin nëpër UI, si RGB në format gjashtëmbëdhjetësh @@ -237,6 +240,7 @@ sq: setting_display_media_default: Parazgjedhje setting_display_media_hide_all: Fshihi krejt setting_display_media_show_all: Shfaqi krejt + setting_emoji_style: Stil emoji-sh setting_expand_spoilers: Mesazhet me sinjalizime mbi lëndën, zgjeroji përherë setting_hide_network: Fshiheni rrjetin tuaj setting_missing_alt_text_modal: Shfaq dialog ripohimi, para postimi mediash pa tekst alternativ @@ -273,12 +277,16 @@ sq: content_cache_retention_period: Periudhë mbajtjeje lënde të largët custom_css: CSS Vetjake favicon: Favikonë + local_live_feed_access: Hyrje te prurje të atypëratyshme që përmbajnë postime vendore + local_topic_feed_access: Hyrje te prurje hashtag-ësh dhe lidhjesh që përmbajnë postime vendore mascot: Simbol vetjak (e dikurshme) media_cache_retention_period: Periudhë mbajtjeje lënde media min_age: Domosdosmëri moshe minimum peers_api_enabled: Publiko te API listë shërbyesish të zbuluar profile_directory: Aktivizo drejtori profilesh registrations_mode: Kush mund të regjistrohet + remote_live_feed_access: Hyrje te prurje të atypëratyshme që përmbajnë postime nga larg + remote_topic_feed_access: Hyrje te prurje hashtag-ësh dhe lidhjesh që përmbajnë postime nga larg require_invite_text: Kërko një arsye për pjesëmarrje show_domain_blocks: Shfaq bllokime përkatësish show_domain_blocks_rationale: Shfaq pse janë bllokuar përkatësitë @@ -365,6 +373,10 @@ sq: name: Emër permissions_as_keys: Leje position: Përparësi + username_block: + allow_with_approval: Lejo regjistrim me miratim + comparison: Metodë krahasimi + username: Fjalë për t’u vëzhguar webhook: events: Akte të aktivizuar template: Gjedhe ngarkese diff --git a/config/locales/sq.yml b/config/locales/sq.yml index b0ce596564..3f1978f7c8 100644 --- a/config/locales/sq.yml +++ b/config/locales/sq.yml @@ -190,6 +190,7 @@ sq: create_relay: Krijoni Rele create_unavailable_domain: Krijo Përkatësi të Papërdorshme create_user_role: Krijoni Rol + create_username_block: Krijoni Rregull Emrash Përdoruesish demote_user: Zhgradoje Përdoruesin destroy_announcement: Fshije Lajmërimin destroy_canonical_email_block: Fshini Bllokim Email-esh @@ -203,6 +204,7 @@ sq: destroy_status: Fshi Gjendje destroy_unavailable_domain: Fshi Përkatësi të Papërdorshme destroy_user_role: Asgjësoje Rolin + destroy_username_block: Fshini Rregull Emrash Përdoruesish disable_2fa_user: Çaktivizo 2FA-në disable_custom_emoji: Çaktivizo Emotikon Vetjak disable_relay: Çaktivizoje Relenë @@ -237,6 +239,7 @@ sq: update_report: Përditësoni Raportimin update_status: Përditëso Gjendjen update_user_role: Përditësoni Rol + update_username_block: Përditësoni Rregull Emrash Përdoruesish actions: approve_appeal_html: "%{name} miratoi apelim vendimi moderimi nga %{target}" approve_user_html: "%{name} miratoi regjistrim nga %{target}" @@ -255,6 +258,7 @@ sq: create_relay_html: "%{name} krijoi një rele %{target}" create_unavailable_domain_html: "%{name} ndali dërgimin drejt përkatësisë %{target}" create_user_role_html: "%{name} krijoi rolin %{target}" + create_username_block_html: "%{name} shtoi rregull për emra përdoruesish që përmbajnë %{target}" demote_user_html: "%{name} zhgradoi përdoruesin %{target}" destroy_announcement_html: "%{name} fshiu lajmërimin për %{target}" destroy_canonical_email_block_html: "%{name} zhbllokoi email me hashin %{target}" @@ -268,6 +272,7 @@ sq: destroy_status_html: "%{name} hoqi gjendje nga %{target}" destroy_unavailable_domain_html: "%{name} rinisi dërgimin drejt përkatësisë %{target}" destroy_user_role_html: "%{name} fshiu rolin %{target}" + destroy_username_block_html: "%{name} hoqi rregull për emra përdoruesish që përmbajnë %{target}" disable_2fa_user_html: "%{name} çaktivizoi domosdoshmërinë për dyfaktorësh për përdoruesin %{target}" disable_custom_emoji_html: "%{name} çaktivizoi emoxhin %{target}" disable_relay_html: "%{name} çaktivizoi relenë %{target}" @@ -302,6 +307,7 @@ sq: update_report_html: "%{name} përditësoi raportimin %{target}" update_status_html: "%{name} përditësoi gjendjen me %{target}" update_user_role_html: "%{name} ndryshoi rolin për %{target}" + update_username_block_html: "%{name} përditësoi rregull për emra përdoruesish që përmbajnë %{target}" deleted_account: fshiu llogarinë empty: S’u gjetën regjistra. filter_by_action: Filtroji sipas veprimit @@ -505,6 +511,7 @@ sq: select_capabilities: Përzgjidhni Aftësi sign_in: Hyni status: Gjendje + title: Shërbyes Shërbimesh Ndihmëse Fediversi title: FASP follow_recommendations: description_html: "Rekomandimet për ndjekje ndihmojnë përdoruesit e rinj të gjejnë shpejt lëndë me interes. Kur një përdorues nuk ka ndërvepruar mjaftueshëm me të tjerët, që të formohen rekomandime të personalizuara ndjekjeje, rekomandohen këto llogari. Ato përzgjidhen çdo ditë, prej një përzierje llogarish me shkallën më të lartë të angazhimit dhe numrin më të lartë të ndjekësve vendorë për një gjuhë të dhënë." @@ -1090,6 +1097,14 @@ sq: delete: Fshije edit: title: Përpunoni rregull emrash përdoruesi + matches_exactly_html: Baras me %{string} + new: + create: Krijoni rregull + title: Krijoni rregull të ri emrash përdoruesish + no_username_block_selected: S’u ndryshua ndonjë rregull emrash përdoruesishm ngaqë s’u përzgjodh ndonjë + not_permitted: Jo i lejuar + title: Rregulla emrash përdoruesish + updated_msg: Rregulli i emrave të përdoruesve u përditësua me sukses warning_presets: add_new: Shtoni të ri delete: Fshije @@ -1880,6 +1895,7 @@ sq: edited_at_html: Përpunuar më %{date} errors: in_reply_not_found: Gjendja të cilës po provoni t’i përgjigjeni s’duket se ekziston. + quoted_status_not_found: Postimi që po rrekeni të citoni nuk duket se ekziston. over_character_limit: u tejkalua kufi shenjash prej %{max} pin_errors: direct: Postimet që janë të dukshme vetëm për përdoruesit e përmendur s’mund të fiksohen From b7c5e60426bba0bc719eee9370706108ec89f54f Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 9 Oct 2025 11:53:45 +0200 Subject: [PATCH 05/11] Fix quote post state sometimes not being updated through streaming server (#36408) --- app/services/activitypub/process_status_update_service.rb | 2 ++ app/workers/activitypub/refetch_and_verify_quote_worker.rb | 1 + 2 files changed, 3 insertions(+) diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 66c8da2b60..62805eaddc 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -74,6 +74,8 @@ class ActivityPub::ProcessStatusUpdateService < BaseService update_quote_approval! update_counts! end + + broadcast_updates! if @status.quote&.state_previously_changed? end def update_interaction_policies! diff --git a/app/workers/activitypub/refetch_and_verify_quote_worker.rb b/app/workers/activitypub/refetch_and_verify_quote_worker.rb index 0c7ecd9b2a..e2df023103 100644 --- a/app/workers/activitypub/refetch_and_verify_quote_worker.rb +++ b/app/workers/activitypub/refetch_and_verify_quote_worker.rb @@ -10,6 +10,7 @@ class ActivityPub::RefetchAndVerifyQuoteWorker def perform(quote_id, quoted_uri, options = {}) quote = Quote.find(quote_id) ActivityPub::VerifyQuoteService.new.call(quote, fetchable_quoted_uri: quoted_uri, request_id: options[:request_id]) + ::DistributionWorker.perform_async(quote.status_id, { 'update' => true }) if quote.state_previously_changed? rescue ActiveRecord::RecordNotFound # Do nothing true From d4a4a7177ae0a89b2fe44778e92c5da123173535 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 9 Oct 2025 15:52:38 +0200 Subject: [PATCH 06/11] Fix crash when serializing quotes of deleted posts for ActivityPub (#36381) --- app/lib/activitypub/activity/create.rb | 4 +- app/lib/activitypub/parser/status_parser.rb | 8 ++++ app/models/quote.rb | 2 +- .../activitypub/note_serializer.rb | 10 +++-- .../process_status_update_service.rb | 4 +- spec/lib/activitypub/activity/create_spec.rb | 24 ++++++++++++ .../activitypub/note_serializer_spec.rb | 15 ++++++++ .../process_status_update_service_spec.rb | 38 +++++++++++++++++++ 8 files changed, 97 insertions(+), 8 deletions(-) diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 607e9be8cc..3d52c9a56c 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -218,11 +218,11 @@ class ActivityPub::Activity::Create < ActivityPub::Activity def process_quote @quote_uri = @status_parser.quote_uri - return if @quote_uri.blank? + return unless @status_parser.quote? approval_uri = @status_parser.quote_approval_uri approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri) - @quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?) + @quote = Quote.new(account: @account, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?, state: @status_parser.deleted_quote? ? :deleted : :pending) end def process_hashtag(tag) diff --git a/app/lib/activitypub/parser/status_parser.rb b/app/lib/activitypub/parser/status_parser.rb index 7439cca5b2..57e6cb926c 100644 --- a/app/lib/activitypub/parser/status_parser.rb +++ b/app/lib/activitypub/parser/status_parser.rb @@ -118,6 +118,14 @@ class ActivityPub::Parser::StatusParser flags end + def quote? + %w(quote _misskey_quote quoteUrl quoteUri).any? { |key| @object[key].present? } + end + + def deleted_quote? + @object['quote'].is_a?(Hash) && @object['quote']['type'] == 'Tombstone' + end + def quote_uri %w(quote _misskey_quote quoteUrl quoteUri).filter_map do |key| value_or_id(as_array(@object[key]).first) diff --git a/app/models/quote.rb b/app/models/quote.rb index dcfcd3b353..0d24cb239a 100644 --- a/app/models/quote.rb +++ b/app/models/quote.rb @@ -25,7 +25,7 @@ class Quote < ApplicationRecord REFRESH_DEADLINE = 6.hours enum :state, - { pending: 0, accepted: 1, rejected: 2, revoked: 3 }, + { pending: 0, accepted: 1, rejected: 2, revoked: 3, deleted: 4 }, validate: true belongs_to :status diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 4c5d3f4cf8..f162f4ee24 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -32,8 +32,8 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer attribute :voters_count, if: :poll_and_voters_count? attribute :quote, if: :quote? - attribute :quote, key: :_misskey_quote, if: :quote? - attribute :quote, key: :quote_uri, if: :quote? + attribute :quote, key: :_misskey_quote, if: :serializable_quote? + attribute :quote, key: :quote_uri, if: :serializable_quote? attribute :quote_authorization, if: :quote_authorization? attribute :interaction_policy @@ -214,13 +214,17 @@ class ActivityPub::NoteSerializer < ActivityPub::Serializer object.quote&.present? end + def serializable_quote? + object.quote&.quoted_status&.present? + end + def quote_authorization? object.quote.present? && ActivityPub::TagManager.instance.approval_uri_for(object.quote).present? end def quote # TODO: handle inlining self-quotes - ActivityPub::TagManager.instance.uri_for(object.quote.quoted_status) + object.quote.quoted_status.present? ? ActivityPub::TagManager.instance.uri_for(object.quote.quoted_status) : { type: 'Tombstone' } end def quote_authorization diff --git a/app/services/activitypub/process_status_update_service.rb b/app/services/activitypub/process_status_update_service.rb index 62805eaddc..7e26734258 100644 --- a/app/services/activitypub/process_status_update_service.rb +++ b/app/services/activitypub/process_status_update_service.rb @@ -300,7 +300,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService def update_quote! quote_uri = @status_parser.quote_uri - if quote_uri.present? + if @status_parser.quote? approval_uri = @status_parser.quote_approval_uri approval_uri = nil if unsupported_uri_scheme?(approval_uri) || TagManager.instance.local_url?(approval_uri) @@ -310,7 +310,7 @@ class ActivityPub::ProcessStatusUpdateService < BaseService # Revoke the quote while we get a chance… maybe this should be a `before_destroy` hook? RevokeQuoteService.new.call(@status.quote) if @status.quote.quoted_account&.local? && @status.quote.accepted? @status.quote.destroy - quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?) + quote = Quote.create(status: @status, approval_uri: approval_uri, legacy: @status_parser.legacy_quote?, state: @status_parser.deleted_quote? ? :deleted : :pending) @quote_changed = true else quote = @status.quote diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 80a5c6907c..2f2f91e369 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -938,6 +938,30 @@ RSpec.describe ActivityPub::Activity::Create do end end + context 'with an unverifiable quote of a dead post' do + let(:quoted_status) { Fabricate(:status) } + + let(:object_json) do + build_object( + type: 'Note', + content: 'woah what she said is amazing', + quote: { type: 'Tombstone' } + ) + end + + it 'creates a status with an unverified quote' do + expect { subject.perform }.to change(sender.statuses, :count).by(1) + + status = sender.statuses.first + expect(status).to_not be_nil + expect(status.quote).to_not be_nil + expect(status.quote).to have_attributes( + state: 'deleted', + approval_uri: nil + ) + end + end + context 'with an unverifiable unknown post' do let(:unknown_post_uri) { 'https://unavailable.example.com/unavailable-post' } diff --git a/spec/serializers/activitypub/note_serializer_spec.rb b/spec/serializers/activitypub/note_serializer_spec.rb index 04179e9bf4..0d11386d57 100644 --- a/spec/serializers/activitypub/note_serializer_spec.rb +++ b/spec/serializers/activitypub/note_serializer_spec.rb @@ -58,6 +58,21 @@ RSpec.describe ActivityPub::NoteSerializer do end end + context 'with a deleted quote' do + let(:quoted_status) { Fabricate(:status) } + + before do + Fabricate(:quote, status: parent, quoted_status: nil, state: :accepted) + end + + it 'has the expected shape' do + expect(subject).to include({ + 'type' => 'Note', + 'quote' => { 'type' => 'Tombstone' }, + }) + end + end + context 'with a quote policy' do let(:parent) { Fabricate(:status, quote_approval_policy: Status::QUOTE_APPROVAL_POLICY_FLAGS[:followers] << 16) } diff --git a/spec/services/activitypub/process_status_update_service_spec.rb b/spec/services/activitypub/process_status_update_service_spec.rb index 7af2c67387..56a8c71cbe 100644 --- a/spec/services/activitypub/process_status_update_service_spec.rb +++ b/spec/services/activitypub/process_status_update_service_spec.rb @@ -1053,6 +1053,44 @@ RSpec.describe ActivityPub::ProcessStatusUpdateService do end end + context 'when the status swaps a verified quote with an ID-less Tombstone through an explicit update' do + let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') } + let(:quoted_status) { Fabricate(:status, account: quoted_account) } + let(:second_quoted_status) { Fabricate(:status, account: quoted_account) } + let!(:quote) { Fabricate(:quote, status: status, quoted_status: quoted_status, approval_uri: approval_uri, state: :accepted) } + let(:approval_uri) { 'https://quoted.example.com/approvals/1' } + + let(:payload) do + { + '@context': [ + 'https://www.w3.org/ns/activitystreams', + { + '@id': 'https://w3id.org/fep/044f#quote', + '@type': '@id', + }, + { + '@id': 'https://w3id.org/fep/044f#quoteAuthorization', + '@type': '@id', + }, + ], + id: 'foo', + type: 'Note', + summary: 'Show more', + content: 'Hello universe', + updated: '2021-09-08T22:39:25Z', + quote: { type: 'Tombstone' }, + } + end + + it 'updates the URI and unverifies the quote' do + expect { subject.call(status, json, json) } + .to change { status.quote.quoted_status }.from(quoted_status).to(nil) + .and change { status.quote.state }.from('accepted').to('deleted') + + expect { quote.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + context 'when the status swaps a verified quote with another verifiable quote through an explicit update' do let(:quoted_account) { Fabricate(:account, domain: 'quoted.example.com') } let(:second_quoted_account) { Fabricate(:account, domain: 'second-quoted.example.com') } From 258869278e8f42ff7c1795eb024c7c2ed6cfebca Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 9 Oct 2025 16:08:36 +0200 Subject: [PATCH 07/11] Fix: Embed author handle using wrong DisplayName (#36413) --- .../explore/components/author_link.jsx | 23 ------------------- .../explore/components/author_link.tsx | 22 ++++++++++++++++++ 2 files changed, 22 insertions(+), 23 deletions(-) delete mode 100644 app/javascript/mastodon/features/explore/components/author_link.jsx create mode 100644 app/javascript/mastodon/features/explore/components/author_link.tsx diff --git a/app/javascript/mastodon/features/explore/components/author_link.jsx b/app/javascript/mastodon/features/explore/components/author_link.jsx deleted file mode 100644 index cf92ebc78b..0000000000 --- a/app/javascript/mastodon/features/explore/components/author_link.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import PropTypes from 'prop-types'; - -import { Avatar } from 'mastodon/components/avatar'; -import { useAppSelector } from 'mastodon/store'; -import { LinkedDisplayName } from '@/mastodon/components/display_name'; - -export const AuthorLink = ({ accountId }) => { - const account = useAppSelector(state => state.getIn(['accounts', accountId])); - - if (!account) { - return null; - } - - return ( - - - - ); -}; - -AuthorLink.propTypes = { - accountId: PropTypes.string.isRequired, -}; diff --git a/app/javascript/mastodon/features/explore/components/author_link.tsx b/app/javascript/mastodon/features/explore/components/author_link.tsx new file mode 100644 index 0000000000..a4667693a5 --- /dev/null +++ b/app/javascript/mastodon/features/explore/components/author_link.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; + +import { LinkedDisplayName } from '@/mastodon/components/display_name'; +import { Avatar } from 'mastodon/components/avatar'; +import { useAppSelector } from 'mastodon/store'; + +export const AuthorLink: FC<{ accountId: string }> = ({ accountId }) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + + if (!account) { + return null; + } + + return ( + + + + ); +}; From c858fc77ef194be0217fd98eae84efd261dba798 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 9 Oct 2025 16:31:13 +0200 Subject: [PATCH 08/11] Fixes handled link formatting (#36410) Co-authored-by: Claire --- .../status/handled_link.stories.tsx | 32 ++++++++++- .../components/status/handled_link.tsx | 56 +++++++++---------- .../mastodon/components/status_content.jsx | 6 +- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/app/javascript/mastodon/components/status/handled_link.stories.tsx b/app/javascript/mastodon/components/status/handled_link.stories.tsx index 71bf8eee63..e343833704 100644 --- a/app/javascript/mastodon/components/status/handled_link.stories.tsx +++ b/app/javascript/mastodon/components/status/handled_link.stories.tsx @@ -1,19 +1,24 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { HashtagMenuController } from '@/mastodon/features/ui/components/hashtag_menu_controller'; +import { accountFactoryState } from '@/testing/factories'; import { HoverCardController } from '../hover_card_controller'; import type { HandledLinkProps } from './handled_link'; import { HandledLink } from './handled_link'; -type HandledLinkStoryProps = Pick & { +type HandledLinkStoryProps = Pick< + HandledLinkProps, + 'href' | 'text' | 'prevText' +> & { mentionAccount: 'local' | 'remote' | 'none'; + hashtagAccount: boolean; }; const meta = { title: 'Components/Status/HandledLink', - render({ mentionAccount, ...args }) { + render({ mentionAccount, hashtagAccount, ...args }) { let mention: HandledLinkProps['mention'] | undefined; if (mentionAccount === 'local') { mention = { id: '1', acct: 'testuser' }; @@ -22,7 +27,13 @@ const meta = { } return ( <> - + + {args.text} + @@ -32,6 +43,7 @@ const meta = { href: 'https://example.com/path/subpath?query=1#hash', text: 'https://example.com', mentionAccount: 'none', + hashtagAccount: false, }, argTypes: { mentionAccount: { @@ -40,6 +52,13 @@ const meta = { defaultValue: 'none', }, }, + parameters: { + state: { + accounts: { + '1': accountFactoryState({ id: '1', acct: 'hashtaguser' }), + }, + }, + }, } satisfies Meta; export default meta; @@ -48,9 +67,16 @@ type Story = StoryObj; export const Default: Story = {}; +export const Simple: Story = { + args: { + href: 'https://example.com/test', + }, +}; + export const Hashtag: Story = { args: { text: '#example', + hashtagAccount: true, }, }; diff --git a/app/javascript/mastodon/components/status/handled_link.tsx b/app/javascript/mastodon/components/status/handled_link.tsx index 0f486b33e9..3c8973992b 100644 --- a/app/javascript/mastodon/components/status/handled_link.tsx +++ b/app/javascript/mastodon/components/status/handled_link.tsx @@ -10,6 +10,7 @@ import type { OnElementHandler } from '@/mastodon/utils/html'; export interface HandledLinkProps { href: string; text: string; + prevText?: string; hashtagAccountId?: string; mention?: Pick; } @@ -17,13 +18,15 @@ export interface HandledLinkProps { export const HandledLink: FC> = ({ href, text, + prevText, hashtagAccountId, mention, className, + children, ...props }) => { // Handle hashtags - if (text.startsWith('#')) { + if (text.startsWith('#') || prevText?.endsWith('#')) { const hashtag = text.slice(1).trim(); return ( > = ({ rel='tag' data-menu-hashtag={hashtagAccountId} > - #{hashtag} + {children} ); - } else if (text.startsWith('@') && mention) { + } else if ((text.startsWith('@') || prevText?.endsWith('@')) && mention) { // Handle mentions return ( > = ({ title={`@${mention.acct}`} data-hover-card-account={mention.id} > - @{text.slice(1).trim()} + {children} ); } - // Non-absolute paths treated as internal links. + // Non-absolute paths treated as internal links. This shouldn't happen, but just in case. if (href.startsWith('/')) { return ( - {text} + {children} ); } - try { - const url = new URL(href); - const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. - return ( - - {url.protocol + '//'} - {`${url.hostname}/${first ?? ''}`} - {'/' + rest.join('/')} - - ); - } catch { - return text; - } + return ( + + {children} + + ); }; export const useElementHandledLink = ({ @@ -89,7 +84,7 @@ export const useElementHandledLink = ({ hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( - (element, { key, ...props }) => { + (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = hrefToMention?.(element.href); return ( @@ -98,9 +93,12 @@ export const useElementHandledLink = ({ key={key as string} // React requires keys to not be part of spread props. href={element.href} text={element.innerText} + prevText={element.previousSibling?.textContent ?? undefined} hashtagAccountId={hashtagAccountId} mention={mention} - /> + > + {children} + ); } return undefined; diff --git a/app/javascript/mastodon/components/status_content.jsx b/app/javascript/mastodon/components/status_content.jsx index 54579a1134..14779ce3a4 100644 --- a/app/javascript/mastodon/components/status_content.jsx +++ b/app/javascript/mastodon/components/status_content.jsx @@ -204,7 +204,7 @@ class StatusContent extends PureComponent { this.node = c; }; - handleElement = (element, { key, ...props }) => { + handleElement = (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); return ( @@ -215,7 +215,9 @@ class StatusContent extends PureComponent { hashtagAccountId={this.props.status.getIn(['account', 'id'])} mention={mention?.toJSON()} key={key} - /> + > + {children} + ); } else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) { return null; From 48bb64cde35b6146c1f96a6d7a31ac95c2f30346 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 8 Oct 2025 19:17:03 +0200 Subject: [PATCH 09/11] [Glitch] Emoji: Fixes issue with handled link not correctly showing remote users Port 5bc7c4b7e880ee1456dc21987d8fba32e340d31e to glitch-soc Signed-off-by: Claire --- .../status/handled_link.stories.tsx | 29 +++++++++----- .../glitch/components/status/handled_link.tsx | 38 +++++++++---------- .../glitch/components/status_content.jsx | 2 +- .../components/embedded_status_content.tsx | 5 +-- 4 files changed, 42 insertions(+), 32 deletions(-) diff --git a/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx b/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx index abc2aa6cef..9fab899738 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx @@ -1,19 +1,28 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller'; -import { accountFactoryState } from '@/testing/factories'; import { HoverCardController } from '../hover_card_controller'; import type { HandledLinkProps } from './handled_link'; import { HandledLink } from './handled_link'; +type HandledLinkStoryProps = Pick & { + mentionAccount: 'local' | 'remote' | 'none'; +}; + const meta = { title: 'Components/Status/HandledLink', - render(args) { + render({ mentionAccount, ...args }) { + let mention: HandledLinkProps['mention'] | undefined; + if (mentionAccount === 'local') { + mention = { id: '1', acct: 'testuser' }; + } else if (mentionAccount === 'remote') { + mention = { id: '2', acct: 'remoteuser@mastodon.social' }; + } return ( <> - + @@ -22,15 +31,16 @@ const meta = { args: { href: 'https://example.com/path/subpath?query=1#hash', text: 'https://example.com', + mentionAccount: 'none', }, - parameters: { - state: { - accounts: { - '1': accountFactoryState(), - }, + argTypes: { + mentionAccount: { + control: { type: 'select' }, + options: ['local', 'remote', 'none'], + defaultValue: 'none', }, }, -} satisfies Meta>; +} satisfies Meta; export default meta; @@ -47,6 +57,7 @@ export const Hashtag: Story = { export const Mention: Story = { args: { text: '@user', + mentionAccount: 'local', }, }; diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx index c153053b23..d86ec47852 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -1,22 +1,25 @@ import { useCallback } from 'react'; import type { ComponentProps, FC } from 'react'; +import classNames from 'classnames'; import { Link } from 'react-router-dom'; +import type { ApiMentionJSON } from '@/flavours/glitch/api_types/statuses'; import type { OnElementHandler } from '@/flavours/glitch/utils/html'; export interface HandledLinkProps { href: string; text: string; hashtagAccountId?: string; - mentionAccountId?: string; + mention?: Pick; } export const HandledLink: FC> = ({ href, text, hashtagAccountId, - mentionAccountId, + mention, + className, ...props }) => { // Handle hashtags @@ -24,8 +27,7 @@ export const HandledLink: FC> = ({ const hashtag = text.slice(1).trim(); return ( > = ({ #{hashtag} ); - } else if (text.startsWith('@')) { + } else if (text.startsWith('@') && mention) { // Handle mentions - const mention = text.slice(1).trim(); return ( - @{mention} + @{text.slice(1).trim()} ); } @@ -52,7 +52,7 @@ export const HandledLink: FC> = ({ // Non-absolute paths treated as internal links. if (href.startsWith('/')) { return ( - + {text} ); @@ -66,7 +66,7 @@ export const HandledLink: FC> = ({ {...props} href={href} title={href} - className='unhandled-link' + className={classNames('unhandled-link', className)} target='_blank' rel='noreferrer noopener' translate='no' @@ -83,15 +83,15 @@ export const HandledLink: FC> = ({ export const useElementHandledLink = ({ hashtagAccountId, - hrefToMentionAccountId, + hrefToMention, }: { hashtagAccountId?: string; - hrefToMentionAccountId?: (href: string) => string | undefined; + hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( (element, { key, ...props }) => { if (element instanceof HTMLAnchorElement) { - const mentionId = hrefToMentionAccountId?.(element.href); + const mention = hrefToMention?.(element.href); return ( ); } return undefined; }, - [hashtagAccountId, hrefToMentionAccountId], + [hashtagAccountId, hrefToMention], ); return { onElement }; }; diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index d3c48dcbe9..81ce42a0a9 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -310,7 +310,7 @@ class StatusContent extends PureComponent { href={element.href} text={element.innerText} hashtagAccountId={this.props.status.getIn(['account', 'id'])} - mentionAccountId={mention?.get('id')} + mention={mention?.toJSON()} key={key} /> ); diff --git a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx index 3cb1e12ed4..5d9d23e2e9 100644 --- a/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx +++ b/app/javascript/flavours/glitch/features/notifications_v2/components/embedded_status_content.tsx @@ -48,9 +48,8 @@ export const EmbeddedStatusContent: React.FC<{ ); const htmlHandlers = useElementHandledLink({ hashtagAccountId: status.get('account') as string | undefined, - hrefToMentionAccountId(href) { - const mention = mentions.find((item) => item.url === href); - return mention?.id; + hrefToMention(href) { + return mentions.find((item) => item.url === href); }, }); From 3c3a812a9c41ff59275453ef49fc4784a3656755 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 9 Oct 2025 16:08:36 +0200 Subject: [PATCH 10/11] [Glitch] Fix: Embed author handle using wrong DisplayName Port 258869278e8f42ff7c1795eb024c7c2ed6cfebca to glitch-soc Signed-off-by: Claire --- .../explore/components/author_link.jsx | 23 ------------------- .../explore/components/author_link.tsx | 22 ++++++++++++++++++ 2 files changed, 22 insertions(+), 23 deletions(-) delete mode 100644 app/javascript/flavours/glitch/features/explore/components/author_link.jsx create mode 100644 app/javascript/flavours/glitch/features/explore/components/author_link.tsx diff --git a/app/javascript/flavours/glitch/features/explore/components/author_link.jsx b/app/javascript/flavours/glitch/features/explore/components/author_link.jsx deleted file mode 100644 index 9dad72b48a..0000000000 --- a/app/javascript/flavours/glitch/features/explore/components/author_link.jsx +++ /dev/null @@ -1,23 +0,0 @@ -import PropTypes from 'prop-types'; - -import { Avatar } from 'flavours/glitch/components/avatar'; -import { useAppSelector } from 'flavours/glitch/store'; -import { LinkedDisplayName } from '../../../components/display_name'; - -export const AuthorLink = ({ accountId }) => { - const account = useAppSelector(state => state.getIn(['accounts', accountId])); - - if (!account) { - return null; - } - - return ( - - - - ); -}; - -AuthorLink.propTypes = { - accountId: PropTypes.string.isRequired, -}; diff --git a/app/javascript/flavours/glitch/features/explore/components/author_link.tsx b/app/javascript/flavours/glitch/features/explore/components/author_link.tsx new file mode 100644 index 0000000000..fefd5803a2 --- /dev/null +++ b/app/javascript/flavours/glitch/features/explore/components/author_link.tsx @@ -0,0 +1,22 @@ +import type { FC } from 'react'; + +import { LinkedDisplayName } from '@/flavours/glitch/components/display_name'; +import { Avatar } from 'flavours/glitch/components/avatar'; +import { useAppSelector } from 'flavours/glitch/store'; + +export const AuthorLink: FC<{ accountId: string }> = ({ accountId }) => { + const account = useAppSelector((state) => state.accounts.get(accountId)); + + if (!account) { + return null; + } + + return ( + + + + ); +}; From 8a50fb02ce46d33439ff0c15533e12bdab140291 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 9 Oct 2025 16:31:13 +0200 Subject: [PATCH 11/11] [Glitch] Fixes handled link formatting Port c858fc77ef194be0217fd98eae84efd261dba798 to glitch-soc Co-authored-by: Claire Signed-off-by: Claire --- .../status/handled_link.stories.tsx | 32 ++++++++++- .../glitch/components/status/handled_link.tsx | 56 +++++++++---------- .../glitch/components/status_content.jsx | 6 +- 3 files changed, 60 insertions(+), 34 deletions(-) diff --git a/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx b/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx index 9fab899738..f29f957dfe 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.stories.tsx @@ -1,19 +1,24 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { HashtagMenuController } from '@/flavours/glitch/features/ui/components/hashtag_menu_controller'; +import { accountFactoryState } from '@/testing/factories'; import { HoverCardController } from '../hover_card_controller'; import type { HandledLinkProps } from './handled_link'; import { HandledLink } from './handled_link'; -type HandledLinkStoryProps = Pick & { +type HandledLinkStoryProps = Pick< + HandledLinkProps, + 'href' | 'text' | 'prevText' +> & { mentionAccount: 'local' | 'remote' | 'none'; + hashtagAccount: boolean; }; const meta = { title: 'Components/Status/HandledLink', - render({ mentionAccount, ...args }) { + render({ mentionAccount, hashtagAccount, ...args }) { let mention: HandledLinkProps['mention'] | undefined; if (mentionAccount === 'local') { mention = { id: '1', acct: 'testuser' }; @@ -22,7 +27,13 @@ const meta = { } return ( <> - + + {args.text} + @@ -32,6 +43,7 @@ const meta = { href: 'https://example.com/path/subpath?query=1#hash', text: 'https://example.com', mentionAccount: 'none', + hashtagAccount: false, }, argTypes: { mentionAccount: { @@ -40,6 +52,13 @@ const meta = { defaultValue: 'none', }, }, + parameters: { + state: { + accounts: { + '1': accountFactoryState({ id: '1', acct: 'hashtaguser' }), + }, + }, + }, } satisfies Meta; export default meta; @@ -48,9 +67,16 @@ type Story = StoryObj; export const Default: Story = {}; +export const Simple: Story = { + args: { + href: 'https://example.com/test', + }, +}; + export const Hashtag: Story = { args: { text: '#example', + hashtagAccount: true, }, }; diff --git a/app/javascript/flavours/glitch/components/status/handled_link.tsx b/app/javascript/flavours/glitch/components/status/handled_link.tsx index d86ec47852..dfc81cb96b 100644 --- a/app/javascript/flavours/glitch/components/status/handled_link.tsx +++ b/app/javascript/flavours/glitch/components/status/handled_link.tsx @@ -10,6 +10,7 @@ import type { OnElementHandler } from '@/flavours/glitch/utils/html'; export interface HandledLinkProps { href: string; text: string; + prevText?: string; hashtagAccountId?: string; mention?: Pick; } @@ -17,13 +18,15 @@ export interface HandledLinkProps { export const HandledLink: FC> = ({ href, text, + prevText, hashtagAccountId, mention, className, + children, ...props }) => { // Handle hashtags - if (text.startsWith('#')) { + if (text.startsWith('#') || prevText?.endsWith('#')) { const hashtag = text.slice(1).trim(); return ( > = ({ rel='tag' data-menu-hashtag={hashtagAccountId} > - #{hashtag} + {children} ); - } else if (text.startsWith('@') && mention) { + } else if ((text.startsWith('@') || prevText?.endsWith('@')) && mention) { // Handle mentions return ( > = ({ title={`@${mention.acct}`} data-hover-card-account={mention.id} > - @{text.slice(1).trim()} + {children} ); } - // Non-absolute paths treated as internal links. + // Non-absolute paths treated as internal links. This shouldn't happen, but just in case. if (href.startsWith('/')) { return ( - {text} + {children} ); } - try { - const url = new URL(href); - const [first, ...rest] = url.pathname.split('/').slice(1); // Start at 1 to skip the leading slash. - return ( - - {url.protocol + '//'} - {`${url.hostname}/${first ?? ''}`} - {'/' + rest.join('/')} - - ); - } catch { - return text; - } + return ( + + {children} + + ); }; export const useElementHandledLink = ({ @@ -89,7 +84,7 @@ export const useElementHandledLink = ({ hrefToMention?: (href: string) => ApiMentionJSON | undefined; } = {}) => { const onElement = useCallback( - (element, { key, ...props }) => { + (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = hrefToMention?.(element.href); return ( @@ -98,9 +93,12 @@ export const useElementHandledLink = ({ key={key as string} // React requires keys to not be part of spread props. href={element.href} text={element.innerText} + prevText={element.previousSibling?.textContent ?? undefined} hashtagAccountId={hashtagAccountId} mention={mention} - /> + > + {children} + ); } return undefined; diff --git a/app/javascript/flavours/glitch/components/status_content.jsx b/app/javascript/flavours/glitch/components/status_content.jsx index 81ce42a0a9..6f7b9e0fdf 100644 --- a/app/javascript/flavours/glitch/components/status_content.jsx +++ b/app/javascript/flavours/glitch/components/status_content.jsx @@ -301,7 +301,7 @@ class StatusContent extends PureComponent { this.node = c; }; - handleElement = (element, { key, ...props }) => { + handleElement = (element, { key, ...props }, children) => { if (element instanceof HTMLAnchorElement) { const mention = this.props.status.get('mentions').find(item => element.href === item.get('url')); return ( @@ -312,7 +312,9 @@ class StatusContent extends PureComponent { hashtagAccountId={this.props.status.getIn(['account', 'id'])} mention={mention?.toJSON()} key={key} - /> + > + {children} + ); } else if (element instanceof HTMLParagraphElement && element.classList.contains('quote-inline')) { return null;