about summary refs log tree commit diff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/controllers/activitypub/collections_controller.rb16
-rw-r--r--app/controllers/activitypub/outboxes_controller.rb6
-rw-r--r--app/controllers/follower_accounts_controller.rb7
-rw-r--r--app/controllers/following_accounts_controller.rb8
-rw-r--r--app/javascript/core/settings.js8
-rw-r--r--app/javascript/flavours/glitch/components/poll.js33
-rw-r--r--app/javascript/flavours/glitch/components/relative_timestamp.js34
-rw-r--r--app/javascript/mastodon/components/poll.js33
-rw-r--r--app/javascript/mastodon/components/relative_timestamp.js30
-rw-r--r--app/javascript/mastodon/locales/ca.json34
-rw-r--r--app/javascript/mastodon/locales/cs.json2
-rw-r--r--app/javascript/mastodon/locales/eo.json42
-rw-r--r--app/javascript/mastodon/locales/pt-BR.json54
-rw-r--r--app/models/export.rb6
-rw-r--r--app/services/import_service.rb23
-rw-r--r--app/workers/import/relationship_worker.rb4
16 files changed, 192 insertions, 148 deletions
diff --git a/app/controllers/activitypub/collections_controller.rb b/app/controllers/activitypub/collections_controller.rb
index 995da9c55..853f4f907 100644
--- a/app/controllers/activitypub/collections_controller.rb
+++ b/app/controllers/activitypub/collections_controller.rb
@@ -6,13 +6,19 @@ class ActivityPub::CollectionsController < Api::BaseController
   before_action :set_account
   before_action :set_size
   before_action :set_statuses
+  before_action :set_cache_headers
 
   def show
-    render json: collection_presenter,
-           serializer: ActivityPub::CollectionSerializer,
-           adapter: ActivityPub::Adapter,
-           content_type: 'application/activity+json',
-           skip_activities: true
+    skip_session!
+
+    render_cached_json(['activitypub', 'collection', @account, params[:id]], content_type: 'application/activity+json') do
+      ActiveModelSerializers::SerializableResource.new(
+        collection_presenter,
+        serializer: ActivityPub::CollectionSerializer,
+        adapter: ActivityPub::Adapter,
+        skip_activities: true
+      )
+    end
   end
 
   private
diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb
index be4289b21..438fa226e 100644
--- a/app/controllers/activitypub/outboxes_controller.rb
+++ b/app/controllers/activitypub/outboxes_controller.rb
@@ -7,8 +7,14 @@ class ActivityPub::OutboxesController < Api::BaseController
 
   before_action :set_account
   before_action :set_statuses
+  before_action :set_cache_headers
 
   def show
+    unless page_requested?
+      skip_session!
+      expires_in 1.minute, public: true
+    end
+
     render json: outbox_presenter, serializer: ActivityPub::OutboxSerializer, adapter: ActivityPub::Adapter, content_type: 'application/activity+json'
   end
 
diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb
index 213c209ab..1462b94fc 100644
--- a/app/controllers/follower_accounts_controller.rb
+++ b/app/controllers/follower_accounts_controller.rb
@@ -3,6 +3,8 @@
 class FollowerAccountsController < ApplicationController
   include AccountControllerConcern
 
+  before_action :set_cache_headers
+
   def index
     respond_to do |format|
       format.html do
@@ -18,6 +20,11 @@ class FollowerAccountsController < ApplicationController
       format.json do
         raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
 
+        if params[:page].blank?
+          skip_session!
+          expires_in 3.minutes, public: true
+        end
+
         render json: collection_presenter,
                serializer: ActivityPub::CollectionSerializer,
                adapter: ActivityPub::Adapter,
diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb
index 098b2a20c..181f85221 100644
--- a/app/controllers/following_accounts_controller.rb
+++ b/app/controllers/following_accounts_controller.rb
@@ -3,10 +3,13 @@
 class FollowingAccountsController < ApplicationController
   include AccountControllerConcern
 
+  before_action :set_cache_headers
+
   def index
     respond_to do |format|
       format.html do
         use_pack 'public'
+        mark_cacheable! unless user_signed_in?
 
         next if @account.user_hides_network?
 
@@ -17,6 +20,11 @@ class FollowingAccountsController < ApplicationController
       format.json do
         raise Mastodon::NotPermittedError if params[:page].present? && @account.user_hides_network?
 
+        if params[:page].blank?
+          skip_session!
+          expires_in 3.minutes, public: true
+        end
+
         render json: collection_presenter,
                serializer: ActivityPub::CollectionSerializer,
                adapter: ActivityPub::Adapter,
