Merge pull request #3428 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 8a0261c51c
This commit is contained in:
Claire
2026-03-04 18:35:14 +01:00
committed by GitHub
31 changed files with 283 additions and 69 deletions

View File

@@ -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

View File

@@ -5,6 +5,7 @@
.filterSelectButton {
appearance: none;
border: none;
color: inherit;
background: none;
padding: 8px 0;
font-size: 15px;

View File

@@ -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();

View File

@@ -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}`;

View File

@@ -5,6 +5,7 @@
.filterSelectButton {
appearance: none;
border: none;
color: inherit;
background: none;
padding: 8px 0;
font-size: 15px;

View File

@@ -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();

View File

@@ -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}`;

View File

@@ -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",

View File

@@ -1074,7 +1074,7 @@
"sign_in_banner.sign_in": "Kirjaudu",
"sign_in_banner.sso_redirect": "Kirjaudu tai rekisteröidy",
"skip_links.hotkey": "<span>Pikanäppäin</span> {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ä",

View File

@@ -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": "<span>Snarknappur</span> {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",

View File

@@ -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 sinscrire",
"skip_links.hotkey": "<span>Raccourci</span> {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 linterface de modération pour @{name}",
"status.admin_domain": "Ouvrir linterface de modération pour {domain}",
"status.admin_status": "Ouvrir ce message dans linterface de modération",

View File

@@ -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 sinscrire",
"skip_links.hotkey": "<span>Raccourci</span> {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 linterface de modération pour @{name}",
"status.admin_domain": "Ouvrir linterface de modération pour {domain}",
"status.admin_status": "Ouvrir ce message dans linterface de modération",

View File

@@ -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",

View File

@@ -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": "가입 후 월별 사용자 유지율",

View File

@@ -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",

View File

@@ -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": "<span>Kısayol tuşu</span> {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",

View File

@@ -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": "<span>快捷键</span> {hotkey}",
"skip_links.skip_to_content": "跳转到主内容",
"skip_links.skip_to_navigation": "跳转到主导航",
"status.admin_account": "打开 @{name} 的管理界面",
"status.admin_domain": "打开 {domain} 的管理界面",
"status.admin_status": "在管理界面查看此嘟文",

View File

@@ -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

View File

@@ -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],

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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) }

View File

@@ -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 }

View File

@@ -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

View File

@@ -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

View File

@@ -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