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)
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
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;
}
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')})` }}>
<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}
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;
+ }
+ }
+ }
+}
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;
+ }
+}
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;
'Emoji' => 'toot:Emoji',
'focalPoint' => { '@container' => '@list', '@id' => 'toot:focalPoint' },
'featured' => 'toot:featured',
+ 'schema' => 'http://schema.org#',
+ 'PropertyValue' => 'schema:PropertyValue',
+ 'value' => 'schema:value',
},
],
}.freeze
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)
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]
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])
# memorial :boolean default(FALSE), not null
# moved_to_account_id :integer
# featured_collection_url :string
+# fields :jsonb
#
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 = []
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)
has_one :public_key, serializer: ActivityPub::PublicKeySerializer
has_many :virtual_tags, key: :tag
+ has_many :virtual_attachments, key: :attachment
attribute :moved_to, if: :moved?
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
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
@account.display_name = @json['name'] || ''
@account.note = @json['summary'] || ''
@account.locked = @json['manuallyApprovesFollowers'] || false
+ @account.fields = property_values || {}
end
def set_fetchable_attributes!
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
.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
.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
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:
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
display_name: Display name
email: E-mail address
expires_in: Expire after
+ fields: Profile metadata
filtered_languages: Filtered languages
header: Header
locale: Language
--- /dev/null
+class AddFieldsToAccounts < ActiveRecord::Migration[5.1]
+ def change
+ add_column :accounts, :fields, :jsonb
+ end
+end
#
# 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"
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"
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