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
This commit is contained in:
		| @@ -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 | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
|   | ||||
| @@ -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} | ||||
|   | ||||
| @@ -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; | ||||
|       } | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -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; | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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]) | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										5
									
								
								db/migrate/20180410204633_add_fields_to_accounts.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								db/migrate/20180410204633_add_fields_to_accounts.rb
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| class AddFieldsToAccounts < ActiveRecord::Migration[5.1] | ||||
|   def change | ||||
|     add_column :accounts, :fields, :jsonb | ||||
|   end | ||||
| end | ||||
| @@ -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" | ||||
|   | ||||
| @@ -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 | ||||
|   | ||||
		Reference in New Issue
	
	Block a user