diff --git a/app/javascript/core/settings.js b/app/javascript/core/settings.js
index af97fb25f..e0cb944e0 100644
--- a/app/javascript/core/settings.js
+++ b/app/javascript/core/settings.js
@@ -42,14 +42,20 @@ delegate(document, '#account_locked', 'change', ({ target }) => {
 });
 
 delegate(document, '.input-copy input', 'click', ({ target }) => {
+  target.focus();
   target.select();
+  target.setSelectionRange(0, target.value.length);
 });
 
 delegate(document, '.input-copy button', 'click', ({ target }) => {
   const input = target.parentNode.querySelector('.input-copy__wrapper input');
 
+  const oldReadOnly = input.readonly;
+
+  input.readonly = false;
   input.focus();
   input.select();
+  input.setSelectionRange(0, input.value.length);
 
   try {
     if (document.execCommand('copy')) {
@@ -63,4 +69,6 @@ delegate(document, '.input-copy button', 'click', ({ target }) => {
   } catch (err) {
     console.error(err);
   }
+
+  input.readonly = oldReadOnly;
 });
diff --git a/app/javascript/flavours/glitch/components/poll.js b/app/javascript/flavours/glitch/components/poll.js
index 56331cb29..690f9ae5a 100644
--- a/app/javascript/flavours/glitch/components/poll.js
+++ b/app/javascript/flavours/glitch/components/poll.js
@@ -9,41 +9,12 @@ import Motion from 'mastodon/features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import escapeTextContentForBrowser from 'escape-html';
 import emojify from 'mastodon/features/emoji/emoji';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
-  moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
-  seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
-  minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
-  hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
-  days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
   closed: { id: 'poll.closed', defaultMessage: 'Closed' },
 });
 
-const SECOND = 1000;
-const MINUTE = 1000 * 60;
-const HOUR   = 1000 * 60 * 60;
-const DAY    = 1000 * 60 * 60 * 24;
-
-const timeRemainingString = (intl, date, now) => {
-  const delta = date.getTime() - now;
-
-  let relativeTime;
-
-  if (delta < 10 * SECOND) {
-    relativeTime = intl.formatMessage(messages.moments);
-  } else if (delta < MINUTE) {
-    relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
-  } else if (delta < HOUR) {
-    relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
-  } else if (delta < DAY) {
-    relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
-  } else {
-    relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
-  }
-
-  return relativeTime;
-};
-
 const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
   obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
   return obj;
@@ -146,7 +117,7 @@ class Poll extends ImmutablePureComponent {
       return null;
     }
 
-    const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now());
+    const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
     const showResults   = poll.get('voted') || poll.get('expired');
     const disabled      = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
 
