From 316290ba9d25358f88a9616ba9cbc30b8ccef453 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 11 Mar 2026 08:42:36 +0100 Subject: [PATCH 01/17] Prevent hover card from showing unintentionally (#38112) --- .../components/hover_card_controller.tsx | 35 +++++++++++++------ 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/app/javascript/mastodon/components/hover_card_controller.tsx b/app/javascript/mastodon/components/hover_card_controller.tsx index d9352018bb..a0c704a4e7 100644 --- a/app/javascript/mastodon/components/hover_card_controller.tsx +++ b/app/javascript/mastodon/components/hover_card_controller.tsx @@ -14,6 +14,10 @@ import { useTimeout } from 'mastodon/hooks/useTimeout'; const offset = [-12, 4] as OffsetValue; const enterDelay = 750; const leaveDelay = 150; +// Only open the card if the mouse was moved within this time, +// to avoid triggering the card without intentional mouse movement +// (e.g. when content changed underneath the mouse cursor) +const activeMovementThreshold = 150; const popperConfig = { strategy: 'fixed' } as UsePopperOptions; const isHoverCardAnchor = (element: HTMLElement) => @@ -23,10 +27,10 @@ export const HoverCardController: React.FC = () => { const [open, setOpen] = useState(false); const [accountId, setAccountId] = useState(); const [anchor, setAnchor] = useState(null); - const isUsingTouchRef = useRef(false); const cardRef = useRef(null); const [setLeaveTimeout, cancelLeaveTimeout] = useTimeout(); const [setEnterTimeout, cancelEnterTimeout, delayEnterTimeout] = useTimeout(); + const [setMoveTimeout, cancelMoveTimeout] = useTimeout(); const [setScrollTimeout] = useTimeout(); const handleClose = useCallback(() => { @@ -45,6 +49,8 @@ export const HoverCardController: React.FC = () => { useEffect(() => { let isScrolling = false; + let isUsingTouch = false; + let isActiveMouseMovement = false; let currentAnchor: HTMLElement | null = null; let currentTitle: string | null = null; @@ -66,7 +72,7 @@ export const HoverCardController: React.FC = () => { const handleTouchStart = () => { // Keeping track of touch events to prevent the // hover card from being displayed on touch devices - isUsingTouchRef.current = true; + isUsingTouch = true; }; const handleMouseEnter = (e: MouseEvent) => { @@ -78,13 +84,14 @@ export const HoverCardController: React.FC = () => { return; } - // Bail out if a touch is active - if (isUsingTouchRef.current) { + // Bail out if we're scrolling, a touch is active, + // or if there was no active mouse movement + if (isScrolling || !isActiveMouseMovement || isUsingTouch) { return; } // We've entered an anchor - if (!isScrolling && isHoverCardAnchor(target)) { + if (isHoverCardAnchor(target)) { cancelLeaveTimeout(); currentAnchor?.removeAttribute('aria-describedby'); @@ -99,10 +106,7 @@ export const HoverCardController: React.FC = () => { } // We've entered the hover card - if ( - !isScrolling && - (target === currentAnchor || target === cardRef.current) - ) { + if (target === currentAnchor || target === cardRef.current) { cancelLeaveTimeout(); } }; @@ -141,10 +145,17 @@ export const HoverCardController: React.FC = () => { }; const handleMouseMove = () => { - if (isUsingTouchRef.current) { - isUsingTouchRef.current = false; + if (isUsingTouch) { + isUsingTouch = false; } + delayEnterTimeout(enterDelay); + + cancelMoveTimeout(); + isActiveMouseMovement = true; + setMoveTimeout(() => { + isActiveMouseMovement = false; + }, activeMovementThreshold); }; document.body.addEventListener('touchstart', handleTouchStart, { @@ -188,6 +199,8 @@ export const HoverCardController: React.FC = () => { setOpen, setAccountId, setAnchor, + setMoveTimeout, + cancelMoveTimeout, ]); return ( From 9916c786e67080e71b6f8fc36d11f5d8daa233d9 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 11 Mar 2026 10:42:24 +0100 Subject: [PATCH 02/17] Add fallback to `Object` intent for FEP-3b86 in remote interaction helper (#38130) --- FEDERATION.md | 3 +- .../entrypoints/remote_interaction_helper.ts | 36 +++++++++++-------- 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/FEDERATION.md b/FEDERATION.md index 0ac44afc3c..2d007dada1 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -13,7 +13,8 @@ - [FEP-f1d5: NodeInfo in Fediverse Software](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md) - [FEP-8fcf: Followers collection synchronization across servers](https://codeberg.org/fediverse/fep/src/branch/main/fep/8fcf/fep-8fcf.md) - [FEP-5feb: Search indexing consent for actors](https://codeberg.org/fediverse/fep/src/branch/main/fep/5feb/fep-5feb.md) -- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md): partial support for incoming quote-posts +- [FEP-044f: Consent-respecting quote posts](https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md) +- [FEP-3b86: Activity Intents](https://codeberg.org/fediverse/fep/src/branch/main/fep/3b86/fep-3b86.md): offer handlers for `Object` and `Create` (with support for the `content` parameter only), has support for the `Follow`, `Announce`, `Like` and `Object` intents ## ActivityPub in Mastodon diff --git a/app/javascript/entrypoints/remote_interaction_helper.ts b/app/javascript/entrypoints/remote_interaction_helper.ts index 26f9e1f4e0..093f6a7ec2 100644 --- a/app/javascript/entrypoints/remote_interaction_helper.ts +++ b/app/javascript/entrypoints/remote_interaction_helper.ts @@ -39,26 +39,27 @@ const findLink = (rel: string, data: unknown): JRDLink | undefined => { } }; -const intentParams = (intent: string) => { +const intentParams = (intent: string): [string, string] | null => { switch (intent) { case 'follow': - return ['https://w3id.org/fep/3b86/Follow', 'object'] as [string, string]; + return ['https://w3id.org/fep/3b86/Follow', 'object']; case 'reblog': - return ['https://w3id.org/fep/3b86/Announce', 'object'] as [ - string, - string, - ]; + return ['https://w3id.org/fep/3b86/Announce', 'object']; case 'favourite': - return ['https://w3id.org/fep/3b86/Like', 'object'] as [string, string]; + return ['https://w3id.org/fep/3b86/Like', 'object']; case 'vote': case 'reply': - return ['https://w3id.org/fep/3b86/Object', 'object'] as [string, string]; + return ['https://w3id.org/fep/3b86/Object', 'object']; default: return null; } }; -const findTemplateLink = (data: unknown, intent: string) => { +const findTemplateLink = ( + data: unknown, + intent: string, +): [string, string] | [null, null] => { + // Find the FEP-3b86 handler for the specific intent const [needle, param] = intentParams(intent) ?? [ 'http://ostatus.org/schema/1.0/subscribe', 'uri', @@ -66,14 +67,21 @@ const findTemplateLink = (data: unknown, intent: string) => { const match = findLink(needle, data); - if (match) { - return [match.template, param] as [string, string]; + if (match?.template) { + return [match.template, param]; } - const fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data); + // If the specific intent wasn't found, try the FEP-3b86 handler for the `Object` intent + let fallback = findLink('https://w3id.org/fep/3b86/Object', data); + if (fallback?.template) { + return [fallback.template, 'object']; + } - if (fallback) { - return [fallback.template, 'uri'] as [string, string]; + // If it's still not found, try the legacy OStatus subscribe handler + fallback = findLink('http://ostatus.org/schema/1.0/subscribe', data); + + if (fallback?.template) { + return [fallback.template, 'uri']; } return [null, null]; From 32fc5304a7176c9383de791ea881803f46da4bf4 Mon Sep 17 00:00:00 2001 From: Claire Date: Wed, 11 Mar 2026 10:49:52 +0100 Subject: [PATCH 03/17] Change HTTP signatures to skip the `Accept` header (#38132) --- app/lib/request.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/request.rb b/app/lib/request.rb index cc741f212d..66d7ece70f 100644 --- a/app/lib/request.rb +++ b/app/lib/request.rb @@ -208,7 +208,7 @@ class Request return end - signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding'), @verb, Addressable::URI.parse(request.uri)) + signature_value = @signing.sign(signed_headers.without('User-Agent', 'Accept-Encoding', 'Accept'), @verb, Addressable::URI.parse(request.uri)) request.headers['Signature'] = signature_value end From 53f4d7f0292a97770081a40a4c3562d414786739 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 11 Mar 2026 05:51:49 -0400 Subject: [PATCH 04/17] Update `RemoteIp` patch with Rails 8.1 changes (#38139) --- lib/action_dispatch/remote_ip_extensions.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/action_dispatch/remote_ip_extensions.rb b/lib/action_dispatch/remote_ip_extensions.rb index e5c48bf3c5..bf78c69439 100644 --- a/lib/action_dispatch/remote_ip_extensions.rb +++ b/lib/action_dispatch/remote_ip_extensions.rb @@ -17,11 +17,11 @@ module ActionDispatch module GetIpExtensions def calculate_ip # Set by the Rack web server, this is a single value. - remote_addr = ips_from(@req.remote_addr).last + remote_addr = sanitize_ips(ips_from(@req.remote_addr)).last # Could be a CSV list and/or repeated headers that were concatenated. - client_ips = ips_from(@req.client_ip).reverse! - forwarded_ips = ips_from(@req.x_forwarded_for).reverse! + client_ips = sanitize_ips(ips_from(@req.client_ip)).reverse! + forwarded_ips = sanitize_ips(@req.forwarded_for || []).reverse! # `Client-Ip` and `X-Forwarded-For` should not, generally, both be set. If they # are both set, it means that either: From 68f4fe74252176d71eb1c63458872d79dafbb64a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:05:54 +0100 Subject: [PATCH 05/17] Update dependency fastimage to v2.4.1 (#38135) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 120b28f757..65abef60ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -246,7 +246,7 @@ GEM faraday-net_http (3.4.2) net-http (~> 0.5) fast_blank (1.0.1) - fastimage (2.4.0) + fastimage (2.4.1) ffi (1.17.3) ffi-compiler (1.3.2) ffi (>= 1.15.5) From d39f8679311c76ddf1ff76cb64fdec1f7ab0c8a7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:16:06 +0100 Subject: [PATCH 06/17] New Crowdin Translations (automated) (#38143) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/fr-CA.json | 8 ++++++++ app/javascript/mastodon/locales/fr.json | 8 ++++++++ app/javascript/mastodon/locales/hu.json | 5 +++++ app/javascript/mastodon/locales/ro.json | 12 ++++++++++++ app/javascript/mastodon/locales/tr.json | 8 ++++++++ 5 files changed, 41 insertions(+) diff --git a/app/javascript/mastodon/locales/fr-CA.json b/app/javascript/mastodon/locales/fr-CA.json index a27b980d3e..6360c49eeb 100644 --- a/app/javascript/mastodon/locales/fr-CA.json +++ b/app/javascript/mastodon/locales/fr-CA.json @@ -338,12 +338,14 @@ "collections.create_collection": "Créer une collection", "collections.delete_collection": "Supprimer la collection", "collections.description_length_hint": "Maximum 100 caractères", + "collections.detail.accept_inclusion": "D'accord", "collections.detail.accounts_heading": "Comptes", "collections.detail.author_added_you": "{author} vous a ajouté·e à cette collection", "collections.detail.curated_by_author": "Organisée par {author}", "collections.detail.curated_by_you": "Organisée par vous", "collections.detail.loading": "Chargement de la collection…", "collections.detail.other_accounts_in_collection": "Autres comptes dans cette collection :", + "collections.detail.revoke_inclusion": "Me retirer", "collections.detail.sensitive_note": "Cette collection contient des comptes et du contenu qui peut être sensibles.", "collections.detail.share": "Partager la collection", "collections.edit_details": "Modifier les détails", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Dernière publication il y a plus d'une semaine", "collections.remove_account": "Supprimer ce compte", "collections.report_collection": "Signaler cette collection", + "collections.revoke_collection_inclusion": "Me retirer de cette collection", + "collections.revoke_inclusion.confirmation": "Vous avez été retiré·e de « {collection} »", + "collections.revoke_inclusion.error": "Une erreur s'est produite, veuillez réessayer plus tard.", "collections.search_accounts_label": "Chercher des comptes à ajouter…", "collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes", "collections.sensitive": "Sensible", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Supprimer l'abonné·e", "confirmations.remove_from_followers.message": "{name} cessera de vous suivre. Voulez-vous vraiment continuer ?", "confirmations.remove_from_followers.title": "Supprimer l'abonné·e ?", + "confirmations.revoke_collection_inclusion.confirm": "Me retirer", + "confirmations.revoke_collection_inclusion.message": "Cette action est permanente, la personne qui gère la collection ne pourra plus vous y rajouter plus tard.", + "confirmations.revoke_collection_inclusion.title": "Vous retirer de cette collection ?", "confirmations.revoke_quote.confirm": "Retirer le message", "confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.", "confirmations.revoke_quote.title": "Retirer le message ?", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index 1bf320aa01..acde4d4a92 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -338,12 +338,14 @@ "collections.create_collection": "Créer une collection", "collections.delete_collection": "Supprimer la collection", "collections.description_length_hint": "Maximum 100 caractères", + "collections.detail.accept_inclusion": "D'accord", "collections.detail.accounts_heading": "Comptes", "collections.detail.author_added_you": "{author} vous a ajouté·e à cette collection", "collections.detail.curated_by_author": "Organisée par {author}", "collections.detail.curated_by_you": "Organisée par vous", "collections.detail.loading": "Chargement de la collection…", "collections.detail.other_accounts_in_collection": "Autres comptes dans cette collection :", + "collections.detail.revoke_inclusion": "Me retirer", "collections.detail.sensitive_note": "Cette collection contient des comptes et du contenu qui peut être sensibles.", "collections.detail.share": "Partager la collection", "collections.edit_details": "Modifier les détails", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Dernière publication il y a plus d'une semaine", "collections.remove_account": "Supprimer ce compte", "collections.report_collection": "Signaler cette collection", + "collections.revoke_collection_inclusion": "Me retirer de cette collection", + "collections.revoke_inclusion.confirmation": "Vous avez été retiré·e de « {collection} »", + "collections.revoke_inclusion.error": "Une erreur s'est produite, veuillez réessayer plus tard.", "collections.search_accounts_label": "Chercher des comptes à ajouter…", "collections.search_accounts_max_reached": "Vous avez ajouté le nombre maximum de comptes", "collections.sensitive": "Sensible", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Supprimer l'abonné·e", "confirmations.remove_from_followers.message": "{name} cessera de vous suivre. Voulez-vous vraiment continuer ?", "confirmations.remove_from_followers.title": "Supprimer l'abonné·e ?", + "confirmations.revoke_collection_inclusion.confirm": "Me retirer", + "confirmations.revoke_collection_inclusion.message": "Cette action est permanente, la personne qui gère la collection ne pourra plus vous y rajouter plus tard.", + "confirmations.revoke_collection_inclusion.title": "Vous retirer de cette collection ?", "confirmations.revoke_quote.confirm": "Retirer le message", "confirmations.revoke_quote.message": "Cette action ne peut pas être annulée.", "confirmations.revoke_quote.title": "Retirer le message ?", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index 665cd22df8..4e3c3fb7df 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -177,7 +177,10 @@ "account_edit.field_edit_modal.value_label": "Érték", "account_edit.field_reorder_modal.drag_cancel": "Az áthúzás megszakítva. A(z) „{item}” mező el lett dobva.", "account_edit.field_reorder_modal.drag_end": "A(z) „{item}” mező el lett dobva.", + "account_edit.field_reorder_modal.drag_instructions": "Az egyéni mezők átrendezéséhez nyomj Szóközt vagy Entert. Húzás közben használd a nyílgombokat a mező felfelé vagy lefelé mozgatásához. A mező új pozícióba helyezéséhez nyomd meg a Szóközt vagy az Entert, vagy a megszakításhoz nyomd meg az Esc gombot.", "account_edit.field_reorder_modal.drag_move": "A(z) „{item}” mező át lett helyezve.", + "account_edit.field_reorder_modal.drag_over": "A(z) „{item}” mező át lett helyezve ennek a helyére: „{over}”.", + "account_edit.field_reorder_modal.drag_start": "A(z) „{item}” mező áthelyezéshez felvéve.", "account_edit.field_reorder_modal.handle_label": "A(z) „{item}” mező húzása", "account_edit.field_reorder_modal.title": "Mezők átrendezése", "account_edit.name_modal.add_title": "Megjelenítendő név hozzáadása", @@ -194,6 +197,8 @@ "account_edit.profile_tab.subtitle": "Szabd testre a profilodon látható lapokat, és a megjelenített tartalmukat.", "account_edit.profile_tab.title": "Profil lap beállításai", "account_edit.save": "Mentés", + "account_edit.verified_modal.details": "Növeld a Mastodon-profilod hitelességét a személyes webhelyekre mutató hivatkozások ellenőrzésével. Így működik:", + "account_edit.verified_modal.invisible_link.details": "A hivatkozás hozzáadása a fejlécedhez. A fontos rész a rel=\"me\", mely megakadályozza, hogy mások a nevedben lépjenek fel olyan oldalakon, ahol van felhasználók által előállított tartalom. A(z) {tag} helyett a „link” címkét is használhatod az oldal fejlécében, de a HTML-nek elérhetőnek kell lennie JavaScript futtatása nélkül is.", "account_edit.verified_modal.invisible_link.summary": "Hogyan lehet egy hivatkozás láthatatlanná tenni?", "account_edit.verified_modal.step1.header": "Másold a lenti HTML-kódot és illeszd be a webhelyed fejlécébe", "account_edit.verified_modal.step2.details": "Ha már egyéni mezőként hozzáadtad a webhelyedet, akkor törölnöd kell, újból hozzá kell adnod, hogy újra ellenőrizve legyen.", diff --git a/app/javascript/mastodon/locales/ro.json b/app/javascript/mastodon/locales/ro.json index bdfbb605d4..fd7dfcc569 100644 --- a/app/javascript/mastodon/locales/ro.json +++ b/app/javascript/mastodon/locales/ro.json @@ -1,6 +1,7 @@ { "about.blocks": "Servere moderate", "about.contact": "Contact:", + "about.default_locale": "Standard", "about.disclaimer": "Mastodon este o aplicație gratuită, cu sursă deschisă și o marcă înregistrată a Mastodon gGmbH.", "about.domain_blocks.no_reason_available": "Motivul nu este disponibil", "about.domain_blocks.preamble": "Mastodon îți permite în general să vezi conținut de la și să interacționezi cu utilizatori de pe oricare server în fediverse. Acestea sunt excepțiile care au fost făcute pe acest server.", @@ -8,22 +9,33 @@ "about.domain_blocks.silenced.title": "Limitat", "about.domain_blocks.suspended.explanation": "Nicio informație de la acest server nu va fi procesată, stocată sau trimisă, făcând imposibilă orice interacțiune sau comunicare cu utilizatorii de pe acest server.", "about.domain_blocks.suspended.title": "Suspendat", + "about.language_label": "Limbă", "about.not_available": "Această informație nu a fost pusă la dispoziție pe acest server.", "about.powered_by": "Media socială descentralizată furnizată de {mastodon}", "about.rules": "Reguli server", "account.account_note_header": "Notă personală", + "account.activity": "Activități", + "account.add_note": "Adaugă o notă personală", "account.add_or_remove_from_list": "Adaugă sau elimină din liste", + "account.badges.admin": "Admin", + "account.badges.blocked": "Blocat", "account.badges.bot": "Robot", + "account.badges.domain_blocked": "Domeniu blocat", "account.badges.group": "Grup", + "account.badges.muted": "Silențios", + "account.badges.muted_until": "Silențios până la {until}", "account.block": "Blochează pe @{name}", "account.block_domain": "Blochează domeniul {domain}", "account.block_short": "Blochează", "account.blocked": "Blocat", + "account.blocking": "Blocarea", "account.cancel_follow_request": "Retrage cererea de urmărire", "account.copy": "Copiază link-ul profilului", "account.direct": "Menționează pe @{name} în privat", "account.disable_notifications": "Nu îmi mai trimite notificări când postează @{name}", + "account.edit_note": "Editare notă personală", "account.edit_profile": "Modifică profilul", + "account.edit_profile_short": "Editare", "account.enable_notifications": "Trimite-mi o notificare când postează @{name}", "account.endorse": "Promovează pe profil", "account.featured_tags.last_status_at": "Ultima postare pe {date}", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index a6d249925a..40ac1f4dee 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -338,12 +338,14 @@ "collections.create_collection": "Koleksiyon oluştur", "collections.delete_collection": "Koleksiyonu sil", "collections.description_length_hint": "100 karakterle sınırlı", + "collections.detail.accept_inclusion": "Tamam", "collections.detail.accounts_heading": "Hesaplar", "collections.detail.author_added_you": "{author} sizi koleksiyonuna ekledi", "collections.detail.curated_by_author": "{author} tarafından derlenen", "collections.detail.curated_by_you": "Sizin derledikleriniz", "collections.detail.loading": "Koleksiyon yükleniyor…", "collections.detail.other_accounts_in_collection": "Bu koleksiyondaki diğer kişiler:", + "collections.detail.revoke_inclusion": "Beni çıkar", "collections.detail.sensitive_note": "Bu koleksiyon bazı kullanıcılar için hassas olabilecek hesap ve içerik içerebilir.", "collections.detail.share": "Bu koleksiyonu paylaş", "collections.edit_details": "Ayrıntıları düzenle", @@ -359,6 +361,9 @@ "collections.old_last_post_note": "Son gönderi bir haftadan önce", "collections.remove_account": "Bu hesabı çıkar", "collections.report_collection": "Bu koleksiyonu bildir", + "collections.revoke_collection_inclusion": "Beni bu koleksiyondan çıkar", + "collections.revoke_inclusion.confirmation": "\"{collection}\" koleksiyonundan çıkarıldınız", + "collections.revoke_inclusion.error": "Bir hata oluştu, lütfen daha sonra tekrar deneyin.", "collections.search_accounts_label": "Eklemek için hesap arayın…", "collections.search_accounts_max_reached": "Maksimum hesabı eklediniz", "collections.sensitive": "Hassas", @@ -482,6 +487,9 @@ "confirmations.remove_from_followers.confirm": "Takipçi kaldır", "confirmations.remove_from_followers.message": "{name} sizi takip etmeyi bırakacaktır. Devam etmek istediğinize emin misiniz?", "confirmations.remove_from_followers.title": "Takipçiyi kaldır?", + "confirmations.revoke_collection_inclusion.confirm": "Beni çıkar", + "confirmations.revoke_collection_inclusion.message": "Bu eylem kalıcıdır ve koleksiyonu derleyen kişi daha sonra sizi koleksiyona tekrar ekleyemeyecektir.", + "confirmations.revoke_collection_inclusion.title": "Kendini bu koleksiyondan çıkar?", "confirmations.revoke_quote.confirm": "Gönderiyi kaldır", "confirmations.revoke_quote.message": "Bu işlem geri alınamaz.", "confirmations.revoke_quote.title": "Gönderiyi silmek ister misiniz?", From d047a10cf5defc4f87093748461e7376c79fc3b0 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 11 Mar 2026 06:18:24 -0400 Subject: [PATCH 07/17] Use `around_action` to set locale in admin/notification mailers (#38140) --- app/mailers/admin_mailer.rb | 34 ++++++++++++------------------ app/mailers/notification_mailer.rb | 30 +++++++++++--------------- 2 files changed, 25 insertions(+), 39 deletions(-) diff --git a/app/mailers/admin_mailer.rb b/app/mailers/admin_mailer.rb index cc2a537b3c..fe2325b6f3 100644 --- a/app/mailers/admin_mailer.rb +++ b/app/mailers/admin_mailer.rb @@ -11,30 +11,26 @@ class AdminMailer < ApplicationMailer after_action :set_important_headers!, only: :new_critical_software_updates + around_action :set_locale + default to: -> { @me.user_email } def new_report(report) @report = report - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance, id: @report.id) - end + mail subject: default_i18n_subject(instance: @instance, id: @report.id) end def new_appeal(appeal) @appeal = appeal - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username) - end + mail subject: default_i18n_subject(instance: @instance, username: @appeal.account.username) end def new_pending_account(user) @account = user.account - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance, username: @account.username) - end + mail subject: default_i18n_subject(instance: @instance, username: @account.username) end def new_trends(links, tags, statuses) @@ -42,31 +38,23 @@ class AdminMailer < ApplicationMailer @tags = tags @statuses = statuses - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end def new_software_updates @software_updates = SoftwareUpdate.by_version - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end def new_critical_software_updates @software_updates = SoftwareUpdate.urgent.by_version - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end def auto_close_registrations - locale_for_account(@me) do - mail subject: default_i18n_subject(instance: @instance) - end + mail subject: default_i18n_subject(instance: @instance) end private @@ -79,6 +67,10 @@ class AdminMailer < ApplicationMailer @instance = Rails.configuration.x.local_domain end + def set_locale(&block) + locale_for_account(@me, &block) + end + def set_important_headers! headers( 'Importance' => 'high', diff --git a/app/mailers/notification_mailer.rb b/app/mailers/notification_mailer.rb index 54dde1bb0d..ecb3750968 100644 --- a/app/mailers/notification_mailer.rb +++ b/app/mailers/notification_mailer.rb @@ -15,6 +15,8 @@ class NotificationMailer < ApplicationMailer before_deliver :verify_functional_user + around_action :set_locale + default to: -> { email_address_with_name(@user.email, @me.username) } layout 'mailer' @@ -22,45 +24,33 @@ class NotificationMailer < ApplicationMailer def mention return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @status.account.acct) - end + mail subject: default_i18n_subject(name: @status.account.acct) end def quote return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @status.account.acct) - end + mail subject: default_i18n_subject(name: @status.account.acct) end def follow - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end def favourite return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end def reblog return if @status.blank? - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end def follow_request - locale_for_account(@me) do - mail subject: default_i18n_subject(name: @account.acct) - end + mail subject: default_i18n_subject(name: @account.acct) end private @@ -81,6 +71,10 @@ class NotificationMailer < ApplicationMailer @account = @notification.from_account end + def set_locale(&block) + locale_for_account(@me, &block) + end + def verify_functional_user throw(:abort) unless @user.functional? end From dc004caf71c30e9b4debebc8b52722a7d37b62aa Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 11 Mar 2026 06:49:07 -0400 Subject: [PATCH 08/17] Convert attempt IP from EmailDomainBlock history tracking to string before recording (#38137) --- app/models/email_domain_block.rb | 2 +- app/models/trends/history.rb | 4 ++-- spec/models/email_domain_block_spec.rb | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/app/models/email_domain_block.rb b/app/models/email_domain_block.rb index 583d2e6c1b..44d6bc6987 100644 --- a/app/models/email_domain_block.rb +++ b/app/models/email_domain_block.rb @@ -59,7 +59,7 @@ class EmailDomainBlock < ApplicationRecord def blocking?(allow_with_approval: false) blocks = EmailDomainBlock.where(domain: domains_with_variants, allow_with_approval: allow_with_approval).by_domain_length - blocks.each { |block| block.history.add(@attempt_ip) } if @attempt_ip.present? + blocks.each { |block| block.history.add(@attempt_ip.to_s) } if @attempt_ip.present? blocks.any? end diff --git a/app/models/trends/history.rb b/app/models/trends/history.rb index 21331f00dc..9e4d173475 100644 --- a/app/models/trends/history.rb +++ b/app/models/trends/history.rb @@ -40,11 +40,11 @@ class Trends::History with_redis { |redis| redis.get(key_for(:uses)).to_i } end - def add(account_id) + def add(value) with_redis do |redis| redis.pipelined do |pipeline| pipeline.incrby(key_for(:uses), 1) - pipeline.pfadd(key_for(:accounts), account_id) + pipeline.pfadd(key_for(:accounts), value) pipeline.expire(key_for(:uses), EXPIRE_AFTER) pipeline.expire(key_for(:accounts), EXPIRE_AFTER) end diff --git a/spec/models/email_domain_block_spec.rb b/spec/models/email_domain_block_spec.rb index c3662b2d6c..5dbc4a5aff 100644 --- a/spec/models/email_domain_block_spec.rb +++ b/spec/models/email_domain_block_spec.rb @@ -56,16 +56,20 @@ RSpec.describe EmailDomainBlock do end describe '.requires_approval?' do - subject { described_class.requires_approval?(input) } + subject { described_class.requires_approval?(input, attempt_ip: IPAddr.new('100.100.100.100')) } let(:input) { nil } context 'with a matching block requiring approval' do - before { Fabricate :email_domain_block, domain: input, allow_with_approval: true } + let!(:email_domain_block) { Fabricate :email_domain_block, domain: input, allow_with_approval: true } let(:input) { 'host.example' } - it { is_expected.to be true } + it 'returns true and records attempt' do + expect do + expect(subject).to be(true) + end.to change { email_domain_block.history.get(Date.current).accounts }.by(1) + end end context 'with a matching block not requiring approval' do From da4b717211656f8710fb7194c538864db589253a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:53:38 +0100 Subject: [PATCH 09/17] Update dependency rspec-rails to v8.0.4 (#38146) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 65abef60ff..68b7cb0a60 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -738,17 +738,17 @@ GEM rspec-support (~> 3.13.0) rspec-github (3.0.0) rspec-core (~> 3.0) - rspec-mocks (3.13.7) + rspec-mocks (3.13.8) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-rails (8.0.3) + rspec-rails (8.0.4) actionpack (>= 7.2) activesupport (>= 7.2) railties (>= 7.2) - rspec-core (~> 3.13) - rspec-expectations (~> 3.13) - rspec-mocks (~> 3.13) - rspec-support (~> 3.13) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) rspec-sidekiq (5.3.0) rspec-core (~> 3.0) rspec-expectations (~> 3.0) From f971670c620db61da4dadff07efe86b51c145bfd Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 11 Mar 2026 13:09:54 +0100 Subject: [PATCH 10/17] Profile editing: Fix bug with reordering (#38147) --- .../features/account_edit/modals/fields_reorder_modal.tsx | 6 ++---- app/javascript/mastodon/reducers/slices/profile_edit.ts | 7 ++++++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx index 5eee431a27..8a94c99ac2 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_reorder_modal.tsx @@ -212,11 +212,9 @@ export const ReorderFieldsModal: FC = ({ onClose }) => { return; } newFields.push({ name: field.name, value: field.value }); - - void dispatch(patchProfile({ fields_attributes: newFields })).then( - onClose, - ); } + + void dispatch(patchProfile({ fields_attributes: newFields })).then(onClose); }, [dispatch, fieldKeys, fields, onClose]); const emojis = useAppSelector((state) => state.custom_emojis); diff --git a/app/javascript/mastodon/reducers/slices/profile_edit.ts b/app/javascript/mastodon/reducers/slices/profile_edit.ts index e4840c642d..62a908e5b1 100644 --- a/app/javascript/mastodon/reducers/slices/profile_edit.ts +++ b/app/javascript/mastodon/reducers/slices/profile_edit.ts @@ -221,7 +221,12 @@ export const patchProfile = createDataLoadingThunk( `${profileEditSlice.name}/patchProfile`, (params: Partial) => apiPatchProfile(params), transformProfile, - { useLoadingBar: false }, + { + useLoadingBar: false, + condition(_, { getState }) { + return !getState().profileEdit.isPending; + }, + }, ); export const selectFieldById = createAppSelector( From 12c6c6dcf9a6875db03dbb9d66eed897c1d964e3 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 11 Mar 2026 14:19:39 +0100 Subject: [PATCH 11/17] Profile editing: Add warning for links (#38148) --- .../account_edit/modals/fields_modals.tsx | 20 +++++++++++++- app/javascript/mastodon/locales/en.json | 3 ++- app/javascript/mastodon/utils/checks.test.ts | 21 +++++++++++++++ app/javascript/mastodon/utils/checks.ts | 26 +++++++++++++++++++ 4 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 app/javascript/mastodon/utils/checks.test.ts diff --git a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx index c7b3b6ebc5..b5a095cf68 100644 --- a/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx +++ b/app/javascript/mastodon/features/account_edit/modals/fields_modals.tsx @@ -18,6 +18,7 @@ import { useAppDispatch, useAppSelector, } from '@/mastodon/store'; +import { isUrlWithoutProtocol } from '@/mastodon/utils/checks'; import { ConfirmationModal } from '../../ui/components/confirmation_modals'; import type { DialogModalProps } from '../../ui/components/dialog_modal'; @@ -48,7 +49,7 @@ const messages = defineMessages({ }, editValueHint: { id: 'account_edit.field_edit_modal.value_hint', - defaultMessage: 'E.g. “example.me”', + defaultMessage: 'E.g. “https://example.me”', }, limitHeader: { id: 'account_edit.field_edit_modal.limit_header', @@ -109,6 +110,10 @@ export const EditFieldModal: FC = ({ ); return hasLink && hasEmoji; }, [customEmojiCodes, newLabel, newValue]); + const hasLinkWithoutProtocol = useMemo( + () => isUrlWithoutProtocol(newValue), + [newValue], + ); const dispatch = useAppDispatch(); const handleSave = useCallback(() => { @@ -175,6 +180,19 @@ export const EditFieldModal: FC = ({ /> )} + + {hasLinkWithoutProtocol && ( + + https://, + }} + /> + + )} ); }; diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 57b868c848..2ede5449d1 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -173,7 +173,8 @@ "account_edit.field_edit_modal.link_emoji_warning": "We recommend against the use of custom emoji in combination with urls. Custom fields containing both will display as text only instead of as a link, in order to prevent user confusion.", "account_edit.field_edit_modal.name_hint": "E.g. “Personal website”", "account_edit.field_edit_modal.name_label": "Label", - "account_edit.field_edit_modal.value_hint": "E.g. “example.me”", + "account_edit.field_edit_modal.url_warning": "To add a link, please include {protocol} at the beginning.", + "account_edit.field_edit_modal.value_hint": "E.g. “https://example.me”", "account_edit.field_edit_modal.value_label": "Value", "account_edit.field_reorder_modal.drag_cancel": "Dragging was cancelled. Field \"{item}\" was dropped.", "account_edit.field_reorder_modal.drag_end": "Field \"{item}\" was dropped.", diff --git a/app/javascript/mastodon/utils/checks.test.ts b/app/javascript/mastodon/utils/checks.test.ts new file mode 100644 index 0000000000..862a2a0abf --- /dev/null +++ b/app/javascript/mastodon/utils/checks.test.ts @@ -0,0 +1,21 @@ +import { isUrlWithoutProtocol } from './checks'; + +describe('isUrlWithoutProtocol', () => { + test.concurrent.each([ + ['example.com', true], + ['sub.domain.co.uk', true], + ['example', false], // No dot + ['example..com', false], // Consecutive dots + ['example.com.', false], // Trailing dot + ['example.c', false], // TLD too short + ['example.123', false], // Numeric TLDs are not valid + ['example.com/path', true], // Paths are allowed + ['example.com?query=string', true], // Query strings are allowed + ['example.com#fragment', true], // Fragments are allowed + ['example .com', false], // Spaces are not allowed + ['example://com', false], // Protocol inside the string is not allowed + ['example.com^', false], // Invalid characters not allowed + ])('should return %s for input "%s"', (input, expected) => { + expect(isUrlWithoutProtocol(input)).toBe(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/checks.ts b/app/javascript/mastodon/utils/checks.ts index 8b05ac24a7..d5d528bdc6 100644 --- a/app/javascript/mastodon/utils/checks.ts +++ b/app/javascript/mastodon/utils/checks.ts @@ -9,3 +9,29 @@ export function isValidUrl( return false; } } + +/** + * Checks if the input string is probably a URL without a protocol. Note this is not full URL validation, + * and is mostly used to detect link-like inputs. + * @see https://www.xjavascript.com/blog/check-if-a-javascript-string-is-a-url/ + * @param input The input string to check + */ +export function isUrlWithoutProtocol(input: string): boolean { + if (!input.length || input.includes(' ') || input.includes('://')) { + return false; + } + + try { + const url = new URL(`http://${input}`); + const { host } = url; + return ( + host !== '' && // Host is not empty + host.includes('.') && // Host contains at least one dot + !host.endsWith('.') && // No trailing dot + !host.includes('..') && // No consecutive dots + /\.[\w]{2,}$/.test(host) // TLD is at least 2 characters + ); + } catch {} + + return false; +} From 20932752fee1dc3da856fdb9fadc8df5aaae23f1 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 11 Mar 2026 14:20:56 +0100 Subject: [PATCH 12/17] Refactor collection editor state handling (#38133) --- .../features/collections/detail/index.tsx | 11 ++- .../collections/detail/share_modal.tsx | 2 +- .../features/collections/editor/accounts.tsx | 86 ++++++++++--------- .../features/collections/editor/details.tsx | 86 ++++++++++--------- .../features/collections/editor/index.tsx | 20 ++++- .../features/collections/editor/state.ts | 52 ----------- .../mastodon/reducers/slices/collections.ts | 65 +++++++++++++- 7 files changed, 181 insertions(+), 141 deletions(-) delete mode 100644 app/javascript/mastodon/features/collections/editor/state.ts diff --git a/app/javascript/mastodon/features/collections/detail/index.tsx b/app/javascript/mastodon/features/collections/detail/index.tsx index 9870e44bc6..8db00e73d3 100644 --- a/app/javascript/mastodon/features/collections/detail/index.tsx +++ b/app/javascript/mastodon/features/collections/detail/index.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect } from 'react'; import { defineMessages, FormattedMessage, useIntl } from 'react-intl'; import { Helmet } from 'react-helmet'; -import { useLocation, useParams } from 'react-router'; +import { useHistory, useLocation, useParams } from 'react-router'; import { openModal } from '@/mastodon/actions/modal'; import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; @@ -84,6 +84,7 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ const intl = useIntl(); const { name, description, tag, account_id } = collection; const dispatch = useAppDispatch(); + const history = useHistory(); const handleShare = useCallback(() => { dispatch( @@ -97,12 +98,14 @@ const CollectionHeader: React.FC<{ collection: ApiCollectionJSON }> = ({ }, [collection, dispatch]); const location = useLocation<{ newCollection?: boolean } | undefined>(); - const wasJustCreated = location.state?.newCollection; + const isNewCollection = location.state?.newCollection; useEffect(() => { - if (wasJustCreated) { + if (isNewCollection) { + // Replace with current pathname to clear `newCollection` state + history.replace(location.pathname); handleShare(); } - }, [handleShare, wasJustCreated]); + }, [history, handleShare, isNewCollection, location.pathname]); return (
diff --git a/app/javascript/mastodon/features/collections/detail/share_modal.tsx b/app/javascript/mastodon/features/collections/detail/share_modal.tsx index 0f4681d077..26bab6abe0 100644 --- a/app/javascript/mastodon/features/collections/detail/share_modal.tsx +++ b/app/javascript/mastodon/features/collections/detail/share_modal.tsx @@ -64,7 +64,7 @@ export const CollectionShareModal: React.FC<{ onClose(); dispatch(changeCompose(shareMessage)); dispatch(focusCompose()); - }, [collectionLink, dispatch, intl, isOwnCollection, onClose]); + }, [onClose, collectionLink, dispatch, intl, isOwnCollection]); return ( diff --git a/app/javascript/mastodon/features/collections/editor/accounts.tsx b/app/javascript/mastodon/features/collections/editor/accounts.tsx index 47af9e211c..423b72e628 100644 --- a/app/javascript/mastodon/features/collections/editor/accounts.tsx +++ b/app/javascript/mastodon/features/collections/editor/accounts.tsx @@ -2,7 +2,7 @@ import { useCallback, useId, useMemo, useState } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import CancelIcon from '@/material-icons/400-24px/cancel.svg?react'; import CheckIcon from '@/material-icons/400-24px/check.svg?react'; @@ -30,12 +30,12 @@ import { useAccount } from 'mastodon/hooks/useAccount'; import { me } from 'mastodon/initial_state'; import { addCollectionItem, + getCollectionItemIds, removeCollectionItem, + updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; import { store, useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { TempCollectionState } from './state'; -import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; @@ -52,9 +52,8 @@ function isOlderThanAWeek(date?: string): boolean { const AddedAccountItem: React.FC<{ accountId: string; - isRemovable: boolean; onRemove: (id: string) => void; -}> = ({ accountId, isRemovable, onRemove }) => { +}> = ({ accountId, onRemove }) => { const intl = useIntl(); const account = useAccount(accountId); @@ -86,17 +85,15 @@ const AddedAccountItem: React.FC<{ id={accountId} extraAccountInfo={lastPostHint} > - {isRemovable && ( - - )} + ); }; @@ -139,28 +136,25 @@ export const CollectionAccounts: React.FC<{ const intl = useIntl(); const dispatch = useAppDispatch(); const history = useHistory(); - const location = useLocation(); - const { id, initialItemIds } = getCollectionEditorState( - collection, - location.state, - ); - const isEditMode = !!id; - const collectionItems = collection?.items; - const [searchValue, setSearchValue] = useState(''); - // This state is only used when creating a new collection. - // In edit mode, the collection will be updated instantly - const [addedAccountIds, setAccountIds] = useState(initialItemIds); + const { id, items } = collection ?? {}; + const isEditMode = !!id; + const collectionItems = items; + + const addedAccountIds = useAppSelector( + (state) => state.collections.editor.accountIds, + ); + + // In edit mode, we're bypassing state and just return collection items directly, + // since they're edited "live", saving after each addition/deletion const accountIds = useMemo( () => - isEditMode - ? (collectionItems - ?.map((item) => item.account_id) - .filter((id): id is string => !!id) ?? []) - : addedAccountIds, + isEditMode ? getCollectionItemIds(collectionItems) : addedAccountIds, [isEditMode, collectionItems, addedAccountIds], ); + const [searchValue, setSearchValue] = useState(''); + const hasMaxAccounts = accountIds.length === MAX_ACCOUNT_COUNT; const { @@ -233,28 +227,41 @@ export const CollectionAccounts: React.FC<{ [dispatch, relationships], ); - const removeAccountItem = useCallback((accountId: string) => { - setAccountIds((ids) => ids.filter((id) => id !== accountId)); - }, []); + const removeAccountItem = useCallback( + (accountId: string) => { + dispatch( + updateCollectionEditorField({ + field: 'accountIds', + value: accountIds.filter((id) => id !== accountId), + }), + ); + }, + [accountIds, dispatch], + ); const addAccountItem = useCallback( (accountId: string) => { confirmFollowStatus(accountId, () => { - setAccountIds((ids) => [...ids, accountId]); + dispatch( + updateCollectionEditorField({ + field: 'accountIds', + value: [...accountIds, accountId], + }), + ); }); }, - [confirmFollowStatus], + [accountIds, confirmFollowStatus, dispatch], ); const toggleAccountItem = useCallback( (item: SuggestionItem) => { - if (addedAccountIds.includes(item.id)) { + if (accountIds.includes(item.id)) { removeAccountItem(item.id); } else { addAccountItem(item.id); } }, - [addAccountItem, addedAccountIds, removeAccountItem], + [accountIds, addAccountItem, removeAccountItem], ); const instantRemoveAccountItem = useCallback( @@ -406,7 +413,6 @@ export const CollectionAccounts: React.FC<{ > diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index 6234bca514..875d09c9eb 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -1,13 +1,12 @@ -import { useCallback, useState } from 'react'; +import { useCallback } from 'react'; import { FormattedMessage } from 'react-intl'; -import { useHistory, useLocation } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; import type { - ApiCollectionJSON, ApiCreateCollectionPayload, ApiUpdateCollectionPayload, } from 'mastodon/api_types/collections'; @@ -23,70 +22,77 @@ import { TextInputField } from 'mastodon/components/form_fields/text_input_field import { createCollection, updateCollection, + updateCollectionEditorField, } from 'mastodon/reducers/slices/collections'; -import { useAppDispatch } from 'mastodon/store'; +import { useAppDispatch, useAppSelector } from 'mastodon/store'; -import type { TempCollectionState } from './state'; -import { getCollectionEditorState } from './state'; import classes from './styles.module.scss'; import { WizardStepHeader } from './wizard_step_header'; -export const CollectionDetails: React.FC<{ - collection?: ApiCollectionJSON | null; -}> = ({ collection }) => { +export const CollectionDetails: React.FC = () => { const dispatch = useAppDispatch(); const history = useHistory(); - const location = useLocation(); - - const { - id, - initialName, - initialDescription, - initialTopic, - initialItemIds, - initialDiscoverable, - initialSensitive, - } = getCollectionEditorState(collection, location.state); - - const [name, setName] = useState(initialName); - const [description, setDescription] = useState(initialDescription); - const [topic, setTopic] = useState(initialTopic); - const [discoverable, setDiscoverable] = useState(initialDiscoverable); - const [sensitive, setSensitive] = useState(initialSensitive); + const { id, name, description, topic, discoverable, sensitive, accountIds } = + useAppSelector((state) => state.collections.editor); const handleNameChange = useCallback( (event: React.ChangeEvent) => { - setName(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'name', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleDescriptionChange = useCallback( (event: React.ChangeEvent) => { - setDescription(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'description', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleTopicChange = useCallback( (event: React.ChangeEvent) => { - setTopic(event.target.value); + dispatch( + updateCollectionEditorField({ + field: 'topic', + value: event.target.value, + }), + ); }, - [], + [dispatch], ); const handleDiscoverableChange = useCallback( (event: React.ChangeEvent) => { - setDiscoverable(event.target.value === 'public'); + dispatch( + updateCollectionEditorField({ + field: 'discoverable', + value: event.target.value === 'public', + }), + ); }, - [], + [dispatch], ); const handleSensitiveChange = useCallback( (event: React.ChangeEvent) => { - setSensitive(event.target.checked); + dispatch( + updateCollectionEditorField({ + field: 'sensitive', + value: event.target.checked, + }), + ); }, - [], + [dispatch], ); const handleSubmit = useCallback( @@ -112,7 +118,7 @@ export const CollectionDetails: React.FC<{ description, discoverable, sensitive, - account_ids: initialItemIds, + account_ids: accountIds, }; if (topic) { payload.tag_name = topic; @@ -124,9 +130,7 @@ export const CollectionDetails: React.FC<{ }), ).then((result) => { if (isFulfilled(result)) { - history.replace( - `/collections/${result.payload.collection.id}/edit/details`, - ); + history.replace(`/collections`); history.push(`/collections/${result.payload.collection.id}`, { newCollection: true, }); @@ -143,7 +147,7 @@ export const CollectionDetails: React.FC<{ sensitive, dispatch, history, - initialItemIds, + accountIds, ], ); diff --git a/app/javascript/mastodon/features/collections/editor/index.tsx b/app/javascript/mastodon/features/collections/editor/index.tsx index 2200bccb17..ff1549b942 100644 --- a/app/javascript/mastodon/features/collections/editor/index.tsx +++ b/app/javascript/mastodon/features/collections/editor/index.tsx @@ -16,7 +16,10 @@ import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react'; import { Column } from 'mastodon/components/column'; import { ColumnHeader } from 'mastodon/components/column_header'; import { LoadingIndicator } from 'mastodon/components/loading_indicator'; -import { fetchCollection } from 'mastodon/reducers/slices/collections'; +import { + collectionEditorActions, + fetchCollection, +} from 'mastodon/reducers/slices/collections'; import { useAppDispatch, useAppSelector } from 'mastodon/store'; import { CollectionAccounts } from './accounts'; @@ -68,6 +71,7 @@ export const CollectionEditorPage: React.FC<{ const collection = useAppSelector((state) => id ? state.collections.collections[id] : undefined, ); + const editorStateId = useAppSelector((state) => state.collections.editor.id); const isEditMode = !!id; const isLoading = isEditMode && !collection; @@ -77,6 +81,18 @@ export const CollectionEditorPage: React.FC<{ } }, [dispatch, id]); + useEffect(() => { + if (id !== editorStateId) { + void dispatch(collectionEditorActions.reset()); + } + }, [dispatch, editorStateId, id]); + + useEffect(() => { + if (collection) { + void dispatch(collectionEditorActions.init(collection)); + } + }, [dispatch, collection]); + const pageTitle = intl.formatMessage(usePageTitle(id)); return ( @@ -104,7 +120,7 @@ export const CollectionEditorPage: React.FC<{ exact path={`${path}/details`} // eslint-disable-next-line react/jsx-no-bind - render={() => } + render={() => } /> )} diff --git a/app/javascript/mastodon/features/collections/editor/state.ts b/app/javascript/mastodon/features/collections/editor/state.ts deleted file mode 100644 index abac0b94b5..0000000000 --- a/app/javascript/mastodon/features/collections/editor/state.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { - ApiCollectionJSON, - ApiCreateCollectionPayload, -} from '@/mastodon/api_types/collections'; - -/** - * Temporary editor state across creation steps, - * kept in location state - */ -export type TempCollectionState = - | Partial - | undefined; - -/** - * Resolve initial editor state. Temporary location state - * trumps stored data, otherwise initial values are returned. - */ -export function getCollectionEditorState( - collection: ApiCollectionJSON | null | undefined, - locationState: TempCollectionState, -) { - const { - id, - name = '', - description = '', - tag, - language = '', - discoverable = true, - sensitive = false, - items, - } = collection ?? {}; - - const collectionItemIds = - items?.map((item) => item.account_id).filter(onlyExistingIds) ?? []; - - const initialItemIds = ( - locationState?.account_ids ?? collectionItemIds - ).filter(onlyExistingIds); - - return { - id, - initialItemIds, - initialName: locationState?.name ?? name, - initialDescription: locationState?.description ?? description, - initialTopic: locationState?.tag_name ?? tag?.name ?? '', - initialLanguage: locationState?.language ?? language, - initialDiscoverable: locationState?.discoverable ?? discoverable, - initialSensitive: locationState?.sensitive ?? sensitive, - }; -} - -const onlyExistingIds = (id?: string): id is string => !!id; diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index 127794b478..dc20b98732 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -1,3 +1,4 @@ +import type { PayloadAction } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit'; import { importFetchedAccounts } from '@/mastodon/actions/importer'; @@ -36,17 +37,69 @@ interface CollectionState { status: QueryStatus; } >; + editor: { + id: string | undefined; + name: string; + description: string; + topic: string; + language: string | null; + discoverable: boolean; + sensitive: boolean; + accountIds: string[]; + }; +} + +type EditorField = CollectionState['editor']; + +interface UpdateEditorFieldPayload { + field: K; + value: EditorField[K]; } const initialState: CollectionState = { collections: {}, accountCollections: {}, + editor: { + id: undefined, + name: '', + description: '', + topic: '', + language: null, + discoverable: true, + sensitive: false, + accountIds: [], + }, }; const collectionSlice = createSlice({ name: 'collections', initialState, - reducers: {}, + reducers: { + init(state, action: PayloadAction) { + const collection = action.payload; + + state.editor = { + id: collection?.id, + name: collection?.name ?? '', + description: collection?.description ?? '', + topic: collection?.tag?.name ?? '', + language: collection?.language ?? '', + discoverable: collection?.discoverable ?? true, + sensitive: collection?.sensitive ?? false, + accountIds: getCollectionItemIds(collection?.items ?? []), + }; + }, + reset(state) { + state.editor = initialState.editor; + }, + updateEditorField( + state: CollectionState, + action: PayloadAction>, + ) { + const { field, value } = action.payload; + state.editor[field] = value; + }, + }, extraReducers(builder) { /** * Fetching account collections @@ -104,6 +157,7 @@ const collectionSlice = createSlice({ builder.addCase(updateCollection.fulfilled, (state, action) => { const { collection } = action.payload; state.collections[collection.id] = collection; + state.editor = initialState.editor; }); /** @@ -132,6 +186,7 @@ const collectionSlice = createSlice({ const { collection } = actions.payload; state.collections[collection.id] = collection; + state.editor = initialState.editor; if (state.accountCollections[collection.account_id]) { state.accountCollections[collection.account_id]?.collectionIds.unshift( @@ -240,6 +295,9 @@ export const revokeCollectionInclusion = createAppAsyncThunk( ); export const collections = collectionSlice.reducer; +export const collectionEditorActions = collectionSlice.actions; +export const updateCollectionEditorField = + collectionSlice.actions.updateEditorField; /** * Selectors @@ -278,3 +336,8 @@ export const selectAccountCollections = createAppSelector( } satisfies AccountCollectionQuery; }, ); + +const onlyExistingIds = (id?: string): id is string => !!id; + +export const getCollectionItemIds = (items?: ApiCollectionJSON['items']) => + items?.map((item) => item.account_id).filter(onlyExistingIds) ?? []; From 4a08ab64d1d22e0572e85a2ace18347ec00b2ff6 Mon Sep 17 00:00:00 2001 From: Echo Date: Wed, 11 Mar 2026 14:52:44 +0100 Subject: [PATCH 13/17] Profile editing: Always show field buttons (#38152) --- .../mastodon/features/account_edit/index.tsx | 31 +++++++++---------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/app/javascript/mastodon/features/account_edit/index.tsx b/app/javascript/mastodon/features/account_edit/index.tsx index 43a13f612a..7dc2397f8b 100644 --- a/app/javascript/mastodon/features/account_edit/index.tsx +++ b/app/javascript/mastodon/features/account_edit/index.tsx @@ -205,24 +205,21 @@ export const AccountEdit: FC = () => { showDescription={!hasFields} buttons={ <> - {profile.fields.length > 1 && ( - - )} - {hasFields && ( - = maxFieldCount} + + = maxFieldCount} + /> } > From 3091e2e52527930cec91dbf4cdd921a80def5ec9 Mon Sep 17 00:00:00 2001 From: David Roetzel Date: Wed, 11 Mar 2026 15:29:00 +0100 Subject: [PATCH 14/17] Ingestion of remote collections (#38144) --- FEDERATION.md | 2 + app/models/collection.rb | 16 +++- app/serializers/rest/collection_serializer.rb | 4 +- .../process_featured_collection_service.rb | 52 +++++++++++++ .../process_featured_item_worker.rb | 16 ++++ spec/models/collection_spec.rb | 8 ++ .../rest/collection_serializer_spec.rb | 9 +++ ...rocess_featured_collection_service_spec.rb | 76 +++++++++++++++++++ .../process_featured_item_worker_spec.rb | 25 ++++++ 9 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 app/services/activitypub/process_featured_collection_service.rb create mode 100644 app/workers/activitypub/process_featured_item_worker.rb create mode 100644 spec/services/activitypub/process_featured_collection_service_spec.rb create mode 100644 spec/workers/activitypub/process_featured_item_worker_spec.rb diff --git a/FEDERATION.md b/FEDERATION.md index 2d007dada1..7593d6d953 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -69,3 +69,5 @@ The following table summarizes those limits. | Account aliases (actor `alsoKnownAs`) | 256 | List will be truncated | | Custom emoji shortcode (`Emoji` `name`) | 2048 | Emoji will be rejected | | Media and avatar/header descriptions (`name`/`summary`) | 1500 | Description will be truncated | +| Collection name (`FeaturedCollection` `name`) | 256 | Name will be truncated | +| Collection description (`FeaturedCollection` `summary`) | 2048 | Description will be truncated | diff --git a/app/models/collection.rb b/app/models/collection.rb index 0061e7ff5c..3be633bbf1 100644 --- a/app/models/collection.rb +++ b/app/models/collection.rb @@ -22,6 +22,8 @@ # class Collection < ApplicationRecord MAX_ITEMS = 25 + NAME_LENGTH_HARD_LIMIT = 256 + DESCRIPTION_LENGTH_HARD_LIMIT = 2048 belongs_to :account belongs_to :tag, optional: true @@ -31,10 +33,16 @@ class Collection < ApplicationRecord has_many :collection_reports, dependent: :delete_all validates :name, presence: true - validates :description, presence: true, - if: :local? - validates :description_html, presence: true, - if: :remote? + validates :name, length: { maximum: 40 }, if: :local? + validates :name, length: { maximum: NAME_LENGTH_HARD_LIMIT }, if: :remote? + validates :description, + presence: true, + length: { maximum: 100 }, + if: :local? + validates :description_html, + presence: true, + length: { maximum: DESCRIPTION_LENGTH_HARD_LIMIT }, + if: :remote? validates :local, inclusion: [true, false] validates :sensitive, inclusion: [true, false] validates :discoverable, inclusion: [true, false] diff --git a/app/serializers/rest/collection_serializer.rb b/app/serializers/rest/collection_serializer.rb index 370384c220..ac7c8ad026 100644 --- a/app/serializers/rest/collection_serializer.rb +++ b/app/serializers/rest/collection_serializer.rb @@ -14,7 +14,9 @@ class REST::CollectionSerializer < ActiveModel::Serializer end def description - object.local? ? object.description : object.description_html + return object.description if object.local? + + Sanitize.fragment(object.description_html, Sanitize::Config::MASTODON_STRICT) end def items diff --git a/app/services/activitypub/process_featured_collection_service.rb b/app/services/activitypub/process_featured_collection_service.rb new file mode 100644 index 0000000000..edbb50c533 --- /dev/null +++ b/app/services/activitypub/process_featured_collection_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessFeaturedCollectionService + include JsonLdHelper + include Lockable + include Redisable + + ITEMS_LIMIT = 150 + + def call(account, json) + @account = account + @json = json + return if non_matching_uri_hosts?(@account.uri, @json['id']) + + with_redis_lock("collection:#{@json['id']}") do + return if @account.collections.exists?(uri: @json['id']) + + @collection = @account.collections.create!( + local: false, + uri: @json['id'], + name: (@json['name'] || '')[0, Collection::NAME_LENGTH_HARD_LIMIT], + description_html: truncated_summary, + language:, + sensitive: @json['sensitive'], + discoverable: @json['discoverable'], + original_number_of_items: @json['totalItems'] || 0, + tag_name: @json.dig('topic', 'name') + ) + + process_items! + + @collection + end + end + + private + + def truncated_summary + text = @json['summaryMap']&.values&.first || @json['summary'] || '' + text[0, Collection::DESCRIPTION_LENGTH_HARD_LIMIT] + end + + def language + @json['summaryMap']&.keys&.first + end + + def process_items! + @json['orderedItems'].take(ITEMS_LIMIT).each do |item_json| + ActivityPub::ProcessFeaturedItemWorker.perform_async(@collection.id, item_json) + end + end +end diff --git a/app/workers/activitypub/process_featured_item_worker.rb b/app/workers/activitypub/process_featured_item_worker.rb new file mode 100644 index 0000000000..dd765e7df6 --- /dev/null +++ b/app/workers/activitypub/process_featured_item_worker.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ActivityPub::ProcessFeaturedItemWorker + include Sidekiq::Worker + include ExponentialBackoff + + sidekiq_options queue: 'pull', retry: 3 + + def perform(collection_id, id_or_json) + collection = Collection.find(collection_id) + + ActivityPub::ProcessFeaturedItemService.new.call(collection, id_or_json) + rescue ActiveRecord::RecordNotFound + true + end +end diff --git a/spec/models/collection_spec.rb b/spec/models/collection_spec.rb index fc833d354b..6937829ebb 100644 --- a/spec/models/collection_spec.rb +++ b/spec/models/collection_spec.rb @@ -8,8 +8,12 @@ RSpec.describe Collection do it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_length_of(:name).is_at_most(40) } + it { is_expected.to validate_presence_of(:description) } + it { is_expected.to validate_length_of(:description).is_at_most(100) } + it { is_expected.to_not allow_value(nil).for(:local) } it { is_expected.to_not allow_value(nil).for(:sensitive) } @@ -23,10 +27,14 @@ RSpec.describe Collection do context 'when collection is remote' do subject { Fabricate.build :collection, local: false } + it { is_expected.to validate_length_of(:name).is_at_most(Collection::NAME_LENGTH_HARD_LIMIT) } + it { is_expected.to_not validate_presence_of(:description) } it { is_expected.to validate_presence_of(:description_html) } + it { is_expected.to validate_length_of(:description_html).is_at_most(Collection::DESCRIPTION_LENGTH_HARD_LIMIT) } + it { is_expected.to validate_presence_of(:uri) } it { is_expected.to validate_presence_of(:original_number_of_items) } diff --git a/spec/serializers/rest/collection_serializer_spec.rb b/spec/serializers/rest/collection_serializer_spec.rb index 816b1873f6..67ff464d18 100644 --- a/spec/serializers/rest/collection_serializer_spec.rb +++ b/spec/serializers/rest/collection_serializer_spec.rb @@ -51,5 +51,14 @@ RSpec.describe REST::CollectionSerializer do expect(subject) .to include('description' => '

remote

') end + + context 'when the description contains unwanted HTML' do + let(:description_html) { '

Nice people

' } + let(:collection) { Fabricate(:remote_collection, description_html:) } + + it 'scrubs the HTML' do + expect(subject).to include('description' => '

Nice people

') + end + end end end diff --git a/spec/services/activitypub/process_featured_collection_service_spec.rb b/spec/services/activitypub/process_featured_collection_service_spec.rb new file mode 100644 index 0000000000..3a0fdd82f1 --- /dev/null +++ b/spec/services/activitypub/process_featured_collection_service_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessFeaturedCollectionService do + subject { described_class.new } + + let(:account) { Fabricate(:remote_account) } + let(:summary) { '

A list of remote actors you should follow.

' } + let(:base_json) do + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => 'https://example.com/featured_collections/1', + 'type' => 'FeaturedCollection', + 'attributedTo' => account.uri, + 'name' => 'Good people from other servers', + 'sensitive' => false, + 'discoverable' => true, + 'topic' => { + 'type' => 'Hashtag', + 'name' => '#people', + }, + 'published' => '2026-03-09T15:19:25Z', + 'totalItems' => 2, + 'orderedItems' => [ + 'https://example.com/featured_items/1', + 'https://example.com/featured_items/2', + ], + } + end + let(:featured_collection_json) { base_json.merge('summary' => summary) } + + context "when the collection's URI does not match the account's" do + let(:non_matching_account) { Fabricate(:remote_account, domain: 'other.example.com') } + + it 'does not create a collection and returns `nil`' do + expect do + expect(subject.call(non_matching_account, featured_collection_json)).to be_nil + end.to_not change(Collection, :count) + end + end + + context 'when URIs match up' do + it 'creates a collection and queues jobs to handle its items' do + expect { subject.call(account, featured_collection_json) }.to change(account.collections, :count).by(1) + + new_collection = account.collections.last + expect(new_collection.uri).to eq 'https://example.com/featured_collections/1' + expect(new_collection.name).to eq 'Good people from other servers' + expect(new_collection.description_html).to eq '

A list of remote actors you should follow.

' + expect(new_collection.sensitive).to be false + expect(new_collection.discoverable).to be true + expect(new_collection.tag.formatted_name).to eq '#people' + + expect(ActivityPub::ProcessFeaturedItemWorker).to have_enqueued_sidekiq_job.exactly(2).times + end + end + + context 'when the json includes a summary map' do + let(:featured_collection_json) do + base_json.merge({ + 'summaryMap' => { + 'en' => summary, + }, + }) + end + + it 'sets language and summary correctly' do + expect { subject.call(account, featured_collection_json) }.to change(account.collections, :count).by(1) + + new_collection = account.collections.last + expect(new_collection.language).to eq 'en' + expect(new_collection.description_html).to eq '

A list of remote actors you should follow.

' + end + end +end diff --git a/spec/workers/activitypub/process_featured_item_worker_spec.rb b/spec/workers/activitypub/process_featured_item_worker_spec.rb new file mode 100644 index 0000000000..f27ec21c35 --- /dev/null +++ b/spec/workers/activitypub/process_featured_item_worker_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ActivityPub::ProcessFeaturedItemWorker do + subject { described_class.new } + + let(:collection) { Fabricate(:remote_collection) } + let(:object) { 'https://example.com/featured_items/1' } + let(:stubbed_service) do + instance_double(ActivityPub::ProcessFeaturedItemService, call: true) + end + + before do + allow(ActivityPub::ProcessFeaturedItemService).to receive(:new).and_return(stubbed_service) + end + + describe 'perform' do + it 'calls the service to process the item' do + subject.perform(collection.id, object) + + expect(stubbed_service).to have_received(:call).with(collection, object) + end + end +end From 3ef7d2835a5ef36bac71cd392710b5ea10602cb1 Mon Sep 17 00:00:00 2001 From: diondiondion Date: Wed, 11 Mar 2026 18:04:37 +0100 Subject: [PATCH 15/17] Collection editor: Format topic as hashtag (#38153) --- .../features/collections/editor/details.tsx | 6 +++- .../mastodon/reducers/slices/collections.ts | 32 +++++++++---------- .../mastodon/utils/hashtags.test.ts | 28 ++++++++++++++++ app/javascript/mastodon/utils/hashtags.ts | 32 +++++++++++++++++++ 4 files changed, 81 insertions(+), 17 deletions(-) create mode 100644 app/javascript/mastodon/utils/hashtags.test.ts diff --git a/app/javascript/mastodon/features/collections/editor/details.tsx b/app/javascript/mastodon/features/collections/editor/details.tsx index 875d09c9eb..f59bd4de51 100644 --- a/app/javascript/mastodon/features/collections/editor/details.tsx +++ b/app/javascript/mastodon/features/collections/editor/details.tsx @@ -6,6 +6,7 @@ import { useHistory } from 'react-router-dom'; import { isFulfilled } from '@reduxjs/toolkit'; +import { inputToHashtag } from '@/mastodon/utils/hashtags'; import type { ApiCreateCollectionPayload, ApiUpdateCollectionPayload, @@ -64,7 +65,7 @@ export const CollectionDetails: React.FC = () => { dispatch( updateCollectionEditorField({ field: 'topic', - value: event.target.value, + value: inputToHashtag(event.target.value), }), ); }, @@ -219,6 +220,9 @@ export const CollectionDetails: React.FC = () => { } value={topic} onChange={handleTopicChange} + autoCapitalize='off' + autoCorrect='off' + spellCheck='false' maxLength={40} /> diff --git a/app/javascript/mastodon/reducers/slices/collections.ts b/app/javascript/mastodon/reducers/slices/collections.ts index dc20b98732..a534a13440 100644 --- a/app/javascript/mastodon/reducers/slices/collections.ts +++ b/app/javascript/mastodon/reducers/slices/collections.ts @@ -37,30 +37,30 @@ interface CollectionState { status: QueryStatus; } >; - editor: { - id: string | undefined; - name: string; - description: string; - topic: string; - language: string | null; - discoverable: boolean; - sensitive: boolean; - accountIds: string[]; - }; + editor: EditorState; } -type EditorField = CollectionState['editor']; +interface EditorState { + id: string | null; + name: string; + description: string; + topic: string; + language: string | null; + discoverable: boolean; + sensitive: boolean; + accountIds: string[]; +} -interface UpdateEditorFieldPayload { +interface UpdateEditorFieldPayload { field: K; - value: EditorField[K]; + value: EditorState[K]; } const initialState: CollectionState = { collections: {}, accountCollections: {}, editor: { - id: undefined, + id: null, name: '', description: '', topic: '', @@ -79,7 +79,7 @@ const collectionSlice = createSlice({ const collection = action.payload; state.editor = { - id: collection?.id, + id: collection?.id ?? null, name: collection?.name ?? '', description: collection?.description ?? '', topic: collection?.tag?.name ?? '', @@ -92,7 +92,7 @@ const collectionSlice = createSlice({ reset(state) { state.editor = initialState.editor; }, - updateEditorField( + updateEditorField( state: CollectionState, action: PayloadAction>, ) { diff --git a/app/javascript/mastodon/utils/hashtags.test.ts b/app/javascript/mastodon/utils/hashtags.test.ts new file mode 100644 index 0000000000..05b79b1d52 --- /dev/null +++ b/app/javascript/mastodon/utils/hashtags.test.ts @@ -0,0 +1,28 @@ +import { inputToHashtag } from './hashtags'; + +describe('inputToHashtag', () => { + test.concurrent.each([ + ['', ''], + // Prepend or keep hashtag + ['mastodon', '#mastodon'], + ['#mastodon', '#mastodon'], + // Preserve trailing whitespace + ['mastodon ', '#mastodon '], + [' ', '# '], + // Collapse whitespace & capitalise first character + ['cats of mastodon', '#catsOfMastodon'], + ['x y z', '#xYZ'], + [' mastodon', '#mastodon'], + // Preserve initial casing + ['Log in', '#LogIn'], + ['#NaturePhotography', '#NaturePhotography'], + // Normalise hash symbol variant + ['#nature', '#nature'], + ['#Nature Photography', '#NaturePhotography'], + // Allow special characters + ['hello-world', '#hello-world'], + ['hello,world', '#hello,world'], + ])('for input "%s", return "%s"', (input, expected) => { + expect(inputToHashtag(input)).toBe(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/hashtags.ts b/app/javascript/mastodon/utils/hashtags.ts index 0c5505c6c9..d14efe5db3 100644 --- a/app/javascript/mastodon/utils/hashtags.ts +++ b/app/javascript/mastodon/utils/hashtags.ts @@ -27,3 +27,35 @@ const buildHashtagRegex = () => { export const HASHTAG_PATTERN_REGEX = buildHashtagPatternRegex(); export const HASHTAG_REGEX = buildHashtagRegex(); + +/** + * Formats an input string as a hashtag: + * - Prepends `#` unless present + * - Strips spaces (except at the end, to allow typing it) + * - Capitalises first character after stripped space + */ +export const inputToHashtag = (input: string): string => { + if (!input) { + return ''; + } + + const trailingSpace = /\s+$/.exec(input)?.[0] ?? ''; + const trimmedInput = input.trimEnd(); + + const withoutHash = + trimmedInput.startsWith('#') || trimmedInput.startsWith('#') + ? trimmedInput.slice(1) + : trimmedInput; + + // Split by space, filter empty strings, and capitalise the start of each word but the first + const words = withoutHash + .split(/\s+/) + .filter((word) => word.length > 0) + .map((word, index) => + index === 0 + ? word + : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ); + + return `#${words.join('')}${trailingSpace}`; +}; From 0a216003ffb67b4199747fd2eed2618adb21dfa4 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 11 Mar 2026 13:11:00 -0400 Subject: [PATCH 16/17] Disable `use_multi_json` for json validator / match_json_schema (#38151) --- config/environments/test.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/environments/test.rb b/config/environments/test.rb index 0c4f1de41e..12709d5f0b 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -92,3 +92,6 @@ end Sidekiq.strict_args! Redis.raise_deprecations = true + +# Silence deprecation warning from json-schema +JSON::Validator.use_multi_json = false From 811575a10903cada549580979cc809ca98ad570c Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Wed, 11 Mar 2026 13:11:28 -0400 Subject: [PATCH 17/17] Use bundler version 4.0.8 (#38150) --- Gemfile.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 68b7cb0a60..5ab80abb8c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -99,7 +99,7 @@ GEM ast (2.4.3) attr_required (1.0.2) aws-eventstream (1.4.0) - aws-partitions (1.1222.0) + aws-partitions (1.1223.0) aws-sdk-core (3.243.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) @@ -157,7 +157,7 @@ GEM case_transform (0.2) activesupport cbor (0.5.10.1) - cgi (0.4.2) + cgi (0.5.1) charlock_holmes (0.7.9) chewy (7.6.0) activesupport (>= 5.2) @@ -209,7 +209,7 @@ GEM activerecord (>= 4.2, < 9.0) docile (1.4.1) domain_name (0.6.20240107) - doorkeeper (5.8.2) + doorkeeper (5.9.0) railties (>= 5) dotenv (3.2.0) drb (2.2.3) @@ -230,7 +230,7 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - excon (1.3.2) + excon (1.4.0) logger fabrication (3.0.0) faker (3.6.1) @@ -276,9 +276,9 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - google-protobuf (4.33.5) + google-protobuf (4.34.0) bigdecimal - rake (>= 13) + rake (~> 13.3) googleapis-common-protos-types (1.22.0) google-protobuf (~> 4.26) haml (7.2.0) @@ -352,7 +352,7 @@ GEM azure-blob (~> 0.5.2) hashie (~> 5.0) jmespath (1.6.2) - json (2.18.1) + json (2.19.1) json-canonicalization (1.0.0) json-jwt (1.17.0) activesupport (>= 4.2) @@ -446,7 +446,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0224) + mime-types-data (3.2026.0303) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (6.0.2) @@ -507,7 +507,7 @@ GEM tzinfo validate_url webfinger (~> 2.0) - openssl (3.3.2) + openssl (4.0.1) openssl-signature_algorithm (1.3.0) openssl (> 2.0) opentelemetry-api (1.7.0) @@ -766,7 +766,7 @@ GEM rubocop-ast (>= 1.49.0, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 2.4.0, < 4.0) - rubocop-ast (1.49.0) + rubocop-ast (1.49.1) parser (>= 3.3.7.2) prism (~> 1.7) rubocop-capybara (2.22.1) @@ -792,7 +792,7 @@ GEM lint_roller (~> 1.1) rubocop (~> 1.72, >= 1.72.1) rubocop-rspec (~> 3.5) - ruby-prof (2.0.2) + ruby-prof (2.0.4) base64 ostruct ruby-progressbar (1.13.0) @@ -847,7 +847,7 @@ GEM stackprof (0.2.28) starry (0.2.0) base64 - stoplight (5.7.0) + stoplight (5.8.0) concurrent-ruby zeitwerk stringio (3.2.0) @@ -867,7 +867,7 @@ GEM test-prof (1.5.2) thor (1.5.0) tilt (2.7.0) - timeout (0.6.0) + timeout (0.6.1) tpm-key_attestation (0.14.1) bindata (~> 2.4) openssl (> 2.0) @@ -1100,4 +1100,4 @@ RUBY VERSION ruby 3.4.8 BUNDLED WITH - 4.0.7 + 4.0.8