From 78ed4ab75ff77d7cba60d478aa1f45d1c104785d Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Sat, 14 Apr 2018 12:41:08 +0200 Subject: [PATCH] Add bio fields (#6645) * Add bio fields - Fix #3211 - Fix #232 - Fix #121 * Display bio fields in web UI * Fix output of links and missing fields * Federate bio fields over ActivityPub as PropertyValue * Improve how the fields are stored, add to Edit profile form * Add rel=me to links in fields Fix #121 --- .../settings/profiles_controller.rb | 6 ++- .../mastodon/actions/importer/normalizer.js | 8 +++ .../features/account/components/header.js | 14 +++++ app/javascript/styles/mastodon/accounts.scss | 54 +++++++++++++++++++ .../styles/mastodon/components.scss | 37 +++++++++++++ app/javascript/styles/mastodon/forms.scss | 12 +++++ app/lib/activitypub/adapter.rb | 3 ++ app/lib/formatter.rb | 18 +++++-- app/models/account.rb | 36 +++++++++++++ .../activitypub/actor_serializer.rb | 17 ++++++ app/serializers/rest/account_serializer.rb | 10 ++++ .../activitypub/process_account_service.rb | 6 +++ app/views/accounts/_header.html.haml | 8 +++ app/views/settings/profiles/show.html.haml | 10 ++++ config/locales/simple_form.en.yml | 6 +++ .../20180410204633_add_fields_to_accounts.rb | 5 ++ db/schema.rb | 3 +- .../process_account_service_spec.rb | 28 +++++++++- 18 files changed, 274 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20180410204633_add_fields_to_accounts.rb diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb index 28f78a4fb8..5d81668dea 100644 --- a/app/controllers/settings/profiles_controller.rb +++ b/app/controllers/settings/profiles_controller.rb @@ -11,7 +11,9 @@ class Settings::ProfilesController < ApplicationController obfuscate_filename [:account, :avatar] obfuscate_filename [:account, :header] - def show; end + def show + @account.build_fields + end def update if UpdateAccountService.new.call(@account, account_params) @@ -25,7 +27,7 @@ class Settings::ProfilesController < ApplicationController private def account_params - params.require(:account).permit(:display_name, :note, :avatar, :header, :locked) + params.require(:account).permit(:display_name, :note, :avatar, :header, :locked, fields_attributes: [:name, :value]) end def set_account diff --git a/app/javascript/mastodon/actions/importer/normalizer.js b/app/javascript/mastodon/actions/importer/normalizer.js index 1b09f319ff..5f1274fab6 100644 --- a/app/javascript/mastodon/actions/importer/normalizer.js +++ b/app/javascript/mastodon/actions/importer/normalizer.js @@ -10,6 +10,14 @@ export function normalizeAccount(account) { account.display_name_html = emojify(escapeTextContentForBrowser(displayName)); account.note_emojified = emojify(account.note); + if (account.fields) { + account.fields = account.fields.map(pair => ({ + ...pair, + name_emojified: emojify(escapeTextContentForBrowser(pair.name)), + value_emojified: emojify(pair.value), + })); + } + if (account.moved) { account.moved = account.moved.id; } diff --git a/app/javascript/mastodon/features/account/components/header.js b/app/javascript/mastodon/features/account/components/header.js index bb7b3b6329..bbf886dca8 100644 --- a/app/javascript/mastodon/features/account/components/header.js +++ b/app/javascript/mastodon/features/account/components/header.js @@ -130,6 +130,7 @@ export default class Header extends ImmutablePureComponent { const content = { __html: account.get('note_emojified') }; const displayNameHtml = { __html: account.get('display_name_html') }; + const fields = account.get('fields'); return (
@@ -140,6 +141,19 @@ export default class Header extends ImmutablePureComponent { @{account.get('acct')} {lockedIcon}
+ {fields.size > 0 && ( + + + {fields.map((pair, i) => ( + + + ))} + +
+ +
+ )} + {info} {mutingInfo} {actionBtn} diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss index dd82ab375e..0b49da1ade 100644 --- a/app/javascript/styles/mastodon/accounts.scss +++ b/app/javascript/styles/mastodon/accounts.scss @@ -563,3 +563,57 @@ border-color: rgba(lighten($error-red, 12%), 0.5); } } + +.account__header__fields { + border-collapse: collapse; + padding: 0; + margin: 15px -15px -15px; + border: 0 none; + border-top: 1px solid lighten($ui-base-color, 4%); + border-bottom: 1px solid lighten($ui-base-color, 4%); + + th, + td { + padding: 15px; + padding-left: 15px; + border: 0 none; + border-bottom: 1px solid lighten($ui-base-color, 4%); + vertical-align: middle; + } + + th { + padding-left: 15px; + font-weight: 500; + text-align: center; + width: 94px; + color: $ui-secondary-color; + background: rgba(darken($ui-base-color, 8%), 0.5); + } + + td { + color: $ui-primary-color; + text-align: center; + width: 100%; + padding-left: 0; + } + + a { + color: $ui-highlight-color; + text-decoration: none; + + &:hover, + &:focus, + &:active { + text-decoration: underline; + } + } + + tr { + &:last-child { + th, + td { + border-bottom: 0; + } + } + } +} diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index 888a0ad823..96112d84a7 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -5176,3 +5176,40 @@ noscript { background: lighten($ui-highlight-color, 7%); } } + +.account__header .account__header__fields { + font-size: 14px; + line-height: 20px; + overflow: hidden; + border-collapse: collapse; + margin: 20px -10px -20px; + border-bottom: 0; + + tr { + border-top: 1px solid lighten($ui-base-color, 8%); + text-align: center; + } + + th, + td { + padding: 14px 20px; + vertical-align: middle; + max-height: 40px; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + th { + color: $ui-primary-color; + background: darken($ui-base-color, 4%); + max-width: 120px; + font-weight: 500; + } + + td { + flex: auto; + color: $primary-text-color; + background: $ui-base-color; + } +} diff --git a/app/javascript/styles/mastodon/forms.scss b/app/javascript/styles/mastodon/forms.scss index d74c5a4fd0..945579a9cb 100644 --- a/app/javascript/styles/mastodon/forms.scss +++ b/app/javascript/styles/mastodon/forms.scss @@ -15,6 +15,18 @@ code { overflow: hidden; } + .row { + display: flex; + margin: 0 -5px; + + .input { + box-sizing: border-box; + flex: 1 1 auto; + width: 50%; + padding: 0 5px; + } + } + span.hint { display: block; color: $ui-primary-color; diff --git a/app/lib/activitypub/adapter.rb b/app/lib/activitypub/adapter.rb index f19b04ae64..e880499f1a 100644 --- a/app/lib/activitypub/adapter.rb +++ b/app/lib/activitypub/adapter.rb @@ -19,6 +19,9 @@ class ActivityPub::Adapter < ActiveModelSerializers::Adapter::Base 'Emoji' => 'toot:Emoji', 'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' }, 'featured' => 'toot:featured', + 'schema' => 'http://schema.org#', + 'PropertyValue' => 'schema:PropertyValue', + 'value' => 'schema:value', }, ], }.freeze diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index f7e7a3c238..4124f1660c 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -71,6 +71,11 @@ class Formatter html.html_safe # rubocop:disable Rails/OutputSafety end + def format_field(account, str) + return reformat(str).html_safe unless account.local? # rubocop:disable Rails/OutputSafety + encode_and_link_urls(str, me: true).html_safe # rubocop:disable Rails/OutputSafety + end + def linkify(text) html = encode_and_link_urls(text) html = simple_format(html, {}, sanitize: false) @@ -85,12 +90,17 @@ class Formatter HTMLEntities.new.encode(html) end - def encode_and_link_urls(html, accounts = nil) + def encode_and_link_urls(html, accounts = nil, options = {}) entities = Extractor.extract_entities_with_indices(html, extract_url_without_protocol: false) + if accounts.is_a?(Hash) + options = accounts + accounts = nil + end + rewrite(html.dup, entities) do |entity| if entity[:url] - link_to_url(entity) + link_to_url(entity, options) elsif entity[:hashtag] link_to_hashtag(entity) elsif entity[:screen_name] @@ -177,10 +187,12 @@ class Formatter result.flatten.join end - def link_to_url(entity) + def link_to_url(entity, options = {}) url = Addressable::URI.parse(entity[:url]) html_attrs = { target: '_blank', rel: 'nofollow noopener' } + html_attrs[:rel] = "me #{html_attrs[:rel]}" if options[:me] + Twitter::Autolink.send(:link_to_text, entity, link_html(entity[:url]), url, html_attrs) rescue Addressable::URI::InvalidURIError, IDN::Idna::IdnaError encode(entity[:url]) diff --git a/app/models/account.rb b/app/models/account.rb index 5bdcfa99ed..05e817f63d 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -44,6 +44,7 @@ # memorial :boolean default(FALSE), not null # moved_to_account_id :integer # featured_collection_url :string +# fields :jsonb # class Account < ApplicationRecord @@ -189,6 +190,30 @@ class Account < ApplicationRecord @keypair ||= OpenSSL::PKey::RSA.new(private_key || public_key) end + def fields + (self[:fields] || []).map { |f| Field.new(self, f) } + end + + def fields_attributes=(attributes) + fields = [] + + attributes.each_value do |attr| + next if attr[:name].blank? + fields << attr + end + + self[:fields] = fields + end + + def build_fields + return if fields.size >= 4 + + raw_fields = self[:fields] || [] + add_fields = 4 - raw_fields.size + add_fields.times { raw_fields << { name: '', value: '' } } + self.fields = raw_fields + end + def magic_key modulus, exponent = [keypair.public_key.n, keypair.public_key.e].map do |component| result = [] @@ -238,6 +263,17 @@ class Account < ApplicationRecord shared_inbox_url.presence || inbox_url end + class Field < ActiveModelSerializers::Model + attributes :name, :value, :account, :errors + + def initialize(account, attr) + @account = account + @name = attr['name'] + @value = attr['value'] + @errors = {} + end + end + class << self def readonly_attributes super - %w(statuses_count following_count followers_count) diff --git a/app/serializers/activitypub/actor_serializer.rb b/app/serializers/activitypub/actor_serializer.rb index df30907264..fcf3bdf175 100644 --- a/app/serializers/activitypub/actor_serializer.rb +++ b/app/serializers/activitypub/actor_serializer.rb @@ -11,6 +11,7 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer has_one :public_key, serializer: ActivityPub::PublicKeySerializer has_many :virtual_tags, key: :tag + has_many :virtual_attachments, key: :attachment attribute :moved_to, if: :moved? @@ -107,10 +108,26 @@ class ActivityPub::ActorSerializer < ActiveModel::Serializer object.emojis end + def virtual_attachments + object.fields + end + def moved_to ActivityPub::TagManager.instance.uri_for(object.moved_to_account) end class CustomEmojiSerializer < ActivityPub::EmojiSerializer end + + class Account::FieldSerializer < ActiveModel::Serializer + attributes :type, :name, :value + + def type + 'PropertyValue' + end + + def value + Formatter.instance.format_field(object.account, object.value) + end + end end diff --git a/app/serializers/rest/account_serializer.rb b/app/serializers/rest/account_serializer.rb index 6097acda5b..863238eb74 100644 --- a/app/serializers/rest/account_serializer.rb +++ b/app/serializers/rest/account_serializer.rb @@ -9,6 +9,16 @@ class REST::AccountSerializer < ActiveModel::Serializer has_one :moved_to_account, key: :moved, serializer: REST::AccountSerializer, if: :moved_and_not_nested? + class FieldSerializer < ActiveModel::Serializer + attributes :name, :value + + def value + Formatter.instance.format_field(object.account, object.value) + end + end + + has_many :fields + def id object.id.to_s end diff --git a/app/services/activitypub/process_account_service.rb b/app/services/activitypub/process_account_service.rb index 4475a90797..da32f9615f 100644 --- a/app/services/activitypub/process_account_service.rb +++ b/app/services/activitypub/process_account_service.rb @@ -70,6 +70,7 @@ class ActivityPub::ProcessAccountService < BaseService @account.display_name = @json['name'] || '' @account.note = @json['summary'] || '' @account.locked = @json['manuallyApprovesFollowers'] || false + @account.fields = property_values || {} end def set_fetchable_attributes! @@ -126,6 +127,11 @@ class ActivityPub::ProcessAccountService < BaseService end end + def property_values + return unless @json['attachment'].is_a?(Array) + @json['attachment'].select { |attachment| attachment['type'] == 'PropertyValue' }.map { |attachment| attachment.slice('name', 'value') } + end + def mismatching_origin?(url) needle = Addressable::URI.parse(url).host haystack = Addressable::URI.parse(@uri).host diff --git a/app/views/accounts/_header.html.haml b/app/views/accounts/_header.html.haml index b78998e9e4..0d3a0d08d8 100644 --- a/app/views/accounts/_header.html.haml +++ b/app/views/accounts/_header.html.haml @@ -23,6 +23,14 @@ .bio .account__header__content.p-note.emojify= Formatter.instance.simplified_format(account, custom_emojify: true) + - unless account.fields.empty? + %table.account__header__fields + %tbody + - account.fields.each do |field| + %tr + %th.emojify= field.name + %td.emojify= Formatter.instance.format_field(account, field.value) + .details-counters .counter{ class: active_nav_class(short_account_url(account)) } = link_to short_account_url(account), class: 'u-url u-uid' do diff --git a/app/views/settings/profiles/show.html.haml b/app/views/settings/profiles/show.html.haml index 0fc9db2b9f..f28834d72e 100644 --- a/app/views/settings/profiles/show.html.haml +++ b/app/views/settings/profiles/show.html.haml @@ -19,6 +19,16 @@ .fields-group = f.input :locked, as: :boolean, wrapper: :with_label, hint: t('simple_form.hints.defaults.locked') + .fields-group + .input.with_block_label + %label= t('simple_form.labels.defaults.fields') + %span.hint= t('simple_form.hints.defaults.fields') + + = f.simple_fields_for :fields do |fields_f| + .row + = fields_f.input :name, placeholder: t('simple_form.labels.account.fields.name') + = fields_f.input :value, placeholder: t('simple_form.labels.account.fields.value') + .actions = f.button :button, t('generic.save_changes'), type: :submit diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 37a02bde63..1a0d60f710 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -8,6 +8,7 @@ en: display_name: one: 1 character left other: %{count} characters left + fields: You can have up to 4 items displayed as a table on your profile header: PNG, GIF or JPG. At most 2MB. Will be downscaled to 700x335px locked: Requires you to manually approve followers note: @@ -22,6 +23,10 @@ en: user: filtered_languages: Checked languages will be filtered from public timelines for you labels: + account: + fields: + name: Label + value: Content defaults: avatar: Avatar confirm_new_password: Confirm new password @@ -31,6 +36,7 @@ en: display_name: Display name email: E-mail address expires_in: Expire after + fields: Profile metadata filtered_languages: Filtered languages header: Header locale: Language diff --git a/db/migrate/20180410204633_add_fields_to_accounts.rb b/db/migrate/20180410204633_add_fields_to_accounts.rb new file mode 100644 index 0000000000..5b8c17480f --- /dev/null +++ b/db/migrate/20180410204633_add_fields_to_accounts.rb @@ -0,0 +1,5 @@ +class AddFieldsToAccounts < ActiveRecord::Migration[5.1] + def change + add_column :accounts, :fields, :jsonb + end +end diff --git a/db/schema.rb b/db/schema.rb index 218457e657..10a8f2edc5 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: 2018_04_02_040909) do +ActiveRecord::Schema.define(version: 2018_04_10_204633) do # These are extensions that must be enabled in order to support this database enable_extension "pg_stat_statements" @@ -75,6 +75,7 @@ ActiveRecord::Schema.define(version: 2018_04_02_040909) do t.boolean "memorial", default: false, null: false t.bigint "moved_to_account_id" t.string "featured_collection_url" + t.jsonb "fields" t.index "(((setweight(to_tsvector('simple'::regconfig, (display_name)::text), 'A'::\"char\") || setweight(to_tsvector('simple'::regconfig, (username)::text), 'B'::\"char\")) || setweight(to_tsvector('simple'::regconfig, (COALESCE(domain, ''::character varying))::text), 'C'::\"char\")))", name: "search_index", using: :gin t.index "lower((username)::text), lower((domain)::text)", name: "index_accounts_on_username_and_domain_lower" t.index ["uri"], name: "index_accounts_on_uri" diff --git a/spec/services/activitypub/process_account_service_spec.rb b/spec/services/activitypub/process_account_service_spec.rb index 84a74c231f..15e1f4bb29 100644 --- a/spec/services/activitypub/process_account_service_spec.rb +++ b/spec/services/activitypub/process_account_service_spec.rb @@ -1,5 +1,31 @@ require 'rails_helper' RSpec.describe ActivityPub::ProcessAccountService do - pending + subject { described_class.new } + + context 'property values' do + let(:payload) do + { + id: 'https://foo', + type: 'Actor', + inbox: 'https://foo/inbox', + attachment: [ + { type: 'PropertyValue', name: 'Pronouns', value: 'They/them' }, + { type: 'PropertyValue', name: 'Occupation', value: 'Unit test' }, + ], + }.with_indifferent_access + end + + it 'parses out of attachment' do + account = subject.call('alice', 'example.com', payload) + expect(account.fields).to be_a Array + expect(account.fields.size).to eq 2 + expect(account.fields[0]).to be_a Account::Field + expect(account.fields[0].name).to eq 'Pronouns' + expect(account.fields[0].value).to eq 'They/them' + expect(account.fields[1]).to be_a Account::Field + expect(account.fields[1].name).to eq 'Occupation' + expect(account.fields[1].value).to eq 'Unit test' + end + end end