diff --git a/app/javascript/flavours/glitch/components/relative_timestamp.js b/app/javascript/flavours/glitch/components/relative_timestamp.js
index 9609714a1..aa4b73cfe 100644
--- a/app/javascript/flavours/glitch/components/relative_timestamp.js
+++ b/app/javascript/flavours/glitch/components/relative_timestamp.js
@@ -8,6 +8,11 @@ const messages = defineMessages({
   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
   days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+  moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
+  seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
+  minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
+  hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
+  days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
 });
 
 const dateFormatOptions = {
@@ -86,13 +91,34 @@ export const timeAgoString = (intl, date, now, year) => {
   return relativeTime;
 };
 
-@injectIntl
-export default class RelativeTimestamp extends React.Component {
+const timeRemainingString = (intl, date, now) => {
+  const delta = date.getTime() - now;
+
+  let relativeTime;
+
+  if (delta < 10 * SECOND) {
+    relativeTime = intl.formatMessage(messages.moments_remaining);
+  } else if (delta < MINUTE) {
+    relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
+  } else if (delta < HOUR) {
+    relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
+  } else if (delta < DAY) {
+    relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
+  } else {
+    relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
+  }
+
+  return relativeTime;
+};
+
+export default @injectIntl
+class RelativeTimestamp extends React.Component {
 
   static propTypes = {
     intl: PropTypes.object.isRequired,
     timestamp: PropTypes.string.isRequired,
     year: PropTypes.number.isRequired,
+    futureDate: PropTypes.bool,
   };
 
   state = {
@@ -145,10 +171,10 @@ export default class RelativeTimestamp extends React.Component {
   }
 
   render () {
-    const { timestamp, intl, year } = this.props;
+    const { timestamp, intl, year, futureDate } = this.props;
 
     const date         = new Date(timestamp);
-    const relativeTime = timeAgoString(intl, date, this.state.now, year);
+    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
 
     return (
       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
diff --git a/app/javascript/mastodon/components/poll.js b/app/javascript/mastodon/components/poll.js
index 56331cb29..690f9ae5a 100644
--- a/app/javascript/mastodon/components/poll.js
+++ b/app/javascript/mastodon/components/poll.js
@@ -9,41 +9,12 @@ import Motion from 'mastodon/features/ui/util/optional_motion';
 import spring from 'react-motion/lib/spring';
 import escapeTextContentForBrowser from 'escape-html';
 import emojify from 'mastodon/features/emoji/emoji';
+import RelativeTimestamp from './relative_timestamp';
 
 const messages = defineMessages({
-  moments: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
-  seconds: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
-  minutes: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
-  hours: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
-  days: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
   closed: { id: 'poll.closed', defaultMessage: 'Closed' },
 });
 
-const SECOND = 1000;
-const MINUTE = 1000 * 60;
-const HOUR   = 1000 * 60 * 60;
-const DAY    = 1000 * 60 * 60 * 24;
-
-const timeRemainingString = (intl, date, now) => {
-  const delta = date.getTime() - now;
-
-  let relativeTime;
-
-  if (delta < 10 * SECOND) {
-    relativeTime = intl.formatMessage(messages.moments);
-  } else if (delta < MINUTE) {
-    relativeTime = intl.formatMessage(messages.seconds, { number: Math.floor(delta / SECOND) });
-  } else if (delta < HOUR) {
-    relativeTime = intl.formatMessage(messages.minutes, { number: Math.floor(delta / MINUTE) });
-  } else if (delta < DAY) {
-    relativeTime = intl.formatMessage(messages.hours, { number: Math.floor(delta / HOUR) });
-  } else {
-    relativeTime = intl.formatMessage(messages.days, { number: Math.floor(delta / DAY) });
-  }
-
-  return relativeTime;
-};
-
 const makeEmojiMap = record => record.get('emojis').reduce((obj, emoji) => {
   obj[`:${emoji.get('shortcode')}:`] = emoji.toJS();
   return obj;
@@ -146,7 +117,7 @@ class Poll extends ImmutablePureComponent {
       return null;
     }
 
-    const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : timeRemainingString(intl, new Date(poll.get('expires_at')), intl.now());
+    const timeRemaining = poll.get('expired') ? intl.formatMessage(messages.closed) : <RelativeTimestamp timestamp={poll.get('expires_at')} futureDate />;
     const showResults   = poll.get('voted') || poll.get('expired');
     const disabled      = this.props.disabled || Object.entries(this.state.selected).every(item => !item);
 
diff --git a/app/javascript/mastodon/components/relative_timestamp.js b/app/javascript/mastodon/components/relative_timestamp.js
index 57d99dd19..aa4b73cfe 100644
--- a/app/javascript/mastodon/components/relative_timestamp.js
+++ b/app/javascript/mastodon/components/relative_timestamp.js
@@ -8,6 +8,11 @@ const messages = defineMessages({
   minutes: { id: 'relative_time.minutes', defaultMessage: '{number}m' },
   hours: { id: 'relative_time.hours', defaultMessage: '{number}h' },
   days: { id: 'relative_time.days', defaultMessage: '{number}d' },
+  moments_remaining: { id: 'time_remaining.moments', defaultMessage: 'Moments remaining' },
+  seconds_remaining: { id: 'time_remaining.seconds', defaultMessage: '{number, plural, one {# second} other {# seconds}} left' },
+  minutes_remaining: { id: 'time_remaining.minutes', defaultMessage: '{number, plural, one {# minute} other {# minutes}} left' },
+  hours_remaining: { id: 'time_remaining.hours', defaultMessage: '{number, plural, one {# hour} other {# hours}} left' },
+  days_remaining: { id: 'time_remaining.days', defaultMessage: '{number, plural, one {# day} other {# days}} left' },
 });
 
 const dateFormatOptions = {
@@ -86,6 +91,26 @@ export const timeAgoString = (intl, date, now, year) => {
   return relativeTime;
 };
 
+const timeRemainingString = (intl, date, now) => {
+  const delta = date.getTime() - now;
+
+  let relativeTime;
+
+  if (delta < 10 * SECOND) {
+    relativeTime = intl.formatMessage(messages.moments_remaining);
+  } else if (delta < MINUTE) {
+    relativeTime = intl.formatMessage(messages.seconds_remaining, { number: Math.floor(delta / SECOND) });
+  } else if (delta < HOUR) {
+    relativeTime = intl.formatMessage(messages.minutes_remaining, { number: Math.floor(delta / MINUTE) });
+  } else if (delta < DAY) {
+    relativeTime = intl.formatMessage(messages.hours_remaining, { number: Math.floor(delta / HOUR) });
+  } else {
+    relativeTime = intl.formatMessage(messages.days_remaining, { number: Math.floor(delta / DAY) });
+  }
+
+  return relativeTime;
+};
+
 export default @injectIntl
 class RelativeTimestamp extends React.Component {
 
@@ -93,6 +118,7 @@ class RelativeTimestamp extends React.Component {
     intl: PropTypes.object.isRequired,
     timestamp: PropTypes.string.isRequired,
     year: PropTypes.number.isRequired,
+    futureDate: PropTypes.bool,
   };
 
   state = {
@@ -145,10 +171,10 @@ class RelativeTimestamp extends React.Component {
   }
 
   render () {
-    const { timestamp, intl, year } = this.props;
+    const { timestamp, intl, year, futureDate } = this.props;
 
     const date         = new Date(timestamp);
-    const relativeTime = timeAgoString(intl, date, this.state.now, year);
+    const relativeTime = futureDate ? timeRemainingString(intl, date, this.state.now) : timeAgoString(intl, date, this.state.now, year);
 
     return (
       <time dateTime={timestamp} title={intl.formatDate(date, dateFormatOptions)}>
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 6c98c6ae7..c6b71a4f8 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -71,10 +71,10 @@
   "compose_form.lock_disclaimer": "El teu compte no està bloquejat {locked}. Tothom pot seguir-te i veure els teus missatges a seguidors.",
   "compose_form.lock_disclaimer.lock": "blocat",
   "compose_form.placeholder": "En què estàs pensant?",
-  "compose_form.poll.add_option": "Add a choice",
-  "compose_form.poll.duration": "Poll duration",
-  "compose_form.poll.option_placeholder": "Choice {number}",
-  "compose_form.poll.remove_option": "Remove this choice",
+  "compose_form.poll.add_option": "Afegeix una opció",
+  "compose_form.poll.duration": "Durada de l'enquesta",
+  "compose_form.poll.option_placeholder": "Opció {number}",
+  "compose_form.poll.remove_option": "Elimina aquesta opció",
   "compose_form.publish": "Toot",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.marked": "Mèdia marcat com a sensible",
@@ -108,7 +108,7 @@
   "emoji_button.food": "Menjar i beure",
   "emoji_button.label": "Insereix un emoji",
   "emoji_button.nature": "Natura",
-  "emoji_button.not_found": "Emojos no!! (╯°□°)╯︵ ┻━┻",
+  "emoji_button.not_found": "Emojis no!! (╯°□°)╯︵ ┻━┻",
   "emoji_button.objects": "Objectes",
   "emoji_button.people": "Gent",
   "emoji_button.recent": "Usats freqüentment",
@@ -154,8 +154,8 @@
   "home.column_settings.basic": "Bàsic",
   "home.column_settings.show_reblogs": "Mostrar impulsos",
   "home.column_settings.show_replies": "Mostrar respostes",
-  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
-  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
+  "intervals.full.days": "{number, plural, one {# dia} other {# dies}}",
+  "intervals.full.hours": "{number, plural, one {# hora} other {# hores}}",
   "intervals.full.minutes": "{number, plural, one {# minut} other {# minuts}}",
   "introduction.federation.action": "Següent",
   "introduction.federation.federated.headline": "Federada",
@@ -179,14 +179,14 @@
   "keyboard_shortcuts.boost": "impulsar",
   "keyboard_shortcuts.column": "per centrar un estat en una de les columnes",
   "keyboard_shortcuts.compose": "per centrar l'area de composició de text",
-  "keyboard_shortcuts.description": "Description",
+  "keyboard_shortcuts.description": "Descripció",
   "keyboard_shortcuts.direct": "per obrir la columna de missatges directes",
   "keyboard_shortcuts.down": "per baixar en la llista",
   "keyboard_shortcuts.enter": "ampliar estat",
   "keyboard_shortcuts.favourite": "afavorir",
   "keyboard_shortcuts.favourites": "per obrir la llista de favorits",
   "keyboard_shortcuts.federated": "per obrir la línia de temps federada",
-  "keyboard_shortcuts.heading": "Keyboard Shortcuts",
+  "keyboard_shortcuts.heading": "Dreçeres de teclat",
   "keyboard_shortcuts.home": "per obrir la línia de temps Inici",
   "keyboard_shortcuts.hotkey": "Tecla d'accés directe",
   "keyboard_shortcuts.legend": "per a mostrar aquesta llegenda",
@@ -247,7 +247,7 @@
   "notification.follow": "{name} et segueix",
   "notification.mention": "{name} t'ha esmentat",
   "notification.poll": "Ha finalitzat una enquesta en la que has votat",
-  "notification.reblog": "{name} ha retootejat el teu estat",
+  "notification.reblog": "{name} ha impulsat el teu estat",
   "notifications.clear": "Netejar notificacions",
   "notifications.clear_confirmation": "Estàs segur que vols esborrar permanenment totes les teves notificacions?",
   "notifications.column_settings.alert": "Notificacions d'escriptori",
@@ -258,7 +258,7 @@
   "notifications.column_settings.follow": "Nous seguidors:",
   "notifications.column_settings.mention": "Mencions:",
   "notifications.column_settings.poll": "Resultats de l’enquesta:",
-  "notifications.column_settings.push": "Push notificacions",
+  "notifications.column_settings.push": "Notificacions push",
   "notifications.column_settings.reblog": "Impulsos:",
   "notifications.column_settings.show": "Mostrar en la columna",
   "notifications.column_settings.sound": "Reproduïr so",
@@ -273,8 +273,8 @@
   "poll.refresh": "Actualitza",
   "poll.total_votes": "{count, plural, one {# vot} other {# vots}}",
   "poll.vote": "Vota",
-  "poll_button.add_poll": "Add a poll",
-  "poll_button.remove_poll": "Remove poll",
+  "poll_button.add_poll": "Afegeix una enquesta",
+  "poll_button.remove_poll": "Elimina l'enquesta",
   "privacy.change": "Ajusta l'estat de privacitat",
   "privacy.direct.long": "Publicar només per als usuaris esmentats",
   "privacy.direct.short": "Directe",
@@ -311,9 +311,9 @@
   "search_results.total": "{count, number} {count, plural, un {result} altres {results}}",
   "status.admin_account": "Obre l'interfície de moderació per a @{name}",
   "status.admin_status": "Obre aquest estat a la interfície de moderació",
-  "status.block": "Block @{name}",
+  "status.block": "Bloqueja @{name}",
   "status.cancel_reblog_private": "Desfer l'impuls",
-  "status.cannot_reblog": "Aquesta publicació no pot ser retootejada",
+  "status.cannot_reblog": "Aquesta publicació no pot ser impulsada",
   "status.copy": "Copia l'enllaç a l'estat",
   "status.delete": "Esborrar",
   "status.detailed_status": "Visualització detallada de la conversa",
@@ -333,7 +333,7 @@
   "status.read_more": "Llegir més",
   "status.reblog": "Impuls",
   "status.reblog_private": "Impulsar a l'audiència original",
-  "status.reblogged_by": "{name} ha retootejat",
+  "status.reblogged_by": "{name} ha impulsat",
   "status.reblogs.empty": "Encara ningú no ha impulsat aquest toot. Quan algú ho faci, apareixeran aquí.",
   "status.redraft": "Esborrar i reescriure",
   "status.reply": "Respondre",
@@ -366,7 +366,7 @@
   "upload_area.title": "Arrossega i deixa anar per carregar",
   "upload_button.label": "Afegir multimèdia (JPEG, PNG, GIF, WebM, MP4, MOV)",
   "upload_error.limit": "S'ha superat el límit de càrrega d'arxius.",
-  "upload_error.poll": "File upload not allowed with polls.",
+  "upload_error.poll": "No es permet l'enviament de fitxers amb les enquestes.",
   "upload_form.description": "Descriure els problemes visuals",
   "upload_form.focus": "Modificar la previsualització",
   "upload_form.undo": "Esborra",
diff --git a/app/javascript/mastodon/locales/cs.json b/app/javascript/mastodon/locales/cs.json
index e711961e7..9701bd695 100644
--- a/app/javascript/mastodon/locales/cs.json
+++ b/app/javascript/mastodon/locales/cs.json
@@ -318,7 +318,7 @@
   "status.delete": "Smazat",
   "status.detailed_status": "Detailní zobrazení konverzace",
   "status.direct": "Poslat přímou zprávu uživateli @{name}",
-  "status.embed": "Vložit",
+  "status.embed": "Vložit na web",
   "status.favourite": "Oblíbit",
   "status.filtered": "Filtrováno",
   "status.load_more": "Zobrazit více",
diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json
index dd2188d8e..ed0113650 100644
--- a/app/javascript/mastodon/locales/eo.json
+++ b/app/javascript/mastodon/locales/eo.json
@@ -71,10 +71,10 @@
   "compose_form.lock_disclaimer": "Via konta ne estas {locked}. Iu ajn povas sekvi vin por vidi viajn mesaĝojn, kiuj estas nur por sekvantoj.",
   "compose_form.lock_disclaimer.lock": "ŝlosita",
   "compose_form.placeholder": "Pri kio vi pensas?",
-  "compose_form.poll.add_option": "Add a choice",
-  "compose_form.poll.duration": "Poll duration",
-  "compose_form.poll.option_placeholder": "Choice {number}",
-  "compose_form.poll.remove_option": "Remove this choice",
+  "compose_form.poll.add_option": "Aldoni elekto",
+  "compose_form.poll.duration": "Balotenketo daŭro",
+  "compose_form.poll.option_placeholder": "elekto {number}",
+  "compose_form.poll.remove_option": "Forigi ĉi tiu elekton",
   "compose_form.publish": "Hup",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.marked": "Aŭdovidaĵo markita tikla",
@@ -83,7 +83,7 @@
   "compose_form.spoiler.unmarked": "Teksto ne kaŝita",
   "compose_form.spoiler_placeholder": "Skribu vian averton ĉi tie",
   "confirmation_modal.cancel": "Nuligi",
-  "confirmations.block.block_and_report": "Block & Report",
+  "confirmations.block.block_and_report": "Bloki & Signali",
   "confirmations.block.confirm": "Bloki",
   "confirmations.block.message": "Ĉu vi certas, ke vi volas bloki {name}?",
   "confirmations.delete.confirm": "Forigi",
@@ -154,15 +154,15 @@
   "home.column_settings.basic": "Bazaj agordoj",
   "home.column_settings.show_reblogs": "Montri diskonigojn",
   "home.column_settings.show_replies": "Montri respondojn",
-  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
-  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
+  "intervals.full.days": "{number, plural, one {# tago} other {# tagoj}}",
+  "intervals.full.hours": "{number, plural, one {# horo} other {# horoj}}",
   "intervals.full.minutes": "{number, plural, one {# minuto} other {# minutoj}}",
   "introduction.federation.action": "Sekva",
-  "introduction.federation.federated.headline": "Federated",
+  "introduction.federation.federated.headline": "Federacio",
   "introduction.federation.federated.text": "Publikaj mesaĝoj el aliaj serviloj de la Fediverse aperos en la fratara tempolinio.",
-  "introduction.federation.home.headline": "Home",
+  "introduction.federation.home.headline": "Heimo",
   "introduction.federation.home.text": "Mesaĝoj de homoj, kiujn vi sekvas, aperos en via hejma fluo. Vi povas sekvi iun ajn de ajna servilo!",
-  "introduction.federation.local.headline": "Local",
+  "introduction.federation.local.headline": "Loka",
   "introduction.federation.local.text": "Publikaj mesaĝoj de homoj de via servilo aperos en la loka tempolinio.",
   "introduction.interactions.action": "Fini la lernilon!",
   "introduction.interactions.favourite.headline": "Stelumi",
@@ -246,7 +246,7 @@
   "notification.favourite": "{name} stelumis vian mesaĝon",
   "notification.follow": "{name} eksekvis vin",
   "notification.mention": "{name} menciis vin",
-  "notification.poll": "A poll you have voted in has ended",
+  "notification.poll": "Balotenketo ke vi balotis estas finita",
   "notification.reblog": "{name} diskonigis vian mesaĝon",
   "notifications.clear": "Forviŝi sciigojn",
   "notifications.clear_confirmation": "Ĉu vi certas, ke vi volas porĉiame forviŝi ĉiujn viajn sciigojn?",
@@ -257,7 +257,7 @@
   "notifications.column_settings.filter_bar.show": "Montri",
   "notifications.column_settings.follow": "Novaj sekvantoj:",
   "notifications.column_settings.mention": "Mencioj:",
-  "notifications.column_settings.poll": "Poll results:",
+  "notifications.column_settings.poll": "Balotenketo rezulto:",
   "notifications.column_settings.push": "Puŝsciigoj",
   "notifications.column_settings.reblog": "Diskonigoj:",
   "notifications.column_settings.show": "Montri en kolumno",
@@ -267,14 +267,14 @@
   "notifications.filter.favourites": "Stelumoj",
   "notifications.filter.follows": "Sekvoj",
   "notifications.filter.mentions": "Mencioj",
-  "notifications.filter.polls": "Poll results",
+  "notifications.filter.polls": "Balotenketoj rezultoj",
   "notifications.group": "{count} sciigoj",
   "poll.closed": "Finita",
   "poll.refresh": "Aktualigi",
   "poll.total_votes": "{count, plural, one {# voĉdono} other {# voĉdonoj}}",
   "poll.vote": "Voĉdoni",
-  "poll_button.add_poll": "Add a poll",
-  "poll_button.remove_poll": "Remove poll",
+  "poll_button.add_poll": "Aldoni balotenketon",
+  "poll_button.remove_poll": "Forigi balotenketon",
   "privacy.change": "Agordi mesaĝan privatecon",
   "privacy.direct.long": "Afiŝi nur al menciitaj uzantoj",
   "privacy.direct.short": "Rekta",
@@ -356,17 +356,17 @@
   "tabs_bar.local_timeline": "Loka tempolinio",
   "tabs_bar.notifications": "Sciigoj",
   "tabs_bar.search": "Serĉi",
-  "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
-  "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
-  "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
-  "time_remaining.moments": "Moments remaining",
-  "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "time_remaining.days": "{number, plural, one {# tago} other {# tagoj}} restanta",
+  "time_remaining.hours": "{number, plural, one {# horo} other {# horoj}} restanta",
+  "time_remaining.minutes": "{number, plural, one {# minuto} other {# minutoj}} restanta",
+  "time_remaining.moments": "Momento restanta",
+  "time_remaining.seconds": "{number, plural, one {# sekundo} other {# sekundoj}} restanta",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {persono} other {personoj}} parolas",
   "ui.beforeunload": "Via malneto perdiĝos se vi eliras de Mastodon.",
   "upload_area.title": "Altreni kaj lasi por alŝuti",
   "upload_button.label": "Aldoni aŭdovidaĵon (JPEG, PNG, GIF, WebM, MP4, MOV)",
   "upload_error.limit": "Limo de dosiera alŝutado transpasita.",
-  "upload_error.poll": "File upload not allowed with polls.",
+  "upload_error.poll": "Alŝuto de dosiero ne permisita kun balotenketo",
   "upload_form.description": "Priskribi por misvidantaj homoj",
   "upload_form.focus": "Antaŭvido de ŝanĝo",
   "upload_form.undo": "Forigi",
diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json
index 7533a462b..c2bac0f07 100644
--- a/app/javascript/mastodon/locales/pt-BR.json
+++ b/app/javascript/mastodon/locales/pt-BR.json
@@ -71,10 +71,10 @@
   "compose_form.lock_disclaimer": "A sua conta não está {locked}. Qualquer pessoa pode te seguir e visualizar postagens direcionadas a apenas seguidores.",
   "compose_form.lock_disclaimer.lock": "trancada",
   "compose_form.placeholder": "No que você está pensando?",
-  "compose_form.poll.add_option": "Add a choice",
-  "compose_form.poll.duration": "Poll duration",
-  "compose_form.poll.option_placeholder": "Choice {number}",
-  "compose_form.poll.remove_option": "Remove this choice",
+  "compose_form.poll.add_option": "Adicionar uma opção",
+  "compose_form.poll.duration": "Duração da enquete",
+  "compose_form.poll.option_placeholder": "Opção {number}",
+  "compose_form.poll.remove_option": "Remover essa opção",
   "compose_form.publish": "Publicar",
   "compose_form.publish_loud": "{publish}!",
   "compose_form.sensitive.marked": "Mídia está marcada como sensível",
@@ -83,7 +83,7 @@
   "compose_form.spoiler.unmarked": "O texto não está escondido",
   "compose_form.spoiler_placeholder": "Aviso de conteúdo",
   "confirmation_modal.cancel": "Cancelar",
-  "confirmations.block.block_and_report": "Block & Report",
+  "confirmations.block.block_and_report": "Bloquear e denunciar",
   "confirmations.block.confirm": "Bloquear",
   "confirmations.block.message": "Você tem certeza de que quer bloquear {name}?",
   "confirmations.delete.confirm": "Excluir",
@@ -145,17 +145,17 @@
   "hashtag.column_header.tag_mode.all": "e {additional}",
   "hashtag.column_header.tag_mode.any": "ou {additional}",
   "hashtag.column_header.tag_mode.none": "sem {additional}",
-  "hashtag.column_settings.select.no_options_message": "No suggestions found",
-  "hashtag.column_settings.select.placeholder": "Enter hashtags…",
+  "hashtag.column_settings.select.no_options_message": "Nenhuma sugestão encontrada",
+  "hashtag.column_settings.select.placeholder": "Adicione as hashtags…",
   "hashtag.column_settings.tag_mode.all": "Todas essas",
   "hashtag.column_settings.tag_mode.any": "Qualquer uma dessas",
   "hashtag.column_settings.tag_mode.none": "Nenhuma dessas",
-  "hashtag.column_settings.tag_toggle": "Include additional tags in this column",
+  "hashtag.column_settings.tag_toggle": "Incluir outras hashtags nessa coluna",
   "home.column_settings.basic": "Básico",
   "home.column_settings.show_reblogs": "Mostrar compartilhamentos",
   "home.column_settings.show_replies": "Mostrar as respostas",
-  "intervals.full.days": "{number, plural, one {# day} other {# days}}",
-  "intervals.full.hours": "{number, plural, one {# hour} other {# hours}}",
+  "intervals.full.days": "{number, plural, one {# dia} other {# dias}}",
+  "intervals.full.hours": "{number, plural, one {# hora} other {# horas}}",
   "intervals.full.minutes": "{number, plural, one {# minute} other {# minutes}}",
   "introduction.federation.action": "Próximo",
   "introduction.federation.federated.headline": "Federated",
@@ -212,7 +212,7 @@
   "lists.account.remove": "Remover da lista",
   "lists.delete": "Delete list",
   "lists.edit": "Editar lista",
-  "lists.edit.submit": "Change title",
+  "lists.edit.submit": "Mudar o título",
   "lists.new.create": "Adicionar lista",
   "lists.new.title_placeholder": "Novo título da lista",
   "lists.search": "Procurar entre as pessoas que você segue",
@@ -246,7 +246,7 @@
   "notification.favourite": "{name} adicionou a sua postagem aos favoritos",
   "notification.follow": "{name} te seguiu",
   "notification.mention": "{name} te mencionou",
-  "notification.poll": "A poll you have voted in has ended",
+  "notification.poll": "Uma enquete em que você votou chegou ao fim",
   "notification.reblog": "{name} compartilhou a sua postagem",
   "notifications.clear": "Limpar notificações",
   "notifications.clear_confirmation": "Você tem certeza de que quer limpar todas as suas notificações permanentemente?",
@@ -257,7 +257,7 @@
   "notifications.column_settings.filter_bar.show": "Mostrar",
   "notifications.column_settings.follow": "Novos seguidores:",
   "notifications.column_settings.mention": "Menções:",
-  "notifications.column_settings.poll": "Poll results:",
+  "notifications.column_settings.poll": "Resultados da enquete:",
   "notifications.column_settings.push": "Enviar notificações",
   "notifications.column_settings.reblog": "Compartilhamento:",
   "notifications.column_settings.show": "Mostrar nas colunas",
@@ -267,14 +267,14 @@
   "notifications.filter.favourites": "Favoritos",
   "notifications.filter.follows": "Seguidores",
   "notifications.filter.mentions": "Menções",
-  "notifications.filter.polls": "Poll results",
+  "notifications.filter.polls": "Resultados da enquete",
   "notifications.group": "{count} notificações",
-  "poll.closed": "Closed",
-  "poll.refresh": "Refresh",
-  "poll.total_votes": "{count, plural, one {# vote} other {# votes}}",
+  "poll.closed": "Fechada",
+  "poll.refresh": "Atualizar",
+  "poll.total_votes": "{count, plural, one {# voto} other {# votos}}",
   "poll.vote": "Votar",
-  "poll_button.add_poll": "Add a poll",
-  "poll_button.remove_poll": "Remove poll",
+  "poll_button.add_poll": "Adicionar uma enquete",
+  "poll_button.remove_poll": "Remover enquete",
   "privacy.change": "Ajustar a privacidade da mensagem",
   "privacy.direct.long": "Apenas para usuários mencionados",
   "privacy.direct.short": "Direta",
@@ -314,7 +314,7 @@
   "status.block": "Block @{name}",
   "status.cancel_reblog_private": "Desfazer compartilhamento",
   "status.cannot_reblog": "Esta postagem não pode ser compartilhada",
-  "status.copy": "Copy link to status",
+  "status.copy": "Copiar o link para o status",
   "status.delete": "Excluir",
   "status.detailed_status": "Visão detalhada da conversa",
   "status.direct": "Enviar mensagem direta a @{name}",
@@ -356,17 +356,17 @@
   "tabs_bar.local_timeline": "Local",
   "tabs_bar.notifications": "Notificações",
   "tabs_bar.search": "Buscar",
-  "time_remaining.days": "{number, plural, one {# day} other {# days}} left",
-  "time_remaining.hours": "{number, plural, one {# hour} other {# hours}} left",
-  "time_remaining.minutes": "{number, plural, one {# minute} other {# minutes}} left",
-  "time_remaining.moments": "Moments remaining",
-  "time_remaining.seconds": "{number, plural, one {# second} other {# seconds}} left",
+  "time_remaining.days": "{number, plural, one {# dia restante} other {# dias restantes}}",
+  "time_remaining.hours": "{number, plural, one {# hora restante} other {# horas restantes}}",
+  "time_remaining.minutes": "{number, plural, one {# minuto restante} other {# minutos restantes}}",
+  "time_remaining.moments": "Momentos restantes",
+  "time_remaining.seconds": "{number, plural, one {# segundo restante} other {# segundos restantes}}",
   "trends.count_by_accounts": "{count} {rawCount, plural, one {pessoa} other {pessoas}} falando sobre",
   "ui.beforeunload": "Seu rascunho será perdido se você sair do Mastodon.",
   "upload_area.title": "Arraste e solte para enviar",
   "upload_button.label": "Adicionar mídia (JPEG, PNG, GIF, WebM, MP4, MOV)",
-  "upload_error.limit": "File upload limit exceeded.",
-  "upload_error.poll": "File upload not allowed with polls.",
+  "upload_error.limit": "Limite de envio de arquivos excedido.",
+  "upload_error.poll": "Envio de arquivos não é permitido com enquetes.",
   "upload_form.description": "Descreva a imagem para deficientes visuais",
   "upload_form.focus": "Ajustar foco",
   "upload_form.undo": "Remover",
diff --git a/app/models/export.rb b/app/models/export.rb
index 9bf866d35..b35632c60 100644
--- a/app/models/export.rb
+++ b/app/models/export.rb
@@ -14,7 +14,11 @@ class Export
   end
 
   def to_muted_accounts_csv
-    to_csv account.muting.select(:username, :domain)
+    CSV.generate(headers: ['Account address', 'Hide notifications'], write_headers: true) do |csv|
+      account.mute_relationships.includes(:target_account).reorder(id: :desc).each do |mute|
+        csv << [acct(mute.target_account), mute.hide_notifications]
+      end
+    end
   end
 
   def to_following_accounts_csv
diff --git a/app/services/import_service.rb b/app/services/import_service.rb
index 3f558626e..c1c88e0dd 100644
--- a/app/services/import_service.rb
+++ b/app/services/import_service.rb
@@ -8,7 +8,6 @@ class ImportService < BaseService
   def call(import)
     @import  = import
     @account = @import.account
-    @data    = CSV.new(import_data).reject(&:blank?)
 
     case @import.type
     when 'following'
@@ -25,19 +24,23 @@ class ImportService < BaseService
   private
 
   def import_follows!
+    parse_import_data!(['Account address'])
     import_relationships!('follow', 'unfollow', @account.following, follow_limit)
   end
 
   def import_blocks!
+    parse_import_data!(['Account address'])
     import_relationships!('block', 'unblock', @account.blocking, ROWS_PROCESSING_LIMIT)
   end
 
   def import_mutes!
+    parse_import_data!(['Account address'])
     import_relationships!('mute', 'unmute', @account.muting, ROWS_PROCESSING_LIMIT)
   end
 
   def import_domain_blocks!
-    items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row.first.strip }
+    parse_import_data!(['#domain'])
+    items = @data.take(ROWS_PROCESSING_LIMIT).map { |row| row['#domain'].strip }
 
     if @import.overwrite?
       presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
@@ -61,25 +64,33 @@ class ImportService < BaseService
   end
 
   def import_relationships!(action, undo_action, overwrite_scope, limit)
-    items = @data.take(limit).map { |row| row.first.strip }
+    items = @data.take(limit).map { |row| [row['Account address']&.strip, row['Hide notifications']&.strip] }.reject { |(id, _)| id.blank? }
 
     if @import.overwrite?
-      presence_hash = items.each_with_object({}) { |id, mapping| mapping[id] = true }
+      presence_hash = items.each_with_object({}) { |(id, extra), mapping| mapping[id] = [true, extra] }
 
       overwrite_scope.find_each do |target_account|
         if presence_hash[target_account.acct]
           items.delete(target_account.acct)
+          extra = presence_hash[target_account.acct][1]
+          Import::RelationshipWorker.perform_async(@account.id, target_account.acct, action, ActiveModel::Type::Boolean.new.cast(extra))
         else
           Import::RelationshipWorker.perform_async(@account.id, target_account.acct, undo_action)
         end
       end
     end
 
-    Import::RelationshipWorker.push_bulk(items) do |acct|
-      [@account.id, acct, action]
+    Import::RelationshipWorker.push_bulk(items) do |acct, extra|
+      [@account.id, acct, action, ActiveModel::Type::Boolean.new.cast(extra)]
     end
   end
 
+  def parse_import_data!(default_headers)
+    data = CSV.parse(import_data, headers: true)
+    data = CSV.parse(import_data, headers: default_headers) unless data.headers&.first&.strip&.include?(' ')
+    @data = data.reject(&:blank?)
+  end
+
   def import_data
     Paperclip.io_adapters.for(@import.data).read
   end
diff --git a/app/workers/import/relationship_worker.rb b/app/workers/import/relationship_worker.rb
index e9db20a46..43ec09ea2 100644
--- a/app/workers/import/relationship_worker.rb
+++ b/app/workers/import/relationship_worker.rb
@@ -5,7 +5,7 @@ class Import::RelationshipWorker
 
   sidekiq_options queue: 'pull', retry: 8, dead: false
 
-  def perform(account_id, target_account_uri, relationship)
+  def perform(account_id, target_account_uri, relationship, extra = nil)
     from_account   = Account.find(account_id)
     target_account = ResolveAccountService.new.call(target_account_uri)
 
@@ -21,7 +21,7 @@ class Import::RelationshipWorker
     when 'unblock'
       UnblockService.new.call(from_account, target_account)
     when 'mute'
-      MuteService.new.call(from_account, target_account)
+      MuteService.new.call(from_account, target_account, notifications: extra)
     when 'unmute'
       UnmuteService.new.call(from_account, target_account)
     end