@@ -199,6 +209,7 @@ export default class UI extends React.PureComponent {
+
diff --git a/app/javascript/mastodon/features/ui/util/async-components.js b/app/javascript/mastodon/features/ui/util/async-components.js
index 7f6090d3a..c767f77a7 100644
--- a/app/javascript/mastodon/features/ui/util/async-components.js
+++ b/app/javascript/mastodon/features/ui/util/async-components.js
@@ -34,6 +34,10 @@ export function GettingStarted () {
return import(/* webpackChunkName: "features/getting_started" */'../../getting_started');
}
+export function PinnedStatuses () {
+ return import(/* webpackChunkName: "features/pinned_statuses" */'../../pinned_statuses');
+}
+
export function AccountTimeline () {
return import(/* webpackChunkName: "features/account_timeline" */'../../account_timeline');
}
@@ -78,26 +82,10 @@ export function Mutes () {
return import(/* webpackChunkName: "features/mutes" */'../../mutes');
}
-export function MediaModal () {
- return import(/* webpackChunkName: "modals/media_modal" */'../components/media_modal');
-}
-
export function OnboardingModal () {
return import(/* webpackChunkName: "modals/onboarding_modal" */'../components/onboarding_modal');
}
-export function VideoModal () {
- return import(/* webpackChunkName: "modals/video_modal" */'../components/video_modal');
-}
-
-export function BoostModal () {
- return import(/* webpackChunkName: "modals/boost_modal" */'../components/boost_modal');
-}
-
-export function ConfirmationModal () {
- return import(/* webpackChunkName: "modals/confirmation_modal" */'../components/confirmation_modal');
-}
-
export function ReportModal () {
return import(/* webpackChunkName: "modals/report_modal" */'../components/report_modal');
}
@@ -109,3 +97,7 @@ export function MediaGallery () {
export function VideoPlayer () {
return import(/* webpackChunkName: "status/video_player" */'../../../components/video_player');
}
+
+export function EmbedModal () {
+ return import(/* webpackChunkName: "modals/embed_modal" */'../components/embed_modal');
+}
diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json
index f5cf77f92..2ceb6eb9a 100644
--- a/app/javascript/mastodon/locales/ar.json
+++ b/app/javascript/mastodon/locales/ar.json
@@ -47,7 +47,7 @@
"compose_form.lock_disclaimer.lock": "مقفل",
"compose_form.placeholder": "فيمَ تفكّر؟",
"compose_form.privacy_disclaimer": "Your private status will be delivered to mentioned users on {domains}. Do you trust {domainsCount, plural, one {that server} other {those servers}}? Post privacy only works on Mastodon instances. If {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, there will be no indication that your post is private, and it may be boosted or otherwise made visible to unintended recipients.",
- "compose_form.publish": "بوّق !",
+ "compose_form.publish": "بوّق",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive": "ضع علامة على الوسيط باعتباره حسّاس",
"compose_form.spoiler": "أخفِ النص واعرض تحذيرا",
@@ -63,6 +63,8 @@
"confirmations.mute.message": "هل أنت متأكد أنك تريد كتم {name} ؟",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "الأنشطة",
"emoji_button.flags": "الأعلام",
"emoji_button.food": "الطعام والشراب",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "تعذرت ترقية هذا المنشور",
"status.delete": "إحذف",
+ "status.embed": "Embed",
"status.favourite": "أضف إلى المفضلة",
"status.load_more": "حمّل المزيد",
"status.media_hidden": "الصورة مستترة",
"status.mention": "أذكُر @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "وسع هذه المشاركة",
+ "status.pin": "Pin on profile",
"status.reblog": "رَقِّي",
"status.reblogged_by": "{name} رقى",
"status.reply": "ردّ",
@@ -179,6 +183,7 @@
"status.show_less": "إعرض أقلّ",
"status.show_more": "أظهر المزيد",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "تحرير",
"tabs_bar.federated_timeline": "الموحَّد",
"tabs_bar.home": "الرئيسية",
diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json
index e6788f9eb..183ba2673 100644
--- a/app/javascript/mastodon/locales/bg.json
+++ b/app/javascript/mastodon/locales/bg.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Изтриване",
+ "status.embed": "Embed",
"status.favourite": "Предпочитани",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Споменаване",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
+ "status.pin": "Pin on profile",
"status.reblog": "Споделяне",
"status.reblogged_by": "{name} сподели",
"status.reply": "Отговор",
@@ -179,6 +183,7 @@
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Съставяне",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Начало",
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 95b3c60bf..0e3d2bc18 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Estàs segur que vols silenciar {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activitat",
"emoji_button.flags": "Flags",
"emoji_button.food": "Menjar i Beure",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
"status.delete": "Esborrar",
+ "status.embed": "Embed",
"status.favourite": "Favorit",
"status.load_more": "Carrega més",
"status.media_hidden": "Multimèdia amagat",
"status.mention": "Esmentar @{name}",
"status.mute_conversation": "Silenciar conversació",
"status.open": "Ampliar aquest estat",
+ "status.pin": "Pin on profile",
"status.reblog": "Boost",
"status.reblogged_by": "{name} ha retootejat",
"status.reply": "Respondre",
@@ -179,6 +183,7 @@
"status.show_less": "Mostra menys",
"status.show_more": "Mostra més",
"status.unmute_conversation": "Activar conversació",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Compondre",
"tabs_bar.federated_timeline": "Federada",
"tabs_bar.home": "Inici",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index 67a99b765..3133238cd 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -1,92 +1,94 @@
{
"account.block": "@{name} blocken",
- "account.block_domain": "Hide everything from {domain}",
- "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+ "account.block_domain": "Alles von {domain} verstecken",
+ "account.disclaimer_full": "Hier aufgeführten Informationen können unvollständig sein.",
"account.edit_profile": "Profil bearbeiten",
"account.follow": "Folgen",
"account.followers": "Folgende",
"account.follows": "Folgt",
"account.follows_you": "Folgt dir",
- "account.media": "Media",
+ "account.media": "Medien",
"account.mention": "@{name} erwähnen",
"account.mute": "@{name} stummschalten",
"account.posts": "Beiträge",
"account.report": "@{name} melden",
- "account.requested": "Warte auf Erlaubnis",
- "account.share": "Share @{name}'s profile",
+ "account.requested": "Warte auf Erlaubnis. Klicke zum Abbrechen",
+ "account.share": "Profil von @{name} teilen",
"account.unblock": "@{name} entblocken",
- "account.unblock_domain": "Unhide {domain}",
+ "account.unblock_domain": "{domain} wieder anzeigen",
"account.unfollow": "Entfolgen",
"account.unmute": "@{name} nicht mehr stummschalten",
- "account.view_full_profile": "View full profile",
+ "account.view_full_profile": "Komplettes Profil anzeigen",
"boost_modal.combo": "Du kannst {combo} drücken, um dies beim nächsten Mal zu überspringen",
- "bundle_column_error.body": "Something went wrong while loading this component.",
- "bundle_column_error.retry": "Try again",
- "bundle_column_error.title": "Network error",
- "bundle_modal_error.close": "Close",
- "bundle_modal_error.message": "Something went wrong while loading this component.",
- "bundle_modal_error.retry": "Try again",
- "column.blocks": "Blockierte Benutzer",
+ "bundle_column_error.body": "Etwas ist beim Laden schiefgelaufen.",
+ "bundle_column_error.retry": "Erneut versuchen",
+ "bundle_column_error.title": "Netzwerkfehlher",
+ "bundle_modal_error.close": "Schließen",
+ "bundle_modal_error.message": "Etwas ist beim Laden schiefgelaufen.",
+ "bundle_modal_error.retry": "Erneut versuchen",
+ "column.blocks": "Blockierte Profile",
"column.community": "Lokale Zeitleiste",
"column.favourites": "Favoriten",
"column.follow_requests": "Folgeanfragen",
"column.home": "Startseite",
- "column.mutes": "Stummgeschaltete Benutzer",
+ "column.mutes": "Stummgeschaltete Profile",
"column.notifications": "Mitteilungen",
"column.public": "Gesamtes bekanntes Netz",
"column_back_button.label": "Zurück",
- "column_header.hide_settings": "Hide settings",
- "column_header.moveLeft_settings": "Move column to the left",
- "column_header.moveRight_settings": "Move column to the right",
- "column_header.pin": "Pin",
- "column_header.show_settings": "Show settings",
- "column_header.unpin": "Unpin",
+ "column_header.hide_settings": "Einstellungen verbergen",
+ "column_header.moveLeft_settings": "Spalte links verschieben",
+ "column_header.moveRight_settings": "Spalte rechts verschieben",
+ "column_header.pin": "Anheften",
+ "column_header.show_settings": "Einstellungen anzeigen",
+ "column_header.unpin": "Lösen",
"column_subheading.navigation": "Navigation",
- "column_subheading.settings": "Settings",
- "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
- "compose_form.lock_disclaimer.lock": "locked",
+ "column_subheading.settings": "Einstellungen",
+ "compose_form.lock_disclaimer": "Dein Profil ist nicht {locked}. Jeder kann dir jederzeit folgen, um deine privaten Beiträge einzusehen.",
+ "compose_form.lock_disclaimer.lock": "gesperrt",
"compose_form.placeholder": "Worüber möchtest du schreiben?",
- "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Benutzer auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
+ "compose_form.privacy_disclaimer": "Dein privater Status wird an die genannten Profile auf den Domains {domains} zugestellt. Vertraust du {domainsCount, plural, one {diesem Server} other {diesen Servern}}? Private Beiträge funktionieren nur auf Mastodon-Instanzen. Wenn {domains} {domainsCount, plural, one {keine Mastodon-Instanz ist} other {keine Mastodon-Instanzen sind}}, wird es dort kein Anzeichen geben, dass dein Beitrag privat ist und er könnte geteilt oder anderweitig für unerwünschte Empfänger sichtbar gemacht werden.",
"compose_form.publish": "Tröt",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive": "Medien als heikel markieren",
"compose_form.spoiler": "Text hinter Warnung verbergen",
"compose_form.spoiler_placeholder": "Inhaltswarnung",
- "confirmation_modal.cancel": "Cancel",
- "confirmations.block.confirm": "Block",
- "confirmations.block.message": "Are you sure you want to block {name}?",
- "confirmations.delete.confirm": "Delete",
- "confirmations.delete.message": "Are you sure you want to delete this status?",
- "confirmations.domain_block.confirm": "Hide entire domain",
- "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
- "confirmations.mute.confirm": "Mute",
- "confirmations.mute.message": "Are you sure you want to mute {name}?",
- "confirmations.unfollow.confirm": "Unfollow",
- "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
- "emoji_button.activity": "Activity",
- "emoji_button.flags": "Flags",
- "emoji_button.food": "Food & Drink",
+ "confirmation_modal.cancel": "Abbrechen",
+ "confirmations.block.confirm": "Blockieren",
+ "confirmations.block.message": "Bist du dir sicher, dass du {name} blockieren möchtest?",
+ "confirmations.delete.confirm": "Löschen",
+ "confirmations.delete.message": "Bist du dir sicher, dass du diesen Beitrag löschen möchstest?",
+ "confirmations.domain_block.confirm": "Die ganze Domain verbergen",
+ "confirmations.domain_block.message": "Bist du dir wirklich sicher, dass du die ganze Domain {domain} verbergen willst? In den meisten Fällen sind ein paar gezielte Blocks genug.",
+ "confirmations.mute.confirm": "Stummschalten",
+ "confirmations.mute.message": "Bist du dir sicher, dass du {name} stummschalten möchstest?",
+ "confirmations.unfollow.confirm": "Entfolgen",
+ "confirmations.unfollow.message": "Bist du dir sicher, dass du {name} entfolgen möchstest?",
+ "embed.instructions": "Du kannst diesen Beitrag auf deiner Webseite einbetten, in dem du den folgenden Code einfügst.",
+ "embed.preview": "So wird es aussehen:",
+ "emoji_button.activity": "Aktivitäten",
+ "emoji_button.flags": "Flaggen",
+ "emoji_button.food": "Essen und Trinken",
"emoji_button.label": "Emoji einfügen",
- "emoji_button.nature": "Nature",
- "emoji_button.objects": "Objects",
- "emoji_button.people": "People",
- "emoji_button.search": "Search...",
- "emoji_button.symbols": "Symbols",
- "emoji_button.travel": "Travel & Places",
+ "emoji_button.nature": "Natur",
+ "emoji_button.objects": "Dinge",
+ "emoji_button.people": "Leute",
+ "emoji_button.search": "Suche…",
+ "emoji_button.symbols": "Symbole",
+ "emoji_button.travel": "Reise und Orte",
"empty_column.community": "Die lokale Zeitleiste ist leer. Schreibe etwas öffentlich, um den Ball ins Rollen zu bringen!",
"empty_column.hashtag": "Es gibt noch nichts unter diesem Hashtag.",
- "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Benutzer anzutreffen.",
- "empty_column.home.inactivity": "Your home feed is empty. If you have been inactive for a while, it will be regenerated for you soon.",
+ "empty_column.home": "Du folgst noch niemandem. Besuche {public} oder benutze die Suche, um zu starten oder andere Profile zu finden.",
+ "empty_column.home.inactivity": "Deine Zeitleiste ist leer. Falls du eine längere Zeit inaktiv gewesen bist, wird sie für dich so schnell wie möglich wiedererstellt.",
"empty_column.home.public_timeline": "die öffentliche Zeitleiste",
"empty_column.notifications": "Du hast noch keine Mitteilungen. Interagiere mit anderen, um die Konversation zu starten.",
- "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Benutzern von anderen Instanzen, um es aufzufüllen.",
+ "empty_column.public": "Hier ist nichts zu sehen! Schreibe etwas öffentlich oder folge Profilen von anderen Instanzen, um es aufzufüllen.",
"follow_request.authorize": "Erlauben",
"follow_request.reject": "Ablehnen",
- "getting_started.appsshort": "Apps",
- "getting_started.faq": "FAQ",
+ "getting_started.appsshort": "Anwendungen",
+ "getting_started.faq": "Häufig gestellte Fragen",
"getting_started.heading": "Erste Schritte",
"getting_started.open_source_notice": "Mastodon ist quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
- "getting_started.userguide": "User Guide",
+ "getting_started.userguide": "Bedienungsanleitung",
"home.column_settings.advanced": "Fortgeschritten",
"home.column_settings.basic": "Einfach",
"home.column_settings.filter_regex": "Filter durch reguläre Ausdrücke",
@@ -94,27 +96,27 @@
"home.column_settings.show_replies": "Antworten anzeigen",
"home.settings": "Spalteneinstellungen",
"lightbox.close": "Schließen",
- "lightbox.next": "Next",
- "lightbox.previous": "Previous",
+ "lightbox.next": "Weiter",
+ "lightbox.previous": "Zurück",
"loading_indicator.label": "Lade…",
"media_gallery.toggle_visible": "Sichtbarkeit einstellen",
"missing_indicator.label": "Nicht gefunden",
- "navigation_bar.blocks": "Blockierte Benutzer",
+ "navigation_bar.blocks": "Blockierte Profile",
"navigation_bar.community_timeline": "Lokale Zeitleiste",
"navigation_bar.edit_profile": "Profil bearbeiten",
"navigation_bar.favourites": "Favoriten",
"navigation_bar.follow_requests": "Folgeanfragen",
"navigation_bar.info": "Erweiterte Informationen",
"navigation_bar.logout": "Abmelden",
- "navigation_bar.mutes": "Stummgeschaltete Benutzer",
+ "navigation_bar.mutes": "Stummgeschaltete Profile",
"navigation_bar.preferences": "Einstellungen",
"navigation_bar.public_timeline": "Föderierte Zeitleiste",
"notification.favourite": "{name} favorisierte deinen Status",
"notification.follow": "{name} folgt dir",
"notification.mention": "{name} erwähnte dich",
"notification.reblog": "{name} teilte deinen Status",
- "notifications.clear": "Mitteilungen beseitigen",
- "notifications.clear_confirmation": "Bist du sicher, dass du alle Mitteilungen beseitigen willst?",
+ "notifications.clear": "Mitteilungen löschen",
+ "notifications.clear_confirmation": "Bist du dir sicher, dass du alle Mitteilungen löschen möchstest?",
"notifications.column_settings.alert": "Desktop-Benachrichtigungen",
"notifications.column_settings.favourite": "Favorisierungen:",
"notifications.column_settings.follow": "Neue Folgende:",
@@ -124,28 +126,28 @@
"notifications.column_settings.reblog": "Geteilte Beiträge:",
"notifications.column_settings.show": "In der Spalte anzeigen",
"notifications.column_settings.sound": "Ton abspielen",
- "onboarding.done": "Done",
- "onboarding.next": "Next",
- "onboarding.page_five.public_timelines": "The local timeline shows public posts from everyone on {domain}. The federated timeline shows public posts from everyone who people on {domain} follow. These are the Public Timelines, a great way to discover new people.",
- "onboarding.page_four.home": "The home timeline shows posts from people you follow.",
- "onboarding.page_four.notifications": "The notifications column shows when someone interacts with you.",
- "onboarding.page_one.federation": "Mastodon is a network of independent servers joining up to make one larger social network. We call these servers instances.",
- "onboarding.page_one.handle": "You are on {domain}, so your full handle is {handle}",
- "onboarding.page_one.welcome": "Welcome to Mastodon!",
- "onboarding.page_six.admin": "Your instance's admin is {admin}.",
- "onboarding.page_six.almost_done": "Almost done...",
- "onboarding.page_six.appetoot": "Bon Appetoot!",
- "onboarding.page_six.apps_available": "There are {apps} available for iOS, Android and other platforms.",
- "onboarding.page_six.github": "Mastodon is free open-source software. You can report bugs, request features, or contribute to the code on {github}.",
- "onboarding.page_six.guidelines": "community guidelines",
- "onboarding.page_six.read_guidelines": "Please read {domain}'s {guidelines}!",
- "onboarding.page_six.various_app": "mobile apps",
- "onboarding.page_three.profile": "Edit your profile to change your avatar, bio, and display name. There, you will also find other preferences.",
- "onboarding.page_three.search": "Use the search bar to find people and look at hashtags, such as {illustration} and {introductions}. To look for a person who is not on this instance, use their full handle.",
- "onboarding.page_two.compose": "Write posts from the compose column. You can upload images, change privacy settings, and add content warnings with the icons below.",
- "onboarding.skip": "Skip",
+ "onboarding.done": "Fertig",
+ "onboarding.next": "Weiter",
+ "onboarding.page_five.public_timelines": "Die lokale Zeitleiste zeigt alle Beiträge von Leuten, die auch auf deiner Instanz {domain} sind. Das gesamte bekannte Netz zeigt Beiträge von allen, die kollektiv aus deiner Instanz heraus gefolgt werden. Zusammen werden die beiden Leisten auch öffentliche Zeitleisten genannt, durch sie kannst du viel neues entdecken.",
+ "onboarding.page_four.home": "Die Startseite zeigt dir Beiträge von Leuten, denen du folgst.",
+ "onboarding.page_four.notifications": "Wenn jemand mir dir interagiert, bekommst du eine Mitteilung.",
+ "onboarding.page_one.federation": "Mastodon ist ein soziales Netzwerk, das aus unabhängigen Servern besteht. Diese Server nennen wir auch Instanzen.",
+ "onboarding.page_one.handle": "Du bist auf der Instanz {domain}, also ist dein vollständiger Profilname im Netzwerk {handle}",
+ "onboarding.page_one.welcome": "Willkommen bei Mastodon!",
+ "onboarding.page_six.admin": "Für deine Instanz ist {admin} zuständig.",
+ "onboarding.page_six.almost_done": "Fast fertig…",
+ "onboarding.page_six.appetoot": "Guten Appetröt!",
+ "onboarding.page_six.apps_available": "Es gibt verschiedene {apps} für iOS, Android und andere Plattformen.",
+ "onboarding.page_six.github": "Mastodon ist freie, quelloffene Software. Du kannst auf {github} dazu beitragen oder Probleme melden.",
+ "onboarding.page_six.guidelines": "Richtlinien",
+ "onboarding.page_six.read_guidelines": "Bitte mach dich mit den {guidelines} von {domain} vertraut!",
+ "onboarding.page_six.various_app": "mobile Anwendungen",
+ "onboarding.page_three.profile": "Bearbeite dein Profil, um dein Bild, deinen Namen oder deine Beschreibung anzupassen. Dort findest du auch andere Einstellungen.",
+ "onboarding.page_three.search": "Benutze die Suchfunktion, um Leute oder Themen zu finden. Zum Beispiel, die Hashtags {illustration} oder {introductions}. Um eine Person zu finden, die auf einer anderen Instanz ist, benutze den vollständigen Profilnamen.",
+ "onboarding.page_two.compose": "Schreibe Beiträge aus der Schreiben-Spalte. Du kannst Bilder und kurze Videos hochladen, Sichtbarkeitseinstellungen ändern und Inhaltswarnungen hinzufügen.",
+ "onboarding.skip": "Überspringen",
"privacy.change": "Privatsphäre des Status anpassen",
- "privacy.direct.long": "Beitrag nur an erwähnte Benutzer",
+ "privacy.direct.long": "Beitrag nur an erwähnte Profile",
"privacy.direct.short": "Direkt",
"privacy.private.long": "Beitrag nur an Folgende",
"privacy.private.short": "Privat",
@@ -159,15 +161,17 @@
"report.target": "Melden",
"search.placeholder": "Suche",
"search_results.total": "{count, number} {count, plural, one {Ergebnis} other {Ergebnisse}}",
- "standalone.public_title": "A look inside...",
- "status.cannot_reblog": "This post cannot be boosted",
+ "standalone.public_title": "Vorschau…",
+ "status.cannot_reblog": "Dieser Beitrag kann nicht geteilt werden",
"status.delete": "Löschen",
+ "status.embed": "Einbetten",
"status.favourite": "Favorisieren",
"status.load_more": "Weitere laden",
"status.media_hidden": "Medien versteckt",
"status.mention": "Erwähnen",
- "status.mute_conversation": "Mute conversation",
+ "status.mute_conversation": "Thread stummschalten",
"status.open": "Öffnen",
+ "status.pin": "Auf dem Profil anheften",
"status.reblog": "Teilen",
"status.reblogged_by": "{name} teilte",
"status.reply": "Antworten",
@@ -175,13 +179,14 @@
"status.report": "@{name} melden",
"status.sensitive_toggle": "Klicke, um sie zu sehen",
"status.sensitive_warning": "Heikle Inhalte",
- "status.share": "Share",
+ "status.share": "Teilen",
"status.show_less": "Weniger anzeigen",
"status.show_more": "Mehr anzeigen",
- "status.unmute_conversation": "Unmute conversation",
+ "status.unmute_conversation": "Stummschaltung von Thread aufheben",
+ "status.unpin": "Vom Profil lösen",
"tabs_bar.compose": "Schreiben",
"tabs_bar.federated_timeline": "Föderation",
- "tabs_bar.home": "Home",
+ "tabs_bar.home": "Startseite",
"tabs_bar.local_timeline": "Lokal",
"tabs_bar.notifications": "Mitteilungen",
"upload_area.title": "Hereinziehen zum Hochladen",
diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json
index ef76f6e5b..a0cb8f978 100644
--- a/app/javascript/mastodon/locales/defaultMessages.json
+++ b/app/javascript/mastodon/locales/defaultMessages.json
@@ -189,6 +189,18 @@
{
"defaultMessage": "Unmute conversation",
"id": "status.unmute_conversation"
+ },
+ {
+ "defaultMessage": "Pin on profile",
+ "id": "status.pin"
+ },
+ {
+ "defaultMessage": "Unpin from profile",
+ "id": "status.unpin"
+ },
+ {
+ "defaultMessage": "Embed",
+ "id": "status.embed"
}
],
"path": "app/javascript/mastodon/components/status_action_bar.json"
@@ -424,7 +436,7 @@
"id": "account.follow"
},
{
- "defaultMessage": "Awaiting approval",
+ "defaultMessage": "Awaiting approval. Click to cancel follow request",
"id": "account.requested"
},
{
@@ -1035,6 +1047,18 @@
{
"defaultMessage": "Share",
"id": "status.share"
+ },
+ {
+ "defaultMessage": "Pin on profile",
+ "id": "status.pin"
+ },
+ {
+ "defaultMessage": "Unpin from profile",
+ "id": "status.unpin"
+ },
+ {
+ "defaultMessage": "Embed",
+ "id": "status.embed"
}
],
"path": "app/javascript/mastodon/features/status/components/action_bar.json"
@@ -1108,6 +1132,23 @@
],
"path": "app/javascript/mastodon/features/ui/components/confirmation_modal.json"
},
+ {
+ "descriptors": [
+ {
+ "defaultMessage": "Embed",
+ "id": "status.embed"
+ },
+ {
+ "defaultMessage": "Embed this status on your website by copying the code below.",
+ "id": "embed.instructions"
+ },
+ {
+ "defaultMessage": "Here is what it will look like:",
+ "id": "embed.preview"
+ }
+ ],
+ "path": "app/javascript/mastodon/features/ui/components/embed_modal.json"
+ },
{
"descriptors": [
{
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 2ea2062d3..f42851f45 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -12,7 +12,7 @@
"account.mute": "Mute @{name}",
"account.posts": "Posts",
"account.report": "Report @{name}",
- "account.requested": "Awaiting approval",
+ "account.requested": "Awaiting approval. Click to cancel follow request",
"account.share": "Share @{name}'s profile",
"account.unblock": "Unblock @{name}",
"account.unblock_domain": "Unhide {domain}",
@@ -34,6 +34,7 @@
"column.mutes": "Muted users",
"column.notifications": "Notifications",
"column.public": "Federated timeline",
+ "column.pins": "Pinned toots",
"column_back_button.label": "Back",
"column_header.hide_settings": "Hide settings",
"column_header.moveLeft_settings": "Move column to the left",
@@ -63,6 +64,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -109,6 +112,7 @@
"navigation_bar.mutes": "Muted users",
"navigation_bar.preferences": "Preferences",
"navigation_bar.public_timeline": "Federated timeline",
+ "navigation_bar.pins": "Pinned toots",
"notification.favourite": "{name} favourited your status",
"notification.follow": "{name} followed you",
"notification.mention": "{name} mentioned you",
@@ -162,12 +166,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Delete",
+ "status.embed": "Embed",
"status.favourite": "Favourite",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
+ "status.pin": "Pin on profile",
"status.reblog": "Boost",
"status.reblogged_by": "{name} boosted",
"status.reply": "Reply",
@@ -179,6 +185,7 @@
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Compose",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index 960d747ec..d828d0858 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Forigi",
+ "status.embed": "Embed",
"status.favourite": "Favori",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mencii @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
+ "status.pin": "Pin on profile",
"status.reblog": "Diskonigi",
"status.reblogged_by": "{name} diskonigita",
"status.reply": "Respondi",
@@ -179,6 +183,7 @@
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Ekskribi",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Hejmo",
diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json
index 212d16639..d35eb84e7 100644
--- a/app/javascript/mastodon/locales/es.json
+++ b/app/javascript/mastodon/locales/es.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Borrar",
+ "status.embed": "Embed",
"status.favourite": "Favorito",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mencionar",
"status.mute_conversation": "Mute conversation",
"status.open": "Expandir estado",
+ "status.pin": "Pin on profile",
"status.reblog": "Retoot",
"status.reblogged_by": "Retooteado por {name}",
"status.reply": "Responder",
@@ -179,6 +183,7 @@
"status.show_less": "Mostrar menos",
"status.show_more": "Mostrar más",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Redactar",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Inicio",
diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json
index d2682ef12..d05b26eb9 100644
--- a/app/javascript/mastodon/locales/fa.json
+++ b/app/javascript/mastodon/locales/fa.json
@@ -1,7 +1,7 @@
{
"account.block": "مسدودسازی @{name}",
"account.block_domain": "پنهانسازی همه چیز از سرور {domain}",
- "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+ "account.disclaimer_full": "اطلاعات زیر ممکن است نمایهٔ این کاربر را به تمامی نشان ندهد.",
"account.edit_profile": "ویرایش نمایه",
"account.follow": "پی بگیرید",
"account.followers": "پیگیران",
@@ -13,7 +13,7 @@
"account.posts": "نوشتهها",
"account.report": "گزارش @{name}",
"account.requested": "در انتظار پذیرش",
- "account.share": "Share @{name}'s profile",
+ "account.share": "همرسانی نمایهٔ @{name}",
"account.unblock": "رفع انسداد @{name}",
"account.unblock_domain": "رفع پنهانسازی از {domain}",
"account.unfollow": "پایان پیگیری",
@@ -63,6 +63,8 @@
"confirmations.mute.message": "آیا واقعاً میخواهید {name} را بیصدا کنید؟",
"confirmations.unfollow.confirm": "لغو پیگیری",
"confirmations.unfollow.message": "آیا واقعاً میخواهید به پیگیری از {name} پایان دهید؟",
+ "embed.instructions": "برای جاگذاری این نوشته در سایت خودتان، کد زیر را کپی کنید.",
+ "embed.preview": "نوشتهٔ جاگذاریشده این گونه به نظر خواهد رسید:",
"emoji_button.activity": "فعالیت",
"emoji_button.flags": "پرچمها",
"emoji_button.food": "غذا و نوشیدنی",
@@ -162,12 +164,14 @@
"standalone.public_title": "نگاهی به کاربران این سرور...",
"status.cannot_reblog": "این نوشته را نمیشود بازبوقید",
"status.delete": "پاککردن",
+ "status.embed": "جاگذاری",
"status.favourite": "پسندیدن",
"status.load_more": "بیشتر نشان بده",
"status.media_hidden": "تصویر پنهان شده",
"status.mention": "نامبردن از @{name}",
"status.mute_conversation": "بیصداکردن گفتگو",
"status.open": "این نوشته را باز کن",
+ "status.pin": "نوشتهٔ ثابت نمایه",
"status.reblog": "بازبوقیدن",
"status.reblogged_by": "{name} بازبوقید",
"status.reply": "پاسخ",
@@ -179,6 +183,7 @@
"status.show_less": "نهفتن",
"status.show_more": "نمایش",
"status.unmute_conversation": "باصداکردن گفتگو",
+ "status.unpin": "برداشتن نوشتهٔ ثابت نمایه",
"tabs_bar.compose": "بنویسید",
"tabs_bar.federated_timeline": "همگانی",
"tabs_bar.home": "خانه",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index cb9e9c2a6..926a57ff1 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Poista",
+ "status.embed": "Embed",
"status.favourite": "Tykkää",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mainitse @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
+ "status.pin": "Pin on profile",
"status.reblog": "Buustaa",
"status.reblogged_by": "{name} buustasi",
"status.reply": "Vastaa",
@@ -179,6 +183,7 @@
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Luo",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Koti",
diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json
index ad9060d25..8ca632acc 100644
--- a/app/javascript/mastodon/locales/fr.json
+++ b/app/javascript/mastodon/locales/fr.json
@@ -13,18 +13,18 @@
"account.posts": "Statuts",
"account.report": "Signaler",
"account.requested": "Invitation envoyée",
- "account.share": "Share @{name}'s profile",
+ "account.share": "Partager le profil de @{name}",
"account.unblock": "Débloquer",
"account.unblock_domain": "Ne plus masquer {domain}",
"account.unfollow": "Ne plus suivre",
"account.unmute": "Ne plus masquer",
"account.view_full_profile": "Afficher le profil complet",
"boost_modal.combo": "Vous pouvez appuyer sur {combo} pour pouvoir passer ceci, la prochaine fois",
- "bundle_column_error.body": "Une erreur s'est produite lors du chargement de ce composant.",
+ "bundle_column_error.body": "Une erreur s’est produite lors du chargement de ce composant.",
"bundle_column_error.retry": "Réessayer",
"bundle_column_error.title": "Erreur réseau",
"bundle_modal_error.close": "Fermer",
- "bundle_modal_error.message": "Une erreur s'est produite lors du chargement de ce composant.",
+ "bundle_modal_error.message": "Une erreur s’est produite lors du chargement de ce composant.",
"bundle_modal_error.retry": "Réessayer",
"column.blocks": "Comptes bloqués",
"column.community": "Fil public local",
@@ -34,21 +34,22 @@
"column.mutes": "Comptes masqués",
"column.notifications": "Notifications",
"column.public": "Fil public global",
+ "column.pins": "Pouets épinglés",
"column_back_button.label": "Retour",
- "column_header.hide_settings": "Hide settings",
- "column_header.moveLeft_settings": "Move column to the left",
- "column_header.moveRight_settings": "Move column to the right",
+ "column_header.hide_settings": "Masquer les paramètres",
+ "column_header.moveLeft_settings": "Déplacer la colonne vers la gauche",
+ "column_header.moveRight_settings": "Déplacer la colonne vers la droite",
"column_header.pin": "Épingler",
- "column_header.show_settings": "Show settings",
+ "column_header.show_settings": "Afficher les paramètres",
"column_header.unpin": "Retirer",
"column_subheading.navigation": "Navigation",
"column_subheading.settings": "Paramètres",
- "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets restreints.",
+ "compose_form.lock_disclaimer": "Votre compte n’est pas {locked}. Tout le monde peut vous suivre et voir vos pouets privés.",
"compose_form.lock_disclaimer.lock": "verrouillé",
"compose_form.placeholder": "Qu’avez-vous en tête ?",
"compose_form.privacy_disclaimer": "Votre statut privé va être transmis aux personnes mentionnées sur {domains}. Avez-vous confiance en {domainsCount, plural, one {ce serveur} other {ces serveurs}} pour ne pas divulguer votre statut ? Les statuts privés ne fonctionnent que sur les instances de Mastodon. Si {domains} {domainsCount, plural, one {n’est pas une instance de Mastodon} other {ne sont pas des instances de Mastodon}}, il n’y aura aucune indication que votre statut est privé, et il pourrait être partagé ou rendu visible d’une autre manière à d’autres personnes imprévues.",
"compose_form.publish": "Pouet ",
- "compose_form.publish_loud": "{publish}!",
+ "compose_form.publish_loud": "{publish} !",
"compose_form.sensitive": "Marquer le média comme sensible",
"compose_form.spoiler": "Masquer le texte derrière un avertissement",
"compose_form.spoiler_placeholder": "Écrivez ici votre avertissement",
@@ -62,7 +63,9 @@
"confirmations.mute.confirm": "Masquer",
"confirmations.mute.message": "Confirmez vous le masquage de {name} ?",
"confirmations.unfollow.confirm": "Ne plus suivre",
- "confirmations.unfollow.message": "Vous voulez-vous arrêter de suivre {name} ?",
+ "confirmations.unfollow.message": "Voulez-vous arrêter de suivre {name} ?",
+ "embed.instructions": "Intégrez ce statut à votre site en copiant ce code ci-dessous.",
+ "embed.preview": "Il apparaîtra comme cela : ",
"emoji_button.activity": "Activités",
"emoji_button.flags": "Drapeaux",
"emoji_button.food": "Boire et manger",
@@ -94,8 +97,8 @@
"home.column_settings.show_replies": "Afficher les réponses",
"home.settings": "Paramètres de la colonne",
"lightbox.close": "Fermer",
- "lightbox.next": "Next",
- "lightbox.previous": "Previous",
+ "lightbox.next": "Suivant",
+ "lightbox.previous": "Précédent",
"loading_indicator.label": "Chargement…",
"media_gallery.toggle_visible": "Modifier la visibilité",
"missing_indicator.label": "Non trouvé",
@@ -109,6 +112,7 @@
"navigation_bar.mutes": "Comptes masqués",
"navigation_bar.preferences": "Préférences",
"navigation_bar.public_timeline": "Fil public global",
+ "navigation_bar.pins": "Pouets épinglés",
"notification.favourite": "{name} a ajouté à ses favoris :",
"notification.follow": "{name} vous suit.",
"notification.mention": "{name} vous a mentionné⋅e :",
@@ -134,8 +138,8 @@
"onboarding.page_one.welcome": "Bienvenue sur Mastodon !",
"onboarding.page_six.admin": "L’administrateur⋅trice de votre instance est {admin}",
"onboarding.page_six.almost_done": "Nous y sommes presque…",
- "onboarding.page_six.appetoot": "Bon Appétoot!",
- "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon Appétoot!",
+ "onboarding.page_six.appetoot": "Bon appouétit !",
+ "onboarding.page_six.apps_available": "De nombreuses {apps} sont disponibles pour iOS, Android et autres. Et maintenant… Bon appouétit !",
"onboarding.page_six.github": "Mastodon est un logiciel libre, gratuit et open-source. Vous pouvez rapporter des bogues, suggérer des fonctionnalités, ou contribuer à son développement sur {github}.",
"onboarding.page_six.guidelines": "règles de la communauté",
"onboarding.page_six.read_guidelines": "S’il vous plaît, n’oubliez pas de lire les {guidelines} !",
@@ -159,15 +163,17 @@
"report.target": "Signalement",
"search.placeholder": "Rechercher",
"search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}",
- "standalone.public_title": "Coup d'œil",
+ "standalone.public_title": "Jeter un coup d’œil…",
"status.cannot_reblog": "Cette publication ne peut être boostée",
"status.delete": "Effacer",
+ "status.embed": "Embed",
"status.favourite": "Ajouter aux favoris",
"status.load_more": "Charger plus",
"status.media_hidden": "Média caché",
"status.mention": "Mentionner",
"status.mute_conversation": "Masquer la conversation",
"status.open": "Déplier ce statut",
+ "status.pin": "Épingler sur le profil",
"status.reblog": "Partager",
"status.reblogged_by": "{name} a partagé :",
"status.reply": "Répondre",
@@ -175,10 +181,11 @@
"status.report": "Signaler @{name}",
"status.sensitive_toggle": "Cliquer pour afficher",
"status.sensitive_warning": "Contenu sensible",
- "status.share": "Share",
+ "status.share": "Partager",
"status.show_less": "Replier",
"status.show_more": "Déplier",
"status.unmute_conversation": "Ne plus masquer la conversation",
+ "status.unpin": "Retirer du profil",
"tabs_bar.compose": "Composer",
"tabs_bar.federated_timeline": "Fil public global",
"tabs_bar.home": "Accueil",
diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json
index 34266d8e1..9ef933108 100644
--- a/app/javascript/mastodon/locales/he.json
+++ b/app/javascript/mastodon/locales/he.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "להשתיק את {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "פעילות",
"emoji_button.flags": "דגלים",
"emoji_button.food": "אוכל ושתיה",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "לא ניתן להדהד הודעה זו",
"status.delete": "מחיקה",
+ "status.embed": "Embed",
"status.favourite": "חיבוב",
"status.load_more": "עוד",
"status.media_hidden": "מדיה מוסתרת",
"status.mention": "פניה אל @{name}",
"status.mute_conversation": "השתקת שיחה",
"status.open": "הרחבת הודעה",
+ "status.pin": "Pin on profile",
"status.reblog": "הדהוד",
"status.reblogged_by": "הודהד על ידי {name}",
"status.reply": "תגובה",
@@ -179,6 +183,7 @@
"status.show_less": "הראה פחות",
"status.show_more": "הראה יותר",
"status.unmute_conversation": "הסרת השתקת שיחה",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "חיבור",
"tabs_bar.federated_timeline": "ציר זמן בין-קהילתי",
"tabs_bar.home": "בבית",
diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json
index f69b096d4..f301723cf 100644
--- a/app/javascript/mastodon/locales/hr.json
+++ b/app/javascript/mastodon/locales/hr.json
@@ -1,7 +1,7 @@
{
"account.block": "Blokiraj @{name}",
"account.block_domain": "Sakrij sve sa {domain}",
- "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+ "account.disclaimer_full": "Ovaj korisnik je sa druge instance. Ovaj broj bi mogao biti veći.",
"account.edit_profile": "Uredi profil",
"account.follow": "Slijedi",
"account.followers": "Sljedbenici",
@@ -15,7 +15,7 @@
"account.requested": "Čeka pristanak",
"account.share": "Share @{name}'s profile",
"account.unblock": "Deblokiraj @{name}",
- "account.unblock_domain": "Otkrij {domain}",
+ "account.unblock_domain": "Poništi sakrivanje {domain}",
"account.unfollow": "Prestani slijediti",
"account.unmute": "Poništi utišavanje @{name}",
"account.view_full_profile": "View full profile",
@@ -43,7 +43,7 @@
"column_header.unpin": "Unpin",
"column_subheading.navigation": "Navigacija",
"column_subheading.settings": "Postavke",
- "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti i vidjeti tvoje postove namijenjene samo sljedbenicima.",
+ "compose_form.lock_disclaimer": "Tvoj račun nije {locked}. Svatko te može slijediti kako bi vidio postove namijenjene samo tvojim sljedbenicima.",
"compose_form.lock_disclaimer.lock": "zaključan",
"compose_form.placeholder": "Što ti je na umu?",
"compose_form.privacy_disclaimer": "Tvoj privatni status će biti dostavljen spomenutim korisnicima na {domains}. Vjeruješ li {domainsCount, plural, one {that server} drugim {those servers}}? Privatnost postova radi samo na Mastodon instancama. Ako {domains} {domainsCount, plural, one {is not a Mastodon instance} other {are not Mastodon instances}}, neće biti indikacije da je tvoj post privatan, i mogao bi biti podignut ili biti učinjen vidljivim na drugi način neželjenim primateljima.",
@@ -54,29 +54,32 @@
"compose_form.spoiler_placeholder": "Upozorenje o sadržaju",
"confirmation_modal.cancel": "Otkaži",
"confirmations.block.confirm": "Blokiraj",
- "confirmations.block.message": "Jesi li siguran da želiš blokirati {name}?",
+ "confirmations.block.message": "Želiš li sigurno blokirati {name}?",
"confirmations.delete.confirm": "Obriši",
- "confirmations.delete.message": "Jesi li siguran da želiš obrisati ovaj status?",
+ "confirmations.delete.message": "Želiš li stvarno obrisati ovaj status?",
"confirmations.domain_block.confirm": "Sakrij cijelu domenu",
- "confirmations.domain_block.message": "Jesi li zaista, zaista siguran da želiš blokirati sve sa {domain}? U većini slučajeva nekoliko ciljanih blokiranja ili utišavanja je dostatno i poželjnije.",
+ "confirmations.domain_block.message": "Jesi li zaista, zaista siguran da želiš potpuno blokirati {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
"confirmations.mute.confirm": "Utišaj",
"confirmations.mute.message": "Jesi li siguran da želiš utišati {name}?",
+ "confirmations.mute.message": "Jesi li siguran da želiš utišati {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Aktivnost",
"emoji_button.flags": "Zastave",
"emoji_button.food": "Hrana & Piće",
"emoji_button.label": "Umetni smajlije",
- "emoji_button.nature": "Nature",
+ "emoji_button.nature": "Priroda",
"emoji_button.objects": "Objekti",
"emoji_button.people": "Ljudi",
"emoji_button.search": "Traži...",
"emoji_button.symbols": "Simboli",
- "emoji_button.travel": "Putovanja i Mjesta",
+ "emoji_button.travel": "Putovanja & Mjesta",
"empty_column.community": "Lokalni timeline je prazan. Napiši nešto javno kako bi pokrenuo stvari!",
"empty_column.hashtag": "Još ne postoji ništa s ovim hashtagom.",
"empty_column.home": "Još ne slijediš nikoga. Posjeti {public} ili koristi tražilicu kako bi počeo i upoznao druge korisnike.",
- "empty_column.home.inactivity": "Tvoj home feed je prazan. Ako si neko vrijeme bio neaktivan, regenerirat će se uskoro.",
+ "empty_column.home.inactivity": "Tvoj home feed je prazan. Ako si neko vrijeme bio neaktivan, uskoro ćese regenerirati.",
"empty_column.home.public_timeline": "javni timeline",
"empty_column.notifications": "Još nemaš notifikacija. Komuniciraj sa drugima kako bi započeo razgovor.",
"empty_column.public": "Ovdje nema ništa! Napiši nešto javno, ili ručno slijedi korisnike sa drugih instanci kako bi popunio",
@@ -86,11 +89,11 @@
"getting_started.faq": "FAQ",
"getting_started.heading": "Počnimo",
"getting_started.open_source_notice": "Mastodon je softver otvorenog koda. Možeš pridonijeti ili prijaviti probleme na GitHubu {github}.",
- "getting_started.userguide": "Vodič za korisnike",
+ "getting_started.userguide": "Upute za korištenje",
"home.column_settings.advanced": "Napredno",
"home.column_settings.basic": "Osnovno",
"home.column_settings.filter_regex": "Filtriraj s regularnim izrazima",
- "home.column_settings.show_reblogs": "Pokaži boosts",
+ "home.column_settings.show_reblogs": "Pokaži boostove",
"home.column_settings.show_replies": "Pokaži odgovore",
"home.settings": "Postavke Stupca",
"lightbox.close": "Zatvori",
@@ -111,7 +114,7 @@
"navigation_bar.public_timeline": "Federalni timeline",
"notification.favourite": "{name} je lajkao tvoj status",
"notification.follow": "{name} te sada slijedi",
- "notification.mention": "{name} mentioned you",
+ "notification.mention": "{name} te je spomenuo",
"notification.reblog": "{name} je podigao tvoj status",
"notifications.clear": "Očisti notifikacije",
"notifications.clear_confirmation": "Želiš li zaista obrisati sve svoje notifikacije?",
@@ -121,28 +124,28 @@
"notifications.column_settings.mention": "Spominjanja:",
"notifications.column_settings.push": "Push notifications",
"notifications.column_settings.push_meta": "This device",
- "notifications.column_settings.reblog": "Boosts:",
+ "notifications.column_settings.reblog": "Boostovi:",
"notifications.column_settings.show": "Prikaži u stupcu",
"notifications.column_settings.sound": "Sviraj zvuk",
"onboarding.done": "Učinjeno",
- "onboarding.next": "Sljedeća",
- "onboarding.page_five.public_timelines": "The local timeline prikazuje javne postove svih na {domain}. Federalni timeline pokazuje javne postove svih sa {domain} domena koje slijediš. To je sjajan način da otkriješ nove ljude.",
- "onboarding.page_four.home": "The home timeline prikazuje samo postove ljudi koje slijediš.",
- "onboarding.page_four.notifications": "Stupac notifikacija pokazuje kada je netko u interakciji s tobom.",
- "onboarding.page_one.federation": "Mastodon je mreža nezavisnih servera udruženih kako bi stvorili veću socijalnu mrežu. Te servere zovemo instance.",
- "onboarding.page_one.handle": "Ti si na {domain}, tako da je tvoj potpuni opis {handle}",
- "onboarding.page_one.welcome": "Dobro došli u Mastodon!",
+ "onboarding.next": "Sljedeće",
+ "onboarding.page_five.public_timelines": "Lokalni timeline prikazuje javne postove sviju od svakog na {domain}. Federalni timeline prikazuje javne postove svakog koga ljudi na {domain} slijede. To su Javni Timelineovi, sjajan način za otkriti nove ljude.",
+ "onboarding.page_four.home": "The home timeline prikazuje postove ljudi koje slijediš.",
+ "onboarding.page_four.notifications": "Stupac za notifikacije pokazuje poruke drugih upućene tebi.",
+ "onboarding.page_one.federation": "Mastodon čini mreža neovisnih servera udruženih u jednu veću socialnu mrežu. Te servere nazivamo instancama.",
+ "onboarding.page_one.handle": "Ti si na {domain}, i tvoja puna handle je {handle}",
+ "onboarding.page_one.welcome": "Dobro došli na Mastodon!",
"onboarding.page_six.admin": "Administrator tvoje instance je {admin}.",
"onboarding.page_six.almost_done": "Još malo pa gotovo...",
"onboarding.page_six.appetoot": "Živjeli!",
"onboarding.page_six.apps_available": "Postoje {apps} dostupne za iOS, Android i druge platforme.",
- "onboarding.page_six.github": "Mastodon je besplatan softver otvorenog koda. Možeš prijaviti greške, zahtijevati mogućnosti, ili pridonijeti kodu na {github}.",
+ "onboarding.page_six.github": "Mastodon je besplatan softver otvorenog koda. You can report bugs, request features, or contribute to the code on {github}.",
"onboarding.page_six.guidelines": "smjernice zajednice",
- "onboarding.page_six.read_guidelines": "Molimo, pročitaj {domain}'s {guidelines}!",
+ "onboarding.page_six.read_guidelines": "Molimo pročitaj {domain}'s {guidelines}!",
"onboarding.page_six.various_app": "mobilne aplikacije",
- "onboarding.page_three.profile": "Uredi svoj profil mijenjanjem avatara, biografije i imena koje će biti prikazano. Naći ćeš i druge korisne postavke.",
- "onboarding.page_three.search": "Koristi tražilicu kako bi pronašao ljude i sadržaj sa određenim hashtagovima, kao što su {illustration} i {introductions}. Da bi našao osobu koja nije na ovoj instanci, upotrijebi njihov puni opis.",
- "onboarding.page_two.compose": "Piši postove u stupcu za njihovo sastavljanje. Možeš uploadati slike, promijeniti postavke privatnosti, i dodati upozorenja o sadržaju s ikonama ispod.",
+ "onboarding.page_three.profile": "Uredi svoj profil promjenom svog avatara, biografije, i imena. Ovdje ćeš isto tako pronaći i druge postavke.",
+ "onboarding.page_three.search": "Koristi tražilicu kako bi pronašao ljude i tražio hashtags, kao što su {illustration} i {introductions}. Kako bi pronašao osobu koja nije na ovoj instanci, upotrijebi njen pun handle.",
+ "onboarding.page_two.compose": "Piši postove u stupcu za sastavljanje. Možeš uploadati slike, promijeniti postavke privatnosti, i dodati upozorenja o sadržaju s ikonama ispod.",
"onboarding.skip": "Preskoči",
"privacy.change": "Podesi status privatnosti",
"privacy.direct.long": "Prikaži samo spomenutim korisnicima",
@@ -160,14 +163,16 @@
"search.placeholder": "Traži",
"search_results.total": "{count, number} {count, plural, one {result} other {results}}",
"standalone.public_title": "A look inside...",
- "status.cannot_reblog": "Ovaj post ne može biti podignut",
+ "status.cannot_reblog": "Ovaj post ne može biti boostan",
"status.delete": "Obriši",
+ "status.embed": "Embed",
"status.favourite": "Označi omiljenim",
"status.load_more": "Učitaj više",
"status.media_hidden": "Sakriven media sadržaj",
"status.mention": "Spomeni @{name}",
"status.mute_conversation": "Utišaj razgovor",
"status.open": "Proširi ovaj status",
+ "status.pin": "Pin on profile",
"status.reblog": "Podigni",
"status.reblogged_by": "{name} je podigao",
"status.reply": "Odgovori",
@@ -179,6 +184,7 @@
"status.show_less": "Pokaži manje",
"status.show_more": "Pokaži više",
"status.unmute_conversation": "Poništi utišavanje razgovora",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Sastavi",
"tabs_bar.federated_timeline": "Federalni",
"tabs_bar.home": "Dom",
@@ -191,5 +197,5 @@
"video_player.expand": "Proširi video",
"video_player.toggle_sound": "Toggle zvuk",
"video_player.toggle_visible": "Preklopi vidljivost",
- "video_player.video_error": "Video nije mogao biti prikazan"
+ "video_player.video_error": "Video ne može biti reproduciran"
}
diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json
index 4d2a50963..a708ec638 100644
--- a/app/javascript/mastodon/locales/hu.json
+++ b/app/javascript/mastodon/locales/hu.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Törlés",
+ "status.embed": "Embed",
"status.favourite": "Kedvenc",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Említés",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
+ "status.pin": "Pin on profile",
"status.reblog": "Reblog",
"status.reblogged_by": "{name} reblogolta",
"status.reply": "Válasz",
@@ -179,6 +183,7 @@
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Összeállítás",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Kezdőlap",
diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json
index 532739e3c..d71e293e8 100644
--- a/app/javascript/mastodon/locales/id.json
+++ b/app/javascript/mastodon/locales/id.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Apa anda yakin ingin membisukan {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Aktivitas",
"emoji_button.flags": "Bendera",
"emoji_button.food": "Makanan & Minuman",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Hapus",
+ "status.embed": "Embed",
"status.favourite": "Difavoritkan",
"status.load_more": "Tampilkan semua",
"status.media_hidden": "Media disembunyikan",
"status.mention": "Balasan @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Tampilkan status ini",
+ "status.pin": "Pin on profile",
"status.reblog": "Boost",
"status.reblogged_by": "di-boost {name}",
"status.reply": "Balas",
@@ -179,6 +183,7 @@
"status.show_less": "Tampilkan lebih sedikit",
"status.show_more": "Tampilkan semua",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Tulis",
"tabs_bar.federated_timeline": "Gabungan",
"tabs_bar.home": "Beranda",
diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json
index a5e363e40..5df5c59a1 100644
--- a/app/javascript/mastodon/locales/io.json
+++ b/app/javascript/mastodon/locales/io.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Efacar",
+ "status.embed": "Embed",
"status.favourite": "Favorizar",
"status.load_more": "Kargar pluse",
"status.media_hidden": "Kontenajo celita",
"status.mention": "Mencionar @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Detaligar ca mesajo",
+ "status.pin": "Pin on profile",
"status.reblog": "Repetar",
"status.reblogged_by": "{name} repetita",
"status.reply": "Respondar",
@@ -179,6 +183,7 @@
"status.show_less": "Montrar mine",
"status.show_more": "Montrar plue",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Kompozar",
"tabs_bar.federated_timeline": "Federata",
"tabs_bar.home": "Hemo",
diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json
index 329eb82ca..eec35a70c 100644
--- a/app/javascript/mastodon/locales/it.json
+++ b/app/javascript/mastodon/locales/it.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Elimina",
+ "status.embed": "Embed",
"status.favourite": "Apprezzato",
"status.load_more": "Mostra di più",
"status.media_hidden": "Allegato nascosto",
"status.mention": "Nomina @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Espandi questo post",
+ "status.pin": "Pin on profile",
"status.reblog": "Condividi",
"status.reblogged_by": "{name} ha condiviso",
"status.reply": "Rispondi",
@@ -179,6 +183,7 @@
"status.show_less": "Mostra meno",
"status.show_more": "Mostra di più",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Scrivi",
"tabs_bar.federated_timeline": "Federazione",
"tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index 4c98086bb..65838a3f8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -34,6 +34,7 @@
"column.mutes": "ミュートしたユーザー",
"column.notifications": "通知",
"column.public": "連合タイムライン",
+ "column.pins": "固定されたトゥート",
"column_back_button.label": "戻る",
"column_header.hide_settings": "設定を隠す",
"column_header.moveLeft_settings": "カラムを左に移動する",
@@ -63,6 +64,8 @@
"confirmations.mute.message": "本当に{name}をミュートしますか?",
"confirmations.unfollow.confirm": "フォロー解除",
"confirmations.unfollow.message": "本当に{name}をフォロー解除しますか?",
+ "embed.instructions": "下記のコードをコピーしてウェブサイトに埋め込みます。",
+ "embed.preview": "表示例:",
"emoji_button.activity": "活動",
"emoji_button.flags": "国旗",
"emoji_button.food": "食べ物",
@@ -109,6 +112,7 @@
"navigation_bar.mutes": "ミュートしたユーザー",
"navigation_bar.preferences": "ユーザー設定",
"navigation_bar.public_timeline": "連合タイムライン",
+ "navigation_bar.pins": "固定されたトゥート",
"notification.favourite": "{name}さんがあなたのトゥートをお気に入りに登録しました",
"notification.follow": "{name}さんにフォローされました",
"notification.mention": "{name}さんがあなたに返信しました",
@@ -159,15 +163,17 @@
"report.target": "{target} を通報する",
"search.placeholder": "検索",
"search_results.total": "{count, number}件の結果",
- "standalone.public_title": "連合タイムライン",
+ "standalone.public_title": "今こんな話をしています",
"status.cannot_reblog": "この投稿はブーストできません",
"status.delete": "削除",
+ "status.embed": "埋め込み",
"status.favourite": "お気に入り",
"status.load_more": "もっと見る",
"status.media_hidden": "非表示のメディア",
"status.mention": "返信",
"status.mute_conversation": "会話をミュート",
"status.open": "詳細を表示",
+ "status.pin": "プロフィールに固定表示",
"status.reblog": "ブースト",
"status.reblogged_by": "{name}さんにブーストされました",
"status.reply": "返信",
@@ -179,6 +185,7 @@
"status.show_less": "隠す",
"status.show_more": "もっと見る",
"status.unmute_conversation": "会話のミュートを解除",
+ "status.unpin": "プロフィールの固定表示を解除",
"tabs_bar.compose": "投稿",
"tabs_bar.federated_timeline": "連合",
"tabs_bar.home": "ホーム",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 47d0d4087..8393e82e5 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -34,6 +34,7 @@
"column.mutes": "뮤트 중인 사용자",
"column.notifications": "알림",
"column.public": "연합 타임라인",
+ "column.pins": "고정된 Toot",
"column_back_button.label": "돌아가기",
"column_header.hide_settings": "Hide settings",
"column_header.moveLeft_settings": "Move column to the left",
@@ -63,6 +64,8 @@
"confirmations.mute.message": "정말로 {name}를 뮤트하시겠습니까?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "활동",
"emoji_button.flags": "국기",
"emoji_button.food": "음식",
@@ -109,6 +112,7 @@
"navigation_bar.mutes": "뮤트 중인 사용자",
"navigation_bar.preferences": "사용자 설정",
"navigation_bar.public_timeline": "연합 타임라인",
+ "navigation_bar.pins": "고정된 Toot",
"notification.favourite": "{name}님이 즐겨찾기 했습니다",
"notification.follow": "{name}님이 나를 팔로우 했습니다",
"notification.mention": "{name}님이 답글을 보냈습니다",
@@ -162,12 +166,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다",
"status.delete": "삭제",
+ "status.embed": "Embed",
"status.favourite": "즐겨찾기",
"status.load_more": "더 보기",
"status.media_hidden": "미디어 숨겨짐",
"status.mention": "답장",
"status.mute_conversation": "이 대화를 뮤트",
"status.open": "상세 정보 표시",
+ "status.pin": "Pin on profile",
"status.reblog": "부스트",
"status.reblogged_by": "{name}님이 부스트 했습니다",
"status.reply": "답장",
@@ -179,6 +185,7 @@
"status.show_less": "숨기기",
"status.show_more": "더 보기",
"status.unmute_conversation": "이 대화의 뮤트 해제하기",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "포스트",
"tabs_bar.federated_timeline": "연합",
"tabs_bar.home": "홈",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index 4d68c7992..d6775e1e4 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Weet je het zeker dat je {name} wilt negeren?",
"confirmations.unfollow.confirm": "Ontvolgen",
"confirmations.unfollow.message": "Weet je het zeker dat je {name} wilt ontvolgen?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activiteiten",
"emoji_button.flags": "Vlaggen",
"emoji_button.food": "Eten en drinken",
@@ -162,12 +164,14 @@
"standalone.public_title": "Een kijkje binnenin...",
"status.cannot_reblog": "Deze toot kan niet geboost worden",
"status.delete": "Verwijderen",
+ "status.embed": "Embed",
"status.favourite": "Favoriet",
"status.load_more": "Meer laden",
"status.media_hidden": "Media verborgen",
"status.mention": "Vermeld @{name}",
"status.mute_conversation": "Negeer conversatie",
"status.open": "Toot volledig tonen",
+ "status.pin": "Pin on profile",
"status.reblog": "Boost",
"status.reblogged_by": "{name} boostte",
"status.reply": "Reageren",
@@ -179,6 +183,7 @@
"status.show_less": "Minder tonen",
"status.show_more": "Meer tonen",
"status.unmute_conversation": "Conversatie niet meer negeren",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Schrijven",
"tabs_bar.federated_timeline": "Globaal",
"tabs_bar.home": "Start",
diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json
index 9453e65ff..f3c24a807 100644
--- a/app/javascript/mastodon/locales/no.json
+++ b/app/javascript/mastodon/locales/no.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Er du sikker på at du vil dempe {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Aktivitet",
"emoji_button.flags": "Flagg",
"emoji_button.food": "Mat og drikke",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Denne posten kan ikke fremheves",
"status.delete": "Slett",
+ "status.embed": "Embed",
"status.favourite": "Lik",
"status.load_more": "Last mer",
"status.media_hidden": "Media skjult",
"status.mention": "Nevn @{name}",
"status.mute_conversation": "Demp samtale",
"status.open": "Utvid denne statusen",
+ "status.pin": "Pin on profile",
"status.reblog": "Fremhev",
"status.reblogged_by": "Fremhevd av {name}",
"status.reply": "Svar",
@@ -179,6 +183,7 @@
"status.show_less": "Vis mindre",
"status.show_more": "Vis mer",
"status.unmute_conversation": "Ikke demp samtale",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Komponer",
"tabs_bar.federated_timeline": "Felles",
"tabs_bar.home": "Hjem",
diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json
index 5cf6d52c5..d2b2dd48f 100644
--- a/app/javascript/mastodon/locales/oc.json
+++ b/app/javascript/mastodon/locales/oc.json
@@ -12,7 +12,7 @@
"account.mute": "Rescondre @{name}",
"account.posts": "Estatuts",
"account.report": "Senhalar @{name}",
- "account.requested": "Invitacion mandada",
+ "account.requested": "Invitacion mandada. Clicatz per anullar.",
"account.share": "Partejar lo perfil a @{name}",
"account.unblock": "Desblocar @{name}",
"account.unblock_domain": "Desblocar {domain}",
@@ -34,6 +34,7 @@
"column.mutes": "Personas en silenci",
"column.notifications": "Notificacions",
"column.public": "Flux public global",
+ "column.pins": "Tuts penjats",
"column_back_button.label": "Tornar",
"column_header.hide_settings": "Amagar los paramètres",
"column_header.moveLeft_settings": "Desplaçar la colomna a man drecha",
@@ -45,24 +46,26 @@
"column_subheading.settings": "Paramètres",
"compose_form.lock_disclaimer": "Vòstre compte es pas {locked}. Tot lo mond pòt vos sègre e veire los estatuts reservats als seguidors.",
"compose_form.lock_disclaimer.lock": "clavat",
- "compose_form.placeholder": "A de qué pensatz ?",
- "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
+ "compose_form.placeholder": "A de qué pensatz ?",
+ "compose_form.privacy_disclaimer": "Vòstre estatut privat serà enviat a las personas mencionadas sus {domains}. Vos fisatz d’aqueste {domainsCount, plural, one { servidor} other {s servidors}} per divulgar pas vòstre estatut ? Los estatuts privats foncionan pas que sus las instàncias de Mastodon. Se {domains} {domainsCount, plural, one {es pas una instància a Mastodon} other {son pas d'instàncias a Mastodon}}, i aurà pas d’indicacion disent que vòstre estatut es privat e poirà èsser partejat o èsser visible a de mond pas prevists",
"compose_form.publish": "Tut",
- "compose_form.publish_loud": "{publish} !",
+ "compose_form.publish_loud": "{publish} !",
"compose_form.sensitive": "Marcar lo mèdia coma sensible",
"compose_form.spoiler": "Rescondre lo tèxte darrièr un avertiment",
"compose_form.spoiler_placeholder": "Escrivètz l’avertiment aquí",
"confirmation_modal.cancel": "Anullar",
"confirmations.block.confirm": "Blocar",
- "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
+ "confirmations.block.message": "Sètz segur de voler blocar {name} ?",
"confirmations.delete.confirm": "Suprimir",
- "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
+ "confirmations.delete.message": "Sètz segur de voler suprimir l’estatut ?",
"confirmations.domain_block.confirm": "Amagar tot lo domeni",
- "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
+ "confirmations.domain_block.message": "Sètz segur segur de voler blocar completament {domain} ? De còps cal pas que blocar o rescondre unas personas solament.",
"confirmations.mute.confirm": "Metre en silenci",
- "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
+ "confirmations.mute.message": "Sètz segur de voler metre en silenci {name} ?",
"confirmations.unfollow.confirm": "Quitar de sègre",
- "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
+ "confirmations.unfollow.message": "Volètz vertadièrament quitar de sègre {name} ?",
+ "embed.instructions": "Embarcar aqueste estatut per o far veire sus un site Internet en copiar lo còdi çai-jos.",
+ "embed.preview": "Semblarà aquò : ",
"emoji_button.activity": "Activitats",
"emoji_button.flags": "Drapèus",
"emoji_button.food": "Beure e manjar",
@@ -73,19 +76,19 @@
"emoji_button.search": "Cercar…",
"emoji_button.symbols": "Simbòls",
"emoji_button.travel": "Viatges & lòcs",
- "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
+ "empty_column.community": "Lo flux public local es void. Escrivètz quicòm per lo garnir !",
"empty_column.hashtag": "I a pas encara de contengut ligat a aqueste hashtag",
"empty_column.home": "Pel moment seguètz pas degun. Visitatz {public} o utilizatz la recèrca per vos connectar a d’autras personas.",
"empty_column.home.inactivity": "Vòstra pagina d’acuèlh es voida. Se sètz estat inactiu per un moment, serà tornada generar per vos dins una estona.",
"empty_column.home.public_timeline": "lo flux public",
"empty_column.notifications": "Avètz pas encara de notificacions. Respondètz a qualqu’un per començar una conversacion.",
- "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
+ "empty_column.public": "I a pas res aquí ! Escrivètz quicòm de public, o seguètz de personas d’autras instàncias per garnir lo flux public.",
"follow_request.authorize": "Autorizar",
"follow_request.reject": "Regetar",
"getting_started.appsshort": "Apps",
"getting_started.faq": "FAQ",
"getting_started.heading": "Per començar",
- "getting_started.open_source_notice": "Mastodon es un logicial liure. Podètz contribuir e mandar vòstres comentaris e rapòrt de bug via{github} sus GitHub.",
+ "getting_started.open_source_notice": "Mastodon es un logicial liure. Podètz contribuir e mandar vòstres comentaris e rapòrt de bug via {github} sus GitHub.",
"getting_started.userguide": "Guida d’utilizacion",
"home.column_settings.advanced": "Avançat",
"home.column_settings.basic": "Basic",
@@ -94,8 +97,8 @@
"home.column_settings.show_replies": "Mostrar las responsas",
"home.settings": "Paramètres de la colomna",
"lightbox.close": "Tampar",
- "lightbox.next": "Next",
- "lightbox.previous": "Previous",
+ "lightbox.next": "Seguent",
+ "lightbox.previous": "Precedent",
"loading_indicator.label": "Cargament…",
"media_gallery.toggle_visible": "Modificar la visibilitat",
"missing_indicator.label": "Pas trobat",
@@ -109,36 +112,37 @@
"navigation_bar.mutes": "Personas rescondudas",
"navigation_bar.preferences": "Preferéncias",
"navigation_bar.public_timeline": "Flux public global",
- "notification.favourite": "{name} a ajustat a sos favorits :",
+ "navigation_bar.pins": "Tuts penjats",
+ "notification.favourite": "{name} a ajustat a sos favorits :",
"notification.follow": "{name} vos sèc",
- "notification.mention": "{name} vos a mencionat :",
- "notification.reblog": "{name} a partejat vòstre estatut :",
+ "notification.mention": "{name} vos a mencionat :",
+ "notification.reblog": "{name} a partejat vòstre estatut :",
"notifications.clear": "Escafar",
- "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
+ "notifications.clear_confirmation": "Volètz vertadièrament escafar totas vòstras las notificacions ?",
"notifications.column_settings.alert": "Notificacions localas",
- "notifications.column_settings.favourite": "Favorits :",
- "notifications.column_settings.follow": "Nòus seguidors :",
- "notifications.column_settings.mention": "Mencions :",
+ "notifications.column_settings.favourite": "Favorits :",
+ "notifications.column_settings.follow": "Nòus seguidors :",
+ "notifications.column_settings.mention": "Mencions :",
"notifications.column_settings.push": "Notificacions",
"notifications.column_settings.push_meta": "Aqueste periferic",
- "notifications.column_settings.reblog": "Partatges :",
+ "notifications.column_settings.reblog": "Partatges :",
"notifications.column_settings.show": "Mostrar dins la colomna",
"notifications.column_settings.sound": "Emetre un son",
- "onboarding.done": "Fach",
+ "onboarding.done": "Sortir",
"onboarding.next": "Seguent",
- "onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra instància, aquí {domain}. Lo flux federat mòstra los estatuts publics de tot lo mond sus {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
+ "onboarding.page_five.public_timelines": "Lo flux local mòstra los estatuts publics del monde de vòstra instància, aquí {domain}. Lo flux federat mòstra los estatuts publics de la gent que los de {domain} sègon. Son los fluxes publics, un bon biais de trobar de mond.",
"onboarding.page_four.home": "Lo flux d’acuèlh mòstra los estatuts del mond que seguètz.",
"onboarding.page_four.notifications": "La colomna de notificacions vos fa veire quand qualqu’un interagís amb vos",
- "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum ma larg. Òm los apèla instàncias.",
+ "onboarding.page_one.federation": "Mastodon es un malhum de servidors independents que comunican per bastir un malhum mai larg. Òm los apèla instàncias.",
"onboarding.page_one.handle": "Sètz sus {domain}, doncas vòstre identificant complet es {handle}",
- "onboarding.page_one.welcome": "Benvengut a Mastodon !",
+ "onboarding.page_one.welcome": "Benvengut a Mastodon !",
"onboarding.page_six.admin": "Vòstre administrator d’instància es {admin}.",
"onboarding.page_six.almost_done": "Gaireben acabat…",
- "onboarding.page_six.appetoot": "Bon Appetut!",
+ "onboarding.page_six.appetoot": "Bon Appetut !",
"onboarding.page_six.apps_available": "I a d’aplicacions per mobil per iOS, Android e mai.",
"onboarding.page_six.github": "Mastodon es un logicial liure e open-source. Podètz senhalar de bugs, demandar de foncionalitats e contribuir al còdi sus {github}.",
"onboarding.page_six.guidelines": "guida de la comunitat",
- "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} a {domain} !",
+ "onboarding.page_six.read_guidelines": "Mercés de legir la {guidelines} de {domain} !",
"onboarding.page_six.various_app": "aplicacions per mobil",
"onboarding.page_three.profile": "Modificatz vòstre perfil per cambiar vòstre avatar, bio e escais-nom. I a enlà totas las preferéncias.",
"onboarding.page_three.search": "Emplegatz la barra de recèrca per trobar de mond e engachatz las etiquetas coma {illustration} e {introductions}. Per trobar una persona d’una autra instància, picatz son identificant complet.",
@@ -162,14 +166,16 @@
"standalone.public_title": "Una ulhada dedins…",
"status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat",
"status.delete": "Escafar",
+ "status.embed": "Embarcar",
"status.favourite": "Apondre als favorits",
"status.load_more": "Cargar mai",
"status.media_hidden": "Mèdia rescondut",
"status.mention": "Mencionar",
"status.mute_conversation": "Rescondre la conversacion",
"status.open": "Desplegar aqueste estatut",
+ "status.pin": "Penjar al perfil",
"status.reblog": "Partejar",
- "status.reblogged_by": "{name} a partejat :",
+ "status.reblogged_by": "{name} a partejat :",
"status.reply": "Respondre",
"status.replyAll": "Respondre a la conversacion",
"status.report": "Senhalar @{name}",
@@ -179,6 +185,7 @@
"status.show_less": "Tornar plegar",
"status.show_more": "Desplegar",
"status.unmute_conversation": "Conversacions amb silenci levat",
+ "status.unpin": "Tirar del perfil",
"tabs_bar.compose": "Compausar",
"tabs_bar.federated_timeline": "Flux public global",
"tabs_bar.home": "Acuèlh",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index c42721f64..daa60128d 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -10,9 +10,9 @@
"account.media": "Media",
"account.mention": "Wspomnij o @{name}",
"account.mute": "Wycisz @{name}",
- "account.posts": "Posty",
+ "account.posts": "Wpisy",
"account.report": "Zgłoś @{name}",
- "account.requested": "Oczekująca prośba",
+ "account.requested": "Oczekująca prośba, kliknij aby anulować",
"account.share": "Udostępnij profil @{name}",
"account.unblock": "Odblokuj @{name}",
"account.unblock_domain": "Odblokuj domenę {domain}",
@@ -33,6 +33,7 @@
"column.home": "Strona główna",
"column.mutes": "Wyciszeni użytkownicy",
"column.notifications": "Powiadomienia",
+ "column.pins": "Przypięte wpisy",
"column.public": "Globalna oś czasu",
"column_back_button.label": "Wróć",
"column_header.hide_settings": "Ukryj ustawienia",
@@ -43,10 +44,10 @@
"column_header.unpin": "Cofnij przypięcie",
"column_subheading.navigation": "Nawigacja",
"column_subheading.settings": "Ustawienia",
- "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje posty przeznaczone tylko dla śledzących.",
+ "compose_form.lock_disclaimer": "Twoje konto nie jest {locked}. Każdy, kto Cię śledzi, może wyświetlać Twoje wpisy przeznaczone tylko dla śledzących.",
"compose_form.lock_disclaimer.lock": "zablokowane",
"compose_form.placeholder": "Co Ci chodzi po głowie?",
- "compose_form.privacy_disclaimer": "Twój post zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność postów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, post może być widoczny dla niewłaściwych osób.",
+ "compose_form.privacy_disclaimer": "Twój wpis zostanie dostarczony do użytkowników z {domains}. Czy ufasz {domainsCount, plural, one {temu serwerowi} other {tym serwerom}}? Prywatność wpisów obowiązuje tylko na instancjach Mastodona. Jeżeli {domains} {domainsCount, plural, one {nie jest instancją Mastodona} other {nie są instancjami Mastodona}}, wpis może być widoczny dla niewłaściwych osób.",
"compose_form.publish": "Wyślij",
"compose_form.publish_loud": "{publish}!",
"compose_form.sensitive": "Oznacz treści jako wrażliwe",
@@ -63,6 +64,8 @@
"confirmations.mute.message": "Czy na pewno chcesz wyciszyć {name}?",
"confirmations.unfollow.confirm": "Przestań śledzić",
"confirmations.unfollow.message": "Czy na pewno zamierzasz przestać śledzić {name}?",
+ "embed.instructions": "Osadź ten status na swojej stronie wklejając poniższy kod.",
+ "embed.preview": "Tak będzie to wyglądać:",
"emoji_button.activity": "Aktywność",
"emoji_button.flags": "Flagi",
"emoji_button.food": "Żywność i napoje",
@@ -70,11 +73,11 @@
"emoji_button.nature": "Natura",
"emoji_button.objects": "Objekty",
"emoji_button.people": "Ludzie",
- "emoji_button.search": "Szukaj...",
+ "emoji_button.search": "Szukaj…",
"emoji_button.symbols": "Symbole",
"emoji_button.travel": "Podróże i miejsca",
- "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby odbić piłeczkę!",
- "empty_column.hashtag": "Nie ma postów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
+ "empty_column.community": "Lokalna oś czasu jest pusta. Napisz coś publicznie, aby zagaić!",
+ "empty_column.hashtag": "Nie ma wpisów oznaczonych tym hashtagiem. Możesz napisać pierwszy!",
"empty_column.home": "Nie śledzisz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć interesujące Cię profile.",
"empty_column.home.inactivity": "Strumień jest pusty. Jeżeli nie było Cię tu ostatnio, zostanie on wypełniony wkrótce.",
"empty_column.home.public_timeline": "publiczna oś czasu",
@@ -85,7 +88,7 @@
"getting_started.appsshort": "Aplikacje",
"getting_started.faq": "FAQ",
"getting_started.heading": "Naucz się korzystać",
- "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj {github}.",
+ "getting_started.open_source_notice": "Mastodon jest oprogramowaniem o otwartym źródle. Możesz pomóc w rozwoju lub zgłaszać błędy na GitHubie tutaj: {github}.",
"getting_started.userguide": "Podręcznik użytkownika",
"home.column_settings.advanced": "Zaawansowane",
"home.column_settings.basic": "Podstawowe",
@@ -96,7 +99,7 @@
"lightbox.close": "Zamknij",
"lightbox.next": "Następne",
"lightbox.previous": "Poprzednie",
- "loading_indicator.label": "Ładowanie...",
+ "loading_indicator.label": "Ładowanie…",
"media_gallery.toggle_visible": "Przełącz widoczność",
"missing_indicator.label": "Nie znaleziono",
"navigation_bar.blocks": "Zablokowani użytkownicy",
@@ -107,6 +110,7 @@
"navigation_bar.info": "Szczegółowe informacje",
"navigation_bar.logout": "Wyloguj",
"navigation_bar.mutes": "Wyciszeni użytkownicy",
+ "navigation_bar.pins": "Przypięte wpisy",
"navigation_bar.preferences": "Preferencje",
"navigation_bar.public_timeline": "Oś czasu federacji",
"notification.favourite": "{name} dodał Twój status do ulubionych",
@@ -116,12 +120,12 @@
"notifications.clear": "Wyczyść powiadomienia",
"notifications.clear_confirmation": "Czy na pewno chcesz bezpowrotnie usunąć wszystkie powiadomienia?",
"notifications.column_settings.alert": "Powiadomienia na pulpicie",
- "notifications.column_settings.favourite": "Ulubione:",
+ "notifications.column_settings.favourite": "Dodanie do ulubionych:",
"notifications.column_settings.follow": "Nowi śledzący:",
- "notifications.column_settings.mention": "Wspomniali:",
+ "notifications.column_settings.mention": "Wspomnienia:",
"notifications.column_settings.push": "Powiadomienia push",
"notifications.column_settings.push_meta": "To urządzenie",
- "notifications.column_settings.reblog": "Podbili:",
+ "notifications.column_settings.reblog": "Podbicia:",
"notifications.column_settings.show": "Pokaż w kolumnie",
"notifications.column_settings.sound": "Odtwarzaj dźwięk",
"onboarding.done": "Gotowe",
@@ -142,32 +146,34 @@
"onboarding.page_six.various_app": "aplikacje mobilne",
"onboarding.page_three.profile": "Edytuj profil, aby zmienić obraz profilowy, biografię, wyświetlaną nazwę i inne ustawienia.",
"onboarding.page_three.search": "Użyj paska wyszukiwania aby znaleźć ludzi i hashtagi, takie jak {illustration} i {introductions}. Aby znaleźć osobę spoza tej instancji, musisz użyć pełnego adresu.",
- "onboarding.page_two.compose": "Napisz posty, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.",
+ "onboarding.page_two.compose": "Utwórz wpisy, aby wypełnić kolumnę. Możesz wysłać zdjęcia, zmienić ustawienia prywatności lub dodać ostrzeżenie o zawartości.",
"onboarding.skip": "Pomiń",
- "privacy.change": "Dostosuj widoczność postów",
- "privacy.direct.long": "Widoczne tylko dla oznaczonych",
+ "privacy.change": "Dostosuj widoczność wpisów",
+ "privacy.direct.long": "Widoczny tylko dla wspomnianych",
"privacy.direct.short": "Bezpośrednio",
- "privacy.private.long": "Widoczne tylko dla śledzących",
- "privacy.private.short": "Tylko śledzący",
- "privacy.public.long": "Widoczne na publicznych osiach czasu",
- "privacy.public.short": "Publiczne",
- "privacy.unlisted.long": "Niewidoczne na publicznych osiach czasu",
- "privacy.unlisted.short": "Niewidoczne",
+ "privacy.private.long": "Widoczny tylko dla osób, które Cię śledzą",
+ "privacy.private.short": "Tylko dla śledzących",
+ "privacy.public.long": "Widoczny na publicznych osiach czasu",
+ "privacy.public.short": "Publiczny",
+ "privacy.unlisted.long": "Niewidoczny na publicznych osiach czasu",
+ "privacy.unlisted.short": "Niewidoczny",
"reply_indicator.cancel": "Anuluj",
"report.placeholder": "Dodatkowe komentarze",
"report.submit": "Wyślij",
"report.target": "Zgłaszanie {target}",
"search.placeholder": "Szukaj",
"search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}",
- "standalone.public_title": "Spojrzenie wgłąb…",
- "status.cannot_reblog": "Ten post nie może zostać podbity",
+ "standalone.public_title": "Spojrzenie w głąb…",
+ "status.cannot_reblog": "Ten wpis nie może zostać podbity",
"status.delete": "Usuń",
+ "status.embed": "Osadź",
"status.favourite": "Ulubione",
"status.load_more": "Załaduj więcej",
"status.media_hidden": "Zawartość multimedialna ukryta",
"status.mention": "Wspomnij o @{name}",
"status.mute_conversation": "Wycisz konwersację",
"status.open": "Rozszerz ten status",
+ "status.pin": "Przypnij do profilu",
"status.reblog": "Podbij",
"status.reblogged_by": "{name} podbił",
"status.reply": "Odpowiedz",
@@ -178,7 +184,8 @@
"status.share": "Udostępnij",
"status.show_less": "Pokaż mniej",
"status.show_more": "Pokaż więcej",
- "status.unmute_conversation": "Cofnij wyciezenie konwersacji",
+ "status.unmute_conversation": "Cofnij wyciszenie konwersacji",
+ "status.unpin": "Odepnij z profilu",
"tabs_bar.compose": "Napisz",
"tabs_bar.federated_timeline": "Globalne",
"tabs_bar.home": "Strona główna",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 55d2f05de..e861bf73f 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -1,68 +1,70 @@
{
"account.block": "Bloquear @{name}",
- "account.block_domain": "Hide everything from {domain}",
- "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+ "account.block_domain": "Esconder tudo de {domain}",
+ "account.disclaimer_full": "As informações abaixo podem refletir o perfil do usuário de maneira incompleta.",
"account.edit_profile": "Editar perfil",
"account.follow": "Seguir",
"account.followers": "Seguidores",
"account.follows": "Segue",
- "account.follows_you": "É teu seguidor",
- "account.media": "Media",
+ "account.follows_you": "É seu seguidor",
+ "account.media": "Mídia",
"account.mention": "Mencionar @{name}",
"account.mute": "Silenciar @{name}",
"account.posts": "Posts",
"account.report": "Denunciar @{name}",
- "account.requested": "A aguardar aprovação",
- "account.share": "Share @{name}'s profile",
+ "account.requested": "Aguardando aprovação",
+ "account.share": "Compartilhar perfil de @{name}",
"account.unblock": "Não bloquear @{name}",
- "account.unblock_domain": "Unhide {domain}",
+ "account.unblock_domain": "Desbloquear {domain}",
"account.unfollow": "Deixar de seguir",
"account.unmute": "Não silenciar @{name}",
- "account.view_full_profile": "View full profile",
+ "account.view_full_profile": "Ver perfil completo",
"boost_modal.combo": "Pode clicar {combo} para não voltar a ver",
"bundle_column_error.body": "Something went wrong while loading this component.",
- "bundle_column_error.retry": "Try again",
+ "bundle_column_error.retry": "Tente novamente",
"bundle_column_error.title": "Network error",
- "bundle_modal_error.close": "Close",
+ "bundle_modal_error.close": "Fechar",
"bundle_modal_error.message": "Something went wrong while loading this component.",
- "bundle_modal_error.retry": "Try again",
- "column.blocks": "Utilizadores Bloqueados",
+ "bundle_modal_error.retry": "Tente novamente",
+ "column.blocks": "Usuários bloqueados",
"column.community": "Local",
"column.favourites": "Favoritos",
- "column.follow_requests": "Seguidores Pendentes",
- "column.home": "Home",
- "column.mutes": "Utilizadores silenciados",
+ "column.follow_requests": "Seguidores pendentes",
+ "column.home": "Página inicial",
+ "column.mutes": "Usuários silenciados",
"column.notifications": "Notificações",
"column.public": "Global",
"column_back_button.label": "Voltar",
- "column_header.hide_settings": "Hide settings",
- "column_header.moveLeft_settings": "Move column to the left",
- "column_header.moveRight_settings": "Move column to the right",
- "column_header.pin": "Pin",
- "column_header.show_settings": "Show settings",
- "column_header.unpin": "Unpin",
- "column_subheading.navigation": "Navigation",
- "column_subheading.settings": "Settings",
- "compose_form.lock_disclaimer": "Your account is not {locked}. Anyone can follow you to view your follower-only posts.",
+ "column_header.hide_settings": "Esconder configurações",
+ "column_header.moveLeft_settings": "Mover coluna para a esquerda",
+ "column_header.moveRight_settings": "Mover coluna para a direita",
+ "column_header.pin": "Fixar",
+ "column_header.show_settings": "Mostrar configurações",
+ "column_header.unpin": "Desafixar",
+ "column_subheading.navigation": "Navegação",
+ "column_subheading.settings": "Configurações",
+ "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar as suas postagens só para seguidores.",
"compose_form.lock_disclaimer.lock": "locked",
- "compose_form.placeholder": "Em que estás a pensar?",
- "compose_form.privacy_disclaimer": "O teu conteúdo privado vai ser partilhado com os utilizadores do {domains}. Confias {domainsCount, plural, one {neste servidor} other {nestes servidores}}? A privacidade só funciona em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não existem indicadores da privacidade da tua partilha, e podem ser partilhados com outros.",
+ "compose_form.placeholder": "No que você está pensando?",
+ "compose_form.privacy_disclaimer": "O seu conteúdo privado será compartilhado com os usuários do {domains}. Você confia {domainsCount, plural, one {neste servidor} other {nestes servidores}}? As configurações de privacidade só funcionam em instâncias do Mastodon. Se {domains} {domainsCount, plural, one {não é uma instância} other {não são instâncias}}, não há como garantir a privacidade de suas postagens, e elas podem ser compartilhadas com outros.",
"compose_form.publish": "Publicar",
"compose_form.publish_loud": "{publish}!",
- "compose_form.sensitive": "Marcar media como conteúdo sensível",
+ "compose_form.sensitive": "Marcar mídia como conteúdo sensível",
"compose_form.spoiler": "Esconder texto com aviso",
"compose_form.spoiler_placeholder": "Aviso de conteúdo",
- "confirmation_modal.cancel": "Cancel",
- "confirmations.block.confirm": "Block",
- "confirmations.block.message": "Are you sure you want to block {name}?",
- "confirmations.delete.confirm": "Delete",
- "confirmations.delete.message": "Are you sure you want to delete this status?",
- "confirmations.domain_block.confirm": "Hide entire domain",
- "confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
- "confirmations.mute.confirm": "Mute",
- "confirmations.mute.message": "Are you sure you want to mute {name}?",
- "confirmations.unfollow.confirm": "Unfollow",
- "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "confirmation_modal.cancel": "Cancelar",
+ "confirmations.block.confirm": "Bloquear",
+ "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?",
+ "confirmations.delete.confirm": "Excluir",
+ "confirmations.delete.message": "Você tem certeza de que quer excluir este status?",
+ "confirmations.domain_block.confirm": "Esconder o domínio inteiro",
+ "confirmations.domain_block.message": "Você quer mesmo bloquear {domain} inteiro? Na maioria dos casos, silenciar ou bloquear alguns usuários é o suficiente e o recomendado.",
+ "confirmations.mute.confirm": "Silenciar",
+ "confirmations.mute.message": "Você tem certeza de que quer silenciar {name}?",
+ "confirmations.unfollow.confirm": "Deixar de seguir",
+ "confirmations.unfollow.message": "Você tem certeza de que quer deixar de seguir {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Eliminar",
+ "status.embed": "Embed",
"status.favourite": "Adicionar aos favoritos",
"status.load_more": "Carregar mais",
"status.media_hidden": "Media escondida",
"status.mention": "Mencionar @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expandir",
+ "status.pin": "Pin on profile",
"status.reblog": "Partilhar",
"status.reblogged_by": "{name} partilhou",
"status.reply": "Responder",
@@ -179,6 +183,7 @@
"status.show_less": "Mostrar menos",
"status.show_more": "Mostrar mais",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Criar",
"tabs_bar.federated_timeline": "Global",
"tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json
index 55d2f05de..f9e686411 100644
--- a/app/javascript/mastodon/locales/pt.json
+++ b/app/javascript/mastodon/locales/pt.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Eliminar",
+ "status.embed": "Embed",
"status.favourite": "Adicionar aos favoritos",
"status.load_more": "Carregar mais",
"status.media_hidden": "Media escondida",
"status.mention": "Mencionar @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expandir",
+ "status.pin": "Pin on profile",
"status.reblog": "Partilhar",
"status.reblogged_by": "{name} partilhou",
"status.reply": "Responder",
@@ -179,6 +183,7 @@
"status.show_less": "Mostrar menos",
"status.show_more": "Mostrar mais",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Criar",
"tabs_bar.federated_timeline": "Global",
"tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json
index 1abfb4370..0f78f4b17 100644
--- a/app/javascript/mastodon/locales/ru.json
+++ b/app/javascript/mastodon/locales/ru.json
@@ -1,7 +1,7 @@
{
"account.block": "Блокировать",
"account.block_domain": "Блокировать все с {domain}",
- "account.disclaimer_full": "Information below may reflect the user's profile incompletely.",
+ "account.disclaimer_full": "Нижеуказанная информация может не полностью отражать профиль пользователя.",
"account.edit_profile": "Изменить профиль",
"account.follow": "Подписаться",
"account.followers": "Подписаны",
@@ -13,19 +13,19 @@
"account.posts": "Посты",
"account.report": "Пожаловаться",
"account.requested": "Ожидает подтверждения",
- "account.share": "Share @{name}'s profile",
+ "account.share": "Поделиться профилем @{name}",
"account.unblock": "Разблокировать",
"account.unblock_domain": "Разблокировать {domain}",
"account.unfollow": "Отписаться",
"account.unmute": "Снять глушение",
- "account.view_full_profile": "View full profile",
+ "account.view_full_profile": "Показать полный профиль",
"boost_modal.combo": "Нажмите {combo}, чтобы пропустить это в следующий раз",
- "bundle_column_error.body": "Something went wrong while loading this component.",
- "bundle_column_error.retry": "Try again",
- "bundle_column_error.title": "Network error",
- "bundle_modal_error.close": "Close",
- "bundle_modal_error.message": "Something went wrong while loading this component.",
- "bundle_modal_error.retry": "Try again",
+ "bundle_column_error.body": "Что-то пошло не так при загрузке этого компонента.",
+ "bundle_column_error.retry": "Попробовать снова",
+ "bundle_column_error.title": "Ошибка сети",
+ "bundle_modal_error.close": "Закрыть",
+ "bundle_modal_error.message": "Что-то пошло не так при загрузке этого компонента.",
+ "bundle_modal_error.retry": "Попробовать снова",
"column.blocks": "Список блокировки",
"column.community": "Локальная лента",
"column.favourites": "Понравившееся",
@@ -35,11 +35,11 @@
"column.notifications": "Уведомления",
"column.public": "Глобальная лента",
"column_back_button.label": "Назад",
- "column_header.hide_settings": "Hide settings",
- "column_header.moveLeft_settings": "Move column to the left",
- "column_header.moveRight_settings": "Move column to the right",
+ "column_header.hide_settings": "Скрыть настройки",
+ "column_header.moveLeft_settings": "Передвинуть колонку влево",
+ "column_header.moveRight_settings": "Передвинуть колонку вправо",
"column_header.pin": "Закрепить",
- "column_header.show_settings": "Show settings",
+ "column_header.show_settings": "Показать настройки",
"column_header.unpin": "Открепить",
"column_subheading.navigation": "Навигация",
"column_subheading.settings": "Настройки",
@@ -61,8 +61,10 @@
"confirmations.domain_block.message": "Вы на самом деле уверены, что хотите блокировать весь {domain}? В большинстве случаев нескольких отдельных блокировок или глушений достаточно.",
"confirmations.mute.confirm": "Заглушить",
"confirmations.mute.message": "Вы уверены, что хотите заглушить {name}?",
- "confirmations.unfollow.confirm": "Unfollow",
- "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "confirmations.unfollow.confirm": "Отписаться",
+ "confirmations.unfollow.message": "Вы уверены, что хотите отписаться от {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Занятия",
"emoji_button.flags": "Флаги",
"emoji_button.food": "Еда и напитки",
@@ -94,8 +96,8 @@
"home.column_settings.show_replies": "Показывать ответы",
"home.settings": "Настройки колонки",
"lightbox.close": "Закрыть",
- "lightbox.next": "Next",
- "lightbox.previous": "Previous",
+ "lightbox.next": "Далее",
+ "lightbox.previous": "Назад",
"loading_indicator.label": "Загрузка...",
"media_gallery.toggle_visible": "Показать/скрыть",
"missing_indicator.label": "Не найдено",
@@ -119,8 +121,8 @@
"notifications.column_settings.favourite": "Нравится:",
"notifications.column_settings.follow": "Новые подписчики:",
"notifications.column_settings.mention": "Упоминания:",
- "notifications.column_settings.push": "Push notifications",
- "notifications.column_settings.push_meta": "This device",
+ "notifications.column_settings.push": "Push-уведомления",
+ "notifications.column_settings.push_meta": "Это устройство",
"notifications.column_settings.reblog": "Продвижения:",
"notifications.column_settings.show": "Показывать в колонке",
"notifications.column_settings.sound": "Проигрывать звук",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Этот статус не может быть продвинут",
"status.delete": "Удалить",
+ "status.embed": "Embed",
"status.favourite": "Нравится",
"status.load_more": "Показать еще",
"status.media_hidden": "Медиаконтент скрыт",
"status.mention": "Упомянуть @{name}",
"status.mute_conversation": "Заглушить тред",
"status.open": "Развернуть статус",
+ "status.pin": "Pin on profile",
"status.reblog": "Продвинуть",
"status.reblogged_by": "{name} продвинул(а)",
"status.reply": "Ответить",
@@ -179,6 +183,7 @@
"status.show_less": "Свернуть",
"status.show_more": "Развернуть",
"status.unmute_conversation": "Снять глушение с треда",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Написать",
"tabs_bar.federated_timeline": "Глобальная",
"tabs_bar.home": "Главная",
diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json
index aa0929f82..069fdf7c3 100644
--- a/app/javascript/mastodon/locales/th.json
+++ b/app/javascript/mastodon/locales/th.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Are you sure you want to mute {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Activity",
"emoji_button.flags": "Flags",
"emoji_button.food": "Food & Drink",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "This post cannot be boosted",
"status.delete": "Delete",
+ "status.embed": "Embed",
"status.favourite": "Favourite",
"status.load_more": "Load more",
"status.media_hidden": "Media hidden",
"status.mention": "Mention @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Expand this status",
+ "status.pin": "Pin on profile",
"status.reblog": "Boost",
"status.reblogged_by": "{name} boosted",
"status.reply": "Reply",
@@ -179,6 +183,7 @@
"status.show_less": "Show less",
"status.show_more": "Show more",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Compose",
"tabs_bar.federated_timeline": "Federated",
"tabs_bar.home": "Home",
diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json
index 37ce8597e..8a36bd207 100644
--- a/app/javascript/mastodon/locales/tr.json
+++ b/app/javascript/mastodon/locales/tr.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "{name} kullanıcısını sessize almak istiyor musunuz?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Aktivite",
"emoji_button.flags": "Bayraklar",
"emoji_button.food": "Yiyecek ve İçecek",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Bu gönderi boost edilemez",
"status.delete": "Sil",
+ "status.embed": "Embed",
"status.favourite": "Favorilere ekle",
"status.load_more": "Daha fazla",
"status.media_hidden": "Gizli görsel",
"status.mention": "Bahset @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "Bu gönderiyi genişlet",
+ "status.pin": "Pin on profile",
"status.reblog": "Boost'la",
"status.reblogged_by": "{name} boost etti",
"status.reply": "Cevapla",
@@ -179,6 +183,7 @@
"status.show_less": "Daha azı",
"status.show_more": "Daha fazlası",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Oluştur",
"tabs_bar.federated_timeline": "Federe",
"tabs_bar.home": "Ana sayfa",
diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json
index fea7bd94e..1d06218e6 100644
--- a/app/javascript/mastodon/locales/uk.json
+++ b/app/javascript/mastodon/locales/uk.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "Ви впевнені, що хочете заглушити {name}?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "Заняття",
"emoji_button.flags": "Прапори",
"emoji_button.food": "Їжа та напої",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "Цей допис не може бути передмухнутий",
"status.delete": "Видалити",
+ "status.embed": "Embed",
"status.favourite": "Подобається",
"status.load_more": "Завантажити більше",
"status.media_hidden": "Медіаконтент приховано",
"status.mention": "Згадати",
"status.mute_conversation": "Заглушити діалог",
"status.open": "Розгорнути допис",
+ "status.pin": "Pin on profile",
"status.reblog": "Передмухнути",
"status.reblogged_by": "{name} передмухнув(-ла)",
"status.reply": "Відповісти",
@@ -179,6 +183,7 @@
"status.show_less": "Згорнути",
"status.show_more": "Розгорнути",
"status.unmute_conversation": "Зняти глушення з діалогу",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "Написати",
"tabs_bar.federated_timeline": "Глобальна",
"tabs_bar.home": "Головна",
diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json
index d0c4b3d1b..93faf8876 100644
--- a/app/javascript/mastodon/locales/zh-CN.json
+++ b/app/javascript/mastodon/locales/zh-CN.json
@@ -5,7 +5,7 @@
"account.edit_profile": "修改个人资料",
"account.follow": "关注",
"account.followers": "关注者",
- "account.follows": "正关注",
+ "account.follows": "正在关注",
"account.follows_you": "关注你",
"account.media": "Media",
"account.mention": "提及 @{name}",
@@ -13,19 +13,19 @@
"account.posts": "嘟文",
"account.report": "举报 @{name}",
"account.requested": "等待审批",
- "account.share": "Share @{name}'s profile",
+ "account.share": "分享 @{name}的个人资料",
"account.unblock": "解除对 @{name} 的屏蔽",
- "account.unblock_domain": "Unhide {domain}",
+ "account.unblock_domain": "解除封锁 {domain}",
"account.unfollow": "取消关注",
"account.unmute": "取消 @{name} 的静音",
- "account.view_full_profile": "View full profile",
+ "account.view_full_profile": "查看完整资料",
"boost_modal.combo": "如你想在下次路过时显示,请按{combo},",
- "bundle_column_error.body": "Something went wrong while loading this component.",
- "bundle_column_error.retry": "Try again",
- "bundle_column_error.title": "Network error",
- "bundle_modal_error.close": "Close",
- "bundle_modal_error.message": "Something went wrong while loading this component.",
- "bundle_modal_error.retry": "Try again",
+ "bundle_column_error.body": "载入组件出错。",
+ "bundle_column_error.retry": "再次尝试",
+ "bundle_column_error.title": "网络错误",
+ "bundle_modal_error.close": "关闭",
+ "bundle_modal_error.message": "载入组件出错。",
+ "bundle_modal_error.retry": "再次尝试",
"column.blocks": "屏蔽用户",
"column.community": "本站时间轴",
"column.favourites": "赞过的嘟文",
@@ -34,7 +34,7 @@
"column.mutes": "被静音的用户",
"column.notifications": "通知",
"column.public": "跨站公共时间轴",
- "column_back_button.label": "Back",
+ "column_back_button.label": "返回",
"column_header.hide_settings": "Hide settings",
"column_header.moveLeft_settings": "Move column to the left",
"column_header.moveRight_settings": "Move column to the right",
@@ -61,8 +61,10 @@
"confirmations.domain_block.message": "Are you really, really sure you want to block the entire {domain}? In most cases a few targeted blocks or mutes are sufficient and preferable.",
"confirmations.mute.confirm": "静音",
"confirmations.mute.message": "想好了,真的要静音 {name}?",
- "confirmations.unfollow.confirm": "Unfollow",
- "confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "confirmations.unfollow.confirm": "取消关注",
+ "confirmations.unfollow.message": "确定要取消关注 {name}吗?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "活动",
"emoji_button.flags": "旗帜",
"emoji_button.food": "食物和饮料",
@@ -86,7 +88,7 @@
"getting_started.faq": "FAQ",
"getting_started.heading": "开始使用",
"getting_started.open_source_notice": "Mastodon 是一个开放源码的软件。你可以在官方 GitHub ({github}) 贡献或者回报问题。",
- "getting_started.userguide": "User Guide",
+ "getting_started.userguide": "用户指南",
"home.column_settings.advanced": "高端",
"home.column_settings.basic": "基本",
"home.column_settings.filter_regex": "使用正则表达式 (regex) 过滤",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "没法转嘟这条嘟文啦……",
"status.delete": "删除",
+ "status.embed": "Embed",
"status.favourite": "赞",
"status.load_more": "加载更多",
"status.media_hidden": "隐藏媒体内容",
"status.mention": "提及 @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "展开嘟文",
+ "status.pin": "Pin on profile",
"status.reblog": "转嘟",
"status.reblogged_by": "{name} 转嘟",
"status.reply": "回应",
@@ -179,6 +183,7 @@
"status.show_less": "减少显示",
"status.show_more": "显示更多",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "撰写",
"tabs_bar.federated_timeline": "跨站",
"tabs_bar.home": "主页",
diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json
index 7312aae82..d689cd5ae 100644
--- a/app/javascript/mastodon/locales/zh-HK.json
+++ b/app/javascript/mastodon/locales/zh-HK.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "你確定要將{name}靜音嗎?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "活動",
"emoji_button.flags": "旗幟",
"emoji_button.food": "飲飲食食",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "這篇文章無法被轉推",
"status.delete": "刪除",
+ "status.embed": "Embed",
"status.favourite": "喜歡",
"status.load_more": "載入更多",
"status.media_hidden": "隱藏媒體內容",
"status.mention": "提及 @{name}",
"status.mute_conversation": "Mute conversation",
"status.open": "展開文章",
+ "status.pin": "Pin on profile",
"status.reblog": "轉推",
"status.reblogged_by": "{name} 轉推",
"status.reply": "回應",
@@ -179,6 +183,7 @@
"status.show_less": "減少顯示",
"status.show_more": "顯示更多",
"status.unmute_conversation": "Unmute conversation",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "撰寫",
"tabs_bar.federated_timeline": "跨站",
"tabs_bar.home": "主頁",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 1c2e35272..dcb9d7f3c 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -63,6 +63,8 @@
"confirmations.mute.message": "你確定要消音 {name} ?",
"confirmations.unfollow.confirm": "Unfollow",
"confirmations.unfollow.message": "Are you sure you want to unfollow {name}?",
+ "embed.instructions": "Embed this status on your website by copying the code below.",
+ "embed.preview": "Here is what it will look like:",
"emoji_button.activity": "活動",
"emoji_button.flags": "旗幟",
"emoji_button.food": "食物與飲料",
@@ -162,12 +164,14 @@
"standalone.public_title": "A look inside...",
"status.cannot_reblog": "此貼文無法轉推",
"status.delete": "刪除",
+ "status.embed": "Embed",
"status.favourite": "喜愛",
"status.load_more": "載入更多",
"status.media_hidden": "媒體已隱藏",
"status.mention": "提到 @{name}",
"status.mute_conversation": "消音對話",
"status.open": "展開這個狀態",
+ "status.pin": "Pin on profile",
"status.reblog": "轉推",
"status.reblogged_by": "{name} 轉推了",
"status.reply": "回應",
@@ -179,6 +183,7 @@
"status.show_less": "看少點",
"status.show_more": "看更多",
"status.unmute_conversation": "不消音對話",
+ "status.unpin": "Unpin from profile",
"tabs_bar.compose": "編輯",
"tabs_bar.federated_timeline": "聯盟",
"tabs_bar.home": "家",
diff --git a/app/javascript/mastodon/reducers/accounts.js b/app/javascript/mastodon/reducers/accounts.js
index 4d7c3adc9..6442d13be 100644
--- a/app/javascript/mastodon/reducers/accounts.js
+++ b/app/javascript/mastodon/reducers/accounts.js
@@ -44,7 +44,9 @@ import {
FAVOURITED_STATUSES_EXPAND_SUCCESS,
} from '../actions/favourites';
import { STORE_HYDRATE } from '../actions/store';
+import emojify from '../emoji';
import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
const normalizeAccount = (state, account) => {
account = { ...account };
@@ -53,6 +55,10 @@ const normalizeAccount = (state, account) => {
delete account.following_count;
delete account.statuses_count;
+ const displayName = account.display_name.length === 0 ? account.username : account.display_name;
+ account.display_name_html = emojify(escapeTextContentForBrowser(displayName));
+ account.note_emojified = emojify(account.note);
+
return state.set(account.id, fromJS(account));
};
diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js
index e137b774e..34f5dab7f 100644
--- a/app/javascript/mastodon/reducers/compose.js
+++ b/app/javascript/mastodon/reducers/compose.js
@@ -141,10 +141,20 @@ const privacyPreference = (a, b) => {
}
};
+const hydrate = (state, hydratedState) => {
+ state = clearAll(state.merge(hydratedState));
+
+ if (hydratedState.has('text')) {
+ state = state.set('text', hydratedState.get('text'));
+ }
+
+ return state;
+};
+
export default function compose(state = initialState, action) {
switch(action.type) {
case STORE_HYDRATE:
- return clearAll(state.merge(action.state.get('compose')));
+ return hydrate(state, action.state.get('compose'));
case COMPOSE_MOUNT:
return state.set('mounted', true);
case COMPOSE_UNMOUNT:
diff --git a/app/javascript/mastodon/reducers/reports.js b/app/javascript/mastodon/reducers/reports.js
index 283c5b6f5..a08bbec38 100644
--- a/app/javascript/mastodon/reducers/reports.js
+++ b/app/javascript/mastodon/reducers/reports.js
@@ -28,7 +28,7 @@ export default function reports(state = initialState, action) {
if (state.getIn(['new', 'account_id']) !== action.account.get('id')) {
map.setIn(['new', 'status_ids'], action.status ? ImmutableSet([action.status.getIn(['reblog', 'id'], action.status.get('id'))]) : ImmutableSet());
map.setIn(['new', 'comment'], '');
- } else {
+ } else if (action.status) {
map.updateIn(['new', 'status_ids'], ImmutableSet(), set => set.add(action.status.getIn(['reblog', 'id'], action.status.get('id'))));
}
});
diff --git a/app/javascript/mastodon/reducers/status_lists.js b/app/javascript/mastodon/reducers/status_lists.js
index bbc973302..c4aeb338f 100644
--- a/app/javascript/mastodon/reducers/status_lists.js
+++ b/app/javascript/mastodon/reducers/status_lists.js
@@ -2,7 +2,16 @@ import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS,
} from '../actions/favourites';
+import {
+ PINNED_STATUSES_FETCH_SUCCESS,
+} from '../actions/pin_statuses';
import { Map as ImmutableMap, List as ImmutableList } from 'immutable';
+import {
+ FAVOURITE_SUCCESS,
+ UNFAVOURITE_SUCCESS,
+ PIN_SUCCESS,
+ UNPIN_SUCCESS,
+} from '../actions/interactions';
const initialState = ImmutableMap({
favourites: ImmutableMap({
@@ -10,6 +19,11 @@ const initialState = ImmutableMap({
loaded: false,
items: ImmutableList(),
}),
+ pins: ImmutableMap({
+ next: null,
+ loaded: false,
+ items: ImmutableList(),
+ }),
});
const normalizeList = (state, listType, statuses, next) => {
@@ -27,12 +41,34 @@ const appendToList = (state, listType, statuses, next) => {
}));
};
+const prependOneToList = (state, listType, status) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('items', map.get('items').unshift(status.get('id')));
+ }));
+};
+
+const removeOneFromList = (state, listType, status) => {
+ return state.update(listType, listMap => listMap.withMutations(map => {
+ map.set('items', map.get('items').filter(item => item !== status.get('id')));
+ }));
+};
+
export default function statusLists(state = initialState, action) {
switch(action.type) {
case FAVOURITED_STATUSES_FETCH_SUCCESS:
return normalizeList(state, 'favourites', action.statuses, action.next);
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
return appendToList(state, 'favourites', action.statuses, action.next);
+ case FAVOURITE_SUCCESS:
+ return prependOneToList(state, 'favourites', action.status);
+ case UNFAVOURITE_SUCCESS:
+ return removeOneFromList(state, 'favourites', action.status);
+ case PINNED_STATUSES_FETCH_SUCCESS:
+ return normalizeList(state, 'pins', action.statuses, action.next);
+ case PIN_SUCCESS:
+ return prependOneToList(state, 'pins', action.status);
+ case UNPIN_SUCCESS:
+ return removeOneFromList(state, 'pins', action.status);
default:
return state;
}
diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js
index b1b1d0988..eec2a5f16 100644
--- a/app/javascript/mastodon/reducers/statuses.js
+++ b/app/javascript/mastodon/reducers/statuses.js
@@ -7,12 +7,16 @@ import {
FAVOURITE_SUCCESS,
FAVOURITE_FAIL,
UNFAVOURITE_SUCCESS,
+ PIN_SUCCESS,
+ UNPIN_SUCCESS,
} from '../actions/interactions';
import {
STATUS_FETCH_SUCCESS,
CONTEXT_FETCH_SUCCESS,
STATUS_MUTE_SUCCESS,
STATUS_UNMUTE_SUCCESS,
+ STATUS_SET_HEIGHT,
+ STATUSES_CLEAR_HEIGHT,
} from '../actions/statuses';
import {
TIMELINE_REFRESH_SUCCESS,
@@ -32,8 +36,15 @@ import {
FAVOURITED_STATUSES_FETCH_SUCCESS,
FAVOURITED_STATUSES_EXPAND_SUCCESS,
} from '../actions/favourites';
+import {
+ PINNED_STATUSES_FETCH_SUCCESS,
+} from '../actions/pin_statuses';
import { SEARCH_FETCH_SUCCESS } from '../actions/search';
+import emojify from '../emoji';
import { Map as ImmutableMap, fromJS } from 'immutable';
+import escapeTextContentForBrowser from 'escape-html';
+
+const domParser = new DOMParser();
const normalizeStatus = (state, status) => {
if (!status) {
@@ -49,7 +60,9 @@ const normalizeStatus = (state, status) => {
}
const searchContent = [status.spoiler_text, status.content].join(' ').replace(/
/g, '\n').replace(/<\/p>/g, '\n\n');
- normalStatus.search_index = new DOMParser().parseFromString(searchContent, 'text/html').documentElement.textContent;
+ normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
+ normalStatus.contentHtml = emojify(normalStatus.content);
+ normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''));
return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
};
@@ -82,6 +95,18 @@ const filterStatuses = (state, relationship) => {
return state;
};
+const setHeight = (state, id, height) => {
+ return state.update(id, ImmutableMap(), map => map.set('height', height));
+};
+
+const clearHeights = (state) => {
+ state.forEach(status => {
+ state = state.deleteIn([status.get('id'), 'height']);
+ });
+
+ return state;
+};
+
const initialState = ImmutableMap();
export default function statuses(state = initialState, action) {
@@ -94,6 +119,8 @@ export default function statuses(state = initialState, action) {
case UNREBLOG_SUCCESS:
case FAVOURITE_SUCCESS:
case UNFAVOURITE_SUCCESS:
+ case PIN_SUCCESS:
+ case UNPIN_SUCCESS:
return normalizeStatus(state, action.response);
case FAVOURITE_REQUEST:
return state.setIn([action.status.get('id'), 'favourited'], true);
@@ -114,12 +141,17 @@ export default function statuses(state = initialState, action) {
case NOTIFICATIONS_EXPAND_SUCCESS:
case FAVOURITED_STATUSES_FETCH_SUCCESS:
case FAVOURITED_STATUSES_EXPAND_SUCCESS:
+ case PINNED_STATUSES_FETCH_SUCCESS:
case SEARCH_FETCH_SUCCESS:
return normalizeStatuses(state, action.statuses);
case TIMELINE_DELETE:
return deleteStatus(state, action.id, action.references);
case ACCOUNT_BLOCK_SUCCESS:
return filterStatuses(state, action.relationship);
+ case STATUS_SET_HEIGHT:
+ return setHeight(state, action.id, action.height);
+ case STATUSES_CLEAR_HEIGHT:
+ return clearHeights(state);
default:
return state;
}
diff --git a/app/javascript/mastodon/scroll.js b/app/javascript/mastodon/scroll.js
index c089d37db..2af07e0fb 100644
--- a/app/javascript/mastodon/scroll.js
+++ b/app/javascript/mastodon/scroll.js
@@ -1,9 +1,9 @@
const easingOutQuint = (x, t, b, c, d) => c * ((t = t / d - 1) * t * t * t * t + 1) + b;
-const scrollTop = (node) => {
+const scroll = (node, key, target) => {
const startTime = Date.now();
- const offset = node.scrollTop;
- const targetY = -offset;
+ const offset = node[key];
+ const gap = target - offset;
const duration = 1000;
let interrupt = false;
@@ -15,7 +15,7 @@ const scrollTop = (node) => {
return;
}
- node.scrollTop = easingOutQuint(0, elapsed, offset, targetY, duration);
+ node[key] = easingOutQuint(0, elapsed, offset, gap, duration);
requestAnimationFrame(step);
};
@@ -26,4 +26,5 @@ const scrollTop = (node) => {
};
};
-export default scrollTop;
+export const scrollRight = (node, position) => scroll(node, 'scrollLeft', position);
+export const scrollTop = (node) => scroll(node, 'scrollTop', 0);
diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js
index acb85f626..f63cff335 100644
--- a/app/javascript/mastodon/service_worker/web_push_notifications.js
+++ b/app/javascript/mastodon/service_worker/web_push_notifications.js
@@ -31,8 +31,8 @@ const notify = options =>
const group = cloneNotification(notifications[0]);
group.title = formatGroupTitle(group.data.message, group.data.count + 1);
- group.body = `${options.title}\n${group.body}`;
- group.data = { ...group.data, count: group.data.count + 1 };
+ group.body = `${options.title}\n${group.body}`;
+ group.data = { ...group.data, count: group.data.count + 1 };
return self.registration.showNotification(group.title, group);
}
@@ -43,18 +43,18 @@ const notify = options =>
const handlePush = (event) => {
const options = event.data.json();
- options.body = options.data.nsfw || options.data.content;
- options.image = options.image || undefined; // Null results in a network request (404)
+ options.body = options.data.nsfw || options.data.content;
+ options.dir = options.data.dir;
+ options.image = options.image || undefined; // Null results in a network request (404)
options.timestamp = options.timestamp && new Date(options.timestamp);
const expandAction = options.data.actions.find(action => action.todo === 'expand');
if (expandAction) {
- options.actions = [expandAction];
- options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
-
+ options.actions = [expandAction];
+ options.hiddenActions = options.data.actions.filter(action => action !== expandAction);
options.data.hiddenImage = options.image;
- options.image = undefined;
+ options.image = undefined;
} else {
options.actions = options.data.actions;
}
@@ -75,8 +75,8 @@ const cloneNotification = (notification) => {
const expandNotification = (notification) => {
const nextNotification = cloneNotification(notification);
- nextNotification.body = notification.data.content;
- nextNotification.image = notification.data.hiddenImage;
+ nextNotification.body = notification.data.content;
+ nextNotification.image = notification.data.hiddenImage;
nextNotification.actions = notification.data.actions.filter(action => action.todo !== 'expand');
return self.registration.showNotification(nextNotification.title, nextNotification);
@@ -105,8 +105,7 @@ const openUrl = url =>
const webClients = clientList.filter(client => /\/web\//.test(client.url));
if (webClients.length !== 0) {
- const client = findBestClient(webClients);
-
+ const client = findBestClient(webClients);
const { pathname } = new URL(url);
if (pathname.startsWith('/web/')) {
@@ -126,8 +125,7 @@ const openUrl = url =>
});
const removeActionFromNotification = (notification, action) => {
- const actions = notification.actions.filter(act => act.action !== action.action);
-
+ const actions = notification.actions.filter(act => act.action !== action.action);
const nextNotification = cloneNotification(notification);
nextNotification.actions = actions;
diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js
index 96ac63b52..3dbed09ea 100644
--- a/app/javascript/mastodon/web_push_subscription.js
+++ b/app/javascript/mastodon/web_push_subscription.js
@@ -48,7 +48,6 @@ export function register () {
if (supportsPushNotifications) {
if (!getApplicationServerKey()) {
- // eslint-disable-next-line no-console
console.error('The VAPID public key is not set. You will not be able to receive Web Push Notifications.');
return;
}
@@ -84,10 +83,8 @@ export function register () {
})
.catch(error => {
if (error.code === 20 && error.name === 'AbortError') {
- // eslint-disable-next-line no-console
console.warn('Your browser supports Web Push Notifications, but does not seem to implement the VAPID protocol.');
} else if (error.code === 5 && error.name === 'InvalidCharacterError') {
- // eslint-disable-next-line no-console
console.error('The VAPID public key seems to be invalid:', getApplicationServerKey());
}
@@ -103,7 +100,6 @@ export function register () {
}
});
} else {
- // eslint-disable-next-line no-console
console.warn('Your browser does not support Web Push Notifications.');
}
}
diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js
index da1f550fc..1a274c4cb 100644
--- a/app/javascript/packs/public.js
+++ b/app/javascript/packs/public.js
@@ -1,4 +1,21 @@
import loadPolyfills from '../mastodon/load_polyfills';
+import ready from '../mastodon/ready';
+
+window.addEventListener('message', e => {
+ const data = e.data || {};
+
+ if (!window.parent || data.type !== 'setHeight') {
+ return;
+ }
+
+ ready(() => {
+ window.parent.postMessage({
+ type: 'setHeight',
+ id: data.id,
+ height: document.getElementsByTagName('html')[0].scrollHeight,
+ }, '*');
+ });
+});
function main() {
const { length } = require('stringz');
@@ -6,13 +23,13 @@ function main() {
const { delegate } = require('rails-ujs');
const emojify = require('../mastodon/emoji').default;
const { getLocale } = require('../mastodon/locales');
- const ready = require('../mastodon/ready').default;
-
const { localeData } = getLocale();
+
localeData.forEach(IntlRelativeFormat.__addLocaleData);
ready(() => {
const locale = document.documentElement.lang;
+
const dateTimeFormat = new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
@@ -20,6 +37,7 @@ function main() {
hour: 'numeric',
minute: 'numeric',
});
+
const relativeFormat = new IntlRelativeFormat(locale);
[].forEach.call(document.querySelectorAll('.emojify'), (content) => {
@@ -29,14 +47,24 @@ function main() {
[].forEach.call(document.querySelectorAll('time.formatted'), (content) => {
const datetime = new Date(content.getAttribute('datetime'));
const formattedDate = dateTimeFormat.format(datetime);
+
content.title = formattedDate;
content.textContent = formattedDate;
});
[].forEach.call(document.querySelectorAll('time.time-ago'), (content) => {
const datetime = new Date(content.getAttribute('datetime'));
+
+ content.title = dateTimeFormat.format(datetime);
content.textContent = relativeFormat.format(datetime);
});
+
+ [].forEach.call(document.querySelectorAll('.logo-button'), (content) => {
+ content.addEventListener('click', (e) => {
+ e.preventDefault();
+ window.open(e.target.href, 'mastodon-intent', 'width=400,height=400,resizable=no,menubar=no,status=no,scrollbars=yes');
+ });
+ });
});
delegate(document, '.video-player video', 'click', ({ target }) => {
@@ -65,6 +93,7 @@ function main() {
delegate(document, '.status__content__spoiler-link', 'click', ({ target }) => {
const contentEl = target.parentNode.parentNode.querySelector('.e-content');
+
if (contentEl.style.display === 'block') {
contentEl.style.display = 'none';
target.parentNode.style.marginBottom = 0;
@@ -72,11 +101,13 @@ function main() {
contentEl.style.display = 'block';
target.parentNode.style.marginBottom = null;
}
+
return false;
});
delegate(document, '.account_display_name', 'input', ({ target }) => {
const nameCounter = document.querySelector('.name-counter');
+
if (nameCounter) {
nameCounter.textContent = 30 - length(target.value);
}
@@ -84,6 +115,7 @@ function main() {
delegate(document, '.account_note', 'input', ({ target }) => {
const noteCounter = document.querySelector('.note-counter');
+
if (noteCounter) {
noteCounter.textContent = 160 - length(target.value);
}
@@ -93,6 +125,7 @@ function main() {
const avatar = document.querySelector('.card.compact .avatar img');
const [file] = target.files || [];
const url = URL.createObjectURL(file);
+
avatar.src = url;
});
@@ -100,6 +133,7 @@ function main() {
const header = document.querySelector('.card.compact');
const [file] = target.files || [];
const url = URL.createObjectURL(file);
+
header.style.backgroundImage = `url(${url})`;
});
}
diff --git a/app/javascript/packs/share.js b/app/javascript/packs/share.js
new file mode 100644
index 000000000..51e4ae38b
--- /dev/null
+++ b/app/javascript/packs/share.js
@@ -0,0 +1,24 @@
+import loadPolyfills from '../mastodon/load_polyfills';
+
+require.context('../images/', true);
+
+function loaded() {
+ const ComposeContainer = require('../mastodon/containers/compose_container').default;
+ const React = require('react');
+ const ReactDOM = require('react-dom');
+ const mountNode = document.getElementById('mastodon-compose');
+
+ if (mountNode !== null) {
+ const props = JSON.parse(mountNode.getAttribute('data-props'));
+ ReactDOM.render(, mountNode);
+ }
+}
+
+function main() {
+ const ready = require('../mastodon/ready').default;
+ ready(loaded);
+}
+
+loadPolyfills().then(main).catch(error => {
+ console.error(error);
+});
diff --git a/app/javascript/styles/about.scss b/app/javascript/styles/about.scss
index d409c8214..28924738a 100644
--- a/app/javascript/styles/about.scss
+++ b/app/javascript/styles/about.scss
@@ -1,52 +1,96 @@
-.about-body {
- .wrapper {
- max-width: 600px;
- margin: 0 auto;
+.landing-page {
+ p,
+ li {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ margin-bottom: 12px;
color: $ui-primary-color;
- padding-top: 50px;
- padding-bottom: 50px;
- &.thicc {
- max-width: 800px;
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
}
}
- h1 {
- font: 46px/52px 'mastodon-font-sans-serif', sans-serif;
- font-weight: 600;
- margin-bottom: 20px;
- color: $ui-highlight-color;
- padding: 20px 0;
+ em {
+ display: inline;
+ margin: 0;
+ padding: 0;
+ font-weight: 500;
+ background: transparent;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ color: lighten($ui-primary-color, 10%);
+ }
- img {
- margin-bottom: -5px;
- margin-right: 5px;
- width: 46px;
- height: 46px;
+ h1 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 26px;
+ line-height: 30px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+
+ small {
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ display: block;
+ font-size: 18px;
+ font-weight: 400;
+ color: $ui-base-lighter-color;
}
}
h2 {
font-family: 'mastodon-font-display', sans-serif;
- font-size: 24px;
- line-height: 28px;
- font-weight: 400;
+ font-size: 22px;
+ line-height: 26px;
+ font-weight: 500;
margin-bottom: 20px;
- color: $primary-text-color;
+ color: $ui-secondary-color;
}
h3 {
font-family: 'mastodon-font-display', sans-serif;
- font-size: 20px;
- line-height: 28px;
- font-weight: 400;
+ font-size: 18px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h4 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 16px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h5 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 500;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ }
+
+ h6 {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 12px;
+ line-height: 24px;
+ font-weight: 500;
margin-bottom: 20px;
color: $ui-secondary-color;
}
ul,
ol {
- list-style: inherit;
margin-left: 20px;
&[type='a'] {
@@ -58,220 +102,30 @@
}
}
+ ul {
+ list-style: disc;
+ }
+
+ ol {
+ list-style: decimal;
+ }
+
li > ol,
li > ul {
- margin-top: 20px;
+ margin-top: 6px;
}
- p,
- li {
- font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
- font-weight: 400;
- margin-bottom: 12px;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
- }
- }
-
- em {
- display: inline-block;
- padding: 7px 7px 5px;
- margin: 0 2px;
- background: $ui-primary-color;
- color: $ui-base-color;
- font: 16px/16px 'mastodon-font-sans-serif', sans-serif;
- font-weight: 300;
- }
-
- .screenshot {
- box-shadow: 0 0 15px rgba($base-shadow-color, 0.4);
- margin-bottom: 26px;
-
- img {
- max-width: 100%;
- height: auto;
- display: block;
- }
- }
-
- .actions {
- overflow: hidden;
- margin-bottom: 20px;
-
- .info {
- float: right;
- text-align: right;
- line-height: 36px;
-
- a {
- color: $ui-primary-color;
- text-decoration: underline;
- }
- }
- }
-
- @media screen and (max-width: 625px) {
- .wrapper {
- padding: 20px;
- }
- }
-}
-
-.information-board {
- background: darken($ui-base-color, 4%);
- padding: 20px 0;
-
- .panel {
- position: absolute;
- width: 280px;
- box-sizing: border-box;
- background: darken($ui-base-color, 8%);
- padding: 20px;
- padding-top: 10px;
- border-radius: 4px 4px 0 0;
- right: 0;
- bottom: -40px;
-
- .panel-header {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 14px;
- line-height: 24px;
- font-weight: 500;
- color: $ui-base-lighter-color;
- padding-bottom: 5px;
- margin-bottom: 15px;
- border-bottom: 1px solid lighten($ui-base-color, 4%);
- text-overflow: ellipsis;
- white-space: nowrap;
- overflow: hidden;
-
- a,
- span {
- font-weight: 400;
- color: lighten($ui-base-color, 34%);
- }
-
- a {
- text-decoration: none;
- }
- }
+ hr {
+ border-color: rgba($ui-base-lighter-color, .6);
}
.container {
- position: relative;
- padding-right: 280px + 15px;
- }
-
- .information-board-sections {
- display: flex;
- justify-content: space-between;
- flex-wrap: wrap;
- }
-
- .section {
- flex: 1 0 0;
- font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
- text-align: right;
- padding: 10px 15px;
-
- span,
- strong {
- display: block;
- }
-
- span {
- font-size: 16px;
-
- &:last-child {
- color: $ui-secondary-color;
- }
- }
-
- strong {
- font-weight: 500;
- font-size: 32px;
- line-height: 48px;
- color: $primary-text-color;
- }
- }
-}
-
-.owner {
- text-align: center;
-
- .avatar {
- width: 80px;
- height: 80px;
+ width: 100%;
+ box-sizing: border-box;
+ max-width: 800px;
margin: 0 auto;
- margin-bottom: 15px;
-
- img {
- display: block;
- width: 80px;
- height: 80px;
- border-radius: 48px;
- }
}
- .name {
- font-size: 14px;
-
- a {
- display: block;
- color: $primary-text-color;
- text-decoration: none;
-
- &:hover {
- .display_name {
- text-decoration: underline;
- }
- }
- }
-
- .username {
- display: block;
- color: $ui-primary-color;
- }
- }
-}
-
-.features-list__row {
- display: flex;
- padding: 10px 0;
- justify-content: space-between;
-
- &:first-child {
- padding-top: 0;
- }
-
- .visual {
- flex: 0 0 auto;
- display: flex;
- align-items: center;
- margin-left: 15px;
-
- .fa {
- display: block;
- color: $ui-primary-color;
- font-size: 48px;
- }
- }
-
- .text {
- font-size: 16px;
- line-height: 30px;
- color: $ui-base-lighter-color;
-
- h6 {
- font-weight: 500;
- color: $ui-primary-color;
- }
- }
-}
-
-.landing-page {
.header-wrapper {
padding-top: 15px;
background: $ui-base-color;
@@ -284,6 +138,17 @@
.hero .heading {
padding-bottom: 30px;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
}
}
@@ -307,17 +172,6 @@
}
}
- p,
- li {
- font: inherit;
- font-weight: inherit;
- margin-bottom: 0;
- }
-
- hr {
- border-color: rgba($ui-base-lighter-color, .6);
- }
-
.header {
line-height: 30px;
overflow: hidden;
@@ -327,6 +181,62 @@
justify-content: space-between;
}
+ .links {
+ position: relative;
+ z-index: 4;
+
+ a {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ color: $ui-primary-color;
+ text-decoration: none;
+ padding: 12px 16px;
+ line-height: 32px;
+ font-family: 'mastodon-font-display', sans-serif;
+ font-weight: 500;
+ font-size: 14px;
+
+ &:hover {
+ color: $ui-secondary-color;
+ }
+ }
+
+ .brand {
+ a {
+ padding-left: 0;
+ padding-right: 0;
+ color: $white;
+ }
+
+ img {
+ height: 32px;
+ position: relative;
+ top: 4px;
+ left: -10px;
+ }
+ }
+
+ ul {
+ list-style: none;
+ margin: 0;
+
+ li {
+ display: inline-block;
+ vertical-align: bottom;
+ margin: 0;
+
+ &:first-child a {
+ padding-left: 0;
+ }
+
+ &:last-child a {
+ padding-right: 0;
+ }
+ }
+ }
+ }
+
.hero {
margin-top: 50px;
align-items: center;
@@ -379,6 +289,12 @@
}
}
+ .heading {
+ position: relative;
+ z-index: 4;
+ padding-bottom: 150px;
+ }
+
.simple_form,
.closed-registrations-message {
background: darken($ui-base-color, 4%);
@@ -400,12 +316,6 @@
}
}
- .heading {
- position: relative;
- z-index: 4;
- padding-bottom: 150px;
- }
-
.closed-registrations-message {
min-height: 330px;
display: flex;
@@ -413,233 +323,263 @@
justify-content: space-between;
}
}
+ }
- ul {
- list-style: none;
- margin: 0;
+ .about-short {
+ background: darken($ui-base-color, 4%);
+ padding: 50px 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
- li {
- display: inline-block;
- vertical-align: bottom;
- margin: 0;
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+ }
+ }
- &:first-child a {
- padding-left: 0;
- }
+ .information-board {
+ background: darken($ui-base-color, 4%);
+ padding: 20px 0;
- &:last-child a {
- padding-right: 0;
- }
- }
+ .container {
+ position: relative;
+ padding-right: 280px + 15px;
}
- .links {
- position: relative;
- z-index: 4;
+ .information-board-sections {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ }
- a {
- display: flex;
- justify-content: center;
- align-items: center;
- color: $ui-primary-color;
- text-decoration: none;
- padding: 12px 16px;
- line-height: 32px;
- font-family: 'mastodon-font-display', sans-serif;
- font-weight: 500;
- font-size: 14px;
+ .section {
+ flex: 1 0 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ line-height: 28px;
+ color: $primary-text-color;
+ text-align: right;
+ padding: 10px 15px;
- &:hover {
+ span,
+ strong {
+ display: block;
+ }
+
+ span {
+ &:last-child {
color: $ui-secondary-color;
}
}
- .brand {
- a {
- padding-left: 0;
- padding-right: 0;
- color: $white;
+ strong {
+ font-weight: 500;
+ font-size: 32px;
+ line-height: 48px;
+ }
+ }
+
+ .panel {
+ position: absolute;
+ width: 280px;
+ box-sizing: border-box;
+ background: darken($ui-base-color, 8%);
+ padding: 20px;
+ padding-top: 10px;
+ border-radius: 4px 4px 0 0;
+ right: 0;
+ bottom: -40px;
+
+ .panel-header {
+ font-family: 'mastodon-font-display', sans-serif;
+ font-size: 14px;
+ line-height: 24px;
+ font-weight: 500;
+ color: $ui-primary-color;
+ padding-bottom: 5px;
+ margin-bottom: 15px;
+ border-bottom: 1px solid lighten($ui-base-color, 4%);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ overflow: hidden;
+
+ a,
+ span {
+ font-weight: 400;
+ color: darken($ui-primary-color, 10%);
}
+ a {
+ text-decoration: none;
+ }
+ }
+ }
+
+ .owner {
+ text-align: center;
+
+ .avatar {
+ width: 80px;
+ height: 80px;
+ margin: 0 auto;
+ margin-bottom: 15px;
+
img {
- height: 32px;
- position: relative;
- top: 4px;
- left: -10px;
+ display: block;
+ width: 80px;
+ height: 80px;
+ border-radius: 48px;
+ }
+ }
+
+ .name {
+ font-size: 14px;
+
+ a {
+ display: block;
+ color: $primary-text-color;
+ text-decoration: none;
+
+ &:hover {
+ .display_name {
+ text-decoration: underline;
+ }
+ }
+ }
+
+ .username {
+ display: block;
+ color: $ui-primary-color;
}
}
}
}
- .container {
- width: 100%;
- box-sizing: border-box;
- max-width: 800px;
- margin: 0 auto;
- }
-
- .wrapper {
- max-width: 800px;
- margin: 0 auto;
- padding: 0;
- }
-
- .learn-more-cta {
- background: darken($ui-base-color, 4%);
- padding: 50px 0;
- }
-
- .extended-description {
- padding: 50px 0;
-
- ul,
- ol {
- list-style: inherit;
- margin-left: 20px;
-
- &[type='a'] {
- list-style-type: lower-alpha;
- }
-
- &[type='i'] {
- list-style-type: lower-roman;
- }
- }
-
- li > ol,
- li > ul {
- margin-top: 20px;
- }
-
- p,
- li {
- font: 16px/28px 'mastodon-font-sans-serif', sans-serif;
- font-weight: 400;
- margin-bottom: 12px;
- color: $ui-base-lighter-color;
-
- a {
- color: $ui-highlight-color;
- text-decoration: underline;
- }
- }
- }
-
- h3 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 16px;
- line-height: 24px;
- font-weight: 500;
- margin-bottom: 20px;
- color: $ui-primary-color;
- }
-
- p {
- font-size: 16px;
- line-height: 30px;
- color: $ui-base-lighter-color;
- }
-
.features {
padding: 50px 0;
.container {
display: flex;
}
- }
- #mastodon-timeline {
- display: flex;
- -webkit-overflow-scrolling: touch;
- -ms-overflow-style: -ms-autohiding-scrollbar;
- font-family: 'mastodon-font-sans-serif', sans-serif;
- font-size: 13px;
- line-height: 18px;
- font-weight: 400;
- color: $primary-text-color;
- width: 330px;
- margin-right: 30px;
- flex: 0 0 auto;
- background: $ui-base-color;
- overflow: hidden;
- box-shadow: 0 0 6px rgba($black, 0.1);
-
- .column-header {
- color: inherit;
- font-family: inherit;
- font-size: 16px;
- line-height: inherit;
- font-weight: inherit;
- margin: 0;
- padding: 15px;
- }
-
- .column {
- padding: 0;
- border-radius: 4px;
- overflow: hidden;
- }
-
- .scrollable {
- height: 400px;
- }
-
- p {
- font-size: inherit;
- line-height: inherit;
- font-weight: inherit;
- color: $primary-text-color;
- margin-bottom: 20px;
-
- &:last-child {
- margin-bottom: 0;
- }
-
- a {
- color: $ui-secondary-color;
- text-decoration: none;
- }
- }
- }
-
- .about-mastodon {
- max-width: 675px;
-
- p {
- margin-bottom: 20px;
- }
-
- .features-list {
- margin-top: 20px;
- }
- }
-
- em {
- display: inline;
- margin: 0;
- padding: 0;
- font-weight: 500;
- background: transparent;
- font-family: inherit;
- font-size: inherit;
- line-height: inherit;
- color: $ui-primary-color;
- }
-
- h1 {
- font-family: 'mastodon-font-display', sans-serif;
- font-size: 26px;
- line-height: 30px;
- margin-bottom: 0;
- font-weight: 500;
- color: $ui-secondary-color;
-
- small {
+ #mastodon-timeline {
+ display: flex;
+ -webkit-overflow-scrolling: touch;
+ -ms-overflow-style: -ms-autohiding-scrollbar;
font-family: 'mastodon-font-sans-serif', sans-serif;
- display: block;
- font-size: 18px;
+ font-size: 13px;
+ line-height: 18px;
font-weight: 400;
- color: $ui-base-lighter-color;
+ color: $primary-text-color;
+ width: 330px;
+ margin-right: 30px;
+ flex: 0 0 auto;
+ background: $ui-base-color;
+ overflow: hidden;
+ box-shadow: 0 0 6px rgba($black, 0.1);
+
+ .column-header {
+ color: inherit;
+ font-family: inherit;
+ font-size: 16px;
+ line-height: inherit;
+ font-weight: inherit;
+ margin: 0;
+ padding: 15px;
+ }
+
+ .column {
+ padding: 0;
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .scrollable {
+ height: 400px;
+ }
+
+ p {
+ font-size: inherit;
+ line-height: inherit;
+ font-weight: inherit;
+ color: $primary-text-color;
+ margin-bottom: 20px;
+
+ &:last-child {
+ margin-bottom: 0;
+ }
+
+ a {
+ color: $ui-secondary-color;
+ text-decoration: none;
+ }
+ }
+ }
+
+ .about-mastodon {
+ max-width: 675px;
+
+ p {
+ margin-bottom: 20px;
+ }
+
+ .features-list {
+ margin-top: 20px;
+
+ .features-list__row {
+ display: flex;
+ padding: 10px 0;
+ justify-content: space-between;
+
+ &:first-child {
+ padding-top: 0;
+ }
+
+ .visual {
+ flex: 0 0 auto;
+ display: flex;
+ align-items: center;
+ margin-left: 15px;
+
+ .fa {
+ display: block;
+ color: $ui-primary-color;
+ font-size: 48px;
+ }
+ }
+
+ .text {
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ h6 {
+ font-size: inherit;
+ line-height: inherit;
+ margin-bottom: 0;
+ }
+ }
+ }
+ }
+ }
+ }
+
+ .extended-description {
+ padding: 50px 0;
+ font-family: 'mastodon-font-sans-serif', sans-serif;
+ font-size: 16px;
+ font-weight: 400;
+ font-size: 16px;
+ line-height: 30px;
+ color: $ui-primary-color;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
}
}
@@ -663,8 +603,15 @@
padding: 0 20px;
}
- .information-board .container {
- padding-right: 20px;
+ .information-board {
+
+ .container {
+ padding-right: 20px;
+ }
+
+ .section {
+ text-align: center;
+ }
.panel {
position: static;
@@ -678,10 +625,6 @@
}
}
- .information-board .section {
- text-align: center;
- }
-
.header-wrapper .mascot {
left: 20px;
}
@@ -699,6 +642,7 @@
&.compact .hero .heading {
padding-bottom: 20px;
+ text-align: initial;
}
}
@@ -707,51 +651,41 @@
display: block;
}
- .links {
- padding-top: 15px;
- background: darken($ui-base-color, 4%);
- }
-
.header {
+ .links {
+ padding-top: 15px;
+ background: darken($ui-base-color, 4%);
+
+ a {
+ padding: 12px 8px;
+ }
+
+ .nav {
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: space-around;
+ }
+
+ .brand img {
+ left: 0;
+ top: 0;
+ }
+ }
+
.hero {
margin-top: 30px;
padding: 0;
- .heading {
- padding: 0 20px 20px;
+ .floats {
+ display: none;
}
- }
- .floats {
- display: none;
- }
+ .heading {
+ padding: 30px 20px;
+ text-align: center;
+ }
- .heading,
- .nav {
- text-align: center;
- }
-
- .nav {
- display: flex;
- flex-flow: row wrap;
- justify-content: space-around;
- }
-
- .links a {
- padding: 12px 8px;
- }
-
- .heading h1 {
- padding: 30px 0;
- }
-
- .links .brand img {
- left: 0;
- top: 0;
- }
-
- .hero {
.simple_form,
.closed-registrations-message {
background: darken($ui-base-color, 8%);
@@ -762,7 +696,7 @@
}
}
- #mastodon-timeline {
+ .features #mastodon-timeline {
height: 70vh;
width: 100%;
margin-bottom: 50px;
diff --git a/app/javascript/styles/accounts.scss b/app/javascript/styles/accounts.scss
index 99eb5ebea..744650554 100644
--- a/app/javascript/styles/accounts.scss
+++ b/app/javascript/styles/accounts.scss
@@ -1,21 +1,15 @@
.card {
- background: $ui-base-color;
+ background-color: lighten($ui-base-color, 4%);
background-size: cover;
background-position: center;
- padding: 60px 0;
- padding-bottom: 0;
border-radius: 4px 4px 0 0;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
overflow: hidden;
position: relative;
-
- @media screen and (max-width: 700px) {
- border-radius: 0;
- box-shadow: none;
- }
+ display: flex;
&::after {
- background: linear-gradient(rgba($base-shadow-color, 0.5), rgba($base-shadow-color, 0.8));
+ background: rgba(darken($ui-base-color, 8%), 0.5);
display: block;
content: "";
position: absolute;
@@ -26,6 +20,31 @@
z-index: 1;
}
+ @media screen and (max-width: 740px) {
+ border-radius: 0;
+ box-shadow: none;
+ }
+
+ .card__illustration {
+ padding: 60px 0;
+ position: relative;
+ flex: 1 1 auto;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ }
+
+ .card__bio {
+ max-width: 260px;
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ background: rgba(darken($ui-base-color, 8%), 0.8);
+ position: relative;
+ z-index: 2;
+ }
+
&.compact {
padding: 30px 0;
border-radius: 4px;
@@ -44,11 +63,12 @@
font-size: 20px;
line-height: 18px * 1.5;
color: $primary-text-color;
+ padding: 10px 15px;
+ padding-bottom: 0;
font-weight: 500;
- text-align: center;
position: relative;
z-index: 2;
- text-shadow: 0 0 2px $base-shadow-color;
+ margin-bottom: 30px;
small {
display: block;
@@ -61,7 +81,6 @@
.avatar {
width: 120px;
margin: 0 auto;
- margin-bottom: 15px;
position: relative;
z-index: 2;
@@ -70,43 +89,68 @@
height: 120px;
display: block;
border-radius: 120px;
+ box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
}
}
.controls {
position: absolute;
- top: 10px;
- right: 10px;
+ top: 15px;
+ left: 15px;
z-index: 2;
+
+ .icon-button {
+ color: rgba($white, 0.8);
+ text-decoration: none;
+ font-size: 13px;
+ line-height: 13px;
+ font-weight: 500;
+
+ .fa {
+ font-weight: 400;
+ margin-right: 5px;
+ }
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: $white;
+ }
+ }
}
- .details {
- display: flex;
- margin-top: 30px;
- position: relative;
- z-index: 2;
- flex-direction: row;
+ .roles {
+ margin-bottom: 30px;
+ padding: 0 15px;
}
.details-counters {
+ margin-top: 30px;
display: flex;
flex-direction: row;
- order: 0;
+ width: 100%;
}
.counter {
- width: 80px;
+ width: 33.3%;
+ box-sizing: border-box;
+ flex: 0 0 auto;
color: $ui-primary-color;
padding: 5px 10px 0;
margin-bottom: 10px;
- border-right: 1px solid $ui-primary-color;
+ border-right: 1px solid lighten($ui-base-color, 4%);
cursor: default;
+ text-align: center;
position: relative;
a {
display: block;
}
+ &:last-child {
+ border-right: 0;
+ }
+
&::after {
display: block;
content: "";
@@ -116,7 +160,7 @@
width: 100%;
border-bottom: 4px solid $ui-primary-color;
opacity: 0.5;
- transition: all 0.8s ease;
+ transition: all 400ms ease;
}
&.active {
@@ -129,7 +173,7 @@
&:hover {
&::after {
opacity: 1;
- transition-duration: 0.2s;
+ transition-duration: 100ms;
}
}
@@ -140,44 +184,40 @@
.counter-label {
font-size: 12px;
- text-transform: uppercase;
display: block;
margin-bottom: 5px;
- text-shadow: 0 0 2px $base-shadow-color;
}
.counter-number {
font-weight: 500;
font-size: 18px;
color: $primary-text-color;
+ font-family: 'mastodon-font-display', sans-serif;
}
}
.bio {
- flex: 1;
font-size: 14px;
line-height: 18px;
- padding: 5px 10px;
+ padding: 0 15px;
color: $ui-secondary-color;
- order: 1;
}
@media screen and (max-width: 480px) {
- .details {
- display: block;
+ display: block;
+
+ .card__bio {
+ max-width: none;
+ }
+
+ .name,
+ .roles {
+ text-align: center;
+ margin-bottom: 15px;
}
.bio {
- text-align: center;
- margin-bottom: 20px;
- }
-
- .counter {
- flex: 1 1 auto;
- }
-
- .counter:last-child {
- border-right: none;
+ margin-bottom: 15px;
}
}
}
@@ -256,7 +296,9 @@
}
.next,
- .prev {
+ .prev,
+ .next a,
+ .prev a {
display: inline-block;
}
}
@@ -264,15 +306,17 @@
.accounts-grid {
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
- background: $simple-background-color;
+ background: darken($simple-background-color, 8%);
border-radius: 0 0 4px 4px;
- padding: 20px 10px;
+ padding: 20px 5px;
padding-bottom: 10px;
overflow: hidden;
display: flex;
flex-wrap: wrap;
+ z-index: 2;
+ position: relative;
- @media screen and (max-width: 700px) {
+ @media screen and (max-width: 740px) {
border-radius: 0;
box-shadow: none;
}
@@ -280,37 +324,64 @@
.account-grid-card {
box-sizing: border-box;
width: 335px;
- border: 1px solid $ui-secondary-color;
+ background: $simple-background-color;
border-radius: 4px;
color: $ui-base-color;
- margin-bottom: 10px;
+ margin: 0 5px 10px;
+ position: relative;
- &:nth-child(odd) {
- margin-right: 10px;
+ @media screen and (max-width: 740px) {
+ width: calc(100% - 10px);
}
.account-grid-card__header {
overflow: hidden;
- padding: 10px;
- border-bottom: 1px solid $ui-secondary-color;
+ height: 100px;
+ border-radius: 4px 4px 0 0;
+ background-color: lighten($ui-base-color, 4%);
+ background-size: cover;
+ background-position: center;
+ position: relative;
+
+ &::after {
+ background: rgba(darken($ui-base-color, 8%), 0.5);
+ display: block;
+ content: "";
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ z-index: 1;
+ }
+ }
+
+ .account-grid-card__avatar {
+ box-sizing: border-box;
+ padding: 15px;
+ position: absolute;
+ z-index: 2;
+ top: 100px - (40px + 2px);
+ left: -2px;
}
.avatar {
- width: 60px;
- height: 60px;
- float: left;
- margin-right: 15px;
+ width: 80px;
+ height: 80px;
img {
display: block;
- width: 60px;
- height: 60px;
- border-radius: 60px;
+ width: 80px;
+ height: 80px;
+ border-radius: 80px;
+ border: 2px solid $simple-background-color;
}
}
.name {
+ padding: 15px;
padding-top: 10px;
+ padding-left: 15px + 80px + 15px;
a {
display: block;
@@ -318,6 +389,7 @@
text-decoration: none;
text-overflow: ellipsis;
overflow: hidden;
+ font-weight: 500;
&:hover {
.display_name {
@@ -328,30 +400,38 @@
}
.display_name {
- font-size: 14px;
+ font-size: 16px;
display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
}
.username {
- color: $ui-highlight-color;
+ color: lighten($ui-base-color, 34%);
+ font-size: 14px;
+ font-weight: 400;
}
.note {
- padding: 10px;
+ padding: 10px 15px;
padding-top: 15px;
- color: $ui-primary-color;
+ box-sizing: border-box;
+ color: lighten($ui-base-color, 26%);
word-wrap: break-word;
+ min-height: 80px;
}
}
}
.nothing-here {
+ width: 100%;
+ display: block;
color: $ui-primary-color;
font-size: 14px;
font-weight: 500;
text-align: center;
- padding: 15px 0;
- padding-bottom: 25px;
+ padding: 60px 0;
+ padding-top: 55px;
cursor: default;
}
@@ -416,3 +496,43 @@
color: $ui-base-color;
}
}
+
+.activity-stream-tabs {
+ background: $simple-background-color;
+ border-bottom: 1px solid $ui-secondary-color;
+ position: relative;
+ z-index: 2;
+
+ a {
+ display: inline-block;
+ padding: 15px;
+ text-decoration: none;
+ color: $ui-highlight-color;
+ text-transform: uppercase;
+ font-weight: 500;
+
+ &:hover,
+ &:active,
+ &:focus {
+ color: lighten($ui-highlight-color, 8%);
+ }
+
+ &.active {
+ color: $ui-base-color;
+ cursor: default;
+ }
+ }
+}
+
+.account-role {
+ display: inline-block;
+ padding: 4px 6px;
+ cursor: default;
+ border-radius: 3px;
+ font-size: 12px;
+ line-height: 12px;
+ font-weight: 500;
+ color: $success-green;
+ background-color: rgba($success-green, 0.1);
+ border: 1px solid rgba($success-green, 0.5);
+}
diff --git a/app/javascript/styles/admin.scss b/app/javascript/styles/admin.scss
index 4c3bbdfc5..fa7859e38 100644
--- a/app/javascript/styles/admin.scss
+++ b/app/javascript/styles/admin.scss
@@ -32,7 +32,7 @@
a {
display: block;
- padding: 15px 25px;
+ padding: 15px;
color: rgba($primary-text-color, 0.7);
text-decoration: none;
transition: all 200ms linear;
@@ -61,6 +61,7 @@
a {
border: 0;
+ padding: 15px 35px;
&.selected {
color: $primary-text-color;
@@ -98,7 +99,7 @@
h6 {
font-size: 16px;
- color: $ui-primary-color;
+ color: $ui-secondary-color;
line-height: 28px;
font-weight: 400;
}
@@ -123,10 +124,10 @@
}
.muted-hint {
- color: lighten($ui-base-color, 27%);
+ color: $ui-primary-color;
a {
- color: $ui-primary-color;
+ color: $ui-highlight-color;
}
}
@@ -139,15 +140,23 @@
.simple_form {
max-width: 400px;
- .label_input {
- label.select {
- width: 50%;
- }
+ &.edit_user,
+ &.new_form_admin_settings,
+ &.new_form_two_factor_confirmation,
+ &.new_form_delete_confirmation,
+ &.new_import,
+ &.new_domain_block,
+ &.edit_domain_block {
+ max-width: none;
+ }
- select {
- width: 50%;
- float: right;
- }
+ .form_two_factor_confirmation_code,
+ .form_delete_confirmation_password {
+ max-width: 400px;
+ }
+
+ .actions {
+ max-width: 400px;
}
}
@@ -181,11 +190,15 @@
.filters {
display: flex;
- margin-bottom: 20px;
+ flex-wrap: wrap;
.filter-subset {
flex: 0 0 auto;
- margin-right: 40px;
+ margin: 0 40px 10px 0;
+
+ &:last-child {
+ margin-bottom: 20px;
+ }
ul {
margin-top: 5px;
@@ -227,27 +240,25 @@
.report-accounts {
display: flex;
+ flex-wrap: wrap;
margin-bottom: 20px;
}
.report-accounts__item {
- flex: 1 1 0;
display: flex;
+ flex: 250px;
flex-direction: column;
+ margin: 0 5px;
& > strong {
display: block;
- margin-bottom: 10px;
+ margin: 0 0 10px -5px;
font-weight: 500;
font-size: 14px;
line-height: 18px;
color: $ui-secondary-color;
}
- &:first-child {
- margin-right: 10px;
- }
-
.account-card {
flex: 1 1 auto;
}
@@ -261,6 +272,11 @@
.activity-stream {
flex: 2 0 0;
margin-right: 20px;
+ max-width: calc(100% - 60px);
+
+ .entry {
+ border-radius: 4px;
+ }
}
}
@@ -280,18 +296,25 @@
.batch-form-box {
display: flex;
- margin-bottom: 10px;
+ flex-wrap: wrap;
+ margin-bottom: 5px;
#form_status_batch_action {
- margin-right: 5px;
+ margin: 0 5px 5px 0;
font-size: 14px;
}
+ input.button {
+ margin: 0 5px 5px 0;
+ }
+
.media-spoiler-toggle-buttons {
margin-left: auto;
.button {
overflow: visible;
+ margin: 0 0 5px 5px;
+ float: right;
}
}
}
diff --git a/app/javascript/styles/basics.scss b/app/javascript/styles/basics.scss
index 182ea36a4..c5f98750c 100644
--- a/app/javascript/styles/basics.scss
+++ b/app/javascript/styles/basics.scss
@@ -7,13 +7,28 @@ body {
line-height: 18px;
font-weight: 400;
color: $primary-text-color;
- padding-bottom: 140px;
+ padding-bottom: 20px;
text-rendering: optimizelegibility;
font-feature-settings: "kern";
text-size-adjust: none;
-webkit-tap-highlight-color: rgba(0,0,0,0);
-webkit-tap-highlight-color: transparent;
+ &.system-font {
+ // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
+ // -apple-system => Safari <11 specific
+ // BlinkMacSystemFont => Chrome <56 on macOS specific
+ // Segoe UI => Windows 7/8/10
+ // Oxygen => KDE
+ // Ubuntu => Unity/Ubuntu
+ // Cantarell => GNOME
+ // Fira Sans => Firefox OS
+ // Droid Sans => Older Androids (<4.0)
+ // Helvetica Neue => Older macOS <10.11
+ // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", mastodon-font-sans-serif, sans-serif;
+ }
+
&.app-body {
position: fixed;
width: 100%;
@@ -30,6 +45,7 @@ body {
&.embed {
background: transparent;
margin: 0;
+ padding-bottom: 0;
.container {
position: absolute;
@@ -46,10 +62,6 @@ body {
height: 100%;
padding: 0;
}
-
- @media screen and (max-width: 360px) {
- padding-bottom: 0;
- }
}
button {
@@ -68,18 +80,3 @@ button {
align-items: center;
justify-content: center;
}
-
-.system-font {
- // system-ui => standard property (Chrome/Android WebView 56+, Opera 43+, Safari 11+)
- // -apple-system => Safari <11 specific
- // BlinkMacSystemFont => Chrome <56 on macOS specific
- // Segoe UI => Windows 7/8/10
- // Oxygen => KDE
- // Ubuntu => Unity/Ubuntu
- // Cantarell => GNOME
- // Fira Sans => Firefox OS
- // Droid Sans => Older Androids (<4.0)
- // Helvetica Neue => Older macOS <10.11
- // mastodon-font-sans-serif => web-font (Roboto) fallback and newer Androids (>=4.0)
- font-family: system-ui, -apple-system,BlinkMacSystemFont, "Segoe UI","Oxygen", "Ubuntu","Cantarell","Fira Sans","Droid Sans","Helvetica Neue",mastodon-font-sans-serif, sans-serif;
-}
diff --git a/app/javascript/styles/compact_header.scss b/app/javascript/styles/compact_header.scss
index 27a67135f..cf12fcfec 100644
--- a/app/javascript/styles/compact_header.scss
+++ b/app/javascript/styles/compact_header.scss
@@ -3,9 +3,15 @@
font-size: 24px;
line-height: 28px;
color: $ui-primary-color;
- overflow: hidden;
font-weight: 500;
margin-bottom: 20px;
+ padding: 0 10px;
+ overflow-wrap: break-word;
+
+ @media screen and (max-width: 740px) {
+ text-align: center;
+ padding: 20px 10px 0;
+ }
a {
color: inherit;
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss
index 34e4b2e72..75485d6b6 100644
--- a/app/javascript/styles/components.scss
+++ b/app/javascript/styles/components.scss
@@ -237,6 +237,8 @@
line-height: 0;
display: inline-block;
width: 0;
+ height: 0;
+ position: absolute;
}
.ellipsis {
@@ -394,6 +396,11 @@
bottom: -1px;
right: 8px;
}
+
+ ::-webkit-scrollbar-track:hover,
+ ::-webkit-scrollbar-track:active {
+ background-color: rgba($base-overlay-background, 0.3);
+ }
}
}
@@ -1050,6 +1057,8 @@
strong,
span {
display: block;
+ text-overflow: ellipsis;
+ overflow: hidden;
}
strong {
@@ -1572,7 +1581,6 @@
overflow-y: scroll;
overflow-x: hidden;
flex: 1 1 auto;
- backface-visibility: hidden;
-webkit-overflow-scrolling: touch;
@supports(display: grid) { // hack to fix Chrome <57
contain: strict;
@@ -1590,8 +1598,9 @@
flex: 0 0 auto;
font-size: 16px;
border: 0;
- text-align: start;
+ text-align: unset;
padding: 15px;
+ margin: 0;
z-index: 3;
&:hover {
@@ -1613,6 +1622,10 @@
&:hover {
text-decoration: underline;
}
+
+ &:last-child {
+ padding: 0 15px 0 0;
+ }
}
.column-back-button__icon {
@@ -1865,7 +1878,7 @@
.character-counter__wrapper {
line-height: 36px;
- margin-right: 16px;
+ margin: 0 16px 0 8px;
padding-top: 10px;
}
@@ -2048,6 +2061,18 @@ button.icon-button.active i.fa-retweet {
background: lighten($ui-base-color, 8%);
}
+.status-card.horizontal {
+ display: block;
+
+ .status-card__image {
+ width: 100%;
+ }
+
+ .status-card__image-image {
+ border-radius: 4px 4px 0 0;
+ }
+}
+
.status-card__image-image {
border-radius: 4px 0 0 4px;
display: block;
@@ -2315,19 +2340,11 @@ button.icon-button.active i.fa-retweet {
}
.media-spoiler {
- align-items: center;
background: $base-overlay-background;
color: $primary-text-color;
- cursor: pointer;
- display: flex;
- flex-direction: column;
border: 0;
width: 100%;
height: 100%;
- justify-content: center;
- position: relative;
- text-align: center;
- z-index: 100;
}
.media-spoiler__warning {
@@ -3090,7 +3107,8 @@ button.icon-button.active i.fa-retweet {
}
.onboarding-modal,
-.error-modal {
+.error-modal,
+.embed-modal {
background: $ui-secondary-color;
color: $ui-base-color;
border-radius: 8px;
@@ -3787,6 +3805,8 @@ button.icon-button.active i.fa-retweet {
cursor: pointer;
margin-top: 8px;
position: relative;
+ border: 0;
+ display: block;
}
.media-spoiler-video-play-icon {
@@ -3871,6 +3891,15 @@ noscript {
margin: 30px auto;
color: $ui-secondary-color;
max-width: 400px;
+
+ a {
+ color: $ui-highlight-color;
+ text-decoration: underline;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
}
}
@@ -3942,3 +3971,64 @@ noscript {
}
}
}
+
+.embed-modal {
+ max-width: 80vw;
+ max-height: 80vh;
+
+ h4 {
+ padding: 30px;
+ font-weight: 500;
+ font-size: 16px;
+ text-align: center;
+ }
+
+ .embed-modal__container {
+ padding: 10px;
+
+ .hint {
+ margin-bottom: 15px;
+ }
+
+ .embed-modal__html {
+ color: $ui-secondary-color;
+ outline: 0;
+ box-sizing: border-box;
+ display: block;
+ width: 100%;
+ border: none;
+ padding: 10px;
+ font-family: 'mastodon-font-monospace', monospace;
+ background: $ui-base-color;
+ color: $ui-primary-color;
+ font-size: 14px;
+ margin: 0;
+ margin-bottom: 15px;
+
+ &::-moz-focus-inner {
+ border: 0;
+ }
+
+ &::-moz-focus-inner,
+ &:focus,
+ &:active {
+ outline: 0 !important;
+ }
+
+ &:focus {
+ background: lighten($ui-base-color, 4%);
+ }
+
+ @media screen and (max-width: 600px) {
+ font-size: 16px;
+ }
+ }
+
+ .embed-modal__iframe {
+ width: 400px;
+ max-width: 100%;
+ overflow: hidden;
+ border: 0;
+ }
+ }
+}
diff --git a/app/javascript/styles/containers.scss b/app/javascript/styles/containers.scss
index 7dcf2c006..af2589e23 100644
--- a/app/javascript/styles/containers.scss
+++ b/app/javascript/styles/containers.scss
@@ -3,7 +3,7 @@
margin: 0 auto;
margin-top: 40px;
- @media screen and (max-width: 700px) {
+ @media screen and (max-width: 740px) {
width: 100%;
margin: 0;
}
@@ -13,8 +13,9 @@
margin: 100px auto;
margin-bottom: 50px;
- @media screen and (max-width: 360px) {
+ @media screen and (max-width: 400px) {
margin: 30px auto;
+ margin-bottom: 20px;
}
h1 {
@@ -42,3 +43,74 @@
}
}
}
+
+.compose-standalone {
+ .compose-form {
+ width: 400px;
+ margin: 0 auto;
+ padding: 20px 0;
+ margin-top: 40px;
+ box-sizing: border-box;
+
+ @media screen and (max-width: 400px) {
+ width: 100%;
+ margin-top: 0;
+ padding: 20px;
+ }
+ }
+}
+
+.account-header {
+ width: 400px;
+ margin: 0 auto;
+ display: flex;
+ font-size: 13px;
+ line-height: 18px;
+ box-sizing: border-box;
+ padding: 20px 0;
+ padding-bottom: 0;
+ margin-bottom: -30px;
+ margin-top: 40px;
+
+ @media screen and (max-width: 440px) {
+ width: 100%;
+ margin: 0;
+ margin-bottom: 10px;
+ padding: 20px;
+ padding-bottom: 0;
+ }
+
+ .avatar {
+ width: 40px;
+ height: 40px;
+ margin-right: 8px;
+
+ img {
+ width: 100%;
+ height: 100%;
+ display: block;
+ margin: 0;
+ border-radius: 4px;
+ }
+ }
+
+ .name {
+ flex: 1 1 auto;
+ color: $ui-secondary-color;
+ width: calc(100% - 88px);
+
+ .username {
+ display: block;
+ font-weight: 500;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+ }
+
+ .logout-link {
+ display: block;
+ font-size: 32px;
+ line-height: 40px;
+ margin-left: 8px;
+ }
+}
diff --git a/app/javascript/styles/forms.scss b/app/javascript/styles/forms.scss
index cffb6f197..747610237 100644
--- a/app/javascript/styles/forms.scss
+++ b/app/javascript/styles/forms.scss
@@ -24,7 +24,7 @@ code {
p.hint {
margin-bottom: 15px;
- color: lighten($ui-base-color, 32%);
+ color: $ui-primary-color;
&.subtle-hint {
text-align: center;
@@ -32,10 +32,10 @@ code {
line-height: 18px;
margin-top: 15px;
margin-bottom: 0;
- color: $ui-base-lighter-color;
+ color: $ui-primary-color;
a {
- color: $ui-primary-color;
+ color: $ui-highlight-color;
}
}
}
@@ -53,7 +53,6 @@ code {
label {
flex: 0 0 auto;
- width: 100px;
}
input {
@@ -65,12 +64,37 @@ code {
padding: 15px 0;
margin-bottom: 0;
+ .label_input {
+ flex-wrap: wrap;
+ align-items: flex-start;
+ }
+
+ &.select .label_input {
+ align-items: initial;
+ }
+
.label_input > label {
font-family: inherit;
font-size: 16px;
color: $primary-text-color;
display: block;
padding-top: 5px;
+ margin-bottom: 5px;
+ flex: 1;
+ min-width: 150px;
+ word-wrap: break-word;
+
+ &.select {
+ flex: 0;
+ }
+
+ & ~ * {
+ margin-left: 10px;
+ }
+ }
+
+ ul {
+ flex: 390px;
}
&.boolean {
@@ -317,7 +341,7 @@ code {
}
.flash-message {
- background: $ui-base-color;
+ background: lighten($ui-base-color, 8%);
color: $ui-primary-color;
border-radius: 4px;
padding: 15px 10px;
@@ -359,17 +383,23 @@ code {
color: $ui-secondary-color;
font-weight: 500;
}
+
+ @media screen and (max-width: 740px) and (min-width: 441px) {
+ margin-top: 40px;
+ }
}
.qr-wrapper {
display: flex;
+ flex-wrap: wrap;
+ align-items: flex-start;
}
.qr-code {
flex: 0 0 auto;
background: $simple-background-color;
padding: 4px;
- margin-bottom: 20px;
+ margin: 0 10px 20px 0;
box-shadow: 0 0 15px rgba($base-shadow-color, 0.2);
display: inline-block;
@@ -380,8 +410,9 @@ code {
}
.qr-alternative {
- margin-left: 10px;
- color: $ui-primary-color;
+ margin-bottom: 20px;
+ color: $ui-secondary-color;
+ flex: 150px;
samp {
display: block;
@@ -391,7 +422,6 @@ code {
.table-form {
p {
- max-width: 400px;
margin-bottom: 15px;
strong {
@@ -403,7 +433,6 @@ code {
.simple_form,
.table-form {
.warning {
- max-width: 400px;
box-sizing: border-box;
background: rgba($error-value-color, 0.5);
color: $primary-text-color;
diff --git a/app/javascript/styles/landing_strip.scss b/app/javascript/styles/landing_strip.scss
index d2ac5b822..15ff84912 100644
--- a/app/javascript/styles/landing_strip.scss
+++ b/app/javascript/styles/landing_strip.scss
@@ -5,6 +5,8 @@
padding: 14px;
border-radius: 4px;
margin-bottom: 20px;
+ display: flex;
+ align-items: center;
strong,
a {
@@ -15,4 +17,15 @@
color: inherit;
text-decoration: underline;
}
+
+ .logo {
+ width: 30px;
+ height: 30px;
+ flex: 0 0 auto;
+ margin-right: 15px;
+ }
+
+ @media screen and (max-width: 740px) {
+ margin-bottom: 0;
+ }
}
diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss
index 4966fbc21..6c003d69a 100644
--- a/app/javascript/styles/rtl.scss
+++ b/app/javascript/styles/rtl.scss
@@ -8,7 +8,7 @@ body.rtl {
}
.character-counter__wrapper {
- margin-right: 0;
+ margin-right: 8px;
margin-left: 16px;
}
@@ -32,6 +32,11 @@ body.rtl {
right: auto;
}
+ .column-header__back-button {
+ padding-left: 5px;
+ padding-right: 0;
+ }
+
.column-header__setting-arrows {
float: left;
}
@@ -54,25 +59,64 @@ body.rtl {
right: 10px;
}
- .status {
+ .status,
+ .activity-stream .status.light {
padding-left: 10px;
padding-right: 68px;
}
- .status__info .status__display-name {
+ .status__info .status__display-name,
+ .activity-stream .status.light .status__display-name {
padding-left: 25px;
padding-right: 0;
}
+ .activity-stream .pre-header {
+ padding-right: 68px;
+ padding-left: 0;
+ }
+
+ .status__prepend {
+ margin-left: 0;
+ margin-right: 68px;
+ }
+
+ .status__prepend-icon-wrapper {
+ left: auto;
+ right: -26px;
+ }
+
+ .activity-stream .pre-header .pre-header__icon {
+ left: auto;
+ right: 42px;
+ }
+
+ .account__avatar-overlay-overlay {
+ right: auto;
+ left: 0;
+ }
+
.column-back-button--slim-button {
right: auto;
left: 0;
}
- .status__relative-time {
+ .status__relative-time,
+ .activity-stream .status.light .status__header .status__meta {
float: left;
}
+ .activity-stream .detailed-status.light .detailed-status__display-name > div {
+ float: right;
+ margin-right: 0;
+ margin-left: 10px;
+ }
+
+ .activity-stream .detailed-status.light .detailed-status__meta span > span {
+ margin-left: 0;
+ margin-right: 6px;
+ }
+
.status__action-bar-button {
float: right;
margin-right: 0;
@@ -129,6 +173,78 @@ body.rtl {
right: -2.14285714em;
}
+ .admin-wrapper .sidebar ul a i.fa,
+ a.table-action-link i.fa {
+ margin-right: 0;
+ margin-left: 5px;
+ }
+
+ .simple_form .check_boxes .checkbox label,
+ .simple_form .input.with_label.boolean label.checkbox {
+ padding-left: 0;
+ padding-right: 25px;
+ }
+
+ .simple_form .check_boxes .checkbox input[type="checkbox"],
+ .simple_form .input.boolean input[type="checkbox"] {
+ left: auto;
+ right: 0;
+ }
+
+ .simple_form .input-with-append .input input {
+ padding-left: 127px;
+ padding-right: 0;
+ }
+
+ .simple_form .input-with-append .append {
+ right: auto;
+ left: 0;
+ }
+
+ .table th,
+ .table td {
+ text-align: right;
+ }
+
+ .filters .filter-subset {
+ margin-right: 0;
+ margin-left: 45px;
+ }
+
+ .landing-page .header-wrapper .mascot {
+ right: 60px;
+ left: auto;
+ }
+
+ .landing-page .header .hero .floats .float-1 {
+ left: -120px;
+ right: auto;
+ }
+
+ .landing-page .header .hero .floats .float-2 {
+ left: 210px;
+ right: auto;
+ }
+
+ .landing-page .header .hero .floats .float-3 {
+ left: 110px;
+ right: auto;
+ }
+
+ .landing-page .header .links .brand img {
+ left: 0;
+ }
+
+ .landing-page .fa-external-link {
+ padding-right: 5px;
+ padding-left: 0 !important;
+ }
+
+ .landing-page .features #mastodon-timeline {
+ margin-right: 0;
+ margin-left: 30px;
+ }
+
@media screen and (min-width: 1025px) {
.column,
.drawer {
@@ -139,11 +255,6 @@ body.rtl {
padding-left: 5px;
padding-right: 10px;
}
-
- &:last-child {
- padding-right: 0;
- padding-left: 10px;
- }
}
.columns-area > div {
diff --git a/app/javascript/styles/stream_entries.scss b/app/javascript/styles/stream_entries.scss
index 9e062c57e..8ed4c0b25 100644
--- a/app/javascript/styles/stream_entries.scss
+++ b/app/javascript/styles/stream_entries.scss
@@ -8,6 +8,7 @@
.detailed-status.light,
.status.light {
border-bottom: 1px solid $ui-secondary-color;
+ animation: none;
}
&:last-child {
@@ -34,6 +35,14 @@
}
}
}
+
+ @media screen and (max-width: 740px) {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0 !important;
+ }
+ }
}
&.with-header {
@@ -44,6 +53,14 @@
.status.light {
border-radius: 0;
}
+
+ &:last-child {
+ &,
+ .detailed-status.light,
+ .status.light {
+ border-radius: 0 0 4px 4px;
+ }
+ }
}
}
}
@@ -386,19 +403,52 @@
.embed {
.activity-stream {
- border-radius: 4px;
box-shadow: none;
.entry {
- &:last-child {
- border-radius: 0 0 4px 4px;
- }
- &:first-child {
- border-radius: 4px 4px 0 0;
+ .detailed-status.light {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-start;
- &:last-child {
- border-radius: 4px;
+ .detailed-status__display-name {
+ flex: 1;
+ margin: 0 5px 15px 0;
+ }
+
+ .button.button-secondary.logo-button {
+ flex: 0 auto;
+ font-size: 14px;
+
+ svg {
+ width: 20px;
+ height: auto;
+ vertical-align: middle;
+ margin-right: 5px;
+
+ path:first-child {
+ fill: $ui-primary-color;
+ }
+
+ path:last-child {
+ fill: $simple-background-color;
+ }
+ }
+
+ &:active,
+ &:focus,
+ &:hover {
+ svg path:first-child {
+ fill: lighten($ui-primary-color, 4%);
+ }
+ }
+ }
+
+ .status__content,
+ .detailed-status__meta {
+ flex: 100%;
}
}
}
diff --git a/app/javascript/styles/tables.scss b/app/javascript/styles/tables.scss
index 6e54c59c0..ad46f5f9f 100644
--- a/app/javascript/styles/tables.scss
+++ b/app/javascript/styles/tables.scss
@@ -3,7 +3,6 @@
max-width: 100%;
border-spacing: 0;
border-collapse: collapse;
- margin-bottom: 20px;
th,
td {
@@ -43,19 +42,17 @@
font-weight: 500;
}
- &.inline-table {
- td,
- th {
- padding: 8px 0;
- }
-
- & > tbody > tr:nth-child(odd) > td,
- & > tbody > tr:nth-child(odd) > th {
- background: transparent;
- }
+ &.inline-table > tbody > tr:nth-child(odd) > td,
+ &.inline-table > tbody > tr:nth-child(odd) > th {
+ background: transparent;
}
}
+.table-wrapper {
+ overflow: auto;
+ margin-bottom: 20px;
+}
+
samp {
font-family: 'mastodon-font-monospace', monospace;
}
diff --git a/app/lib/activitypub/activity.rb b/app/lib/activitypub/activity.rb
new file mode 100644
index 000000000..b06dd6194
--- /dev/null
+++ b/app/lib/activitypub/activity.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity
+ include JsonLdHelper
+
+ def initialize(json, account)
+ @json = json
+ @account = account
+ @object = @json['object']
+ end
+
+ def perform
+ raise NotImplementedError
+ end
+
+ class << self
+ def factory(json, account)
+ @json = json
+ klass&.new(json, account)
+ end
+
+ private
+
+ def klass
+ case @json['type']
+ when 'Create'
+ ActivityPub::Activity::Create
+ when 'Announce'
+ ActivityPub::Activity::Announce
+ when 'Delete'
+ ActivityPub::Activity::Delete
+ when 'Follow'
+ ActivityPub::Activity::Follow
+ when 'Like'
+ ActivityPub::Activity::Like
+ when 'Block'
+ ActivityPub::Activity::Block
+ when 'Update'
+ ActivityPub::Activity::Update
+ when 'Undo'
+ ActivityPub::Activity::Undo
+ when 'Accept'
+ ActivityPub::Activity::Accept
+ when 'Reject'
+ ActivityPub::Activity::Reject
+ end
+ end
+ end
+
+ protected
+
+ def status_from_uri(uri)
+ ActivityPub::TagManager.instance.uri_to_resource(uri, Status)
+ end
+
+ def account_from_uri(uri)
+ ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+ end
+
+ def object_uri
+ @object_uri ||= value_or_id(@object)
+ end
+
+ def redis
+ Redis.current
+ end
+
+ def distribute(status)
+ notify_about_reblog(status) if reblog_of_local_account?(status)
+ notify_about_mentions(status)
+ crawl_links(status)
+ distribute_to_followers(status)
+ end
+
+ def reblog_of_local_account?(status)
+ status.reblog? && status.reblog.account.local?
+ end
+
+ def notify_about_reblog(status)
+ NotifyService.new.call(status.reblog.account, status)
+ end
+
+ def notify_about_mentions(status)
+ status.mentions.includes(:account).each do |mention|
+ next unless mention.account.local? && audience_includes?(mention.account)
+ NotifyService.new.call(mention.account, mention)
+ end
+ end
+
+ def crawl_links(status)
+ return if status.spoiler_text?
+ LinkCrawlWorker.perform_async(status.id)
+ end
+
+ def distribute_to_followers(status)
+ ::DistributionWorker.perform_async(status.id)
+ end
+
+ def delete_arrived_first?(uri)
+ redis.exists("delete_upon_arrival:#{@account.id}:#{uri}")
+ end
+
+ def delete_later!(uri)
+ redis.setex("delete_upon_arrival:#{@account.id}:#{uri}", 6.hours.seconds, uri)
+ end
+end
diff --git a/app/lib/activitypub/activity/accept.rb b/app/lib/activitypub/activity/accept.rb
new file mode 100644
index 000000000..bd90c9019
--- /dev/null
+++ b/app/lib/activitypub/activity/accept.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Accept < ActivityPub::Activity
+ def perform
+ case @object['type']
+ when 'Follow'
+ accept_follow
+ end
+ end
+
+ private
+
+ def accept_follow
+ target_account = account_from_uri(target_uri)
+
+ return if target_account.nil? || !target_account.local?
+
+ follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+ follow_request&.authorize!
+ end
+
+ def target_uri
+ @target_uri ||= value_or_id(@object['actor'])
+ end
+end
diff --git a/app/lib/activitypub/activity/announce.rb b/app/lib/activitypub/activity/announce.rb
new file mode 100644
index 000000000..c4da405c7
--- /dev/null
+++ b/app/lib/activitypub/activity/announce.rb
@@ -0,0 +1,28 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Announce < ActivityPub::Activity
+ def perform
+ original_status = status_from_uri(object_uri)
+ original_status ||= fetch_remote_original_status
+
+ return if original_status.nil? || delete_arrived_first?(@json['id'])
+
+ status = Status.find_by(account: @account, reblog: original_status)
+
+ return status unless status.nil?
+
+ status = Status.create!(account: @account, reblog: original_status, uri: @json['id'])
+ distribute(status)
+ status
+ end
+
+ private
+
+ def fetch_remote_original_status
+ if object_uri.start_with?('http')
+ ActivityPub::FetchRemoteStatusService.new.call(object_uri)
+ elsif @object['url'].present?
+ ::FetchRemoteStatusService.new.call(@object['url'])
+ end
+ end
+end
diff --git a/app/lib/activitypub/activity/block.rb b/app/lib/activitypub/activity/block.rb
new file mode 100644
index 000000000..f630d5db2
--- /dev/null
+++ b/app/lib/activitypub/activity/block.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Block < ActivityPub::Activity
+ def perform
+ target_account = account_from_uri(object_uri)
+
+ return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.blocking?(target_account)
+
+ UnfollowService.new.call(target_account, @account) if target_account.following?(@account)
+ @account.block!(target_account)
+ end
+end
diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb
new file mode 100644
index 000000000..9a34484f5
--- /dev/null
+++ b/app/lib/activitypub/activity/create.rb
@@ -0,0 +1,185 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Create < ActivityPub::Activity
+ def perform
+ return if delete_arrived_first?(object_uri) || unsupported_object_type?
+
+ status = find_existing_status
+
+ return status unless status.nil?
+
+ ApplicationRecord.transaction do
+ status = Status.create!(status_params)
+
+ process_tags(status)
+ process_attachments(status)
+ end
+
+ resolve_thread(status)
+ distribute(status)
+ forward_for_reply if status.public_visibility? || status.unlisted_visibility?
+
+ status
+ end
+
+ private
+
+ def find_existing_status
+ status = status_from_uri(object_uri)
+ status ||= Status.find_by(uri: @object['atomUri']) if @object['atomUri'].present?
+ status
+ end
+
+ def status_params
+ {
+ uri: @object['id'],
+ url: object_url || @object['id'],
+ account: @account,
+ text: text_from_content || '',
+ language: language_from_content,
+ spoiler_text: @object['summary'] || '',
+ created_at: @object['published'] || Time.now.utc,
+ reply: @object['inReplyTo'].present?,
+ sensitive: @object['sensitive'] || false,
+ visibility: visibility_from_audience,
+ thread: replied_to_status,
+ conversation: conversation_from_uri(@object['conversation']),
+ }
+ end
+
+ def process_tags(status)
+ return unless @object['tag'].is_a?(Array)
+
+ @object['tag'].each do |tag|
+ case tag['type']
+ when 'Hashtag'
+ process_hashtag tag, status
+ when 'Mention'
+ process_mention tag, status
+ end
+ end
+ end
+
+ def process_hashtag(tag, status)
+ hashtag = tag['name'].gsub(/\A#/, '').mb_chars.downcase
+ hashtag = Tag.where(name: hashtag).first_or_initialize(name: hashtag)
+
+ status.tags << hashtag
+ end
+
+ def process_mention(tag, status)
+ account = account_from_uri(tag['href'])
+ account = FetchRemoteAccountService.new.call(tag['href']) if account.nil?
+ return if account.nil?
+ account.mentions.create(status: status)
+ end
+
+ def process_attachments(status)
+ return unless @object['attachment'].is_a?(Array)
+
+ @object['attachment'].each do |attachment|
+ next if unsupported_media_type?(attachment['mediaType'])
+
+ href = Addressable::URI.parse(attachment['url']).normalize.to_s
+ media_attachment = MediaAttachment.create(status: status, account: status.account, remote_url: href)
+
+ next if skip_download?
+
+ media_attachment.file_remote_url = href
+ media_attachment.save
+ end
+ end
+
+ def resolve_thread(status)
+ return unless status.reply? && status.thread.nil?
+ ThreadResolveWorker.perform_async(status.id, in_reply_to_uri)
+ end
+
+ def conversation_from_uri(uri)
+ return nil if uri.nil?
+ return Conversation.find_by(id: TagManager.instance.unique_tag_to_local_id(uri, 'Conversation')) if TagManager.instance.local_id?(uri)
+ Conversation.find_by(uri: uri) || Conversation.create!(uri: uri)
+ end
+
+ def visibility_from_audience
+ if equals_or_includes?(@object['to'], ActivityPub::TagManager::COLLECTIONS[:public])
+ :public
+ elsif equals_or_includes?(@object['cc'], ActivityPub::TagManager::COLLECTIONS[:public])
+ :unlisted
+ elsif equals_or_includes?(@object['to'], @account.followers_url)
+ :private
+ else
+ :direct
+ end
+ end
+
+ def audience_includes?(account)
+ uri = ActivityPub::TagManager.instance.uri_for(account)
+ equals_or_includes?(@object['to'], uri) || equals_or_includes?(@object['cc'], uri)
+ end
+
+ def replied_to_status
+ return @replied_to_status if defined?(@replied_to_status)
+
+ if in_reply_to_uri.blank?
+ @replied_to_status = nil
+ else
+ @replied_to_status = status_from_uri(in_reply_to_uri)
+ @replied_to_status ||= status_from_uri(@object['inReplyToAtomUri']) if @object['inReplyToAtomUri'].present?
+ @replied_to_status
+ end
+ end
+
+ def in_reply_to_uri
+ value_or_id(@object['inReplyTo'])
+ end
+
+ def text_from_content
+ if @object['content'].present?
+ @object['content']
+ elsif language_map?
+ @object['contentMap'].values.first
+ end
+ end
+
+ def language_from_content
+ return nil unless language_map?
+ @object['contentMap'].keys.first
+ end
+
+ def object_url
+ return if @object['url'].blank?
+
+ value = first_of_value(@object['url'])
+
+ return value if value.is_a?(String)
+
+ value['href']
+ end
+
+ def language_map?
+ @object['contentMap'].is_a?(Hash) && !@object['contentMap'].empty?
+ end
+
+ def unsupported_object_type?
+ @object.is_a?(String) || !%w(Article Note).include?(@object['type'])
+ end
+
+ def unsupported_media_type?(mime_type)
+ mime_type.present? && !(MediaAttachment::IMAGE_MIME_TYPES + MediaAttachment::VIDEO_MIME_TYPES).include?(mime_type)
+ end
+
+ def skip_download?
+ return @skip_download if defined?(@skip_download)
+ @skip_download ||= DomainBlock.find_by(domain: @account.domain)&.reject_media?
+ end
+
+ def reply_to_local?
+ !replied_to_status.nil? && replied_to_status.account.local?
+ end
+
+ def forward_for_reply
+ return unless @json['signature'].present? && reply_to_local?
+ ActivityPub::RawDistributionWorker.perform_async(Oj.dump(@json), replied_to_status.account_id)
+ end
+end
diff --git a/app/lib/activitypub/activity/delete.rb b/app/lib/activitypub/activity/delete.rb
new file mode 100644
index 000000000..4c6afb090
--- /dev/null
+++ b/app/lib/activitypub/activity/delete.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Delete < ActivityPub::Activity
+ def perform
+ if @account.uri == object_uri
+ delete_person
+ else
+ delete_note
+ end
+ end
+
+ private
+
+ def delete_person
+ SuspendAccountService.new.call(@account)
+ end
+
+ def delete_note
+ status = Status.find_by(uri: object_uri, account: @account)
+ status ||= Status.find_by(uri: @object['atomUri'], account: @account) if @object.is_a?(Hash) && @object['atomUri'].present?
+
+ delete_later!(object_uri)
+
+ return if status.nil?
+
+ forward_for_reblogs(status)
+ delete_now!(status)
+ end
+
+ def forward_for_reblogs(status)
+ return if @json['signature'].blank?
+
+ ActivityPub::RawDistributionWorker.push_bulk(status.reblogs.includes(:account).references(:account).merge(Account.local).pluck(:account_id)) do |account_id|
+ [payload, account_id]
+ end
+ end
+
+ def delete_now!(status)
+ RemoveStatusService.new.call(status)
+ end
+
+ def payload
+ @payload ||= Oj.dump(@json)
+ end
+end
diff --git a/app/lib/activitypub/activity/follow.rb b/app/lib/activitypub/activity/follow.rb
new file mode 100644
index 000000000..8adbbb9c3
--- /dev/null
+++ b/app/lib/activitypub/activity/follow.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Follow < ActivityPub::Activity
+ def perform
+ target_account = account_from_uri(object_uri)
+
+ return if target_account.nil? || !target_account.local? || delete_arrived_first?(@json['id']) || @account.requested?(target_account)
+
+ # Fast-forward repeat follow requests
+ if @account.following?(target_account)
+ AuthorizeFollowService.new.call(@account, target_account, skip_follow_request: true)
+ return
+ end
+
+ follow_request = FollowRequest.create!(account: @account, target_account: target_account)
+
+ if target_account.locked?
+ NotifyService.new.call(target_account, follow_request)
+ else
+ AuthorizeFollowService.new.call(@account, target_account)
+ NotifyService.new.call(target_account, ::Follow.find_by(account: @account, target_account: target_account))
+ end
+ end
+end
diff --git a/app/lib/activitypub/activity/like.rb b/app/lib/activitypub/activity/like.rb
new file mode 100644
index 000000000..674d5fe47
--- /dev/null
+++ b/app/lib/activitypub/activity/like.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Like < ActivityPub::Activity
+ def perform
+ original_status = status_from_uri(object_uri)
+
+ return if original_status.nil? || !original_status.account.local? || delete_arrived_first?(@json['id']) || @account.favourited?(original_status)
+
+ favourite = original_status.favourites.create!(account: @account)
+ NotifyService.new.call(original_status.account, favourite)
+ end
+end
diff --git a/app/lib/activitypub/activity/reject.rb b/app/lib/activitypub/activity/reject.rb
new file mode 100644
index 000000000..d815feeb6
--- /dev/null
+++ b/app/lib/activitypub/activity/reject.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Reject < ActivityPub::Activity
+ def perform
+ case @object['type']
+ when 'Follow'
+ reject_follow
+ end
+ end
+
+ private
+
+ def reject_follow
+ target_account = account_from_uri(target_uri)
+
+ return if target_account.nil? || !target_account.local?
+
+ follow_request = FollowRequest.find_by(account: target_account, target_account: @account)
+ follow_request&.reject!
+ end
+
+ def target_uri
+ @target_uri ||= value_or_id(@object['actor'])
+ end
+end
diff --git a/app/lib/activitypub/activity/undo.rb b/app/lib/activitypub/activity/undo.rb
new file mode 100644
index 000000000..4b0905de2
--- /dev/null
+++ b/app/lib/activitypub/activity/undo.rb
@@ -0,0 +1,71 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Undo < ActivityPub::Activity
+ def perform
+ case @object['type']
+ when 'Announce'
+ undo_announce
+ when 'Follow'
+ undo_follow
+ when 'Like'
+ undo_like
+ when 'Block'
+ undo_block
+ end
+ end
+
+ private
+
+ def undo_announce
+ status = Status.find_by(uri: object_uri, account: @account)
+
+ if status.nil?
+ delete_later!(object_uri)
+ else
+ RemoveStatusService.new.call(status)
+ end
+ end
+
+ def undo_follow
+ target_account = account_from_uri(target_uri)
+
+ return if target_account.nil? || !target_account.local?
+
+ if @account.following?(target_account)
+ @account.unfollow!(target_account)
+ elsif @account.requested?(target_account)
+ FollowRequest.find_by(account: @account, target_account: target_account)&.destroy
+ else
+ delete_later!(object_uri)
+ end
+ end
+
+ def undo_like
+ status = status_from_uri(target_uri)
+
+ return if status.nil? || !status.account.local?
+
+ if @account.favourited?(status)
+ favourite = status.favourites.where(account: @account).first
+ favourite&.destroy
+ else
+ delete_later!(object_uri)
+ end
+ end
+
+ def undo_block
+ target_account = account_from_uri(target_uri)
+
+ return if target_account.nil? || !target_account.local?
+
+ if @account.blocking?(target_account)
+ UnblockService.new.call(@account, target_account)
+ else
+ delete_later!(object_uri)
+ end
+ end
+
+ def target_uri
+ @target_uri ||= value_or_id(@object['object'])
+ end
+end
diff --git a/app/lib/activitypub/activity/update.rb b/app/lib/activitypub/activity/update.rb
new file mode 100644
index 000000000..0134b4015
--- /dev/null
+++ b/app/lib/activitypub/activity/update.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class ActivityPub::Activity::Update < ActivityPub::Activity
+ def perform
+ case @object['type']
+ when 'Person'
+ update_account
+ end
+ end
+
+ private
+
+ def update_account
+ return if @account.uri != object_uri
+ ActivityPub::ProcessAccountService.new.call(@account.username, @account.domain, @object)
+ end
+end
diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb
index 0a70207bc..6ed66a239 100644
--- a/app/lib/activitypub/adapter.rb
+++ b/app/lib/activitypub/adapter.rb
@@ -1,13 +1,34 @@
# frozen_string_literal: true
class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base
+ CONTEXT = {
+ '@context': [
+ 'https://www.w3.org/ns/activitystreams',
+ 'https://w3id.org/security/v1',
+
+ {
+ 'manuallyApprovesFollowers' => 'as:manuallyApprovesFollowers',
+ 'sensitive' => 'as:sensitive',
+ 'Hashtag' => 'as:Hashtag',
+ 'ostatus' => 'http://ostatus.org#',
+ 'atomUri' => 'ostatus:atomUri',
+ 'inReplyToAtomUri' => 'ostatus:inReplyToAtomUri',
+ 'conversation' => 'ostatus:conversation',
+ },
+ ],
+ }.freeze
+
def self.default_key_transform
:camel_lower
end
+ def self.transform_key_casing!(value, _options)
+ ActivityPub::CaseTransform.camel_lower(value)
+ end
+
def serializable_hash(options = nil)
options = serialization_options(options)
- serialized_hash = { '@context': 'https://www.w3.org/ns/activitystreams' }.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
+ serialized_hash = CONTEXT.merge(ActiveModelSerializers::Adapter::Attributes.new(serializer, instance_options).serializable_hash(options))
self.class.transform_key_casing!(serialized_hash, instance_options)
end
end
diff --git a/app/lib/activitypub/case_transform.rb b/app/lib/activitypub/case_transform.rb
new file mode 100644
index 000000000..7f716f862
--- /dev/null
+++ b/app/lib/activitypub/case_transform.rb
@@ -0,0 +1,24 @@
+# frozen_string_literal: true
+
+module ActivityPub::CaseTransform
+ class << self
+ def camel_lower_cache
+ @camel_lower_cache ||= {}
+ end
+
+ def camel_lower(value)
+ case value
+ when Array then value.map { |item| camel_lower(item) }
+ when Hash then value.deep_transform_keys! { |key| camel_lower(key) }
+ when Symbol then camel_lower(value.to_s).to_sym
+ when String
+ camel_lower_cache[value] ||= if value.start_with?('_:')
+ '_:' + value.gsub(/\A_:/, '').underscore.camelize(:lower)
+ else
+ value.underscore.camelize(:lower)
+ end
+ else value
+ end
+ end
+ end
+end
diff --git a/app/lib/activitypub/linked_data_signature.rb b/app/lib/activitypub/linked_data_signature.rb
new file mode 100644
index 000000000..adb8b6cdf
--- /dev/null
+++ b/app/lib/activitypub/linked_data_signature.rb
@@ -0,0 +1,56 @@
+# frozen_string_literal: true
+
+class ActivityPub::LinkedDataSignature
+ include JsonLdHelper
+
+ CONTEXT = 'https://w3id.org/identity/v1'
+
+ def initialize(json)
+ @json = json.with_indifferent_access
+ end
+
+ def verify_account!
+ return unless @json['signature'].is_a?(Hash)
+
+ type = @json['signature']['type']
+ creator_uri = @json['signature']['creator']
+ signature = @json['signature']['signatureValue']
+
+ return unless type == 'RsaSignature2017'
+
+ creator = ActivityPub::TagManager.instance.uri_to_resource(creator_uri, Account)
+ creator ||= ActivityPub::FetchRemoteKeyService.new.call(creator_uri)
+
+ return if creator.nil?
+
+ options_hash = hash(@json['signature'].without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+ document_hash = hash(@json.without('signature'))
+ to_be_verified = options_hash + document_hash
+
+ if creator.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, Base64.decode64(signature), to_be_verified)
+ creator
+ end
+ end
+
+ def sign!(creator)
+ options = {
+ 'type' => 'RsaSignature2017',
+ 'creator' => [ActivityPub::TagManager.instance.uri_for(creator), '#main-key'].join,
+ 'created' => Time.now.utc.iso8601,
+ }
+
+ options_hash = hash(options.without('type', 'id', 'signatureValue').merge('@context' => CONTEXT))
+ document_hash = hash(@json.without('signature'))
+ to_be_signed = options_hash + document_hash
+
+ signature = Base64.strict_encode64(creator.keypair.sign(OpenSSL::Digest::SHA256.new, to_be_signed))
+
+ @json.merge('signature' => options.merge('signatureValue' => signature))
+ end
+
+ private
+
+ def hash(obj)
+ Digest::SHA256.hexdigest(canonicalize(obj))
+ end
+end
diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb
index ec42bcad3..929e87852 100644
--- a/app/lib/activitypub/tag_manager.rb
+++ b/app/lib/activitypub/tag_manager.rb
@@ -6,6 +6,8 @@ class ActivityPub::TagManager
include Singleton
include RoutingHelper
+ CONTEXT = 'https://www.w3.org/ns/activitystreams'
+
COLLECTIONS = {
public: 'https://www.w3.org/ns/activitystreams#Public',
}.freeze
@@ -17,6 +19,7 @@ class ActivityPub::TagManager
when :person
short_account_url(target)
when :note, :comment, :activity
+ return activity_account_status_url(target.account, target) if target.reblog?
short_account_status_url(target.account, target)
end
end
@@ -28,10 +31,17 @@ class ActivityPub::TagManager
when :person
account_url(target)
when :note, :comment, :activity
+ return activity_account_status_url(target.account, target) if target.reblog?
account_status_url(target.account, target)
end
end
+ def activity_uri_for(target)
+ return nil unless %i(note comment activity).include?(target.object_type) && target.local?
+
+ activity_account_status_url(target.account, target)
+ end
+
# Primary audience of a status
# Public statuses go out to primarily the public collection
# Unlisted and private statuses go out primarily to the followers collection
@@ -66,4 +76,34 @@ class ActivityPub::TagManager
cc
end
+
+ def local_uri?(uri)
+ uri = Addressable::URI.parse(uri)
+ host = uri.normalized_host
+ host = "#{host}:#{uri.port}" if uri.port
+
+ !host.nil? && (::TagManager.instance.local_domain?(host) || ::TagManager.instance.web_domain?(host))
+ end
+
+ def uri_to_local_id(uri, param = :id)
+ path_params = Rails.application.routes.recognize_path(uri)
+ path_params[param]
+ end
+
+ def uri_to_resource(uri, klass)
+ if local_uri?(uri)
+ case klass.name
+ when 'Account'
+ klass.find_local(uri_to_local_id(uri, :username))
+ else
+ StatusFinder.new(uri).status
+ end
+ elsif ::TagManager.instance.local_id?(uri)
+ klass.find_by(id: ::TagManager.instance.unique_tag_to_local_id(uri, klass.to_s))
+ else
+ klass.find_by(uri: uri.split('#').first)
+ end
+ rescue ActiveRecord::RecordNotFound
+ nil
+ end
end
diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb
index 7b89305ac..cacc0364f 100644
--- a/app/lib/formatter.rb
+++ b/app/lib/formatter.rb
@@ -104,7 +104,7 @@ class Formatter
html_attrs = { target: '_blank', rel: 'nofollow noopener' }
Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), normalized_url, html_attrs)
- rescue Addressable::URI::InvalidURIError
+ rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError
encode(entity[:url])
end
diff --git a/app/lib/language_detector.rb b/app/lib/language_detector.rb
index cc7509fdc..1d9932b52 100644
--- a/app/lib/language_detector.rb
+++ b/app/lib/language_detector.rb
@@ -20,7 +20,16 @@ class LanguageDetector
private
def detected_language_code
- result.language.to_sym if detected_language_reliable?
+ iso6391(result.language).to_sym if detected_language_reliable?
+ end
+
+ def iso6391(bcp47)
+ iso639 = bcp47.split('-').first
+
+ # CLD3 returns grandfathered language code for Hebrew
+ return 'he' if iso639 == 'iw'
+
+ ISO_639.find(iso639).alpha2
end
def result
diff --git a/app/lib/ostatus/activity/base.rb b/app/lib/ostatus/activity/base.rb
index e1477f0eb..1dc7abee3 100644
--- a/app/lib/ostatus/activity/base.rb
+++ b/app/lib/ostatus/activity/base.rb
@@ -29,21 +29,43 @@ class OStatus::Activity::Base
end
def url
- link = @xml.at_xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS)
+ link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| link_candidate['type'] == 'text/html' }
link.nil? ? nil : link['href']
end
+ def activitypub_uri
+ link = @xml.xpath('./xmlns:link[@rel="alternate"]', xmlns: TagManager::XMLNS).find { |link_candidate| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link_candidate['type']) }
+ link.nil? ? nil : link['href']
+ end
+
+ def activitypub_uri?
+ activitypub_uri.present?
+ end
+
private
def find_status(uri)
if TagManager.instance.local_id?(uri)
local_id = TagManager.instance.unique_tag_to_local_id(uri, 'Status')
return Status.find_by(id: local_id)
+ elsif ActivityPub::TagManager.instance.local_uri?(uri)
+ local_id = ActivityPub::TagManager.instance.uri_to_local_id(uri)
+ return Status.find_by(id: local_id)
end
Status.find_by(uri: uri)
end
+ def find_activitypub_status(uri, href)
+ tag_matches = /tag:([^,:]+)[^:]*:objectId=([\d]+)/.match(uri)
+ href_matches = %r{/users/([^/]+)}.match(href)
+
+ unless tag_matches.nil? || href_matches.nil?
+ uri = "https://#{tag_matches[1]}/users/#{href_matches[1]}/statuses/#{tag_matches[2]}"
+ Status.find_by(uri: uri)
+ end
+ end
+
def redis
Redis.current
end
diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb
index e22f746f2..1a23c9efa 100644
--- a/app/lib/ostatus/activity/creation.rb
+++ b/app/lib/ostatus/activity/creation.rb
@@ -9,6 +9,11 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
return [nil, false] if @account.suspended?
+ if activitypub_uri? && [:public, :unlisted].include?(visibility_scope)
+ result = perform_via_activitypub
+ return result if result.first.present?
+ end
+
Rails.logger.debug "Creating remote status #{id}"
# Return early if status already exists in db
@@ -16,24 +21,28 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
return [status, false] unless status.nil?
- status = Status.create!(
- uri: id,
- url: url,
- account: @account,
- reblog: reblog,
- text: content,
- spoiler_text: content_warning,
- created_at: published,
- reply: thread?,
- language: content_language,
- visibility: visibility_scope,
- conversation: find_or_create_conversation,
- thread: thread? ? find_status(thread.first) : nil
- )
+ cached_reblog = reblog
- save_mentions(status)
- save_hashtags(status)
- save_media(status)
+ ApplicationRecord.transaction do
+ status = Status.create!(
+ uri: id,
+ url: url,
+ account: @account,
+ reblog: cached_reblog,
+ text: content,
+ spoiler_text: content_warning,
+ created_at: published,
+ reply: thread?,
+ language: content_language,
+ visibility: visibility_scope,
+ conversation: find_or_create_conversation,
+ thread: thread? ? find_status(thread.first) || find_activitypub_status(thread.first, thread.second) : nil
+ )
+
+ save_mentions(status)
+ save_hashtags(status)
+ save_media(status)
+ end
if thread? && status.thread.nil?
Rails.logger.debug "Trying to attach #{status.id} (#{id}) to #{thread.first}"
@@ -48,6 +57,10 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
[status, true]
end
+ def perform_via_activitypub
+ [find_status(activitypub_uri) || ActivityPub::FetchRemoteStatusService.new.call(activitypub_uri), false]
+ end
+
def content
@xml.at_xpath('./xmlns:content', xmlns: TagManager::XMLNS).content
end
diff --git a/app/lib/ostatus/activity/deletion.rb b/app/lib/ostatus/activity/deletion.rb
index 860faf501..c98f5ee0a 100644
--- a/app/lib/ostatus/activity/deletion.rb
+++ b/app/lib/ostatus/activity/deletion.rb
@@ -3,7 +3,9 @@
class OStatus::Activity::Deletion < OStatus::Activity::Base
def perform
Rails.logger.debug "Deleting remote status #{id}"
- status = Status.find_by(uri: id, account: @account)
+
+ status = Status.find_by(uri: id, account: @account)
+ status ||= Status.find_by(uri: activitypub_uri, account: @account) if activitypub_uri?
if status.nil?
redis.setex("delete_upon_arrival:#{@account.id}:#{id}", 6 * 3_600, id)
diff --git a/app/lib/ostatus/activity/remote.rb b/app/lib/ostatus/activity/remote.rb
index ecec6886c..5b204b6d8 100644
--- a/app/lib/ostatus/activity/remote.rb
+++ b/app/lib/ostatus/activity/remote.rb
@@ -2,6 +2,10 @@
class OStatus::Activity::Remote < OStatus::Activity::Base
def perform
- find_status(id) || FetchRemoteStatusService.new.call(url)
+ if activitypub_uri?
+ find_status(activitypub_uri) || FetchRemoteStatusService.new.call(url)
+ else
+ find_status(id) || FetchRemoteStatusService.new.call(url)
+ end
end
end
diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb
index 0d62361be..b8e22a381 100644
--- a/app/lib/ostatus/atom_serializer.rb
+++ b/app/lib/ostatus/atom_serializer.rb
@@ -65,7 +65,7 @@ class OStatus::AtomSerializer
add_namespaces(entry) if root
- append_element(entry, 'id', TagManager.instance.unique_tag(stream_entry.created_at, stream_entry.activity_id, stream_entry.activity_type))
+ append_element(entry, 'id', TagManager.instance.uri_for(stream_entry.status))
append_element(entry, 'published', stream_entry.created_at.iso8601)
append_element(entry, 'updated', stream_entry.updated_at.iso8601)
append_element(entry, 'title', stream_entry&.status&.title || "#{stream_entry.account.acct} deleted status")
@@ -79,11 +79,14 @@ class OStatus::AtomSerializer
if stream_entry.status.nil?
append_element(entry, 'content', 'Deleted status')
+ elsif stream_entry.status.destroyed?
+ append_element(entry, 'content', 'Deleted status')
+ append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(stream_entry.status)) if stream_entry.account.local?
else
serialize_status_attributes(entry, stream_entry.status)
end
- append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: account_stream_entry_url(stream_entry.account, stream_entry))
+ append_element(entry, 'link', nil, rel: :alternate, type: 'text/html', href: TagManager.instance.url_for(stream_entry.status))
append_element(entry, 'link', nil, rel: :self, type: 'application/atom+xml', href: account_stream_entry_url(stream_entry.account, stream_entry, format: 'atom'))
append_element(entry, 'thr:in-reply-to', nil, ref: TagManager.instance.uri_for(stream_entry.thread), href: TagManager.instance.url_for(stream_entry.thread)) if stream_entry.threaded?
append_element(entry, 'ostatus:conversation', nil, ref: conversation_uri(stream_entry.status.conversation)) unless stream_entry&.status&.conversation_id.nil?
@@ -343,6 +346,8 @@ class OStatus::AtomSerializer
end
def serialize_status_attributes(entry, status)
+ append_element(entry, 'link', nil, rel: :alternate, type: 'application/activity+json', href: ActivityPub::TagManager.instance.uri_for(status)) if status.account.local?
+
append_element(entry, 'summary', status.spoiler_text, 'xml:lang': status.language) if status.spoiler_text?
append_element(entry, 'content', Formatter.instance.format(status).to_str, type: 'html', 'xml:lang': status.language)
diff --git a/app/lib/request.rb b/app/lib/request.rb
index e73c5ac20..c01e07925 100644
--- a/app/lib/request.rb
+++ b/app/lib/request.rb
@@ -12,15 +12,21 @@ class Request
@headers = {}
set_common_headers!
+ set_digest! if options.key?(:body)
end
- def on_behalf_of(account)
+ def on_behalf_of(account, key_id_format = :acct)
raise ArgumentError unless account.local?
- @account = account
+
+ @account = account
+ @key_id_format = key_id_format
+
+ self
end
def add_headers(new_headers)
@headers.merge!(new_headers)
+ self
end
def perform
@@ -40,8 +46,11 @@ class Request
@headers['Date'] = Time.now.utc.httpdate
end
+ def set_digest!
+ @headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
+ end
+
def signature
- key_id = @account.to_webfinger_s
algorithm = 'rsa-sha256'
signature = Base64.strict_encode64(@account.keypair.sign(OpenSSL::Digest::SHA256.new, signed_string))
@@ -60,6 +69,15 @@ class Request
@user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})"
end
+ def key_id
+ case @key_id_format
+ when :acct
+ @account.to_webfinger_s
+ when :uri
+ [ActivityPub::TagManager.instance.uri_for(@account), '#main-key'].join
+ end
+ end
+
def timeout
{ write: 10, connect: 10, read: 10 }
end
diff --git a/app/lib/stream_entry_finder.rb b/app/lib/status_finder.rb
similarity index 69%
rename from app/lib/stream_entry_finder.rb
rename to app/lib/status_finder.rb
index 0ea33229c..4d1aed297 100644
--- a/app/lib/stream_entry_finder.rb
+++ b/app/lib/status_finder.rb
@@ -1,20 +1,22 @@
# frozen_string_literal: true
-class StreamEntryFinder
+class StatusFinder
attr_reader :url
def initialize(url)
@url = url
end
- def stream_entry
+ def status
verify_action!
+ raise ActiveRecord::RecordNotFound unless TagManager.instance.local_url?(url)
+
case recognized_params[:controller]
when 'stream_entries'
- StreamEntry.find(recognized_params[:id])
+ StreamEntry.find(recognized_params[:id]).status
when 'statuses'
- Status.find(recognized_params[:id]).stream_entry
+ Status.find(recognized_params[:id])
else
raise ActiveRecord::RecordNotFound
end
diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb
index 5f87a2a48..f33a20c6f 100644
--- a/app/lib/tag_manager.rb
+++ b/app/lib/tag_manager.rb
@@ -49,12 +49,17 @@ class TagManager
def unique_tag_to_local_id(tag, expected_type)
return nil unless local_id?(tag)
- matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
- return matches[1] unless matches.nil?
+
+ if ActivityPub::TagManager.instance.local_uri?(tag)
+ ActivityPub::TagManager.instance.uri_to_local_id(tag)
+ else
+ matches = Regexp.new("objectId=([\\d]+):objectType=#{expected_type}").match(tag)
+ return matches[1] unless matches.nil?
+ end
end
def local_id?(id)
- id.start_with?("tag:#{Rails.configuration.x.local_domain}")
+ id.start_with?("tag:#{Rails.configuration.x.local_domain}") || ActivityPub::TagManager.instance.local_uri?(id)
end
def web_domain?(domain)
@@ -92,7 +97,7 @@ class TagManager
when :person
account_url(target)
when :note, :comment, :activity
- unique_tag(target.created_at, target.id, 'Status')
+ target.uri || unique_tag(target.created_at, target.id, 'Status')
end
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 163bd1c0e..529334559 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -77,6 +77,10 @@ class Account < ApplicationRecord
has_many :mentions, inverse_of: :account, dependent: :destroy
has_many :notifications, inverse_of: :account, dependent: :destroy
+ # Pinned statuses
+ has_many :status_pins, inverse_of: :account, dependent: :destroy
+ has_many :pinned_statuses, -> { reorder('status_pins.created_at DESC') }, through: :status_pins, class_name: 'Status', source: :status
+
# Media
has_many :media_attachments, dependent: :destroy
@@ -91,7 +95,7 @@ class Account < ApplicationRecord
scope :local, -> { where(domain: nil) }
scope :without_followers, -> { where(followers_count: 0) }
scope :with_followers, -> { where('followers_count > 0') }
- scope :expiring, ->(time) { where(subscription_expires_at: nil).or(where('subscription_expires_at < ?', time)).remote.with_followers }
+ scope :expiring, ->(time) { remote.where.not(subscription_expires_at: nil).where('subscription_expires_at < ?', time) }
scope :partitioned, -> { order('row_number() over (partition by domain)') }
scope :silenced, -> { where(silenced: true) }
scope :suspended, -> { where(suspended: true) }
@@ -105,6 +109,7 @@ class Account < ApplicationRecord
:current_sign_in_ip,
:current_sign_in_at,
:confirmed?,
+ :admin?,
:locale,
to: :user,
prefix: true,
@@ -133,11 +138,11 @@ class Account < ApplicationRecord
end
def keypair
- OpenSSL::PKey::RSA.new(private_key || public_key)
+ @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key)
end
def subscription(webhook_url)
- OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url)
+ @subscription ||= OStatus2::Subscription.new(remote_url, secret: secret, webhook: webhook_url, hub: hub_url)
end
def save_with_optional_media!
@@ -171,6 +176,10 @@ class Account < ApplicationRecord
reorder(nil).pluck('distinct accounts.domain')
end
+ def inboxes
+ reorder(nil).where(protocol: :activitypub).pluck("distinct coalesce(nullif(accounts.shared_inbox_url, ''), accounts.inbox_url)")
+ end
+
def triadic_closures(account, limit: 5, offset: 0)
sql = <<-SQL.squish
WITH first_degree AS (
@@ -263,7 +272,7 @@ class Account < ApplicationRecord
def generate_keys
return unless local?
- keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 1024 : 2048)
+ keypair = OpenSSL::PKey::RSA.new(Rails.env.test? ? 512 : 2048)
self.private_key = keypair.to_pem
self.public_key = keypair.public_key.to_pem
end
diff --git a/app/models/concerns/account_avatar.rb b/app/models/concerns/account_avatar.rb
index a6527a85b..8a5c9a22c 100644
--- a/app/models/concerns/account_avatar.rb
+++ b/app/models/concerns/account_avatar.rb
@@ -8,7 +8,7 @@ module AccountAvatar
class_methods do
def avatar_styles(file)
styles = { original: '120x120#' }
- styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+ styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
styles
end
diff --git a/app/models/concerns/account_header.rb b/app/models/concerns/account_header.rb
index 4ba9212a2..aff2aa3f9 100644
--- a/app/models/concerns/account_header.rb
+++ b/app/models/concerns/account_header.rb
@@ -8,7 +8,7 @@ module AccountHeader
class_methods do
def header_styles(file)
styles = { original: '700x335#' }
- styles[:static] = { format: 'png' } if file.content_type == 'image/gif'
+ styles[:static] = { format: 'png', convert_options: '-coalesce' } if file.content_type == 'image/gif'
styles
end
diff --git a/app/models/concerns/account_interactions.rb b/app/models/concerns/account_interactions.rb
index 9ffed2910..b26520f5b 100644
--- a/app/models/concerns/account_interactions.rb
+++ b/app/models/concerns/account_interactions.rb
@@ -138,4 +138,8 @@ module AccountInteractions
def reblogged?(status)
status.proper.reblogs.where(account: self).exists?
end
+
+ def pinned?(status)
+ status_pins.where(status: status).exists?
+ end
end
diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb
index 1bd87a642..270043a9e 100644
--- a/app/models/concerns/remotable.rb
+++ b/app/models/concerns/remotable.rb
@@ -10,6 +10,8 @@ module Remotable
alt_method_name = "reset_#{attachment_name}!".to_sym
define_method method_name do |url|
+ return if url.blank?
+
begin
parsed_url = Addressable::URI.parse(url).normalize
rescue Addressable::URI::InvalidURIError
diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb
index c3a04ba65..2b148c82b 100644
--- a/app/models/form/admin_settings.rb
+++ b/app/models/form/admin_settings.rb
@@ -24,6 +24,8 @@ class Form::AdminSettings
:open_deletion=,
:timeline_preview,
:timeline_preview=,
+ :bootstrap_timeline_accounts,
+ :bootstrap_timeline_accounts=,
to: Setting
)
end
diff --git a/app/models/import.rb b/app/models/import.rb
index 815e02589..4656c3af6 100644
--- a/app/models/import.rb
+++ b/app/models/import.rb
@@ -28,4 +28,5 @@ class Import < ApplicationRecord
has_attached_file :data, url: '/system/:hash.:extension', hash_secret: ENV['PAPERCLIP_SECRET']
validates_attachment_content_type :data, content_type: FILE_TYPES
+ validates_attachment_presence :data
end
diff --git a/app/models/media_attachment.rb b/app/models/media_attachment.rb
index 1e8c6d00a..d83ca44f1 100644
--- a/app/models/media_attachment.rb
+++ b/app/models/media_attachment.rb
@@ -142,9 +142,11 @@ class MediaAttachment < ApplicationRecord
def populate_meta
meta = {}
+
file.queued_for_write.each do |style, file|
begin
geo = Paperclip::Geometry.from_file file
+
meta[style] = {
width: geo.width.to_i,
height: geo.height.to_i,
@@ -155,6 +157,7 @@ class MediaAttachment < ApplicationRecord
meta[style] = {}
end
end
+
meta
end
diff --git a/app/models/preview_card.rb b/app/models/preview_card.rb
index c334c48aa..b7efac354 100644
--- a/app/models/preview_card.rb
+++ b/app/models/preview_card.rb
@@ -4,16 +4,13 @@
# Table name: preview_cards
#
# id :integer not null, primary key
-# status_id :integer
# url :string default(""), not null
-# title :string
-# description :string
+# title :string default(""), not null
+# description :string default(""), not null
# image_file_name :string
# image_content_type :string
# image_file_size :integer
# image_updated_at :datetime
-# created_at :datetime not null
-# updated_at :datetime not null
# type :integer default("link"), not null
# html :text default(""), not null
# author_name :string default(""), not null
@@ -22,6 +19,8 @@
# provider_url :string default(""), not null
# width :integer default(0), not null
# height :integer default(0), not null
+# created_at :datetime not null
+# updated_at :datetime not null
#
class PreviewCard < ApplicationRecord
@@ -31,21 +30,37 @@ class PreviewCard < ApplicationRecord
enum type: [:link, :photo, :video, :rich]
- belongs_to :status
+ has_and_belongs_to_many :statuses
- has_attached_file :image, styles: { original: '120x120#' }, convert_options: { all: '-quality 80 -strip' }
+ has_attached_file :image, styles: { original: '280x120>' }, convert_options: { all: '-quality 80 -strip' }
include Attachmentable
include Remotable
- validates :url, presence: true
+ validates :url, presence: true, uniqueness: true
validates_attachment_content_type :image, content_type: IMAGE_MIME_TYPES
validates_attachment_size :image, less_than: 1.megabytes
+ before_save :extract_dimensions, if: :link?
+
def save_with_optional_image!
save!
rescue ActiveRecord::RecordInvalid
self.image = nil
save!
end
+
+ private
+
+ def extract_dimensions
+ file = image.queued_for_write[:original]
+
+ return if file.nil?
+
+ geo = Paperclip::Geometry.from_file(file)
+ self.width = geo.width.to_i
+ self.height = geo.height.to_i
+ rescue Paperclip::Errors::NotIdentifiedByImageMagickError
+ nil
+ end
end
diff --git a/app/models/remote_follow.rb b/app/models/remote_follow.rb
index 8366d43c5..c3f867743 100644
--- a/app/models/remote_follow.rb
+++ b/app/models/remote_follow.rb
@@ -42,7 +42,7 @@ class RemoteFollow
def acct_resource
@_acct_resource ||= Goldfinger.finger("acct:#{acct}")
- rescue Goldfinger::Error
+ rescue Goldfinger::Error, HTTP::ConnectionError
nil
end
diff --git a/app/models/report.rb b/app/models/report.rb
index 4d2552d30..479aa17bb 100644
--- a/app/models/report.rb
+++ b/app/models/report.rb
@@ -22,6 +22,8 @@ class Report < ApplicationRecord
scope :unresolved, -> { where(action_taken: false) }
scope :resolved, -> { where(action_taken: true) }
+ validates :comment, length: { maximum: 1000 }
+
def statuses
Status.where(id: status_ids).includes(:account, :media_attachments, :mentions)
end
diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb
index 7eb16af8f..c1645223b 100644
--- a/app/models/session_activation.rb
+++ b/app/models/session_activation.rb
@@ -25,6 +25,7 @@
#
class SessionActivation < ApplicationRecord
+ belongs_to :user, inverse_of: :session_activations, required: true
belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy
belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy
diff --git a/app/models/status.rb b/app/models/status.rb
index 24eaf7071..514cab2e4 100644
--- a/app/models/status.rb
+++ b/app/models/status.rb
@@ -22,6 +22,7 @@
# reblogs_count :integer default(0), not null
# language :string
# conversation_id :integer
+# local :boolean
#
class Status < ApplicationRecord
@@ -47,10 +48,12 @@ class Status < ApplicationRecord
has_many :replies, foreign_key: 'in_reply_to_id', class_name: 'Status', inverse_of: :thread
has_many :mentions, dependent: :destroy
has_many :media_attachments, dependent: :destroy
+
has_and_belongs_to_many :tags
+ has_and_belongs_to_many :preview_cards
has_one :notification, as: :activity, dependent: :destroy
- has_one :preview_card, dependent: :destroy
+ has_one :stream_entry, as: :activity, inverse_of: :status
validates :uri, uniqueness: true, unless: :local?
validates :text, presence: true, unless: :reblog?
@@ -60,8 +63,8 @@ class Status < ApplicationRecord
default_scope { recent }
scope :recent, -> { reorder(id: :desc) }
- scope :remote, -> { where.not(uri: nil) }
- scope :local, -> { where(uri: nil) }
+ scope :remote, -> { where(local: false).or(where.not(uri: nil)) }
+ scope :local, -> { where(local: true).or(where(uri: nil)) }
scope :without_replies, -> { where('statuses.reply = FALSE OR statuses.in_reply_to_account_id = statuses.account_id') }
scope :without_reblogs, -> { where('statuses.reblog_of_id IS NULL') }
@@ -82,7 +85,7 @@ class Status < ApplicationRecord
end
def local?
- uri.nil?
+ attributes['local'] || uri.nil?
end
def reblog?
@@ -90,7 +93,11 @@ class Status < ApplicationRecord
end
def verb
- reblog? ? :share : :post
+ if destroyed?
+ :delete
+ else
+ reblog? ? :share : :post
+ end
end
def object_type
@@ -110,7 +117,11 @@ class Status < ApplicationRecord
end
def title
- reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+ if destroyed?
+ "#{account.acct} deleted status"
+ else
+ reblog? ? "#{account.acct} shared a status by #{reblog.account.acct}" : "New status by #{account.acct}"
+ end
end
def hidden?
@@ -121,11 +132,14 @@ class Status < ApplicationRecord
!sensitive? && media_attachments.any?
end
+ after_create :store_uri, if: :local?
+
before_validation :prepare_contents, if: :local?
before_validation :set_reblog
before_validation :set_visibility
before_validation :set_conversation
before_validation :set_sensitivity
+ before_validation :set_local
class << self
def not_in_filtered_languages(account)
@@ -164,6 +178,10 @@ class Status < ApplicationRecord
ConversationMute.select('conversation_id').where(conversation_id: conversation_ids).where(account_id: account_id).map { |m| [m.conversation_id, true] }.to_h
end
+ def pins_map(status_ids, account_id)
+ StatusPin.select('status_id').where(status_id: status_ids).where(account_id: account_id).map { |p| [p.status_id, true] }.to_h
+ end
+
def reload_stale_associations!(cached_items)
account_ids = []
@@ -239,6 +257,10 @@ class Status < ApplicationRecord
private
+ def store_uri
+ update_attribute(:uri, ActivityPub::TagManager.instance.uri_for(self)) if uri.nil?
+ end
+
def prepare_contents
text&.strip!
spoiler_text&.strip!
@@ -278,4 +300,8 @@ class Status < ApplicationRecord
thread.account_id
end
end
+
+ def set_local
+ self.local = account.local?
+ end
end
diff --git a/app/models/status_pin.rb b/app/models/status_pin.rb
new file mode 100644
index 000000000..a72c19750
--- /dev/null
+++ b/app/models/status_pin.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+# == Schema Information
+#
+# Table name: status_pins
+#
+# id :integer not null, primary key
+# account_id :integer not null
+# status_id :integer not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+
+class StatusPin < ApplicationRecord
+ belongs_to :account, required: true
+ belongs_to :status, required: true
+
+ validates_with StatusPinValidator
+end
diff --git a/app/models/subscription.rb b/app/models/subscription.rb
index bf643c1f9..14f1a140c 100644
--- a/app/models/subscription.rb
+++ b/app/models/subscription.rb
@@ -26,6 +26,7 @@ class Subscription < ApplicationRecord
scope :confirmed, -> { where(confirmed: true) }
scope :future_expiration, -> { where(arel_table[:expires_at].gt(Time.now.utc)) }
+ scope :expired, -> { where(arel_table[:expires_at].lt(Time.now.utc)) }
scope :active, -> { confirmed.future_expiration }
def lease_seconds=(value)
diff --git a/app/models/user.rb b/app/models/user.rb
index 96a2d09b7..5e548c1ef 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -46,6 +46,8 @@ class User < ApplicationRecord
belongs_to :account, inverse_of: :user, required: true
accepts_nested_attributes_for :account
+ has_many :applications, class_name: 'Doorkeeper::Application', as: :owner
+
validates :locale, inclusion: I18n.available_locales.map(&:to_s), if: :locale?
validates_with BlacklistedEmailValidator, if: :email_changed?
@@ -108,10 +110,21 @@ class User < ApplicationRecord
settings.noindex
end
+ def token_for_app(a)
+ return nil if a.nil? || a.owner != self
+ Doorkeeper::AccessToken
+ .find_or_create_by(application_id: a.id, resource_owner_id: id) do |t|
+
+ t.scopes = a.scopes
+ t.expires_in = Doorkeeper.configuration.access_token_expires_in
+ t.use_refresh_token = Doorkeeper.configuration.refresh_token_enabled?
+ end
+ end
+
def activate_session(request)
session_activations.activate(session_id: SecureRandom.hex,
user_agent: request.user_agent,
- ip: request.ip).session_id
+ ip: request.remote_ip).session_id
end
def exclusive_session(id)
diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb
index e76f61278..cb15dfa37 100644
--- a/app/models/web/push_subscription.rb
+++ b/app/models/web/push_subscription.rb
@@ -13,59 +13,14 @@
#
require 'webpush'
-require_relative '../../models/setting'
class Web::PushSubscription < ApplicationRecord
- include RoutingHelper
- include StreamEntriesHelper
- include ActionView::Helpers::TranslationHelper
- include ActionView::Helpers::SanitizeHelper
-
has_one :session_activation
- before_create :send_welcome_notification
-
def push(notification)
- name = display_name notification.from_account
- title = title_str(name, notification)
- body = body_str notification
- dir = dir_str body
- url = url_str notification
- image = image_str notification
- actions = actions_arr notification
-
- access_token = actions.empty? ? nil : find_or_create_access_token(notification).token
- nsfw = notification.target_status.nil? || notification.target_status.spoiler_text.empty? ? nil : notification.target_status.spoiler_text
-
- # TODO: Make sure that the payload does not exceed 4KB - Webpush::PayloadTooLarge
- Webpush.payload_send(
- message: JSON.generate(
- title: title,
- dir: dir,
- image: image,
- badge: full_asset_url('badge.png', skip_pipeline: true),
- tag: notification.id,
- timestamp: notification.created_at,
- icon: notification.from_account.avatar_static_url,
- data: {
- content: decoder.decode(strip_tags(body)),
- nsfw: nsfw.nil? ? nil : decoder.decode(strip_tags(nsfw)),
- url: url,
- actions: actions,
- access_token: access_token,
- message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
- }
- ),
- endpoint: endpoint,
- p256dh: key_p256dh,
- auth: key_auth,
- vapid: {
- subject: "mailto:#{Setting.site_contact_email}",
- private_key: Rails.configuration.x.vapid_private_key,
- public_key: Rails.configuration.x.vapid_public_key,
- },
- ttl: 40 * 60 * 60 # 48 hours
- )
+ I18n.with_locale(session_activation.user.locale || I18n.default_locale) do
+ push_payload(message_from(notification), 48.hours.seconds)
+ end
end
def pushable?(notification)
@@ -73,120 +28,47 @@ class Web::PushSubscription < ApplicationRecord
end
def as_payload
- payload = {
- id: id,
- endpoint: endpoint,
- }
-
+ payload = { id: id, endpoint: endpoint }
payload[:alerts] = data['alerts'] if data && data.key?('alerts')
-
payload
end
+ def access_token
+ find_or_create_access_token.token
+ end
+
private
- def title_str(name, notification)
- case notification.type
- when :mention then translate('push_notifications.mention.title', name: name)
- when :follow then translate('push_notifications.follow.title', name: name)
- when :favourite then translate('push_notifications.favourite.title', name: name)
- when :reblog then translate('push_notifications.reblog.title', name: name)
- end
- end
+ def push_payload(message, ttl = 5.minutes.seconds)
+ # TODO: Make sure that the payload does not
+ # exceed 4KB - Webpush::PayloadTooLarge
- def body_str(notification)
- case notification.type
- when :mention then notification.target_status.text
- when :follow then notification.from_account.note
- when :favourite then notification.target_status.text
- when :reblog then notification.target_status.text
- end
- end
-
- def url_str(notification)
- case notification.type
- when :mention then web_url("statuses/#{notification.target_status.id}")
- when :follow then web_url("accounts/#{notification.from_account.id}")
- when :favourite then web_url("statuses/#{notification.target_status.id}")
- when :reblog then web_url("statuses/#{notification.target_status.id}")
- end
- end
-
- def actions_arr(notification)
- actions =
- case notification.type
- when :mention then [
- {
- title: translate('push_notifications.mention.action_favourite'),
- icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
- todo: 'request',
- method: 'POST',
- action: "/api/v1/statuses/#{notification.target_status.id}/favourite",
- },
- ]
- else []
- end
-
- should_hide = notification.type.equal?(:mention) && !notification.target_status.nil? && (notification.target_status.sensitive || !notification.target_status.spoiler_text.empty?)
- can_boost = notification.type.equal?(:mention) && !notification.target_status.nil? && !notification.target_status.hidden?
-
- if should_hide
- actions.insert(0, title: translate('push_notifications.mention.action_expand'), icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true), todo: 'expand', action: 'expand')
- end
-
- if can_boost
- actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true), todo: 'request', method: 'POST', action: "/api/v1/statuses/#{notification.target_status.id}/reblog" }
- end
-
- actions
- end
-
- def image_str(notification)
- return nil if notification.target_status.nil? || notification.target_status.media_attachments.empty?
-
- full_asset_url(notification.target_status.media_attachments.first.file.url(:small))
- end
-
- def dir_str(body)
- rtl?(body) ? 'rtl' : 'ltr'
- end
-
- def send_welcome_notification
Webpush.payload_send(
- message: JSON.generate(
- title: translate('push_notifications.subscribed.title'),
- icon: full_asset_url('android-chrome-192x192.png', skip_pipeline: true),
- badge: full_asset_url('badge.png', skip_pipeline: true),
- data: {
- content: translate('push_notifications.subscribed.body'),
- actions: [],
- url: web_url('notifications'),
- message: translate('push_notifications.group.title'), # Do not pass count, will be formatted in the ServiceWorker
- }
- ),
+ message: Oj.dump(message),
endpoint: endpoint,
p256dh: key_p256dh,
auth: key_auth,
+ ttl: ttl,
vapid: {
- subject: "mailto:#{Setting.site_contact_email}",
+ subject: "mailto:#{::Setting.site_contact_email}",
private_key: Rails.configuration.x.vapid_private_key,
public_key: Rails.configuration.x.vapid_public_key,
- },
- ttl: 5 * 60 # 5 minutes
+ }
)
end
- def find_or_create_access_token(notification)
+ def message_from(notification)
+ serializable_resource = ActiveModelSerializers::SerializableResource.new(notification, serializer: Web::NotificationSerializer, scope: self, scope_name: :current_push_subscription)
+ serializable_resource.as_json
+ end
+
+ def find_or_create_access_token
Doorkeeper::AccessToken.find_or_create_for(
Doorkeeper::Application.find_by(superapp: true),
- notification.account.user.id,
+ session_activation.user_id,
Doorkeeper::OAuth::Scopes.from_string('read write follow'),
Doorkeeper.configuration.access_token_expires_in,
Doorkeeper.configuration.refresh_token_enabled?
)
end
-
- def decoder
- @decoder ||= HTMLEntities.new
- end
end
diff --git a/app/presenters/account_relationships_presenter.rb b/app/presenters/account_relationships_presenter.rb
index 657807863..a30558bac 100644
--- a/app/presenters/account_relationships_presenter.rb
+++ b/app/presenters/account_relationships_presenter.rb
@@ -4,12 +4,12 @@ class AccountRelationshipsPresenter
attr_reader :following, :followed_by, :blocking,
:muting, :requested, :domain_blocking
- def initialize(account_ids, current_account_id)
- @following = Account.following_map(account_ids, current_account_id)
- @followed_by = Account.followed_by_map(account_ids, current_account_id)
- @blocking = Account.blocking_map(account_ids, current_account_id)
- @muting = Account.muting_map(account_ids, current_account_id)
- @requested = Account.requested_map(account_ids, current_account_id)
- @domain_blocking = Account.domain_blocking_map(account_ids, current_account_id)
+ def initialize(account_ids, current_account_id, options = {})
+ @following = Account.following_map(account_ids, current_account_id).merge(options[:following_map] || {})
+ @followed_by = Account.followed_by_map(account_ids, current_account_id).merge(options[:followed_by_map] || {})
+ @blocking = Account.blocking_map(account_ids, current_account_id).merge(options[:blocking_map] || {})
+ @muting = Account.muting_map(account_ids, current_account_id).merge(options[:muting_map] || {})
+ @requested = Account.requested_map(account_ids, current_account_id).merge(options[:requested_map] || {})
+ @domain_blocking = Account.domain_blocking_map(account_ids, current_account_id).merge(options[:domain_blocking_map] || {})
end
end
diff --git a/app/presenters/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb
index 9507aad4a..70c496be8 100644
--- a/app/presenters/initial_state_presenter.rb
+++ b/app/presenters/initial_state_presenter.rb
@@ -1,5 +1,6 @@
# frozen_string_literal: true
class InitialStatePresenter < ActiveModelSerializers::Model
- attributes :settings, :push_subscription, :token, :current_account, :admin
+ attributes :settings, :push_subscription, :token,
+ :current_account, :admin, :text
end
diff --git a/app/presenters/instance_presenter.rb b/app/presenters/instance_presenter.rb
index 5d5be58ba..8104b7531 100644
--- a/app/presenters/instance_presenter.rb
+++ b/app/presenters/instance_presenter.rb
@@ -31,4 +31,8 @@ class InstancePresenter
def version_number
Mastodon::Version
end
+
+ def source_url
+ Mastodon::Version.source_url
+ end
end
diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb
index 03294015f..10b449504 100644
--- a/app/presenters/status_relationships_presenter.rb
+++ b/app/presenters/status_relationships_presenter.rb
@@ -1,19 +1,24 @@
# frozen_string_literal: true
class StatusRelationshipsPresenter
- attr_reader :reblogs_map, :favourites_map, :mutes_map
+ attr_reader :reblogs_map, :favourites_map, :mutes_map, :pins_map
- def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {})
+ def initialize(statuses, current_account_id = nil, options = {})
if current_account_id.nil?
@reblogs_map = {}
@favourites_map = {}
@mutes_map = {}
+ @pins_map = {}
else
- status_ids = statuses.compact.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
- conversation_ids = statuses.compact.map(&:conversation_id).compact.uniq
- @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(reblogs_map)
- @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(favourites_map)
- @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(mutes_map)
+ statuses = statuses.compact
+ status_ids = statuses.flat_map { |s| [s.id, s.reblog_of_id] }.uniq
+ conversation_ids = statuses.map(&:conversation_id).compact.uniq
+ pinnable_status_ids = statuses.map(&:proper).select { |s| s.account_id == current_account_id && %w(public unlisted).include?(s.visibility) }.map(&:id)
+
+ @reblogs_map = Status.reblogs_map(status_ids, current_account_id).merge(options[:reblogs_map] || {})
+ @favourites_map = Status.favourites_map(status_ids, current_account_id).merge(options[:favourites_map] || {})
+ @mutes_map = Status.mutes_map(conversation_ids, current_account_id).merge(options[:mutes_map] || {})
+ @pins_map = Status.pins_map(pinnable_status_ids, current_account_id).merge(options[:pins_map] || {})
end
end
end
diff --git a/app/serializers/activitypub/accept_follow_serializer.rb b/app/serializers/activitypub/accept_follow_serializer.rb
index ce900bc78..3e23591a5 100644
--- a/app/serializers/activitypub/accept_follow_serializer.rb
+++ b/app/serializers/activitypub/accept_follow_serializer.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ActivityPub::AcceptFollowSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::FollowSerializer
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.target_account), '#accepts/follows/', object.id].join
+ end
+
def type
'Accept'
end
diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb
index 69e2160c5..349495e84 100644
--- a/app/serializers/activitypub/activity_serializer.rb
+++ b/app/serializers/activitypub/activity_serializer.rb
@@ -6,11 +6,11 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer
def id
- [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join
+ [ActivityPub::TagManager.instance.activity_uri_for(object)].join
end
def type
- object.reblog? ? 'Announce' : 'Create'
+ announce? ? 'Announce' : 'Create'
end
def actor
@@ -24,4 +24,8 @@ class ActivityPub::ActivitySerializer < ActiveModel::Serializer
def cc
ActivityPub::TagManager.instance.cc(object)
end
+
+ def announce?
+ object.reblog?
+ end
end
diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb
index f5e626d73..a11178f5b 100644
--- a/app/serializers/activitypub/actor_serializer.rb
+++ b/app/serializers/activitypub/actor_serializer.rb
@@ -4,11 +4,41 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :id, :type, :following, :followers,
- :inbox, :outbox, :preferred_username,
- :name, :summary, :icon, :image
+ :inbox, :outbox,
+ :preferred_username, :name, :summary,
+ :url, :manually_approves_followers
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
+ class ImageSerializer < ActiveModel::Serializer
+ include RoutingHelper
+
+ attributes :type, :url
+
+ def type
+ 'Image'
+ end
+
+ def url
+ full_asset_url(object.url(:original))
+ end
+ end
+
+ class EndpointsSerializer < ActiveModel::Serializer
+ include RoutingHelper
+
+ attributes :shared_inbox
+
+ def shared_inbox
+ inbox_url
+ end
+ end
+
+ has_one :endpoints, serializer: EndpointsSerializer
+
+ has_one :icon, serializer: ImageSerializer, if: :avatar_exists?
+ has_one :image, serializer: ImageSerializer, if: :header_exists?
+
def id
account_url(object)
end
@@ -26,13 +56,17 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
end
def inbox
- nil
+ account_inbox_url(object)
end
def outbox
account_outbox_url(object)
end
+ def endpoints
+ object
+ end
+
def preferred_username
object.username
end
@@ -46,14 +80,30 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer
end
def icon
- full_asset_url(object.avatar.url(:original))
+ object.avatar
end
def image
- full_asset_url(object.header.url(:original))
+ object.header
end
def public_key
object
end
+
+ def url
+ short_account_url(object)
+ end
+
+ def avatar_exists?
+ object.avatar.exists?
+ end
+
+ def header_exists?
+ object.header.exists?
+ end
+
+ def manually_approves_followers
+ object.locked
+ end
end
diff --git a/app/serializers/activitypub/block_serializer.rb b/app/serializers/activitypub/block_serializer.rb
index a001b213b..b3bd9f868 100644
--- a/app/serializers/activitypub/block_serializer.rb
+++ b/app/serializers/activitypub/block_serializer.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
class ActivityPub::BlockSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
attribute :virtual_object, key: :object
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id].join
+ end
+
def type
'Block'
end
diff --git a/app/serializers/activitypub/delete_serializer.rb b/app/serializers/activitypub/delete_serializer.rb
index 77098b1b0..87a43b95d 100644
--- a/app/serializers/activitypub/delete_serializer.rb
+++ b/app/serializers/activitypub/delete_serializer.rb
@@ -1,8 +1,29 @@
# frozen_string_literal: true
class ActivityPub::DeleteSerializer < ActiveModel::Serializer
- attributes :type, :actor
- attribute :virtual_object, key: :object
+ class TombstoneSerializer < ActiveModel::Serializer
+ attributes :id, :type, :atom_uri
+
+ def id
+ ActivityPub::TagManager.instance.uri_for(object)
+ end
+
+ def type
+ 'Tombstone'
+ end
+
+ def atom_uri
+ ::TagManager.instance.uri_for(object)
+ end
+ end
+
+ attributes :id, :type, :actor
+
+ has_one :object, serializer: TombstoneSerializer
+
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object), '#delete'].join
+ end
def type
'Delete'
@@ -11,8 +32,4 @@ class ActivityPub::DeleteSerializer < ActiveModel::Serializer
def actor
ActivityPub::TagManager.instance.uri_for(object.account)
end
-
- def virtual_object
- ActivityPub::TagManager.instance.uri_for(object)
- end
end
diff --git a/app/serializers/activitypub/follow_serializer.rb b/app/serializers/activitypub/follow_serializer.rb
index 1953a2d7b..86c9992fe 100644
--- a/app/serializers/activitypub/follow_serializer.rb
+++ b/app/serializers/activitypub/follow_serializer.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
class ActivityPub::FollowSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
attribute :virtual_object, key: :object
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id].join
+ end
+
def type
'Follow'
end
diff --git a/app/serializers/activitypub/like_serializer.rb b/app/serializers/activitypub/like_serializer.rb
index 4226913f5..c1a7ff6f6 100644
--- a/app/serializers/activitypub/like_serializer.rb
+++ b/app/serializers/activitypub/like_serializer.rb
@@ -1,9 +1,13 @@
# frozen_string_literal: true
class ActivityPub::LikeSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
attribute :virtual_object, key: :object
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id].join
+ end
+
def type
'Like'
end
diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb
index 4c13f8e59..d42f54263 100644
--- a/app/serializers/activitypub/note_serializer.rb
+++ b/app/serializers/activitypub/note_serializer.rb
@@ -3,7 +3,9 @@
class ActivityPub::NoteSerializer < ActiveModel::Serializer
attributes :id, :type, :summary, :content,
:in_reply_to, :published, :url,
- :attributed_to, :to, :cc, :sensitive
+ :attributed_to, :to, :cc, :sensitive,
+ :atom_uri, :in_reply_to_atom_uri,
+ :conversation
has_many :media_attachments, key: :attachment
has_many :virtual_tags, key: :tag
@@ -25,7 +27,13 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
end
def in_reply_to
- ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply?
+ return unless object.reply?
+
+ if object.thread.uri.nil? || object.thread.uri.start_with?('http')
+ ActivityPub::TagManager.instance.uri_for(object.thread)
+ else
+ object.thread.url
+ end
end
def published
@@ -52,6 +60,30 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
object.mentions + object.tags
end
+ def atom_uri
+ return unless object.local?
+
+ ::TagManager.instance.uri_for(object)
+ end
+
+ def in_reply_to_atom_uri
+ return unless object.reply?
+
+ ::TagManager.instance.uri_for(object.thread)
+ end
+
+ def conversation
+ if object.conversation.uri?
+ object.conversation.uri
+ else
+ TagManager.instance.unique_tag(object.conversation.created_at, object.conversation.id, 'Conversation')
+ end
+ end
+
+ def local?
+ object.account.local?
+ end
+
class MediaAttachmentSerializer < ActiveModel::Serializer
include RoutingHelper
diff --git a/app/serializers/activitypub/reject_follow_serializer.rb b/app/serializers/activitypub/reject_follow_serializer.rb
index 28584d627..7814f4f57 100644
--- a/app/serializers/activitypub/reject_follow_serializer.rb
+++ b/app/serializers/activitypub/reject_follow_serializer.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ActivityPub::RejectFollowSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::FollowSerializer
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.target_account), '#rejects/follows/', object.id].join
+ end
+
def type
'Reject'
end
diff --git a/app/serializers/activitypub/undo_announce_serializer.rb b/app/serializers/activitypub/undo_announce_serializer.rb
new file mode 100644
index 000000000..839847e22
--- /dev/null
+++ b/app/serializers/activitypub/undo_announce_serializer.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class ActivityPub::UndoAnnounceSerializer < ActiveModel::Serializer
+ attributes :id, :type, :actor
+
+ has_one :object, serializer: ActivityPub::ActivitySerializer
+
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#announces/', object.id, '/undo'].join
+ end
+
+ def type
+ 'Undo'
+ end
+
+ def actor
+ ActivityPub::TagManager.instance.uri_for(object.account)
+ end
+end
diff --git a/app/serializers/activitypub/undo_block_serializer.rb b/app/serializers/activitypub/undo_block_serializer.rb
index f71faa729..2f43d8402 100644
--- a/app/serializers/activitypub/undo_block_serializer.rb
+++ b/app/serializers/activitypub/undo_block_serializer.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ActivityPub::UndoBlockSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::BlockSerializer
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#blocks/', object.id, '/undo'].join
+ end
+
def type
'Undo'
end
diff --git a/app/serializers/activitypub/undo_follow_serializer.rb b/app/serializers/activitypub/undo_follow_serializer.rb
index fe91f5f1c..e5b7f143d 100644
--- a/app/serializers/activitypub/undo_follow_serializer.rb
+++ b/app/serializers/activitypub/undo_follow_serializer.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ActivityPub::UndoFollowSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::FollowSerializer
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#follows/', object.id, '/undo'].join
+ end
+
def type
'Undo'
end
diff --git a/app/serializers/activitypub/undo_like_serializer.rb b/app/serializers/activitypub/undo_like_serializer.rb
index db9cd1d0d..25f4ccaae 100644
--- a/app/serializers/activitypub/undo_like_serializer.rb
+++ b/app/serializers/activitypub/undo_like_serializer.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ActivityPub::UndoLikeSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::LikeSerializer
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object.account), '#likes/', object.id, '/undo'].join
+ end
+
def type
'Undo'
end
diff --git a/app/serializers/activitypub/update_serializer.rb b/app/serializers/activitypub/update_serializer.rb
index 322305da8..ebc667d96 100644
--- a/app/serializers/activitypub/update_serializer.rb
+++ b/app/serializers/activitypub/update_serializer.rb
@@ -1,10 +1,14 @@
# frozen_string_literal: true
class ActivityPub::UpdateSerializer < ActiveModel::Serializer
- attributes :type, :actor
+ attributes :id, :type, :actor
has_one :object, serializer: ActivityPub::ActorSerializer
+ def id
+ [ActivityPub::TagManager.instance.uri_for(object), '#updates/', object.updated_at.to_i].join
+ end
+
def type
'Update'
end
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 0191948b1..32ffcc688 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -19,7 +19,6 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:boost_modal] = object.current_account.user.setting_boost_modal
store[:delete_modal] = object.current_account.user.setting_delete_modal
store[:auto_play_gif] = object.current_account.user.setting_auto_play_gif
- store[:system_font_ui] = object.current_account.user.setting_system_font_ui
end
store
@@ -34,6 +33,8 @@ class InitialStateSerializer < ActiveModel::Serializer
store[:default_sensitive] = object.current_account.user.setting_default_sensitive
end
+ store[:text] = object.text if object.text
+
store
end
diff --git a/app/serializers/oembed_serializer.rb b/app/serializers/oembed_serializer.rb
index 78376d253..af03fd47a 100644
--- a/app/serializers/oembed_serializer.rb
+++ b/app/serializers/oembed_serializer.rb
@@ -21,7 +21,7 @@ class OEmbedSerializer < ActiveModel::Serializer
end
def author_url
- account_url(object.account)
+ short_account_url(object.account)
end
def provider_name
@@ -37,13 +37,15 @@ class OEmbedSerializer < ActiveModel::Serializer
end
def html
- tag :iframe,
- src: embed_account_stream_entry_url(object.account, object),
- style: 'width: 100%; overflow: hidden',
- frameborder: '0',
- scrolling: 'no',
- width: width,
- height: height
+ attributes = {
+ src: embed_short_account_status_url(object.account, object),
+ class: 'mastodon-embed',
+ style: 'max-width: 100%; border: 0',
+ width: width,
+ height: height,
+ }
+
+ content_tag(:iframe, nil, attributes) + content_tag(:script, nil, src: full_asset_url('embed.js'), async: true)
end
def width
diff --git a/app/serializers/rest/instance_serializer.rb b/app/serializers/rest/instance_serializer.rb
index 8e32f9cb3..a97137909 100644
--- a/app/serializers/rest/instance_serializer.rb
+++ b/app/serializers/rest/instance_serializer.rb
@@ -2,7 +2,7 @@
class REST::InstanceSerializer < ActiveModel::Serializer
attributes :uri, :title, :description, :email,
- :version, :urls
+ :version, :urls, :stats
def uri
Rails.configuration.x.local_domain
@@ -24,7 +24,21 @@ class REST::InstanceSerializer < ActiveModel::Serializer
Mastodon::Version.to_s
end
+ def stats
+ {
+ user_count: instance_presenter.user_count,
+ status_count: instance_presenter.status_count,
+ domain_count: instance_presenter.domain_count,
+ }
+ end
+
def urls
{ streaming_api: Rails.configuration.x.streaming_api_base_url }
end
+
+ private
+
+ def instance_presenter
+ @instance_presenter ||= InstancePresenter.new
+ end
end
diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb
index 246b12a90..298a3bb40 100644
--- a/app/serializers/rest/status_serializer.rb
+++ b/app/serializers/rest/status_serializer.rb
@@ -8,6 +8,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :favourited, if: :current_user?
attribute :reblogged, if: :current_user?
attribute :muted, if: :current_user?
+ attribute :pinned, if: :pinnable?
belongs_to :reblog, serializer: REST::StatusSerializer
belongs_to :application
@@ -57,6 +58,21 @@ class REST::StatusSerializer < ActiveModel::Serializer
end
end
+ def pinned
+ if instance_options && instance_options[:relationships]
+ instance_options[:relationships].pins_map[object.id] || false
+ else
+ current_user.account.pinned?(object)
+ end
+ end
+
+ def pinnable?
+ current_user? &&
+ current_user.account_id == object.account_id &&
+ !object.reblog? &&
+ %w(public unlisted).include?(object.visibility)
+ end
+
class ApplicationSerializer < ActiveModel::Serializer
attributes :name, :website
end
diff --git a/app/serializers/web/notification_serializer.rb b/app/serializers/web/notification_serializer.rb
new file mode 100644
index 000000000..e5524fe7a
--- /dev/null
+++ b/app/serializers/web/notification_serializer.rb
@@ -0,0 +1,169 @@
+# frozen_string_literal: true
+
+class Web::NotificationSerializer < ActiveModel::Serializer
+ include RoutingHelper
+ include StreamEntriesHelper
+
+ class DataSerializer < ActiveModel::Serializer
+ include RoutingHelper
+ include StreamEntriesHelper
+ include ActionView::Helpers::SanitizeHelper
+
+ attributes :content, :nsfw, :url, :actions,
+ :access_token, :message, :dir
+
+ def content
+ decoder.decode(strip_tags(body))
+ end
+
+ def dir
+ rtl?(body) ? 'rtl' : 'ltr'
+ end
+
+ def nsfw
+ return if object.target_status.nil?
+ object.target_status.spoiler_text.presence
+ end
+
+ def url
+ case object.type
+ when :mention
+ web_url("statuses/#{object.target_status.id}")
+ when :follow
+ web_url("accounts/#{object.from_account.id}")
+ when :favourite
+ web_url("statuses/#{object.target_status.id}")
+ when :reblog
+ web_url("statuses/#{object.target_status.id}")
+ end
+ end
+
+ def actions
+ return @actions if defined?(@actions)
+
+ @actions = []
+
+ if object.type == :mention
+ @actions << expand_action if collapsed?
+ @actions << favourite_action
+ @actions << reblog_action if rebloggable?
+ end
+
+ @actions
+ end
+
+ def access_token
+ return if actions.empty?
+ current_push_subscription.access_token
+ end
+
+ def message
+ I18n.t('push_notifications.group.title')
+ end
+
+ private
+
+ def body
+ case object.type
+ when :mention
+ object.target_status.text
+ when :follow
+ object.from_account.note
+ when :favourite
+ object.target_status.text
+ when :reblog
+ object.target_status.text
+ end
+ end
+
+ def decoder
+ @decoder ||= HTMLEntities.new
+ end
+
+ def expand_action
+ {
+ title: I18n.t('push_notifications.mention.action_expand'),
+ icon: full_asset_url('web-push-icon_expand.png', skip_pipeline: true),
+ todo: 'expand',
+ action: 'expand',
+ }
+ end
+
+ def favourite_action
+ {
+ title: I18n.t('push_notifications.mention.action_favourite'),
+ icon: full_asset_url('web-push-icon_favourite.png', skip_pipeline: true),
+ todo: 'request',
+ method: 'POST',
+ action: "/api/v1/statuses/#{object.target_status.id}/favourite",
+ }
+ end
+
+ def reblog_action
+ {
+ title: I18n.t('push_notifications.mention.action_boost'),
+ icon: full_asset_url('web-push-icon_reblog.png', skip_pipeline: true),
+ todo: 'request',
+ method: 'POST',
+ action: "/api/v1/statuses/#{object.target_status.id}/reblog",
+ }
+ end
+
+ def collapsed?
+ !object.target_status.nil? && (object.target_status.sensitive? || object.target_status.spoiler_text.present?)
+ end
+
+ def rebloggable?
+ !object.target_status.nil? && !object.target_status.hidden?
+ end
+ end
+
+ attributes :title, :image, :badge, :tag,
+ :timestamp, :icon
+
+ has_one :data, serializer: DataSerializer
+
+ def title
+ case object.type
+ when :mention
+ I18n.t('push_notifications.mention.title', name: name)
+ when :follow
+ I18n.t('push_notifications.follow.title', name: name)
+ when :favourite
+ I18n.t('push_notifications.favourite.title', name: name)
+ when :reblog
+ I18n.t('push_notifications.reblog.title', name: name)
+ end
+ end
+
+ def image
+ return if object.target_status.nil? || object.target_status.media_attachments.empty?
+ full_asset_url(object.target_status.media_attachments.first.file.url(:small))
+ end
+
+ def badge
+ full_asset_url('badge.png', skip_pipeline: true)
+ end
+
+ def tag
+ object.id
+ end
+
+ def timestamp
+ object.created_at
+ end
+
+ def icon
+ object.from_account.avatar_static_url
+ end
+
+ def data
+ object
+ end
+
+ private
+
+ def name
+ display_name(object.from_account)
+ end
+end
diff --git a/app/services/activitypub/fetch_remote_account_service.rb b/app/services/activitypub/fetch_remote_account_service.rb
new file mode 100644
index 000000000..3eeca585e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_account_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteAccountService < BaseService
+ include JsonLdHelper
+
+ # Should be called when uri has already been checked for locality
+ # Does a WebFinger roundtrip on each call
+ def call(uri, prefetched_json = nil)
+ @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+ return unless supported_context? && expected_type?
+
+ @uri = @json['id']
+ @username = @json['preferredUsername']
+ @domain = Addressable::URI.parse(uri).normalized_host
+
+ return unless verified_webfinger?
+
+ ActivityPub::ProcessAccountService.new.call(@username, @domain, @json)
+ rescue Oj::ParseError
+ nil
+ end
+
+ private
+
+ def verified_webfinger?
+ webfinger = Goldfinger.finger("acct:#{@username}@#{@domain}")
+ confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+
+ return true if @username.casecmp(confirmed_username).zero? && @domain.casecmp(confirmed_domain).zero?
+
+ webfinger = Goldfinger.finger("acct:#{confirmed_username}@#{confirmed_domain}")
+ confirmed_username, confirmed_domain = split_acct(webfinger.subject)
+ self_reference = webfinger.link('self')
+
+ return false if self_reference&.href != @uri
+
+ @username = confirmed_username
+ @domain = confirmed_domain
+
+ true
+ rescue Goldfinger::Error
+ false
+ end
+
+ def split_acct(acct)
+ acct.gsub(/\Aacct:/, '').split('@')
+ end
+
+ def supported_context?
+ super(@json)
+ end
+
+ def expected_type?
+ @json['type'] == 'Person'
+ end
+end
diff --git a/app/services/activitypub/fetch_remote_key_service.rb b/app/services/activitypub/fetch_remote_key_service.rb
new file mode 100644
index 000000000..ebd64071e
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_key_service.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteKeyService < BaseService
+ include JsonLdHelper
+
+ # Returns account that owns the key
+ def call(uri, prefetched_json = nil)
+ @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+ return unless supported_context?(@json) && expected_type?
+ return find_account(uri, @json) if person?
+
+ @owner = fetch_resource(owner_uri)
+
+ return unless supported_context?(@owner) && confirmed_owner?
+
+ find_account(owner_uri, @owner)
+ end
+
+ private
+
+ def find_account(uri, prefetched_json)
+ account = ActivityPub::TagManager.instance.uri_to_resource(uri, Account)
+ account ||= ActivityPub::FetchRemoteAccountService.new.call(uri, prefetched_json)
+ account
+ end
+
+ def expected_type?
+ person? || public_key?
+ end
+
+ def person?
+ @json['type'] == 'Person'
+ end
+
+ def public_key?
+ @json['publicKeyPem'].present? && @json['owner'].present?
+ end
+
+ def owner_uri
+ @owner_uri ||= value_or_id(@json['owner'])
+ end
+
+ def confirmed_owner?
+ @owner['type'] == 'Person' && value_or_id(@owner['publicKey']) == @json['id']
+ end
+end
diff --git a/app/services/activitypub/fetch_remote_status_service.rb b/app/services/activitypub/fetch_remote_status_service.rb
new file mode 100644
index 000000000..68ca58d62
--- /dev/null
+++ b/app/services/activitypub/fetch_remote_status_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::FetchRemoteStatusService < BaseService
+ include JsonLdHelper
+
+ # Should be called when uri has already been checked for locality
+ def call(uri, prefetched_json = nil)
+ @json = body_to_json(prefetched_json) || fetch_resource(uri)
+
+ return unless supported_context?
+
+ activity = activity_json
+ actor_id = value_or_id(activity['actor'])
+
+ return unless expected_type?(activity) && trustworthy_attribution?(uri, actor_id)
+
+ actor = ActivityPub::TagManager.instance.uri_to_resource(actor_id, Account)
+ actor = ActivityPub::FetchRemoteAccountService.new.call(actor_id) if actor.nil?
+
+ ActivityPub::Activity.factory(activity, actor).perform
+ end
+
+ private
+
+ def activity_json
+ if %w(Note Article).include? @json['type']
+ {
+ 'type' => 'Create',
+ 'actor' => first_of_value(@json['attributedTo']),
+ 'object' => @json,
+ }
+ else
+ @json
+ end
+ end
+
+ def trustworthy_attribution?(uri, attributed_to)
+ Addressable::URI.parse(uri).normalized_host.casecmp(Addressable::URI.parse(attributed_to).normalized_host).zero?
+ end
+
+ def supported_context?
+ super(@json)
+ end
+
+ def expected_type?(json)
+ %w(Create Announce).include? json['type']
+ end
+end
diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb
new file mode 100644
index 000000000..b54e447ad
--- /dev/null
+++ b/app/services/activitypub/process_account_service.rb
@@ -0,0 +1,134 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessAccountService < BaseService
+ include JsonLdHelper
+
+ # Should be called with confirmed valid JSON
+ # and WebFinger-resolved username and domain
+ def call(username, domain, json)
+ return if json['inbox'].blank?
+
+ @json = json
+ @uri = @json['id']
+ @username = username
+ @domain = domain
+ @account = Account.find_by(uri: @uri)
+ @collections = {}
+
+ create_account if @account.nil?
+ upgrade_account if @account.ostatus?
+ update_account
+
+ @account
+ rescue Oj::ParseError
+ nil
+ end
+
+ private
+
+ def create_account
+ @account = Account.new
+ @account.protocol = :activitypub
+ @account.username = @username
+ @account.domain = @domain
+ @account.uri = @uri
+ @account.suspended = true if auto_suspend?
+ @account.silenced = true if auto_silence?
+ @account.private_key = nil
+ @account.save!
+ end
+
+ def update_account
+ @account.last_webfingered_at = Time.now.utc
+ @account.protocol = :activitypub
+ @account.inbox_url = @json['inbox'] || ''
+ @account.outbox_url = @json['outbox'] || ''
+ @account.shared_inbox_url = (@json['endpoints'].is_a?(Hash) ? @json['endpoints']['sharedInbox'] : @json['sharedInbox']) || ''
+ @account.followers_url = @json['followers'] || ''
+ @account.url = url || @uri
+ @account.display_name = @json['name'] || ''
+ @account.note = @json['summary'] || ''
+ @account.avatar_remote_url = image_url('icon') unless skip_download?
+ @account.header_remote_url = image_url('image') unless skip_download?
+ @account.public_key = public_key || ''
+ @account.locked = @json['manuallyApprovesFollowers'] || false
+ @account.statuses_count = outbox_total_items if outbox_total_items.present?
+ @account.following_count = following_total_items if following_total_items.present?
+ @account.followers_count = followers_total_items if followers_total_items.present?
+ @account.save_with_optional_media!
+ end
+
+ def upgrade_account
+ ActivityPub::PostUpgradeWorker.perform_async(@account.domain)
+ end
+
+ def image_url(key)
+ value = first_of_value(@json[key])
+
+ return if value.nil?
+ return value['url'] if value.is_a?(Hash)
+
+ image = fetch_resource(value)
+ image['url'] if image
+ end
+
+ def public_key
+ value = first_of_value(@json['publicKey'])
+
+ return if value.nil?
+ return value['publicKeyPem'] if value.is_a?(Hash)
+
+ key = fetch_resource(value)
+ key['publicKeyPem'] if key
+ end
+
+ def url
+ return if @json['url'].blank?
+
+ value = first_of_value(@json['url'])
+
+ return value if value.is_a?(String)
+
+ value['href']
+ end
+
+ def outbox_total_items
+ collection_total_items('outbox')
+ end
+
+ def following_total_items
+ collection_total_items('following')
+ end
+
+ def followers_total_items
+ collection_total_items('followers')
+ end
+
+ def collection_total_items(type)
+ return if @json[type].blank?
+ return @collections[type] if @collections.key?(type)
+
+ collection = fetch_resource(@json[type])
+
+ @collections[type] = collection.is_a?(Hash) && collection['totalItems'].present? && collection['totalItems'].is_a?(Numeric) ? collection['totalItems'] : nil
+ rescue HTTP::Error, OpenSSL::SSL::SSLError
+ @collections[type] = nil
+ end
+
+ def skip_download?
+ @account.suspended? || domain_block&.reject_media?
+ end
+
+ def auto_suspend?
+ domain_block && domain_block.suspend?
+ end
+
+ def auto_silence?
+ domain_block && domain_block.silence?
+ end
+
+ def domain_block
+ return @domain_block if defined?(@domain_block)
+ @domain_block = DomainBlock.find_by(domain: @domain)
+ end
+end
diff --git a/app/services/activitypub/process_collection_service.rb b/app/services/activitypub/process_collection_service.rb
new file mode 100644
index 000000000..bc04c50ba
--- /dev/null
+++ b/app/services/activitypub/process_collection_service.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+class ActivityPub::ProcessCollectionService < BaseService
+ include JsonLdHelper
+
+ def call(body, account)
+ @account = account
+ @json = Oj.load(body, mode: :strict)
+
+ return if @account.suspended? || !supported_context?
+
+ return if different_actor? && verify_account!.nil?
+
+ case @json['type']
+ when 'Collection', 'CollectionPage'
+ process_items @json['items']
+ when 'OrderedCollection', 'OrderedCollectionPage'
+ process_items @json['orderedItems']
+ else
+ process_items [@json]
+ end
+ rescue Oj::ParseError
+ nil
+ end
+
+ private
+
+ def different_actor?
+ @json['actor'].present? && value_or_id(@json['actor']) != @account.uri && @json['signature'].present?
+ end
+
+ def process_items(items)
+ items.reverse_each.map { |item| process_item(item) }.compact
+ end
+
+ def supported_context?
+ super(@json)
+ end
+
+ def process_item(item)
+ activity = ActivityPub::Activity.factory(item, @account)
+ activity&.perform
+ end
+
+ def verify_account!
+ @account = ActivityPub::LinkedDataSignature.new(@json).verify_account!
+ end
+end
diff --git a/app/services/authorize_follow_service.rb b/app/services/authorize_follow_service.rb
index 41815a393..b1bff8962 100644
--- a/app/services/authorize_follow_service.rb
+++ b/app/services/authorize_follow_service.rb
@@ -1,14 +1,36 @@
# frozen_string_literal: true
class AuthorizeFollowService < BaseService
- def call(source_account, target_account)
- follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
- follow_request.authorize!
- NotificationWorker.perform_async(build_xml(follow_request), target_account.id, source_account.id) unless source_account.local?
+ def call(source_account, target_account, options = {})
+ if options[:skip_follow_request]
+ follow_request = FollowRequest.new(account: source_account, target_account: target_account)
+ else
+ follow_request = FollowRequest.find_by!(account: source_account, target_account: target_account)
+ follow_request.authorize!
+ end
+
+ create_notification(follow_request) unless source_account.local?
+ follow_request
end
private
+ def create_notification(follow_request)
+ if follow_request.account.ostatus?
+ NotificationWorker.perform_async(build_xml(follow_request), follow_request.target_account_id, follow_request.account_id)
+ elsif follow_request.account.activitypub?
+ ActivityPub::DeliveryWorker.perform_async(build_json(follow_request), follow_request.target_account_id, follow_request.account.inbox_url)
+ end
+ end
+
+ def build_json(follow_request)
+ Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+ follow_request,
+ serializer: ActivityPub::AcceptFollowSerializer,
+ adapter: ActivityPub::Adapter
+ ).as_json).sign!(follow_request.target_account))
+ end
+
def build_xml(follow_request)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.authorize_follow_request_salmon(follow_request))
end
diff --git a/app/services/batched_remove_status_service.rb b/app/services/batched_remove_status_service.rb
index ab810c628..86eaa5735 100644
--- a/app/services/batched_remove_status_service.rb
+++ b/app/services/batched_remove_status_service.rb
@@ -15,19 +15,26 @@ class BatchedRemoveStatusService < BaseService
@mentions = statuses.map { |s| [s.id, s.mentions.includes(:account).to_a] }.to_h
@tags = statuses.map { |s| [s.id, s.tags.pluck(:name)] }.to_h
- @stream_entry_batches = []
- @salmon_batches = []
- @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+ @stream_entry_batches = []
+ @salmon_batches = []
+ @activity_json_batches = []
+ @json_payloads = statuses.map { |s| [s.id, Oj.dump(event: :delete, payload: s.id)] }.to_h
+ @activity_json = {}
+ @activity_xml = {}
# Ensure that rendered XML reflects destroyed state
- Status.where(id: statuses.map(&:id)).in_batches.destroy_all
+ statuses.each(&:destroy)
# Batch by source account
statuses.group_by(&:account_id).each do |_, account_statuses|
account = account_statuses.first.account
unpush_from_home_timelines(account_statuses)
- batch_stream_entries(account_statuses) if account.local?
+
+ if account.local?
+ batch_stream_entries(account, account_statuses)
+ batch_activity_json(account, account_statuses)
+ end
end
# Cannot be batched
@@ -36,17 +43,32 @@ class BatchedRemoveStatusService < BaseService
batch_salmon_slaps(status) if status.local?
end
- Pubsubhubbub::DistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
+ Pubsubhubbub::RawDistributionWorker.push_bulk(@stream_entry_batches) { |batch| batch }
NotificationWorker.push_bulk(@salmon_batches) { |batch| batch }
+ ActivityPub::DeliveryWorker.push_bulk(@activity_json_batches) { |batch| batch }
end
private
- def batch_stream_entries(statuses)
- stream_entry_ids = statuses.map { |s| s.stream_entry.id }
+ def batch_stream_entries(account, statuses)
+ statuses.each do |status|
+ @stream_entry_batches << [build_xml(status.stream_entry), account.id]
+ end
+ end
- stream_entry_ids.each_slice(100) do |batch_of_stream_entry_ids|
- @stream_entry_batches << [batch_of_stream_entry_ids]
+ def batch_activity_json(account, statuses)
+ account.followers.inboxes.each do |inbox_url|
+ statuses.each do |status|
+ @activity_json_batches << [build_json(status), account.id, inbox_url]
+ end
+ end
+
+ statuses.each do |status|
+ other_recipients = (status.mentions + status.reblogs).map(&:account).reject(&:local?).select(&:activitypub?).uniq(&:id)
+
+ other_recipients.each do |target_account|
+ @activity_json_batches << [build_json(status), account.id, target_account.inbox_url]
+ end
end
end
@@ -78,11 +100,10 @@ class BatchedRemoveStatusService < BaseService
def batch_salmon_slaps(status)
return if @mentions[status.id].empty?
- payload = stream_entry_to_xml(status.stream_entry.reload)
- recipients = @mentions[status.id].map(&:account).reject(&:local?).uniq(&:domain).map(&:id)
+ recipients = @mentions[status.id].map(&:account).reject(&:local?).select(&:ostatus?).uniq(&:domain).map(&:id)
recipients.each do |recipient_id|
- @salmon_batches << [payload, status.account_id, recipient_id]
+ @salmon_batches << [build_xml(status.stream_entry), status.account_id, recipient_id]
end
end
@@ -111,4 +132,24 @@ class BatchedRemoveStatusService < BaseService
def redis
Redis.current
end
+
+ def build_json(status)
+ return @activity_json[status.id] if @activity_json.key?(status.id)
+
+ @activity_json[status.id] = sign_json(status, ActiveModelSerializers::SerializableResource.new(
+ status,
+ serializer: status.reblog? ? ActivityPub::UndoAnnounceSerializer : ActivityPub::DeleteSerializer,
+ adapter: ActivityPub::Adapter
+ ).as_json)
+ end
+
+ def build_xml(stream_entry)
+ return @activity_xml[stream_entry.id] if @activity_xml.key?(stream_entry.id)
+
+ @activity_xml[stream_entry.id] = stream_entry_to_xml(stream_entry)
+ end
+
+ def sign_json(status, json)
+ Oj.dump(ActivityPub::LinkedDataSignature.new(json).sign!(status.account))
+ end
end
diff --git a/app/services/block_domain_service.rb b/app/services/block_domain_service.rb
index a6b3c4cdb..1473bc841 100644
--- a/app/services/block_domain_service.rb
+++ b/app/services/block_domain_service.rb
@@ -30,7 +30,7 @@ class BlockDomainService < BaseService
def suspend_accounts!
blocked_domain_accounts.where(suspended: false).find_each do |account|
- account.subscription(api_subscription_url(account.id)).unsubscribe if account.subscribed?
+ UnsubscribeService.new.call(account) if account.subscribed?
SuspendAccountService.new.call(account)
end
end
diff --git a/app/services/block_service.rb b/app/services/block_service.rb
index 5d7bf6a3b..b39c3eef2 100644
--- a/app/services/block_service.rb
+++ b/app/services/block_service.rb
@@ -12,11 +12,28 @@ class BlockService < BaseService
block = account.block!(target_account)
BlockWorker.perform_async(account.id, target_account.id)
- NotificationWorker.perform_async(build_xml(block), account.id, target_account.id) unless target_account.local?
+ create_notification(block) unless target_account.local?
+ block
end
private
+ def create_notification(block)
+ if block.target_account.ostatus?
+ NotificationWorker.perform_async(build_xml(block), block.account_id, block.target_account_id)
+ elsif block.target_account.activitypub?
+ ActivityPub::DeliveryWorker.perform_async(build_json(block), block.account_id, block.target_account.inbox_url)
+ end
+ end
+
+ def build_json(block)
+ Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+ block,
+ serializer: ActivityPub::BlockSerializer,
+ adapter: ActivityPub::Adapter
+ ).as_json).sign!(block.account))
+ end
+
def build_xml(block)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.block_salmon(block))
end
diff --git a/app/services/bootstrap_timeline_service.rb b/app/services/bootstrap_timeline_service.rb
new file mode 100644
index 000000000..c01e25824
--- /dev/null
+++ b/app/services/bootstrap_timeline_service.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class BootstrapTimelineService < BaseService
+ def call(source_account)
+ bootstrap_timeline_accounts.each do |target_account|
+ FollowService.new.call(source_account, target_account)
+ end
+ end
+
+ private
+
+ def bootstrap_timeline_accounts
+ return @bootstrap_timeline_accounts if defined?(@bootstrap_timeline_accounts)
+
+ @bootstrap_timeline_accounts = bootstrap_timeline_accounts_usernames.empty? ? admin_accounts : local_unlocked_accounts(bootstrap_timeline_accounts_usernames)
+ end
+
+ def bootstrap_timeline_accounts_usernames
+ @bootstrap_timeline_accounts_usernames ||= (Setting.bootstrap_timeline_accounts || '').split(',').map { |str| str.strip.gsub(/\A@/, '') }.reject(&:blank?)
+ end
+
+ def admin_accounts
+ User.admins
+ .includes(:account)
+ .where(accounts: { locked: false })
+ .map(&:account)
+ end
+
+ def local_unlocked_accounts(usernames)
+ Account.local
+ .where(username: usernames)
+ .where(locked: false)
+ end
+end
diff --git a/app/services/favourite_service.rb b/app/services/favourite_service.rb
index 291f9e56e..44df3ed13 100644
--- a/app/services/favourite_service.rb
+++ b/app/services/favourite_service.rb
@@ -15,18 +15,32 @@ class FavouriteService < BaseService
return favourite unless favourite.nil?
favourite = Favourite.create!(account: account, status: status)
-
- if status.local?
- NotifyService.new.call(favourite.status.account, favourite)
- else
- NotificationWorker.perform_async(build_xml(favourite), account.id, status.account_id)
- end
-
+ create_notification(favourite)
favourite
end
private
+ def create_notification(favourite)
+ status = favourite.status
+
+ if status.account.local?
+ NotifyService.new.call(status.account, favourite)
+ elsif status.account.ostatus?
+ NotificationWorker.perform_async(build_xml(favourite), favourite.account_id, status.account_id)
+ elsif status.account.activitypub?
+ ActivityPub::DeliveryWorker.perform_async(build_json(favourite), favourite.account_id, status.account.inbox_url)
+ end
+ end
+
+ def build_json(favourite)
+ Oj.dump(ActivityPub::LinkedDataSignature.new(ActiveModelSerializers::SerializableResource.new(
+ favourite,
+ serializer: ActivityPub::LikeSerializer,
+ adapter: ActivityPub::Adapter
+ ).as_json).sign!(favourite.account))
+ end
+
def build_xml(favourite)
OStatus::AtomSerializer.render(OStatus::AtomSerializer.new.favourite_salmon(favourite))
end
diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb
index 3ac441e3e..9c5777b5d 100644
--- a/app/services/fetch_atom_service.rb
+++ b/app/services/fetch_atom_service.rb
@@ -1,21 +1,17 @@
# frozen_string_literal: true
class FetchAtomService < BaseService
+ include JsonLdHelper
+
def call(url)
return if url.blank?
- response = Request.new(:head, url).perform
+ result = process(url)
- Rails.logger.debug "Remote status HEAD request returned code #{response.code}"
+ # retry without ActivityPub
+ result ||= process(url) if @unsupported_activity
- response = Request.new(:get, url).perform if response.code == 405
-
- Rails.logger.debug "Remote status GET request returned code #{response.code}"
-
- return nil if response.code != 200
- return [url, fetch(url)] if response.mime_type == 'application/atom+xml'
- return process_headers(url, response) if response['Link'].present?
- process_html(fetch(url))
+ result
rescue OpenSSL::SSL::SSLError => e
Rails.logger.debug "SSL error: #{e}"
nil
@@ -26,27 +22,67 @@ class FetchAtomService < BaseService
private
- def process_html(body)
- Rails.logger.debug 'Processing HTML'
-
- page = Nokogiri::HTML(body)
- alternate_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
-
- return nil if alternate_link.nil?
- [alternate_link['href'], fetch(alternate_link['href'])]
+ def process(url, terminal = false)
+ @url = url
+ perform_request
+ process_response(terminal)
end
- def process_headers(url, response)
- Rails.logger.debug 'Processing link header'
+ def perform_request
+ accept = 'text/html'
+ accept = 'application/activity+json, application/ld+json, application/atom+xml, ' + accept unless @unsupported_activity
- link_header = LinkHeader.parse(response['Link'].is_a?(Array) ? response['Link'].first : response['Link'])
- alternate_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
-
- return process_html(fetch(url)) if alternate_link.nil?
- [alternate_link.href, fetch(alternate_link.href)]
+ @response = Request.new(:get, @url)
+ .add_headers('Accept' => accept)
+ .perform
end
- def fetch(url)
- Request.new(:get, url).perform.to_s
+ def process_response(terminal = false)
+ return nil if @response.code != 200
+
+ if @response.mime_type == 'application/atom+xml'
+ [@url, @response.to_s, :ostatus]
+ elsif ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(@response.mime_type)
+ if supported_activity?(@response.to_s)
+ [@url, @response.to_s, :activitypub]
+ else
+ @unsupported_activity = true
+ nil
+ end
+ elsif @response['Link'] && !terminal
+ process_headers
+ elsif @response.mime_type == 'text/html' && !terminal
+ process_html
+ end
+ end
+
+ def process_html
+ page = Nokogiri::HTML(@response.to_s)
+
+ json_link = page.xpath('//link[@rel="alternate"]').find { |link| ['application/activity+json', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'].include?(link['type']) }
+ atom_link = page.xpath('//link[@rel="alternate"]').find { |link| link['type'] == 'application/atom+xml' }
+
+ result ||= process(json_link['href'], terminal: true) unless json_link.nil? || @unsupported_activity
+ result ||= process(atom_link['href'], terminal: true) unless atom_link.nil?
+
+ result
+ end
+
+ def process_headers
+ link_header = LinkHeader.parse(@response['Link'].is_a?(Array) ? @response['Link'].first : @response['Link'])
+
+ json_link = link_header.find_link(%w(rel alternate), %w(type application/activity+json)) || link_header.find_link(%w(rel alternate), ['type', 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'])
+ atom_link = link_header.find_link(%w(rel alternate), %w(type application/atom+xml))
+
+ result ||= process(json_link.href, terminal: true) unless json_link.nil? || @unsupported_activity
+ result ||= process(atom_link.href, terminal: true) unless atom_link.nil?
+
+ result
+ end
+
+ def supported_activity?(body)
+ json = body_to_json(body)
+ return false unless supported_context?(json)
+ json['type'] == 'Person' ? json['inbox'].present? : true
end
end
diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb
index 20c85e0ea..c38e9e7df 100644
--- a/app/services/fetch_link_card_service.rb
+++ b/app/services/fetch_link_card_service.rb
@@ -4,29 +4,45 @@ class FetchLinkCardService < BaseService
URL_PATTERN = %r{https?://\S+}
def call(status)
- # Get first http/https URL that isn't local
- url = parse_urls(status)
+ @status = status
+ @url = parse_urls
- return if url.nil?
+ return if @url.nil? || @status.preview_cards.any?
- url = url.to_s
- card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url)
- res = Request.new(:head, url).perform
+ @url = @url.to_s
- return if res.code != 200 || res.mime_type != 'text/html'
+ RedisLock.acquire(lock_options) do |lock|
+ if lock.acquired?
+ @card = PreviewCard.find_by(url: @url)
+ process_url if @card.nil?
+ end
+ end
- attempt_opengraph(card, url) unless attempt_oembed(card, url)
+ attach_card unless @card.nil?
rescue HTTP::ConnectionError, OpenSSL::SSL::SSLError
nil
end
private
- def parse_urls(status)
- if status.local?
- urls = status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
+ def process_url
+ @card = PreviewCard.new(url: @url)
+ res = Request.new(:head, @url).perform
+
+ return if res.code != 200 || res.mime_type != 'text/html'
+
+ attempt_oembed || attempt_opengraph
+ end
+
+ def attach_card
+ @status.preview_cards << @card
+ end
+
+ def parse_urls
+ if @status.local?
+ urls = @status.text.match(URL_PATTERN).to_a.map { |uri| Addressable::URI.parse(uri).normalize }
else
- html = Nokogiri::HTML(status.text)
+ html = Nokogiri::HTML(@status.text)
links = html.css('a')
urls = links.map { |a| Addressable::URI.parse(a['href']).normalize unless skip_link?(a) }.compact
end
@@ -44,41 +60,41 @@ class FetchLinkCardService < BaseService
a['rel']&.include?('tag') || a['class']&.include?('u-url')
end
- def attempt_oembed(card, url)
- response = OEmbed::Providers.get(url)
+ def attempt_oembed
+ response = OEmbed::Providers.get(@url)
- card.type = response.type
- card.title = response.respond_to?(:title) ? response.title : ''
- card.author_name = response.respond_to?(:author_name) ? response.author_name : ''
- card.author_url = response.respond_to?(:author_url) ? response.author_url : ''
- card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
- card.provider_url = response.respond_to?(:provider_url) ? response.provider_url : ''
- card.width = 0
- card.height = 0
+ @card.type = response.type
+ @card.title = response.respond_to?(:title) ? response.title : ''
+ @card.author_name = response.respond_to?(:author_name) ? response.author_name : ''
+ @card.author_url = response.respond_to?(:author_url) ? response.author_url : ''
+ @card.provider_name = response.respond_to?(:provider_name) ? response.provider_name : ''
+ @card.provider_url = response.respond_to?(:provider_url) ? response.provider_url : ''
+ @card.width = 0
+ @card.height = 0
- case card.type
+ case @card.type
when 'link'
- card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
+ @card.image = URI.parse(response.thumbnail_url) if response.respond_to?(:thumbnail_url)
when 'photo'
- card.url = response.url
- card.width = response.width.presence || 0
- card.height = response.height.presence || 0
+ @card.url = response.url
+ @card.width = response.width.presence || 0
+ @card.height = response.height.presence || 0
when 'video'
- card.width = response.width.presence || 0
- card.height = response.height.presence || 0
- card.html = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
+ @card.width = response.width.presence || 0
+ @card.height = response.height.presence || 0
+ @card.html = Formatter.instance.sanitize(response.html, Sanitize::Config::MASTODON_OEMBED)
when 'rich'
# Most providers rely on ', uri: 'beep boop') }
+ let(:status) { Fabricate(:status, account: remote_account, text: '') }
it 'returns tag-stripped text' do
is_expected.to eq ''
diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb
index b0cb8f019..0451eceeb 100644
--- a/spec/lib/ostatus/atom_serializer_spec.rb
+++ b/spec/lib/ostatus/atom_serializer_spec.rb
@@ -196,7 +196,7 @@ RSpec.describe OStatus::AtomSerializer do
author = OStatus::AtomSerializer.new.author(account)
- link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' }
+ link = author.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' }
expect(link[:type]).to eq 'text/html'
expect(link[:rel]).to eq 'alternate'
expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username'
@@ -403,10 +403,10 @@ RSpec.describe OStatus::AtomSerializer do
it 'returns element whose rendered view triggers creation when processed' do
remote_account = Account.create!(username: 'username')
- remote_status = Fabricate(:status, account: remote_account)
- remote_status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z')
+ remote_status = Fabricate(:status, account: remote_account, created_at: '2000-01-01T00:00:00Z')
entry = OStatus::AtomSerializer.new.entry(remote_status.stream_entry, true)
+ entry.nodes.delete_if { |node| node[:type] == 'application/activity+json' } # Remove ActivityPub link to simplify test
xml = OStatus::AtomSerializer.render(entry).gsub('cb6e6126.ngrok.io', 'remote')
remote_status.destroy!
@@ -415,12 +415,12 @@ RSpec.describe OStatus::AtomSerializer do
account = Account.create!(
domain: 'remote',
username: 'username',
- last_webfingered_at: Time.now.utc,
+ last_webfingered_at: Time.now.utc
)
ProcessFeedService.new.call(xml, account)
- expect(Status.find_by(uri: "tag:remote,2000-01-01:objectId=#{remote_status.id}:objectType=Status")).to be_instance_of Status
+ expect(Status.find_by(uri: "https://remote/users/#{remote_status.account.to_param}/statuses/#{remote_status.id}")).to be_instance_of Status
end
end
@@ -464,12 +464,11 @@ RSpec.describe OStatus::AtomSerializer do
end
it 'appends id element with unique tag' do
- status = Fabricate(:status, reblog_of_id: nil)
- status.stream_entry.update!(created_at: '2000-01-01T00:00:00Z')
+ status = Fabricate(:status, reblog_of_id: nil, created_at: '2000-01-01T00:00:00Z')
entry = OStatus::AtomSerializer.new.entry(status.stream_entry)
- expect(entry.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status"
+ expect(entry.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}"
end
it 'appends published element with created date' do
@@ -514,7 +513,7 @@ RSpec.describe OStatus::AtomSerializer do
entry = OStatus::AtomSerializer.new.entry(reblog.stream_entry)
object = entry.nodes.find { |node| node.name == 'activity:object' }
- expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{reblogged.id}:objectType=Status"
+ expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{reblogged.account.to_param}/statuses/#{reblogged.id}"
end
it 'does not append activity:object element if target is not present' do
@@ -529,9 +528,9 @@ RSpec.describe OStatus::AtomSerializer do
entry = OStatus::AtomSerializer.new.entry(status.stream_entry)
- link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' }
+ link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' }
expect(link[:type]).to eq 'text/html'
- expect(link[:href]).to eq "https://cb6e6126.ngrok.io/users/username/updates/#{status.stream_entry.id}"
+ expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}"
end
it 'appends link element for itself' do
@@ -552,7 +551,7 @@ RSpec.describe OStatus::AtomSerializer do
entry = OStatus::AtomSerializer.new.entry(reply_status.stream_entry)
in_reply_to = entry.nodes.find { |node| node.name == 'thr:in-reply-to' }
- expect(in_reply_to[:ref]).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{in_reply_to_status.id}:objectType=Status"
+ expect(in_reply_to[:ref]).to eq "https://cb6e6126.ngrok.io/users/#{in_reply_to_status.account.to_param}/statuses/#{in_reply_to_status.id}"
end
it 'does not append thr:in-reply-to element if not threaded' do
@@ -642,7 +641,7 @@ RSpec.describe OStatus::AtomSerializer do
feed = OStatus::AtomSerializer.new.feed(account, [])
- link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' }
+ link = feed.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' }
expect(link[:type]).to eq 'text/html'
expect(link[:href]).to eq 'https://cb6e6126.ngrok.io/@username'
end
@@ -933,7 +932,7 @@ RSpec.describe OStatus::AtomSerializer do
favourite_salmon = OStatus::AtomSerializer.new.favourite_salmon(favourite)
object = favourite_salmon.nodes.find { |node| node.name == 'activity:object' }
- expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status"
+ expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}"
end
it 'appends thr:in-reply-to element for status' do
@@ -944,7 +943,7 @@ RSpec.describe OStatus::AtomSerializer do
favourite_salmon = OStatus::AtomSerializer.new.favourite_salmon(favourite)
in_reply_to = favourite_salmon.nodes.find { |node| node.name == 'thr:in-reply-to' }
- expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status"
+ expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}"
expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}"
end
@@ -1033,7 +1032,7 @@ RSpec.describe OStatus::AtomSerializer do
unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite)
object = unfavourite_salmon.nodes.find { |node| node.name == 'activity:object' }
- expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status"
+ expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}"
end
it 'appends thr:in-reply-to element for status' do
@@ -1044,7 +1043,7 @@ RSpec.describe OStatus::AtomSerializer do
unfavourite_salmon = OStatus::AtomSerializer.new.unfavourite_salmon(favourite)
in_reply_to = unfavourite_salmon.nodes.find { |node| node.name == 'thr:in-reply-to' }
- expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status"
+ expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}"
expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}"
end
@@ -1452,7 +1451,7 @@ RSpec.describe OStatus::AtomSerializer do
it 'appends id element with URL for status' do
status = Fabricate(:status, created_at: '2000-01-01T00:00:00Z')
object = OStatus::AtomSerializer.new.object(status)
- expect(object.id.text).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{status.id}:objectType=Status"
+ expect(object.id.text).to eq "https://cb6e6126.ngrok.io/users/#{status.account.to_param}/statuses/#{status.id}"
end
it 'appends published element with created date' do
@@ -1462,7 +1461,8 @@ RSpec.describe OStatus::AtomSerializer do
end
it 'appends updated element with updated date' do
- status = Fabricate(:status, updated_at: '2000-01-01T00:00:00Z')
+ status = Fabricate(:status)
+ status.updated_at = '2000-01-01T00:00:00Z'
object = OStatus::AtomSerializer.new.object(status)
expect(object.updated.text).to eq '2000-01-01T00:00:00Z'
end
@@ -1509,7 +1509,7 @@ RSpec.describe OStatus::AtomSerializer do
entry = OStatus::AtomSerializer.new.object(status)
- link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' }
+ link = entry.nodes.find { |node| node.name == 'link' && node[:rel] == 'alternate' && node[:type] == 'text/html' }
expect(link[:type]).to eq 'text/html'
expect(link[:href]).to eq "https://cb6e6126.ngrok.io/@username/#{status.id}"
end
@@ -1522,7 +1522,7 @@ RSpec.describe OStatus::AtomSerializer do
entry = OStatus::AtomSerializer.new.object(reply)
in_reply_to = entry.nodes.find { |node| node.name == 'thr:in-reply-to' }
- expect(in_reply_to.ref).to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{thread.id}:objectType=Status"
+ expect(in_reply_to.ref).to eq "https://cb6e6126.ngrok.io/users/#{thread.account.to_param}/statuses/#{thread.id}"
expect(in_reply_to.href).to eq "https://cb6e6126.ngrok.io/@username/#{thread.id}"
end
diff --git a/spec/lib/stream_entry_finder_spec.rb b/spec/lib/status_finder_spec.rb
similarity index 64%
rename from spec/lib/stream_entry_finder_spec.rb
rename to spec/lib/status_finder_spec.rb
index 64e03c36a..3ef086736 100644
--- a/spec/lib/stream_entry_finder_spec.rb
+++ b/spec/lib/status_finder_spec.rb
@@ -2,17 +2,17 @@
require 'rails_helper'
-describe StreamEntryFinder do
+describe StatusFinder do
include RoutingHelper
- describe '#stream_entry' do
+ describe '#status' do
context 'with a status url' do
let(:status) { Fabricate(:status) }
let(:url) { short_account_status_url(account_username: status.account.username, id: status.id) }
subject { described_class.new(url) }
it 'finds the stream entry' do
- expect(subject.stream_entry).to eq(status.stream_entry)
+ expect(subject.status).to eq(status)
end
it 'raises an error if action is not :show' do
@@ -20,7 +20,7 @@ describe StreamEntryFinder do
expect(recognized).to receive(:[]).with(:action).and_return(:create)
expect(Rails.application.routes).to receive(:recognize_path).with(url).and_return(recognized)
- expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -30,7 +30,17 @@ describe StreamEntryFinder do
subject { described_class.new(url) }
it 'finds the stream entry' do
- expect(subject.stream_entry).to eq(stream_entry)
+ expect(subject.status).to eq(stream_entry.status)
+ end
+ end
+
+ context 'with a remote url even if id exists on local' do
+ let(:status) { Fabricate(:status) }
+ let(:url) { "https://example.com/users/test/statuses/#{status.id}" }
+ subject { described_class.new(url) }
+
+ it 'raises an error' do
+ expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -39,7 +49,7 @@ describe StreamEntryFinder do
subject { described_class.new(url) }
it 'raises an error' do
- expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound)
end
end
@@ -48,7 +58,7 @@ describe StreamEntryFinder do
subject { described_class.new(url) }
it 'raises an error' do
- expect { subject.stream_entry }.to raise_error(ActiveRecord::RecordNotFound)
+ expect { subject.status }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
diff --git a/spec/lib/tag_manager_spec.rb b/spec/lib/tag_manager_spec.rb
index 1fae6bec4..1cd6e0a6f 100644
--- a/spec/lib/tag_manager_spec.rb
+++ b/spec/lib/tag_manager_spec.rb
@@ -157,23 +157,12 @@ RSpec.describe TagManager do
describe '#uri_for' do
subject { TagManager.instance.uri_for(target) }
- context 'activity object' do
- let(:target) { Fabricate(:status, reblog: Fabricate(:status)).stream_entry }
-
- before { target.update!(created_at: '2000-01-01T00:00:00Z') }
-
- it 'returns the unique tag for status' do
- expect(target.object_type).to eq :activity
- is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status"
- end
- end
-
context 'comment object' do
let(:target) { Fabricate(:status, created_at: '2000-01-01T00:00:00Z', reply: true) }
it 'returns the unique tag for status' do
expect(target.object_type).to eq :comment
- is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status"
+ is_expected.to eq target.uri
end
end
@@ -182,7 +171,7 @@ RSpec.describe TagManager do
it 'returns the unique tag for status' do
expect(target.object_type).to eq :note
- is_expected.to eq "tag:cb6e6126.ngrok.io,2000-01-01:objectId=#{target.id}:objectType=Status"
+ is_expected.to eq target.uri
end
end
diff --git a/spec/models/account_spec.rb b/spec/models/account_spec.rb
index eeaebb779..aef0c3082 100644
--- a/spec/models/account_spec.rb
+++ b/spec/models/account_spec.rb
@@ -642,7 +642,6 @@ RSpec.describe Account, type: :model do
it 'returns remote accounts with followers whose subscription expiration date is past or not given' do
local = Fabricate(:account, domain: nil)
matches = [
- { domain: 'remote', subscription_expires_at: nil },
{ domain: 'remote', subscription_expires_at: '2000-01-01T00:00:00Z' },
].map(&method(:Fabricate).curry(2).call(:account))
matches.each(&local.method(:follow!))
diff --git a/spec/models/import_spec.rb b/spec/models/import_spec.rb
index fa52077cd..321761166 100644
--- a/spec/models/import_spec.rb
+++ b/spec/models/import_spec.rb
@@ -1,5 +1,24 @@
require 'rails_helper'
RSpec.describe Import, type: :model do
+ let (:account) { Fabricate(:account) }
+ let (:type) { 'following' }
+ let (:data) { attachment_fixture('imports.txt') }
+ describe 'validations' do
+ it 'has a valid parameters' do
+ import = Import.create(account: account, type: type, data: data)
+ expect(import).to be_valid
+ end
+
+ it 'is invalid without an type' do
+ import = Import.create(account: account, data: data)
+ expect(import).to model_have_error_on_field(:type)
+ end
+
+ it 'is invalid without a data' do
+ import = Import.create(account: account, type: type)
+ expect(import).to model_have_error_on_field(:data)
+ end
+ end
end
diff --git a/spec/models/report_spec.rb b/spec/models/report_spec.rb
index 6c2723845..d40ebf6dc 100644
--- a/spec/models/report_spec.rb
+++ b/spec/models/report_spec.rb
@@ -21,4 +21,18 @@ describe Report do
expect(report.media_attachments).to eq [media_attachment]
end
end
+
+ describe 'validatiions' do
+ it 'has a valid fabricator' do
+ report = Fabricate(:report)
+ report.valid?
+ expect(report).to be_valid
+ end
+
+ it 'is invalid if comment is longer than 1000 characters' do
+ report = Fabricate.build(:report, comment: Faker::Lorem.characters(1001))
+ report.valid?
+ expect(report).to model_have_error_on_field(:comment)
+ end
+ end
end
diff --git a/spec/models/status_pin_spec.rb b/spec/models/status_pin_spec.rb
new file mode 100644
index 000000000..6f54f80f9
--- /dev/null
+++ b/spec/models/status_pin_spec.rb
@@ -0,0 +1,41 @@
+require 'rails_helper'
+
+RSpec.describe StatusPin, type: :model do
+ describe 'validations' do
+ it 'allows pins of own statuses' do
+ account = Fabricate(:account)
+ status = Fabricate(:status, account: account)
+
+ expect(StatusPin.new(account: account, status: status).save).to be true
+ end
+
+ it 'does not allow pins of statuses by someone else' do
+ account = Fabricate(:account)
+ status = Fabricate(:status)
+
+ expect(StatusPin.new(account: account, status: status).save).to be false
+ end
+
+ it 'does not allow pins of reblogs' do
+ account = Fabricate(:account)
+ status = Fabricate(:status, account: account)
+ reblog = Fabricate(:status, reblog: status)
+
+ expect(StatusPin.new(account: account, status: reblog).save).to be false
+ end
+
+ it 'does not allow pins of private statuses' do
+ account = Fabricate(:account)
+ status = Fabricate(:status, account: account, visibility: :private)
+
+ expect(StatusPin.new(account: account, status: status).save).to be false
+ end
+
+ it 'does not allow pins of direct statuses' do
+ account = Fabricate(:account)
+ status = Fabricate(:status, account: account, visibility: :direct)
+
+ expect(StatusPin.new(account: account, status: status).save).to be false
+ end
+ end
+end
diff --git a/spec/models/status_spec.rb b/spec/models/status_spec.rb
index 626fc3f98..484effd5e 100644
--- a/spec/models/status_spec.rb
+++ b/spec/models/status_spec.rb
@@ -13,9 +13,15 @@ RSpec.describe Status, type: :model do
end
it 'returns false if a remote URI is set' do
- subject.uri = 'a'
+ alice.update(domain: 'example.com')
+ subject.save
expect(subject.local?).to be false
end
+
+ it 'returns true if a URI is set and `local` is true' do
+ subject.update(uri: 'example.com', local: true)
+ expect(subject.local?).to be true
+ end
end
describe '#reblog?' do
@@ -495,7 +501,7 @@ RSpec.describe Status, type: :model do
end
end
- describe 'before_create' do
+ describe 'before_validation' do
it 'sets account being replied to correctly over intermediary nodes' do
first_status = Fabricate(:status, account: bob)
intermediary = Fabricate(:status, thread: first_status, account: alice)
@@ -512,5 +518,22 @@ RSpec.describe Status, type: :model do
parent = Fabricate(:status, text: 'First')
expect(Status.create(account: alice, thread: parent, text: 'Response').conversation_id).to eq parent.conversation_id
end
+
+ it 'sets `local` to true for status by local account' do
+ expect(Status.create(account: alice, text: 'foo').local).to be true
+ end
+
+ it 'sets `local` to false for status by remote account' do
+ alice.update(domain: 'example.com')
+ expect(Status.create(account: alice, text: 'foo').local).to be false
+ end
+ end
+
+ describe 'after_create' do
+ it 'saves ActivityPub uri as uri for local status' do
+ status = Status.create(account: alice, text: 'foo')
+ status.reload
+ expect(status.uri).to start_with('https://')
+ end
end
end
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index ef45818b9..99aeca01b 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -286,4 +286,24 @@ RSpec.describe User, type: :model do
Fabricate(:user)
end
end
+
+ describe 'token_for_app' do
+ let(:user) { Fabricate(:user) }
+ let(:app) { Fabricate(:application, owner: user) }
+
+ it 'returns a token' do
+ expect(user.token_for_app(app)).to be_a(Doorkeeper::AccessToken)
+ end
+
+ it 'persists a token' do
+ t = user.token_for_app(app)
+ expect(user.token_for_app(app)).to eql(t)
+ end
+
+ it 'is nil if user does not own app' do
+ app.update!(owner: nil)
+
+ expect(user.token_for_app(app)).to be_nil
+ end
+ end
end
diff --git a/spec/services/activitypub/fetch_remote_account_service_spec.rb b/spec/services/activitypub/fetch_remote_account_service_spec.rb
new file mode 100644
index 000000000..ed7e9bba8
--- /dev/null
+++ b/spec/services/activitypub/fetch_remote_account_service_spec.rb
@@ -0,0 +1,123 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::FetchRemoteAccountService do
+ subject { ActivityPub::FetchRemoteAccountService.new }
+
+ let!(:actor) do
+ {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: 'https://example.com/alice',
+ type: 'Person',
+ preferredUsername: 'alice',
+ name: 'Alice',
+ summary: 'Foo bar',
+ inbox: 'http://example.com/alice/inbox',
+ }
+ end
+
+ describe '#call' do
+ let(:account) { subject.call('https://example.com/alice') }
+
+ shared_examples 'sets profile data' do
+ it 'returns an account' do
+ expect(account).to be_an Account
+ end
+
+ it 'sets display name' do
+ expect(account.display_name).to eq 'Alice'
+ end
+
+ it 'sets note' do
+ expect(account.note).to eq 'Foo bar'
+ end
+
+ it 'sets URL' do
+ expect(account.url).to eq 'https://example.com/alice'
+ end
+ end
+
+ context 'when the account does not have a inbox' do
+ let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
+
+ before do
+ actor[:inbox] = nil
+
+ stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
+ stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+ end
+
+ it 'fetches resource' do
+ account
+ expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
+ end
+
+ it 'looks up webfinger' do
+ account
+ expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
+ end
+
+ it 'returns nil' do
+ expect(account).to be_nil
+ end
+
+ end
+
+ context 'when URI and WebFinger share the same host' do
+ let!(:webfinger) { { subject: 'acct:alice@example.com', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
+
+ before do
+ stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
+ stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+ end
+
+ it 'fetches resource' do
+ account
+ expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
+ end
+
+ it 'looks up webfinger' do
+ account
+ expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
+ end
+
+ it 'sets username and domain from webfinger' do
+ expect(account.username).to eq 'alice'
+ expect(account.domain).to eq 'example.com'
+ end
+
+ include_examples 'sets profile data'
+ end
+
+ context 'when WebFinger presents different domain than URI' do
+ let!(:webfinger) { { subject: 'acct:alice@iscool.af', links: [{ rel: 'self', href: 'https://example.com/alice' }] } }
+
+ before do
+ stub_request(:get, 'https://example.com/alice').to_return(body: Oj.dump(actor))
+ stub_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+ stub_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af').to_return(body: Oj.dump(webfinger), headers: { 'Content-Type': 'application/jrd+json' })
+ end
+
+ it 'fetches resource' do
+ account
+ expect(a_request(:get, 'https://example.com/alice')).to have_been_made.once
+ end
+
+ it 'looks up webfinger' do
+ account
+ expect(a_request(:get, 'https://example.com/.well-known/webfinger?resource=acct:alice@example.com')).to have_been_made.once
+ end
+
+ it 'looks up "redirected" webfinger' do
+ account
+ expect(a_request(:get, 'https://iscool.af/.well-known/webfinger?resource=acct:alice@iscool.af')).to have_been_made.once
+ end
+
+ it 'sets username and domain from final webfinger' do
+ expect(account.username).to eq 'alice'
+ expect(account.domain).to eq 'iscool.af'
+ end
+
+ include_examples 'sets profile data'
+ end
+ end
+end
diff --git a/spec/services/activitypub/fetch_remote_status_service_spec.rb b/spec/services/activitypub/fetch_remote_status_service_spec.rb
new file mode 100644
index 000000000..3b22257ed
--- /dev/null
+++ b/spec/services/activitypub/fetch_remote_status_service_spec.rb
@@ -0,0 +1,75 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::FetchRemoteStatusService do
+ let(:sender) { Fabricate(:account) }
+ let(:recipient) { Fabricate(:account) }
+ let(:valid_domain) { Rails.configuration.x.local_domain }
+
+ let(:note) do
+ {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: "https://#{valid_domain}/@foo/1234",
+ type: 'Note',
+ content: 'Lorem ipsum',
+ attributedTo: ActivityPub::TagManager.instance.uri_for(sender),
+ }
+ end
+
+ let(:create) do
+ {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: "https://#{valid_domain}/@foo/1234/activity",
+ type: 'Create',
+ actor: ActivityPub::TagManager.instance.uri_for(sender),
+ object: note,
+ }
+ end
+
+ subject { described_class.new }
+
+ describe '#call' do
+ before do
+ subject.call(object[:id], Oj.dump(object))
+ end
+
+ context 'with Note object' do
+ let(:object) { note }
+
+ it 'creates status' do
+ status = sender.statuses.first
+
+ expect(status).to_not be_nil
+ expect(status.text).to eq 'Lorem ipsum'
+ end
+ end
+
+ context 'with Create activity' do
+ let(:object) { create }
+
+ it 'creates status' do
+ status = sender.statuses.first
+
+ expect(status).to_not be_nil
+ expect(status.text).to eq 'Lorem ipsum'
+ end
+ end
+
+ context 'with Announce activity' do
+ let(:status) { Fabricate(:status, account: recipient) }
+
+ let(:object) do
+ {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: "https://#{valid_domain}/@foo/1234/activity",
+ type: 'Announce',
+ actor: ActivityPub::TagManager.instance.uri_for(sender),
+ object: ActivityPub::TagManager.instance.uri_for(status),
+ }
+ end
+
+ it 'creates a reblog by sender of status' do
+ expect(sender.reblogged?(status)).to be true
+ end
+ end
+ end
+end
diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb
new file mode 100644
index 000000000..84a74c231
--- /dev/null
+++ b/spec/services/activitypub/process_account_service_spec.rb
@@ -0,0 +1,5 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::ProcessAccountService do
+ pending
+end
diff --git a/spec/services/activitypub/process_collection_service_spec.rb b/spec/services/activitypub/process_collection_service_spec.rb
new file mode 100644
index 000000000..249b12470
--- /dev/null
+++ b/spec/services/activitypub/process_collection_service_spec.rb
@@ -0,0 +1,55 @@
+require 'rails_helper'
+
+RSpec.describe ActivityPub::ProcessCollectionService do
+ let(:actor) { Fabricate(:account) }
+
+ let(:payload) do
+ {
+ '@context': 'https://www.w3.org/ns/activitystreams',
+ id: 'foo',
+ type: 'Create',
+ actor: ActivityPub::TagManager.instance.uri_for(actor),
+ object: {
+ id: 'bar',
+ type: 'Note',
+ content: 'Lorem ipsum',
+ },
+ }
+ end
+
+ let(:json) { Oj.dump(payload) }
+
+ subject { described_class.new }
+
+ describe '#call' do
+ context 'when actor is the sender'
+ context 'when actor differs from sender' do
+ let(:forwarder) { Fabricate(:account) }
+
+ it 'processes payload with sender if no signature exists' do
+ expect_any_instance_of(ActivityPub::LinkedDataSignature).not_to receive(:verify_account!)
+ expect(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), forwarder)
+
+ subject.call(json, forwarder)
+ end
+
+ it 'processes payload with actor if valid signature exists' do
+ payload['signature'] = {'type' => 'RsaSignature2017'}
+
+ expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_account!).and_return(actor)
+ expect(ActivityPub::Activity).to receive(:factory).with(instance_of(Hash), actor)
+
+ subject.call(json, forwarder)
+ end
+
+ it 'does not process payload if invalid signature exists' do
+ payload['signature'] = {'type' => 'RsaSignature2017'}
+
+ expect_any_instance_of(ActivityPub::LinkedDataSignature).to receive(:verify_account!).and_return(nil)
+ expect(ActivityPub::Activity).not_to receive(:factory)
+
+ subject.call(json, forwarder)
+ end
+ end
+ end
+end
diff --git a/spec/services/authorize_follow_service_spec.rb b/spec/services/authorize_follow_service_spec.rb
index 3f3a2bc56..d74eb41a2 100644
--- a/spec/services/authorize_follow_service_spec.rb
+++ b/spec/services/authorize_follow_service_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe AuthorizeFollowService do
end
end
- describe 'remote' do
+ describe 'remote OStatus' do
let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
before do
@@ -46,4 +46,26 @@ RSpec.describe AuthorizeFollowService do
}).to have_been_made.once
end
end
+
+ describe 'remote ActivityPub' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account }
+
+ before do
+ FollowRequest.create(account: bob, target_account: sender)
+ stub_request(:post, bob.inbox_url).to_return(status: 200)
+ subject.call(bob, sender)
+ end
+
+ it 'removes follow request' do
+ expect(bob.requested?(sender)).to be false
+ end
+
+ it 'creates follow relation' do
+ expect(bob.following?(sender)).to be true
+ end
+
+ it 'sends an accept activity' do
+ expect(a_request(:post, bob.inbox_url)).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/batched_remove_status_service_spec.rb b/spec/services/batched_remove_status_service_spec.rb
index c20085e25..b1e9ac567 100644
--- a/spec/services/batched_remove_status_service_spec.rb
+++ b/spec/services/batched_remove_status_service_spec.rb
@@ -6,6 +6,7 @@ RSpec.describe BatchedRemoveStatusService do
let!(:alice) { Fabricate(:account) }
let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') }
let!(:jeff) { Fabricate(:account) }
+ let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
let(:status1) { PostStatusService.new.call(alice, 'Hello @bob@example.com') }
let(:status2) { PostStatusService.new.call(alice, 'Another status') }
@@ -15,9 +16,11 @@ RSpec.describe BatchedRemoveStatusService do
stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {})
stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {})
+ stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now)
jeff.follow!(alice)
+ hank.follow!(alice)
status1
status2
@@ -45,11 +48,10 @@ RSpec.describe BatchedRemoveStatusService do
expect(Redis.current).to have_received(:publish).with('timeline:public', any_args).at_least(:once)
end
- it 'sends PuSH update to PuSH subscribers with two payloads united' do
+ it 'sends PuSH update to PuSH subscribers' do
expect(a_request(:post, 'http://example.com/push').with { |req|
- matches = req.body.scan(TagManager::VERBS[:delete])
- matches.size == 2
- }).to have_been_made
+ matches = req.body.match(TagManager::VERBS[:delete])
+ }).to have_been_made.at_least_once
end
it 'sends Salmon slap to previously mentioned users' do
@@ -58,4 +60,8 @@ RSpec.describe BatchedRemoveStatusService do
xml.match(TagManager::VERBS[:delete])
}).to have_been_made.once
end
+
+ it 'sends delete activity to followers' do
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.at_least_once
+ end
end
diff --git a/spec/services/block_service_spec.rb b/spec/services/block_service_spec.rb
index 2a54e032e..bd2ab3d53 100644
--- a/spec/services/block_service_spec.rb
+++ b/spec/services/block_service_spec.rb
@@ -17,7 +17,7 @@ RSpec.describe BlockService do
end
end
- describe 'remote' do
+ describe 'remote OStatus' do
let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
before do
@@ -36,4 +36,21 @@ RSpec.describe BlockService do
}).to have_been_made.once
end
end
+
+ describe 'remote ActivityPub' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account }
+
+ before do
+ stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
+ subject.call(sender, bob)
+ end
+
+ it 'creates a blocking relation' do
+ expect(sender.blocking?(bob)).to be true
+ end
+
+ it 'sends a block activity' do
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/bootstrap_timeline_service_spec.rb b/spec/services/bootstrap_timeline_service_spec.rb
new file mode 100644
index 000000000..5189b1de8
--- /dev/null
+++ b/spec/services/bootstrap_timeline_service_spec.rb
@@ -0,0 +1,37 @@
+require 'rails_helper'
+
+RSpec.describe BootstrapTimelineService do
+ subject { described_class.new }
+
+ describe '#call' do
+ let(:source_account) { Fabricate(:account) }
+
+ context 'when setting is empty' do
+ let!(:admin) { Fabricate(:user, admin: true) }
+
+ before do
+ Setting.bootstrap_timeline_accounts = nil
+ subject.call(source_account)
+ end
+
+ it 'follows admin accounts from account' do
+ expect(source_account.following?(admin.account)).to be true
+ end
+ end
+
+ context 'when setting is set' do
+ let!(:alice) { Fabricate(:account, username: 'alice') }
+ let!(:bob) { Fabricate(:account, username: 'bob') }
+
+ before do
+ Setting.bootstrap_timeline_accounts = 'alice, bob'
+ subject.call(source_account)
+ end
+
+ it 'follows found accounts from account' do
+ expect(source_account.following?(alice)).to be true
+ expect(source_account.following?(bob)).to be true
+ end
+ end
+ end
+end
diff --git a/spec/services/favourite_service_spec.rb b/spec/services/favourite_service_spec.rb
index 36f1b64d4..2ab1f32ca 100644
--- a/spec/services/favourite_service_spec.rb
+++ b/spec/services/favourite_service_spec.rb
@@ -18,8 +18,8 @@ RSpec.describe FavouriteService do
end
end
- describe 'remote' do
- let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
+ describe 'remote OStatus' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com:blahblah') }
before do
@@ -38,4 +38,22 @@ RSpec.describe FavouriteService do
}).to have_been_made.once
end
end
+
+ describe 'remote ActivityPub' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :activitypub, username: 'bob', domain: 'example.com', inbox_url: 'http://example.com/inbox')).account }
+ let(:status) { Fabricate(:status, account: bob) }
+
+ before do
+ stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {})
+ subject.call(sender, status)
+ end
+
+ it 'creates a favourite' do
+ expect(status.favourites.first).to_not be_nil
+ end
+
+ it 'sends a like activity' do
+ expect(a_request(:post, "http://example.com/inbox")).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb
index 698eb0324..b0aa740ac 100644
--- a/spec/services/fetch_link_card_service_spec.rb
+++ b/spec/services/fetch_link_card_service_spec.rb
@@ -31,7 +31,7 @@ RSpec.describe FetchLinkCardService do
it 'works with SJIS' do
expect(a_request(:get, 'http://example.com/sjis')).to have_been_made.at_least_once
- expect(status.preview_card.title).to eq("SJISのページ")
+ expect(status.preview_cards.first.title).to eq("SJISのページ")
end
end
@@ -40,7 +40,7 @@ RSpec.describe FetchLinkCardService do
it 'works with SJIS even with wrong charset header' do
expect(a_request(:get, 'http://example.com/sjis_with_wrong_charset')).to have_been_made.at_least_once
- expect(status.preview_card.title).to eq("SJISのページ")
+ expect(status.preview_cards.first.title).to eq("SJISのページ")
end
end
@@ -49,13 +49,13 @@ RSpec.describe FetchLinkCardService do
it 'works with koi8-r' do
expect(a_request(:get, 'http://example.com/koi8-r')).to have_been_made.at_least_once
- expect(status.preview_card.title).to eq("Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.")
+ expect(status.preview_cards.first.title).to eq("Московя начинаетъ только въ XVI ст. привлекать внимане иностранцевъ.")
end
end
end
context 'in a remote status' do
- let(:status) { Fabricate(:status, uri: 'abc', text: 'Habt ihr ein paar gute Links zu #Wannacry herumfliegen? Ich will mal unter
https://github.com/qbi/WannaCry was sammeln. !security ') }
+ let(:status) { Fabricate(:status, account: Fabricate(:account, domain: 'example.com'), text: 'Habt ihr ein paar gute Links zu #Wannacry herumfliegen? Ich will mal unter
https://github.com/qbi/WannaCry was sammeln. !security ') }
it 'parses out URLs' do
expect(a_request(:head, 'https://github.com/qbi/WannaCry')).to have_been_made.at_least_once
diff --git a/spec/services/fetch_remote_resource_service_spec.rb b/spec/services/fetch_remote_resource_service_spec.rb
index 81b0e48e3..c14fcfc4e 100644
--- a/spec/services/fetch_remote_resource_service_spec.rb
+++ b/spec/services/fetch_remote_resource_service_spec.rb
@@ -30,7 +30,7 @@ describe FetchRemoteResourceService do
_result = subject.call(url)
- expect(account_service).to have_received(:call).with(feed_url, feed_content)
+ expect(account_service).to have_received(:call).with(feed_url, feed_content, nil)
end
it 'fetches remote statuses for entry types' do
@@ -47,7 +47,7 @@ describe FetchRemoteResourceService do
_result = subject.call(url)
- expect(account_service).to have_received(:call).with(feed_url, feed_content)
+ expect(account_service).to have_received(:call).with(feed_url, feed_content, nil)
end
end
end
diff --git a/spec/services/follow_service_spec.rb b/spec/services/follow_service_spec.rb
index 32dedb3ad..1e2378031 100644
--- a/spec/services/follow_service_spec.rb
+++ b/spec/services/follow_service_spec.rb
@@ -44,9 +44,9 @@ RSpec.describe FollowService do
end
end
- context 'remote account' do
+ context 'remote OStatus account' do
describe 'locked account' do
- let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, locked: true, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
before do
stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {})
@@ -66,7 +66,7 @@ RSpec.describe FollowService do
end
describe 'unlocked account' do
- let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account }
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account }
before do
stub_request(:post, "http://salmon.example.com/").to_return(:status => 200, :body => "", :headers => {})
@@ -91,7 +91,7 @@ RSpec.describe FollowService do
end
describe 'already followed account' do
- let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account }
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, protocol: :ostatus, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com', hub_url: 'http://hub.example.com')).account }
before do
sender.follow!(bob)
@@ -111,4 +111,21 @@ RSpec.describe FollowService do
end
end
end
+
+ context 'remote ActivityPub account' do
+ let(:bob) { Fabricate(:user, account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account }
+
+ before do
+ stub_request(:post, "http://example.com/inbox").to_return(:status => 200, :body => "", :headers => {})
+ subject.call(sender, bob.acct)
+ end
+
+ it 'creates follow request' do
+ expect(FollowRequest.find_by(account: sender, target_account: bob)).to_not be_nil
+ end
+
+ it 'sends a follow activity to the inbox' do
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/post_status_service_spec.rb b/spec/services/post_status_service_spec.rb
index 57876dcc2..4182c4e1f 100644
--- a/spec/services/post_status_service_spec.rb
+++ b/spec/services/post_status_service_spec.rb
@@ -100,16 +100,18 @@ RSpec.describe PostStatusService do
expect(hashtags_service).to have_received(:call).with(status)
end
- it 'pings PuSH hubs' do
+ it 'gets distributed' do
allow(DistributionWorker).to receive(:perform_async)
allow(Pubsubhubbub::DistributionWorker).to receive(:perform_async)
+ allow(ActivityPub::DistributionWorker).to receive(:perform_async)
+
account = Fabricate(:account)
status = subject.call(account, "test status update")
expect(DistributionWorker).to have_received(:perform_async).with(status.id)
- expect(Pubsubhubbub::DistributionWorker).
- to have_received(:perform_async).with(status.stream_entry.id)
+ expect(Pubsubhubbub::DistributionWorker).to have_received(:perform_async).with(status.stream_entry.id)
+ expect(ActivityPub::DistributionWorker).to have_received(:perform_async).with(status.id)
end
it 'crawls links' do
diff --git a/spec/services/process_feed_service_spec.rb b/spec/services/process_feed_service_spec.rb
index 5e34370ee..aca675dc6 100644
--- a/spec/services/process_feed_service_spec.rb
+++ b/spec/services/process_feed_service_spec.rb
@@ -124,8 +124,7 @@ RSpec.describe ProcessFeedService do
XML
- stub_request(:head, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, headers: { 'Content-Type' => 'application/atom+xml' })
- stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, body: real_body)
+ stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 200, body: real_body, headers: { 'Content-Type' => 'application/atom+xml' })
bad_actor = Fabricate(:account, username: 'sombra', domain: 'talon.xyz')
@@ -168,7 +167,7 @@ XML
end
it 'ignores reblogs if it failed to retreive reblogged statuses' do
- stub_request(:head, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 404)
+ stub_request(:get, 'https://overwatch.com/users/tracer/updates/1').to_return(status: 404)
actor = Fabricate(:account, username: 'tracer', domain: 'overwatch.com')
diff --git a/spec/services/process_mentions_service_spec.rb b/spec/services/process_mentions_service_spec.rb
index 984d13746..09f8fa45b 100644
--- a/spec/services/process_mentions_service_spec.rb
+++ b/spec/services/process_mentions_service_spec.rb
@@ -1,22 +1,44 @@
require 'rails_helper'
RSpec.describe ProcessMentionsService do
- let(:account) { Fabricate(:account, username: 'alice') }
- let(:remote_user) { Fabricate(:account, username: 'remote_user', domain: 'example.com', salmon_url: 'http://salmon.example.com') }
- let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") }
+ let(:account) { Fabricate(:account, username: 'alice') }
+ let(:status) { Fabricate(:status, account: account, text: "Hello @#{remote_user.acct}") }
- subject { ProcessMentionsService.new }
+ context 'OStatus' do
+ let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com') }
- before do
- stub_request(:post, remote_user.salmon_url)
- subject.(status)
+ subject { ProcessMentionsService.new }
+
+ before do
+ stub_request(:post, remote_user.salmon_url)
+ subject.call(status)
+ end
+
+ it 'creates a mention' do
+ expect(remote_user.mentions.where(status: status).count).to eq 1
+ end
+
+ it 'posts to remote user\'s Salmon end point' do
+ expect(a_request(:post, remote_user.salmon_url)).to have_been_made.once
+ end
end
- it 'creates a mention' do
- expect(remote_user.mentions.where(status: status).count).to eq 1
- end
+ context 'ActivityPub' do
+ let(:remote_user) { Fabricate(:account, username: 'remote_user', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
- it 'posts to remote user\'s Salmon end point' do
- expect(a_request(:post, remote_user.salmon_url)).to have_been_made
+ subject { ProcessMentionsService.new }
+
+ before do
+ stub_request(:post, remote_user.inbox_url)
+ subject.call(status)
+ end
+
+ it 'creates a mention' do
+ expect(remote_user.mentions.where(status: status).count).to eq 1
+ end
+
+ it 'sends activity to the inbox' do
+ expect(a_request(:post, remote_user.inbox_url)).to have_been_made.once
+ end
end
end
diff --git a/spec/services/reblog_service_spec.rb b/spec/services/reblog_service_spec.rb
index 5f89169e9..0ad5c5f6b 100644
--- a/spec/services/reblog_service_spec.rb
+++ b/spec/services/reblog_service_spec.rb
@@ -2,22 +2,49 @@ require 'rails_helper'
RSpec.describe ReblogService do
let(:alice) { Fabricate(:account, username: 'alice') }
- let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') }
- let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') }
- subject { ReblogService.new }
+ context 'OStatus' do
+ let(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com') }
+ let(:status) { Fabricate(:status, account: bob, uri: 'tag:example.com;something:something') }
- before do
- stub_request(:post, 'http://salmon.example.com')
+ subject { ReblogService.new }
- subject.(alice, status)
+ before do
+ stub_request(:post, 'http://salmon.example.com')
+ subject.call(alice, status)
+ end
+
+ it 'creates a reblog' do
+ expect(status.reblogs.count).to eq 1
+ end
+
+ it 'sends a Salmon slap for a remote reblog' do
+ expect(a_request(:post, 'http://salmon.example.com')).to have_been_made
+ end
end
- it 'creates a reblog' do
- expect(status.reblogs.count).to eq 1
- end
+ context 'ActivityPub' do
+ let(:bob) { Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
+ let(:status) { Fabricate(:status, account: bob) }
- it 'sends a Salmon slap for a remote reblog' do
- expect(a_request(:post, 'http://salmon.example.com')).to have_been_made
+ subject { ReblogService.new }
+
+ before do
+ stub_request(:post, bob.inbox_url)
+ allow(ActivityPub::DistributionWorker).to receive(:perform_async)
+ subject.call(alice, status)
+ end
+
+ it 'creates a reblog' do
+ expect(status.reblogs.count).to eq 1
+ end
+
+ it 'distributes to followers' do
+ expect(ActivityPub::DistributionWorker).to have_received(:perform_async)
+ end
+
+ it 'sends an announce activity to the author' do
+ expect(a_request(:post, bob.inbox_url)).to have_been_made.once
+ end
end
end
diff --git a/spec/services/reject_follow_service_spec.rb b/spec/services/reject_follow_service_spec.rb
index 50749b633..2e06345b3 100644
--- a/spec/services/reject_follow_service_spec.rb
+++ b/spec/services/reject_follow_service_spec.rb
@@ -22,7 +22,7 @@ RSpec.describe RejectFollowService do
end
end
- describe 'remote' do
+ describe 'remote OStatus' do
let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
before do
@@ -46,4 +46,26 @@ RSpec.describe RejectFollowService do
}).to have_been_made.once
end
end
+
+ describe 'remote ActivityPub' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', protocol: :activitypub, inbox_url: 'http://example.com/inbox')).account }
+
+ before do
+ FollowRequest.create(account: bob, target_account: sender)
+ stub_request(:post, bob.inbox_url).to_return(status: 200)
+ subject.call(bob, sender)
+ end
+
+ it 'removes follow request' do
+ expect(bob.requested?(sender)).to be false
+ end
+
+ it 'does not create follow relation' do
+ expect(bob.following?(sender)).to be false
+ end
+
+ it 'sends a reject activity' do
+ expect(a_request(:post, bob.inbox_url)).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/remove_status_service_spec.rb b/spec/services/remove_status_service_spec.rb
index a3bce7613..8b34bdb6b 100644
--- a/spec/services/remove_status_service_spec.rb
+++ b/spec/services/remove_status_service_spec.rb
@@ -6,14 +6,21 @@ RSpec.describe RemoveStatusService do
let!(:alice) { Fabricate(:account) }
let!(:bob) { Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://example.com/salmon') }
let!(:jeff) { Fabricate(:account) }
+ let!(:hank) { Fabricate(:account, username: 'hank', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox') }
+ let!(:bill) { Fabricate(:account, username: 'bill', protocol: :activitypub, domain: 'example2.com', inbox_url: 'http://example2.com/inbox') }
before do
stub_request(:post, 'http://example.com/push').to_return(status: 200, body: '', headers: {})
stub_request(:post, 'http://example.com/salmon').to_return(status: 200, body: '', headers: {})
+ stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
+ stub_request(:post, 'http://example2.com/inbox').to_return(status: 200)
Fabricate(:subscription, account: alice, callback_url: 'http://example.com/push', confirmed: true, expires_at: 30.days.from_now)
jeff.follow!(alice)
+ hank.follow!(alice)
+
@status = PostStatusService.new.call(alice, 'Hello @bob@example.com')
+ Fabricate(:status, account: bill, reblog: @status, uri: 'hoge')
subject.call(@status)
end
@@ -31,10 +38,18 @@ RSpec.describe RemoveStatusService do
}).to have_been_made
end
+ it 'sends delete activity to followers' do
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.twice
+ end
+
it 'sends Salmon slap to previously mentioned users' do
expect(a_request(:post, "http://example.com/salmon").with { |req|
xml = OStatus2::Salmon.new.unpack(req.body)
xml.match(TagManager::VERBS[:delete])
}).to have_been_made.once
end
+
+ it 'sends delete activity to rebloggers' do
+ expect(a_request(:post, 'http://example2.com/inbox')).to have_been_made
+ end
end
diff --git a/spec/services/resolve_remote_account_service_spec.rb b/spec/services/resolve_remote_account_service_spec.rb
index c3b902b34..d0eab2310 100644
--- a/spec/services/resolve_remote_account_service_spec.rb
+++ b/spec/services/resolve_remote_account_service_spec.rb
@@ -1,7 +1,7 @@
require 'rails_helper'
RSpec.describe ResolveRemoteAccountService do
- subject { ResolveRemoteAccountService.new }
+ subject { described_class.new }
before do
stub_request(:get, "https://quitter.no/.well-known/host-meta").to_return(request_fixture('.host-meta.txt'))
@@ -29,29 +29,6 @@ RSpec.describe ResolveRemoteAccountService do
expect(subject.call('catsrgr8@example.com')).to be_nil
end
- it 'returns an already existing remote account' do
- old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no')
- returned_account = subject.call('gargron@quitter.no')
-
- expect(old_account.id).to eq returned_account.id
- end
-
- it 'returns a new remote account' do
- account = subject.call('gargron@quitter.no')
-
- expect(account.username).to eq 'gargron'
- expect(account.domain).to eq 'quitter.no'
- expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom'
- end
-
- it 'follows a legitimate account redirection' do
- account = subject.call('gargron@redirected.com')
-
- expect(account.username).to eq 'gargron'
- expect(account.domain).to eq 'quitter.no'
- expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom'
- end
-
it 'prevents hijacking existing accounts' do
account = subject.call('hacker1@redirected.com')
expect(account.salmon_url).to_not eq 'https://hacker.com/main/salmon/user/7477'
@@ -61,12 +38,41 @@ RSpec.describe ResolveRemoteAccountService do
expect(subject.call('hacker2@redirected.com')).to be_nil
end
- it 'returns a new remote account' do
- account = subject.call('foo@localdomain.com')
+ context 'with an OStatus account' do
+ it 'returns an already existing remote account' do
+ old_account = Fabricate(:account, username: 'gargron', domain: 'quitter.no')
+ returned_account = subject.call('gargron@quitter.no')
- expect(account.username).to eq 'foo'
- expect(account.domain).to eq 'localdomain.com'
- expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom'
+ expect(old_account.id).to eq returned_account.id
+ end
+
+ it 'returns a new remote account' do
+ account = subject.call('gargron@quitter.no')
+
+ expect(account.username).to eq 'gargron'
+ expect(account.domain).to eq 'quitter.no'
+ expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom'
+ end
+
+ it 'follows a legitimate account redirection' do
+ account = subject.call('gargron@redirected.com')
+
+ expect(account.username).to eq 'gargron'
+ expect(account.domain).to eq 'quitter.no'
+ expect(account.remote_url).to eq 'https://quitter.no/api/statuses/user_timeline/7477.atom'
+ end
+
+ it 'returns a new remote account' do
+ account = subject.call('foo@localdomain.com')
+
+ expect(account.username).to eq 'foo'
+ expect(account.domain).to eq 'localdomain.com'
+ expect(account.remote_url).to eq 'https://webdomain.com/users/foo.atom'
+ end
+ end
+
+ context 'with an ActivityPub account' do
+ pending
end
it 'processes one remote account at a time using locks' do
@@ -78,7 +84,7 @@ RSpec.describe ResolveRemoteAccountService do
Thread.new do
true while wait_for_start
begin
- return_values << ResolveRemoteAccountService.new.call('foo@localdomain.com')
+ return_values << described_class.new.call('foo@localdomain.com')
rescue ActiveRecord::RecordNotUnique
fail_occurred = true
end
diff --git a/spec/services/unblock_service_spec.rb b/spec/services/unblock_service_spec.rb
index 1b9ae1239..def4981e7 100644
--- a/spec/services/unblock_service_spec.rb
+++ b/spec/services/unblock_service_spec.rb
@@ -18,7 +18,7 @@ RSpec.describe UnblockService do
end
end
- describe 'remote' do
+ describe 'remote OStatus' do
let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
before do
@@ -28,7 +28,7 @@ RSpec.describe UnblockService do
end
it 'destroys the blocking relation' do
- expect(sender.following?(bob)).to be false
+ expect(sender.blocking?(bob)).to be false
end
it 'sends an unblock salmon slap' do
@@ -38,4 +38,22 @@ RSpec.describe UnblockService do
}).to have_been_made.once
end
end
+
+ describe 'remote ActivityPub' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account }
+
+ before do
+ sender.block!(bob)
+ stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
+ subject.call(sender, bob)
+ end
+
+ it 'destroys the blocking relation' do
+ expect(sender.blocking?(bob)).to be false
+ end
+
+ it 'sends an unblock activity' do
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/unfollow_service_spec.rb b/spec/services/unfollow_service_spec.rb
index 8ec2148a1..29040431e 100644
--- a/spec/services/unfollow_service_spec.rb
+++ b/spec/services/unfollow_service_spec.rb
@@ -18,8 +18,8 @@ RSpec.describe UnfollowService do
end
end
- describe 'remote' do
- let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
+ describe 'remote OStatus' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :ostatus, domain: 'example.com', salmon_url: 'http://salmon.example.com')).account }
before do
sender.follow!(bob)
@@ -38,4 +38,22 @@ RSpec.describe UnfollowService do
}).to have_been_made.once
end
end
+
+ describe 'remote ActivityPub' do
+ let(:bob) { Fabricate(:user, email: 'bob@example.com', account: Fabricate(:account, username: 'bob', protocol: :activitypub, domain: 'example.com', inbox_url: 'http://example.com/inbox')).account }
+
+ before do
+ sender.follow!(bob)
+ stub_request(:post, 'http://example.com/inbox').to_return(status: 200)
+ subject.call(sender, bob)
+ end
+
+ it 'destroys the following relation' do
+ expect(sender.following?(bob)).to be false
+ end
+
+ it 'sends an unfollow activity' do
+ expect(a_request(:post, 'http://example.com/inbox')).to have_been_made.once
+ end
+ end
end
diff --git a/spec/services/unsubscribe_service_spec.rb b/spec/services/unsubscribe_service_spec.rb
index c81772037..2a02f4c75 100644
--- a/spec/services/unsubscribe_service_spec.rb
+++ b/spec/services/unsubscribe_service_spec.rb
@@ -26,7 +26,7 @@ RSpec.describe UnsubscribeService do
stub_request(:post, 'http://hub.example.com/').to_raise(HTTP::Error)
subject.call(account)
- expect(logger).to have_received(:debug).with(/PuSH subscription request for bob@example.com could not be made due to HTTP or SSL error/)
+ expect(logger).to have_received(:debug).with(/unsubscribe for bob@example.com failed/)
end
def stub_logger
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
index 2bc462121..eecaec4ac 100644
--- a/spec/spec_helper.rb
+++ b/spec/spec_helper.rb
@@ -1,11 +1,15 @@
require 'simplecov'
+GC.disable
+
SimpleCov.start 'rails' do
add_group 'Services', 'app/services'
add_group 'Presenters', 'app/presenters'
add_group 'Validators', 'app/validators'
end
+gc_counter = -1
+
RSpec.configure do |config|
config.expect_with :rspec do |expectations|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
@@ -22,8 +26,21 @@ RSpec.configure do |config|
end
config.after :suite do
+ gc_counter = 0
FileUtils.rm_rf(Dir["#{Rails.root}/spec/test_files/"])
end
+
+ config.after :each do
+ gc_counter += 1
+
+ if gc_counter > 19
+ GC.enable
+ GC.start
+ GC.disable
+
+ gc_counter = 0
+ end
+ end
end
def body_as_json
diff --git a/spec/views/about/show.html.haml_spec.rb b/spec/views/about/show.html.haml_spec.rb
index c0ead6349..aa151dd27 100644
--- a/spec/views/about/show.html.haml_spec.rb
+++ b/spec/views/about/show.html.haml_spec.rb
@@ -13,6 +13,7 @@ describe 'about/show.html.haml', without_verify_partial_doubles: true do
site_title: 'something',
site_description: 'something',
version_number: '1.0',
+ source_url: 'https://github.com/tootsuite/mastodon',
open_registrations: false,
closed_registrations_message: 'yes')
assign(:instance_presenter, instance_presenter)
diff --git a/spec/workers/activitypub/delivery_worker_spec.rb b/spec/workers/activitypub/delivery_worker_spec.rb
new file mode 100644
index 000000000..351be185c
--- /dev/null
+++ b/spec/workers/activitypub/delivery_worker_spec.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe ActivityPub::DeliveryWorker do
+ subject { described_class.new }
+
+ let(:sender) { Fabricate(:account) }
+ let(:payload) { 'test' }
+
+ describe 'perform' do
+ it 'performs a request' do
+ stub_request(:post, 'https://example.com/api').to_return(status: 200)
+ subject.perform(payload, sender.id, 'https://example.com/api')
+ expect(a_request(:post, 'https://example.com/api')).to have_been_made.once
+ end
+
+ it 'raises when request fails' do
+ stub_request(:post, 'https://example.com/api').to_return(status: 500)
+ expect { subject.perform(payload, sender.id, 'https://example.com/api') }.to raise_error Mastodon::UnexpectedResponseError
+ end
+ end
+end
diff --git a/spec/workers/activitypub/distribution_worker_spec.rb b/spec/workers/activitypub/distribution_worker_spec.rb
new file mode 100644
index 000000000..368ca025a
--- /dev/null
+++ b/spec/workers/activitypub/distribution_worker_spec.rb
@@ -0,0 +1,48 @@
+require 'rails_helper'
+
+describe ActivityPub::DistributionWorker do
+ subject { described_class.new }
+
+ let(:status) { Fabricate(:status) }
+ let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
+
+ describe '#perform' do
+ before do
+ allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
+ follower.follow!(status.account)
+ end
+
+ context 'with public status' do
+ before do
+ status.update(visibility: :public)
+ end
+
+ it 'delivers to followers' do
+ subject.perform(status.id)
+ expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
+ end
+ end
+
+ context 'with private status' do
+ before do
+ status.update(visibility: :private)
+ end
+
+ it 'delivers to followers' do
+ subject.perform(status.id)
+ expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
+ end
+ end
+
+ context 'with direct status' do
+ before do
+ status.update(visibility: :direct)
+ end
+
+ it 'does nothing' do
+ subject.perform(status.id)
+ expect(ActivityPub::DeliveryWorker).to_not have_received(:push_bulk)
+ end
+ end
+ end
+end
diff --git a/spec/workers/activitypub/processing_worker_spec.rb b/spec/workers/activitypub/processing_worker_spec.rb
new file mode 100644
index 000000000..b42c0bdbc
--- /dev/null
+++ b/spec/workers/activitypub/processing_worker_spec.rb
@@ -0,0 +1,15 @@
+require 'rails_helper'
+
+describe ActivityPub::ProcessingWorker do
+ subject { described_class.new }
+
+ let(:account) { Fabricate(:account) }
+
+ describe '#perform' do
+ it 'delegates to ActivityPub::ProcessCollectionService' do
+ allow(ActivityPub::ProcessCollectionService).to receive(:new).and_return(double(:service, call: nil))
+ subject.perform(account.id, '')
+ expect(ActivityPub::ProcessCollectionService).to have_received(:new)
+ end
+ end
+end
diff --git a/spec/workers/activitypub/update_distribution_worker_spec.rb b/spec/workers/activitypub/update_distribution_worker_spec.rb
new file mode 100644
index 000000000..688a424d5
--- /dev/null
+++ b/spec/workers/activitypub/update_distribution_worker_spec.rb
@@ -0,0 +1,20 @@
+require 'rails_helper'
+
+describe ActivityPub::UpdateDistributionWorker do
+ subject { described_class.new }
+
+ let(:account) { Fabricate(:account) }
+ let(:follower) { Fabricate(:account, protocol: :activitypub, inbox_url: 'http://example.com') }
+
+ describe '#perform' do
+ before do
+ allow(ActivityPub::DeliveryWorker).to receive(:push_bulk)
+ follower.follow!(account)
+ end
+
+ it 'delivers to followers' do
+ subject.perform(account.id)
+ expect(ActivityPub::DeliveryWorker).to have_received(:push_bulk).with(['http://example.com'])
+ end
+ end
+end
diff --git a/spec/workers/pubsubhubbub/distribution_worker_spec.rb b/spec/workers/pubsubhubbub/distribution_worker_spec.rb
index 89191c084..5c22e7fa8 100644
--- a/spec/workers/pubsubhubbub/distribution_worker_spec.rb
+++ b/spec/workers/pubsubhubbub/distribution_worker_spec.rb
@@ -22,24 +22,62 @@ describe Pubsubhubbub::DistributionWorker do
end
end
- describe 'with private status' do
- let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) }
+ context 'when OStatus privacy is used' do
+ around do |example|
+ before_val = Rails.configuration.x.use_ostatus_privacy
+ Rails.configuration.x.use_ostatus_privacy = true
+ example.run
+ Rails.configuration.x.use_ostatus_privacy = before_val
+ end
- it 'delivers payload only to subscriptions with followers' do
- allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk)
- subject.perform(status.stream_entry.id)
- expect(Pubsubhubbub::DeliveryWorker).to have_received(:push_bulk).with([subscription_with_follower])
- expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk).with([anonymous_subscription])
+ describe 'with private status' do
+ let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) }
+
+ it 'delivers payload only to subscriptions with followers' do
+ allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk)
+ subject.perform(status.stream_entry.id)
+ expect(Pubsubhubbub::DeliveryWorker).to have_received(:push_bulk).with([subscription_with_follower])
+ expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk).with([anonymous_subscription])
+ end
+ end
+
+ describe 'with direct status' do
+ let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) }
+
+ it 'does not deliver payload' do
+ allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk)
+ subject.perform(status.stream_entry.id)
+ expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk)
+ end
end
end
- describe 'with direct status' do
- let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) }
+ context 'when OStatus privacy is not used' do
+ around do |example|
+ before_val = Rails.configuration.x.use_ostatus_privacy
+ Rails.configuration.x.use_ostatus_privacy = false
+ example.run
+ Rails.configuration.x.use_ostatus_privacy = before_val
+ end
- it 'does not deliver payload' do
- allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk)
- subject.perform(status.stream_entry.id)
- expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk)
+ describe 'with private status' do
+ let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :private) }
+
+ it 'does not deliver anything' do
+ allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk)
+ subject.perform(status.stream_entry.id)
+ expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk)
+ end
+ end
+
+ describe 'with direct status' do
+ let(:status) { Fabricate(:status, account: alice, text: 'Hello', visibility: :direct) }
+
+ it 'does not deliver payload' do
+ allow(Pubsubhubbub::DeliveryWorker).to receive(:push_bulk)
+ subject.perform(status.stream_entry.id)
+ expect(Pubsubhubbub::DeliveryWorker).to_not have_received(:push_bulk)
+ end
end
end
end
diff --git a/streaming/index.js b/streaming/index.js
index c7e0de96c..3e80c8b30 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -403,11 +403,11 @@ const startWorker = (workerId) => {
});
app.get('/api/v1/streaming/hashtag', (req, res) => {
- streamFrom(`timeline:hashtag:${req.query.tag}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
+ streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}`, req, streamToHttp(req, res), streamHttpEnd(req), true);
});
app.get('/api/v1/streaming/hashtag/local', (req, res) => {
- streamFrom(`timeline:hashtag:${req.query.tag}:local`, req, streamToHttp(req, res), streamHttpEnd(req), true);
+ streamFrom(`timeline:hashtag:${req.query.tag.toLowerCase()}:local`, req, streamToHttp(req, res), streamHttpEnd(req), true);
});
const wss = new WebSocket.Server({ server, verifyClient: wsVerifyClient });
@@ -438,10 +438,10 @@ const startWorker = (workerId) => {
streamFrom('timeline:public:local', req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
case 'hashtag':
- streamFrom(`timeline:hashtag:${location.query.tag}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+ streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
case 'hashtag:local':
- streamFrom(`timeline:hashtag:${location.query.tag}:local`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
+ streamFrom(`timeline:hashtag:${location.query.tag.toLowerCase()}:local`, req, streamToWs(req, ws), streamWsEnd(req, ws), true);
break;
default:
ws.close();
diff --git a/yarn.lock b/yarn.lock
index cfb0f5175..c1c27a615 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3130,23 +3130,17 @@ intl-messageformat-parser@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/intl-messageformat-parser/-/intl-messageformat-parser-1.3.0.tgz#c5d26ffb894c7d9c2b9fa444c67f417ab2594268"
-intl-messageformat@1.3.0, intl-messageformat@^1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-1.3.0.tgz#f7d926aded7a3ab19b2dc601efd54e99a4bd4eae"
- dependencies:
- intl-messageformat-parser "1.2.0"
-
intl-messageformat@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-2.0.0.tgz#3d56982583425aee23b76c8b985fb9b0aae5be3c"
dependencies:
intl-messageformat-parser "1.2.0"
-intl-relativeformat@^1.3.0:
- version "1.3.0"
- resolved "https://registry.yarnpkg.com/intl-relativeformat/-/intl-relativeformat-1.3.0.tgz#893dc7076fccd380cf091a2300c380fa57ace45b"
+intl-messageformat@^2.1.0:
+ version "2.1.0"
+ resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-2.1.0.tgz#1c51da76f02a3f7b360654cdc51bbc4d3fa6c72c"
dependencies:
- intl-messageformat "1.3.0"
+ intl-messageformat-parser "1.2.0"
intl-relativeformat@^2.0.0:
version "2.0.0"
@@ -5312,13 +5306,13 @@ react-intl-translations-manager@^5.0.0:
json-stable-stringify "^1.0.1"
mkdirp "^0.5.1"
-react-intl@^2.3.0:
- version "2.3.0"
- resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-2.3.0.tgz#e1df6af5667fdf01cbe4aab20e137251e2ae5142"
+react-intl@^2.4.0:
+ version "2.4.0"
+ resolved "https://registry.yarnpkg.com/react-intl/-/react-intl-2.4.0.tgz#66c14dc9df9a73b2fbbfbd6021726e80a613eb15"
dependencies:
intl-format-cache "^2.0.5"
- intl-messageformat "^1.3.0"
- intl-relativeformat "^1.3.0"
+ intl-messageformat "^2.1.0"
+ intl-relativeformat "^2.0.0"
invariant "^2.1.1"
react-motion@^0.5.0: