diff --git a/Gemfile.lock b/Gemfile.lock index 7f9f82cb39..ee036b208b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -294,7 +294,7 @@ GEM activesupport (>= 5.1) haml (>= 4.0.6) railties (>= 5.1) - haml_lint (0.71.0) + haml_lint (0.72.0) haml (>= 5.0) parallel (~> 1.10) rainbow @@ -628,7 +628,7 @@ GEM psych (5.3.1) date stringio - public_suffix (7.0.2) + public_suffix (7.0.5) puma (7.2.0) nio4r (~> 2.0) pundit (2.5.2) @@ -892,7 +892,7 @@ GEM unf (~> 0.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - tzinfo-data (1.2025.3) + tzinfo-data (1.2026.1) tzinfo (>= 1.0.0) unf (0.1.4) unf_ext diff --git a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss index 2ef62a7d25..b39892beec 100644 --- a/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss +++ b/app/javascript/mastodon/features/account_timeline/v2/styles.module.scss @@ -5,6 +5,7 @@ .filterSelectButton { appearance: none; border: none; + color: inherit; background: none; padding: 8px 0; font-size: 15px; diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index d2317e716f..0fc13c8f79 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -105,8 +105,8 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ ); }, [collection, dispatch]); - const location = useLocation<{ newCollection?: boolean }>(); - const wasJustCreated = location.state.newCollection; + const location = useLocation<{ newCollection?: boolean } | undefined>(); + const wasJustCreated = location.state?.newCollection; useEffect(() => { if (wasJustCreated) { handleShare(); diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx index 137794d95b..0f4681d077 100644 --- a/app/javascript/mastodon/features/collections/detail/share_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -40,8 +40,8 @@ export const CollectionShareModal: React.FC<{ }> = ({ collection, onClose }) => { const intl = useIntl(); const dispatch = useAppDispatch(); - const location = useLocation<{ newCollection?: boolean }>(); - const isNew = !!location.state.newCollection; + const location = useLocation<{ newCollection?: boolean } | undefined>(); + const isNew = !!location.state?.newCollection; const isOwnCollection = collection.account_id === me; const collectionLink = `${window.location.origin}/collections/${collection.id}`; diff --git a/app/javascript/mastodon/locales/da.json b/app/javascript/mastodon/locales/da.json index 34215c3d6d..d313e0001e 100644 --- a/app/javascript/mastodon/locales/da.json +++ b/app/javascript/mastodon/locales/da.json @@ -536,7 +536,7 @@ "empty_column.follow_requests": "Du har endnu ingen følgeanmodninger. Når du modtager én, vil den dukke op her.", "empty_column.followed_tags": "Ingen hashtags følges endnu. Når det sker, vil de fremgå her.", "empty_column.hashtag": "Der er intet med dette hashtag endnu.", - "empty_column.home": "Din hjem-tidslinje er tom! Følg nogle personer, for at fylde den op.", + "empty_column.home": "Din hjem-tidslinje er tom! Følg flere personer, for at fylde den op.", "empty_column.list": "Der er ikke noget på denne liste endnu. Når medlemmer af denne liste udgiver nye indlæg, vil de blive vist her.", "empty_column.mutes": "Du har endnu ikke skjult nogle brugere.", "empty_column.notification_requests": "Alt er klar! Der er intet her. Når der modtages nye notifikationer, fremgår de her jævnfør dine indstillinger.", @@ -683,7 +683,7 @@ "keyboard_shortcuts.direct": "Åbn kolonne med private omtaler", "keyboard_shortcuts.down": "Flyt nedad på listen", "keyboard_shortcuts.enter": "Åbn indlæg", - "keyboard_shortcuts.explore": "Åbn Trender-tidslinjen", + "keyboard_shortcuts.explore": "Åbn trender-tidslinjen", "keyboard_shortcuts.favourite": "Føj indlæg til favoritter", "keyboard_shortcuts.favourites": "Åbn favoritlisten", "keyboard_shortcuts.federated": "Åbn fødereret tidslinje", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 544344cfa6..55bf3daee7 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -1074,7 +1074,7 @@ "sign_in_banner.sign_in": "Kirjaudu", "sign_in_banner.sso_redirect": "Kirjaudu tai rekisteröidy", "skip_links.hotkey": "Pikanäppäin {hotkey}", - "skip_links.skip_to_content": "Siitty pääsisältöön", + "skip_links.skip_to_content": "Siirry pääsisältöön", "skip_links.skip_to_navigation": "Siirry päänavigaatioon", "status.admin_account": "Avaa tilin @{name} moderointinäkymä", "status.admin_domain": "Avaa palvelimen {domain} moderointinäkymä", diff --git a/app/javascript/mastodon/locales/fo.json b/app/javascript/mastodon/locales/fo.json index 10f727109e..e6810d4e97 100644 --- a/app/javascript/mastodon/locales/fo.json +++ b/app/javascript/mastodon/locales/fo.json @@ -173,6 +173,7 @@ "account_edit.profile_tab.subtitle": "Tillaga spjøldrini á vanganum hjá tær og tað, tey vísa.", "account_edit.profile_tab.title": "Stillingar fyri spjøldur á vanga", "account_edit.save": "Goym", + "account_edit_tags.add_tag": "Legg #{tagName} afturat", "account_edit_tags.column_title": "Rætta sermerkt frámerki", "account_edit_tags.help_text": "Sermerkt frámerki hjálpa brúkarum at varnast og virka saman við vanga tínum. Tey síggjast sum filtur á virksemisvísingini av vanga tínum.", "account_edit_tags.search_placeholder": "Áset eitt frámerki…", @@ -682,6 +683,7 @@ "keyboard_shortcuts.direct": "Lat teigin við privatum umrøðum upp", "keyboard_shortcuts.down": "Flyt niðureftir listanum", "keyboard_shortcuts.enter": "Opna uppslag", + "keyboard_shortcuts.explore": "Lat rás við vælumtóktum postum upp", "keyboard_shortcuts.favourite": "Dáma post", "keyboard_shortcuts.favourites": "Lat listan av dámdum postum upp", "keyboard_shortcuts.federated": "Lat felags tíðslinju upp", @@ -1071,6 +1073,9 @@ "sign_in_banner.mastodon_is": "Mastodon er best mátin at fylgja við í tí, sum hendir.", "sign_in_banner.sign_in": "Rita inn", "sign_in_banner.sso_redirect": "Rita inn ella Skráset teg", + "skip_links.hotkey": "Snarknappur {hotkey}", + "skip_links.skip_to_content": "Far til høvuðsinnihald", + "skip_links.skip_to_navigation": "Far til høvuðs-navigatión", "status.admin_account": "Lat kjakleiðaramarkamót upp fyri @{name}", "status.admin_domain": "Lat umsjónarmarkamót upp fyri {domain}", "status.admin_status": "Lat hendan postin upp í kjakleiðaramarkamótinum", diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json index 5e753c0e5c..85970ed2fe 100644 --- a/app/javascript/mastodon/locales/fr-CA.json +++ b/app/javascript/mastodon/locales/fr-CA.json @@ -173,6 +173,7 @@ "account_edit.profile_tab.subtitle": "Personnaliser les onglets de votre profil et leur contenu.", "account_edit.profile_tab.title": "Paramètres de l'onglet du profil", "account_edit.save": "Enregistrer", + "account_edit_tags.add_tag": "Ajouter #{tagName}", "account_edit_tags.column_title": "Modifier les hashtags mis en avant", "account_edit_tags.help_text": "Les hashtags mis en avant aident les personnes à découvrir et interagir avec votre profil. Ils apparaissent comme des filtres dans la vue « Activité » de votre profil.", "account_edit_tags.search_placeholder": "Saisir un hashtag…", @@ -1073,6 +1074,8 @@ "sign_in_banner.sign_in": "Se connecter", "sign_in_banner.sso_redirect": "Se connecter ou s’inscrire", "skip_links.hotkey": "Raccourci {hotkey}", + "skip_links.skip_to_content": "Accéder au contenu principal", + "skip_links.skip_to_navigation": "Accéder à la navigation principale", "status.admin_account": "Ouvrir l’interface de modération pour @{name}", "status.admin_domain": "Ouvrir l’interface de modération pour {domain}", "status.admin_status": "Ouvrir ce message dans l’interface de modération", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index d52c9c6f34..5f32e9361c 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -173,6 +173,7 @@ "account_edit.profile_tab.subtitle": "Personnaliser les onglets de votre profil et leur contenu.", "account_edit.profile_tab.title": "Paramètres de l'onglet du profil", "account_edit.save": "Enregistrer", + "account_edit_tags.add_tag": "Ajouter #{tagName}", "account_edit_tags.column_title": "Modifier les hashtags mis en avant", "account_edit_tags.help_text": "Les hashtags mis en avant aident les personnes à découvrir et interagir avec votre profil. Ils apparaissent comme des filtres dans la vue « Activité » de votre profil.", "account_edit_tags.search_placeholder": "Saisir un hashtag…", @@ -1073,6 +1074,8 @@ "sign_in_banner.sign_in": "Se connecter", "sign_in_banner.sso_redirect": "Se connecter ou s’inscrire", "skip_links.hotkey": "Raccourci {hotkey}", + "skip_links.skip_to_content": "Accéder au contenu principal", + "skip_links.skip_to_navigation": "Accéder à la navigation principale", "status.admin_account": "Ouvrir l’interface de modération pour @{name}", "status.admin_domain": "Ouvrir l’interface de modération pour {domain}", "status.admin_status": "Ouvrir ce message dans l’interface de modération", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 6e2b30eb51..50efbb4655 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -44,9 +44,11 @@ "account.familiar_followers_two": "{name1} és {name2} követi", "account.featured": "Kiemelt", "account.featured.accounts": "Profilok", + "account.featured.collections": "Gyűjtemények", "account.featured.hashtags": "Hashtagek", "account.featured_tags.last_status_at": "Legutolsó bejegyzés ideje: {date}", "account.featured_tags.last_status_never": "Nincs bejegyzés", + "account.field_overflow": "Teljes tartalom megjelenítése", "account.filters.all": "Összes tevékenység", "account.filters.boosts_toggle": "Megtolások megjelenítése", "account.filters.posts_boosts": "Bejegyzések és megtolások", @@ -144,17 +146,30 @@ "account_edit.bio.title": "Bemutatkozás", "account_edit.bio_modal.add_title": "Bemutatkozás hozzáadása", "account_edit.bio_modal.edit_title": "Bemutatkozás szerkesztése", + "account_edit.button.add": "{item} hozzáadása", + "account_edit.button.delete": "{item} törlése", + "account_edit.button.edit": "{item} szerkesztése", "account_edit.char_counter": "{currentLength}/{maxLength} karakter", "account_edit.column_button": "Kész", "account_edit.column_title": "Profil szerkesztése", "account_edit.custom_fields.title": "Egyéni mezők", "account_edit.display_name.placeholder": "A megjelenítendő név az, ahogy a neved megjelenik a profilodon és az idővonalakon.", "account_edit.display_name.title": "Megjelenítendő név", + "account_edit.featured_hashtags.item": "hashtagek", + "account_edit.featured_hashtags.placeholder": "Segíts másoknak, hogy azonosíthassák a kedvenc témáid, és gyorsan elérjék azokat.", "account_edit.featured_hashtags.title": "Kiemelt hashtagek", "account_edit.name_modal.add_title": "Megjelenítendő név hozzáadása", "account_edit.name_modal.edit_title": "Megjelenítendő név szerkesztése", + "account_edit.profile_tab.button_label": "Testreszabás", + "account_edit.profile_tab.hint.description": "Ezek a beállítások szabják testre, hogy a felhasználók mit látnak a(z) {server} kiszolgálón a hivatalos alkalmazásokban, de nem biztos, hogy a külső kiszolgálókon vagy alkalmazásokban is érvényesek lesznek.", + "account_edit.profile_tab.hint.title": "A megjelenítés eltérő lehet", + "account_edit.profile_tab.show_featured.description": "A „Kiemelt” egy nem kötelező lap, ahol más fiókokat mutathatsz be.", + "account_edit.profile_tab.show_featured.title": "„Kiemelt” lap megjelenítése", + "account_edit.profile_tab.show_media.description": "A „Média” egy nem kötelező lap, amely a képeket vagy videókat tartalmazó bejegyzéseidet jeleníti meg.", + "account_edit.profile_tab.show_media.title": "„Média” lap megjelenítése", "account_edit.profile_tab.title": "Profil lap beállításai", "account_edit.save": "Mentés", + "account_edit_tags.add_tag": "#{tagName} hozzáadása", "account_note.placeholder": "Kattintás jegyzet hozzáadásához", "admin.dashboard.daily_retention": "Napi regisztráció utáni felhasználómegtartási arány", "admin.dashboard.monthly_retention": "Havi regisztráció utáni felhasználómegtartási arány", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index b08775be97..aece536ee4 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -47,8 +47,12 @@ "account.featured.hashtags": "해시태그", "account.featured_tags.last_status_at": "{date}에 마지막으로 게시", "account.featured_tags.last_status_never": "게시물 없음", + "account.field_overflow": "내용 전체 보기", "account.filters.all": "모든 활동", "account.filters.boosts_toggle": "부스트 보기", + "account.filters.posts_boosts": "게시물과 부스트", + "account.filters.posts_only": "게시물", + "account.filters.posts_replies": "게시물과 답장", "account.filters.replies_toggle": "답글 보기", "account.follow": "팔로우", "account.follow_back": "맞팔로우", @@ -68,14 +72,23 @@ "account.go_to_profile": "프로필로 이동", "account.hide_reblogs": "@{name}의 부스트를 숨기기", "account.in_memoriam": "고인의 계정입니다.", + "account.joined_long": "{date}에 가입함", "account.joined_short": "가입", "account.languages": "구독한 언어 변경", "account.link_verified_on": "{date}에 이 링크의 소유권이 확인 됨", "account.locked_info": "이 계정의 프라이버시 설정은 잠금으로 설정되어 있습니다. 계정 소유자가 수동으로 팔로워를 승인합니다.", "account.media": "미디어", "account.mention": "@{name} 님에게 멘션", + "account.menu.add_to_list": "리스트에 추가…", + "account.menu.block": "계정 차단", + "account.menu.block_domain": "{domain} 차단", + "account.menu.copied": "계정 링크를 복사했습니다", "account.menu.copy": "링크 복사하기", "account.menu.mention": "멘션", + "account.menu.note.description": "나에게만 보입니다", + "account.menu.open_original_page": "{domain}에서 보기", + "account.menu.remove_follower": "팔로워 제거", + "account.menu.report": "계정 신고", "account.menu.share": "공유하기…", "account.moved_to": "{name} 님은 자신의 새 계정이 다음과 같다고 표시했습니다:", "account.mute": "@{name} 뮤트", @@ -85,6 +98,9 @@ "account.muting": "뮤트함", "account.mutual": "서로 팔로우", "account.no_bio": "제공된 설명이 없습니다.", + "account.node_modal.save": "저장", + "account.node_modal.title": "개인 메모 추가", + "account.note.edit_button": "편집", "account.open_original_page": "원본 페이지 열기", "account.posts": "게시물", "account.posts_with_replies": "게시물과 답장", @@ -104,6 +120,15 @@ "account.unmute": "@{name} 뮤트 해제", "account.unmute_notifications_short": "알림 뮤트 해제", "account.unmute_short": "뮤트 해제", + "account_edit.bio.title": "자기소개", + "account_edit.bio_modal.add_title": "자기소개 추가", + "account_edit.bio_modal.edit_title": "자기소개 편집", + "account_edit.button.add": "{item} 추가", + "account_edit.button.delete": "{item} 제거", + "account_edit.button.edit": "{item} 편집", + "account_edit.char_counter": "{currentLength}/{maxLength} 글자", + "account_edit.column_button": "완료", + "account_edit.column_title": "프로필 편집", "account_note.placeholder": "클릭하여 노트 추가", "admin.dashboard.daily_retention": "가입 후 일별 사용자 유지율", "admin.dashboard.monthly_retention": "가입 후 월별 사용자 유지율", diff --git a/app/javascript/mastodon/locales/pt-PT.json b/app/javascript/mastodon/locales/pt-PT.json index 56f3149fdb..72cd874251 100644 --- a/app/javascript/mastodon/locales/pt-PT.json +++ b/app/javascript/mastodon/locales/pt-PT.json @@ -161,7 +161,11 @@ "account_edit.featured_hashtags.title": "Etiquetas em destaque", "account_edit.name_modal.add_title": "Adicionar nome a mostrar", "account_edit.name_modal.edit_title": "Editar o nome a mostrar", + "account_edit.profile_tab.button_label": "Personalizar", + "account_edit.profile_tab.hint.description": "Estas configurações personalizam o que os utilizadores veem no {server} nas aplicações oficiais, mas podem não se aplicar aos utilizadores de outros servidores nem aplicações de terceiros.", + "account_edit.profile_tab.hint.title": "A apresentação ainda pode variar", "account_edit.save": "Guardar", + "account_edit_tags.add_tag": "Adicionar #{tagName}", "account_edit_tags.column_title": "Editar etiquetas em destaque", "account_edit_tags.help_text": "As etiquetas destacadas ajudam os utilizadores a descobrir e interagir com o seu perfil. Aparecem como filtros na vista de atividade da sua página de perfil.", "account_edit_tags.search_placeholder": "Insira uma etiqueta…", @@ -269,6 +273,12 @@ "closed_registrations_modal.find_another_server": "Procurar outro servidor", "closed_registrations_modal.preamble": "O Mastodon é descentralizado, por isso não importa onde a tua conta é criada, pois continuarás a poder acompanhar e interagir com qualquer um neste servidor. Podes até alojar o teu próprio servidor!", "closed_registrations_modal.title": "Criar uma conta no Mastodon", + "collection.share_modal.share_via_post": "Publicar no Mastodon", + "collection.share_modal.share_via_system": "Compartilhar com…", + "collection.share_modal.title": "Partilhar coleção", + "collection.share_modal.title_new": "Partilhe a sua nova coleção!", + "collection.share_template_other": "Veja esta coleção interessante: {link}", + "collection.share_template_own": "Veja a minha nova coleção: {link}", "collections.account_count": "{count, plural, one {# conta} other {# contas}}", "collections.accounts.empty_description": "Adicione até {count} contas que segue", "collections.accounts.empty_title": "Esta coleção está vazia", @@ -287,6 +297,8 @@ "collections.delete_collection": "Eliminar coleção", "collections.description_length_hint": "Limite de 100 caracteres", "collections.detail.accounts_heading": "Contas", + "collections.detail.curated_by_author": "Curado por {author}", + "collections.detail.curated_by_you": "Curado por si", "collections.detail.loading": "A carregar a coleção…", "collections.detail.share": "Partilhar esta coleção", "collections.edit_details": "Editar detalhes", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 2ad5a23d50..55cc5daef6 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -173,6 +173,7 @@ "account_edit.profile_tab.subtitle": "Profilinizdeki sekmeleri ve bunların görüntülediği bilgileri özelleştirin.", "account_edit.profile_tab.title": "Profil sekme ayarları", "account_edit.save": "Kaydet", + "account_edit_tags.add_tag": "#{tagName} ekle", "account_edit_tags.column_title": "Öne çıkarılmış etiketleri düzenle", "account_edit_tags.help_text": "Öne çıkan etiketler kullanıcıların profilinizi keşfetmesine ve etkileşim kurmasına yardımcı olur. Profil sayfanızın Etkinlik görünümünde filtreler olarak görünürler.", "account_edit_tags.search_placeholder": "Bir etiket girin…", @@ -682,6 +683,7 @@ "keyboard_shortcuts.direct": "Özel bahsetmeler sütununu aç", "keyboard_shortcuts.down": "Listede aşağıya inmek için", "keyboard_shortcuts.enter": "Gönderiyi açınız", + "keyboard_shortcuts.explore": "Öne çıkanlar zaman çizelgesini aç", "keyboard_shortcuts.favourite": "Gönderiyi favorilerine ekle", "keyboard_shortcuts.favourites": "Gözde listeni aç", "keyboard_shortcuts.federated": "Federe akışı aç", @@ -1071,6 +1073,9 @@ "sign_in_banner.mastodon_is": "Neler olup bittiğini izlemenin en iyi aracı Mastodon'dur.", "sign_in_banner.sign_in": "Giriş yap", "sign_in_banner.sso_redirect": "Giriş yap veya kaydol", + "skip_links.hotkey": "Kısayol tuşu {hotkey}", + "skip_links.skip_to_content": "Ana içeriğe git", + "skip_links.skip_to_navigation": "Ana gezinmeye git", "status.admin_account": "@{name} için denetim arayüzünü açın", "status.admin_domain": "{domain} için denetim arayüzünü açın", "status.admin_status": "Denetim arayüzünde bu gönderiyi açın", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index be8a700c03..047bc0df9a 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -173,6 +173,7 @@ "account_edit.profile_tab.subtitle": "自定义你个人资料的标签页及其显示的内容。", "account_edit.profile_tab.title": "个人资料标签页设置", "account_edit.save": "保存", + "account_edit_tags.add_tag": "添加 #{tagName}", "account_edit_tags.column_title": "编辑精选话题标签", "account_edit_tags.help_text": "精选话题标签可以帮助他人发现并与你的个人资料互动。这些标签会作为过滤器条件出现在你个人资料页面的活动视图中。", "account_edit_tags.search_placeholder": "输入话题标签…", @@ -682,6 +683,7 @@ "keyboard_shortcuts.direct": "打开私下提及栏", "keyboard_shortcuts.down": "在列表中让光标下移", "keyboard_shortcuts.enter": "展开嘟文", + "keyboard_shortcuts.explore": "打开当前热门时间线", "keyboard_shortcuts.favourite": "喜欢嘟文", "keyboard_shortcuts.favourites": "打开喜欢列表", "keyboard_shortcuts.federated": "打开跨站时间线", @@ -1071,6 +1073,9 @@ "sign_in_banner.mastodon_is": "Mastodon 是了解最新动态的最佳途径。", "sign_in_banner.sign_in": "登录", "sign_in_banner.sso_redirect": "登录或注册", + "skip_links.hotkey": "快捷键 {hotkey}", + "skip_links.skip_to_content": "跳转到主内容", + "skip_links.skip_to_navigation": "跳转到主导航", "status.admin_account": "打开 @{name} 的管理界面", "status.admin_domain": "打开 {domain} 的管理界面", "status.admin_status": "在管理界面查看此嘟文", diff --git a/app/lib/status_cache_hydrator.rb b/app/lib/status_cache_hydrator.rb index b830e509bf..1f1184d42f 100644 --- a/app/lib/status_cache_hydrator.rb +++ b/app/lib/status_cache_hydrator.rb @@ -75,6 +75,8 @@ class StatusCacheHydrator end end + payload[:card][:missing_attribution] = status.preview_card.unverified_author_account_id == account_id if payload[:card] + # Nested statuses are more likely to have a stale cache fill_status_stats(payload, status) if nested end diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb index 644be2671a..4c8b52a8d5 100644 --- a/app/models/preview_card.rb +++ b/app/models/preview_card.rb @@ -33,6 +33,7 @@ # published_at :datetime # image_description :string default(""), not null # author_account_id :bigint(8) +# unverified_author_account_id :bigint(8) # class PreviewCard < ApplicationRecord @@ -61,6 +62,7 @@ class PreviewCard < ApplicationRecord has_one :trend, class_name: 'PreviewCardTrend', inverse_of: :preview_card, dependent: :destroy belongs_to :author_account, class_name: 'Account', optional: true + belongs_to :unverified_author_account, class_name: 'Account', optional: true has_attached_file :image, processors: [:lazy_thumbnail, :blurhash_transcoder], diff --git a/app/serializers/rest/preview_card_serializer.rb b/app/serializers/rest/preview_card_serializer.rb index f73a051ac0..3517be619b 100644 --- a/app/serializers/rest/preview_card_serializer.rb +++ b/app/serializers/rest/preview_card_serializer.rb @@ -15,6 +15,8 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer has_many :authors, serializer: AuthorSerializer + attribute :missing_attribution, if: :current_user? + def url object.original_url.presence || object.url end @@ -26,4 +28,12 @@ class REST::PreviewCardSerializer < ActiveModel::Serializer def html Sanitize.fragment(object.html, Sanitize::Config::MASTODON_OEMBED) end + + def missing_attribution + object.unverified_author_account_id.present? && object.unverified_author_account_id == current_user.account_id + end + + def current_user? + !current_user.nil? + end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 84c4ba06f1..53b6861349 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -159,7 +159,14 @@ class FetchLinkCardService < BaseService @card = PreviewCard.find_or_initialize_by(url: link_details_extractor.canonical_url) if link_details_extractor.canonical_url != @card.url @card.assign_attributes(link_details_extractor.to_preview_card_attributes) - @card.author_account = linked_account if linked_account&.can_be_attributed_from?(domain) || provider&.trendable? + + if linked_account.present? + # There is an overlap in the two conditions when `provider` is trendable. This is on purpose to give users + # a heads-up before we remove the `provider&.trendable?` condition. + @card.author_account = linked_account if linked_account.can_be_attributed_from?(domain) || provider&.trendable? + @card.unverified_author_account = linked_account if linked_account.local? && !linked_account.can_be_attributed_from?(domain) + end + @card.save_with_optional_image! unless @card.title.blank? && @card.html.blank? end end diff --git a/app/services/unfollow_service.rb b/app/services/unfollow_service.rb index 1ea8af6992..385aa0c7b1 100644 --- a/app/services/unfollow_service.rb +++ b/app/services/unfollow_service.rb @@ -6,16 +6,16 @@ class UnfollowService < BaseService include Lockable # Unfollow and notify the remote user - # @param [Account] source_account Where to unfollow from - # @param [Account] target_account Which to unfollow + # @param [Account] follower Where to unfollow from + # @param [Account] followee Which to unfollow # @param [Hash] options # @option [Boolean] :skip_unmerge - def call(source_account, target_account, options = {}) - @source_account = source_account - @target_account = target_account - @options = options + def call(follower, followee, options = {}) + @follower = follower + @followee = followee + @options = options - with_redis_lock("relationship:#{[source_account.id, target_account.id].sort.join(':')}") do + with_redis_lock("relationship:#{[follower.id, followee.id].sort.join(':')}") do unfollow! || undo_follow_request! end end @@ -23,19 +23,25 @@ class UnfollowService < BaseService private def unfollow! - follow = Follow.find_by(account: @source_account, target_account: @target_account) - + follow = Follow.find_by(account: @follower, target_account: @followee) return unless follow + # List members are removed immediately with the follow relationship removal, + # so we need to fetch the list IDs first + list_ids = @follower.owned_lists.with_list_account(@followee).pluck(:list_id) unless @options[:skip_unmerge] + follow.destroy! - create_notification(follow) if !@target_account.local? && @target_account.activitypub? - create_reject_notification(follow) if @target_account.local? && !@source_account.local? && @source_account.activitypub? + if @followee.local? && @follower.remote? && @follower.activitypub? + send_reject_follow(follow) + elsif @followee.remote? && @followee.activitypub? + send_undo_follow(follow) + end unless @options[:skip_unmerge] - UnmergeWorker.perform_async(@target_account.id, @source_account.id, 'home') - UnmergeWorker.push_bulk(@source_account.owned_lists.with_list_account(@target_account).pluck(:list_id)) do |list_id| - [@target_account.id, list_id, 'list'] + UnmergeWorker.perform_async(@followee.id, @follower.id, 'home') + UnmergeWorker.push_bulk(list_ids) do |list_id| + [@followee.id, list_id, 'list'] end end @@ -43,22 +49,21 @@ class UnfollowService < BaseService end def undo_follow_request! - follow_request = FollowRequest.find_by(account: @source_account, target_account: @target_account) - + follow_request = FollowRequest.find_by(account: @follower, target_account: @followee) return unless follow_request follow_request.destroy! - create_notification(follow_request) unless @target_account.local? + send_undo_follow(follow_request) unless @followee.local? follow_request end - def create_notification(follow) + def send_undo_follow(follow) ActivityPub::DeliveryWorker.perform_async(build_json(follow), follow.account_id, follow.target_account.inbox_url) end - def create_reject_notification(follow) + def send_reject_follow(follow) ActivityPub::DeliveryWorker.perform_async(build_reject_json(follow), follow.target_account_id, follow.account.inbox_url) end diff --git a/app/services/update_account_service.rb b/app/services/update_account_service.rb index 78a846e03e..fed9d53009 100644 --- a/app/services/update_account_service.rb +++ b/app/services/update_account_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class UpdateAccountService < BaseService + PREVIEW_CARD_REATTRIBUTION_LIMIT = 1_000 + def call(account, params, raise_error: false) was_locked = account.locked update_method = raise_error ? :update! : :update @@ -11,6 +13,7 @@ class UpdateAccountService < BaseService authorize_all_follow_requests(account) if was_locked && !account.locked check_links(account) process_hashtags(account) + process_attribution_domains(account) end rescue Mastodon::DimensionsValidationError, Mastodon::StreamValidationError => e account.errors.add(:avatar, e.message) @@ -36,4 +39,22 @@ class UpdateAccountService < BaseService def process_hashtags(account) account.tags_as_strings = Extractor.extract_hashtags(account.note) end + + def process_attribution_domains(account) + return unless account.attribute_previously_changed?(:attribution_domains) + + # Go through the most recent cards, and do the rest in a background job + preview_cards = PreviewCard.where(unverified_author_account: account).reorder(id: :desc).limit(PREVIEW_CARD_REATTRIBUTION_LIMIT).to_a + should_queue_worker = preview_cards.size == PREVIEW_CARD_REATTRIBUTION_LIMIT + + preview_cards = preview_cards.filter do |preview_card| + account.can_be_attributed_from?(preview_card.domain) + rescue Addressable::URI::InvalidURIError + false + end + + PreviewCard.where(id: preview_cards.pluck(:id), unverified_author_account: account).update_all(author_account_id: account.id, unverified_author_account_id: nil) + + UpdateLinkCardAttributionWorker.perform_async(account.id) if should_queue_worker + end end diff --git a/app/workers/update_link_card_attribution_worker.rb b/app/workers/update_link_card_attribution_worker.rb new file mode 100644 index 0000000000..9b4973aaa5 --- /dev/null +++ b/app/workers/update_link_card_attribution_worker.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class UpdateLinkCardAttributionWorker + include Sidekiq::IterableJob + + def build_enumerator(account_id, cursor:) + @account = Account.find_by(id: account_id) + return if @account.blank? + + scope = PreviewCard.where(unverified_author_account: @account) + active_record_batches_enumerator(scope, cursor:) + end + + def each_iteration(preview_cards, account_id) + preview_cards = preview_cards.filter do |preview_card| + @account.can_be_attributed_from?(preview_card.domain) + rescue Addressable::URI::InvalidURIError + false + end + + PreviewCard.where(id: preview_cards.pluck(:id), unverified_author_account: @account).update_all(author_account_id: account_id, unverified_author_account_id: nil) + end +end diff --git a/db/migrate/20260303144409_add_unverified_author_account_id_to_preview_cards.rb b/db/migrate/20260303144409_add_unverified_author_account_id_to_preview_cards.rb new file mode 100644 index 0000000000..6ce2cffc6a --- /dev/null +++ b/db/migrate/20260303144409_add_unverified_author_account_id_to_preview_cards.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddUnverifiedAuthorAccountIdToPreviewCards < ActiveRecord::Migration[8.1] + disable_ddl_transaction! + + def change + safety_assured { add_reference :preview_cards, :unverified_author_account, null: true, foreign_key: { to_table: 'accounts', on_delete: :nullify }, index: false } + add_index :preview_cards, [:unverified_author_account_id, :id], algorithm: :concurrently, where: 'unverified_author_account_id IS NOT NULL' + end +end diff --git a/db/schema.rb b/db/schema.rb index 16be435bbc..98cda7ff44 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2026_02_17_154542) do +ActiveRecord::Schema[8.0].define(version: 2026_03_03_144409) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -964,7 +964,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_02_17_154542) do t.datetime "published_at" t.string "image_description", default: "", null: false t.bigint "author_account_id" + t.bigint "unverified_author_account_id" t.index ["author_account_id"], name: "index_preview_cards_on_author_account_id", where: "(author_account_id IS NOT NULL)" + t.index ["unverified_author_account_id", "id"], name: "index_preview_cards_on_unverified_author_account_id_and_id", where: "(unverified_author_account_id IS NOT NULL)" t.index ["url"], name: "index_preview_cards_on_url", unique: true end diff --git a/spec/lib/status_cache_hydrator_spec.rb b/spec/lib/status_cache_hydrator_spec.rb index a6fea36397..3eb781dfba 100644 --- a/spec/lib/status_cache_hydrator_spec.rb +++ b/spec/lib/status_cache_hydrator_spec.rb @@ -19,6 +19,18 @@ RSpec.describe StatusCacheHydrator do end end + context 'when handling a new status with a preview card with unverified account attribution' do + let(:preview_card) { Fabricate(:preview_card, unverified_author_account: account) } + + before do + PreviewCardsStatus.create(status: status, preview_card: preview_card) + end + + it 'renders the same attributes as a full render' do + expect(subject).to eql(compare_to_hash) + end + end + context 'when handling a new status with own poll' do let(:poll) { Fabricate(:poll, account: account) } let(:status) { Fabricate(:status, poll: poll, account: account) } diff --git a/spec/serializers/rest/preview_card_serializer_spec.rb b/spec/serializers/rest/preview_card_serializer_spec.rb index 41ba305b7c..695d02f964 100644 --- a/spec/serializers/rest/preview_card_serializer_spec.rb +++ b/spec/serializers/rest/preview_card_serializer_spec.rb @@ -6,10 +6,16 @@ RSpec.describe REST::PreviewCardSerializer do subject do serialized_record_json( preview_card, - described_class + described_class, + options: { + scope: current_user, + scope_name: :current_user, + } ) end + let(:current_user) { nil } + context 'when preview card does not have author data' do let(:preview_card) { Fabricate.build :preview_card } diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb index 6cf24ca5e1..365468e432 100644 --- a/spec/services/unfollow_service_spec.rb +++ b/spec/services/unfollow_service_spec.rb @@ -5,54 +5,57 @@ require 'rails_helper' RSpec.describe UnfollowService do subject { described_class.new } - let(:sender) { Fabricate(:account, username: 'alice') } + let(:follower) { Fabricate(:account) } + let(:followee) { Fabricate(:account) } - describe 'local' do - let(:bob) { Fabricate(:account, username: 'bob') } + before do + follower.follow!(followee) + end - before { sender.follow!(bob) } + shared_examples 'when the followee is in a list' do + let(:list) { Fabricate(:list, account: follower) } - it 'destroys the following relation' do - subject.call(sender, bob) + before do + list.accounts << followee + end - expect(sender) - .to_not be_following(bob) + it 'schedules removal of posts from this user from the list' do + expect { subject.call(follower, followee) } + .to enqueue_sidekiq_job(UnmergeWorker).with(followee.id, list.id, 'list') end end - describe 'remote ActivityPub', :inline_jobs do - let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } - - before do - sender.follow!(bob) - stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + describe 'a local user unfollowing another local user' do + it 'destroys the following relation and unmerge from home' do + expect { subject.call(follower, followee) } + .to change { follower.following?(followee) }.from(true).to(false) + .and enqueue_sidekiq_job(UnmergeWorker).with(followee.id, follower.id, 'home') end - it 'destroys the following relation and sends unfollow activity' do - subject.call(sender, bob) - - expect(sender) - .to_not be_following(bob) - expect(a_request(:post, 'http://example.com/inbox')) - .to have_been_made.once - end + it_behaves_like 'when the followee is in a list' end - describe 'remote ActivityPub (reverse)', :inline_jobs do - let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + describe 'a local user unfollowing a remote ActivityPub user' do + let(:followee) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } - before do - bob.follow!(sender) - stub_request(:post, 'http://example.com/inbox').to_return(status: 200) + it 'destroys the following relation, unmerge from home and sends undo activity' do + expect { subject.call(follower, followee) } + .to change { follower.following?(followee) }.from(true).to(false) + .and enqueue_sidekiq_job(UnmergeWorker).with(followee.id, follower.id, 'home') + .and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Undo'), follower.id, followee.inbox_url) end - it 'destroys the following relation and sends a reject activity' do - subject.call(bob, sender) + it_behaves_like 'when the followee is in a list' + end - expect(sender) - .to_not be_following(bob) - expect(a_request(:post, 'http://example.com/inbox')) - .to have_been_made.once + describe 'a remote ActivityPub user unfollowing a local user' do + let(:follower) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') } + + it 'destroys the following relation, unmerge from home and sends a reject activity' do + expect { subject.call(follower, followee) } + .to change { follower.following?(followee) }.from(true).to(false) + .and enqueue_sidekiq_job(UnmergeWorker).with(followee.id, follower.id, 'home') + .and enqueue_sidekiq_job(ActivityPub::DeliveryWorker).with(match_json_values(type: 'Reject'), followee.id, follower.inbox_url) end end end diff --git a/spec/services/update_account_service_spec.rb b/spec/services/update_account_service_spec.rb index f9059af07f..d9a66bb24f 100644 --- a/spec/services/update_account_service_spec.rb +++ b/spec/services/update_account_service_spec.rb @@ -33,4 +33,18 @@ RSpec.describe UpdateAccountService do expect(eve).to_not be_requested(account) end end + + describe 'adding domains to attribution_domains' do + let(:account) { Fabricate(:account) } + let!(:preview_card) { Fabricate(:preview_card, url: 'https://writer.example.com/article', unverified_author_account: account, author_account: nil) } + let!(:unattributable_preview_card) { Fabricate(:preview_card, url: 'https://otherwriter.example.com/article', unverified_author_account: account, author_account: nil) } + let!(:unrelated_preview_card) { Fabricate(:preview_card) } + + it 'reattributes expected preview cards' do + expect { subject.call(account, { attribution_domains: ['writer.example.com'] }) } + .to change { preview_card.reload.author_account }.from(nil).to(account) + .and not_change { unattributable_preview_card.reload.author_account } + .and(not_change { unrelated_preview_card.reload.author_account }) + end + end end diff --git a/spec/workers/update_link_card_attribution_worker_spec.rb b/spec/workers/update_link_card_attribution_worker_spec.rb new file mode 100644 index 0000000000..e1726af6d2 --- /dev/null +++ b/spec/workers/update_link_card_attribution_worker_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe UpdateLinkCardAttributionWorker do + let(:worker) { described_class.new } + + let(:account) { Fabricate(:account, attribution_domains: ['writer.example.com']) } + + describe '#perform' do + let!(:preview_card) { Fabricate(:preview_card, url: 'https://writer.example.com/article', unverified_author_account: account, author_account: nil) } + let!(:unattributable_preview_card) { Fabricate(:preview_card, url: 'https://otherwriter.example.com/article', unverified_author_account: account, author_account: nil) } + let!(:unrelated_preview_card) { Fabricate(:preview_card) } + + it 'reattributes expected preview cards' do + expect { worker.perform(account.id) } + .to change { preview_card.reload.author_account }.from(nil).to(account) + .and not_change { unattributable_preview_card.reload.author_account } + .and(not_change { unrelated_preview_card.reload.author_account }) + end + end +end