about summary refs log tree commit diff
diff options
context:
space:
mode:
authorEugen Rochko <eugen@zeonfederated.com>2018-04-14 12:41:08 +0200
committerGitHub <noreply@github.com>2018-04-14 12:41:08 +0200
commit78ed4ab75ff77d7cba60d478aa1f45d1c104785d (patch)
tree0aeea69a41847df51ef1f2ef530b3810f460a601
parent85ab30abf7f8da61d37e4711cba350877bfb6f2b (diff)
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
-rw-r--r--app/controllers/settings/profiles_controller.rb6
-rw-r--r--app/javascript/mastodon/actions/importer/normalizer.js8
-rw-r--r--app/javascript/mastodon/features/account/components/header.js14
-rw-r--r--app/javascript/styles/mastodon/accounts.scss54
-rw-r--r--app/javascript/styles/mastodon/components.scss37
-rw-r--r--app/javascript/styles/mastodon/forms.scss12
-rw-r--r--app/lib/activitypub/adapter.rb3
-rw-r--r--app/lib/formatter.rb18
-rw-r--r--app/models/account.rb36
-rw-r--r--app/serializers/activitypub/actor_serializer.rb17
-rw-r--r--app/serializers/rest/account_serializer.rb10
-rw-r--r--app/services/activitypub/process_account_service.rb6
-rw-r--r--app/views/accounts/_header.html.haml8
-rw-r--r--app/views/settings/profiles/show.html.haml10
-rw-r--r--config/locales/simple_form.en.yml6
-rw-r--r--db/migrate/20180410204633_add_fields_to_accounts.rb5
-rw-r--r--db/schema.rb3
-rw-r--r--spec/services/activitypub/process_account_service_spec.rb28
18 files changed, 274 insertions, 7 deletions
diff --git a/app/controllers/settings/profiles_controller.rb b/app/controllers/settings/profiles_controller.rb
index 28f78a4fb..5d81668de 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 1b09f319f..5f1274fab 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 bb7b3b632..bbf886dca 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 (
       <div className={classNames('account__header', { inactive: !!account.get('moved') })} style={{ backgroundImage: `url(${account.get('header')})` }}>
@@ -140,6 +141,19 @@ export default class Header extends ImmutablePureComponent {
           <span className='account__header__username'>@{account.get('acct')} {lockedIcon}</span>
           <div className='account__header__content' dangerouslySetInnerHTML={content} />
 
+          {fields.size > 0 && (
+            <table className='account__header__fields'>
+              <tbody>
+                {fields.map((pair, i) => (
+                  <tr key={i}>
+                    <th dangerouslySetInnerHTML={{ __html: pair.get('name_emojified') }} />
+                    <td dangerouslySetInnerHTML={{ __html: pair.get('value_emojified') }} />
+                  </tr>
+                ))}
+              </tbody>
+            </table>
+          )}
+
           {info}
           {mutingInfo}
           {actionBtn}
diff --git a/app/javascript/styles/mastodon/accounts.scss b/app/javascript/styles/mastodon/accounts.scss
index dd82ab375..0b49da1ad 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 888a0ad82..96112d84a 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 d74c5a4fd..945579a9c 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 f19b04ae6..e880499f1 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 f7e7a3c23..4124f1660 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 5bdcfa99e..05e817f63 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 df3090726..fcf3bdf17 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 6097acda5..863238eb7 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 4475a9079..da32f9615 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 b78998e9e..0d3a0d08d 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 0fc9db2b9..f28834d72 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 37a02bde6..1a0d60f71 100644
--- a/config/locales/simple_form.en.yml
+++ b/config/locales/simple_form.en.yml
@@ -8,6 +8,7 @@ en:
         display_name:
           one: <span class="name-counter">1</span> character left
           other: <span class="name-counter">%{count}</span> 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 000000000..5b8c17480
--- /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 218457e65..10a8f2edc 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 84a74c231..15e1f4bb2 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