From 3567ac3d3efd9efcee770a0c4925fe5c86bb4d4f Mon Sep 17 00:00:00 2001 From: m4sk1n Date: Wed, 12 Jul 2017 15:53:50 +0200 Subject: i18n: @e19eefe, @056b5ed + consistency improvement (pl) (#4171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * i18n: @e19eefe (pl) Signed-off-by: Marcin Mikołajczak * i18n: @056b5ed (pl) Signed-off-by: Marcin Mikołajczak * i18n: Improve consistency (pl) Signed-off-by: Marcin Mikołajczak --- app/javascript/mastodon/locales/pl.json | 36 ++++++++++++++++----------------- 1 file changed, 18 insertions(+), 18 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index ac63ec40f..c2288c4c0 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -3,10 +3,10 @@ "account.block_domain": "Blokuj wszystko z {domain}", "account.disclaimer": "Ten użytkownik pochodzi z innej instancji. Ta liczba może być większa.", "account.edit_profile": "Edytuj profil", - "account.follow": "Obserwuj", - "account.followers": "Obserwujący", - "account.follows": "Obserwacje", - "account.follows_you": "Obserwuje cię", + "account.follow": "Śledź", + "account.followers": "Śledzący", + "account.follows": "Śledzeni", + "account.follows_you": "Śledzi Cię", "account.media": "Media", "account.mention": "Wspomnij o @{name}", "account.mute": "Wycisz @{name}", @@ -15,7 +15,7 @@ "account.requested": "Oczekująca prośba", "account.unblock": "Odblokuj @{name}", "account.unblock_domain": "Odblokuj domenę {domain}", - "account.unfollow": "Przestań obserwować", + "account.unfollow": "Przestań śledzić", "account.unmute": "Cofnij wyciszenie @{name}", "boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem", "bundle_column_error.body": "Coś poszło nie tak podczas ładowania tego składnika.", @@ -27,7 +27,7 @@ "column.blocks": "Zablokowani użytkownicy", "column.community": "Lokalna oś czasu", "column.favourites": "Ulubione", - "column.follow_requests": "Prośby o obserwację", + "column.follow_requests": "Prośby o śledzenie", "column.home": "Strona główna", "column.mutes": "Wyciszeni użytkownicy", "column.notifications": "Powiadomienia", @@ -37,9 +37,9 @@ "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ę obserwuje, może wyświetlać twoje posty przeznaczone tylko dla obserwujących.", + "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.lock": "zablokowane", - "compose_form.placeholder": "Co ci chodzi po głowie?", + "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.publish": "Wyślij", "compose_form.publish_loud": "{publish}!", @@ -67,7 +67,7 @@ "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.home": "Nie obserwujesz nikogo. Odwiedź publiczną oś czasu lub użyj wyszukiwarki, aby znaleźć ciekawych ludzi.", + "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", "empty_column.notifications": "Nie masz żadnych powiadomień. Rozpocznij interakcje z innymi użytkownikami.", @@ -93,32 +93,32 @@ "navigation_bar.community_timeline": "Lokalna oś czasu", "navigation_bar.edit_profile": "Edytuj profil", "navigation_bar.favourites": "Ulubione", - "navigation_bar.follow_requests": "Prośby o obserwację", + "navigation_bar.follow_requests": "Prośby o śledzenie", "navigation_bar.info": "Szczegółowe informacje", "navigation_bar.logout": "Wyloguj", "navigation_bar.mutes": "Wyciszeni użytkownicy", "navigation_bar.preferences": "Preferencje", "navigation_bar.public_timeline": "Oś czasu federacji", - "notification.favourite": "{name} dodał twój status do ulubionych", - "notification.follow": "{name} zaczął cię obserwować", + "notification.favourite": "{name} dodał Twój status do ulubionych", + "notification.follow": "{name} zaczął Cię śledzić", "notification.mention": "{name} wspomniał o tobie", - "notification.reblog": "{name} podbił twój status", + "notification.reblog": "{name} podbił Twój status", "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.follow": "Nowi obserwujący:", + "notifications.column_settings.follow": "Nowi śledzący:", "notifications.column_settings.mention": "Wspomniali:", "notifications.column_settings.reblog": "Podbili:", "notifications.column_settings.show": "Pokaż w kolumnie", "notifications.column_settings.sound": "Odtwarzaj dźwięk", "onboarding.done": "Gotowe", "onboarding.next": "Dalej", - "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy obserwowanych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.", + "onboarding.page_five.public_timelines": "Lokalna oś czasu zawiera wszystkie publiczne wpisy z {domain}. Federalna oś czasu wyświetla publiczne wpisy śledzonych przez członków {domain}. Są to publiczne osie czasu – najlepszy sposób na poznanie nowych osób.", "onboarding.page_four.home": "Główna oś czasu wyświetla publiczne wpisy.", "onboarding.page_four.notifications": "Kolumna powiadomień wyświetla, gdy ktoś dokonuje interakcji z tobą.", "onboarding.page_one.federation": "Mastodon jest siecią niezależnych serwerów połączonych w jeden portal społecznościowy. Nazywamy te serwery instancjami.", - "onboarding.page_one.handle": "Jesteś na domenie {domain}, więc twój pełny adres to {handle}", + "onboarding.page_one.handle": "Jesteś na domenie {domain}, więc Twój pełny adres to {handle}", "onboarding.page_one.welcome": "Witamy w Mastodon!", "onboarding.page_six.admin": "Administratorem tej instancji jest {admin}.", "onboarding.page_six.almost_done": "Prawie gotowe...", @@ -135,8 +135,8 @@ "privacy.change": "Dostosuj widoczność postów", "privacy.direct.long": "Widoczne tylko dla oznaczonych", "privacy.direct.short": "Bezpośrednio", - "privacy.private.long": "Widoczne tylko dla obserwujących", - "privacy.private.short": "Tylko obserwujący", + "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", -- cgit From 5abb3d815025675d1493da4f8c6e4dda4a6672e9 Mon Sep 17 00:00:00 2001 From: unarist Date: Thu, 13 Jul 2017 03:51:44 +0900 Subject: Rerender modal on property changes (#4175) Render function for BundleContainer must not be methods. React doesn't know dependency of the method, so they won't rerender on property updates. In this case, when you close modal and open another modal immediately, old modal will be open instead of new one. --- app/javascript/mastodon/features/ui/components/modal_root.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/ui/components/modal_root.js b/app/javascript/mastodon/features/ui/components/modal_root.js index 085299038..4240871a7 100644 --- a/app/javascript/mastodon/features/ui/components/modal_root.js +++ b/app/javascript/mastodon/features/ui/components/modal_root.js @@ -54,12 +54,6 @@ export default class ModalRoot extends React.PureComponent { return { opacity: spring(0), scale: spring(0.98) }; } - renderModal = (SpecificComponent) => { - const { props, onClose } = this.props; - - return ; - } - renderLoading = () => { return ; } @@ -95,7 +89,9 @@ export default class ModalRoot extends React.PureComponent {
- {this.renderModal} + + {(SpecificComponent) => } +
))} -- cgit From e48d3bfd01c4bdeb36d39bbdf13b6873e3444179 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 13 Jul 2017 01:47:23 +0200 Subject: Fix #1010 - When spoiler text is set, enforce sensitivity too (#4176) --- app/models/status.rb | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'app') diff --git a/app/models/status.rb b/app/models/status.rb index 791d96df1..edf805336 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -124,6 +124,7 @@ class Status < ApplicationRecord before_validation :set_reblog before_validation :set_visibility before_validation :set_conversation + before_validation :set_sensitivity class << self def not_in_filtered_languages(account) @@ -250,6 +251,10 @@ class Status < ApplicationRecord self.visibility = (account.locked? ? :private : :public) if visibility.nil? end + def set_sensitivity + self.sensitive = sensitive || spoiler_text.present? + end + def set_conversation self.reply = !(in_reply_to_id.nil? && thread.nil?) unless reply -- cgit From 880a5eb25cc62d2181b34a21985addee847cbb49 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Thu, 13 Jul 2017 03:12:25 +0200 Subject: Fix boolean columns sometimes having a null value (#4162) * Fix boolean columns sometimes having a null value * Fix wrong value being set instead of null --- app/models/domain_block.rb | 2 +- app/models/import.rb | 2 +- app/models/status.rb | 5 +++-- app/models/user.rb | 4 ++-- db/migrate/20170711225116_fix_null_booleans.rb | 17 +++++++++++++++++ db/schema.rb | 14 +++++++------- 6 files changed, 31 insertions(+), 13 deletions(-) create mode 100644 db/migrate/20170711225116_fix_null_booleans.rb (limited to 'app') diff --git a/app/models/domain_block.rb b/app/models/domain_block.rb index 99dae9c1d..f26e8183f 100644 --- a/app/models/domain_block.rb +++ b/app/models/domain_block.rb @@ -8,7 +8,7 @@ # created_at :datetime not null # updated_at :datetime not null # severity :integer default("silence") -# reject_media :boolean +# reject_media :boolean default(FALSE), not null # class DomainBlock < ApplicationRecord diff --git a/app/models/import.rb b/app/models/import.rb index 8c6253d49..815e02589 100644 --- a/app/models/import.rb +++ b/app/models/import.rb @@ -6,7 +6,7 @@ # id :integer not null, primary key # account_id :integer not null # type :integer not null -# approved :boolean +# approved :boolean default(FALSE), not null # created_at :datetime not null # updated_at :datetime not null # data_file_name :string diff --git a/app/models/status.rb b/app/models/status.rb index edf805336..65db7579a 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -12,12 +12,12 @@ # in_reply_to_id :integer # reblog_of_id :integer # url :string -# sensitive :boolean default(FALSE) +# sensitive :boolean default(FALSE), not null # visibility :integer default("public"), not null # in_reply_to_account_id :integer # application_id :integer # spoiler_text :text default(""), not null -# reply :boolean default(FALSE) +# reply :boolean default(FALSE), not null # favourites_count :integer default(0), not null # reblogs_count :integer default(0), not null # language :string @@ -249,6 +249,7 @@ class Status < ApplicationRecord def set_visibility self.visibility = (account.locked? ? :private : :public) if visibility.nil? + self.sensitive = false if sensitive.nil? end def set_sensitivity diff --git a/app/models/user.rb b/app/models/user.rb index c80115a08..86e578225 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -17,7 +17,7 @@ # last_sign_in_at :datetime # current_sign_in_ip :inet # last_sign_in_ip :inet -# admin :boolean default(FALSE) +# admin :boolean default(FALSE), not null # confirmation_token :string # confirmed_at :datetime # confirmation_sent_at :datetime @@ -27,7 +27,7 @@ # encrypted_otp_secret_iv :string # encrypted_otp_secret_salt :string # consumed_timestep :integer -# otp_required_for_login :boolean +# otp_required_for_login :boolean default(FALSE), not null # last_emailed_at :datetime # otp_backup_codes :string is an Array # filtered_languages :string default([]), not null, is an Array diff --git a/db/migrate/20170711225116_fix_null_booleans.rb b/db/migrate/20170711225116_fix_null_booleans.rb new file mode 100644 index 000000000..5b319471d --- /dev/null +++ b/db/migrate/20170711225116_fix_null_booleans.rb @@ -0,0 +1,17 @@ +class FixNullBooleans < ActiveRecord::Migration[5.1] + def change + change_column_default :domain_blocks, :reject_media, false + change_column_null :domain_blocks, :reject_media, false, false + + change_column_default :imports, :approved, false + change_column_null :imports, :approved, false, false + + change_column_null :statuses, :sensitive, false, false + change_column_null :statuses, :reply, false, false + + change_column_null :users, :admin, false, false + + change_column_default :users, :otp_required_for_login, false + change_column_null :users, :otp_required_for_login, false, false + end +end diff --git a/db/schema.rb b/db/schema.rb index 159704c6a..0d5c73e2e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170625140443) do +ActiveRecord::Schema.define(version: 20170711225116) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -89,7 +89,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "severity", default: 0 - t.boolean "reject_media" + t.boolean "reject_media", default: false, null: false t.index ["domain"], name: "index_domain_blocks_on_domain", unique: true end @@ -121,7 +121,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do create_table "imports", id: :serial, force: :cascade do |t| t.integer "account_id", null: false t.integer "type", null: false - t.boolean "approved" + t.boolean "approved", default: false, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "data_file_name" @@ -281,12 +281,12 @@ ActiveRecord::Schema.define(version: 20170625140443) do t.bigint "in_reply_to_id" t.bigint "reblog_of_id" t.string "url" - t.boolean "sensitive", default: false + t.boolean "sensitive", default: false, null: false t.integer "visibility", default: 0, null: false t.integer "in_reply_to_account_id" t.integer "application_id" t.text "spoiler_text", default: "", null: false - t.boolean "reply", default: false + t.boolean "reply", default: false, null: false t.integer "favourites_count", default: 0, null: false t.integer "reblogs_count", default: 0, null: false t.string "language" @@ -350,7 +350,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do t.datetime "last_sign_in_at" t.inet "current_sign_in_ip" t.inet "last_sign_in_ip" - t.boolean "admin", default: false + t.boolean "admin", default: false, null: false t.string "confirmation_token" t.datetime "confirmed_at" t.datetime "confirmation_sent_at" @@ -360,7 +360,7 @@ ActiveRecord::Schema.define(version: 20170625140443) do t.string "encrypted_otp_secret_iv" t.string "encrypted_otp_secret_salt" t.integer "consumed_timestep" - t.boolean "otp_required_for_login" + t.boolean "otp_required_for_login", default: false, null: false t.datetime "last_emailed_at" t.string "otp_backup_codes", array: true t.string "filtered_languages", default: [], null: false, array: true -- cgit From c29c20ab3cda3d4b752c67868925c1fe99d0ac71 Mon Sep 17 00:00:00 2001 From: unarist Date: Thu, 13 Jul 2017 21:49:57 +0900 Subject: Add background color for spoiler input, like toot textarea (#4181) --- app/javascript/styles/components.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 8d0350eb6..45dd9f914 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1748,6 +1748,7 @@ width: 100%; margin: 0; color: $ui-base-color; + background: $simple-background-color; padding: 10px; font-family: inherit; font-size: 14px; @@ -1770,7 +1771,6 @@ .autosuggest-textarea__textarea { min-height: 100px; - background: $simple-background-color; border-radius: 4px 4px 0 0; padding-bottom: 0; padding-right: 10px + 22px; -- cgit From c2753fdfb471209fe7f2cdb8844e049207af8ba3 Mon Sep 17 00:00:00 2001 From: unarist Date: Fri, 14 Jul 2017 02:31:33 +0900 Subject: Make tag search case insensitive again (#4184) --- app/models/tag.rb | 2 +- db/migrate/20170713112503_make_tag_search_case_insensitive.rb | 11 +++++++++++ db/schema.rb | 4 ++-- spec/models/tag_spec.rb | 9 +++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) create mode 100644 db/migrate/20170713112503_make_tag_search_case_insensitive.rb (limited to 'app') diff --git a/app/models/tag.rb b/app/models/tag.rb index 08e3c1b03..a14e2cc98 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -23,7 +23,7 @@ class Tag < ApplicationRecord class << self def search_for(term, limit = 5) pattern = sanitize_sql_like(term) + '%' - Tag.where('name like ?', pattern).order(:name).limit(limit) + Tag.where('lower(name) like lower(?)', pattern).order(:name).limit(limit) end end end diff --git a/db/migrate/20170713112503_make_tag_search_case_insensitive.rb b/db/migrate/20170713112503_make_tag_search_case_insensitive.rb new file mode 100644 index 000000000..33ed6c005 --- /dev/null +++ b/db/migrate/20170713112503_make_tag_search_case_insensitive.rb @@ -0,0 +1,11 @@ +class MakeTagSearchCaseInsensitive < ActiveRecord::Migration[5.1] + def up + remove_index :tags, name: :hashtag_search_index + execute 'CREATE INDEX hashtag_search_index ON tags (lower(name) text_pattern_ops);' + end + + def down + remove_index :tags, name: :hashtag_search_index + execute 'CREATE INDEX hashtag_search_index ON tags (name text_pattern_ops);' + end +end diff --git a/db/schema.rb b/db/schema.rb index 0d5c73e2e..d6e572703 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170711225116) do +ActiveRecord::Schema.define(version: 20170713112503) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -332,7 +332,7 @@ ActiveRecord::Schema.define(version: 20170711225116) do t.string "name", default: "", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index "name text_pattern_ops", name: "hashtag_search_index" + t.index "lower((name)::text) text_pattern_ops", name: "hashtag_search_index" t.index ["name"], name: "index_tags_on_name", unique: true end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 7c574eabe..555474c44 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -27,6 +27,15 @@ RSpec.describe Tag, type: :model do expect(results).to eq [tag] end + it 'finds tag records in case insensitive' do + tag = Fabricate(:tag, name: "MATCH") + _miss_tag = Fabricate(:tag, name: "miss") + + results = Tag.search_for("match") + + expect(results).to eq [tag] + end + it 'finds the exact matching tag as the first item' do similar_tag = Fabricate(:tag, name: "matchlater") tag = Fabricate(:tag, name: "match") -- cgit From 0c7c188c459117770ac1f74f70a9e65ed2be606f Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Thu, 13 Jul 2017 22:15:32 +0200 Subject: Web Push Notifications (#3243) * feat: Register push subscription * feat: Notify when mentioned * feat: Boost, favourite, reply, follow, follow request * feat: Notification interaction * feat: Handle change of public key * feat: Unsubscribe if things go wrong * feat: Do not send normal notifications if push is enabled * feat: Focus client if open * refactor: Move push logic to WebPushSubscription * feat: Better title and body * feat: Localize messages * chore: Fix lint errors * feat: Settings * refactor: Lazy load * fix: Check if push settings exist * feat: Device-based preferences * refactor: Simplify logic * refactor: Pull request feedback * refactor: Pull request feedback * refactor: Create /api/web/push_subscriptions endpoint * feat: Spec PushSubscriptionController * refactor: WebPushSubscription => Web::PushSubscription * feat: Spec Web::PushSubscription * feat: Display first media attachment * feat: Support direction * fix: Stuff broken while rebasing * refactor: Integration with session activations * refactor: Cleanup * refactor: Simplify implementation * feat: Set VAPID keys via environment * chore: Comments * fix: Crash when no alerts * fix: Set VAPID keys in testing environment * fix: Follow link * feat: Notification actions * fix: Delete previous subscription * chore: Temporary logs * refactor: Move migration to a later date * fix: Fetch the correct session activation and misc bugs * refactor: Move migration to a later date * fix: Remove follow request (no notifications) * feat: Send administrator contact to push service * feat: Set time-to-live * fix: Do not show sensitive images * fix: Reducer crash in error handling * feat: Add badge * chore: Fix lint error * fix: Checkbox label overlap * fix: Check for payload support * fix: Rename action "type" (crash in latest Chrome) * feat: Action to expand notification * fix: Lint errors * fix: Unescape notification body * fix: Do not allow boosting if the status is hidden * feat: Add VAPID keys to the production sample environment * fix: Strip HTML tags from status * refactor: Better error messages * refactor: Handle browser not implementing the VAPID protocol (Samsung Internet) * fix: Error when target_status is nil * fix: Handle lack of image * fix: Delete reference to invalid subscriptions * feat: Better error handling * fix: Unescape HTML characters after tags are striped * refactor: Simpify code * fix: Modify to work with #4091 * Sort strings alphabetically * i18n: Updated Polish translation it annoys me that it's not fully localized :P * refactor: Use current_session in PushSubscriptionController * fix: Rebase mistake * fix: Set cacheName to mastodon * refactor: Pull request feedback * refactor: Remove logging statements * chore(yarn): Fix conflicts with master * chore(yarn): Copy latest from master * chore(yarn): Readd offline-plugin * refactor: Use save! and update! * refactor: Send notifications async * fix: Allow retry when push fails * fix: Save track for failed pushes * fix: Minify sw.js * fix: Remove account_id from fabricator --- .env.production.sample | 11 ++ .gitignore | 1 + Gemfile | 1 + Gemfile.lock | 6 + .../api/web/push_subscriptions_controller.rb | 39 +++++ app/controllers/home_controller.rb | 1 + .../mastodon/actions/push_notifications.js | 52 ++++++ .../notifications/components/column_settings.js | 23 ++- .../notifications/components/setting_toggle.js | 4 +- .../containers/column_settings_container.js | 9 +- app/javascript/mastodon/main.js | 8 + app/javascript/mastodon/reducers/index.js | 2 + .../mastodon/reducers/push_notifications.js | 51 ++++++ app/javascript/mastodon/service_worker/entry.js | 1 + .../service_worker/web_push_notifications.js | 86 ++++++++++ app/javascript/mastodon/web_push_subscription.js | 109 ++++++++++++ app/javascript/styles/components.scss | 8 +- app/javascript/styles/rtl.scss | 4 + app/models/session_activation.rb | 12 ++ app/models/user.rb | 4 + app/models/web/push_subscription.rb | 190 +++++++++++++++++++++ app/presenters/initial_state_presenter.rb | 2 +- app/serializers/initial_state_serializer.rb | 2 +- app/services/notify_service.rb | 5 + app/views/home/index.html.haml | 1 + app/workers/web_push_notification_worker.rb | 27 +++ config/environments/development.rb | 5 + config/environments/test.rb | 5 + config/initializers/vapid.rb | 17 ++ config/locales/en.yml | 15 ++ config/locales/pl.yml | 15 ++ config/routes.rb | 5 + config/webpack/production.js | 14 ++ ...20170713175513_create_web_push_subscriptions.rb | 12 ++ ...web_push_subscription_to_session_activations.rb | 5 + db/schema.rb | 12 +- package.json | 1 + public/badge.png | Bin 0 -> 31156 bytes .../api/web/push_subscriptions_controller_spec.rb | 81 +++++++++ .../web_push_subscription_fabricator.rb | 5 + spec/models/web/push_subscription_spec.rb | 28 +++ yarn.lock | 25 ++- 42 files changed, 890 insertions(+), 14 deletions(-) create mode 100644 app/controllers/api/web/push_subscriptions_controller.rb create mode 100644 app/javascript/mastodon/actions/push_notifications.js create mode 100644 app/javascript/mastodon/reducers/push_notifications.js create mode 100644 app/javascript/mastodon/service_worker/entry.js create mode 100644 app/javascript/mastodon/service_worker/web_push_notifications.js create mode 100644 app/javascript/mastodon/web_push_subscription.js create mode 100644 app/models/web/push_subscription.rb create mode 100644 app/workers/web_push_notification_worker.rb create mode 100644 config/initializers/vapid.rb create mode 100644 db/migrate/20170713175513_create_web_push_subscriptions.rb create mode 100644 db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb create mode 100644 public/badge.png create mode 100644 spec/controllers/api/web/push_subscriptions_controller_spec.rb create mode 100644 spec/fabricators/web_push_subscription_fabricator.rb create mode 100644 spec/models/web/push_subscription_spec.rb (limited to 'app') diff --git a/.env.production.sample b/.env.production.sample index 394cdedfe..faefa2482 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -31,6 +31,17 @@ PAPERCLIP_SECRET= SECRET_KEY_BASE= OTP_SECRET= +# VAPID keys (used for push notifications +# You can generate the keys using the following command (first is the private key, second is the public one) +# You should only generate this once per instance. If you later decide to change it, all push subscription will +# be invalidated, requiring the users to access the website again to resubscribe. +# +# ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;" +# +# For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html +VAPID_PRIVATE_KEY= +VAPID_PUBLIC_KEY= + # Registrations # Single user mode will disable registrations and redirect frontpage to the first profile # SINGLE_USER_MODE=true diff --git a/.gitignore b/.gitignore index 38ebc934f..868a84368 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ public/system public/assets public/packs public/packs-test +public/sw.js .env .env.production node_modules/ diff --git a/Gemfile b/Gemfile index b52685cba..988b4d6b9 100644 --- a/Gemfile +++ b/Gemfile @@ -64,6 +64,7 @@ gem 'statsd-instrument', '~> 2.1' gem 'twitter-text', '~> 1.14' gem 'tzinfo-data', '~> 1.2017' gem 'webpacker', '~> 2.0' +gem 'webpush' group :development, :test do gem 'fabrication', '~> 2.16' diff --git a/Gemfile.lock b/Gemfile.lock index de0d6a107..5599e1db1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -181,6 +181,7 @@ GEM hashdiff (0.3.4) highline (1.7.8) hiredis (0.6.1) + hkdf (0.3.0) htmlentities (4.3.4) http (2.2.2) addressable (~> 2.3) @@ -209,6 +210,7 @@ GEM jmespath (1.3.1) json (2.1.0) jsonapi-renderer (0.1.2) + jwt (1.5.6) kaminari (1.0.1) activesupport (>= 4.1.0) kaminari-actionview (= 1.0.1) @@ -475,6 +477,9 @@ GEM activesupport (>= 4.2) multi_json (~> 1.2) railties (>= 4.2) + webpush (0.3.2) + hkdf (~> 0.2) + jwt websocket-driver (0.6.5) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.2) @@ -573,6 +578,7 @@ DEPENDENCIES uglifier (~> 3.2) webmock (~> 3.0) webpacker (~> 2.0) + webpush RUBY VERSION ruby 2.4.1p111 diff --git a/app/controllers/api/web/push_subscriptions_controller.rb b/app/controllers/api/web/push_subscriptions_controller.rb new file mode 100644 index 000000000..8425db7b4 --- /dev/null +++ b/app/controllers/api/web/push_subscriptions_controller.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Api::Web::PushSubscriptionsController < Api::BaseController + respond_to :json + + before_action :require_user! + + def create + params.require(:data).require(:endpoint) + params.require(:data).require(:keys).require([:auth, :p256dh]) + + active_session = current_session + + unless active_session.web_push_subscription.nil? + active_session.web_push_subscription.destroy! + active_session.update!(web_push_subscription: nil) + end + + web_subscription = ::Web::PushSubscription.create!( + endpoint: params[:data][:endpoint], + key_p256dh: params[:data][:keys][:p256dh], + key_auth: params[:data][:keys][:auth] + ) + + active_session.update!(web_push_subscription: web_subscription) + + render json: web_subscription.as_payload + end + + def update + params.require([:id, :data]) + + web_subscription = ::Web::PushSubscription.find(params[:id]) + + web_subscription.update!(data: params[:data]) + + render json: web_subscription.as_payload + end +end diff --git a/app/controllers/home_controller.rb b/app/controllers/home_controller.rb index 8a8b9ec76..1585bc810 100644 --- a/app/controllers/home_controller.rb +++ b/app/controllers/home_controller.rb @@ -22,6 +22,7 @@ class HomeController < ApplicationController def initial_state_params { settings: Web::Setting.find_by(user: current_user)&.data || {}, + push_subscription: current_account.user.web_push_subscription(current_session), current_account: current_account, token: current_session.token, admin: Account.find_local(Setting.site_contact_username), diff --git a/app/javascript/mastodon/actions/push_notifications.js b/app/javascript/mastodon/actions/push_notifications.js new file mode 100644 index 000000000..55661d2b0 --- /dev/null +++ b/app/javascript/mastodon/actions/push_notifications.js @@ -0,0 +1,52 @@ +import axios from 'axios'; + +export const SET_BROWSER_SUPPORT = 'PUSH_NOTIFICATIONS_SET_BROWSER_SUPPORT'; +export const SET_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_SET_SUBSCRIPTION'; +export const CLEAR_SUBSCRIPTION = 'PUSH_NOTIFICATIONS_CLEAR_SUBSCRIPTION'; +export const ALERTS_CHANGE = 'PUSH_NOTIFICATIONS_ALERTS_CHANGE'; + +export function setBrowserSupport (value) { + return { + type: SET_BROWSER_SUPPORT, + value, + }; +} + +export function setSubscription (subscription) { + return { + type: SET_SUBSCRIPTION, + subscription, + }; +} + +export function clearSubscription () { + return { + type: CLEAR_SUBSCRIPTION, + }; +} + +export function changeAlerts(key, value) { + return dispatch => { + dispatch({ + type: ALERTS_CHANGE, + key, + value, + }); + + dispatch(saveSettings()); + }; +} + +export function saveSettings() { + return (_, getState) => { + const state = getState().get('push_notifications'); + const subscription = state.get('subscription'); + const alerts = state.get('alerts'); + + axios.put(`/api/web/push_subscriptions/${subscription.get('id')}`, { + data: { + alerts, + }, + }); + }; +} diff --git a/app/javascript/mastodon/features/notifications/components/column_settings.js b/app/javascript/mastodon/features/notifications/components/column_settings.js index 260594894..31cac5bc7 100644 --- a/app/javascript/mastodon/features/notifications/components/column_settings.js +++ b/app/javascript/mastodon/features/notifications/components/column_settings.js @@ -9,18 +9,27 @@ export default class ColumnSettings extends React.PureComponent { static propTypes = { settings: ImmutablePropTypes.map.isRequired, + pushSettings: ImmutablePropTypes.map.isRequired, onChange: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired, onClear: PropTypes.func.isRequired, }; + onPushChange = (key, checked) => { + this.props.onChange(['push', ...key], checked); + } + render () { - const { settings, onChange, onClear } = this.props; + const { settings, pushSettings, onChange, onClear } = this.props; const alertStr = ; const showStr = ; const soundStr = ; + const showPushSettings = pushSettings.get('browserSupport') && pushSettings.get('isSubscribed'); + const pushStr = showPushSettings && ; + const pushMeta = showPushSettings && ; + return (
@@ -30,7 +39,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
@@ -38,7 +48,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
@@ -46,7 +57,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
@@ -54,7 +66,8 @@ export default class ColumnSettings extends React.PureComponent {
- + + {showPushSettings && }
diff --git a/app/javascript/mastodon/features/notifications/components/setting_toggle.js b/app/javascript/mastodon/features/notifications/components/setting_toggle.js index 510820358..be1ff91d6 100644 --- a/app/javascript/mastodon/features/notifications/components/setting_toggle.js +++ b/app/javascript/mastodon/features/notifications/components/setting_toggle.js @@ -10,6 +10,7 @@ export default class SettingToggle extends React.PureComponent { settings: ImmutablePropTypes.map.isRequired, settingKey: PropTypes.array.isRequired, label: PropTypes.node.isRequired, + meta: PropTypes.node, onChange: PropTypes.func.isRequired, } @@ -18,13 +19,14 @@ export default class SettingToggle extends React.PureComponent { } render () { - const { prefix, settings, settingKey, label } = this.props; + const { prefix, settings, settingKey, label, meta } = this.props; const id = ['setting-toggle', prefix, ...settingKey].filter(Boolean).join('-'); return (
+ {meta && {meta}}
); } diff --git a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js index b139d4615..d4ead7881 100644 --- a/app/javascript/mastodon/features/notifications/containers/column_settings_container.js +++ b/app/javascript/mastodon/features/notifications/containers/column_settings_container.js @@ -3,6 +3,7 @@ import { defineMessages, injectIntl } from 'react-intl'; import ColumnSettings from '../components/column_settings'; import { changeSetting, saveSettings } from '../../../actions/settings'; import { clearNotifications } from '../../../actions/notifications'; +import { changeAlerts as changePushNotifications, saveSettings as savePushNotificationSettings } from '../../../actions/push_notifications'; import { openModal } from '../../../actions/modal'; const messages = defineMessages({ @@ -12,16 +13,22 @@ const messages = defineMessages({ const mapStateToProps = state => ({ settings: state.getIn(['settings', 'notifications']), + pushSettings: state.get('push_notifications'), }); const mapDispatchToProps = (dispatch, { intl }) => ({ onChange (key, checked) { - dispatch(changeSetting(['notifications', ...key], checked)); + if (key[0] === 'push') { + dispatch(changePushNotifications(key.slice(1), checked)); + } else { + dispatch(changeSetting(['notifications', ...key], checked)); + } }, onSave () { dispatch(saveSettings()); + dispatch(savePushNotificationSettings()); }, onClear () { diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index d7ffa8ea6..d2c9d1c94 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -29,6 +29,14 @@ function main() { const props = JSON.parse(mountNode.getAttribute('data-props')); ReactDOM.render(, mountNode); + if (process.env.NODE_ENV === 'production') { + // avoid offline in dev mode because it's harder to debug + const OfflinePluginRuntime = require('offline-plugin/runtime'); + const WebPushSubscription = require('./web_push_subscription'); + + OfflinePluginRuntime.install(); + WebPushSubscription.register(); + } perf.stop('main()'); }); } diff --git a/app/javascript/mastodon/reducers/index.js b/app/javascript/mastodon/reducers/index.js index 919345f16..3aaf259c2 100644 --- a/app/javascript/mastodon/reducers/index.js +++ b/app/javascript/mastodon/reducers/index.js @@ -10,6 +10,7 @@ import accounts_counters from './accounts_counters'; import statuses from './statuses'; import relationships from './relationships'; import settings from './settings'; +import push_notifications from './push_notifications'; import status_lists from './status_lists'; import cards from './cards'; import reports from './reports'; @@ -32,6 +33,7 @@ const reducers = { statuses, relationships, settings, + push_notifications, cards, reports, contexts, diff --git a/app/javascript/mastodon/reducers/push_notifications.js b/app/javascript/mastodon/reducers/push_notifications.js new file mode 100644 index 000000000..31a40d246 --- /dev/null +++ b/app/javascript/mastodon/reducers/push_notifications.js @@ -0,0 +1,51 @@ +import { STORE_HYDRATE } from '../actions/store'; +import { SET_BROWSER_SUPPORT, SET_SUBSCRIPTION, CLEAR_SUBSCRIPTION, ALERTS_CHANGE } from '../actions/push_notifications'; +import Immutable from 'immutable'; + +const initialState = Immutable.Map({ + subscription: null, + alerts: new Immutable.Map({ + follow: false, + favourite: false, + reblog: false, + mention: false, + }), + isSubscribed: false, + browserSupport: false, +}); + +export default function push_subscriptions(state = initialState, action) { + switch(action.type) { + case STORE_HYDRATE: { + const push_subscription = action.state.get('push_subscription'); + + if (push_subscription) { + return state + .set('subscription', new Immutable.Map({ + id: push_subscription.get('id'), + endpoint: push_subscription.get('endpoint'), + })) + .set('alerts', push_subscription.get('alerts') || initialState.get('alerts')) + .set('isSubscribed', true); + } + + return state; + } + case SET_SUBSCRIPTION: + return state + .set('subscription', new Immutable.Map({ + id: action.subscription.id, + endpoint: action.subscription.endpoint, + })) + .set('alerts', new Immutable.Map(action.subscription.alerts)) + .set('isSubscribed', true); + case SET_BROWSER_SUPPORT: + return state.set('browserSupport', action.value); + case CLEAR_SUBSCRIPTION: + return initialState; + case ALERTS_CHANGE: + return state.setIn(action.key, action.value); + default: + return state; + } +}; diff --git a/app/javascript/mastodon/service_worker/entry.js b/app/javascript/mastodon/service_worker/entry.js new file mode 100644 index 000000000..364b67066 --- /dev/null +++ b/app/javascript/mastodon/service_worker/entry.js @@ -0,0 +1 @@ +import './web_push_notifications'; diff --git a/app/javascript/mastodon/service_worker/web_push_notifications.js b/app/javascript/mastodon/service_worker/web_push_notifications.js new file mode 100644 index 000000000..1708aa9f7 --- /dev/null +++ b/app/javascript/mastodon/service_worker/web_push_notifications.js @@ -0,0 +1,86 @@ +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.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.data.hiddenImage = options.image; + options.image = undefined; + } else { + options.actions = options.data.actions; + } + + event.waitUntil(self.registration.showNotification(options.title, options)); +}; + +const cloneNotification = (notification) => { + const clone = { }; + + for(var k in notification) { + clone[k] = notification[k]; + } + + return clone; +}; + +const expandNotification = (notification) => { + const nextNotification = cloneNotification(notification); + + 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); +}; + +const makeRequest = (notification, action) => + fetch(action.action, { + headers: { + 'Authorization': `Bearer ${notification.data.access_token}`, + 'Content-Type': 'application/json', + }, + method: action.method, + credentials: 'include', + }); + +const removeActionFromNotification = (notification, action) => { + const actions = notification.actions.filter(act => act.action !== action.action); + + const nextNotification = cloneNotification(notification); + + nextNotification.actions = actions; + + return self.registration.showNotification(nextNotification.title, nextNotification); +}; + +const handleNotificationClick = (event) => { + const reactToNotificationClick = new Promise((resolve, reject) => { + if (event.action) { + const action = event.notification.data.actions.find(({ action }) => action === event.action); + + if (action.todo === 'expand') { + resolve(expandNotification(event.notification)); + } else if (action.todo === 'request') { + resolve(makeRequest(event.notification, action) + .then(() => removeActionFromNotification(event.notification, action))); + } else { + reject(`Unknown action: ${action.todo}`); + } + } else { + event.notification.close(); + resolve(self.clients.openWindow(event.notification.data.url)); + } + }); + + event.waitUntil(reactToNotificationClick); +}; + +self.addEventListener('push', handlePush); +self.addEventListener('notificationclick', handleNotificationClick); diff --git a/app/javascript/mastodon/web_push_subscription.js b/app/javascript/mastodon/web_push_subscription.js new file mode 100644 index 000000000..391d3bcec --- /dev/null +++ b/app/javascript/mastodon/web_push_subscription.js @@ -0,0 +1,109 @@ +import axios from 'axios'; +import { store } from './containers/mastodon'; +import { setBrowserSupport, setSubscription, clearSubscription } from './actions/push_notifications'; + +// Taken from https://www.npmjs.com/package/web-push +const urlBase64ToUint8Array = (base64String) => { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/\-/g, '+') + .replace(/_/g, '/'); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +}; + +const getApplicationServerKey = () => document.querySelector('[name="applicationServerKey"]').getAttribute('content'); + +const getRegistration = () => navigator.serviceWorker.ready; + +const getPushSubscription = (registration) => + registration.pushManager.getSubscription() + .then(subscription => ({ registration, subscription })); + +const subscribe = (registration) => + registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(getApplicationServerKey()), + }); + +const unsubscribe = ({ registration, subscription }) => + subscription ? subscription.unsubscribe().then(() => registration) : registration; + +const sendSubscriptionToBackend = (subscription) => + axios.post('/api/web/push_subscriptions', { + data: subscription, + }).then(response => response.data); + +// Last one checks for payload support: https://web-push-book.gauntface.com/chapter-06/01-non-standards-browsers/#no-payload +const supportsPushNotifications = ('serviceWorker' in navigator && 'PushManager' in window && 'getKey' in PushSubscription.prototype); + +export function register () { + store.dispatch(setBrowserSupport(supportsPushNotifications)); + + 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; + } + + getRegistration() + .then(getPushSubscription) + .then(({ registration, subscription }) => { + if (subscription !== null) { + // We have a subscription, check if it is still valid + const currentServerKey = (new Uint8Array(subscription.options.applicationServerKey)).toString(); + const subscriptionServerKey = urlBase64ToUint8Array(getApplicationServerKey()).toString(); + const serverEndpoint = store.getState().getIn(['push_notifications', 'subscription', 'endpoint']); + + // If the VAPID public key did not change and the endpoint corresponds + // to the endpoint saved in the backend, the subscription is valid + if (subscriptionServerKey === currentServerKey && subscription.endpoint === serverEndpoint) { + return subscription; + } else { + // Something went wrong, try to subscribe again + return unsubscribe({ registration, subscription }).then(subscribe).then(sendSubscriptionToBackend); + } + } + + // No subscription, try to subscribe + return subscribe(registration).then(sendSubscriptionToBackend); + }) + .then(subscription => { + // If we got a PushSubscription (and not a subscription object from the backend) + // it means that the backend subscription is valid (and was set during hydration) + if (!(subscription instanceof PushSubscription)) { + store.dispatch(setSubscription(subscription)); + } + }) + .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()); + } + + // Clear alerts and hide UI settings + store.dispatch(clearSubscription()); + + try { + getRegistration() + .then(getPushSubscription) + .then(unsubscribe); + } catch (e) { + + } + }); + } else { + // eslint-disable-next-line no-console + console.warn('Your browser does not support Web Push Notifications.'); + } +} diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 45dd9f914..02602afa4 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2352,7 +2352,8 @@ button.icon-button.active i.fa-retweet { line-height: 24px; } -.setting-toggle__label { +.setting-toggle__label, +.setting-meta__label { color: $ui-primary-color; display: inline-block; margin-bottom: 14px; @@ -2360,6 +2361,11 @@ button.icon-button.active i.fa-retweet { vertical-align: middle; } +.setting-meta__label { + color: $ui-primary-color; + float: right; +} + .empty-column-indicator, .error-column { color: lighten($ui-base-color, 20%); diff --git a/app/javascript/styles/rtl.scss b/app/javascript/styles/rtl.scss index a91d0d72a..4966fbc21 100644 --- a/app/javascript/styles/rtl.scss +++ b/app/javascript/styles/rtl.scss @@ -45,6 +45,10 @@ body.rtl { margin-right: 8px; } + .setting-meta__label { + float: left; + } + .status__avatar { left: auto; right: 10px; diff --git a/app/models/session_activation.rb b/app/models/session_activation.rb index 887e3e3bd..7eb16af8f 100644 --- a/app/models/session_activation.rb +++ b/app/models/session_activation.rb @@ -3,6 +3,17 @@ # # Table name: session_activations # +# id :integer not null, primary key +# user_id :integer not null +# session_id :string not null +# created_at :datetime not null +# updated_at :datetime not null +# user_agent :string default(""), not null +# ip :inet +# access_token_id :integer +# web_push_subscription_id :integer +# + # id :integer not null, primary key # user_id :integer not null # session_id :string not null @@ -15,6 +26,7 @@ class SessionActivation < ApplicationRecord belongs_to :access_token, class_name: 'Doorkeeper::AccessToken', dependent: :destroy + belongs_to :web_push_subscription, class_name: 'Web::PushSubscription', dependent: :destroy delegate :token, to: :access_token, diff --git a/app/models/user.rb b/app/models/user.rb index 86e578225..a63b1da7f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -113,6 +113,10 @@ class User < ApplicationRecord session_activations.active? id end + def web_push_subscription(session) + session.web_push_subscription.nil? ? nil : session.web_push_subscription.as_payload + end + protected def send_devise_notification(notification, *args) diff --git a/app/models/web/push_subscription.rb b/app/models/web/push_subscription.rb new file mode 100644 index 000000000..4440706a6 --- /dev/null +++ b/app/models/web/push_subscription.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: web_push_subscriptions +# +# id :integer not null, primary key +# endpoint :string not null +# key_p256dh :string not null +# key_auth :string not null +# data :json +# created_at :datetime not null +# updated_at :datetime not null +# + +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) + return unless pushable? 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 + # TODO: Queue the requests - Webpush::TooManyRequests + Webpush.payload_send( + message: JSON.generate( + title: title, + dir: dir, + image: image, + badge: full_asset_url('badge.png'), + 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, + } + ), + 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 + ) + end + + def as_payload + payload = { + id: id, + endpoint: endpoint, + } + + payload[:alerts] = data['alerts'] if data && data.key?('alerts') + + payload + 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 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('emoji/2764.png'), + 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('emoji/1f441.png'), todo: 'expand', action: 'expand') + end + + if can_boost + actions << { title: translate('push_notifications.mention.action_boost'), icon: full_asset_url('emoji/1f504.png'), 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 pushable?(notification) + data && data.key?('alerts') && data['alerts'][notification.type.to_s] + 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'), + badge: full_asset_url('badge.png'), + data: { + content: translate('push_notifications.subscribed.body'), + actions: [], + url: web_url('notifications'), + } + ), + 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: 5 * 60 # 5 minutes + ) + end + + def find_or_create_access_token(notification) + Doorkeeper::AccessToken.find_or_create_for( + Doorkeeper::Application.find_by(superapp: true), + notification.account.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/initial_state_presenter.rb b/app/presenters/initial_state_presenter.rb index 75fef28a8..9507aad4a 100644 --- a/app/presenters/initial_state_presenter.rb +++ b/app/presenters/initial_state_presenter.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class InitialStatePresenter < ActiveModelSerializers::Model - attributes :settings, :token, :current_account, :admin + attributes :settings, :push_subscription, :token, :current_account, :admin end diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb index 6751c9411..704d29a57 100644 --- a/app/serializers/initial_state_serializer.rb +++ b/app/serializers/initial_state_serializer.rb @@ -2,7 +2,7 @@ class InitialStateSerializer < ActiveModel::Serializer attributes :meta, :compose, :accounts, - :media_attachments, :settings + :media_attachments, :settings, :push_subscription def meta store = { diff --git a/app/services/notify_service.rb b/app/services/notify_service.rb index 407d385ea..0ab61b634 100644 --- a/app/services/notify_service.rb +++ b/app/services/notify_service.rb @@ -61,6 +61,11 @@ class NotifyService < BaseService @notification.save! return unless @notification.browserable? Redis.current.publish("timeline:#{@recipient.id}", Oj.dump(event: :notification, payload: InlineRenderer.render(@notification, @recipient, :notification))) + send_push_notifications + end + + def send_push_notifications + WebPushNotificationWorker.perform_async(@recipient.id, @notification.id) end def send_email diff --git a/app/views/home/index.html.haml b/app/views/home/index.html.haml index 71dcb54c6..13ca9ea79 100644 --- a/app/views/home/index.html.haml +++ b/app/views/home/index.html.haml @@ -1,4 +1,5 @@ - content_for :header_tags do + %meta{name: 'applicationServerKey', content: Rails.configuration.x.vapid_public_key} %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) = javascript_pack_tag 'application', integrity: true, crossorigin: 'anonymous' diff --git a/app/workers/web_push_notification_worker.rb b/app/workers/web_push_notification_worker.rb new file mode 100644 index 000000000..0568a3e02 --- /dev/null +++ b/app/workers/web_push_notification_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class WebPushNotificationWorker + include Sidekiq::Worker + + sidekiq_options backtrace: true + + def perform(recipient_id, notification_id) + recipient = Account.find(recipient_id) + notification = Notification.find(notification_id) + + sessions_with_subscriptions = recipient.user.session_activations.reject { |session| session.web_push_subscription.nil? } + + sessions_with_subscriptions.each do |session| + begin + session.web_push_subscription.push(notification) + rescue Webpush::InvalidSubscription, Webpush::ExpiredSubscription + # Subscription expiration is not currently implemented in any browser + session.web_push_subscription.destroy! + session.web_push_subscription = nil + session.save! + rescue Webpush::PayloadTooLarge => e + Rails.logger.error(e) + end + end + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index 406fa970b..4c60965c8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -31,6 +31,11 @@ Rails.application.configure do config.logger = ActiveSupport::TaggedLogging.new(logger) end + # Generate random VAPID keys + vapid_key = Webpush.generate_key + config.x.vapid_private_key = vapid_key.private_key + config.x.vapid_public_key = vapid_key.public_key + # Don't care if the mailer can't send. config.action_mailer.raise_delivery_errors = false diff --git a/config/environments/test.rb b/config/environments/test.rb index bde69eba1..e68cb156d 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -40,6 +40,11 @@ Rails.application.configure do # Print deprecation notices to the stderr. config.active_support.deprecation = :stderr + # Generate random VAPID keys + vapid_key = Webpush.generate_key + config.x.vapid_private_key = vapid_key.private_key + config.x.vapid_public_key = vapid_key.public_key + # Raises error for missing translations # config.action_view.raise_on_missing_translations = true end diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb new file mode 100644 index 000000000..74e07377c --- /dev/null +++ b/config/initializers/vapid.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +Rails.application.configure do + + # You can generate the keys using the following command (first is the private key, second is the public one) + # You should only generate this once per instance. If you later decide to change it, all push subscription will + # be invalidated, requiring the users to access the website again to resubscribe. + # + # ruby -e "require 'webpush'; vapid_key = Webpush.generate_key; puts vapid_key.private_key; puts vapid_key.public_key;" + # + # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html + + if Rails.env.production? + config.x.vapid_private_key = ENV['VAPID_PRIVATE_KEY'] + config.x.vapid_public_key = ENV['VAPID_PUBLIC_KEY'] + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index c9b5d9ab8..79efddfad 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -335,6 +335,21 @@ en: next: Next prev: Prev truncate: "…" + push_notifications: + favourite: + title: "%{name} favourited your status" + follow: + title: "%{name} is now following you" + mention: + action_boost: 'Boost' + action_expand: 'Show more' + action_favourite: 'Favourite' + title: "%{name} mentioned you" + reblog: + title: "%{name} boosted your status" + subscribed: + body: "You can now receive push notifications." + title: "Subscription registered!" remote_follow: acct: Enter your username@domain you want to follow from missing_resource: Could not find the required redirect URL for your account diff --git a/config/locales/pl.yml b/config/locales/pl.yml index dc5aa716b..f9d69745f 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -339,6 +339,21 @@ pl: next: Następna prev: Poprzednia truncate: "…" + push_notifications: + favourite: + title: "%{name} dodał Twój status do ulubionych" + follow: + title: "%{name} zaczął Cię śledzić" + mention: + action_boost: 'Podbij' + action_expand: 'Pokaż więcej' + action_favourite: 'Dodaj do ulubionych' + title: "%{name} wspomniał o Tobie" + reblog: + title: "%{name} podbił Twój status" + subscribed: + body: "Otrzymujesz teraz powiadomienia push." + title: "Zarejestrowano subskrypcję!" remote_follow: acct: Podaj swój adres (nazwa@domena), z którego chcesz śledzić missing_resource: Nie udało się znaleźć adresu przekierowania z Twojej domeny diff --git a/config/routes.rb b/config/routes.rb index 963fedcb4..9171d02d4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -206,6 +206,11 @@ Rails.application.routes.draw do namespace :web do resource :settings, only: [:update] + resources :push_subscriptions, only: [:create] do + member do + put :update + end + end end end diff --git a/config/webpack/production.js b/config/webpack/production.js index 303fca81b..4592db89e 100644 --- a/config/webpack/production.js +++ b/config/webpack/production.js @@ -5,6 +5,9 @@ const merge = require('webpack-merge'); const CompressionPlugin = require('compression-webpack-plugin'); const sharedConfig = require('./shared.js'); const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; +const OfflinePlugin = require('offline-plugin'); +const { publicPath } = require('./configuration.js'); +const path = require('path'); module.exports = merge(sharedConfig, { output: { filename: '[name]-[chunkhash].js' }, @@ -39,5 +42,16 @@ module.exports = merge(sharedConfig, { openAnalyzer: false, logLevel: 'silent', // do not bother Webpacker, who runs with --json and parses stdout }), + new OfflinePlugin({ + publicPath: publicPath, // sw.js must be served from the root to avoid scope issues + caches: { }, // do not cache things, we only use it for push notifications for now + ServiceWorker: { + entry: path.join(__dirname, '../../app/javascript/mastodon/service_worker/entry.js'), + cacheName: 'mastodon', + output: '../sw.js', + publicPath: '/sw.js', + minify: true, + }, + }), ], }); diff --git a/db/migrate/20170713175513_create_web_push_subscriptions.rb b/db/migrate/20170713175513_create_web_push_subscriptions.rb new file mode 100644 index 000000000..4e5c2ba00 --- /dev/null +++ b/db/migrate/20170713175513_create_web_push_subscriptions.rb @@ -0,0 +1,12 @@ +class CreateWebPushSubscriptions < ActiveRecord::Migration[5.1] + def change + create_table :web_push_subscriptions do |t| + t.string :endpoint, null: false + t.string :key_p256dh, null: false + t.string :key_auth, null: false + t.json :data + + t.timestamps + end + end +end diff --git a/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb new file mode 100644 index 000000000..d69cdfa50 --- /dev/null +++ b/db/migrate/20170713190709_add_web_push_subscription_to_session_activations.rb @@ -0,0 +1,5 @@ +class AddWebPushSubscriptionToSessionActivations < ActiveRecord::Migration[5.1] + def change + add_column :session_activations, :web_push_subscription_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index d6e572703..b2c59a0f6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170713112503) do +ActiveRecord::Schema.define(version: 20170713190709) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -258,6 +258,7 @@ ActiveRecord::Schema.define(version: 20170713112503) do t.string "user_agent", default: "", null: false t.inet "ip" t.integer "access_token_id" + t.integer "web_push_subscription_id" t.index ["session_id"], name: "index_session_activations_on_session_id", unique: true t.index ["user_id"], name: "index_session_activations_on_user_id" end @@ -371,6 +372,15 @@ ActiveRecord::Schema.define(version: 20170713112503) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + create_table "web_push_subscriptions", force: :cascade do |t| + t.string "endpoint", null: false + t.string "key_p256dh", null: false + t.string "key_auth", null: false + t.json "data" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "web_settings", id: :serial, force: :cascade do |t| t.integer "user_id" t.json "data" diff --git a/package.json b/package.json index 004c4d1f5..1aaa243c8 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "node-sass": "^4.5.2", "npmlog": "^4.1.2", "object-assign": "^4.1.1", + "offline-plugin": "^4.8.3", "path-complete-extname": "^0.1.0", "pg": "^6.4.0", "postcss-loader": "^2.0.6", diff --git a/public/badge.png b/public/badge.png new file mode 100644 index 000000000..fc1f42dca Binary files /dev/null and b/public/badge.png differ diff --git a/spec/controllers/api/web/push_subscriptions_controller_spec.rb b/spec/controllers/api/web/push_subscriptions_controller_spec.rb new file mode 100644 index 000000000..871176a07 --- /dev/null +++ b/spec/controllers/api/web/push_subscriptions_controller_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Api::Web::PushSubscriptionsController do + render_views + + let(:user) { Fabricate(:user) } + + let(:create_payload) do + { + data: { + endpoint: 'https://fcm.googleapis.com/fcm/send/fiuH06a27qE:APA91bHnSiGcLwdaxdyqVXNDR9w1NlztsHb6lyt5WDKOC_Z_Q8BlFxQoR8tWFSXUIDdkyw0EdvxTu63iqamSaqVSevW5LfoFwojws8XYDXv_NRRLH6vo2CdgiN4jgHv5VLt2A8ah6lUX', + keys: { + p256dh: 'BEm_a0bdPDhf0SOsrnB2-ategf1hHoCnpXgQsFj5JCkcoMrMt2WHoPfEYOYPzOIs9mZE8ZUaD7VA5vouy0kEkr8=', + auth: 'eH_C8rq2raXqlcBVDa1gLg==', + }, + } + } + end + + let(:alerts_payload) do + { + data: { + alerts: { + follow: true, + favourite: false, + reblog: true, + mention: false, + } + } + } + end + + describe 'POST #create' do + it 'saves push subscriptions' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + + user.reload + + push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]) + + expect(push_subscription['endpoint']).to eq(create_payload[:data][:endpoint]) + expect(push_subscription['key_p256dh']).to eq(create_payload[:data][:keys][:p256dh]) + expect(push_subscription['key_auth']).to eq(create_payload[:data][:keys][:auth]) + end + + it 'sends welcome notification' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + end + end + + describe 'PUT #update' do + it 'changes alert settings' do + sign_in(user) + + stub_request(:post, create_payload[:data][:endpoint]).to_return(status: 200) + + post :create, format: :json, params: create_payload + + alerts_payload[:id] = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]).id + + put :update, format: :json, params: alerts_payload + + push_subscription = Web::PushSubscription.find_by(endpoint: create_payload[:data][:endpoint]) + + expect(push_subscription.data['follow']).to eq(alerts_payload[:data][:follow]) + expect(push_subscription.data['favourite']).to eq(alerts_payload[:data][:favourite]) + expect(push_subscription.data['reblog']).to eq(alerts_payload[:data][:reblog]) + expect(push_subscription.data['mention']).to eq(alerts_payload[:data][:mention]) + end + end +end diff --git a/spec/fabricators/web_push_subscription_fabricator.rb b/spec/fabricators/web_push_subscription_fabricator.rb new file mode 100644 index 000000000..72d11b77c --- /dev/null +++ b/spec/fabricators/web_push_subscription_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:web_push_subscription) do + endpoint Faker::Internet.url + key_p256dh Faker::Internet.password + key_auth Faker::Internet.password +end diff --git a/spec/models/web/push_subscription_spec.rb b/spec/models/web/push_subscription_spec.rb new file mode 100644 index 000000000..574da55ac --- /dev/null +++ b/spec/models/web/push_subscription_spec.rb @@ -0,0 +1,28 @@ +require 'rails_helper' + +RSpec.describe Web::PushSubscription, type: :model do + let(:alerts) { { mention: true, reblog: false, follow: true, follow_request: false, favourite: true } } + let(:payload_no_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd').as_payload } + let(:payload_alerts) { Web::PushSubscription.new(id: 1, endpoint: 'a', key_p256dh: 'c', key_auth: 'd', data: { alerts: alerts }).as_payload } + let(:push_subscription) { Web::PushSubscription.new(data: { alerts: alerts }) } + + describe '#as_payload' do + it 'only returns id and endpoint' do + expect(payload_no_alerts.keys).to eq [:id, :endpoint] + end + + it 'returns alerts if set' do + expect(payload_alerts.keys).to eq [:id, :endpoint, :alerts] + end + end + + describe '#pushable?' do + it 'obeys alert settings' do + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Mention'))).to eq true + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Status'))).to eq false + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Follow'))).to eq true + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'FollowRequest'))).to eq false + expect(push_subscription.send(:pushable?, Notification.new(activity_type: 'Favourite'))).to eq true + end + end +end diff --git a/yarn.lock b/yarn.lock index 13c3f4951..812a0721a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2209,7 +2209,7 @@ deep-equal@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.0.1.tgz#f5d260292b660e084eff4cdbc9f08ad3247448b5" -deep-extend@~0.4.0: +deep-extend@^0.4.0, deep-extend@~0.4.0: version "0.4.2" resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.4.2.tgz#48b699c27e334bf89f10892be432f6e4c7d34a7f" @@ -2416,7 +2416,7 @@ ee-first@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" -ejs@^2.5.6: +ejs@^2.3.4, ejs@^2.5.6: version "2.5.6" resolved "https://registry.yarnpkg.com/ejs/-/ejs-2.5.6.tgz#479636bfa3fe3b1debd52087f0acb204b4f19c88" @@ -4059,6 +4059,15 @@ loader-runner@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-2.3.0.tgz#f482aea82d543e07921700d5a46ef26fdac6b8a2" +loader-utils@0.2.x: + version "0.2.17" + resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-0.2.17.tgz#f86e6374d43205a6e6c60e9196f17c0299bfb348" + dependencies: + big.js "^3.1.3" + emojis-list "^2.0.0" + json5 "^0.5.0" + object-assign "^4.0.1" + loader-utils@^1.0.0, loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.1.0.tgz#c98aef488bcceda2ffb5e2de646d6a754429f5cd" @@ -4419,7 +4428,7 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" -minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@~3.0.2: +minimatch@^3.0.0, minimatch@^3.0.2, minimatch@^3.0.3, minimatch@^3.0.4, minimatch@~3.0.2: version "3.0.4" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083" dependencies: @@ -4760,6 +4769,16 @@ obuf@^1.0.0, obuf@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.1.tgz#104124b6c602c6796881a042541d36db43a5264e" +offline-plugin@^4.8.3: + version "4.8.3" + resolved "https://registry.yarnpkg.com/offline-plugin/-/offline-plugin-4.8.3.tgz#9e95bd342ea2ac836b001b81f204c40638694d6c" + dependencies: + deep-extend "^0.4.0" + ejs "^2.3.4" + loader-utils "0.2.x" + minimatch "^3.0.3" + slash "^1.0.0" + on-finished@~2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" -- cgit From a9a0c854e1df8fbc682eeb059fc68e8dbdbb5bde Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Thu, 13 Jul 2017 22:18:18 +0200 Subject: fix(components/media_modal): Style issues (#4187) --- .../mastodon/components/extended_video_player.js | 4 +++- .../mastodon/features/ui/components/media_modal.js | 20 ++++++++++---------- app/javascript/styles/components.scss | 8 ++++++++ 3 files changed, 21 insertions(+), 11 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/components/extended_video_player.js b/app/javascript/mastodon/components/extended_video_player.js index 4c62fa7b3..b38a4b8ff 100644 --- a/app/javascript/mastodon/components/extended_video_player.js +++ b/app/javascript/mastodon/components/extended_video_player.js @@ -5,6 +5,8 @@ export default class ExtendedVideoPlayer extends React.PureComponent { static propTypes = { src: PropTypes.string.isRequired, + width: PropTypes.number, + height: PropTypes.number, time: PropTypes.number, controls: PropTypes.bool.isRequired, muted: PropTypes.bool.isRequired, @@ -30,7 +32,7 @@ export default class ExtendedVideoPlayer extends React.PureComponent { render () { return ( -
+
; } - if (attachment.get('type') === 'image') { - content = media.map((image) => { - const width = image.getIn(['meta', 'original', 'width']) || null; - const height = image.getIn(['meta', 'original', 'height']) || null; + content = media.map((image) => { + const width = image.getIn(['meta', 'original', 'width']) || null; + const height = image.getIn(['meta', 'original', 'height']) || null; + if (image.get('type') === 'image') { return ; - }).toArray(); - } else if (attachment.get('type') === 'gifv') { - content = ; - } + } else if (image.get('type') === 'gifv') { + return ; + } + + return null; + }).toArray(); return (
diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 02602afa4..bcf7ba4e0 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2957,6 +2957,7 @@ button.icon-button.active i.fa-retweet { max-height: 80vh; position: relative; + .extended-video-player, img, canvas, video { @@ -2966,6 +2967,13 @@ button.icon-button.active i.fa-retweet { height: auto; } + .extended-video-player, + video { + display: flex; + width: 80vw; + height: 80vh; + } + img, canvas { display: block; -- cgit From a9067167bb368b1692bdd1ceea216b9215275ee2 Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Fri, 14 Jul 2017 00:49:01 +0200 Subject: Improve swiping (#4188) * feat(components/columns_area): Toggle animation settings * fix(components/media_modal): Center non-visible views * fix(components/media_modal): Check for null * refactor(columns_area): Better logic --- .../mastodon/features/ui/components/columns_area.js | 20 ++++++++++++++------ app/javascript/styles/components.scss | 2 ++ 2 files changed, 16 insertions(+), 6 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index cbc185a7d..ef9a15522 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -32,12 +32,19 @@ export default class ColumnsArea extends ImmutablePureComponent { children: PropTypes.node, }; + componentDidUpdate() { + this.lastIndex = getIndex(this.context.router.history.location.pathname); + } + handleSwipe = (index) => { - window.requestAnimationFrame(() => { - window.requestAnimationFrame(() => { - this.context.router.history.push(getLink(index)); - }); - }); + this.pendingIndex = index; + } + + handleAnimationEnd = () => { + if (typeof this.pendingIndex === 'number') { + this.context.router.history.push(getLink(this.pendingIndex)); + this.pendingIndex = null; + } } renderView = (link, index) => { @@ -68,10 +75,11 @@ export default class ColumnsArea extends ImmutablePureComponent { const { columns, children, singleColumn } = this.props; const columnIndex = getIndex(this.context.router.history.location.pathname); + const shouldAnimate = Math.abs(this.lastIndex - columnIndex) === 1; if (singleColumn) { return columnIndex !== -1 ? ( - + {links.map(this.renderView)} ) :
{children}
; diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index bcf7ba4e0..0face646d 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1312,6 +1312,8 @@ } .react-swipeable-view-container > * { + display: flex; + align-items: center; height: 100%; } -- cgit From eeb5923e89c1b9040df37db8709e3450713c6019 Mon Sep 17 00:00:00 2001 From: unarist Date: Fri, 14 Jul 2017 08:59:34 +0900 Subject: Add object-fit polyfill for Edge (#4182) --- .postcssrc.yml | 1 + app/javascript/mastodon/extra_polyfills.js | 3 ++ app/javascript/mastodon/load_polyfills.js | 5 ++- package.json | 2 + yarn.lock | 66 +++++++++++++++++++++++++++++- 5 files changed, 74 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/.postcssrc.yml b/.postcssrc.yml index 220fe0bb9..efffb39ba 100644 --- a/.postcssrc.yml +++ b/.postcssrc.yml @@ -6,3 +6,4 @@ plugins: - last 2 versions - IE >= 11 - iOS >= 9 + postcss-object-fit-images: {} diff --git a/app/javascript/mastodon/extra_polyfills.js b/app/javascript/mastodon/extra_polyfills.js index 546b693b1..3acc55abd 100644 --- a/app/javascript/mastodon/extra_polyfills.js +++ b/app/javascript/mastodon/extra_polyfills.js @@ -1,2 +1,5 @@ import 'intersection-observer'; import 'requestidlecallback'; +import objectFitImages from 'object-fit-images'; + +objectFitImages(); diff --git a/app/javascript/mastodon/load_polyfills.js b/app/javascript/mastodon/load_polyfills.js index bc5468595..df7889118 100644 --- a/app/javascript/mastodon/load_polyfills.js +++ b/app/javascript/mastodon/load_polyfills.js @@ -20,11 +20,12 @@ function loadPolyfills() { ); // Latest version of Firefox and Safari do not have IntersectionObserver. - // Edge does not have requestIdleCallback. + // Edge does not have requestIdleCallback and object-fit CSS property. // This avoids shipping them all the polyfills. const needsExtraPolyfills = !( window.IntersectionObserver && - window.requestIdleCallback + window.requestIdleCallback && + 'object-fit' in (new Image()).style ); return Promise.all([ diff --git a/package.json b/package.json index 1aaa243c8..5ad576dad 100644 --- a/package.json +++ b/package.json @@ -67,10 +67,12 @@ "node-sass": "^4.5.2", "npmlog": "^4.1.2", "object-assign": "^4.1.1", + "object-fit-images": "^3.2.3", "offline-plugin": "^4.8.3", "path-complete-extname": "^0.1.0", "pg": "^6.4.0", "postcss-loader": "^2.0.6", + "postcss-object-fit-images": "^1.1.2", "postcss-smart-import": "^0.7.5", "precss": "^2.0.0", "prop-types": "^15.5.10", diff --git a/yarn.lock b/yarn.lock index 812a0721a..56a9f7798 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2030,12 +2030,38 @@ css-color-names@0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0" +css-font-size-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz#854875ace9aca6a8d2ee0d345a44aae9bb6db6cb" + +css-font-stretch-keywords@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz#50cee9b9ba031fb5c952d4723139f1e107b54b10" + +css-font-style-keywords@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz#5c3532813f63b4a1de954d13cea86ab4333409e4" + +css-font-weight-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz#9bc04671ac85bc724b574ef5d3ac96b0d604fd97" + +css-global-keywords@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/css-global-keywords/-/css-global-keywords-1.0.1.tgz#72a9aea72796d019b1d2a3252de4e5aaa37e4a69" + css-in-js-utils@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/css-in-js-utils/-/css-in-js-utils-1.0.3.tgz#9ac7e02f763cf85d94017666565ed68a5b5f3215" dependencies: hyphenate-style-name "^1.0.2" +css-list-helpers@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/css-list-helpers/-/css-list-helpers-1.0.1.tgz#fff57192202db83240c41686f919e449a7024f7d" + dependencies: + tcomb "^2.5.0" + css-loader@^0.28.1, css-loader@^0.28.4: version "0.28.4" resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-0.28.4.tgz#6cf3579192ce355e8b38d5f42dd7a1f2ec898d0f" @@ -2072,6 +2098,10 @@ css-selector-tokenizer@^0.7.0: fastparse "^1.1.1" regexpu-core "^1.0.0" +css-system-font-keywords@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz#85c6f086aba4eb32c571a3086affc434b84823ed" + css-what@2.1: version "2.1.0" resolved "https://registry.yarnpkg.com/css-what/-/css-what-2.1.0.tgz#9467d032c38cfaefb9f2d79501253062f87fa1bd" @@ -4713,6 +4743,10 @@ object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" +object-fit-images@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/object-fit-images/-/object-fit-images-3.2.3.tgz#4089f6d0070a3b5563d3c1ab6f1b28d61331f0ac" + object-is@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.0.1.tgz#0aa60ec9989a0b3ed795cf4d06f62cf1ad6539b6" @@ -4896,6 +4930,20 @@ parse-asn1@^5.0.0: evp_bytestokey "^1.0.0" pbkdf2 "^3.0.3" +parse-css-font@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/parse-css-font/-/parse-css-font-2.0.2.tgz#7b60b060705a25a9b90b7f0ed493e5823248a652" + dependencies: + css-font-size-keywords "^1.0.0" + css-font-stretch-keywords "^1.0.1" + css-font-style-keywords "^1.0.1" + css-font-weight-keywords "^1.0.0" + css-global-keywords "^1.0.1" + css-list-helpers "^1.0.1" + css-system-font-keywords "^1.0.0" + tcomb "^2.5.0" + unquote "^1.1.0" + parse-glob@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/parse-glob/-/parse-glob-3.0.4.tgz#b2c376cfb11f35513badd173ef0bb6e3a388391c" @@ -5381,6 +5429,14 @@ postcss-normalize-url@^3.0.7: postcss "^5.0.14" postcss-value-parser "^3.2.3" +postcss-object-fit-images@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/postcss-object-fit-images/-/postcss-object-fit-images-1.1.2.tgz#8b773043db14672ef6cd6f2cb1f0d8b26a9f573b" + dependencies: + parse-css-font "^2.0.2" + postcss "^5.0.16" + quote "^0.4.0" + postcss-ordered-values@^2.1.0: version "2.2.3" resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz#eec6c2a67b6c412a8db2042e77fe8da43f95c11d" @@ -5694,6 +5750,10 @@ querystringify@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-1.0.0.tgz#6286242112c5b712fa654e526652bf6a13ff05cb" +quote@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/quote/-/quote-0.4.0.tgz#10839217f6c1362b89194044d29b233fd7f32f01" + raf@^3.1.0: version "3.3.2" resolved "https://registry.yarnpkg.com/raf/-/raf-3.3.2.tgz#0c13be0b5b49b46f76d6669248d527cf2b02fe27" @@ -6973,7 +7033,7 @@ tar@^2.0.0, tar@^2.2.1: fstream "^1.0.2" inherits "2" -tcomb@^2.5.1: +tcomb@^2.5.0, tcomb@^2.5.1: version "2.7.0" resolved "https://registry.yarnpkg.com/tcomb/-/tcomb-2.7.0.tgz#10d62958041669a5d53567b9a4ee8cde22b1c2b0" @@ -7147,6 +7207,10 @@ unpipe@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" +unquote@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unquote/-/unquote-1.1.0.tgz#98e1fc608b6b854c75afb1b95afc099ba69d942f" + urix@^0.1.0, urix@~0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/urix/-/urix-0.1.0.tgz#da937f7a62e21fec1fd18d49b35c2935067a6c72" -- cgit From a49be27145a858a51e1b9104b0ebc5f63a03b81b Mon Sep 17 00:00:00 2001 From: masarakki Date: Fri, 14 Jul 2017 18:02:49 +0900 Subject: add validation to tag name (#4194) --- app/models/tag.rb | 5 +++-- spec/models/tag_spec.rb | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) (limited to 'app') diff --git a/app/models/tag.rb b/app/models/tag.rb index a14e2cc98..0fa08e157 100644 --- a/app/models/tag.rb +++ b/app/models/tag.rb @@ -12,9 +12,10 @@ class Tag < ApplicationRecord has_and_belongs_to_many :statuses - HASHTAG_RE = /(?:^|[^\/\)\w])#([[:word:]_]*[[:alpha:]_][[:word:]_]*)/i + HASHTAG_NAME_RE = '[[:word:]_]*[[:alpha:]_][[:word:]_]*' + HASHTAG_RE = /(?:^|[^\/\)\w])#(#{HASHTAG_NAME_RE})/i - validates :name, presence: true, uniqueness: true + validates :name, presence: true, uniqueness: true, format: { with: /\A#{HASHTAG_NAME_RE}\z/i } def to_param name diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index 555474c44..f727fa1dd 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -1,6 +1,24 @@ require 'rails_helper' RSpec.describe Tag, type: :model do + describe 'validations' do + it 'invalid with #' do + expect(Tag.new(name: '#hello_world')).to_not be_valid + end + + it 'invalid with .' do + expect(Tag.new(name: '.abcdef123')).to_not be_valid + end + + it 'invalid with spaces' do + expect(Tag.new(name: 'hello world')).to_not be_valid + end + + it 'valid with aesthetic' do + expect(Tag.new(name: 'aesthetic')).to be_valid + end + end + describe 'HASHTAG_RE' do subject { Tag::HASHTAG_RE } -- cgit From 87b96f8d339ff7998fc9a357f979f86cc38ad33e Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 14 Jul 2017 18:03:01 +0900 Subject: Add Japanese translations for #3243 (#4192) --- app/javascript/mastodon/locales/ar.json | 3 +++ app/javascript/mastodon/locales/bg.json | 3 +++ app/javascript/mastodon/locales/ca.json | 3 +++ app/javascript/mastodon/locales/de.json | 3 +++ app/javascript/mastodon/locales/defaultMessages.json | 17 +++++++++++++++++ app/javascript/mastodon/locales/en.json | 3 +++ app/javascript/mastodon/locales/eo.json | 3 +++ app/javascript/mastodon/locales/es.json | 3 +++ app/javascript/mastodon/locales/fa.json | 3 +++ app/javascript/mastodon/locales/fi.json | 3 +++ app/javascript/mastodon/locales/fr.json | 3 +++ app/javascript/mastodon/locales/he.json | 3 +++ app/javascript/mastodon/locales/hr.json | 3 +++ app/javascript/mastodon/locales/hu.json | 3 +++ app/javascript/mastodon/locales/id.json | 3 +++ app/javascript/mastodon/locales/io.json | 3 +++ app/javascript/mastodon/locales/it.json | 3 +++ app/javascript/mastodon/locales/ja.json | 3 +++ app/javascript/mastodon/locales/ko.json | 3 +++ app/javascript/mastodon/locales/nl.json | 3 +++ app/javascript/mastodon/locales/no.json | 3 +++ app/javascript/mastodon/locales/oc.json | 3 +++ app/javascript/mastodon/locales/pl.json | 3 +++ app/javascript/mastodon/locales/pt-BR.json | 3 +++ app/javascript/mastodon/locales/pt.json | 3 +++ app/javascript/mastodon/locales/ru.json | 3 +++ app/javascript/mastodon/locales/th.json | 3 +++ app/javascript/mastodon/locales/tr.json | 3 +++ app/javascript/mastodon/locales/uk.json | 3 +++ app/javascript/mastodon/locales/zh-CN.json | 3 +++ app/javascript/mastodon/locales/zh-HK.json | 3 +++ app/javascript/mastodon/locales/zh-TW.json | 3 +++ config/locales/ja.yml | 15 +++++++++++++++ 33 files changed, 125 insertions(+) (limited to 'app') diff --git a/app/javascript/mastodon/locales/ar.json b/app/javascript/mastodon/locales/ar.json index 6992e7e0f..7b890ce64 100644 --- a/app/javascript/mastodon/locales/ar.json +++ b/app/javascript/mastodon/locales/ar.json @@ -109,6 +109,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.reblog": "الترقيّات:", "notifications.column_settings.show": "إعرِضها في عمود", "notifications.column_settings.sound": "أصدر صوتا", @@ -147,6 +149,7 @@ "report.target": "إبلاغ", "search.placeholder": "ابحث", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "تعذرت ترقية هذا المنشور", "status.delete": "إحذف", "status.favourite": "أضف إلى المفضلة", diff --git a/app/javascript/mastodon/locales/bg.json b/app/javascript/mastodon/locales/bg.json index 7a56e1446..0cf6bf3ac 100644 --- a/app/javascript/mastodon/locales/bg.json +++ b/app/javascript/mastodon/locales/bg.json @@ -109,6 +109,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.reblog": "Споделяния:", "notifications.column_settings.show": "Покажи в колона", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "Търсене", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Изтриване", "status.favourite": "Предпочитани", diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json index b2673915a..1e44d6fa5 100644 --- a/app/javascript/mastodon/locales/ca.json +++ b/app/javascript/mastodon/locales/ca.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favorits:", "notifications.column_settings.follow": "Nous seguidors:", "notifications.column_settings.mention": "Mencions:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "Mostrar en la columna", "notifications.column_settings.sound": "Reproduïr so", @@ -147,6 +149,7 @@ "report.target": "Informes", "search.placeholder": "Cercar", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Aquesta publicació no pot ser retootejada", "status.delete": "Esborrar", "status.favourite": "Favorit", diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json index 4b62403c3..f73011e73 100644 --- a/app/javascript/mastodon/locales/de.json +++ b/app/javascript/mastodon/locales/de.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favorisierungen:", "notifications.column_settings.follow": "Neue Folgende:", "notifications.column_settings.mention": "Erwähnungen:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Geteilte Beiträge:", "notifications.column_settings.show": "In der Spalte anzeigen", "notifications.column_settings.sound": "Ton abspielen", @@ -147,6 +149,7 @@ "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", "status.delete": "Löschen", "status.favourite": "Favorisieren", diff --git a/app/javascript/mastodon/locales/defaultMessages.json b/app/javascript/mastodon/locales/defaultMessages.json index 7c1522299..aaa558c0e 100644 --- a/app/javascript/mastodon/locales/defaultMessages.json +++ b/app/javascript/mastodon/locales/defaultMessages.json @@ -861,6 +861,14 @@ "defaultMessage": "Play sound", "id": "notifications.column_settings.sound" }, + { + "defaultMessage": "Push notifications", + "id": "notifications.column_settings.push" + }, + { + "defaultMessage": "This device", + "id": "notifications.column_settings.push_meta" + }, { "defaultMessage": "New followers:", "id": "notifications.column_settings.follow" @@ -936,6 +944,15 @@ ], "path": "app/javascript/mastodon/features/public_timeline/index.json" }, + { + "descriptors": [ + { + "defaultMessage": "A look inside...", + "id": "standalone.public_title" + } + ], + "path": "app/javascript/mastodon/features/standalone/public_timeline/index.json" + }, { "descriptors": [ { diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json index 778f33269..15afe2309 100644 --- a/app/javascript/mastodon/locales/en.json +++ b/app/javascript/mastodon/locales/en.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.follow": "New followers:", "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "Show in column", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting {target}", "search.placeholder": "Search", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Delete", "status.favourite": "Favourite", diff --git a/app/javascript/mastodon/locales/eo.json b/app/javascript/mastodon/locales/eo.json index 2648a6840..4f9e26c25 100644 --- a/app/javascript/mastodon/locales/eo.json +++ b/app/javascript/mastodon/locales/eo.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoroj:", "notifications.column_settings.follow": "Novaj sekvantoj:", "notifications.column_settings.mention": "Mencioj:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Diskonigoj:", "notifications.column_settings.show": "Montri en kolono", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "Serĉi", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Forigi", "status.favourite": "Favori", diff --git a/app/javascript/mastodon/locales/es.json b/app/javascript/mastodon/locales/es.json index c42930380..64ba78716 100644 --- a/app/javascript/mastodon/locales/es.json +++ b/app/javascript/mastodon/locales/es.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoritos:", "notifications.column_settings.follow": "Nuevos seguidores:", "notifications.column_settings.mention": "Menciones:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Retoots:", "notifications.column_settings.show": "Mostrar en columna", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "Buscar", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Borrar", "status.favourite": "Favorito", diff --git a/app/javascript/mastodon/locales/fa.json b/app/javascript/mastodon/locales/fa.json index c9f1888b5..306937cc2 100644 --- a/app/javascript/mastodon/locales/fa.json +++ b/app/javascript/mastodon/locales/fa.json @@ -109,6 +109,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.reblog": "بازبوق‌ها:", "notifications.column_settings.show": "نمایش در ستون", "notifications.column_settings.sound": "پخش صدا", @@ -147,6 +149,7 @@ "report.target": "گزارش‌دادن", "search.placeholder": "جستجو", "search_results.total": "{count, number} {count, plural, one {نتیجه} other {نتیجه}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "این نوشته را نمی‌شود بازبوقید", "status.delete": "پاک‌کردن", "status.favourite": "پسندیدن", diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json index b836d2f5d..1b17fb155 100644 --- a/app/javascript/mastodon/locales/fi.json +++ b/app/javascript/mastodon/locales/fi.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Tykkäyksiä:", "notifications.column_settings.follow": "Uusia seuraajia:", "notifications.column_settings.mention": "Mainintoja:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Buusteja:", "notifications.column_settings.show": "Näytä sarakkeessa", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "Hae", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Poista", "status.favourite": "Tykkää", diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index eaa01638c..ea69532dd 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoris :", "notifications.column_settings.follow": "Nouveaux⋅elles abonn⋅é⋅s :", "notifications.column_settings.mention": "Mentions :", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Partages :", "notifications.column_settings.show": "Afficher dans la colonne", "notifications.column_settings.sound": "Émettre un son", @@ -147,6 +149,7 @@ "report.target": "Signalement", "search.placeholder": "Rechercher", "search_results.total": "{count, number} {count, plural, one {résultat} other {résultats}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Cette publication ne peut être boostée", "status.delete": "Effacer", "status.favourite": "Ajouter aux favoris", diff --git a/app/javascript/mastodon/locales/he.json b/app/javascript/mastodon/locales/he.json index 98c7ea021..8b63bd26b 100644 --- a/app/javascript/mastodon/locales/he.json +++ b/app/javascript/mastodon/locales/he.json @@ -109,6 +109,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.reblog": "הדהודים:", "notifications.column_settings.show": "הצגה בטור", "notifications.column_settings.sound": "שמע מופעל", @@ -147,6 +149,7 @@ "report.target": "דיווח", "search.placeholder": "חיפוש", "search_results.total": "{count, number} {count, plural, one {תוצאה} other {תוצאות}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "לא ניתן להדהד הודעה זו", "status.delete": "מחיקה", "status.favourite": "חיבוב", diff --git a/app/javascript/mastodon/locales/hr.json b/app/javascript/mastodon/locales/hr.json index fdf5c11c0..165e3088f 100644 --- a/app/javascript/mastodon/locales/hr.json +++ b/app/javascript/mastodon/locales/hr.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoriti:", "notifications.column_settings.follow": "Novi sljedbenici:", "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.show": "Prikaži u stupcu", "notifications.column_settings.sound": "Sviraj zvuk", @@ -147,6 +149,7 @@ "report.target": "Prijavljivanje", "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.delete": "Obriši", "status.favourite": "Označi omiljenim", diff --git a/app/javascript/mastodon/locales/hu.json b/app/javascript/mastodon/locales/hu.json index baf762c8d..71dcce505 100644 --- a/app/javascript/mastodon/locales/hu.json +++ b/app/javascript/mastodon/locales/hu.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.follow": "New followers:", "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "Show in column", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "Keresés", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Törlés", "status.favourite": "Kedvenc", diff --git a/app/javascript/mastodon/locales/id.json b/app/javascript/mastodon/locales/id.json index 6f6d688e9..0c21877d8 100644 --- a/app/javascript/mastodon/locales/id.json +++ b/app/javascript/mastodon/locales/id.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favorit:", "notifications.column_settings.follow": "Pengikut baru:", "notifications.column_settings.mention": "Balasan:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boost:", "notifications.column_settings.show": "Tampilkan dalam kolom", "notifications.column_settings.sound": "Mainkan suara", @@ -147,6 +149,7 @@ "report.target": "Melaporkan", "search.placeholder": "Pencarian", "search_results.total": "{count} {count, plural, one {hasil} other {hasil}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Hapus", "status.favourite": "Difavoritkan", diff --git a/app/javascript/mastodon/locales/io.json b/app/javascript/mastodon/locales/io.json index 25e0adc8a..788d09f34 100644 --- a/app/javascript/mastodon/locales/io.json +++ b/app/javascript/mastodon/locales/io.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favorati:", "notifications.column_settings.follow": "Nova sequanti:", "notifications.column_settings.mention": "Mencioni:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Repeti:", "notifications.column_settings.show": "Montrar en kolumno", "notifications.column_settings.sound": "Plear sono", @@ -147,6 +149,7 @@ "report.target": "Denuncante", "search.placeholder": "Serchez", "search_results.total": "{count, number} {count, plural, one {rezulto} other {rezulti}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Efacar", "status.favourite": "Favorizar", diff --git a/app/javascript/mastodon/locales/it.json b/app/javascript/mastodon/locales/it.json index 4881b0f08..9176bfaaf 100644 --- a/app/javascript/mastodon/locales/it.json +++ b/app/javascript/mastodon/locales/it.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Apprezzati:", "notifications.column_settings.follow": "Nuovi seguaci:", "notifications.column_settings.mention": "Menzioni:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Post condivisi:", "notifications.column_settings.show": "Mostra in colonna", "notifications.column_settings.sound": "Riproduci suono", @@ -147,6 +149,7 @@ "report.target": "Invio la segnalazione", "search.placeholder": "Cerca", "search_results.total": "{count} {count, plural, one {risultato} other {risultati}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Elimina", "status.favourite": "Apprezzato", diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index f62072852..a686cdc03 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "お気に入り", "notifications.column_settings.follow": "新しいフォロワー", "notifications.column_settings.mention": "返信", + "notifications.column_settings.push": "プッシュ通知", + "notifications.column_settings.push_meta": "このデバイス", "notifications.column_settings.reblog": "ブースト", "notifications.column_settings.show": "カラムに表示", "notifications.column_settings.sound": "通知音を再生", @@ -147,6 +149,7 @@ "report.target": "問題のユーザー", "search.placeholder": "検索", "search_results.total": "{count, number}件の結果", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "この投稿はブーストできません", "status.delete": "削除", "status.favourite": "お気に入り", diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json index 5e1aaac85..0b47cc990 100644 --- a/app/javascript/mastodon/locales/ko.json +++ b/app/javascript/mastodon/locales/ko.json @@ -109,6 +109,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.reblog": "부스트", "notifications.column_settings.show": "컬럼에 표시", "notifications.column_settings.sound": "효과음 재생", @@ -147,6 +149,7 @@ "report.target": "문제가 된 사용자", "search.placeholder": "검색", "search_results.total": "{count, number}건의 결과", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "이 포스트는 부스트 할 수 없습니다", "status.delete": "삭제", "status.favourite": "즐겨찾기", diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json index 479d157f3..cf6a8bd31 100644 --- a/app/javascript/mastodon/locales/nl.json +++ b/app/javascript/mastodon/locales/nl.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favorieten:", "notifications.column_settings.follow": "Nieuwe volgers:", "notifications.column_settings.mention": "Vermeldingen:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "In kolom tonen", "notifications.column_settings.sound": "Geluid afspelen", @@ -147,6 +149,7 @@ "report.target": "Rapporteren van", "search.placeholder": "Zoeken", "search_results.total": "{count, number} {count, plural, one {resultaat} other {resultaten}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Deze toot kan niet geboost worden", "status.delete": "Verwijderen", "status.favourite": "Favoriet", diff --git a/app/javascript/mastodon/locales/no.json b/app/javascript/mastodon/locales/no.json index 4bbf14938..1f4082d7b 100644 --- a/app/javascript/mastodon/locales/no.json +++ b/app/javascript/mastodon/locales/no.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Likt:", "notifications.column_settings.follow": "Nye følgere:", "notifications.column_settings.mention": "Nevnt:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Fremhevet:", "notifications.column_settings.show": "Vis i kolonne", "notifications.column_settings.sound": "Spill lyd", @@ -147,6 +149,7 @@ "report.target": "Rapporterer", "search.placeholder": "Søk", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultater}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Denne posten kan ikke fremheves", "status.delete": "Slett", "status.favourite": "Lik", diff --git a/app/javascript/mastodon/locales/oc.json b/app/javascript/mastodon/locales/oc.json index 2c119ef41..dc6dd5e32 100644 --- a/app/javascript/mastodon/locales/oc.json +++ b/app/javascript/mastodon/locales/oc.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favorits :", "notifications.column_settings.follow": "Nòus seguidors :", "notifications.column_settings.mention": "Mencions :", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Partatges :", "notifications.column_settings.show": "Mostrar dins la colomna", "notifications.column_settings.sound": "Emetre un son", @@ -147,6 +149,7 @@ "report.target": "Senhalar {target}", "search.placeholder": "Recercar", "search_results.total": "{count, number} {count, plural, one {resultat} other {resultats}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Aqueste estatut pòt pas èsser partejat", "status.delete": "Escafar", "status.favourite": "Apondre als favorits", diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json index c2288c4c0..233d61995 100644 --- a/app/javascript/mastodon/locales/pl.json +++ b/app/javascript/mastodon/locales/pl.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Ulubione:", "notifications.column_settings.follow": "Nowi śledzący:", "notifications.column_settings.mention": "Wspomniali:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Podbili:", "notifications.column_settings.show": "Pokaż w kolumnie", "notifications.column_settings.sound": "Odtwarzaj dźwięk", @@ -147,6 +149,7 @@ "report.target": "Zgłaszanie {target}", "search.placeholder": "Szukaj", "search_results.total": "{count, number} {count, plural, one {wynik} more {wyniki}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Ten post nie może zostać podbity", "status.delete": "Usuń", "status.favourite": "Ulubione", diff --git a/app/javascript/mastodon/locales/pt-BR.json b/app/javascript/mastodon/locales/pt-BR.json index b199a39ce..cf2b911f2 100644 --- a/app/javascript/mastodon/locales/pt-BR.json +++ b/app/javascript/mastodon/locales/pt-BR.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoritos:", "notifications.column_settings.follow": "Novos seguidores:", "notifications.column_settings.mention": "Menções:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Partilhas:", "notifications.column_settings.show": "Mostrar nas colunas", "notifications.column_settings.sound": "Reproduzir som", @@ -147,6 +149,7 @@ "report.target": "Denunciar", "search.placeholder": "Pesquisar", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Eliminar", "status.favourite": "Adicionar aos favoritos", diff --git a/app/javascript/mastodon/locales/pt.json b/app/javascript/mastodon/locales/pt.json index b199a39ce..cf2b911f2 100644 --- a/app/javascript/mastodon/locales/pt.json +++ b/app/javascript/mastodon/locales/pt.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoritos:", "notifications.column_settings.follow": "Novos seguidores:", "notifications.column_settings.mention": "Menções:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Partilhas:", "notifications.column_settings.show": "Mostrar nas colunas", "notifications.column_settings.sound": "Reproduzir som", @@ -147,6 +149,7 @@ "report.target": "Denunciar", "search.placeholder": "Pesquisar", "search_results.total": "{count, number} {count, plural, one {resultado} other {resultados}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Eliminar", "status.favourite": "Adicionar aos favoritos", diff --git a/app/javascript/mastodon/locales/ru.json b/app/javascript/mastodon/locales/ru.json index f9f48a48d..942a13ede 100644 --- a/app/javascript/mastodon/locales/ru.json +++ b/app/javascript/mastodon/locales/ru.json @@ -109,6 +109,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.reblog": "Продвижения:", "notifications.column_settings.show": "Показывать в колонке", "notifications.column_settings.sound": "Проигрывать звук", @@ -147,6 +149,7 @@ "report.target": "Жалуемся на", "search.placeholder": "Поиск", "search_results.total": "{count, number} {count, plural, one {результат} few {результата} many {результатов} other {результатов}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Этот статус не может быть продвинут", "status.delete": "Удалить", "status.favourite": "Нравится", diff --git a/app/javascript/mastodon/locales/th.json b/app/javascript/mastodon/locales/th.json index 8a39beacb..e9e96c14f 100644 --- a/app/javascript/mastodon/locales/th.json +++ b/app/javascript/mastodon/locales/th.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favourites:", "notifications.column_settings.follow": "New followers:", "notifications.column_settings.mention": "Mentions:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boosts:", "notifications.column_settings.show": "Show in column", "notifications.column_settings.sound": "Play sound", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "Search", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "This post cannot be boosted", "status.delete": "Delete", "status.favourite": "Favourite", diff --git a/app/javascript/mastodon/locales/tr.json b/app/javascript/mastodon/locales/tr.json index 203e4a09e..adfa79cd9 100644 --- a/app/javascript/mastodon/locales/tr.json +++ b/app/javascript/mastodon/locales/tr.json @@ -109,6 +109,8 @@ "notifications.column_settings.favourite": "Favoriler:", "notifications.column_settings.follow": "Yeni takipçiler:", "notifications.column_settings.mention": "Bahsedilenler:", + "notifications.column_settings.push": "Push notifications", + "notifications.column_settings.push_meta": "This device", "notifications.column_settings.reblog": "Boost’lar:", "notifications.column_settings.show": "Bildirimlerde göster", "notifications.column_settings.sound": "Ses çal", @@ -147,6 +149,7 @@ "report.target": "Raporlama", "search.placeholder": "Ara", "search_results.total": "{count, number} {count, plural, one {sonuç} other {sonuçlar}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Bu gönderi boost edilemez", "status.delete": "Sil", "status.favourite": "Favorilere ekle", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index c0f4a8dbb..435067281 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -109,6 +109,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.reblog": "Передмухи:", "notifications.column_settings.show": "Показати в колонці", "notifications.column_settings.sound": "Відтворювати звук", @@ -147,6 +149,7 @@ "report.target": "Скаржимося на", "search.placeholder": "Пошук", "search_results.total": "{count, number} {count, plural, one {результат} few {результати} many {результатів} other {результатів}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "Цей допис не може бути передмухнутий", "status.delete": "Видалити", "status.favourite": "Подобається", diff --git a/app/javascript/mastodon/locales/zh-CN.json b/app/javascript/mastodon/locales/zh-CN.json index 998e1c8da..0f2c1fcec 100644 --- a/app/javascript/mastodon/locales/zh-CN.json +++ b/app/javascript/mastodon/locales/zh-CN.json @@ -109,6 +109,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.reblog": "你的嘟文被转嘟:", "notifications.column_settings.show": "在通知栏显示", "notifications.column_settings.sound": "播放音效", @@ -147,6 +149,7 @@ "report.target": "Reporting", "search.placeholder": "搜索", "search_results.total": "{count, number} {count, plural, one {result} other {results}}", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "没法转嘟这条嘟文啦……", "status.delete": "删除", "status.favourite": "赞", diff --git a/app/javascript/mastodon/locales/zh-HK.json b/app/javascript/mastodon/locales/zh-HK.json index 1079d5429..c0b4cfce9 100644 --- a/app/javascript/mastodon/locales/zh-HK.json +++ b/app/javascript/mastodon/locales/zh-HK.json @@ -109,6 +109,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.reblog": "轉推你的文章:", "notifications.column_settings.show": "在通知欄顯示", "notifications.column_settings.sound": "播放音效", @@ -147,6 +149,7 @@ "report.target": "舉報", "search.placeholder": "搜尋", "search_results.total": "{count, number} 項結果", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "這篇文章無法被轉推", "status.delete": "刪除", "status.favourite": "喜歡", diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json index 6240b8879..772cc691c 100644 --- a/app/javascript/mastodon/locales/zh-TW.json +++ b/app/javascript/mastodon/locales/zh-TW.json @@ -109,6 +109,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.reblog": "轉推:", "notifications.column_settings.show": "顯示在欄位中", "notifications.column_settings.sound": "播放音效", @@ -147,6 +149,7 @@ "report.target": "通報中", "search.placeholder": "搜尋", "search_results.total": "{count, number} 項結果", + "standalone.public_title": "A look inside...", "status.cannot_reblog": "此貼文無法轉推", "status.delete": "刪除", "status.favourite": "喜愛", diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 7cd97a042..37d82a205 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -320,6 +320,21 @@ ja: next: 次 prev: 前 truncate: "…" + push_notifications: + favourite: + title: あなたのトゥートが %{name} さんにお気に入り登録されました + follow: + title: '%{name} さんにフォローされました' + mention: + action_boost: ブースト + action_expand: もっと見る + action_favourite: お気に入り + title: '%{name} さんから返信がありました' + reblog: + title: あなたのトゥートが %{name} さんにブーストされました + subscribed: + body: あなたはプッシュ通知を受け取ることが出来ます + title: Subscription が登録されました remote_follow: acct: あなたの ユーザー名@ドメイン を入力してください missing_resource: リダイレクト先が見つかりませんでした -- cgit From 9008ab340712fafa6c26862138f5acbbc958bc9a Mon Sep 17 00:00:00 2001 From: Yamagishi Kazutoshi Date: Fri, 14 Jul 2017 18:08:56 +0900 Subject: Do not load unnecessary script files (#4193) --- app/javascript/mastodon/main.js | 12 +++--------- app/javascript/mastodon/ready.js | 7 +++++++ app/javascript/packs/about.js | 24 ++++++++++++++++++++++++ app/javascript/packs/public.js | 17 ++--------------- app/views/about/show.html.haml | 7 +++---- 5 files changed, 39 insertions(+), 28 deletions(-) create mode 100644 app/javascript/mastodon/ready.js create mode 100644 app/javascript/packs/about.js (limited to 'app') diff --git a/app/javascript/mastodon/main.js b/app/javascript/mastodon/main.js index d2c9d1c94..1f8ebb721 100644 --- a/app/javascript/mastodon/main.js +++ b/app/javascript/mastodon/main.js @@ -1,12 +1,6 @@ -const perf = require('./performance'); +import ready from './ready'; -function onDomContentLoaded(callback) { - if (document.readyState !== 'loading') { - callback(); - } else { - document.addEventListener('DOMContentLoaded', callback); - } -} +const perf = require('./performance'); function main() { perf.start('main()'); @@ -24,7 +18,7 @@ function main() { } } - onDomContentLoaded(() => { + ready(() => { const mountNode = document.getElementById('mastodon'); const props = JSON.parse(mountNode.getAttribute('data-props')); diff --git a/app/javascript/mastodon/ready.js b/app/javascript/mastodon/ready.js new file mode 100644 index 000000000..dd543910b --- /dev/null +++ b/app/javascript/mastodon/ready.js @@ -0,0 +1,7 @@ +export default function ready(loaded) { + if (['interactive', 'complete'].includes(document.readyState)) { + loaded(); + } else { + document.addEventListener('DOMContentLoaded', loaded); + } +} diff --git a/app/javascript/packs/about.js b/app/javascript/packs/about.js new file mode 100644 index 000000000..7b8ab5e5d --- /dev/null +++ b/app/javascript/packs/about.js @@ -0,0 +1,24 @@ +import TimelineContainer from '../mastodon/containers/timeline_container'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import loadPolyfills from '../mastodon/load_polyfills'; +import ready from '../mastodon/ready'; + +require.context('../images/', true); + +function loaded() { + const mountNode = document.getElementById('mastodon-timeline'); + + if (mountNode !== null) { + const props = JSON.parse(mountNode.getAttribute('data-props')); + ReactDOM.render(, mountNode); + } +} + +function main() { + ready(loaded); +} + +loadPolyfills().then(main).catch(error => { + console.error(error); +}); diff --git a/app/javascript/packs/public.js b/app/javascript/packs/public.js index 0b00da39d..6e6ba3476 100644 --- a/app/javascript/packs/public.js +++ b/app/javascript/packs/public.js @@ -4,9 +4,7 @@ import { delegate } from 'rails-ujs'; import emojify from '../mastodon/emoji'; import { getLocale } from '../mastodon/locales'; import loadPolyfills from '../mastodon/load_polyfills'; -import TimelineContainer from '../mastodon/containers/timeline_container'; -import React from 'react'; -import ReactDOM from 'react-dom'; +import ready from '../mastodon/ready'; require.context('../images/', true); @@ -39,21 +37,10 @@ function loaded() { const datetime = new Date(content.getAttribute('datetime')); content.textContent = relativeFormat.format(datetime);; }); - - const mountNode = document.getElementById('mastodon-timeline'); - - if (mountNode !== null) { - const props = JSON.parse(mountNode.getAttribute('data-props')); - ReactDOM.render(, mountNode); - } } function main() { - if (['interactive', 'complete'].includes(document.readyState)) { - loaded(); - } else { - document.addEventListener('DOMContentLoaded', loaded); - } + ready(loaded); delegate(document, '.video-player video', 'click', ({ target }) => { if (target.paused) { diff --git a/app/views/about/show.html.haml b/app/views/about/show.html.haml index f75f87c99..fd468bba0 100644 --- a/app/views/about/show.html.haml +++ b/app/views/about/show.html.haml @@ -1,11 +1,10 @@ -- content_for :header_tags do - %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) - = javascript_pack_tag 'public', integrity: true, crossorigin: 'anonymous' - - content_for :page_title do = site_hostname - content_for :header_tags do + %script#initial-state{ type: 'application/json' }!= json_escape(@initial_state_json) + = javascript_pack_tag 'about', integrity: true, crossorigin: 'anonymous' + %meta{ property: 'og:site_name', content: site_title }/ %meta{ property: 'og:url', content: about_url }/ %meta{ property: 'og:type', content: 'website' }/ -- cgit From 489d1624777d86b710bd4f467857a7e7c3bd3128 Mon Sep 17 00:00:00 2001 From: Albert ARIBAUD Date: Fri, 14 Jul 2017 12:12:16 +0200 Subject: fr.json: replace "silencer" with "masquer" (#4196) "Silencer" as a verb does not exist in French. A good and valid replacement is "masquer". --- app/javascript/mastodon/locales/fr.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/locales/fr.json b/app/javascript/mastodon/locales/fr.json index ea69532dd..b6605295b 100644 --- a/app/javascript/mastodon/locales/fr.json +++ b/app/javascript/mastodon/locales/fr.json @@ -29,7 +29,7 @@ "column.favourites": "Favoris", "column.follow_requests": "Demandes de suivi", "column.home": "Accueil", - "column.mutes": "Comptes silencés", + "column.mutes": "Comptes masqués", "column.notifications": "Notifications", "column.public": "Fil public global", "column_back_button.label": "Retour", @@ -52,9 +52,9 @@ "confirmations.delete.confirm": "Supprimer", "confirmations.delete.message": "Confirmez vous la suppression de ce pouet ?", "confirmations.domain_block.confirm": "Masquer le domaine entier", - "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou silenciations ciblés sont suffisants et préférables.", - "confirmations.mute.confirm": "Silencer", - "confirmations.mute.message": "Confirmez vous la silenciation {name} ?", + "confirmations.domain_block.message": "Êtes-vous vraiment, vraiment sûr⋅e de vouloir bloquer {domain} en entier ? Dans la plupart des cas, quelques blocages ou masquages ciblés sont suffisants et préférables.", + "confirmations.mute.confirm": "Masquer", + "confirmations.mute.message": "Confirmez vous le masquage de {name} ?", "emoji_button.activity": "Activités", "emoji_button.flags": "Drapeaux", "emoji_button.food": "Boire et manger", @@ -96,7 +96,7 @@ "navigation_bar.follow_requests": "Demandes de suivi", "navigation_bar.info": "Plus d’informations", "navigation_bar.logout": "Déconnexion", - "navigation_bar.mutes": "Comptes silencés", + "navigation_bar.mutes": "Comptes masqués", "navigation_bar.preferences": "Préférences", "navigation_bar.public_timeline": "Fil public global", "notification.favourite": "{name} a ajouté à ses favoris :", -- cgit From c42092ba7ad1cbf78ca8463b6525ec2b7efbdd92 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 14 Jul 2017 16:41:02 +0200 Subject: Add option to opt out of search engines on public profile/status pages (#4199) --- app/controllers/settings/preferences_controller.rb | 1 + app/lib/user_settings_decorator.rb | 5 +++++ app/models/user.rb | 4 ++++ app/views/accounts/show.html.haml | 3 +++ app/views/follower_accounts/index.html.haml | 3 +++ app/views/following_accounts/index.html.haml | 3 +++ app/views/layouts/embedded.html.haml | 2 ++ app/views/settings/preferences/show.html.haml | 3 +++ app/views/stream_entries/show.html.haml | 3 +++ config/locales/simple_form.en.yml | 2 ++ config/settings.yml | 1 + 11 files changed, 30 insertions(+) (limited to 'app') diff --git a/app/controllers/settings/preferences_controller.rb b/app/controllers/settings/preferences_controller.rb index cac5b0ba8..a3f5a008b 100644 --- a/app/controllers/settings/preferences_controller.rb +++ b/app/controllers/settings/preferences_controller.rb @@ -39,6 +39,7 @@ class Settings::PreferencesController < ApplicationController :setting_delete_modal, :setting_auto_play_gif, :setting_system_font_ui, + :setting_noindex, notification_emails: %i(follow follow_request reblog favourite mention digest), interactions: %i(must_be_follower must_be_following) ) diff --git a/app/lib/user_settings_decorator.rb b/app/lib/user_settings_decorator.rb index e0e92b19d..c5da18029 100644 --- a/app/lib/user_settings_decorator.rb +++ b/app/lib/user_settings_decorator.rb @@ -23,6 +23,7 @@ class UserSettingsDecorator user.settings['delete_modal'] = delete_modal_preference user.settings['auto_play_gif'] = auto_play_gif_preference user.settings['system_font_ui'] = system_font_ui_preference + user.settings['noindex'] = noindex_preference end def merged_notification_emails @@ -57,6 +58,10 @@ class UserSettingsDecorator boolean_cast_setting 'setting_auto_play_gif' end + def noindex_preference + boolean_cast_setting 'setting_noindex' + end + def boolean_cast_setting(key) settings[key] == '1' end diff --git a/app/models/user.rb b/app/models/user.rb index a63b1da7f..becf0018f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -99,6 +99,10 @@ class User < ApplicationRecord settings.system_font_ui end + def setting_noindex + settings.noindex + end + def activate_session(request) session_activations.activate(session_id: SecureRandom.hex, user_agent: request.user_agent, diff --git a/app/views/accounts/show.html.haml b/app/views/accounts/show.html.haml index a19049103..7ed634e5d 100644 --- a/app/views/accounts/show.html.haml +++ b/app/views/accounts/show.html.haml @@ -2,6 +2,9 @@ = display_name(@account) - content_for :header_tags do + - if @account.user&.setting_noindex + %meta{ name: 'robots', content: 'noindex' }/ + %link{ rel: 'salmon', href: api_salmon_url(@account.id) }/ %link{ rel: 'alternate', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }/ diff --git a/app/views/follower_accounts/index.html.haml b/app/views/follower_accounts/index.html.haml index 89c7f3a29..738b31638 100644 --- a/app/views/follower_accounts/index.html.haml +++ b/app/views/follower_accounts/index.html.haml @@ -2,6 +2,9 @@ = t('accounts.people_who_follow', name: display_name(@account)) - content_for :header_tags do + - if @account.user&.setting_noindex + %meta{ name: 'robots', content: 'noindex' }/ + = render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false) = render 'accounts/header', account: @account diff --git a/app/views/following_accounts/index.html.haml b/app/views/following_accounts/index.html.haml index 6f0de7590..9637c689f 100644 --- a/app/views/following_accounts/index.html.haml +++ b/app/views/following_accounts/index.html.haml @@ -2,6 +2,9 @@ = t('accounts.people_followed_by', name: display_name(@account)) - content_for :header_tags do + - if @account.user&.setting_noindex + %meta{ name: 'robots', content: 'noindex' }/ + = render 'accounts/og', account: @account, url: account_followers_url(@account, only_path: false) = render 'accounts/header', account: @account diff --git a/app/views/layouts/embedded.html.haml b/app/views/layouts/embedded.html.haml index 4826f32f7..46dab2d0f 100644 --- a/app/views/layouts/embedded.html.haml +++ b/app/views/layouts/embedded.html.haml @@ -2,6 +2,8 @@ %html{ lang: I18n.locale } %head %meta{ charset: 'utf-8' }/ + %meta{ name: 'robots', content: 'noindex' }/ + = stylesheet_pack_tag 'common', media: 'all' = javascript_pack_tag 'common', integrity: true, crossorigin: 'anonymous' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' diff --git a/app/views/settings/preferences/show.html.haml b/app/views/settings/preferences/show.html.haml index 56a261ab6..3b5d90942 100644 --- a/app/views/settings/preferences/show.html.haml +++ b/app/views/settings/preferences/show.html.haml @@ -40,6 +40,9 @@ = ff.input :must_be_follower, as: :boolean, wrapper: :with_label = ff.input :must_be_following, as: :boolean, wrapper: :with_label + .fields-group + = f.input :setting_noindex, as: :boolean, wrapper: :with_label + .fields-group = f.input :setting_boost_modal, as: :boolean, wrapper: :with_label = f.input :setting_delete_modal, as: :boolean, wrapper: :with_label diff --git a/app/views/stream_entries/show.html.haml b/app/views/stream_entries/show.html.haml index d01e82af8..80ea30eb1 100644 --- a/app/views/stream_entries/show.html.haml +++ b/app/views/stream_entries/show.html.haml @@ -1,4 +1,7 @@ - content_for :header_tags do + - if @account.user&.setting_noindex + %meta{ name: 'robots', content: 'noindex' }/ + %link{ rel: 'alternate', type: 'application/atom+xml', href: account_stream_entry_url(@account, @stream_entry, format: 'atom') }/ %link{ rel: 'alternate', type: 'application/json+oembed', href: api_oembed_url(url: account_stream_entry_url(@account, @stream_entry), format: 'json') }/ diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index e3f784a1a..fbaf0ff68 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -12,6 +12,7 @@ en: note: one: 1 character left other: %{count} characters left + setting_noindex: Affects your public profile and status pages imports: data: CSV file exported from another Mastodon instance sessions: @@ -41,6 +42,7 @@ en: setting_default_sensitive: Always mark media as sensitive setting_delete_modal: Show confirmation dialog before deleting a toot setting_system_font_ui: Use system's default font + setting_noindex: Opt-out of search engine indexing severity: Severity type: Import type username: Username diff --git a/config/settings.yml b/config/settings.yml index 579ba2929..d239fb6a6 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -21,6 +21,7 @@ defaults: &defaults auto_play_gif: false delete_modal: true system_font_ui: false + noindex: false notification_emails: follow: false reblog: false -- cgit From e2685ccc81f04e1a63a97af80686bf85027418a6 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 14 Jul 2017 19:47:53 +0200 Subject: Fix #4149, fix #1199 - Store emojis as unicode (#4189) - Use unicode when selecting emoji through picker - Convert shortcodes to unicode when storing text input server-side - Do not convert shortcodes in JS anymore --- Gemfile | 1 + Gemfile.lock | 4 ++- app/helpers/emoji_helper.rb | 19 ++++++++++++ app/javascript/mastodon/actions/compose.js | 7 +++-- app/javascript/mastodon/emoji.js | 24 ++------------- .../features/compose/components/compose_form.js | 3 +- .../compose/components/emoji_picker_dropdown.js | 5 +-- app/javascript/mastodon/reducers/compose.js | 2 +- app/javascript/styles/components.scss | 1 + app/models/account.rb | 10 ++++++ app/models/status.rb | 6 +++- app/services/post_status_service.rb | 2 ++ spec/helpers/emoji_helper_spec.rb | 15 +++++++++ spec/helpers/routing_helper.rb | 5 --- spec/javascript/components/emojify.test.js | 36 ---------------------- 15 files changed, 69 insertions(+), 71 deletions(-) create mode 100644 app/helpers/emoji_helper.rb create mode 100644 spec/helpers/emoji_helper_spec.rb delete mode 100644 spec/helpers/routing_helper.rb (limited to 'app') diff --git a/Gemfile b/Gemfile index 988b4d6b9..531d01ae0 100644 --- a/Gemfile +++ b/Gemfile @@ -28,6 +28,7 @@ gem 'devise', '~> 4.2' gem 'devise-two-factor', '~> 3.0' gem 'doorkeeper', '~> 4.2' gem 'fast_blank', '~> 1.0' +gem 'gemoji', '~> 3.0' gem 'goldfinger', '~> 1.2' gem 'hiredis', '~> 0.6' gem 'redis-namespace', '~> 1.5' diff --git a/Gemfile.lock b/Gemfile.lock index 5599e1db1..83202189d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -106,9 +106,9 @@ GEM rack (>= 1.0.0) rack-test (>= 0.5.4) xpath (~> 2.0) - charlock_holmes (0.7.3) case_transform (0.2) activesupport + charlock_holmes (0.7.3) chunky_png (1.3.8) cld3 (3.1.3) ffi (>= 1.1.0, < 1.10.0) @@ -163,6 +163,7 @@ GEM fuubar (2.2.0) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) + gemoji (3.0.0) globalid (0.4.0) activesupport (>= 4.2.0) goldfinger (1.2.0) @@ -518,6 +519,7 @@ DEPENDENCIES faker (~> 1.7) fast_blank (~> 1.0) fuubar (~> 2.2) + gemoji (~> 3.0) goldfinger (~> 1.2) hamlit-rails (~> 0.2) hiredis (~> 0.6) diff --git a/app/helpers/emoji_helper.rb b/app/helpers/emoji_helper.rb new file mode 100644 index 000000000..c1595851f --- /dev/null +++ b/app/helpers/emoji_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module EmojiHelper + EMOJI_PATTERN = /(?<=[^[:alnum:]:]|\n|^):([\w+-]+):(?=[^[:alnum:]:]|$)/x + + def emojify(text) + return text if text.blank? + + text.gsub(EMOJI_PATTERN) do |match| + emoji = Emoji.find_by_alias($1) # rubocop:disable Rails/DynamicFindBy,Style/PerlBackrefs + + if emoji + emoji.raw + else + match + end + end + end +end diff --git a/app/javascript/mastodon/actions/compose.js b/app/javascript/mastodon/actions/compose.js index 647a52b93..9f05a53e9 100644 --- a/app/javascript/mastodon/actions/compose.js +++ b/app/javascript/mastodon/actions/compose.js @@ -2,8 +2,6 @@ import api from '../api'; import { updateTimeline } from './timelines'; -import * as emojione from 'emojione'; - export const COMPOSE_CHANGE = 'COMPOSE_CHANGE'; export const COMPOSE_SUBMIT_REQUEST = 'COMPOSE_SUBMIT_REQUEST'; export const COMPOSE_SUBMIT_SUCCESS = 'COMPOSE_SUBMIT_SUCCESS'; @@ -73,11 +71,14 @@ export function mentionCompose(account, router) { export function submitCompose() { return function (dispatch, getState) { - const status = emojione.shortnameToUnicode(getState().getIn(['compose', 'text'], '')); + const status = getState().getIn(['compose', 'text'], ''); + if (!status || !status.length) { return; } + dispatch(submitComposeRequest()); + api(getState).post('/api/v1/statuses', { status, in_reply_to_id: getState().getIn(['compose', 'in_reply_to'], null), diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index 7043d5f3a..ed2180cd1 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -6,36 +6,18 @@ const trie = new Trie(Object.keys(emojione.jsEscapeMap)); function emojify(str) { // This walks through the string from start to end, ignoring any tags (

,
, etc.) - // and replacing valid shortnames like :smile: and :wink: as well as unicode strings + // and replacing valid unicode strings // that _aren't_ within tags with an version. - // The goal is to be the same as an emojione.regShortNames/regUnicode replacement, but faster. + // The goal is to be the same as an emojione.regUnicode replacement, but faster. let i = -1; let insideTag = false; - let insideShortname = false; - let shortnameStartIndex = -1; let match; while (++i < str.length) { const char = str.charAt(i); - if (insideShortname && char === ':') { - const shortname = str.substring(shortnameStartIndex, i + 1); - if (shortname in emojione.emojioneList) { - const unicode = emojione.emojioneList[shortname].unicode[emojione.emojioneList[shortname].unicode.length - 1]; - const alt = emojione.convert(unicode.toUpperCase()); - const replacement = `${alt}`; - str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1); - i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string - } else { - i--; // stray colon, try again - } - insideShortname = false; - } else if (insideTag && char === '>') { + if (insideTag && char === '>') { insideTag = false; } else if (char === '<') { insideTag = true; - insideShortname = false; - } else if (!insideTag && char === ':') { - insideShortname = true; - shortnameStartIndex = i; } else if (!insideTag && (match = trie.search(str.substring(i)))) { const unicodeStr = match; if (unicodeStr in emojione.jsEscapeMap) { diff --git a/app/javascript/mastodon/features/compose/components/compose_form.js b/app/javascript/mastodon/features/compose/components/compose_form.js index f7eeedc69..f07552947 100644 --- a/app/javascript/mastodon/features/compose/components/compose_form.js +++ b/app/javascript/mastodon/features/compose/components/compose_form.js @@ -136,7 +136,8 @@ export default class ComposeForm extends ImmutablePureComponent { handleEmojiPick = (data) => { const position = this.autosuggestTextarea.textarea.selectionStart; - this._restoreCaret = position + data.shortname.length + 1; + const emojiChar = String.fromCodePoint(parseInt(data.unicode, 16)); + this._restoreCaret = position + emojiChar.length + 1; this.props.onPickEmoji(position, data); } diff --git a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js index 83c66a5d5..acc584f20 100644 --- a/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js +++ b/app/javascript/mastodon/features/compose/components/emoji_picker_dropdown.js @@ -109,11 +109,12 @@ export default class EmojiPickerDropdown extends React.PureComponent { 🙂 + { this.state.active && !this.state.loading && diff --git a/app/javascript/mastodon/reducers/compose.js b/app/javascript/mastodon/reducers/compose.js index a92b5aa23..ea3b78b67 100644 --- a/app/javascript/mastodon/reducers/compose.js +++ b/app/javascript/mastodon/reducers/compose.js @@ -118,7 +118,7 @@ const insertSuggestion = (state, position, token, completion) => { }; const insertEmoji = (state, position, emojiData) => { - const emoji = emojiData.shortname; + const emoji = String.fromCodePoint(parseInt(emojiData.unicode, 16)); return state.withMutations(map => { map.update('text', oldText => `${oldText.slice(0, position)}${emoji} ${oldText.slice(position)}`); diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 0face646d..0420a2bed 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -2708,6 +2708,7 @@ button.icon-button.active i.fa-retweet { margin-left: 2px; width: 24px; outline: 0; + cursor: pointer; &:active, &:focus { diff --git a/app/models/account.rb b/app/models/account.rb index 2b54cee5f..7243cb1a5 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -47,6 +47,7 @@ class Account < ApplicationRecord include AccountInteractions include Attachmentable include Remotable + include EmojiHelper # Local users has_one :user, inverse_of: :account @@ -240,9 +241,18 @@ class Account < ApplicationRecord before_create :generate_keys before_validation :normalize_domain + before_validation :prepare_contents, if: :local? private + def prepare_contents + display_name&.strip! + note&.strip! + + self.display_name = emojify(display_name) + self.note = emojify(note) + end + def generate_keys return unless local? diff --git a/app/models/status.rb b/app/models/status.rb index 65db7579a..24eaf7071 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -29,6 +29,7 @@ class Status < ApplicationRecord include Streamable include Cacheable include StatusThreadingConcern + include EmojiHelper enum visibility: [:public, :unlisted, :private, :direct], _suffix: :visibility @@ -120,7 +121,7 @@ class Status < ApplicationRecord !sensitive? && media_attachments.any? end - before_validation :prepare_contents + before_validation :prepare_contents, if: :local? before_validation :set_reblog before_validation :set_visibility before_validation :set_conversation @@ -241,6 +242,9 @@ class Status < ApplicationRecord def prepare_contents text&.strip! spoiler_text&.strip! + + self.text = emojify(text) + self.spoiler_text = emojify(spoiler_text) end def set_reblog diff --git a/app/services/post_status_service.rb b/app/services/post_status_service.rb index 2e6fbb5c3..951a38e19 100644 --- a/app/services/post_status_service.rb +++ b/app/services/post_status_service.rb @@ -21,6 +21,7 @@ class PostStatusService < BaseService media = validate_media!(options[:media_ids]) status = nil + ApplicationRecord.transaction do status = account.statuses.create!(text: text, thread: in_reply_to, @@ -31,6 +32,7 @@ class PostStatusService < BaseService application: options[:application]) attach_media(status, media) end + process_mentions_service.call(status) process_hashtags_service.call(status) diff --git a/spec/helpers/emoji_helper_spec.rb b/spec/helpers/emoji_helper_spec.rb new file mode 100644 index 000000000..1eedfb719 --- /dev/null +++ b/spec/helpers/emoji_helper_spec.rb @@ -0,0 +1,15 @@ +require 'rails_helper' + +RSpec.describe EmojiHelper, type: :helper do + describe '#emojify' do + it 'converts shortcodes to unicode' do + text = ':book: Book' + expect(emojify(text)).to eq '📖 Book' + end + + it 'does not convert shortcodes that are part of a string into unicode' do + text = ':see_no_evil::hear_no_evil::speak_no_evil:' + expect(emojify(text)).to eq text + end + end +end diff --git a/spec/helpers/routing_helper.rb b/spec/helpers/routing_helper.rb deleted file mode 100644 index 3cd397397..000000000 --- a/spec/helpers/routing_helper.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'rails_helper' - -RSpec.describe RoutingHelper, type: :helper do - -end diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js index 3e8b25af9..e165b4519 100644 --- a/spec/javascript/components/emojify.test.js +++ b/spec/javascript/components/emojify.test.js @@ -2,32 +2,6 @@ import { expect } from 'chai'; import emojify from '../../../app/javascript/mastodon/emoji'; describe('emojify', () => { - it('does a basic emojify', () => { - expect(emojify(':smile:')).to.equal( - '😄'); - }); - - it('does a double emojify', () => { - expect(emojify(':smile: and :wink:')).to.equal( - '😄 and 😉'); - }); - - it('works with random colons', () => { - expect(emojify(':smile: : :wink:')).to.equal( - '😄 : 😉'); - expect(emojify(':smile::::wink:')).to.equal( - '😄::😉'); - expect(emojify(':smile:::::wink:')).to.equal( - '😄:::😉'); - }); - - it('works with tags', () => { - expect(emojify('

:smile:

')).to.equal( - '

😄

'); - expect(emojify('

:smile:

and

:wink:

')).to.equal( - '

😄

and

😉

'); - }); - it('ignores unknown shortcodes', () => { expect(emojify(':foobarbazfake:')).to.equal(':foobarbazfake:'); }); @@ -46,11 +20,6 @@ describe('emojify', () => { expect(emojify(':smile')).to.equal(':smile'); }); - it('does two emoji next to each other', () => { - expect(emojify(':smile::wink:')).to.equal( - '😄😉'); - }); - it('does unicode', () => { expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).to.equal( '👩‍👩‍👦‍👦'); @@ -72,12 +41,7 @@ describe('emojify', () => { 'foo ❗ #️⃣ bar'); }); - it('does mixed unicode and shortnames', () => { - expect(emojify(':smile:#\uFE0F\u20E3:wink:\u2757')).to.equal('😄#️⃣😉❗'); - }); - it('ignores unicode inside of tags', () => { expect(emojify('

')).to.equal('

'); }); - }); -- cgit From c1f201c49a007e5c0740c00651e549a7b0416b05 Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Fri, 14 Jul 2017 20:30:12 +0200 Subject: 🎄🔨 Force tree shake emojione (#4202) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(yarn): Install babel-plugin-preval as development dependency * feat(babel): Add preval as a plugin * feat(emojione_light): Prevaled module what tree-shaked emojione * refactor(emoji): Use emojione_light * feat: Preload emojione_picker bundle * fix(emojione_light): Do not use Object.entries * fix(emojify): Update tests * chore(emojione_light): Remove silly ascii art --- .babelrc | 3 ++- app/javascript/mastodon/emoji.js | 15 ++++++--------- app/javascript/mastodon/emojione_light.js | 11 +++++++++++ app/views/layouts/application.html.haml | 1 + package.json | 1 + spec/javascript/components/emojify.test.js | 16 ++++++++-------- yarn.lock | 12 ++++++++++-- 7 files changed, 39 insertions(+), 20 deletions(-) create mode 100644 app/javascript/mastodon/emojione_light.js (limited to 'app') diff --git a/.babelrc b/.babelrc index 19968964e..de922f389 100644 --- a/.babelrc +++ b/.babelrc @@ -22,7 +22,8 @@ { "messagesDir": "./build/messages" } - ] + ], + "preval" ], "env": { "development": { diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index ed2180cd1..1de41f572 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -1,8 +1,7 @@ -import emojione from 'emojione'; +import { unicodeToFilename } from './emojione_light'; import Trie from 'substring-trie'; -const mappedUnicode = emojione.mapUnicodeToShort(); -const trie = new Trie(Object.keys(emojione.jsEscapeMap)); +const trie = new Trie(Object.keys(unicodeToFilename)); function emojify(str) { // This walks through the string from start to end, ignoring any tags (

,
, etc.) @@ -20,12 +19,10 @@ function emojify(str) { insideTag = true; } else if (!insideTag && (match = trie.search(str.substring(i)))) { const unicodeStr = match; - if (unicodeStr in emojione.jsEscapeMap) { - const unicode = emojione.jsEscapeMap[unicodeStr]; - const short = mappedUnicode[unicode]; - const filename = emojione.emojioneList[short].fname; - const alt = emojione.convert(unicode.toUpperCase()); - const replacement = `${alt}`; + if (unicodeStr in unicodeToFilename) { + const filename = unicodeToFilename[unicodeStr]; + const alt = unicodeStr; + const replacement = `${alt}`; str = str.substring(0, i) + replacement + str.substring(i + unicodeStr.length); i += (replacement.length - unicodeStr.length); // jump ahead the length we've added to the string } diff --git a/app/javascript/mastodon/emojione_light.js b/app/javascript/mastodon/emojione_light.js new file mode 100644 index 000000000..c75e10a98 --- /dev/null +++ b/app/javascript/mastodon/emojione_light.js @@ -0,0 +1,11 @@ +// @preval +// Force tree shaking on emojione by exposing just a subset of its functionality + +const emojione = require('emojione'); + +const mappedUnicode = emojione.mapUnicodeToShort(); + +module.exports.unicodeToFilename = Object.keys(emojione.jsEscapeMap) + .map(unicodeStr => [unicodeStr, mappedUnicode[emojione.jsEscapeMap[unicodeStr]]]) + .map(([unicodeStr, shortCode]) => ({ [unicodeStr]: emojione.emojioneList[shortCode].fname })) + .reduce((x, y) => Object.assign(x, y), { }); diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index ef97fb127..82b20810a 100755 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -27,6 +27,7 @@ = javascript_pack_tag 'features/notifications', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' = javascript_pack_tag 'features/community_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' = javascript_pack_tag 'features/public_timeline', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' + = javascript_pack_tag 'emojione_picker', integrity: true, crossorigin: 'anonymous', rel: 'preload', as: 'script' = javascript_pack_tag "locale_#{I18n.locale}", integrity: true, crossorigin: 'anonymous' = csrf_meta_tags diff --git a/package.json b/package.json index 5ad576dad..20224796f 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,7 @@ "@storybook/addon-actions": "^3.1.8", "@storybook/react": "^3.1.8", "babel-eslint": "^7.2.3", + "babel-plugin-preval": "^1.3.2", "chai": "^4.1.0", "chai-enzyme": "^0.8.0", "enzyme": "^2.9.1", diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js index e165b4519..2874bb56d 100644 --- a/spec/javascript/components/emojify.test.js +++ b/spec/javascript/components/emojify.test.js @@ -22,23 +22,23 @@ describe('emojify', () => { it('does unicode', () => { expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).to.equal( - '👩‍👩‍👦‍👦'); + '👩‍👩‍👦‍👦'); expect(emojify('\uD83D\uDC68\uD83D\uDC69\uD83D\uDC67\uD83D\uDC67')).to.equal( - '👨👩👧👧'); - expect(emojify('\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66')).to.equal('👩👩👦'); + '👨👩👧👧'); + expect(emojify('\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66')).to.equal('👩👩👦'); expect(emojify('\u2757')).to.equal( - '❗'); + '❗'); }); it('does multiple unicode', () => { expect(emojify('\u2757 #\uFE0F\u20E3')).to.equal( - '❗ #️⃣'); + '❗ #️⃣'); expect(emojify('\u2757#\uFE0F\u20E3')).to.equal( - '❗#️⃣'); + '❗#️⃣'); expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).to.equal( - '❗ #️⃣ ❗'); + '❗ #️⃣ ❗'); expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).to.equal( - 'foo ❗ #️⃣ bar'); + 'foo ❗ #️⃣ bar'); }); it('ignores unicode inside of tags', () => { diff --git a/yarn.lock b/yarn.lock index 56a9f7798..defd8599f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -681,6 +681,14 @@ babel-plugin-lodash@^3.2.11: glob "^7.1.1" lodash "^4.17.2" +babel-plugin-preval@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/babel-plugin-preval/-/babel-plugin-preval-1.3.2.tgz#44192e6e97b58661bf2c5bcae90bba2a366e0134" + dependencies: + babel-core "^6.25.0" + babylon "^6.17.4" + require-from-string "^1.2.1" + babel-plugin-react-docgen@^1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/babel-plugin-react-docgen/-/babel-plugin-react-docgen-1.5.0.tgz#0339717ad51f4a5ce4349330b8266ea5a56f53b4" @@ -1312,7 +1320,7 @@ babel-types@^6.19.0, babel-types@^6.23.0, babel-types@^6.24.1, babel-types@^6.25 lodash "^4.2.0" to-fast-properties "^1.0.1" -babylon@^6.17.0, babylon@^6.17.2: +babylon@^6.17.0, babylon@^6.17.2, babylon@^6.17.4: version "6.17.4" resolved "https://registry.yarnpkg.com/babylon/-/babylon-6.17.4.tgz#3e8b7402b88d22c3423e137a1577883b15ff869a" @@ -6335,7 +6343,7 @@ require-directory@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" -require-from-string@^1.1.0: +require-from-string@^1.1.0, require-from-string@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-1.2.1.tgz#529c9ccef27380adfec9a2f965b649bbee636418" -- cgit From 1618b68bfa740ed655ac45d7d5f4f46fed6c8c62 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 14 Jul 2017 20:41:49 +0200 Subject: HTTP signatures (#4146) * Add Request class with HTTP signature generator Spec: https://tools.ietf.org/html/draft-cavage-http-signatures-06 * Add HTTP signature verification concern * Add test for SignatureVerification concern * Add basic test for Request class * Make PuSH subscribe/unsubscribe requests use new Request class Accidentally fix lease_seconds not being set and sent properly, and change the new minimum subscription duration to 1 day * Make all PuSH workers use new Request class * Make Salmon sender use new Request class * Make FetchLinkService use new Request class * Make FetchAtomService use the new Request class * Make Remotable use the new Request class * Make ResolveRemoteAccountService use the new Request class * Add more tests * Allow +-30 seconds window for signed request to remain valid * Disable time window validation for signed requests, restore 7 days as PuSH subscription duration (which was previous default due to a bug) --- app/controllers/accounts_controller.rb | 1 + app/controllers/api/subscriptions_controller.rb | 2 +- app/controllers/concerns/signature_verification.rb | 87 ++++++++++++++++++++++ app/controllers/stream_entries_controller.rb | 1 + app/helpers/http_helper.rb | 17 ----- app/lib/provider_discovery.rb | 4 +- app/lib/request.rb | 70 +++++++++++++++++ app/models/account.rb | 2 +- app/models/concerns/remotable.rb | 3 +- app/models/subscription.rb | 4 +- app/services/fetch_atom_service.rb | 8 +- app/services/fetch_link_card_service.rb | 6 +- app/services/resolve_remote_account_service.rb | 3 +- app/services/send_interaction_service.rb | 14 +++- app/services/subscribe_service.rb | 48 ++++++++---- app/services/unsubscribe_service.rb | 31 +++++--- app/workers/pubsubhubbub/confirmation_worker.rb | 12 ++- app/workers/pubsubhubbub/delivery_worker.rb | 11 ++- .../concerns/signature_verification_spec.rb | 74 ++++++++++++++++++ spec/helpers/http_helper_spec.rb | 13 ---- spec/lib/request_spec.rb | 54 ++++++++++++++ .../pubsubhubbub/confirmation_worker_spec.rb | 2 +- spec/workers/pubsubhubbub/delivery_worker_spec.rb | 2 +- 23 files changed, 379 insertions(+), 90 deletions(-) create mode 100644 app/controllers/concerns/signature_verification.rb delete mode 100644 app/helpers/http_helper.rb create mode 100644 app/lib/request.rb create mode 100644 spec/controllers/concerns/signature_verification_spec.rb delete mode 100644 spec/helpers/http_helper_spec.rb create mode 100644 spec/lib/request_spec.rb (limited to 'app') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 11402ab79..69b520df1 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -2,6 +2,7 @@ class AccountsController < ApplicationController include AccountControllerConcern + include SignatureVerification def show respond_to do |format| diff --git a/app/controllers/api/subscriptions_controller.rb b/app/controllers/api/subscriptions_controller.rb index d3ea98676..89007f3d6 100644 --- a/app/controllers/api/subscriptions_controller.rb +++ b/app/controllers/api/subscriptions_controller.rb @@ -42,7 +42,7 @@ class Api::SubscriptionsController < Api::BaseController end def lease_seconds_or_default - (params['hub.lease_seconds'] || 86_400).to_i.seconds + (params['hub.lease_seconds'] || 1.day).to_i.seconds end def set_account diff --git a/app/controllers/concerns/signature_verification.rb b/app/controllers/concerns/signature_verification.rb new file mode 100644 index 000000000..abe845d93 --- /dev/null +++ b/app/controllers/concerns/signature_verification.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +# Implemented according to HTTP signatures (Draft 6) +# +module SignatureVerification + extend ActiveSupport::Concern + + def signed_request? + request.headers['Signature'].present? + end + + def signed_request_account + return @signed_request_account if defined?(@signed_request_account) + + unless signed_request? + @signed_request_account = nil + return + end + + raw_signature = request.headers['Signature'] + signature_params = {} + + raw_signature.split(',').each do |part| + parsed_parts = part.match(/([a-z]+)="([^"]+)"/i) + next if parsed_parts.nil? || parsed_parts.size != 3 + signature_params[parsed_parts[1]] = parsed_parts[2] + end + + if incompatible_signature?(signature_params) + @signed_request_account = nil + return + end + + account = ResolveRemoteAccountService.new.call(signature_params['keyId'].gsub(/\Aacct:/, '')) + + if account.nil? + @signed_request_account = nil + return + end + + signature = Base64.decode64(signature_params['signature']) + compare_signed_string = build_signed_string(signature_params['headers']) + + if account.keypair.public_key.verify(OpenSSL::Digest::SHA256.new, signature, compare_signed_string) + @signed_request_account = account + @signed_request_account + else + @signed_request_account = nil + end + end + + private + + def build_signed_string(signed_headers) + signed_headers = 'date' if signed_headers.blank? + + signed_headers.split(' ').map do |signed_header| + if signed_header == Request::REQUEST_TARGET + "#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}" + else + "#{signed_header}: #{request.headers[to_header_name(signed_header)]}" + end + end.join("\n") + end + + def matches_time_window? + begin + time_sent = DateTime.httpdate(request.headers['Date']) + rescue ArgumentError + return false + end + + (Time.now.utc - time_sent).abs <= 30 + end + + def to_header_name(name) + name.split(/-/).map(&:capitalize).join('-') + end + + def incompatible_signature?(signature_params) + signature_params['keyId'].blank? || + signature_params['signature'].blank? || + signature_params['algorithm'].blank? || + signature_params['algorithm'] != 'rsa-sha256' || + !signature_params['keyId'].start_with?('acct:') + end +end diff --git a/app/controllers/stream_entries_controller.rb b/app/controllers/stream_entries_controller.rb index 314d59619..54a435238 100644 --- a/app/controllers/stream_entries_controller.rb +++ b/app/controllers/stream_entries_controller.rb @@ -2,6 +2,7 @@ class StreamEntriesController < ApplicationController include Authorization + include SignatureVerification layout 'public' diff --git a/app/helpers/http_helper.rb b/app/helpers/http_helper.rb deleted file mode 100644 index e39a52da0..000000000 --- a/app/helpers/http_helper.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module HttpHelper - def http_client(options = {}) - timeout = { write: 10, connect: 10, read: 10 }.merge(options) - - HTTP.headers(user_agent: user_agent) - .timeout(:per_operation, timeout) - .follow - end - - private - - def user_agent - @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +http://#{Rails.configuration.x.local_domain}/)" - end -end diff --git a/app/lib/provider_discovery.rb b/app/lib/provider_discovery.rb index 6d48cae2f..5e02e6806 100644 --- a/app/lib/provider_discovery.rb +++ b/app/lib/provider_discovery.rb @@ -1,11 +1,9 @@ # frozen_string_literal: true class ProviderDiscovery < OEmbed::ProviderDiscovery - extend HttpHelper - class << self def discover_provider(url, options = {}) - res = http_client.get(url) + res = Request.new(:get, url).perform format = options[:format] raise OEmbed::NotFound, url if res.code != 200 || res.mime_type != 'text/html' diff --git a/app/lib/request.rb b/app/lib/request.rb new file mode 100644 index 000000000..e73c5ac20 --- /dev/null +++ b/app/lib/request.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +class Request + REQUEST_TARGET = '(request-target)' + + include RoutingHelper + + def initialize(verb, url, options = {}) + @verb = verb + @url = Addressable::URI.parse(url).normalize + @options = options + @headers = {} + + set_common_headers! + end + + def on_behalf_of(account) + raise ArgumentError unless account.local? + @account = account + end + + def add_headers(new_headers) + @headers.merge!(new_headers) + end + + def perform + http_client.headers(headers).public_send(@verb, @url.to_s, @options) + end + + def headers + (@account ? @headers.merge('Signature' => signature) : @headers).without(REQUEST_TARGET) + end + + private + + def set_common_headers! + @headers[REQUEST_TARGET] = "#{@verb} #{@url.path}" + @headers['User-Agent'] = user_agent + @headers['Host'] = @url.host + @headers['Date'] = Time.now.utc.httpdate + 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)) + + "keyId=\"#{key_id}\",algorithm=\"#{algorithm}\",headers=\"#{signed_headers}\",signature=\"#{signature}\"" + end + + def signed_string + @headers.map { |key, value| "#{key.downcase}: #{value}" }.join("\n") + end + + def signed_headers + @headers.keys.join(' ').downcase + end + + def user_agent + @user_agent ||= "#{HTTP::Request::USER_AGENT} (Mastodon/#{Mastodon::Version}; +#{root_url})" + end + + def timeout + { write: 10, connect: 10, read: 10 } + end + + def http_client + HTTP.timeout(:per_operation, timeout).follow + end +end diff --git a/app/models/account.rb b/app/models/account.rb index 7243cb1a5..58b0a1086 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -130,7 +130,7 @@ class Account < ApplicationRecord end def subscription(webhook_url) - OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 86_400 * 30, webhook: webhook_url, hub: hub_url) + OStatus2::Subscription.new(remote_url, secret: secret, lease_seconds: 30.days.seconds, webhook: webhook_url, hub: hub_url) end def save_with_optional_media! diff --git a/app/models/concerns/remotable.rb b/app/models/concerns/remotable.rb index b4f169649..1bd87a642 100644 --- a/app/models/concerns/remotable.rb +++ b/app/models/concerns/remotable.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true module Remotable - include HttpHelper extend ActiveSupport::Concern included do @@ -20,7 +19,7 @@ module Remotable return if !%w(http https).include?(parsed_url.scheme) || parsed_url.host.empty? || self[attribute_name] == url begin - response = http_client.get(url) + response = Request.new(:get, url).perform return if response.code != 200 diff --git a/app/models/subscription.rb b/app/models/subscription.rb index 35a228df0..d9d5024a9 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -16,8 +16,8 @@ # class Subscription < ApplicationRecord - MIN_EXPIRATION = 7.days.seconds.to_i - MAX_EXPIRATION = 30.days.seconds.to_i + MIN_EXPIRATION = 1.day.to_i + MAX_EXPIRATION = 30.days.to_i belongs_to :account, required: true diff --git a/app/services/fetch_atom_service.rb b/app/services/fetch_atom_service.rb index d430b22e9..3ac441e3e 100644 --- a/app/services/fetch_atom_service.rb +++ b/app/services/fetch_atom_service.rb @@ -1,16 +1,14 @@ # frozen_string_literal: true class FetchAtomService < BaseService - include HttpHelper - def call(url) return if url.blank? - response = http_client.head(url) + response = Request.new(:head, url).perform Rails.logger.debug "Remote status HEAD request returned code #{response.code}" - response = http_client.get(url) if response.code == 405 + response = Request.new(:get, url).perform if response.code == 405 Rails.logger.debug "Remote status GET request returned code #{response.code}" @@ -49,6 +47,6 @@ class FetchAtomService < BaseService end def fetch(url) - http_client.get(url).to_s + Request.new(:get, url).perform.to_s end end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 6ef3abb66..20c85e0ea 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class FetchLinkCardService < BaseService - include HttpHelper - URL_PATTERN = %r{https?://\S+} def call(status) @@ -13,7 +11,7 @@ class FetchLinkCardService < BaseService url = url.to_s card = PreviewCard.where(status: status).first_or_initialize(status: status, url: url) - res = http_client.head(url) + res = Request.new(:head, url).perform return if res.code != 200 || res.mime_type != 'text/html' @@ -80,7 +78,7 @@ class FetchLinkCardService < BaseService end def attempt_opengraph(card, url) - response = http_client.get(url) + response = Request.new(:get, url).perform return if response.code != 200 || response.mime_type != 'text/html' diff --git a/app/services/resolve_remote_account_service.rb b/app/services/resolve_remote_account_service.rb index 362d0df98..d2dfda824 100644 --- a/app/services/resolve_remote_account_service.rb +++ b/app/services/resolve_remote_account_service.rb @@ -2,7 +2,6 @@ class ResolveRemoteAccountService < BaseService include OStatus2::MagicKey - include HttpHelper DFRN_NS = 'http://purl.org/macgirvin/dfrn/1.0' @@ -79,7 +78,7 @@ class ResolveRemoteAccountService < BaseService end def get_feed(url) - response = http_client(write: 20, connect: 20, read: 50).get(Addressable::URI.parse(url).normalize) + response = Request.new(:get, url).perform raise Goldfinger::Error, "Feed attempt failed for #{url}: HTTP #{response.code}" unless response.code == 200 [response.to_s, Nokogiri::XML(response)] end diff --git a/app/services/send_interaction_service.rb b/app/services/send_interaction_service.rb index 34c8f9e34..ef38a748b 100644 --- a/app/services/send_interaction_service.rb +++ b/app/services/send_interaction_service.rb @@ -12,13 +12,23 @@ class SendInteractionService < BaseService return if block_notification? - envelope = salmon.pack(@xml, @source_account.keypair) - delivery = salmon.post(@target_account.salmon_url, envelope) + delivery = build_request.perform + raise "Delivery failed for #{target_account.salmon_url}: HTTP #{delivery.code}" unless delivery.code > 199 && delivery.code < 300 end private + def build_request + request = Request.new(:post, @target_account.salmon_url, body: envelope) + request.add_headers('Content-Type' => 'application/magic-envelope+xml') + request + end + + def envelope + salmon.pack(@xml, @source_account.keypair) + end + def block_notification? DomainBlock.blocked?(@target_account.domain) end diff --git a/app/services/subscribe_service.rb b/app/services/subscribe_service.rb index 1e7984a7f..f58067038 100644 --- a/app/services/subscribe_service.rb +++ b/app/services/subscribe_service.rb @@ -2,34 +2,54 @@ class SubscribeService < BaseService def call(account) - account.secret = SecureRandom.hex + @account = account + @account.secret = SecureRandom.hex + @response = build_request.perform - subscription = account.subscription(api_subscription_url(account.id)) - response = subscription.subscribe - - if response_failed_permanently?(response) + if response_failed_permanently? # We're not allowed to subscribe. Fail and move on. - account.secret = '' - account.save! - elsif response_successful?(response) + @account.secret = '' + @account.save! + elsif response_successful? # The subscription will be confirmed asynchronously. - account.save! + @account.save! else # The response was either a 429 rate limit, or a 5xx error. # We need to retry at a later time. Fail loudly! - raise "Subscription attempt failed for #{account.acct} (#{account.hub_url}): HTTP #{response.code}" + raise "Subscription attempt failed for #{@account.acct} (#{@account.hub_url}): HTTP #{@response.code}" end end private + def build_request + request = Request.new(:post, @account.hub_url, form: subscription_params) + request.on_behalf_of(some_local_account) if some_local_account + request + end + + def subscription_params + { + 'hub.topic': @account.remote_url, + 'hub.mode': 'subscribe', + 'hub.callback': api_subscription_url(@account.id), + 'hub.verify': 'async', + 'hub.secret': @account.secret, + 'hub.lease_seconds': 7.days.seconds, + } + end + + def some_local_account + @some_local_account ||= Account.local.first + end + # Any response in the 3xx or 4xx range, except for 429 (rate limit) - def response_failed_permanently?(response) - (response.status.redirect? || response.status.client_error?) && !response.status.too_many_requests? + def response_failed_permanently? + (@response.status.redirect? || @response.status.client_error?) && !@response.status.too_many_requests? end # Any response in the 2xx range - def response_successful?(response) - response.status.success? + def response_successful? + @response.status.success? end end diff --git a/app/services/unsubscribe_service.rb b/app/services/unsubscribe_service.rb index 6db8dbdc4..c2f022d7d 100644 --- a/app/services/unsubscribe_service.rb +++ b/app/services/unsubscribe_service.rb @@ -2,17 +2,30 @@ class UnsubscribeService < BaseService def call(account) - subscription = account.subscription(api_subscription_url(account.id)) - response = subscription.unsubscribe + @account = account + @response = build_request.perform - unless response.status.success? - Rails.logger.debug "PuSH unsubscribe for #{account.acct} failed: #{response.status}" - end + Rails.logger.debug "PuSH unsubscribe for #{@account.acct} failed: #{@response.status}" unless @response.status.success? - account.secret = '' - account.subscription_expires_at = nil - account.save! + @account.secret = '' + @account.subscription_expires_at = nil + @account.save! rescue HTTP::Error, OpenSSL::SSL::SSLError - Rails.logger.debug "PuSH subscription request for #{account.acct} could not be made due to HTTP or SSL error" + Rails.logger.debug "PuSH subscription request for #{@account.acct} could not be made due to HTTP or SSL error" + end + + private + + def build_request + Request.new(:post, @account.hub_url, form: subscription_params) + end + + def subscription_params + { + 'hub.topic': @account.remote_url, + 'hub.mode': 'unsubscribe', + 'hub.callback': api_subscription_url(@account.id), + 'hub.verify': 'async', + } end end diff --git a/app/workers/pubsubhubbub/confirmation_worker.rb b/app/workers/pubsubhubbub/confirmation_worker.rb index 9186c5d7d..e1ccfb99c 100644 --- a/app/workers/pubsubhubbub/confirmation_worker.rb +++ b/app/workers/pubsubhubbub/confirmation_worker.rb @@ -60,9 +60,7 @@ class Pubsubhubbub::ConfirmationWorker end def callback_get_with_params - HTTP.headers(user_agent: 'Mastodon/PubSubHubbub') - .timeout(:per_operation, write: 20, connect: 20, read: 50) - .get(subscription.callback_url, params: callback_params) + Request.new(:get, subscription.callback_url, params: callback_params).perform end def callback_response_body @@ -71,10 +69,10 @@ class Pubsubhubbub::ConfirmationWorker def callback_params { - 'hub.topic' => account_url(subscription.account, format: :atom), - 'hub.mode' => mode, - 'hub.challenge' => challenge, - 'hub.lease_seconds' => subscription.lease_seconds, + 'hub.topic': account_url(subscription.account, format: :atom), + 'hub.mode': mode, + 'hub.challenge': challenge, + 'hub.lease_seconds': subscription.lease_seconds, } end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 981838f33..05d160cf7 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -33,9 +33,9 @@ class Pubsubhubbub::DeliveryWorker end def callback_post_payload - HTTP.timeout(:per_operation, write: 50, connect: 20, read: 50) - .headers(headers) - .post(subscription.callback_url, body: payload) + request = Request.new(:post, subscription.callback_url, body: payload) + request.add_headers(headers) + request.perform end def blocked_domain? @@ -48,13 +48,12 @@ class Pubsubhubbub::DeliveryWorker def headers { - 'User-Agent' => 'Mastodon/PubSubHubbub', 'Content-Type' => 'application/atom+xml', - 'Link' => link_headers, + 'Link' => link_header, }.merge(signature_headers.to_h) end - def link_headers + def link_header LinkHeader.new([hub_link_header, self_link_header]).to_s end diff --git a/spec/controllers/concerns/signature_verification_spec.rb b/spec/controllers/concerns/signature_verification_spec.rb new file mode 100644 index 000000000..b371795ab --- /dev/null +++ b/spec/controllers/concerns/signature_verification_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe ApplicationController, type: :controller do + controller do + include SignatureVerification + + def success + head 200 + end + + def alternative_success + head 200 + end + end + + before do + routes.draw { get 'success' => 'anonymous#success' } + end + + context 'without signature header' do + before do + get :success + end + + describe '#signed_request?' do + it 'returns false' do + expect(controller.signed_request?).to be false + end + end + + describe '#signed_request_account' do + it 'returns nil' do + expect(controller.signed_request_account).to be_nil + end + end + end + + context 'with signature header' do + let!(:author) { Fabricate(:account) } + + before do + get :success + + fake_request = Request.new(:get, request.url) + fake_request.on_behalf_of(author) + + request.headers.merge!(fake_request.headers) + end + + describe '#signed_request?' do + it 'returns true' do + expect(controller.signed_request?).to be true + end + end + + describe '#signed_request_account' do + it 'returns an account' do + expect(controller.signed_request_account).to eq author + end + + it 'returns nil when path does not match' do + request.path = '/alternative-path' + expect(controller.signed_request_account).to be_nil + end + + it 'returns nil when method does not match' do + post :success + expect(controller.signed_request_account).to be_nil + end + end + end +end diff --git a/spec/helpers/http_helper_spec.rb b/spec/helpers/http_helper_spec.rb deleted file mode 100644 index b8e31b8e6..000000000 --- a/spec/helpers/http_helper_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe HttpHelper do - describe 'http_client' do - it 'returns HTTP::Client with default options' do - options = helper.http_client.default_options - expect(options.headers['User-Agent']).to match /.+ \(Mastodon\/.+;\ \+http:\/\/cb6e6126\.ngrok\.io\/\)/ - expect(options.timeout_options).to eq read_timeout: 10, write_timeout: 10, connect_timeout: 10 - end - end -end diff --git a/spec/lib/request_spec.rb b/spec/lib/request_spec.rb new file mode 100644 index 000000000..782f14b18 --- /dev/null +++ b/spec/lib/request_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe Request do + subject { Request.new(:get, 'http://example.com') } + + describe '#headers' do + it 'returns user agent' do + expect(subject.headers['User-Agent']).to be_present + end + + it 'returns the date header' do + expect(subject.headers['Date']).to be_present + end + + it 'returns the host header' do + expect(subject.headers['Host']).to be_present + end + + it 'does not return virtual request-target header' do + expect(subject.headers['(request-target)']).to be_nil + end + end + + describe '#on_behalf_of' do + it 'when used, adds signature header' do + subject.on_behalf_of(Fabricate(:account)) + expect(subject.headers['Signature']).to be_present + end + end + + describe '#add_headers' do + it 'adds headers to the request' do + subject.add_headers('Test' => 'Foo') + expect(subject.headers['Test']).to eq 'Foo' + end + end + + describe '#perform' do + before do + stub_request(:get, 'http://example.com') + subject.perform + end + + it 'executes a HTTP request' do + expect(a_request(:get, 'http://example.com')).to have_been_made.once + end + + it 'sets headers' do + expect(a_request(:get, 'http://example.com').with(headers: subject.headers)).to have_been_made + end + end +end diff --git a/spec/workers/pubsubhubbub/confirmation_worker_spec.rb b/spec/workers/pubsubhubbub/confirmation_worker_spec.rb index 1199d5801..8f66b4520 100644 --- a/spec/workers/pubsubhubbub/confirmation_worker_spec.rb +++ b/spec/workers/pubsubhubbub/confirmation_worker_spec.rb @@ -83,6 +83,6 @@ describe Pubsubhubbub::ConfirmationWorker do end def http_headers - { 'Connection' => 'close', 'Host' => 'example.com', 'User-Agent' => 'Mastodon/PubSubHubbub' } + { 'Connection' => 'close', 'Host' => 'example.com', 'User-Agent' => 'http.rb/2.2.2 (Mastodon/1.4.7; +https://cb6e6126.ngrok.io/)' } end end diff --git a/spec/workers/pubsubhubbub/delivery_worker_spec.rb b/spec/workers/pubsubhubbub/delivery_worker_spec.rb index 081dfa41c..a83245786 100644 --- a/spec/workers/pubsubhubbub/delivery_worker_spec.rb +++ b/spec/workers/pubsubhubbub/delivery_worker_spec.rb @@ -59,7 +59,7 @@ describe Pubsubhubbub::DeliveryWorker do 'Content-Type' => 'application/atom+xml', 'Host' => 'example.com', 'Link' => "; rel=\"hub\", ; rel=\"self\"", - 'User-Agent' => 'Mastodon/PubSubHubbub', + 'User-Agent' => 'http.rb/2.2.2 (Mastodon/1.4.7; +https://cb6e6126.ngrok.io/)', }.tap do |basic| known_digest = OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), subscription.secret.to_s, payload) basic.merge('X-Hub-Signature' => "sha1=#{known_digest}") if subscription.secret? -- cgit From 1896a154f5f5edd4f94c58e5c69dd70ebc728e3d Mon Sep 17 00:00:00 2001 From: unarist Date: Sat, 15 Jul 2017 03:44:53 +0900 Subject: Fix response of unreblog/unfavourite APIs (#4204) Both APIs process asynchronously, so reblogged/favourited fields in the response should be set to `false` manually. --- app/controllers/api/v1/statuses/favourites_controller.rb | 2 +- app/controllers/api/v1/statuses/reblogs_controller.rb | 2 +- app/presenters/status_relationships_presenter.rb | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) (limited to 'app') diff --git a/app/controllers/api/v1/statuses/favourites_controller.rb b/app/controllers/api/v1/statuses/favourites_controller.rb index 4c4b0c160..35f8a48cd 100644 --- a/app/controllers/api/v1/statuses/favourites_controller.rb +++ b/app/controllers/api/v1/statuses/favourites_controller.rb @@ -19,7 +19,7 @@ class Api::V1::Statuses::FavouritesController < Api::BaseController UnfavouriteWorker.perform_async(current_user.account_id, @status.id) - render json: @status, serializer: REST::StatusSerializer + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, favourites_map: @favourites_map) end private diff --git a/app/controllers/api/v1/statuses/reblogs_controller.rb b/app/controllers/api/v1/statuses/reblogs_controller.rb index f7f4b5a5c..634af474f 100644 --- a/app/controllers/api/v1/statuses/reblogs_controller.rb +++ b/app/controllers/api/v1/statuses/reblogs_controller.rb @@ -20,7 +20,7 @@ class Api::V1::Statuses::ReblogsController < Api::BaseController authorize status_for_destroy, :unreblog? RemovalWorker.perform_async(status_for_destroy.id) - render json: @status, serializer: REST::StatusSerializer + render json: @status, serializer: REST::StatusSerializer, relationships: StatusRelationshipsPresenter.new([@status], current_user&.account_id, reblogs_map: @reblogs_map) end private diff --git a/app/presenters/status_relationships_presenter.rb b/app/presenters/status_relationships_presenter.rb index caf00791a..03294015f 100644 --- a/app/presenters/status_relationships_presenter.rb +++ b/app/presenters/status_relationships_presenter.rb @@ -3,7 +3,7 @@ class StatusRelationshipsPresenter attr_reader :reblogs_map, :favourites_map, :mutes_map - def initialize(statuses, current_account_id = nil) + def initialize(statuses, current_account_id = nil, reblogs_map: {}, favourites_map: {}, mutes_map: {}) if current_account_id.nil? @reblogs_map = {} @favourites_map = {} @@ -11,9 +11,9 @@ class StatusRelationshipsPresenter 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) - @favourites_map = Status.favourites_map(status_ids, current_account_id) - @mutes_map = Status.mutes_map(conversation_ids, current_account_id) + @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) end end end -- cgit From 72bd73f605a7253daf445f76425da3182a1f669c Mon Sep 17 00:00:00 2001 From: Sorin Davidoi Date: Fri, 14 Jul 2017 22:31:25 +0200 Subject: Small style fixes (#4206) * fix(components/media_modal): Center horizontally in Firefox * fix(components/status_list): Do not remove load more button --- app/javascript/mastodon/components/load_more.js | 9 ++++++++- app/javascript/mastodon/components/status_list.js | 6 +----- app/javascript/styles/components.scss | 1 + 3 files changed, 10 insertions(+), 6 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/components/load_more.js b/app/javascript/mastodon/components/load_more.js index 2996d4dc8..e2fe1fed7 100644 --- a/app/javascript/mastodon/components/load_more.js +++ b/app/javascript/mastodon/components/load_more.js @@ -6,11 +6,18 @@ export default class LoadMore extends React.PureComponent { static propTypes = { onClick: PropTypes.func, + visible: PropTypes.bool, + } + + static defaultProps = { + visible: true, } render() { + const { visible } = this.props; + return ( - ); diff --git a/app/javascript/mastodon/components/status_list.js b/app/javascript/mastodon/components/status_list.js index 5bc46e8ee..86e8386bd 100644 --- a/app/javascript/mastodon/components/status_list.js +++ b/app/javascript/mastodon/components/status_list.js @@ -101,13 +101,9 @@ export default class StatusList extends ImmutablePureComponent { render () { const { statusIds, scrollKey, trackScroll, shouldUpdateScroll, isLoading, hasMore, prepend, emptyMessage } = this.props; - let loadMore = null; + const loadMore = 0 && hasMore} onClick={this.handleLoadMore} />; let scrollableArea = null; - if (!isLoading && statusIds.size > 0 && hasMore) { - loadMore = ; - } - if (isLoading || statusIds.size > 0 || !emptyMessage) { scrollableArea = (

diff --git a/app/javascript/styles/components.scss b/app/javascript/styles/components.scss index 0420a2bed..fc797a508 100644 --- a/app/javascript/styles/components.scss +++ b/app/javascript/styles/components.scss @@ -1314,6 +1314,7 @@ .react-swipeable-view-container > * { display: flex; align-items: center; + justify-content: center; height: 100%; } -- cgit From de397f3bc1f1c7e632d0db0754711beb6ce04664 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 14 Jul 2017 22:31:38 +0200 Subject: Fix subsequent replies to unresolved status not being filtered from home (#4190) Resolves #4177 - smaller changeset --- app/lib/feed_manager.rb | 7 ++++--- spec/lib/feed_manager_spec.rb | 7 +++++++ 2 files changed, 11 insertions(+), 3 deletions(-) (limited to 'app') diff --git a/app/lib/feed_manager.rb b/app/lib/feed_manager.rb index c507f2636..b1ae11084 100644 --- a/app/lib/feed_manager.rb +++ b/app/lib/feed_manager.rb @@ -93,7 +93,7 @@ class FeedManager end def filter_from_home?(status, receiver_id) - return true if status.reply? && status.in_reply_to_id.nil? + return true if status.reply? && (status.in_reply_to_id.nil? || status.in_reply_to_account_id.nil?) check_for_mutes = [status.account_id] check_for_mutes.concat([status.reblog.account_id]) if status.reblog? @@ -120,12 +120,13 @@ class FeedManager end def filter_from_mentions?(status, receiver_id) + return true if receiver_id == status.account_id + check_for_blocks = [status.account_id] check_for_blocks.concat(status.mentions.pluck(:account_id)) check_for_blocks.concat([status.in_reply_to_account]) if status.reply? && !status.in_reply_to_account_id.nil? - should_filter = receiver_id == status.account_id # Filter if I'm mentioning myself - should_filter ||= Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # or it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked + should_filter = Block.where(account_id: receiver_id, target_account_id: check_for_blocks).any? # Filter if it's from someone I blocked, in reply to someone I blocked, or mentioning someone I blocked should_filter ||= (status.account.silenced? && !Follow.where(account_id: receiver_id, target_account_id: status.account_id).exists?) # of if the account is silenced and I'm not following them should_filter diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb index 4bdc96866..22439cf35 100644 --- a/spec/lib/feed_manager_spec.rb +++ b/spec/lib/feed_manager_spec.rb @@ -81,6 +81,13 @@ RSpec.describe FeedManager do expect(FeedManager.instance.filter?(:home, reply, bob.id)).to be true end + it 'returns true for the second reply by followee to a non-federated status' do + reply = Fabricate(:status, text: 'Reply 1', reply: true, account: alice) + second_reply = Fabricate(:status, text: 'Reply 2', thread: reply, account: alice) + bob.follow!(alice) + expect(FeedManager.instance.filter?(:home, second_reply, bob.id)).to be true + end + it 'returns false for status by followee mentioning another account' do bob.follow!(alice) status = PostStatusService.new.call(alice, 'Hey @jeff') -- cgit From cd9b2ab2f70b6c1da5d0abeaa88eecdfc1b41f78 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Fri, 14 Jul 2017 23:01:20 +0200 Subject: Fix #2672 - Connect signed PuSH subscription requests to instance domain (#4205) * Fix #2672 - Connect signed PuSH subscription requests to instance domain Resolves #2739 * Fix return of locate_subscription * Fix tests --- app/controllers/api/push_controller.rb | 8 +++++++- app/models/subscription.rb | 2 +- app/services/pubsubhubbub/subscribe_service.rb | 16 +++++++++++++--- app/workers/pubsubhubbub/distribution_worker.rb | 8 ++++---- db/migrate/20170714184731_add_domain_to_subscriptions.rb | 5 +++++ db/schema.rb | 3 ++- spec/controllers/api/push_controller_spec.rb | 1 + 7 files changed, 33 insertions(+), 10 deletions(-) create mode 100644 db/migrate/20170714184731_add_domain_to_subscriptions.rb (limited to 'app') diff --git a/app/controllers/api/push_controller.rb b/app/controllers/api/push_controller.rb index 951867140..e04d19125 100644 --- a/app/controllers/api/push_controller.rb +++ b/app/controllers/api/push_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::PushController < Api::BaseController + include SignatureVerification + def update response, status = process_push_request render plain: response, status: status @@ -11,7 +13,7 @@ class Api::PushController < Api::BaseController def process_push_request case hub_mode when 'subscribe' - Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds) + Pubsubhubbub::SubscribeService.new.call(account_from_topic, hub_callback, hub_secret, hub_lease_seconds, verified_domain) when 'unsubscribe' Pubsubhubbub::UnsubscribeService.new.call(account_from_topic, hub_callback) else @@ -57,6 +59,10 @@ class Api::PushController < Api::BaseController TagManager.instance.web_domain?(hub_topic_domain) end + def verified_domain + return signed_request_account.domain if signed_request_account + end + def hub_topic_domain hub_topic_uri.host + (hub_topic_uri.port ? ":#{hub_topic_uri.port}" : '') end diff --git a/app/models/subscription.rb b/app/models/subscription.rb index d9d5024a9..bf643c1f9 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true - # == Schema Information # # Table name: subscriptions @@ -13,6 +12,7 @@ # created_at :datetime not null # updated_at :datetime not null # last_successful_delivery_at :datetime +# domain :string # class Subscription < ApplicationRecord diff --git a/app/services/pubsubhubbub/subscribe_service.rb b/app/services/pubsubhubbub/subscribe_service.rb index eeb7ab258..2dba05b12 100644 --- a/app/services/pubsubhubbub/subscribe_service.rb +++ b/app/services/pubsubhubbub/subscribe_service.rb @@ -3,13 +3,15 @@ class Pubsubhubbub::SubscribeService < BaseService URL_PATTERN = /\A#{URI.regexp(%w(http https))}\z/ - attr_reader :account, :callback, :secret, :lease_seconds + attr_reader :account, :callback, :secret, + :lease_seconds, :domain - def call(account, callback, secret, lease_seconds) + def call(account, callback, secret, lease_seconds, verified_domain = nil) @account = account @callback = Addressable::URI.parse(callback).normalize.to_s @secret = secret @lease_seconds = lease_seconds + @domain = verified_domain process_subscribe end @@ -56,6 +58,14 @@ class Pubsubhubbub::SubscribeService < BaseService end def locate_subscription - Subscription.where(account: account, callback_url: callback).first_or_create!(account: account, callback_url: callback) + subscription = Subscription.find_by(account: account, callback_url: callback) + + if subscription.nil? + subscription = Subscription.new(account: account, callback_url: callback) + end + + subscription.domain = domain + subscription.save! + subscription end end diff --git a/app/workers/pubsubhubbub/distribution_worker.rb b/app/workers/pubsubhubbub/distribution_worker.rb index b41cec90d..7592354cc 100644 --- a/app/workers/pubsubhubbub/distribution_worker.rb +++ b/app/workers/pubsubhubbub/distribution_worker.rb @@ -35,16 +35,16 @@ class Pubsubhubbub::DistributionWorker @payload = AtomSerializer.render(AtomSerializer.new.feed(@account, stream_entries)) @domains = @account.followers.domains - Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.reject { |s| !allowed_to_receive?(s.callback_url) }) do |subscription| + Pubsubhubbub::DeliveryWorker.push_bulk(@subscriptions.reject { |s| !allowed_to_receive?(s.callback_url, s.domain) }) do |subscription| [subscription.id, @payload] end end def active_subscriptions - Subscription.where(account: @account).active.select('id, callback_url') + Subscription.where(account: @account).active.select('id, callback_url, domain') end - def allowed_to_receive?(callback_url) - @domains.include?(Addressable::URI.parse(callback_url).host) + def allowed_to_receive?(callback_url, domain) + (!domain.nil? && @domains.include?(domain)) || @domains.include?(Addressable::URI.parse(callback_url).host) end end diff --git a/db/migrate/20170714184731_add_domain_to_subscriptions.rb b/db/migrate/20170714184731_add_domain_to_subscriptions.rb new file mode 100644 index 000000000..7c01a64f5 --- /dev/null +++ b/db/migrate/20170714184731_add_domain_to_subscriptions.rb @@ -0,0 +1,5 @@ +class AddDomainToSubscriptions < ActiveRecord::Migration[5.1] + def change + add_column :subscriptions, :domain, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index b2c59a0f6..5ec78a7c9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20170713190709) do +ActiveRecord::Schema.define(version: 20170714184731) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -326,6 +326,7 @@ ActiveRecord::Schema.define(version: 20170713190709) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.datetime "last_successful_delivery_at" + t.string "domain" t.index ["account_id", "callback_url"], name: "index_subscriptions_on_account_id_and_callback_url", unique: true end diff --git a/spec/controllers/api/push_controller_spec.rb b/spec/controllers/api/push_controller_spec.rb index 18bfa70e5..647698bd1 100644 --- a/spec/controllers/api/push_controller_spec.rb +++ b/spec/controllers/api/push_controller_spec.rb @@ -21,6 +21,7 @@ RSpec.describe Api::PushController, type: :controller do 'https://callback.host/api', 'as1234df', '3600', + nil ) expect(response).to have_http_status(:success) end -- cgit From 3fbf1bf35acf89d1e397fa2e632529bf5105fe02 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 15 Jul 2017 00:49:34 +0200 Subject: Fix #3773 - Pin favourites column (#4201) --- .../mastodon/features/favourited_statuses/index.js | 64 ++++++++++++++++------ .../features/ui/components/columns_area.js | 3 +- 2 files changed, 48 insertions(+), 19 deletions(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/favourited_statuses/index.js b/app/javascript/mastodon/features/favourited_statuses/index.js index 8cef6a1e4..d9ad9bc1f 100644 --- a/app/javascript/mastodon/features/favourited_statuses/index.js +++ b/app/javascript/mastodon/features/favourited_statuses/index.js @@ -2,11 +2,11 @@ import React from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; -import LoadingIndicator from '../../components/loading_indicator'; import { fetchFavouritedStatuses, expandFavouritedStatuses } from '../../actions/favourites'; import Column from '../ui/components/column'; +import ColumnHeader from '../../components/column_header'; +import { addColumn, removeColumn, moveColumn } from '../../actions/columns'; import StatusList from '../../components/status_list'; -import ColumnBackButtonSlim from '../../components/column_back_button_slim'; import { defineMessages, injectIntl } from 'react-intl'; import ImmutablePureComponent from 'react-immutable-pure-component'; @@ -16,8 +16,6 @@ const messages = defineMessages({ const mapStateToProps = state => ({ statusIds: state.getIn(['status_lists', 'favourites', 'items']), - loaded: state.getIn(['status_lists', 'favourites', 'loaded']), - me: state.getIn(['meta', 'me']), }); @connect(mapStateToProps) @@ -27,34 +25,64 @@ export default class Favourites extends ImmutablePureComponent { static propTypes = { dispatch: PropTypes.func.isRequired, statusIds: ImmutablePropTypes.list.isRequired, - loaded: PropTypes.bool, intl: PropTypes.object.isRequired, - me: PropTypes.number.isRequired, + columnId: PropTypes.string, + multiColumn: PropTypes.bool, }; componentWillMount () { this.props.dispatch(fetchFavouritedStatuses()); } + handlePin = () => { + const { columnId, dispatch } = this.props; + + if (columnId) { + dispatch(removeColumn(columnId)); + } else { + dispatch(addColumn('FAVOURITES', {})); + } + } + + handleMove = (dir) => { + const { columnId, dispatch } = this.props; + dispatch(moveColumn(columnId, dir)); + } + + handleHeaderClick = () => { + this.column.scrollTop(); + } + + setRef = c => { + this.column = c; + } + handleScrollToBottom = () => { this.props.dispatch(expandFavouritedStatuses()); } render () { - const { loaded, intl } = this.props; - - if (!loaded) { - return ( - - - - ); - } + const { intl, statusIds, columnId, multiColumn } = this.props; + const pinned = !!columnId; return ( - - - + + + + ); } diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index ef9a15522..ae3f9261b 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -9,7 +9,7 @@ import { links, getIndex, getLink } from './tabs_bar'; import BundleContainer from '../containers/bundle_container'; import ColumnLoading from './column_loading'; import BundleColumnError from './bundle_column_error'; -import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline } from '../../ui/util/async-components'; +import { Compose, Notifications, HomeTimeline, CommunityTimeline, PublicTimeline, HashtagTimeline, FavouritedStatuses } from '../../ui/util/async-components'; const componentMap = { 'COMPOSE': Compose, @@ -18,6 +18,7 @@ const componentMap = { 'PUBLIC': PublicTimeline, 'COMMUNITY': CommunityTimeline, 'HASHTAG': HashtagTimeline, + 'FAVOURITES': FavouritedStatuses, }; export default class ColumnsArea extends ImmutablePureComponent { -- cgit From 8c45cd0e3683b528b65f416681c8272d5650f32d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 15 Jul 2017 03:01:39 +0200 Subject: Improve ActivityPub representations (#3844) * Improve webfinger templates and make tests more flexible * Clean up AS2 representation of actor * Refactor outbox * Create activities representation * Add representations of followers/following collections, do not redirect /users/:username route if format is empty * Remove unused translations * ActivityPub endpoint for single statuses, add ActivityPub::TagManager for better URL/URI generation * Add ActivityPub::TagManager#to * Represent all attachments as Document instead of Image/Video specifically (Because for remote ones we may not know for sure) Add mentions and hashtags representation to AP notes * Add AP-resolvable hashtag URIs * Use ActiveModelSerializers for ActivityPub * Clean up unused translations * Separate route for object and activity * Adjust cc/to matrices * Add to/cc to activities, ensure announce activity embeds target status and not the wrapper status, add "id" to all collections --- app/controllers/accounts_controller.rb | 4 +- app/controllers/activitypub/outboxes_controller.rb | 28 ++++ .../api/activitypub/activities_controller.rb | 27 ---- .../api/activitypub/notes_controller.rb | 19 --- .../api/activitypub/outbox_controller.rb | 69 --------- app/controllers/follower_accounts_controller.rb | 20 +++ app/controllers/following_accounts_controller.rb | 20 +++ app/controllers/statuses_controller.rb | 18 ++- app/controllers/tags_controller.rb | 22 ++- app/helpers/activitystreams2_builder_helper.rb | 8 -- app/lib/activitypub/adapter.rb | 13 ++ app/lib/activitypub/tag_manager.rb | 69 +++++++++ app/presenters/activitypub/collection_presenter.rb | 5 + app/serializers/activitypub/activity_serializer.rb | 27 ++++ app/serializers/activitypub/actor_serializer.rb | 53 +++++++ .../activitypub/collection_serializer.rb | 26 ++++ app/serializers/activitypub/note_serializer.rb | 106 ++++++++++++++ app/views/accounts/show.activitystreams2.rabl | 9 -- app/views/activitypub/base.activitystreams2.rabl | 1 - .../activitypub/intransient.activitystreams2.rabl | 3 - .../types/announce.activitystreams2.rabl | 3 - .../types/collection.activitystreams2.rabl | 3 - .../activitypub/types/create.activitystreams2.rabl | 3 - .../activitypub/types/note.activitystreams2.rabl | 3 - .../types/ordered_collection.activitystreams2.rabl | 3 - .../ordered_collection_page.activitystreams2.rabl | 3 - .../activitypub/types/person.activitystreams2.rabl | 3 - .../activities/_show_status.activitystreams2.rabl | 4 - .../show_status_announce.activitystreams2.rabl | 8 -- .../show_status_create.activitystreams2.rabl | 8 -- .../activitypub/notes/show.activitystreams2.rabl | 11 -- .../activitypub/outbox/show.activitystreams2.rabl | 12 -- .../outbox/show_page.activitystreams2.rabl | 16 --- app/views/well_known/webfinger/show.json.rabl | 6 +- app/views/well_known/webfinger/show.xml.ruby | 5 +- config/initializers/inflections.rb | 2 + config/initializers/mime_types.rb | 5 +- config/locales/ca.yml | 9 -- config/locales/en.yml | 9 -- config/locales/fa.yml | 9 -- config/locales/fr.yml | 9 -- config/locales/he.yml | 9 -- config/locales/id.yml | 9 -- config/locales/ja.yml | 9 -- config/locales/ko.yml | 9 -- config/locales/no.yml | 9 -- config/locales/oc.yml | 9 -- config/locales/pl.yml | 9 -- config/locales/pt-BR.yml | 9 -- config/locales/pt.yml | 9 -- config/locales/th.yml | 9 -- config/locales/tr.yml | 9 -- config/locales/zh-CN.yml | 9 -- config/locales/zh-HK.yml | 9 -- config/routes.rb | 16 +-- spec/controllers/accounts_controller_spec.rb | 2 +- .../api/activitypub/activities_controller_spec.rb | 69 --------- .../api/activitypub/notes_controller_spec.rb | 73 ---------- .../api/activitypub/outbox_controller_spec.rb | 156 --------------------- .../well_known/webfinger_controller_spec.rb | 39 +++--- .../activitystreams2_builder_helper_spec.rb | 15 -- 61 files changed, 443 insertions(+), 725 deletions(-) create mode 100644 app/controllers/activitypub/outboxes_controller.rb delete mode 100644 app/controllers/api/activitypub/activities_controller.rb delete mode 100644 app/controllers/api/activitypub/notes_controller.rb delete mode 100644 app/controllers/api/activitypub/outbox_controller.rb delete mode 100644 app/helpers/activitystreams2_builder_helper.rb create mode 100644 app/lib/activitypub/adapter.rb create mode 100644 app/lib/activitypub/tag_manager.rb create mode 100644 app/presenters/activitypub/collection_presenter.rb create mode 100644 app/serializers/activitypub/activity_serializer.rb create mode 100644 app/serializers/activitypub/actor_serializer.rb create mode 100644 app/serializers/activitypub/collection_serializer.rb create mode 100644 app/serializers/activitypub/note_serializer.rb delete mode 100644 app/views/accounts/show.activitystreams2.rabl delete mode 100644 app/views/activitypub/base.activitystreams2.rabl delete mode 100644 app/views/activitypub/intransient.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/announce.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/collection.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/create.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/note.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/ordered_collection.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl delete mode 100644 app/views/activitypub/types/person.activitystreams2.rabl delete mode 100644 app/views/api/activitypub/activities/_show_status.activitystreams2.rabl delete mode 100644 app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl delete mode 100644 app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl delete mode 100644 app/views/api/activitypub/notes/show.activitystreams2.rabl delete mode 100644 app/views/api/activitypub/outbox/show.activitystreams2.rabl delete mode 100644 app/views/api/activitypub/outbox/show_page.activitystreams2.rabl delete mode 100644 spec/controllers/api/activitypub/activities_controller_spec.rb delete mode 100644 spec/controllers/api/activitypub/notes_controller_spec.rb delete mode 100644 spec/controllers/api/activitypub/outbox_controller_spec.rb delete mode 100644 spec/helpers/activitystreams2_builder_helper_spec.rb (limited to 'app') diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 69b520df1..a95aabf1d 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -16,7 +16,9 @@ class AccountsController < ApplicationController render xml: AtomSerializer.render(AtomSerializer.new.feed(@account, @entries.to_a)) end - format.activitystreams2 + format.json do + render json: @account, serializer: ActivityPub::ActorSerializer, adapter: ActivityPub::Adapter + end end end diff --git a/app/controllers/activitypub/outboxes_controller.rb b/app/controllers/activitypub/outboxes_controller.rb new file mode 100644 index 000000000..6a58ccf24 --- /dev/null +++ b/app/controllers/activitypub/outboxes_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class ActivityPub::OutboxesController < Api::BaseController + before_action :set_account + + def show + @statuses = @account.statuses.permitted_for(@account, current_account).paginate_by_max_id(20, params[:max_id], params[:since_id]) + @statuses = cache_collection(@statuses, Status) + + render json: outbox_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + + private + + def set_account + @account = Account.find_local!(params[:account_username]) + end + + def outbox_presenter + ActivityPub::CollectionPresenter.new( + id: account_outbox_url(@account), + type: :ordered, + current: account_outbox_url(@account), + size: @account.statuses_count, + items: @statuses + ) + end +end diff --git a/app/controllers/api/activitypub/activities_controller.rb b/app/controllers/api/activitypub/activities_controller.rb deleted file mode 100644 index a880ee92f..000000000 --- a/app/controllers/api/activitypub/activities_controller.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -class Api::ActivityPub::ActivitiesController < Api::BaseController - include Authorization - - # before_action :set_follow, only: [:show_follow] - before_action :set_status, only: [:show_status] - - respond_to :activitystreams2 - - # Show a status in AS2 format, as either an Announce (reblog) or a Create (post) activity. - def show_status - authorize @status, :show? - - if @status.reblog? - render :show_status_announce - else - render :show_status_create - end - end - - private - - def set_status - @status = Status.find(params[:id]) - end -end diff --git a/app/controllers/api/activitypub/notes_controller.rb b/app/controllers/api/activitypub/notes_controller.rb deleted file mode 100644 index 96652b879..000000000 --- a/app/controllers/api/activitypub/notes_controller.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -class Api::ActivityPub::NotesController < Api::BaseController - include Authorization - - before_action :set_status - - respond_to :activitystreams2 - - def show - authorize @status, :show? - end - - private - - def set_status - @status = Status.find(params[:id]) - end -end diff --git a/app/controllers/api/activitypub/outbox_controller.rb b/app/controllers/api/activitypub/outbox_controller.rb deleted file mode 100644 index 1af04cb54..000000000 --- a/app/controllers/api/activitypub/outbox_controller.rb +++ /dev/null @@ -1,69 +0,0 @@ -# frozen_string_literal: true - -class Api::ActivityPub::OutboxController < Api::BaseController - before_action :set_account - - respond_to :activitystreams2 - - def show - if params[:max_id] || params[:since_id] - show_outbox_page - else - show_base_outbox - end - end - - private - - def show_base_outbox - @statuses = Status.as_outbox_timeline(@account) - @statuses = cache_collection(@statuses) - - set_maps(@statuses) - - set_first_last_page(@statuses) - - render :show - end - - def show_outbox_page - all_statuses = Status.as_outbox_timeline(@account) - @statuses = all_statuses.paginate_by_max_id(limit_param(DEFAULT_STATUSES_LIMIT), params[:max_id], params[:since_id]) - - all_statuses = cache_collection(all_statuses) - @statuses = cache_collection(@statuses) - - set_maps(@statuses) - - set_first_last_page(all_statuses) - - @next_page_url = api_activitypub_outbox_url(pagination_params(max_id: @statuses.last.id)) unless @statuses.empty? - @prev_page_url = api_activitypub_outbox_url(pagination_params(since_id: @statuses.first.id)) unless @statuses.empty? - - @paginated = @next_page_url || @prev_page_url - @part_of_url = api_activitypub_outbox_url - - set_pagination_headers(@next_page_url, @prev_page_url) - - render :show_page - end - - def cache_collection(raw) - super(raw, Status) - end - - def set_account - @account = Account.find(params[:id]) - end - - def set_first_last_page(statuses) # rubocop:disable Style/AccessorMethodName - return if statuses.empty? - - @first_page_url = api_activitypub_outbox_url(max_id: statuses.first.id + 1) - @last_page_url = api_activitypub_outbox_url(since_id: statuses.last.id - 1) - end - - def pagination_params(core_params) - params.permit(:local, :limit).merge(core_params) - end -end diff --git a/app/controllers/follower_accounts_controller.rb b/app/controllers/follower_accounts_controller.rb index 1e7c7c406..e58c5ad46 100644 --- a/app/controllers/follower_accounts_controller.rb +++ b/app/controllers/follower_accounts_controller.rb @@ -5,5 +5,25 @@ class FollowerAccountsController < ApplicationController def index @follows = Follow.where(target_account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:account) + + respond_to do |format| + format.html + + format.json do + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_followers_url(@account), + type: :ordered, + current: account_followers_url(@account), + size: @account.followers_count, + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.account) } + ) end end diff --git a/app/controllers/following_accounts_controller.rb b/app/controllers/following_accounts_controller.rb index f4488eef5..69f29cd70 100644 --- a/app/controllers/following_accounts_controller.rb +++ b/app/controllers/following_accounts_controller.rb @@ -5,5 +5,25 @@ class FollowingAccountsController < ApplicationController def index @follows = Follow.where(account: @account).recent.page(params[:page]).per(FOLLOW_PER_PAGE).preload(:target_account) + + respond_to do |format| + format.html + + format.json do + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: account_following_index_url(@account), + type: :ordered, + current: account_following_index_url(@account), + size: @account.following_count, + items: @follows.map { |f| ActivityPub::TagManager.instance.uri_for(f.target_account) } + ) end end diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 59c9d0a87..8e0ce0ec3 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -11,10 +11,22 @@ class StatusesController < ApplicationController before_action :check_account_suspension def show - @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] - @descendants = cache_collection(@status.descendants(current_account), Status) + respond_to do |format| + format.html do + @ancestors = @status.reply? ? cache_collection(@status.ancestors(current_account), Status) : [] + @descendants = cache_collection(@status.descendants(current_account), Status) + + render 'stream_entries/show' + end + + format.json do + render json: @status, serializer: ActivityPub::NoteSerializer, adapter: ActivityPub::Adapter + end + end + end - render 'stream_entries/show' + def activity + render json: @status, serializer: ActivityPub::ActivitySerializer, adapter: ActivityPub::Adapter end private diff --git a/app/controllers/tags_controller.rb b/app/controllers/tags_controller.rb index 53149edf0..8bcce9e13 100644 --- a/app/controllers/tags_controller.rb +++ b/app/controllers/tags_controller.rb @@ -5,7 +5,27 @@ class TagsController < ApplicationController def show @tag = Tag.find_by!(name: params[:id].downcase) - @statuses = @tag.nil? ? [] : Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) + @statuses = Status.as_tag_timeline(@tag, current_account, params[:local]).paginate_by_max_id(20, params[:max_id]) @statuses = cache_collection(@statuses, Status) + + respond_to do |format| + format.html + + format.json do + render json: collection_presenter, serializer: ActivityPub::CollectionSerializer, adapter: ActivityPub::Adapter + end + end + end + + private + + def collection_presenter + ActivityPub::CollectionPresenter.new( + id: tag_url(@tag), + type: :ordered, + current: tag_url(@tag), + size: @tag.statuses.count, + items: @statuses.map { |s| ActivityPub::TagManager.instance.uri_for(s) } + ) end end diff --git a/app/helpers/activitystreams2_builder_helper.rb b/app/helpers/activitystreams2_builder_helper.rb deleted file mode 100644 index 717b470f0..000000000 --- a/app/helpers/activitystreams2_builder_helper.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -module Activitystreams2BuilderHelper - # Gets a usable name for an account, using display name or username. - def account_name(account) - account.display_name.presence || account.username - end -end diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb new file mode 100644 index 000000000..0a70207bc --- /dev/null +++ b/app/lib/activitypub/adapter.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base + def self.default_key_transform + :camel_lower + 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)) + self.class.transform_key_casing!(serialized_hash, instance_options) + end +end diff --git a/app/lib/activitypub/tag_manager.rb b/app/lib/activitypub/tag_manager.rb new file mode 100644 index 000000000..ec42bcad3 --- /dev/null +++ b/app/lib/activitypub/tag_manager.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'singleton' + +class ActivityPub::TagManager + include Singleton + include RoutingHelper + + COLLECTIONS = { + public: 'https://www.w3.org/ns/activitystreams#Public', + }.freeze + + def url_for(target) + return target.url if target.respond_to?(:local?) && !target.local? + + case target.object_type + when :person + short_account_url(target) + when :note, :comment, :activity + short_account_status_url(target.account, target) + end + end + + def uri_for(target) + return target.uri if target.respond_to?(:local?) && !target.local? + + case target.object_type + when :person + account_url(target) + when :note, :comment, :activity + account_status_url(target.account, target) + end + 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 + # Others go out only to the people they mention + def to(status) + case status.visibility + when 'public' + [COLLECTIONS[:public]] + when 'unlisted', 'private' + [account_followers_url(status.account)] + when 'direct' + status.mentions.map { |mention| uri_for(mention.account) } + end + end + + # Secondary audience of a status + # Public statuses go out to followers as well + # Unlisted statuses go to the public as well + # Both of those and private statuses also go to the people mentioned in them + # Direct ones don't have a secondary audience + def cc(status) + cc = [] + + case status.visibility + when 'public' + cc << account_followers_url(status.account) + when 'unlisted' + cc << COLLECTIONS[:public] + end + + cc.concat(status.mentions.map { |mention| uri_for(mention.account) }) unless status.direct_visibility? + + cc + end +end diff --git a/app/presenters/activitypub/collection_presenter.rb b/app/presenters/activitypub/collection_presenter.rb new file mode 100644 index 000000000..6bae2955e --- /dev/null +++ b/app/presenters/activitypub/collection_presenter.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ActivityPub::CollectionPresenter < ActiveModelSerializers::Model + attributes :id, :type, :current, :size, :items +end diff --git a/app/serializers/activitypub/activity_serializer.rb b/app/serializers/activitypub/activity_serializer.rb new file mode 100644 index 000000000..69e2160c5 --- /dev/null +++ b/app/serializers/activitypub/activity_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class ActivityPub::ActivitySerializer < ActiveModel::Serializer + attributes :id, :type, :actor, :to, :cc + + has_one :proper, key: :object, serializer: ActivityPub::NoteSerializer + + def id + [ActivityPub::TagManager.instance.uri_for(object), '/activity'].join + end + + def type + object.reblog? ? 'Announce' : 'Create' + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def to + ActivityPub::TagManager.instance.to(object) + end + + def cc + ActivityPub::TagManager.instance.cc(object) + end +end diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb new file mode 100644 index 000000000..56806152e --- /dev/null +++ b/app/serializers/activitypub/actor_serializer.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +class ActivityPub::ActorSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :id, :type, :following, :followers, + :inbox, :outbox, :preferred_username, + :name, :summary, :icon, :image + + def id + account_url(object) + end + + def type + 'Person' + end + + def following + account_following_index_url(object) + end + + def followers + account_followers_url(object) + end + + def inbox + nil + end + + def outbox + account_outbox_url(object) + end + + def preferred_username + object.username + end + + def name + object.display_name + end + + def summary + Formatter.instance.simplified_format(object) + end + + def icon + full_asset_url(object.avatar.url(:original)) + end + + def image + full_asset_url(object.header.url(:original)) + end +end diff --git a/app/serializers/activitypub/collection_serializer.rb b/app/serializers/activitypub/collection_serializer.rb new file mode 100644 index 000000000..baaba7654 --- /dev/null +++ b/app/serializers/activitypub/collection_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ActivityPub::CollectionSerializer < ActiveModel::Serializer + def self.serializer_for(model, options) + return ActivityPub::ActivitySerializer if model.class.name == 'Status' + super + end + + attributes :id, :type, :total_items, + :current + + has_many :items, key: :ordered_items + + def type + case object.type + when :ordered + 'OrderedCollection' + else + 'Collection' + end + end + + def total_items + object.size + end +end diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb new file mode 100644 index 000000000..ffdc6175d --- /dev/null +++ b/app/serializers/activitypub/note_serializer.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +class ActivityPub::NoteSerializer < ActiveModel::Serializer + attributes :id, :type, :summary, :content, + :in_reply_to, :published, :url, + :actor, :to, :cc, :sensitive + + has_many :media_attachments, key: :attachment + has_many :virtual_tags, key: :tag + + def id + ActivityPub::TagManager.instance.uri_for(object) + end + + def type + 'Note' + end + + def summary + object.spoiler_text.presence + end + + def content + Formatter.instance.format(object) + end + + def in_reply_to + ActivityPub::TagManager.instance.uri_for(object.thread) if object.reply? + end + + def published + object.created_at.iso8601 + end + + def url + ActivityPub::TagManager.instance.url_for(object) + end + + def actor + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def to + ActivityPub::TagManager.instance.to(object) + end + + def cc + ActivityPub::TagManager.instance.cc(object) + end + + def virtual_tags + object.mentions + object.tags + end + + class MediaAttachmentSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :media_type, :url + + def type + 'Document' + end + + def media_type + object.file_content_type + end + + def url + object.local? ? full_asset_url(object.file.url(:original, false)) : object.remote_url + end + end + + class MentionSerializer < ActiveModel::Serializer + attributes :type, :href, :name + + def type + 'Mention' + end + + def href + ActivityPub::TagManager.instance.uri_for(object.account) + end + + def name + "@#{object.account.acct}" + end + end + + class TagSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :href, :name + + def type + 'Hashtag' + end + + def href + tag_url(object) + end + + def name + "##{object.name}" + end + end +end diff --git a/app/views/accounts/show.activitystreams2.rabl b/app/views/accounts/show.activitystreams2.rabl deleted file mode 100644 index 2c0a4ad3a..000000000 --- a/app/views/accounts/show.activitystreams2.rabl +++ /dev/null @@ -1,9 +0,0 @@ -extends 'activitypub/types/person.activitystreams2.rabl' - -object @account - -attributes display_name: :name, username: :preferredUsername, note: :summary - -node(:icon) { |account| full_asset_url(account.avatar.url(:original)) } -node(:image) { |account| full_asset_url(account.header.url(:original)) } -node(:outbox) { |account| api_activitypub_outbox_url(account.id) } diff --git a/app/views/activitypub/base.activitystreams2.rabl b/app/views/activitypub/base.activitystreams2.rabl deleted file mode 100644 index c5e94997a..000000000 --- a/app/views/activitypub/base.activitystreams2.rabl +++ /dev/null @@ -1 +0,0 @@ -node(:'@context') { 'https://www.w3.org/ns/activitystreams' } diff --git a/app/views/activitypub/intransient.activitystreams2.rabl b/app/views/activitypub/intransient.activitystreams2.rabl deleted file mode 100644 index 968e451c2..000000000 --- a/app/views/activitypub/intransient.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/base.activitystreams2.rabl' - -node(:id) { request.original_url } diff --git a/app/views/activitypub/types/announce.activitystreams2.rabl b/app/views/activitypub/types/announce.activitystreams2.rabl deleted file mode 100644 index 4a29aa134..000000000 --- a/app/views/activitypub/types/announce.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/intransient.activitystreams2.rabl' - -node(:type) { 'Announce' } diff --git a/app/views/activitypub/types/collection.activitystreams2.rabl b/app/views/activitypub/types/collection.activitystreams2.rabl deleted file mode 100644 index cc0e532b7..000000000 --- a/app/views/activitypub/types/collection.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/intransient.activitystreams2.rabl' - -node(:type) { 'Collection' } diff --git a/app/views/activitypub/types/create.activitystreams2.rabl b/app/views/activitypub/types/create.activitystreams2.rabl deleted file mode 100644 index e41a056a7..000000000 --- a/app/views/activitypub/types/create.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/intransient.activitystreams2.rabl' - -node(:type) { 'Create' } diff --git a/app/views/activitypub/types/note.activitystreams2.rabl b/app/views/activitypub/types/note.activitystreams2.rabl deleted file mode 100644 index 39c74d4ba..000000000 --- a/app/views/activitypub/types/note.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/intransient.activitystreams2.rabl' - -node(:type) { 'Note' } diff --git a/app/views/activitypub/types/ordered_collection.activitystreams2.rabl b/app/views/activitypub/types/ordered_collection.activitystreams2.rabl deleted file mode 100644 index 2cda6f4d0..000000000 --- a/app/views/activitypub/types/ordered_collection.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/types/collection.activitystreams2.rabl' - -node(:type) { 'OrderedCollection' } diff --git a/app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl b/app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl deleted file mode 100644 index 9937d11e9..000000000 --- a/app/views/activitypub/types/ordered_collection_page.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/types/ordered_collection.activitystreams2.rabl' - -node(:type) { 'OrderedCollectionPage' } diff --git a/app/views/activitypub/types/person.activitystreams2.rabl b/app/views/activitypub/types/person.activitystreams2.rabl deleted file mode 100644 index 487a60791..000000000 --- a/app/views/activitypub/types/person.activitystreams2.rabl +++ /dev/null @@ -1,3 +0,0 @@ -extends 'activitypub/intransient.activitystreams2.rabl' - -node(:type) { 'Person' } diff --git a/app/views/api/activitypub/activities/_show_status.activitystreams2.rabl b/app/views/api/activitypub/activities/_show_status.activitystreams2.rabl deleted file mode 100644 index 472bf5dbd..000000000 --- a/app/views/api/activitypub/activities/_show_status.activitystreams2.rabl +++ /dev/null @@ -1,4 +0,0 @@ -object @status - -node(:actor) { |status| TagManager.instance.url_for(status.account) } -node(:published) { |status| status.created_at.to_time.xmlschema } \ No newline at end of file diff --git a/app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl b/app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl deleted file mode 100644 index 44ac1ba2f..000000000 --- a/app/views/api/activitypub/activities/show_status_announce.activitystreams2.rabl +++ /dev/null @@ -1,8 +0,0 @@ -extends 'activitypub/types/announce.activitystreams2.rabl' -extends 'api/activitypub/activities/_show_status.activitystreams2.rabl' - -object @status - -node(:name) { |status| t('activitypub.activity.announce.name', account_name: account_name(status.account)) } -node(:url) { |status| TagManager.instance.url_for(status) } -node(:object) { |status| api_activitypub_status_url(status.reblog_of_id) } diff --git a/app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl b/app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl deleted file mode 100644 index ff4d39eca..000000000 --- a/app/views/api/activitypub/activities/show_status_create.activitystreams2.rabl +++ /dev/null @@ -1,8 +0,0 @@ -extends 'activitypub/types/create.activitystreams2.rabl' -extends 'api/activitypub/activities/_show_status.activitystreams2.rabl' - -object @status - -node(:name) { |status| t('activitypub.activity.create.name', account_name: account_name(status.account)) } -node(:url) { |status| TagManager.instance.url_for(status) } -node(:object) { |status| api_activitypub_note_url(status) } diff --git a/app/views/api/activitypub/notes/show.activitystreams2.rabl b/app/views/api/activitypub/notes/show.activitystreams2.rabl deleted file mode 100644 index d962f4438..000000000 --- a/app/views/api/activitypub/notes/show.activitystreams2.rabl +++ /dev/null @@ -1,11 +0,0 @@ -extends 'activitypub/types/note.activitystreams2.rabl' - -object @status - -attributes :content - -node(:name) { |status| status.content } -node(:url) { |status| TagManager.instance.url_for(status) } -node(:attributedTo) { |status| TagManager.instance.url_for(status.account) } -node(:inReplyTo) { |status| api_activitypub_note_url(status.thread) } if @status.thread -node(:published) { |status| status.created_at.to_time.xmlschema } diff --git a/app/views/api/activitypub/outbox/show.activitystreams2.rabl b/app/views/api/activitypub/outbox/show.activitystreams2.rabl deleted file mode 100644 index 273b15e82..000000000 --- a/app/views/api/activitypub/outbox/show.activitystreams2.rabl +++ /dev/null @@ -1,12 +0,0 @@ -extends 'activitypub/types/ordered_collection.activitystreams2.rabl' - -object @account - -node(:totalItems) { @statuses.count } -node(:current) { @first_page_url } if @first_page_url -node(:first) { @first_page_url } if @first_page_url -node(:last) { @last_page_url } if @last_page_url - -node(:name) { |account| t('activitypub.outbox.name', account_name: account_name(account)) } -node(:summary) { |account| t('activitypub.outbox.summary', account_name: account_name(account)) } -node(:updated) { |account| (@statuses.empty? ? account.created_at.to_time : @statuses.first.updated_at.to_time).xmlschema } diff --git a/app/views/api/activitypub/outbox/show_page.activitystreams2.rabl b/app/views/api/activitypub/outbox/show_page.activitystreams2.rabl deleted file mode 100644 index b6433ccf2..000000000 --- a/app/views/api/activitypub/outbox/show_page.activitystreams2.rabl +++ /dev/null @@ -1,16 +0,0 @@ -extends 'activitypub/types/ordered_collection_page.activitystreams2.rabl' - -object @account - -node(:items) do - @statuses.map { |status| api_activitypub_status_url(status) } -end - -node(:next) { @next_page_url } if @next_page_url -node(:prev) { @prev_page_url } if @prev_page_url -node(:current) { @first_page_url } if @first_page_url -node(:first) { @first_page_url } if @first_page_url -node(:last) { @last_page_url } if @last_page_url -node(:partOf) { @part_of_url } if @part_of_url - -node(:updated) { |account| (@statuses.empty? ? account.created_at.to_time : @statuses.first.updated_at.to_time).xmlschema } diff --git a/app/views/well_known/webfinger/show.json.rabl b/app/views/well_known/webfinger/show.json.rabl index 123d1d11a..af11cd207 100644 --- a/app/views/well_known/webfinger/show.json.rabl +++ b/app/views/well_known/webfinger/show.json.rabl @@ -3,14 +3,14 @@ object @account node(:subject) { @canonical_account_uri } node(:aliases) do - [TagManager.instance.url_for(@account), TagManager.instance.uri_for(@account)] + [short_account_url(@account), account_url(@account)] end node(:links) do [ - { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: TagManager.instance.url_for(@account) }, + { rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: account_url(@account) }, { rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom') }, - { rel: 'self', type: 'application/activity+json', href: TagManager.instance.url_for(@account) }, + { rel: 'self', type: 'application/activity+json', href: account_url(@account) }, { rel: 'salmon', href: api_salmon_url(@account.id) }, { rel: 'magic-public-key', href: "data:application/magic-public-key,#{@magic_key}" }, { rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}" }, diff --git a/app/views/well_known/webfinger/show.xml.ruby b/app/views/well_known/webfinger/show.xml.ruby index fc0ab5b84..844742d68 100644 --- a/app/views/well_known/webfinger/show.xml.ruby +++ b/app/views/well_known/webfinger/show.xml.ruby @@ -1,10 +1,11 @@ Nokogiri::XML::Builder.new do |xml| xml.XRD(xmlns: 'http://docs.oasis-open.org/ns/xri/xrd-1.0') do xml.Subject @canonical_account_uri - xml.Alias TagManager.instance.url_for(@account) - xml.Alias TagManager.instance.uri_for(@account) + xml.Alias short_account_url(@account) + xml.Alias account_url(@account) xml.Link(rel: 'http://webfinger.net/rel/profile-page', type: 'text/html', href: TagManager.instance.url_for(@account)) xml.Link(rel: 'http://schemas.google.com/g/2010#updates-from', type: 'application/atom+xml', href: account_url(@account, format: 'atom')) + xml.Link(rel: 'self', type: 'application/activity+json', href: account_url(@account)) xml.Link(rel: 'salmon', href: api_salmon_url(@account.id)) xml.Link(rel: 'magic-public-key', href: "data:application/magic-public-key,#{@magic_key}") xml.Link(rel: 'http://ostatus.org/schema/1.0/subscribe', template: "#{authorize_follow_url}?acct={uri}") diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index a7b1ef690..26275d092 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -14,4 +14,6 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.acronym 'StatsD' inflect.acronym 'OEmbed' inflect.acronym 'ActivityPub' + inflect.acronym 'PubSubHubbub' + inflect.acronym 'ActivityStreams' end diff --git a/config/initializers/mime_types.rb b/config/initializers/mime_types.rb index b1b73c846..30e91ad63 100644 --- a/config/initializers/mime_types.rb +++ b/config/initializers/mime_types.rb @@ -1,5 +1,4 @@ # Be sure to restart your server when you modify this file. -Mime::Type.register "application/json", :json, %w( text/x-json application/jsonrequest application/jrd+json ) -Mime::Type.register "text/xml", :xml, %w( application/xml application/atom+xml application/xrd+xml ) -Mime::Type.register "application/activity+json", :activitystreams2 +Mime::Type.register 'application/json', :json, %w(text/x-json application/jsonrequest application/jrd+json application/activity+json) +Mime::Type.register 'text/xml', :xml, %w(application/xml application/atom+xml application/xrd+xml) diff --git a/config/locales/ca.yml b/config/locales/ca.yml index f63aee3e6..0ba893a12 100644 --- a/config/locales/ca.yml +++ b/config/locales/ca.yml @@ -30,15 +30,6 @@ ca: remote_follow: Seguir reserved_username: El nom d'usuari està reservat unfollow: Deixar de seguir - activitypub: - activity: - announce: - name: "%{account_name} shared an activity." - create: - name: "%{account_name} created a note." - outbox: - name: "%{account_name}'s Outbox" - summary: A collection of activities from user %{account_name}. admin: accounts: are_you_sure: Estàs segur? diff --git a/config/locales/en.yml b/config/locales/en.yml index 79efddfad..be1f15e25 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -44,15 +44,6 @@ en: remote_follow: Remote follow reserved_username: The username is reserved unfollow: Unfollow - activitypub: - activity: - announce: - name: "%{account_name} shared an activity." - create: - name: "%{account_name} created a note." - outbox: - name: "%{account_name}'s Outbox" - summary: A collection of activities from user %{account_name}. admin: accounts: are_you_sure: Are you sure? diff --git a/config/locales/fa.yml b/config/locales/fa.yml index ade76d670..218d859bb 100644 --- a/config/locales/fa.yml +++ b/config/locales/fa.yml @@ -29,15 +29,6 @@ fa: posts: نوشته remote_follow: پیگیری غیرمستقیم unfollow: پایان پیگیری - activitypub: - activity: - announce: - name: "%{account_name} فعالیتی آغاز کرد." - create: - name: "%{account_name} یادداشتی نوشت." - outbox: - name: صندوق خروجی %{account_name} - summary: مجموعه‌ای از فعالیت‌های کاربر %{account_name}. admin: accounts: are_you_sure: آیا مطمئن هستید؟ diff --git a/config/locales/fr.yml b/config/locales/fr.yml index cba217651..65e681b20 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -30,15 +30,6 @@ fr: remote_follow: Suivre à distance reserved_username: Ce nom d’utilisateur⋅ice est réservé unfollow: Ne plus suivre - activitypub: - activity: - announce: - name: "%{account_name} a partagé une activité." - create: - name: "%{account_name} a créé une note." - outbox: - name: Boîte d’envoi de %{account_name} - summary: Liste d’activités de %{account_name} admin: accounts: are_you_sure: Êtes-vous certain⋅e ? diff --git a/config/locales/he.yml b/config/locales/he.yml index 21f8f1dc4..251b6914e 100644 --- a/config/locales/he.yml +++ b/config/locales/he.yml @@ -29,15 +29,6 @@ he: posts: הודעות remote_follow: מעקב מרחוק unfollow: הפסקת מעקב - activitypub: - activity: - announce: - name: הודעה שותפה על ידי %{account_name}. - create: - name: הודעה חדשה מאת %{account_name}. - outbox: - name: תיבת הדוא"ל היוצא של %{account_name} - summary: אוסף הפעילויות של %{account_name}. admin: accounts: are_you_sure: בטוח? diff --git a/config/locales/id.yml b/config/locales/id.yml index e3fe96331..7bda52c78 100644 --- a/config/locales/id.yml +++ b/config/locales/id.yml @@ -29,15 +29,6 @@ id: posts: Postingan remote_follow: Mengikuti unfollow: Berhenti mengikuti - activitypub: - activity: - announce: - name: "%{account_name} membagikan aktivitas." - create: - name: "%{account_name} membuat catatan." - outbox: - name: "%{account_name} Outbox" - summary: Koleksi aktivitas dari pengguna %{account_name}. admin: accounts: are_you_sure: Anda yakin? diff --git a/config/locales/ja.yml b/config/locales/ja.yml index 37d82a205..fda87526d 100644 --- a/config/locales/ja.yml +++ b/config/locales/ja.yml @@ -30,15 +30,6 @@ ja: remote_follow: リモートフォロー reserved_username: このユーザー名は予約されています。 unfollow: フォロー解除 - activitypub: - activity: - announce: - name: "%{account_name} さんがアクティビティをシェアしました" - create: - name: "%{account_name} さんがノートを作成しました" - outbox: - name: "%{account_name} さんの送信トレイ" - summary: "%{account_name} さんからのアクティビティコレクション" admin: accounts: are_you_sure: 本当に実行しますか? diff --git a/config/locales/ko.yml b/config/locales/ko.yml index bafc19993..c7c310cfe 100644 --- a/config/locales/ko.yml +++ b/config/locales/ko.yml @@ -30,15 +30,6 @@ ko: remote_follow: 리모트 팔로우 reserved_username: 이 아이디는 예약되어 있습니다. unfollow: 팔로우 해제 - activitypub: - activity: - announce: - name: "%{account_name} 님이 액티비티를 공유했습니다" - create: - name: "%{account_name} 님이 노트를 작성했습니다" - outbox: - name: "%{account_name} 님의 송신함" - summary: "%{account_name} 님의 액티비티 모음" admin: accounts: are_you_sure: 정말로 실행하시겠습니까? diff --git a/config/locales/no.yml b/config/locales/no.yml index 004e1ff80..cf94524d2 100644 --- a/config/locales/no.yml +++ b/config/locales/no.yml @@ -29,15 +29,6 @@ posts: Poster remote_follow: Følg fra andre instanser unfollow: Avfølg - activitypub: - activity: - announce: - name: "%{account_name} delte en aktivitet." - create: - name: "%{account_name} laget en aktivitet." - outbox: - name: "%{account_name} sin utboks" - summary: En samling aktiviteter fra brukeren %{account_name}. admin: accounts: are_you_sure: Er du sikker? diff --git a/config/locales/oc.yml b/config/locales/oc.yml index 91a6ca791..2eb85be58 100644 --- a/config/locales/oc.yml +++ b/config/locales/oc.yml @@ -29,15 +29,6 @@ oc: posts: Estatuts remote_follow: Sègre a distància unfollow: Quitar de sègre - activitypub: - activity: - announce: - name: "%{account_name} a partejat una activitat." - create: - name: "%{account_name} a creat una nòta." - outbox: - name: "%{account_name}'s Outbox" - summary: A collection of activities from user %{account_name}. admin: accounts: are_you_sure: Sètz segur ? diff --git a/config/locales/pl.yml b/config/locales/pl.yml index 9ee6c0540..6f2831670 100644 --- a/config/locales/pl.yml +++ b/config/locales/pl.yml @@ -44,15 +44,6 @@ pl: remote_follow: Zdalne śledzenie reserved_username: Ta nazwa użytkownika jest zarezerwowana. unfollow: Przestań śledzić - activitypub: - activity: - announce: - name: "%{account_name} udostępnił(a) aktywność." - create: - name: "%{account_name} utworzył(a) wpis." - outbox: - name: Skrzynka %{account_name} - summary: Zbiór aktywności użytkownika %{account_name}. admin: accounts: are_you_sure: Jesteś tego pewien? diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml index 355c20d05..5ba763ae4 100644 --- a/config/locales/pt-BR.yml +++ b/config/locales/pt-BR.yml @@ -29,15 +29,6 @@ pt-BR: posts: Posts remote_follow: Acesso remoto unfollow: Unfollow - activitypub: - activity: - announce: - name: "%{account_name} compartilhou uma atividade." - create: - name: "%{account_name} criou uma nota." - outbox: - name: "%{account_name}'s Outbox" - summary: Uma coleção de atividades do usuário %{account_name}. admin: accounts: are_you_sure: Você tem certeza? diff --git a/config/locales/pt.yml b/config/locales/pt.yml index 40be8a6c5..346fcdda8 100644 --- a/config/locales/pt.yml +++ b/config/locales/pt.yml @@ -29,15 +29,6 @@ pt: posts: Posts remote_follow: Seguir remotamente unfollow: Deixar de seguir - activitypub: - activity: - announce: - name: "%{account_name} anunciou uma atividade." - create: - name: "%{account_name} criou uma nota." - outbox: - name: "%{account_name}'s Outbox" - summary: Uma coleção de atividades do usuário %{account_name}. admin: accounts: are_you_sure: Tens a certeza? diff --git a/config/locales/th.yml b/config/locales/th.yml index 263babdd0..17eb96110 100644 --- a/config/locales/th.yml +++ b/config/locales/th.yml @@ -29,15 +29,6 @@ th: posts: โพสต์ remote_follow: Remote follow unfollow: เลิกติดตาม - activitypub: - activity: - announce: - name: "%{account_name} แชร์กิจกรรม." - create: - name: "%{account_name} สร้างโน๊ต." - outbox: - name: "%{account_name}'s Outbox" - summary: รวมกิจกรรมของผู้ใช้ %{account_name}. admin: accounts: are_you_sure: แน่ใจนะ? diff --git a/config/locales/tr.yml b/config/locales/tr.yml index e7864cc57..bb83991cd 100644 --- a/config/locales/tr.yml +++ b/config/locales/tr.yml @@ -29,15 +29,6 @@ tr: posts: Gönderiler remote_follow: Uzaktan takip et unfollow: Takibi bırak - activitypub: - activity: - announce: - name: "%{account_name} bir aktivite paylaştı." - create: - name: "%{account_name} bir not oluşturdu." - outbox: - name: "%{account_name}'in Gönderdikleri" - summary: "%{account_name}'den gelen aktiviteler." admin: accounts: are_you_sure: Emin misiniz? diff --git a/config/locales/zh-CN.yml b/config/locales/zh-CN.yml index 650d4bd15..0526ec1ba 100644 --- a/config/locales/zh-CN.yml +++ b/config/locales/zh-CN.yml @@ -29,15 +29,6 @@ zh-CN: posts: 嘟文 remote_follow: 跨站关注 unfollow: 取消关注 - activitypub: - activity: - announce: - name: "%{account_name} 分享了一个活动。" - create: - name: "%{account_name} 创建了一个记事。" - outbox: - name: "%{account_name} 的集合" - summary: "%{account_name} 的活动集合" admin: accounts: are_you_sure: 你确定吗? diff --git a/config/locales/zh-HK.yml b/config/locales/zh-HK.yml index d2db78be1..06f9ab63d 100644 --- a/config/locales/zh-HK.yml +++ b/config/locales/zh-HK.yml @@ -29,15 +29,6 @@ zh-HK: posts: 文章 remote_follow: 跨站關注 unfollow: 取消關注 - activitypub: - activity: - announce: - name: "%{account_name} 分享了一項活動。" - create: - name: "%{account_name} 新增了一篇筆記。" - outbox: - name: "%{account_name} 的活動" - summary: "%{account_name} 分享的活動列表。" admin: accounts: are_you_sure: 你確定嗎? diff --git a/config/routes.rb b/config/routes.rb index 9171d02d4..dda3534eb 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -26,7 +26,7 @@ Rails.application.routes.draw do confirmations: 'auth/confirmations', } - get '/users/:username', to: redirect('/@%{username}'), constraints: { format: :html } + get '/users/:username', to: redirect('/@%{username}'), constraints: lambda { |req| req.format.nil? } resources :accounts, path: 'users', only: [:show], param: :username do resources :stream_entries, path: 'updates', only: [:show] do @@ -38,10 +38,17 @@ Rails.application.routes.draw do get :remote_follow, to: 'remote_follow#new' post :remote_follow, to: 'remote_follow#create' + resources :statuses, only: [:show] do + member do + get :activity + end + end + resources :followers, only: [:index], controller: :follower_accounts resources :following, only: [:index], controller: :following_accounts resource :follow, only: [:create], controller: :account_follow resource :unfollow, only: [:create], controller: :account_unfollow + resource :outbox, only: [:show], module: :activitypub end get '/@:username', to: 'accounts#show', as: :short_account @@ -119,13 +126,6 @@ Rails.application.routes.draw do # OEmbed get '/oembed', to: 'oembed#show', as: :oembed - # ActivityPub - namespace :activitypub do - get '/users/:id/outbox', to: 'outbox#show', as: :outbox - get '/statuses/:id', to: 'activities#show_status', as: :status - resources :notes, only: [:show] - end - # JSON / REST API namespace :v1 do resources :statuses, only: [:create, :show, :destroy] do diff --git a/spec/controllers/accounts_controller_spec.rb b/spec/controllers/accounts_controller_spec.rb index 447e2dd53..d61c8c9bd 100644 --- a/spec/controllers/accounts_controller_spec.rb +++ b/spec/controllers/accounts_controller_spec.rb @@ -38,7 +38,7 @@ RSpec.describe AccountsController, type: :controller do context 'activitystreams2' do before do - get :show, params: { username: alice.username }, format: 'activitystreams2' + get :show, params: { username: alice.username }, format: 'json' end it 'assigns @account' do diff --git a/spec/controllers/api/activitypub/activities_controller_spec.rb b/spec/controllers/api/activitypub/activities_controller_spec.rb deleted file mode 100644 index 07df28ac2..000000000 --- a/spec/controllers/api/activitypub/activities_controller_spec.rb +++ /dev/null @@ -1,69 +0,0 @@ -require 'rails_helper' - -RSpec.describe Api::ActivityPub::ActivitiesController, type: :controller do - render_views - - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - - describe 'GET #show' do - describe 'normal status' do - public_status = nil - - before do - public_status = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public) - - @request.env['HTTP_ACCEPT'] = 'application/activity+json' - get :show_status, params: { id: public_status.id } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns http success' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('type' => 'Create') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('type' => 'Create') - expect(json_data).to include('object' => api_activitypub_note_url(public_status)) - expect(json_data).to include('url' => TagManager.instance.url_for(public_status)) - end - end - - describe 'reblog' do - original = nil - reblog = nil - - before do - original = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public) - reblog = Fabricate(:status, account: user.account, reblog_of_id: original.id, visibility: :public) - - @request.env['HTTP_ACCEPT'] = 'application/activity+json' - get :show_status, params: { id: reblog.id } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns http success' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('type' => 'Announce') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('type' => 'Announce') - expect(json_data).to include('object' => api_activitypub_status_url(original)) - expect(json_data).to include('url' => TagManager.instance.url_for(reblog)) - end - end - end -end diff --git a/spec/controllers/api/activitypub/notes_controller_spec.rb b/spec/controllers/api/activitypub/notes_controller_spec.rb deleted file mode 100644 index a0f05dc65..000000000 --- a/spec/controllers/api/activitypub/notes_controller_spec.rb +++ /dev/null @@ -1,73 +0,0 @@ -require 'rails_helper' - -RSpec.describe Api::ActivityPub::NotesController, type: :controller do - render_views - - let(:user_alice) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - let(:user_bob) { Fabricate(:user, account: Fabricate(:account, username: 'bob')) } - - describe 'GET #show' do - describe 'normal status' do - public_status = nil - - before do - public_status = Fabricate(:status, account: user_alice.account, text: 'Hello world', visibility: :public) - - @request.env['HTTP_ACCEPT'] = 'application/activity+json' - get :show, params: { id: public_status.id } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns http success' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('type' => 'Note') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('name' => 'Hello world') - expect(json_data).to include('content' => 'Hello world') - expect(json_data).to include('published') - expect(json_data).to include('url' => TagManager.instance.url_for(public_status)) - end - end - - describe 'reply' do - original = nil - reply = nil - - before do - original = Fabricate(:status, account: user_alice.account, text: 'Hello world', visibility: :public) - reply = Fabricate(:status, account: user_bob.account, text: 'Hello world', in_reply_to_id: original.id, visibility: :public) - - @request.env['HTTP_ACCEPT'] = 'application/activity+json' - get :show, params: { id: reply.id } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns http success' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('type' => 'Note') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('name' => 'Hello world') - expect(json_data).to include('content' => 'Hello world') - expect(json_data).to include('published') - expect(json_data).to include('url' => TagManager.instance.url_for(reply)) - expect(json_data).to include('inReplyTo' => api_activitypub_note_url(original)) - end - end - end -end diff --git a/spec/controllers/api/activitypub/outbox_controller_spec.rb b/spec/controllers/api/activitypub/outbox_controller_spec.rb deleted file mode 100644 index 049cf451d..000000000 --- a/spec/controllers/api/activitypub/outbox_controller_spec.rb +++ /dev/null @@ -1,156 +0,0 @@ -require 'rails_helper' - -RSpec.describe Api::ActivityPub::OutboxController, type: :controller do - render_views - - let(:user) { Fabricate(:user, account: Fabricate(:account, username: 'alice')) } - - describe 'GET #show' do - before do - @request.headers['ACCEPT'] = 'application/activity+json' - end - - describe 'collection with small number of statuses' do - public_status = nil - - before do - public_status = Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct) - - get :show, params: { id: user.account.id } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns AS2 JSON body' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('type' => 'OrderedCollection') - expect(json_data).to include('totalItems' => 1) - expect(json_data).to include('current') - expect(json_data).to include('first') - expect(json_data).to include('last') - end - end - - describe 'collection with large number of statuses' do - before do - 30.times do - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public) - end - - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct) - - get :show, params: { id: user.account.id } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns AS2 JSON body' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('type' => 'OrderedCollection') - expect(json_data).to include('totalItems' => 30) - expect(json_data).to include('current') - expect(json_data).to include('first') - expect(json_data).to include('last') - end - end - - describe 'page with small number of statuses' do - statuses = [] - - before do - 5.times do - statuses << Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public) - end - - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct) - - get :show, params: { id: user.account.id, max_id: statuses.last.id + 1 } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns AS2 JSON body' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('type' => 'OrderedCollectionPage') - expect(json_data).to include('partOf') - expect(json_data).to include('items') - expect(json_data['items'].length).to eq(5) - expect(json_data).to include('prev') - expect(json_data).to include('next') - expect(json_data).to include('current') - expect(json_data).to include('first') - expect(json_data).to include('last') - end - end - - describe 'page with large number of statuses' do - statuses = [] - - before do - 30.times do - statuses << Fabricate(:status, account: user.account, text: 'Hello world', visibility: :public) - end - - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :private) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :unlisted) - Fabricate(:status, account: user.account, text: 'Hello world', visibility: :direct) - - get :show, params: { id: user.account.id, max_id: statuses.last.id + 1 } - end - - it 'returns http success' do - expect(response).to have_http_status(:success) - end - - it 'sets Content-Type header to AS2' do - expect(response.header['Content-Type']).to include 'application/activity+json' - end - - it 'returns AS2 JSON body' do - json_data = JSON.parse(response.body) - expect(json_data).to include('@context' => 'https://www.w3.org/ns/activitystreams') - expect(json_data).to include('id' => @request.url) - expect(json_data).to include('type' => 'OrderedCollectionPage') - expect(json_data).to include('partOf') - expect(json_data).to include('items') - expect(json_data['items'].length).to eq(20) - expect(json_data).to include('prev') - expect(json_data).to include('next') - expect(json_data).to include('current') - expect(json_data).to include('first') - expect(json_data).to include('last') - end - end - end -end diff --git a/spec/controllers/well_known/webfinger_controller_spec.rb b/spec/controllers/well_known/webfinger_controller_spec.rb index 3699efb56..466f87c45 100644 --- a/spec/controllers/well_known/webfinger_controller_spec.rb +++ b/spec/controllers/well_known/webfinger_controller_spec.rb @@ -9,7 +9,7 @@ describe WellKnown::WebfingerController, type: :controller do end before do - alice.private_key = < - - acct:alice@cb6e6126.ngrok.io - https://cb6e6126.ngrok.io/@alice - https://cb6e6126.ngrok.io/users/alice - - - - - - -XML + expect(xml.at_xpath('//xmlns:Subject').content).to eq 'acct:alice@cb6e6126.ngrok.io' + expect(xml.xpath('//xmlns:Alias').map(&:content)).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice') end it 'returns http not found when account cannot be found' do @@ -80,19 +74,22 @@ XML end it 'returns JSON when account can be found with alternate domains' do - Rails.configuration.x.alternate_domains = ["foo.org"] - username, domain = alice.to_webfinger_s.split("@") + Rails.configuration.x.alternate_domains = ['foo.org'] + username, = alice.to_webfinger_s.split('@') get :show, params: { resource: "#{username}@foo.org" }, format: :json + json = body_as_json + expect(response).to have_http_status(:success) expect(response.content_type).to eq 'application/jrd+json' - expect(response.body).to eq "{\"subject\":\"acct:alice@cb6e6126.ngrok.io\",\"aliases\":[\"https://cb6e6126.ngrok.io/@alice\",\"https://cb6e6126.ngrok.io/users/alice\"],\"links\":[{\"rel\":\"http://webfinger.net/rel/profile-page\",\"type\":\"text/html\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"http://schemas.google.com/g/2010#updates-from\",\"type\":\"application/atom+xml\",\"href\":\"https://cb6e6126.ngrok.io/users/alice.atom\"},{\"rel\":\"self\",\"type\":\"application/activity+json\",\"href\":\"https://cb6e6126.ngrok.io/@alice\"},{\"rel\":\"salmon\",\"href\":\"#{api_salmon_url(alice.id)}\"},{\"rel\":\"magic-public-key\",\"href\":\"data:application/magic-public-key,RSA.x4D6DyZa3zGa1XLhd_VG1bLGvK-Dmyz93WJfWNezIKeSuJkmA0f2NmoOfLUoumq9szN2Xt0GLDX06tDajdYPPXgLtDG0o1qqTrIJ7UTyYhbo94Wotl9iJvEwa5IjP1Mn00YJ_KvFrzKCm15PC7up6r-NtHsqoYS8X1KAqcbnptU=.AQAB\"},{\"rel\":\"http://ostatus.org/schema/1.0/subscribe\",\"template\":\"https://cb6e6126.ngrok.io/authorize_follow?acct={uri}\"}]}" + expect(json[:subject]).to eq 'acct:alice@cb6e6126.ngrok.io' + expect(json[:aliases]).to include('https://cb6e6126.ngrok.io/@alice', 'https://cb6e6126.ngrok.io/users/alice') end it 'returns http not found when account can not be found with alternate domains' do - Rails.configuration.x.alternate_domains = ["foo.org"] - username, domain = alice.to_webfinger_s.split("@") + Rails.configuration.x.alternate_domains = ['foo.org'] + username, = alice.to_webfinger_s.split('@') get :show, params: { resource: "#{username}@bar.org" }, format: :json diff --git a/spec/helpers/activitystreams2_builder_helper_spec.rb b/spec/helpers/activitystreams2_builder_helper_spec.rb deleted file mode 100644 index 612ce6ad2..000000000 --- a/spec/helpers/activitystreams2_builder_helper_spec.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require 'rails_helper' - -describe Activitystreams2BuilderHelper, type: :helper do - it 'returns display name if present' do - account = Fabricate(:account, display_name: 'display name', username: 'username') - expect(account_name(account)).to eq 'display name' - end - - it 'returns username if display name is not present' do - account = Fabricate(:account, display_name: '', username: 'username') - expect(account_name(account)).to eq 'username' - end -end -- cgit From 05cd37097c134d559550adca6cd17cf7578be94b Mon Sep 17 00:00:00 2001 From: ThibG Date: Sat, 15 Jul 2017 17:24:35 +0200 Subject: Optimize uri normalization (#4212) * Add dependency on idn-ruby to speed up URI normalization * Use normalized_host instead of normalize.host when applicable When we are only interested in the normalized host, calling normalized_host avoids normalizing the other components of the URI as well as creating a new object --- Gemfile | 1 + Gemfile.lock | 2 ++ app/lib/tag_manager.rb | 2 +- app/services/concerns/author_extractor.rb | 2 +- app/services/fetch_remote_status_service.rb | 4 ++-- app/workers/pubsubhubbub/delivery_worker.rb | 2 +- 6 files changed, 8 insertions(+), 5 deletions(-) (limited to 'app') diff --git a/Gemfile b/Gemfile index 5d5ddfae1..a6c2b2d65 100644 --- a/Gemfile +++ b/Gemfile @@ -36,6 +36,7 @@ gem 'htmlentities', '~> 4.3' gem 'http', '~> 2.2' gem 'http_accept_language', '~> 2.1' gem 'httplog', '~> 0.99' +gem 'idn-ruby', require: 'idn' gem 'kaminari', '~> 1.0' gem 'link_header', '~> 0.0' gem 'mime-types', '~> 3.1' diff --git a/Gemfile.lock b/Gemfile.lock index daef3e1ad..f637c9bbe 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -208,6 +208,7 @@ GEM parser (>= 2.2.3.0) rainbow (~> 2.2) terminal-table (>= 1.5.1) + idn-ruby (0.1.0) jmespath (1.3.1) json (2.1.0) jsonapi-renderer (0.1.2) @@ -528,6 +529,7 @@ DEPENDENCIES http_accept_language (~> 2.1) httplog (~> 0.99) i18n-tasks (~> 0.9) + idn-ruby kaminari (~> 1.0) letter_opener (~> 1.4) letter_opener_web (~> 1.3) diff --git a/app/lib/tag_manager.rb b/app/lib/tag_manager.rb index f1a2234dc..5f87a2a48 100644 --- a/app/lib/tag_manager.rb +++ b/app/lib/tag_manager.rb @@ -70,7 +70,7 @@ class TagManager uri = Addressable::URI.new uri.host = domain.gsub(/[\/]/, '') - uri.normalize.host + uri.normalized_host end def same_acct?(canonical, needle) diff --git a/app/services/concerns/author_extractor.rb b/app/services/concerns/author_extractor.rb index 00fe1c663..867d6dc25 100644 --- a/app/services/concerns/author_extractor.rb +++ b/app/services/concerns/author_extractor.rb @@ -14,7 +14,7 @@ module AuthorExtractor return nil if username.blank? || uri.blank? - domain = Addressable::URI.parse(uri).normalize.host + domain = Addressable::URI.parse(uri).normalized_host acct = "#{username}@#{domain}" end diff --git a/app/services/fetch_remote_status_service.rb b/app/services/fetch_remote_status_service.rb index 4cfd33d90..6ac31e4d8 100644 --- a/app/services/fetch_remote_status_service.rb +++ b/app/services/fetch_remote_status_service.rb @@ -24,7 +24,7 @@ class FetchRemoteStatusService < BaseService xml.encoding = 'utf-8' account = author_from_xml(xml.at_xpath('/xmlns:entry', xmlns: TagManager::XMLNS)) - domain = Addressable::URI.parse(url).normalize.host + domain = Addressable::URI.parse(url).normalized_host return nil unless !account.nil? && confirmed_domain?(domain, account) @@ -39,6 +39,6 @@ class FetchRemoteStatusService < BaseService end def confirmed_domain?(domain, account) - account.domain.nil? || domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url).normalize.host).zero? + account.domain.nil? || domain.casecmp(account.domain).zero? || domain.casecmp(Addressable::URI.parse(account.remote_url).normalized_host).zero? end end diff --git a/app/workers/pubsubhubbub/delivery_worker.rb b/app/workers/pubsubhubbub/delivery_worker.rb index 05d160cf7..2e1101b93 100644 --- a/app/workers/pubsubhubbub/delivery_worker.rb +++ b/app/workers/pubsubhubbub/delivery_worker.rb @@ -43,7 +43,7 @@ class Pubsubhubbub::DeliveryWorker end def host - Addressable::URI.parse(subscription.callback_url).normalize.host + Addressable::URI.parse(subscription.callback_url).normalized_host end def headers -- cgit From 695439775eacea081c7257aabab39d0ec6b492dc Mon Sep 17 00:00:00 2001 From: unarist Date: Sun, 16 Jul 2017 00:25:04 +0900 Subject: Fix column swiping (#4211) This fixes broken behavior and enable animation only on swiping. --- .../mastodon/features/ui/components/columns_area.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) (limited to 'app') diff --git a/app/javascript/mastodon/features/ui/components/columns_area.js b/app/javascript/mastodon/features/ui/components/columns_area.js index ae3f9261b..515c377b9 100644 --- a/app/javascript/mastodon/features/ui/components/columns_area.js +++ b/app/javascript/mastodon/features/ui/components/columns_area.js @@ -33,8 +33,22 @@ export default class ColumnsArea extends ImmutablePureComponent { children: PropTypes.node, }; + state = { + shouldAnimate: false, + } + + componentWillReceiveProps() { + this.setState({ shouldAnimate: false }); + } + + componentDidMount() { + this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); + } + componentDidUpdate() { this.lastIndex = getIndex(this.context.router.history.location.pathname); + this.setState({ shouldAnimate: true }); } handleSwipe = (index) => { @@ -74,9 +88,10 @@ export default class ColumnsArea extends ImmutablePureComponent { render () { const { columns, children, singleColumn } = this.props; + const { shouldAnimate } = this.state; const columnIndex = getIndex(this.context.router.history.location.pathname); - const shouldAnimate = Math.abs(this.lastIndex - columnIndex) === 1; + this.pendingIndex = null; if (singleColumn) { return columnIndex !== -1 ? ( -- cgit