From d9dbe62417e6e40d93c42471b7f47f53a0a1f782 Mon Sep 17 00:00:00 2001 From: Claire Date: Thu, 19 Mar 2026 09:13:50 +0100 Subject: [PATCH 01/11] Fix error when processing `Add` activity where the target is embedded and not a special collection (#38282) --- app/lib/activitypub/activity/add.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/activitypub/activity/add.rb b/app/lib/activitypub/activity/add.rb index 7b4576a06a..cfaf29a3be 100644 --- a/app/lib/activitypub/activity/add.rb +++ b/app/lib/activitypub/activity/add.rb @@ -17,7 +17,7 @@ class ActivityPub::Activity::Add < ActivityPub::Activity add_collection else - @collection = @account.collections.find_by(uri: @json['target']) + @collection = @account.collections.find_by(uri: value_or_id(@json['target'])) add_collection_item if @collection && Mastodon::Feature.collections_federation_enabled? end end From ec940e88dfa11f56e9097615606d9f6ae2ee645f Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 19 Mar 2026 09:26:52 +0100 Subject: [PATCH 02/11] Add missing h1 headings to Reset Password & Resend Confirmation Link pages (#38285) --- app/views/auth/confirmations/new.html.haml | 1 + app/views/auth/passwords/new.html.haml | 1 + app/views/auth/sessions/new.html.haml | 5 ++++- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/app/views/auth/confirmations/new.html.haml b/app/views/auth/confirmations/new.html.haml index eaa9d9add0..53c97d1dcb 100644 --- a/app/views/auth/confirmations/new.html.haml +++ b/app/views/auth/confirmations/new.html.haml @@ -18,6 +18,7 @@ %p.lead= t('auth.confirmations.awaiting_review', domain: site_hostname) - else = simple_form_for(resource, as: resource_name, url: confirmation_path(resource_name), html: { method: :post }) do |f| + %h1.title= t('auth.resend_confirmation', domain: site_hostname) = render 'shared/error_messages', object: resource .fields-group diff --git a/app/views/auth/passwords/new.html.haml b/app/views/auth/passwords/new.html.haml index 8d5adaf3b9..3c72dd23d0 100644 --- a/app/views/auth/passwords/new.html.haml +++ b/app/views/auth/passwords/new.html.haml @@ -2,6 +2,7 @@ = t('auth.reset_password') = simple_form_for(resource, as: resource_name, url: password_path(resource_name), html: { method: :post }) do |f| + %h1.title= t('auth.reset_password', domain: site_hostname) = render 'shared/error_messages', object: resource .fields-group diff --git a/app/views/auth/sessions/new.html.haml b/app/views/auth/sessions/new.html.haml index bdb026f79c..944bc6e650 100644 --- a/app/views/auth/sessions/new.html.haml +++ b/app/views/auth/sessions/new.html.haml @@ -38,7 +38,10 @@ - if devise_mapping.omniauthable? && resource_class.omniauth_providers.any? .simple_form.alternative-login - %h2= omniauth_only? ? t('auth.log_in_with') : t('auth.or_log_in_with') + - if omniauth_only? + %h1= t('auth.log_in_with') + - else + %h2= t('auth.or_log_in_with') .actions - resource_class.omniauth_providers.each do |provider| From 6aedd2f9ec6be2363254ae0844df74ef860c4af4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:30:49 +0100 Subject: [PATCH 03/11] New Crowdin Translations (automated) (#38288) Co-authored-by: GitHub Actions --- app/javascript/mastodon/locales/fi.json | 2 +- app/javascript/mastodon/locales/ga.json | 20 ++++++++ app/javascript/mastodon/locales/nn.json | 53 ++++++++++++++++++++-- app/javascript/mastodon/locales/zh-CN.json | 2 +- config/locales/da.yml | 1 + config/locales/de.yml | 1 + config/locales/el.yml | 1 + config/locales/es-AR.yml | 1 + config/locales/fr-CA.yml | 1 + config/locales/fr.yml | 1 + config/locales/ga.yml | 1 + config/locales/gl.yml | 1 + config/locales/is.yml | 1 + config/locales/it.yml | 1 + config/locales/simple_form.de.yml | 2 +- config/locales/simple_form.el.yml | 2 +- config/locales/sq.yml | 1 + config/locales/sv.yml | 1 + config/locales/vi.yml | 1 + config/locales/zh-CN.yml | 1 + config/locales/zh-TW.yml | 1 + 21 files changed, 88 insertions(+), 8 deletions(-) diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index 56214f153f..e8d5383cf4 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -188,7 +188,7 @@ "account_edit.image_alt_modal.details_content": "TEE NÄIN:
  • Kuvaile itseäsi kuvan mukaisesti
  • Käytä kolmannen persoonan muotoja (esim. ”Aleksi” eikä ”minä”)
  • Ole ytimekäs – usein muutama sana riittää
ÄLÄ TEE NÄIN:
  • Aloita sanalla ”Kuva” – sen on tarpeetonta näytönlukuohjelmille
ESIMERKKI:
  • ”Aleksi yllään vihreä paita ja lasit”
", "account_edit.image_alt_modal.details_title": "Vinkkejä: profiilikuvien tekstivastineet", "account_edit.image_alt_modal.edit_title": "Muokkaa tekstivastinetta", - "account_edit.image_alt_modal.text_hint": "Teksivastine auttaa näytönlukuohjelmien käyttäjiä ymmärtämään sisältöä.", + "account_edit.image_alt_modal.text_hint": "Tekstivastine auttaa näytönlukuohjelmien käyttäjiä ymmärtämään sisältöä.", "account_edit.image_alt_modal.text_label": "Tekstivastine", "account_edit.image_delete_modal.confirm": "Haluatko varmasti poistaa tämän kuvan? Tätä toimea ei voi kumota.", "account_edit.image_delete_modal.delete_button": "Poista", diff --git a/app/javascript/mastodon/locales/ga.json b/app/javascript/mastodon/locales/ga.json index ca1155df87..9e4dc96794 100644 --- a/app/javascript/mastodon/locales/ga.json +++ b/app/javascript/mastodon/locales/ga.json @@ -184,6 +184,15 @@ "account_edit.field_reorder_modal.drag_start": "Réimse \"{item}\" bailithe.", "account_edit.field_reorder_modal.handle_label": "Tarraing réimse \"{item}\"", "account_edit.field_reorder_modal.title": "Athshocraigh réimsí", + "account_edit.image_alt_modal.add_title": "Cuir téacs malartach leis", + "account_edit.image_alt_modal.details_content": "DÉAN:
  • Déan cur síos ort féin mar atá sa phictiúr
  • Úsáid teanga an tríú pearsa (m.sh. “Alex” in ionad “mise”)
  • Bí gonta – is minic a bhíonn cúpla focal leordhóthanach
NÁ DÉAN:
  • Tosaigh le “Grianghraf de” – tá sé iomarcach do léitheoirí scáileáin
SAMPLA:
  • “Alex ag caitheamh léine ghlas agus spéaclaí”
", + "account_edit.image_alt_modal.details_title": "Leideanna: Téacs malartach do ghrianghraif phróifíle", + "account_edit.image_alt_modal.edit_title": "Cuir téacs alt in eagar", + "account_edit.image_alt_modal.text_hint": "Cuidíonn téacs malartach le húsáideoirí léitheoirí scáileáin d’ábhar a thuiscint.", + "account_edit.image_alt_modal.text_label": "Téacs malartach", + "account_edit.image_delete_modal.confirm": "An bhfuil tú cinnte gur mian leat an íomhá seo a scriosadh? Ní féidir an gníomh seo a chealú.", + "account_edit.image_delete_modal.delete_button": "Scrios", + "account_edit.image_delete_modal.title": "Íomhá a scriosadh?", "account_edit.image_edit.add_button": "Cuir íomhá leis", "account_edit.image_edit.alt_add_button": "Cuir téacs alt leis", "account_edit.image_edit.alt_edit_button": "Cuir téacs alt in eagar", @@ -203,6 +212,16 @@ "account_edit.profile_tab.subtitle": "Saincheap na cluaisíní ar do phróifíl agus a bhfuil á thaispeáint iontu.", "account_edit.profile_tab.title": "Socruithe an chluaisín próifíle", "account_edit.save": "Sábháil", + "account_edit.upload_modal.back": "Ar ais", + "account_edit.upload_modal.done": "Déanta", + "account_edit.upload_modal.next": "Ar Aghaidh", + "account_edit.upload_modal.step_crop.zoom": "Zúmáil", + "account_edit.upload_modal.step_upload.button": "Brabhsáil comhaid", + "account_edit.upload_modal.step_upload.dragging": "Scaoil le huaslódáil", + "account_edit.upload_modal.step_upload.header": "Roghnaigh íomhá", + "account_edit.upload_modal.step_upload.hint": "Formáid WEBP, PNG, GIF nó JPG, suas le {limit}MB.{br}Scálfar an íomhá go {width}x{height}px.", + "account_edit.upload_modal.title_add": "Cuir grianghraf próifíle leis", + "account_edit.upload_modal.title_replace": "Athsholáthar grianghraf próifíle", "account_edit.verified_modal.details": "Cuir creidiúnacht le do phróifíl Mastodon trí naisc chuig láithreáin ghréasáin phearsanta a fhíorú. Seo mar a oibríonn sé:", "account_edit.verified_modal.invisible_link.details": "Cuir an nasc le do cheanntásc. Is í an chuid thábhachtach ná rel=\"me\" a chuireann cosc ​​ar phearsanú ar shuíomhanna gréasáin a bhfuil inneachar a ghintear ag úsáideoirí. Is féidir leat clib nasc a úsáid fiú i gceanntásc an leathanaigh in ionad {tag}, ach caithfidh an HTML a bheith inrochtana gan JavaScript a chur i gcrích.", "account_edit.verified_modal.invisible_link.summary": "Conas a dhéanaim an nasc dofheicthe?", @@ -374,6 +393,7 @@ "collections.search_accounts_max_reached": "Tá an líon uasta cuntas curtha leis agat", "collections.sensitive": "Íogair", "collections.topic_hint": "Cuir haischlib leis a chabhraíonn le daoine eile príomhábhar an bhailiúcháin seo a thuiscint.", + "collections.topic_special_chars_hint": "Bainfear carachtair speisialta agus tú ag sábháil", "collections.view_collection": "Féach ar bhailiúchán", "collections.view_other_collections_by_user": "Féach ar bhailiúcháin eile ón úsáideoir seo", "collections.visibility_public": "Poiblí", diff --git a/app/javascript/mastodon/locales/nn.json b/app/javascript/mastodon/locales/nn.json index 9736f790cb..e63555ce35 100644 --- a/app/javascript/mastodon/locales/nn.json +++ b/app/javascript/mastodon/locales/nn.json @@ -153,16 +153,51 @@ "account_edit.column_title": "Rediger profil", "account_edit.custom_fields.name": "felt", "account_edit.custom_fields.placeholder": "Legg til pronomen, lenkjer eller kva du elles vil dela.", - "account_edit.custom_fields.reorder_button": "Omorganiser felter", - "account_edit.custom_fields.tip_content": "Du kan enkelt øke troverdighet til Mastodon-kontoen din ved å verifisere koblinger til nettsider du eier.", - "account_edit.custom_fields.tip_title": "Legg til bekreftede lenker", + "account_edit.custom_fields.reorder_button": "Omorganiser felt", + "account_edit.custom_fields.tip_content": "Du kan auka truverdet til Mastodon-kontoen din ved å stadfesta lenker til nettstader du eig.", + "account_edit.custom_fields.tip_title": "Tips: Legg til stadfesta lenker", "account_edit.custom_fields.title": "Eigne felt", - "account_edit.custom_fields.verified_hint": "Hvordan legger jeg til en verifisert lenke?", + "account_edit.custom_fields.verified_hint": "Korleis legg eg til ei stadfesta lenke?", "account_edit.display_name.placeholder": "Det synlege namnet ditt er det som syner på profilen din og i tidsliner.", "account_edit.display_name.title": "Synleg namn", "account_edit.featured_hashtags.item": "emneknaggar", "account_edit.featured_hashtags.placeholder": "Hjelp andre å finna og få rask tilgang til favorittemna dine.", "account_edit.featured_hashtags.title": "Utvalde emneknaggar", + "account_edit.field_delete_modal.confirm": "Vil du sletta dette tilpassa feltet? Du kan ikkje angra.", + "account_edit.field_delete_modal.delete_button": "Slett", + "account_edit.field_delete_modal.title": "Slett tilpassa felt?", + "account_edit.field_edit_modal.add_title": "Legg til eit tilpassa felt", + "account_edit.field_edit_modal.edit_title": "Rediger tilpassa felt", + "account_edit.field_edit_modal.limit_header": "Over maksgrensa for teikn", + "account_edit.field_edit_modal.limit_message": "Det er ikkje sikkert mobilbrukarar ser heile feltet ditt.", + "account_edit.field_edit_modal.link_emoji_warning": "Me rår frå å bruka eigne smilefjes kombinert med adresser. Tilpassa felt som inneheld båe, vil syna som berre tekst i staden for ei lenke, slik at lesarane ikkje blir forvirra.", + "account_edit.field_edit_modal.name_hint": "Til dømes «Personleg nettstad»", + "account_edit.field_edit_modal.name_label": "Etikett", + "account_edit.field_edit_modal.url_warning": "Skriv {protocol} i starten for å leggja til ei lenke.", + "account_edit.field_edit_modal.value_hint": "Til dømes «https://nettstad.no»", + "account_edit.field_edit_modal.value_label": "Verdi", + "account_edit.field_reorder_modal.drag_cancel": "Du avbraut draginga. Feltet «{item}» vart sleppt.", + "account_edit.field_reorder_modal.drag_end": "Feltet «{item}» vart sleppt.", + "account_edit.field_reorder_modal.drag_instructions": "For å flytta eigne felt, trykkjer du mellomrom eller enter. Bruk piltastane når du dreg for å flytta feltet opp eller ned. Trykk mellomrom eller enter ein gong til for å sleppa feltet på den nye plassen, eller trykk escape for å avbryta.", + "account_edit.field_reorder_modal.drag_move": "Feltet «{item}» vart flytt.", + "account_edit.field_reorder_modal.drag_over": "Feltet «{item}» vart flytt over «{over}».", + "account_edit.field_reorder_modal.drag_start": "Plukka opp feltet «{item}».", + "account_edit.field_reorder_modal.handle_label": "Dra feltet «{item}»", + "account_edit.field_reorder_modal.title": "Flytt felt", + "account_edit.image_alt_modal.add_title": "Legg til skildring", + "account_edit.image_alt_modal.details_content": "JA:
  • Skildre deg sjølv på biletet
  • Bruk tredjeperson (til dømes «Anne» i staden for «meg»)
  • Skriv stutt
NEI:
  • Start med «bilete av». Skjermlesarar treng ikkje det.
DØME:
  • «Anne har på seg ei grøn skjorte og briller»
", + "account_edit.image_alt_modal.details_title": "Tips: Skrildre profilbilete", + "account_edit.image_alt_modal.edit_title": "Rediger skildring", + "account_edit.image_alt_modal.text_hint": "Skildringar hjelper folk som bruker skjermlesarar å forstå deg.", + "account_edit.image_alt_modal.text_label": "Skildring", + "account_edit.image_delete_modal.confirm": "Vil du sletta dette biletet? Du kan ikkje angra.", + "account_edit.image_delete_modal.delete_button": "Slett", + "account_edit.image_delete_modal.title": "Slett bilete?", + "account_edit.image_edit.add_button": "Legg til bilete", + "account_edit.image_edit.alt_add_button": "Legg til skildring", + "account_edit.image_edit.alt_edit_button": "Rediger skildring", + "account_edit.image_edit.remove_button": "Fjern bilete", + "account_edit.image_edit.replace_button": "Erstatt bilete", "account_edit.name_modal.add_title": "Legg til synleg namn", "account_edit.name_modal.edit_title": "Endre synleg namn", "account_edit.profile_tab.button_label": "Tilpass", @@ -177,6 +212,16 @@ "account_edit.profile_tab.subtitle": "Tilpass fanene på profilen din og kva dei syner.", "account_edit.profile_tab.title": "Innstillingar for profilfane", "account_edit.save": "Lagre", + "account_edit.upload_modal.back": "Tilbake", + "account_edit.upload_modal.done": "Ferdig", + "account_edit.upload_modal.next": "Neste", + "account_edit.upload_modal.step_crop.zoom": "Forstørre", + "account_edit.upload_modal.step_upload.button": "Sjå gjennom filer", + "account_edit.upload_modal.step_upload.dragging": "Slepp for å lasta opp", + "account_edit.upload_modal.step_upload.header": "Vel eit bilete", + "account_edit.upload_modal.step_upload.hint": "WEBP, PNG, GIF eller JPG-format, opp til {limit}MB.{br}Biletet blir skalert til {width}*{height} punkt.", + "account_edit.upload_modal.title_add": "Legg til profilbilete", + "account_edit.upload_modal.title_replace": "Byt ut profilbilete", "account_edit_tags.add_tag": "Legg til #{tagName}", "account_edit_tags.column_title": "Rediger utvalde emneknaggar", "account_edit_tags.help_text": "Utvalde emneknaggar hjelper folk å oppdaga og samhandla med profilen din. Dei blir viste som filter på aktivitetsoversikta på profilsida di.", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 32e431cef8..92b260542b 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -212,7 +212,7 @@ "account_edit.profile_tab.subtitle": "自定义你个人资料的标签页及其显示的内容。", "account_edit.profile_tab.title": "个人资料标签页设置", "account_edit.save": "保存", - "account_edit.upload_modal.back": "返回", + "account_edit.upload_modal.back": "上一步", "account_edit.upload_modal.done": "完成", "account_edit.upload_modal.next": "下一步", "account_edit.upload_modal.step_crop.zoom": "缩放", diff --git a/config/locales/da.yml b/config/locales/da.yml index adf3049fd4..d12d68a6d0 100644 --- a/config/locales/da.yml +++ b/config/locales/da.yml @@ -1295,6 +1295,7 @@ da: invited_by: 'Du kan tilmelde dig %{domain} takket være den invitation, du har modtaget fra:' preamble: Disse er opsat og håndhæves af %{domain}-moderatorerne. preamble_invited: Før du fortsætter, bedes du overveje de grundregler, der er fastsat af moderatorerne af %{domain}. + read_more: Læs mere title: Nogle grundregler. title_invited: Du er blevet inviteret. security: Sikkerhed diff --git a/config/locales/de.yml b/config/locales/de.yml index f6eb4a6d7b..f981cff223 100644 --- a/config/locales/de.yml +++ b/config/locales/de.yml @@ -1295,6 +1295,7 @@ de: invited_by: 'Du kannst %{domain} beitreten – dank der Einladung von:' preamble: Diese werden von den Moderator*innen von %{domain} festgelegt und durchgesetzt. preamble_invited: Bevor du fortfährst, beachte bitte die Grundregeln der Moderator*innen von %{domain}. + read_more: Weiterlesen title: Einige Grundregeln. title_invited: Du wurdest eingeladen. security: Sicherheit diff --git a/config/locales/el.yml b/config/locales/el.yml index 6b808e7339..b0619d1521 100644 --- a/config/locales/el.yml +++ b/config/locales/el.yml @@ -1295,6 +1295,7 @@ el: invited_by: 'Μπορείτε να συμμετάσχεις στο %{domain} χάρη στην πρόσκληση που έλαβες από:' preamble: Αυτά ορίζονται και επιβάλλονται από τους συντονιστές του%{domain}. preamble_invited: Πριν συνεχίσεις, παρακαλώ δώσε προσοχή στους βασικούς κανόνες που έχουν οριστεί από τους συντονιστές του %{domain}. + read_more: Διαβάστε περισσότερα title: Ορισμένοι βασικοί κανόνες. title_invited: Έχεις προσκληθεί. security: Ασφάλεια diff --git a/config/locales/es-AR.yml b/config/locales/es-AR.yml index c43de642a8..e168affc4a 100644 --- a/config/locales/es-AR.yml +++ b/config/locales/es-AR.yml @@ -1295,6 +1295,7 @@ es-AR: invited_by: 'Podés unirte a %{domain} gracias a la invitación que recibiste de:' preamble: Estas reglas son establecidas y aplicadas por los moderadores de %{domain}. preamble_invited: Antes de continuar, por favor, tené en cuenta las reglas básicas establecidas por los moderadores de %{domain}. + read_more: Leé más title: Algunas reglas básicas. title_invited: Te invitaron. security: Seguridad diff --git a/config/locales/fr-CA.yml b/config/locales/fr-CA.yml index 5ccda09b4b..33e33fae6b 100644 --- a/config/locales/fr-CA.yml +++ b/config/locales/fr-CA.yml @@ -1282,6 +1282,7 @@ fr-CA: progress: confirm: Confirmation de l'adresse mail details: Vos infos + list: Progression de l'inscription review: Notre avis rules: Accepter les règles providers: diff --git a/config/locales/fr.yml b/config/locales/fr.yml index f330733b08..c1008137cb 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -1282,6 +1282,7 @@ fr: progress: confirm: Confirmation de l'adresse mail details: Vos infos + list: Progression de l'inscription review: Notre avis rules: Accepter les règles providers: diff --git a/config/locales/ga.yml b/config/locales/ga.yml index 1d6f2c68e4..802ae59001 100644 --- a/config/locales/ga.yml +++ b/config/locales/ga.yml @@ -1344,6 +1344,7 @@ ga: progress: confirm: Deimhnigh ríomhphost details: Do chuid sonraí + list: Dul chun cinn clárúcháin review: Ár léirmheas rules: Glac le rialacha providers: diff --git a/config/locales/gl.yml b/config/locales/gl.yml index b16f32631a..115b9ab3df 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -1295,6 +1295,7 @@ gl: invited_by: 'Podes unirte a %{domain} grazas ao convite que recibiches de parte de:' preamble: Son establecidas e aplicadas pola moderación de %{domain}. preamble_invited: Antes de continuar adica un minuto a ler as regras básicas establecidas para %{domain}. + read_more: Ler máis title: Algunhas regras básicas. title_invited: Convidáronte. security: Seguranza diff --git a/config/locales/is.yml b/config/locales/is.yml index 19808132a2..b73792eea7 100644 --- a/config/locales/is.yml +++ b/config/locales/is.yml @@ -1299,6 +1299,7 @@ is: invited_by: 'Þú getur tekið þátt í %{domain} þökk sé boði sem þú fékkst frá:' preamble: Þær eru settar og þeim framfylgt af umsjónarmönnum %{domain}. preamble_invited: Áður en haldið er lengra, vinsamlegast kynntu þér reglurnar sem stjórnendur %{domain} hafa sett. + read_more: Lesa meira title: Nokkrar grunnreglur. title_invited: Þér hefur verið boðið. security: Öryggi diff --git a/config/locales/it.yml b/config/locales/it.yml index 987464371f..8c890f0baf 100644 --- a/config/locales/it.yml +++ b/config/locales/it.yml @@ -1295,6 +1295,7 @@ it: invited_by: 'Puoi unirti a %{domain} grazie all''invito che hai ricevuto da:' preamble: Questi sono impostati e applicati dai moderatori di %{domain}. preamble_invited: Prima di procedere, si prega di considerare le regole di base stabilite dai moderatori di %{domain}. + read_more: Scopri di più title: Alcune regole di base. title_invited: Sei stato/a invitato/a. security: Credenziali diff --git a/config/locales/simple_form.de.yml b/config/locales/simple_form.de.yml index 5aec2c90ad..589ad5da47 100644 --- a/config/locales/simple_form.de.yml +++ b/config/locales/simple_form.de.yml @@ -137,7 +137,7 @@ de: indexable: Deine Profilseite kann in Suchergebnissen auf Google, Bing und anderen erscheinen. show_application: Du wirst immer sehen können, über welche App dein Beitrag veröffentlicht wurde. tag: - name: Du kannst nur die Groß- und Kleinschreibung der Buchstaben ändern, um es z. B. lesbarer zu machen + name: Du kannst nur die Groß- und Kleinschreibung der Buchstaben ändern, um z. B. die Lesbarkeit zu verbessern terms_of_service: changelog: Kann mit der Markdown-Syntax formatiert werden. effective_date: Ein angemessener Zeitraum liegt zwischen 10 und 30 Tagen, nachdem deine Nutzer*innen benachrichtigt wurden. diff --git a/config/locales/simple_form.el.yml b/config/locales/simple_form.el.yml index 99601972ed..1477f1e2b1 100644 --- a/config/locales/simple_form.el.yml +++ b/config/locales/simple_form.el.yml @@ -41,7 +41,7 @@ el: defaults: autofollow: Όσοι εγγραφούν μέσω της πρόσκλησης θα σε ακολουθούν αυτόματα avatar: WEBP, PNG, GIF ή JPG. Το πολύ %{size}. Θα υποβαθμιστεί σε %{dimensions}px - bot: Ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται + bot: Υποδεικνύει σε άλλους χρήστες ότι ο λογαριασμός αυτός εκτελεί κυρίως αυτοματοποιημένες ενέργειες και ίσως να μην παρακολουθείται context: Ένα ή περισσότερα πλαίσια στα οποία μπορεί να εφαρμόζεται αυτό το φίλτρο current_password: Για λόγους ασφαλείας παρακαλώ γράψε τον κωδικό του τρέχοντος λογαριασμού current_username: Για επιβεβαίωση, παρακαλώ γράψε το όνομα χρήστη του τρέχοντος λογαριασμού diff --git a/config/locales/sq.yml b/config/locales/sq.yml index 4ac19efd40..e622ce470b 100644 --- a/config/locales/sq.yml +++ b/config/locales/sq.yml @@ -1284,6 +1284,7 @@ sq: invited_by: 'Mund të bëheni pjesë e %{domain} falë ftesës që morët prej:' preamble: Këto vendosen dhe zbatimi i tyre është nën kujdesin e moderatorëve të %{domain}. preamble_invited: Para se të vazhdoni më tej, ju lutemi, shihni rregullat bazë të vendosura nga moderatorët e %{domain}. + read_more: Lexoni më tepër title: Disa rregulla bazë. title_invited: Jeni ftuar. security: Siguri diff --git a/config/locales/sv.yml b/config/locales/sv.yml index 72a8b4cdbb..897ee840d4 100644 --- a/config/locales/sv.yml +++ b/config/locales/sv.yml @@ -1291,6 +1291,7 @@ sv: invited_by: 'Du kan gå med i %{domain} tack vare den inbjudan du har fått från:' preamble: Dessa bestäms och upprätthålls av moderatorerna för %{domain}. preamble_invited: Innan du fortsätter bör du överväga grundreglerna som fastställts av moderatorerna för %{domain}. + read_more: Läs mer title: Några grundregler. title_invited: Du har blivit inbjuden. security: Säkerhet diff --git a/config/locales/vi.yml b/config/locales/vi.yml index 7fb70916e5..4da034dd77 100644 --- a/config/locales/vi.yml +++ b/config/locales/vi.yml @@ -1274,6 +1274,7 @@ vi: invited_by: 'Bạn có thể tham gia %{domain} với thư mời từ:' preamble: Được ban hành và áp dụng bởi quản trị máy chủ %{domain}. preamble_invited: Trước khi tiếp tục, hãy đọc nội quy của %{domain}. + read_more: Đọc tiếp title: Nội quy máy chủ. title_invited: Bạn vừa được mời. security: Bảo mật diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 79ebb5fe00..cc6a6c15c0 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -1274,6 +1274,7 @@ zh-CN: invited_by: 欢迎加入%{domain},你是通过以下用户的邀请加入的: preamble: 以下规则由 %{domain} 的管理员设定并执行。 preamble_invited: 在继续操作前,请先阅读并同意 %{domain} 管理员设置的基本规则。 + read_more: 查看更多 title: 一些基本规则。 title_invited: 通过邀请加入 security: 账号安全 diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml index 90b9077c3c..d845de83db 100644 --- a/config/locales/zh-TW.yml +++ b/config/locales/zh-TW.yml @@ -1276,6 +1276,7 @@ zh-TW: invited_by: 您可以藉由來自此處之邀請而加入 %{domain} preamble: 這些被 %{domain} 的管管們制定以及實施。 preamble_invited: 在您繼續之前,請考慮由 %{domain} 管理員設立的伺服器規則。 + read_more: 閱讀更多 title: 一些基本守則。 title_invited: 我們誠摯地邀請您。 security: 登入資訊 From 71e92ca32a0520be5dda24a0cffe97a2733068c0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:43:56 +0100 Subject: [PATCH 04/11] Update dependency libvips to v8.18.1 (#38276) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 6a191b7e85..c26f957cee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -181,7 +181,7 @@ FROM build AS libvips # libvips version to compile, change with [--build-arg VIPS_VERSION="8.15.2"] # renovate: datasource=github-releases depName=libvips packageName=libvips/libvips -ARG VIPS_VERSION=8.18.0 +ARG VIPS_VERSION=8.18.1 # libvips download URL, change with [--build-arg VIPS_URL="https://github.com/libvips/libvips/releases/download"] ARG VIPS_URL=https://github.com/libvips/libvips/releases/download From f5aa8e1b252b29585bb1e587779686b48ca9b124 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:12:26 +0100 Subject: [PATCH 05/11] Update dependency webmock to v3.26.2 (#38270) 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 84eb20d3ab..0361c7591b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -922,7 +922,7 @@ GEM activesupport faraday (~> 2.0) faraday-follow_redirects - webmock (3.26.1) + webmock (3.26.2) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) From 8c6fdad0ca4f1ad863fc436e037e7317961c1127 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:12:30 +0100 Subject: [PATCH 06/11] Update dependency capybara-playwright-driver to v0.5.9 (#38269) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 0361c7591b..3bf8771494 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -150,7 +150,7 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-playwright-driver (0.5.8) + capybara-playwright-driver (0.5.9) addressable capybara playwright-ruby-client (>= 1.16.0) @@ -446,7 +446,7 @@ GEM mime-types (3.7.0) logger mime-types-data (~> 3.2025, >= 3.2025.0507) - mime-types-data (3.2026.0303) + mime-types-data (3.2026.0317) mini_mime (1.1.5) mini_portile2 (2.8.9) minitest (6.0.2) From d4c7ac5cd5c622416a255cd604a05751a8d242a6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 11:14:39 +0100 Subject: [PATCH 07/11] Update opentelemetry-ruby (non-major) (#38273) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Gemfile | 14 +++++++------- Gemfile.lock | 30 +++++++++++++++--------------- 2 files changed, 22 insertions(+), 22 deletions(-) diff --git a/Gemfile b/Gemfile index a2941923a2..40f95d56e9 100644 --- a/Gemfile +++ b/Gemfile @@ -109,14 +109,14 @@ group :opentelemetry do gem 'opentelemetry-instrumentation-active_job', '~> 0.10.0', require: false gem 'opentelemetry-instrumentation-active_model_serializers', '~> 0.24.0', require: false gem 'opentelemetry-instrumentation-concurrent_ruby', '~> 0.24.0', require: false - gem 'opentelemetry-instrumentation-excon', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-faraday', '~> 0.31.0', require: false - gem 'opentelemetry-instrumentation-http', '~> 0.28.0', require: false - gem 'opentelemetry-instrumentation-http_client', '~> 0.27.0', require: false - gem 'opentelemetry-instrumentation-net_http', '~> 0.27.0', require: false + gem 'opentelemetry-instrumentation-excon', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-faraday', '~> 0.32.0', require: false + gem 'opentelemetry-instrumentation-http', '~> 0.29.0', require: false + gem 'opentelemetry-instrumentation-http_client', '~> 0.28.0', require: false + gem 'opentelemetry-instrumentation-net_http', '~> 0.28.0', require: false gem 'opentelemetry-instrumentation-pg', '~> 0.35.0', require: false - gem 'opentelemetry-instrumentation-rack', '~> 0.29.0', require: false - gem 'opentelemetry-instrumentation-rails', '~> 0.39.0', require: false + gem 'opentelemetry-instrumentation-rack', '~> 0.30.0', require: false + gem 'opentelemetry-instrumentation-rails', '~> 0.40.0', require: false gem 'opentelemetry-instrumentation-redis', '~> 0.28.0', require: false gem 'opentelemetry-instrumentation-sidekiq', '~> 0.28.0', require: false gem 'opentelemetry-sdk', '~> 1.4', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 3bf8771494..7a8cee04f6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -525,7 +525,7 @@ GEM opentelemetry-common (~> 0.21) opentelemetry-instrumentation-action_mailer (0.6.1) opentelemetry-instrumentation-active_support (~> 0.10) - opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-action_pack (0.16.0) opentelemetry-instrumentation-rack (~> 0.29) opentelemetry-instrumentation-action_view (0.11.2) opentelemetry-instrumentation-active_support (~> 0.10) @@ -545,23 +545,23 @@ GEM opentelemetry-registry (~> 0.1) opentelemetry-instrumentation-concurrent_ruby (0.24.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-excon (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-faraday (0.32.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-http (0.29.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-http_client (0.28.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-net_http (0.28.0) opentelemetry-instrumentation-base (~> 0.25) opentelemetry-instrumentation-pg (0.35.0) opentelemetry-helpers-sql opentelemetry-helpers-sql-processor opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-rack (0.30.0) opentelemetry-instrumentation-base (~> 0.25) - opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-rails (0.40.0) opentelemetry-instrumentation-action_mailer (~> 0.6) opentelemetry-instrumentation-action_pack (~> 0.15) opentelemetry-instrumentation-action_view (~> 0.11) @@ -1023,14 +1023,14 @@ DEPENDENCIES opentelemetry-instrumentation-active_job (~> 0.10.0) opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) - opentelemetry-instrumentation-excon (~> 0.27.0) - opentelemetry-instrumentation-faraday (~> 0.31.0) - opentelemetry-instrumentation-http (~> 0.28.0) - opentelemetry-instrumentation-http_client (~> 0.27.0) - opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-excon (~> 0.28.0) + opentelemetry-instrumentation-faraday (~> 0.32.0) + opentelemetry-instrumentation-http (~> 0.29.0) + opentelemetry-instrumentation-http_client (~> 0.28.0) + opentelemetry-instrumentation-net_http (~> 0.28.0) opentelemetry-instrumentation-pg (~> 0.35.0) - opentelemetry-instrumentation-rack (~> 0.29.0) - opentelemetry-instrumentation-rails (~> 0.39.0) + opentelemetry-instrumentation-rack (~> 0.30.0) + opentelemetry-instrumentation-rails (~> 0.40.0) opentelemetry-instrumentation-redis (~> 0.28.0) opentelemetry-instrumentation-sidekiq (~> 0.28.0) opentelemetry-sdk (~> 1.4) From 2ce4529e1e40428068339457bc54fb675936a9b2 Mon Sep 17 00:00:00 2001 From: Matt Jankowski Date: Thu, 19 Mar 2026 06:20:42 -0400 Subject: [PATCH 08/11] Final backup service prep for JSON cleanup (#38284) --- app/services/backup_service.rb | 78 ++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/app/services/backup_service.rb b/app/services/backup_service.rb index 56a3a23c6a..b651a0838f 100644 --- a/app/services/backup_service.rb +++ b/app/services/backup_service.rb @@ -9,6 +9,11 @@ class BackupService < BaseService CHUNK_SIZE = 1.megabyte PLACEHOLDER = '!PLACEHOLDER!' + STREAM_ACTOR = 'actor.json' + STREAM_BOOKMARKS = 'bookmarks.json' + STREAM_LIKES = 'likes.json' + STREAM_OUTBOX = 'outbox.json' + attr_reader :account, :backup def call(backup) @@ -21,7 +26,7 @@ class BackupService < BaseService private def build_outbox_json!(file) - skeleton = serialize(collection_presenter, ActivityPub::CollectionSerializer) + skeleton = serialize(collection_presenter(STREAM_OUTBOX, size: account.statuses.count), ActivityPub::CollectionSerializer) skeleton[:@context] = full_context skeleton[:orderedItems] = [PLACEHOLDER] skeleton = JSON.generate(skeleton) @@ -55,17 +60,9 @@ class BackupService < BaseService def build_archive! tmp_file = Tempfile.new(%w(archive .zip)) - Zip::File.open(tmp_file, create: true) do |zipfile| - dump_outbox!(zipfile) - dump_media_attachments!(zipfile) - dump_likes!(zipfile) - dump_bookmarks!(zipfile) - dump_actor!(zipfile) - end + build_zip_file(tmp_file) - archive_filename = "#{['archive', Time.current.to_fs(:number), SecureRandom.hex(16)].join('-')}.zip" - - @backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename) + @backup.dump = ActionDispatch::Http::UploadedFile.new(tempfile: tmp_file, filename: archive_filename) @backup.processed = true @backup.save! ensure @@ -73,6 +70,24 @@ class BackupService < BaseService tmp_file.unlink end + def build_zip_file(file) + Zip::File.open(file, create: true) do |zip| + dump_outbox!(zip) + dump_media_attachments!(zip) + dump_likes!(zip) + dump_bookmarks!(zip) + dump_actor!(zip) + end + end + + def archive_filename + "#{archive_id}.zip" + end + + def archive_id + [:archive, Time.current.to_fs(:number), SecureRandom.hex(16)].join('-') + end + def dump_media_attachments!(zipfile) MediaAttachment.attached.where(account: account).find_in_batches do |media_attachments| media_attachments.each do |m| @@ -89,7 +104,7 @@ class BackupService < BaseService end def dump_outbox!(zipfile) - zipfile.get_output_stream('outbox.json') do |io| + zipfile.get_output_stream(STREAM_OUTBOX) do |io| build_outbox_json!(io) end end @@ -99,31 +114,32 @@ class BackupService < BaseService actor[:icon][:url] = "avatar#{File.extname(actor[:icon][:url])}" if actor[:icon] actor[:image][:url] = "header#{File.extname(actor[:image][:url])}" if actor[:image] - actor[:outbox] = 'outbox.json' - actor[:likes] = 'likes.json' - actor[:bookmarks] = 'bookmarks.json' + actor[:outbox] = STREAM_OUTBOX + actor[:likes] = STREAM_LIKES + actor[:bookmarks] = STREAM_BOOKMARKS download_to_zip(zipfile, account.avatar, "avatar#{File.extname(account.avatar.path)}") if account.avatar.exists? download_to_zip(zipfile, account.header, "header#{File.extname(account.header.path)}") if account.header.exists? json = JSON.generate(actor) - zipfile.get_output_stream('actor.json') do |io| + zipfile.get_output_stream(STREAM_ACTOR) do |io| io.write(json) end end def dump_likes!(zipfile) - skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'likes.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer) + skeleton = serialize(collection_presenter(STREAM_LIKES), ActivityPub::CollectionSerializer) + skeleton.delete(:totalItems) skeleton[:orderedItems] = [PLACEHOLDER] skeleton = JSON.generate(skeleton) prepend, append = skeleton.split(PLACEHOLDER.to_json) - zipfile.get_output_stream('likes.json') do |io| + zipfile.get_output_stream(STREAM_LIKES) do |io| io.write(prepend) - Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites).find_in_batches.with_index do |statuses, batch| + favourite_statuses.find_in_batches.with_index do |statuses, batch| io.write(',') unless batch.zero? io.write(statuses.map do |status| @@ -137,17 +153,21 @@ class BackupService < BaseService end end + def favourite_statuses + Status.reorder(nil).joins(:favourites).includes(:account).merge(account.favourites) + end + def dump_bookmarks!(zipfile) - skeleton = serialize(ActivityPub::CollectionPresenter.new(id: 'bookmarks.json', type: :ordered, size: 0, items: []), ActivityPub::CollectionSerializer) + skeleton = serialize(collection_presenter(STREAM_BOOKMARKS), ActivityPub::CollectionSerializer) skeleton.delete(:totalItems) skeleton[:orderedItems] = [PLACEHOLDER] skeleton = JSON.generate(skeleton) prepend, append = skeleton.split(PLACEHOLDER.to_json) - zipfile.get_output_stream('bookmarks.json') do |io| + zipfile.get_output_stream(STREAM_BOOKMARKS) do |io| io.write(prepend) - Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks).find_in_batches.with_index do |statuses, batch| + bookmark_statuses.find_in_batches.with_index do |statuses, batch| io.write(',') unless batch.zero? io.write(statuses.map do |status| @@ -161,12 +181,16 @@ class BackupService < BaseService end end - def collection_presenter + def bookmark_statuses + Status.reorder(nil).joins(:bookmarks).includes(:account).merge(account.bookmarks) + end + + def collection_presenter(id, size: 0) ActivityPub::CollectionPresenter.new( - id: 'outbox.json', - type: :ordered, - size: account.statuses_count, - items: [] + id:, + items: [], + size:, + type: :ordered ) end From 43d66959aaef15b2ef26e9fa41e644cbddf89f2a Mon Sep 17 00:00:00 2001 From: diondiondion Date: Thu, 19 Mar 2026 11:58:59 +0100 Subject: [PATCH 09/11] Sign-up server rules: Show different icon on toggle button when description is expanded (#38293) --- app/javascript/styles/mastodon/about.scss | 5 +++++ app/views/auth/rule_translations/_rule_translation.html.haml | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/javascript/styles/mastodon/about.scss b/app/javascript/styles/mastodon/about.scss index cb4885a7e8..3f8145ea6d 100644 --- a/app/javascript/styles/mastodon/about.scss +++ b/app/javascript/styles/mastodon/about.scss @@ -85,6 +85,11 @@ $fluid-breakpoint: $maximum-width + 20px; display: none; } + &[aria-expanded='true'] .icon-expand, + &[aria-expanded='false'] .icon-collapse { + display: none; + } + .icon { width: 1lh; height: 1lh; diff --git a/app/views/auth/rule_translations/_rule_translation.html.haml b/app/views/auth/rule_translations/_rule_translation.html.haml index a8c307270f..cbb53acdf4 100644 --- a/app/views/auth/rule_translations/_rule_translation.html.haml +++ b/app/views/auth/rule_translations/_rule_translation.html.haml @@ -4,5 +4,6 @@ .rules-list__hint{ tabIndex: -1 } %span.rules-list__hint-text= rule_translation.hint %button.rules-list__toggle-button{ type: 'button', hidden: true, 'aria-expanded': 'false' } - = material_symbol('more_horiz') + = material_symbol('keyboard_arrow_up', { class: 'icon-collapse' }) + = material_symbol('more_horiz', { class: 'icon-expand' }) %span.sr-only= t('auth.rules.read_more') From 2af5c8551dce440b3bc297951ef6095c9df1577b Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 19 Mar 2026 12:04:15 +0100 Subject: [PATCH 10/11] Profile redesign: Adjust account number fields to be stacked (#38283) --- .../components/number_fields.tsx | 125 ++++++++++++------ .../components/redesign.module.scss | 18 +++ app/javascript/mastodon/locales/en.json | 1 - 3 files changed, 104 insertions(+), 40 deletions(-) diff --git a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx b/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx index 41d5c36ed7..98de08ee4d 100644 --- a/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx +++ b/app/javascript/mastodon/features/account_timeline/components/number_fields.tsx @@ -1,3 +1,4 @@ +import { useMemo } from 'react'; import type { FC } from 'react'; import { FormattedMessage, useIntl } from 'react-intl'; @@ -18,9 +19,7 @@ import { isRedesignEnabled } from '../common'; import classes from './redesign.module.scss'; -export const AccountNumberFields: FC<{ accountId: string }> = ({ - accountId, -}) => { +const LegacyNumberFields: FC<{ accountId: string }> = ({ accountId }) => { const intl = useIntl(); const account = useAccount(accountId); @@ -29,23 +28,16 @@ export const AccountNumberFields: FC<{ accountId: string }> = ({ } return ( -
- {!isRedesignEnabled() && ( - - - - )} +
+ + + = ({ renderer={FollowersCounter} /> - - {isRedesignEnabled() && ( - - - - ), - }} - /> - )}
); }; + +const RedesignNumberFields: FC<{ accountId: string }> = ({ accountId }) => { + const intl = useIntl(); + const account = useAccount(accountId); + const createdThisYear = useMemo( + () => account?.created_at.includes(new Date().getFullYear().toString()), + [account?.created_at], + ); + + if (!account) { + return null; + } + + return ( +
    +
  • + + + + +
  • + +
  • + + + + + + +
  • + +
  • + + + + + + +
  • + +
  • + + + {createdThisYear ? ( + + ) : ( + + )} + +
  • +
+ ); +}; + +export const AccountNumberFields = isRedesignEnabled() + ? RedesignNumberFields + : LegacyNumberFields; diff --git a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss index e761c761d9..bf3736c096 100644 --- a/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss +++ b/app/javascript/mastodon/features/account_timeline/components/redesign.module.scss @@ -308,16 +308,34 @@ svg.badgeIcon { } .fieldNumbersWrapper { + display: flex; font-size: 13px; padding: 0; + margin: 8px 0; + gap: 20px; + + li { + @container (width < 420px) { + flex: 1 1 0px; + } + } a { + color: inherit; font-weight: unset; + padding: 0; + + &:hover, + &:focus { + color: var(--color-text-brand-soft); + } } strong { + display: block; font-weight: 600; color: var(--color-text-primary); + font-size: 15px; } } diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index e0759e26ce..f3b35a7741 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -73,7 +73,6 @@ "account.go_to_profile": "Go to profile", "account.hide_reblogs": "Hide boosts from @{name}", "account.in_memoriam": "In Memoriam.", - "account.joined_long": "Joined on {date}", "account.joined_short": "Joined", "account.languages": "Change subscribed languages", "account.link_verified_on": "Ownership of this link was checked on {date}", From cffa8de6267a57673b7f6264fab671153f965d23 Mon Sep 17 00:00:00 2001 From: Echo Date: Thu, 19 Mar 2026 12:15:23 +0100 Subject: [PATCH 11/11] Refactor: Relative timestamp component (#38275) --- app/javascript/entrypoints/public.tsx | 32 +- .../mastodon/components/account/index.tsx | 2 +- .../components/edited_timestamp/index.tsx | 5 +- app/javascript/mastodon/components/poll.tsx | 2 +- .../components/relative_timestamp.tsx | 288 ------------------ .../components/relative_timestamp/index.tsx | 83 +++++ .../relative_timestamp.stories.tsx | 65 ++++ .../detail/collection_list_item.tsx | 7 +- .../notifications/components/report.jsx | 2 +- .../ui/components/compare_history_modal.jsx | 2 +- app/javascript/mastodon/utils/time.test.ts | 36 +++ app/javascript/mastodon/utils/time.ts | 246 +++++++++++++++ 12 files changed, 455 insertions(+), 315 deletions(-) delete mode 100644 app/javascript/mastodon/components/relative_timestamp.tsx create mode 100644 app/javascript/mastodon/components/relative_timestamp/index.tsx create mode 100644 app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx create mode 100644 app/javascript/mastodon/utils/time.test.ts create mode 100644 app/javascript/mastodon/utils/time.ts diff --git a/app/javascript/entrypoints/public.tsx b/app/javascript/entrypoints/public.tsx index 4089575e41..8b67698f20 100644 --- a/app/javascript/entrypoints/public.tsx +++ b/app/javascript/entrypoints/public.tsx @@ -1,14 +1,20 @@ import { createRoot } from 'react-dom/client'; import { IntlMessageFormat } from 'intl-messageformat'; -import type { MessageDescriptor, PrimitiveType } from 'react-intl'; +import type { + FormatDateOptions, + IntlShape, + MessageDescriptor, + PrimitiveType, +} from 'react-intl'; import { defineMessages } from 'react-intl'; import axios from 'axios'; import { on } from 'delegated-events'; import { throttle } from 'lodash'; -import { timeAgoString } from '../mastodon/components/relative_timestamp'; +import { formatTime } from '@/mastodon/utils/time'; + import emojify from '../mastodon/features/emoji/emoji'; import loadKeyboardExtensions from '../mastodon/load_keyboard_extensions'; import { loadLocale, getLocale } from '../mastodon/locales'; @@ -58,7 +64,7 @@ function loaded() { const formatMessage = ( { id, defaultMessage }: MessageDescriptor, values?: Record, - ) => { + ): string => { let message: string | undefined = undefined; if (id) message = localeData[id]; @@ -126,23 +132,23 @@ function loaded() { .querySelectorAll('time.time-ago') .forEach((content) => { const datetime = new Date(content.dateTime); - const now = new Date(); const timeGiven = content.dateTime.includes('T'); content.title = timeGiven ? dateTimeFormat.format(datetime) : dateFormat.format(datetime); - content.textContent = timeAgoString( - { - formatMessage, - formatDate: (date: Date, options) => + const now = Date.now(); + content.textContent = formatTime({ + // We don't want to show future dates. + timestamp: Math.min(datetime.getTime(), now), + now, + intl: { + formatMessage: formatMessage as IntlShape['formatMessage'], + formatDate: (date: Date, options: FormatDateOptions) => new Intl.DateTimeFormat(locale, options).format(date), }, - datetime, - now.getTime(), - now.getFullYear(), - timeGiven, - ); + noTime: !timeGiven, + }); }); updateDefaultQuotePrivacyFromPrivacy( diff --git a/app/javascript/mastodon/components/account/index.tsx b/app/javascript/mastodon/components/account/index.tsx index 7397dfd1d1..f8668bce32 100644 --- a/app/javascript/mastodon/components/account/index.tsx +++ b/app/javascript/mastodon/components/account/index.tsx @@ -275,7 +275,7 @@ export const Account: React.FC = ({ if (account?.mute_expires_at) { muteTimeRemaining = ( <> - · + · ); } diff --git a/app/javascript/mastodon/components/edited_timestamp/index.tsx b/app/javascript/mastodon/components/edited_timestamp/index.tsx index 36f8db8abf..eb07559cb2 100644 --- a/app/javascript/mastodon/components/edited_timestamp/index.tsx +++ b/app/javascript/mastodon/components/edited_timestamp/index.tsx @@ -60,10 +60,7 @@ export const EditedTimestamp: React.FC<{ const renderItem = useCallback( (item: HistoryItem, index: number, onClick: React.MouseEventHandler) => { const formattedDate = ( - + ); const formattedName = ( diff --git a/app/javascript/mastodon/components/poll.tsx b/app/javascript/mastodon/components/poll.tsx index b5b5fb3673..3ab31f4229 100644 --- a/app/javascript/mastodon/components/poll.tsx +++ b/app/javascript/mastodon/components/poll.tsx @@ -70,7 +70,7 @@ export const Poll: React.FC = ({ pollId, disabled, status }) => { if (expired) { return intl.formatMessage(messages.closed); } - return ; + return ; }, [expired, intl, poll]); const votesCount = useMemo(() => { if (!poll) { diff --git a/app/javascript/mastodon/components/relative_timestamp.tsx b/app/javascript/mastodon/components/relative_timestamp.tsx deleted file mode 100644 index 6253525091..0000000000 --- a/app/javascript/mastodon/components/relative_timestamp.tsx +++ /dev/null @@ -1,288 +0,0 @@ -import { Component } from 'react'; - -import type { MessageDescriptor, PrimitiveType, IntlShape } from 'react-intl'; -import { injectIntl, defineMessages } from 'react-intl'; - -const messages = defineMessages({ - today: { id: 'relative_time.today', defaultMessage: 'today' }, - just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, - just_now_full: { - id: 'relative_time.full.just_now', - defaultMessage: 'just now', - }, - seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, - seconds_full: { - id: 'relative_time.full.seconds', - defaultMessage: '{number, plural, one {# second} other {# seconds}} ago', - }, - minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, - minutes_full: { - id: 'relative_time.full.minutes', - defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago', - }, - hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, - hours_full: { - id: 'relative_time.full.hours', - defaultMessage: '{number, plural, one {# hour} other {# hours}} ago', - }, - days: { id: 'relative_time.days', defaultMessage: '{number}d' }, - days_full: { - id: 'relative_time.full.days', - defaultMessage: '{number, plural, one {# day} other {# days}} ago', - }, - moments_remaining: { - id: 'time_remaining.moments', - defaultMessage: 'Moments remaining', - }, - seconds_remaining: { - id: 'time_remaining.seconds', - defaultMessage: '{number, plural, one {# second} other {# seconds}} left', - }, - minutes_remaining: { - id: 'time_remaining.minutes', - defaultMessage: '{number, plural, one {# minute} other {# minutes}} left', - }, - hours_remaining: { - id: 'time_remaining.hours', - defaultMessage: '{number, plural, one {# hour} other {# hours}} left', - }, - days_remaining: { - id: 'time_remaining.days', - defaultMessage: '{number, plural, one {# day} other {# days}} left', - }, -}); - -const dateFormatOptions = { - year: 'numeric', - month: 'short', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', -} as const; - -const shortDateFormatOptions = { - month: 'short', - day: 'numeric', -} as const; - -const SECOND = 1000; -const MINUTE = 1000 * 60; -const HOUR = 1000 * 60 * 60; -const DAY = 1000 * 60 * 60 * 24; - -const MAX_DELAY = 2147483647; - -const selectUnits = (delta: number) => { - const absDelta = Math.abs(delta); - - if (absDelta < MINUTE) { - return 'second'; - } else if (absDelta < HOUR) { - return 'minute'; - } else if (absDelta < DAY) { - return 'hour'; - } - - return 'day'; -}; - -const getUnitDelay = (units: string) => { - switch (units) { - case 'second': - return SECOND; - case 'minute': - return MINUTE; - case 'hour': - return HOUR; - case 'day': - return DAY; - default: - return MAX_DELAY; - } -}; - -export const timeAgoString = ( - intl: { - formatDate: IntlShape['formatDate']; - formatMessage: ( - { id, defaultMessage }: MessageDescriptor, - values?: Record, - ) => string; - }, - date: Date, - now: number, - year: number, - timeGiven: boolean, - short?: boolean, -) => { - const delta = now - date.getTime(); - - let relativeTime; - - if (delta < DAY && !timeGiven) { - relativeTime = intl.formatMessage(messages.today); - } else if (delta < 10 * SECOND) { - relativeTime = intl.formatMessage( - short ? messages.just_now : messages.just_now_full, - ); - } else if (delta < 7 * DAY) { - if (delta < MINUTE) { - relativeTime = intl.formatMessage( - short ? messages.seconds : messages.seconds_full, - { number: Math.floor(delta / SECOND) }, - ); - } else if (delta < HOUR) { - relativeTime = intl.formatMessage( - short ? messages.minutes : messages.minutes_full, - { number: Math.floor(delta / MINUTE) }, - ); - } else if (delta < DAY) { - relativeTime = intl.formatMessage( - short ? messages.hours : messages.hours_full, - { number: Math.floor(delta / HOUR) }, - ); - } else { - relativeTime = intl.formatMessage( - short ? messages.days : messages.days_full, - { number: Math.floor(delta / DAY) }, - ); - } - } else if (date.getFullYear() === year) { - relativeTime = intl.formatDate(date, shortDateFormatOptions); - } else { - relativeTime = intl.formatDate(date, { - ...shortDateFormatOptions, - year: 'numeric', - }); - } - - return relativeTime; -}; - -const timeRemainingString = ( - intl: IntlShape, - date: Date, - now: number, - timeGiven = true, -) => { - const delta = date.getTime() - now; - - let relativeTime; - - if (delta < DAY && !timeGiven) { - relativeTime = intl.formatMessage(messages.today); - } else if (delta < 10 * SECOND) { - relativeTime = intl.formatMessage(messages.moments_remaining); - } else if (delta < MINUTE) { - relativeTime = intl.formatMessage(messages.seconds_remaining, { - number: Math.floor(delta / SECOND), - }); - } else if (delta < HOUR) { - relativeTime = intl.formatMessage(messages.minutes_remaining, { - number: Math.floor(delta / MINUTE), - }); - } else if (delta < DAY) { - relativeTime = intl.formatMessage(messages.hours_remaining, { - number: Math.floor(delta / HOUR), - }); - } else { - relativeTime = intl.formatMessage(messages.days_remaining, { - number: Math.floor(delta / DAY), - }); - } - - return relativeTime; -}; - -interface Props { - intl: IntlShape; - timestamp: string; - year?: number; - futureDate?: boolean; - short?: boolean; -} -interface States { - now: number; -} -class RelativeTimestamp extends Component { - state = { - now: Date.now(), - }; - - _timer: number | undefined; - - shouldComponentUpdate(nextProps: Props, nextState: States) { - // As of right now the locale doesn't change without a new page load, - // but we might as well check in case that ever changes. - return ( - this.props.timestamp !== nextProps.timestamp || - this.props.intl.locale !== nextProps.intl.locale || - this.state.now !== nextState.now - ); - } - - UNSAFE_componentWillReceiveProps(nextProps: Props) { - if (this.props.timestamp !== nextProps.timestamp) { - this.setState({ now: Date.now() }); - } - } - - componentDidMount() { - this._scheduleNextUpdate(this.props, this.state); - } - - UNSAFE_componentWillUpdate(nextProps: Props, nextState: States) { - this._scheduleNextUpdate(nextProps, nextState); - } - - componentWillUnmount() { - window.clearTimeout(this._timer); - } - - _scheduleNextUpdate(props: Props, state: States) { - window.clearTimeout(this._timer); - - const { timestamp } = props; - const delta = new Date(timestamp).getTime() - state.now; - const unitDelay = getUnitDelay(selectUnits(delta)); - const unitRemainder = Math.abs(delta % unitDelay); - const updateInterval = 1000 * 10; - const delay = - delta < 0 - ? Math.max(updateInterval, unitDelay - unitRemainder) - : Math.max(updateInterval, unitRemainder); - - this._timer = window.setTimeout(() => { - this.setState({ now: Date.now() }); - }, delay); - } - - render() { - const { - timestamp, - intl, - futureDate, - year = new Date().getFullYear(), - short = true, - } = this.props; - - const timeGiven = timestamp.includes('T'); - const date = new Date(timestamp); - const relativeTime = futureDate - ? timeRemainingString(intl, date, this.state.now, timeGiven) - : timeAgoString(intl, date, this.state.now, year, timeGiven, short); - - return ( - - ); - } -} - -const RelativeTimestampWithIntl = injectIntl(RelativeTimestamp); - -export { RelativeTimestampWithIntl as RelativeTimestamp }; diff --git a/app/javascript/mastodon/components/relative_timestamp/index.tsx b/app/javascript/mastodon/components/relative_timestamp/index.tsx new file mode 100644 index 0000000000..493e535a71 --- /dev/null +++ b/app/javascript/mastodon/components/relative_timestamp/index.tsx @@ -0,0 +1,83 @@ +import { useEffect, useMemo, useState } from 'react'; +import type { FC } from 'react'; + +import { useIntl } from 'react-intl'; + +import { + formatTime, + MAX_TIMEOUT, + relativeTimeParts, + SECOND, + unitToTime, +} from '@/mastodon/utils/time'; + +const dateFormatOptions = { + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', +} as const; + +export const RelativeTimestamp: FC<{ + timestamp: string; + long?: boolean; + noTime?: boolean; + noFuture?: boolean; +}> = ({ timestamp, long = false, noTime = false, noFuture = false }) => { + const intl = useIntl(); + + const [now, setNow] = useState(() => Date.now()); + + const date = useMemo(() => { + const date = new Date(timestamp); + return noFuture ? new Date(Math.min(date.getTime(), now)) : date; + }, [noFuture, now, timestamp]); + const ts = date.getTime(); + + useEffect(() => { + let timeoutId = 0; + const scheduleNextUpdate = () => { + const { unit, delta } = relativeTimeParts(ts); + const unitDelay = unitToTime(unit); + const remainder = Math.abs(delta % unitDelay); + const minDelay = 10 * SECOND; + const delay = Math.min( + Math.max(delta < 0 ? unitDelay - remainder : remainder, minDelay), + MAX_TIMEOUT, + ); + + timeoutId = window.setTimeout(() => { + setNow(Date.now()); + scheduleNextUpdate(); + }, delay); + }; + + scheduleNextUpdate(); + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [ts]); + + const daysOnly = !timestamp.includes('T') || noTime; + const relativeTime = useMemo( + () => + formatTime({ + timestamp: ts, + intl, + short: !long, + noTime: daysOnly, + now, + }), + [ts, intl, long, daysOnly, now], + ); + + return ( + + ); +}; diff --git a/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx b/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx new file mode 100644 index 0000000000..978382515d --- /dev/null +++ b/app/javascript/mastodon/components/relative_timestamp/relative_timestamp.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { DAY } from '@/mastodon/utils/time'; + +import { RelativeTimestamp } from './index'; + +const meta = { + title: 'Components/RelativeTimestamp', + component: RelativeTimestamp, + args: { + timestamp: new Date(Date.now() - DAY * 3).toISOString(), + long: false, + noTime: false, + noFuture: false, + }, + argTypes: { + timestamp: { + control: 'date', + }, + }, + render(props) { + const { timestamp } = props; + const dateString = toDateString(timestamp); + + return ; + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Plain: Story = {}; + +export const Long: Story = { + args: { + long: true, + }, +}; + +export const DateOnly: Story = { + args: { + noTime: true, + }, +}; + +export const NoFuture: Story = { + args: { + timestamp: new Date(Date.now() + DAY * 3).toISOString(), + noFuture: true, + }, +}; + +// Storybook has a known bug with changing a date control from a string to number. +function toDateString(timestamp?: number | string) { + if (!timestamp) { + return new Date().toISOString(); + } + + if (typeof timestamp === 'number') { + return new Date(timestamp).toISOString(); + } + + return timestamp; +} diff --git a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx index 34516c0634..73584a9e7b 100644 --- a/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx +++ b/app/javascript/mastodon/features/collections/detail/collection_list_item.tsx @@ -53,12 +53,7 @@ export const CollectionMetaData: React.FC<{ id='collections.last_updated_at' defaultMessage='Last updated: {date}' values={{ - date: ( - - ), + date: , }} tagName='li' /> diff --git a/app/javascript/mastodon/features/notifications/components/report.jsx b/app/javascript/mastodon/features/notifications/components/report.jsx index ed043ae789..bc3631c86e 100644 --- a/app/javascript/mastodon/features/notifications/components/report.jsx +++ b/app/javascript/mastodon/features/notifications/components/report.jsx @@ -49,7 +49,7 @@ class Report extends ImmutablePureComponent {
- · + ·
{intl.formatMessage(messages[report.get('category')])}
diff --git a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx index ae4c4ed4f7..e3363964a7 100644 --- a/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx +++ b/app/javascript/mastodon/features/ui/components/compare_history_modal.jsx @@ -50,7 +50,7 @@ class CompareHistoryModal extends PureComponent { const content = currentVersion.get('content'); const spoilerContent = escapeTextContentForBrowser(currentVersion.get('spoiler_text')); - const formattedDate = ; + const formattedDate = ; const formattedName = ; const label = currentVersion.get('original') ? ( diff --git a/app/javascript/mastodon/utils/time.test.ts b/app/javascript/mastodon/utils/time.test.ts new file mode 100644 index 0000000000..f1b206b424 --- /dev/null +++ b/app/javascript/mastodon/utils/time.test.ts @@ -0,0 +1,36 @@ +import { DAY, HOUR, MINUTE, relativeTimeParts, SECOND } from './time'; + +describe('relativeTimeParts', () => { + const now = Date.now(); + + test.concurrent.each([ + // Now + [0, { value: 0, unit: 'second' }], + + // Past + [-30 * SECOND, { value: -30, unit: 'second' }], + [-90 * SECOND, { value: -2, unit: 'minute' }], + [-30 * MINUTE, { value: -30, unit: 'minute' }], + [-90 * MINUTE, { value: -2, unit: 'hour' }], + [-5 * HOUR, { value: -5, unit: 'hour' }], + [-24 * HOUR, { value: -1, unit: 'day' }], + [-36 * HOUR, { value: -1, unit: 'day' }], + [-47 * HOUR, { value: -2, unit: 'day' }], + [-3 * DAY, { value: -3, unit: 'day' }], + + // Future + [SECOND, { value: 1, unit: 'second' }], + [59 * SECOND, { value: 59, unit: 'second' }], + [MINUTE, { value: 1, unit: 'minute' }], + [MINUTE + SECOND, { value: 1, unit: 'minute' }], + [59 * MINUTE, { value: 59, unit: 'minute' }], + [HOUR, { value: 1, unit: 'hour' }], + [HOUR + MINUTE, { value: 1, unit: 'hour' }], + [23 * HOUR, { value: 23, unit: 'hour' }], + [DAY, { value: 1, unit: 'day' }], + [DAY + HOUR, { value: 1, unit: 'day' }], + [2 * DAY, { value: 2, unit: 'day' }], + ])('should return correct value and unit for %d ms', (input, expected) => { + expect(relativeTimeParts(now + input, now)).toMatchObject(expected); + }); +}); diff --git a/app/javascript/mastodon/utils/time.ts b/app/javascript/mastodon/utils/time.ts new file mode 100644 index 0000000000..c7ed115d24 --- /dev/null +++ b/app/javascript/mastodon/utils/time.ts @@ -0,0 +1,246 @@ +import type { IntlShape } from 'react-intl'; +import { defineMessages } from 'react-intl'; + +export const SECOND = 1000; +export const MINUTE = SECOND * 60; +export const HOUR = MINUTE * 60; +export const DAY = HOUR * 24; + +export const MAX_TIMEOUT = 2147483647; // Maximum delay for setTimeout in browsers (approximately 24.8 days) + +export type TimeUnit = 'second' | 'minute' | 'hour' | 'day'; + +export function relativeTimeParts( + ts: number, + now = Date.now(), +): { value: number; unit: TimeUnit; delta: number } { + const delta = ts - now; + const absDelta = Math.abs(delta); + + if (absDelta < MINUTE) { + return { value: Math.floor(delta / SECOND), unit: 'second', delta }; + } + + if (absDelta < HOUR) { + return { value: Math.floor(delta / MINUTE), unit: 'minute', delta }; + } + + if (absDelta < DAY) { + return { value: Math.floor(delta / HOUR), unit: 'hour', delta }; + } + + // Round instead of use floor as days are big enough that the value is usually off by a few hours. + return { value: Math.round(delta / DAY), unit: 'day', delta }; +} + +export function isToday(ts: number, now = Date.now()): boolean { + const date = new Date(ts); + const nowDate = new Date(now); + return ( + date.getDate() === nowDate.getDate() && + date.getMonth() === nowDate.getMonth() && + date.getFullYear() === nowDate.getFullYear() + ); +} + +export function isSameYear(ts: number, now = Date.now()): boolean { + const date = new Date(ts); + const nowDate = new Date(now); + return date.getFullYear() === nowDate.getFullYear(); +} + +export function unitToTime(unit: TimeUnit): number { + switch (unit) { + case 'second': + return SECOND; + case 'minute': + return MINUTE; + case 'hour': + return HOUR; + case 'day': + return DAY; + } +} + +const timeMessages = defineMessages({ + today: { id: 'relative_time.today', defaultMessage: 'today' }, + just_now: { id: 'relative_time.just_now', defaultMessage: 'now' }, + just_now_full: { + id: 'relative_time.full.just_now', + defaultMessage: 'just now', + }, + seconds: { id: 'relative_time.seconds', defaultMessage: '{number}s' }, + seconds_full: { + id: 'relative_time.full.seconds', + defaultMessage: '{number, plural, one {# second} other {# seconds}} ago', + }, + minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' }, + minutes_full: { + id: 'relative_time.full.minutes', + defaultMessage: '{number, plural, one {# minute} other {# minutes}} ago', + }, + hours: { id: 'relative_time.hours', defaultMessage: '{number}h' }, + hours_full: { + id: 'relative_time.full.hours', + defaultMessage: '{number, plural, one {# hour} other {# hours}} ago', + }, + days: { id: 'relative_time.days', defaultMessage: '{number}d' }, + days_full: { + id: 'relative_time.full.days', + defaultMessage: '{number, plural, one {# day} other {# days}} ago', + }, + moments_remaining: { + id: 'time_remaining.moments', + defaultMessage: 'Moments remaining', + }, + seconds_remaining: { + id: 'time_remaining.seconds', + defaultMessage: '{number, plural, one {# second} other {# seconds}} left', + }, + minutes_remaining: { + id: 'time_remaining.minutes', + defaultMessage: '{number, plural, one {# minute} other {# minutes}} left', + }, + hours_remaining: { + id: 'time_remaining.hours', + defaultMessage: '{number, plural, one {# hour} other {# hours}} left', + }, + days_remaining: { + id: 'time_remaining.days', + defaultMessage: '{number, plural, one {# day} other {# days}} left', + }, +}); + +const DAYS_LIMIT = 7; +const NOW_SECONDS = 10; + +export function formatTime({ + timestamp, + intl, + now = Date.now(), + noTime = false, + short = false, +}: { + timestamp: number; + intl: Pick; + now?: number; + noTime?: boolean; + short?: boolean; +}) { + const { value, unit } = relativeTimeParts(timestamp, now); + + // If we're only showing days, show "today" for the current day. + if (noTime && isToday(timestamp, now)) { + return intl.formatMessage(timeMessages.today); + } + + if (value > 0) { + return formatFuture({ value, unit, intl }); + } + + if (unit === 'day' && value < -DAYS_LIMIT) { + return formatAbsoluteTime({ timestamp, intl, now }); + } + + return formatRelativePastTime({ value, unit, intl, short }); +} + +export function formatAbsoluteTime({ + timestamp, + intl, + now = Date.now(), +}: { + timestamp: number; + intl: Pick; + now?: number; +}) { + return intl.formatDate(timestamp, { + month: 'short', + day: 'numeric', + // Only show the year if it's different from the current year. + year: isSameYear(timestamp, now) ? undefined : 'numeric', + }); +} + +export function formatFuture({ + unit, + value, + intl, +}: { + value: number; + unit: TimeUnit; + intl: Pick; +}) { + if (unit === 'day') { + return intl.formatMessage(timeMessages.days_remaining, { number: value }); + } + + if (unit === 'hour') { + return intl.formatMessage(timeMessages.hours_remaining, { + number: value, + }); + } + + if (unit === 'minute') { + return intl.formatMessage(timeMessages.minutes_remaining, { + number: value, + }); + } + + if (value > NOW_SECONDS) { + return intl.formatMessage(timeMessages.seconds_remaining, { + number: value, + }); + } + + return intl.formatMessage(timeMessages.moments_remaining); +} + +export function formatRelativePastTime({ + value, + unit, + intl, + short = false, +}: { + value: number; + unit: TimeUnit; + intl: Pick; + short?: boolean; +}) { + const absValue = Math.abs(value); + if (unit === 'day') { + return intl.formatMessage( + short ? timeMessages.days : timeMessages.days_full, + { + number: absValue, + }, + ); + } + + if (unit === 'hour') { + return intl.formatMessage( + short ? timeMessages.hours : timeMessages.hours_full, + { + number: absValue, + }, + ); + } + + if (unit === 'minute') { + return intl.formatMessage( + short ? timeMessages.minutes : timeMessages.minutes_full, + { number: absValue }, + ); + } + + if (absValue >= NOW_SECONDS) { + return intl.formatMessage( + short ? timeMessages.seconds : timeMessages.seconds_full, + { number: absValue }, + ); + } + + return intl.formatMessage( + short ? timeMessages.just_now : timeMessages.just_now_full, + ); +}