From 81cec35dbf1b348d23363559e3f4e6b1ec3415c5 Mon Sep 17 00:00:00 2001 From: Eugen Rochko Date: Tue, 19 Sep 2017 02:42:40 +0200 Subject: [PATCH] Custom emoji (#4988) * Custom emoji - In OStatus: `` - In ActivityPub: `{ type: "Emoji", name: ":coolcat:", href: "http://..." }` - In REST API: Status object includes `emojis` array (`shortcode`, `url`) - Domain blocks with reject media stop emojis - Emoji file up to 50KB - Web UI handles custom emojis - Static pages render custom emojis as `` tags Side effects: - Undo #4500 optimization, as I needed to modify it to restore shortcode handling in emojify() - Formatter#plaintext should now make sure stripped out line-breaks and paragraphs are replaced with newlines * Fix emoji at the start not being converted --- app/javascript/mastodon/emoji.js | 60 +++++++++----- app/javascript/mastodon/reducers/statuses.js | 9 +- app/lib/activitypub/activity/create.rb | 13 +++ app/lib/formatter.rb | 54 +++++++++++- app/lib/ostatus/activity/creation.rb | 20 +++++ app/lib/ostatus/atom_serializer.rb | 4 + app/models/custom_emoji.rb | 38 +++++++++ app/models/status.rb | 4 + .../activitypub/note_serializer.rb | 20 ++++- app/serializers/rest/status_serializer.rb | 11 +++ .../stream_entries/_detailed_status.html.haml | 2 +- .../stream_entries/_simple_status.html.haml | 2 +- .../20170917153509_create_custom_emojis.rb | 13 +++ db/schema.rb | 14 +++- spec/fabricators/custom_emoji_fabricator.rb | 5 ++ spec/fixtures/files/emojo.png | Bin 0 -> 29814 bytes spec/lib/activitypub/activity/create_spec.rb | 25 ++++++ spec/lib/formatter_spec.rb | 78 ++++++++++++++++++ spec/lib/ostatus/atom_serializer_spec.rb | 16 +++- spec/models/custom_emoji_spec.rb | 25 ++++++ 20 files changed, 382 insertions(+), 31 deletions(-) create mode 100644 app/models/custom_emoji.rb create mode 100644 db/migrate/20170917153509_create_custom_emojis.rb create mode 100644 spec/fabricators/custom_emoji_fabricator.rb create mode 100644 spec/fixtures/files/emojo.png create mode 100644 spec/models/custom_emoji_spec.rb diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index a41dfdd1d..865b85b61 100644 --- a/app/javascript/mastodon/emoji.js +++ b/app/javascript/mastodon/emoji.js @@ -3,28 +3,48 @@ import Trie from 'substring-trie'; const trie = new Trie(Object.keys(unicodeMapping)); -const emojify = str => { - let rtn = ''; - for (;;) { - let match, i = 0; - while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) { - i += str.codePointAt(i) < 65536 ? 1 : 2; - } - if (i === str.length) - break; - else if (str[i] === '<') { - let tagend = str.indexOf('>', i + 1) + 1; - if (!tagend) - break; - rtn += str.slice(0, tagend); - str = str.slice(tagend); - } else { - const [filename, shortCode] = unicodeMapping[match]; - rtn += str.slice(0, i) + `${match}`; - str = str.slice(i + match.length); +const emojify = (str, customEmojis = {}) => { + // This walks through the string from start to end, ignoring any tags (

,
, etc.) + // and replacing valid unicode strings + // that _aren't_ within tags with an version. + // 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 customEmojis) { + const replacement = `${shortname}`; + 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--; + } + insideShortname = false; + } else 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 unicodeMapping) { + const [filename, shortCode] = unicodeMapping[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 + } } } - return rtn + str; + return str; }; export default emojify; diff --git a/app/javascript/mastodon/reducers/statuses.js b/app/javascript/mastodon/reducers/statuses.js index 7f906bef6..38b23504e 100644 --- a/app/javascript/mastodon/reducers/statuses.js +++ b/app/javascript/mastodon/reducers/statuses.js @@ -58,9 +58,14 @@ const normalizeStatus = (state, status) => { } const searchContent = [status.spoiler_text, status.content].join(' ').replace(/
/g, '\n').replace(/<\/p>

/g, '\n\n'); + const emojiMap = normalStatus.emojis.reduce((obj, emoji) => { + obj[`:${emoji.shortcode}:`] = emoji.url; + return obj; + }, {}); + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; - normalStatus.contentHtml = emojify(normalStatus.content); - normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || '')); + normalStatus.contentHtml = emojify(normalStatus.content, emojiMap); + normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap); return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus))); }; diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index 894759d9a..41f2b0bad 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -61,6 +61,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity process_hashtag tag, status when 'Mention' process_mention tag, status + when 'Emoji' + process_emoji tag, status end end end @@ -79,6 +81,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity account.mentions.create(status: status) end + def process_emoji(tag, _status) + shortcode = tag['name'].delete(':') + emoji = CustomEmoji.find_by(shortcode: shortcode, domain: @account.domain) + + return if !emoji.nil? || skip_download? + + emoji = CustomEmoji.new(domain: @account.domain, shortcode: shortcode) + emoji.image_remote_url = tag['href'] + emoji.save + end + def process_attachments(status) return unless @object['attachment'].is_a?(Array) diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 575830190..29fea27de 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -9,7 +9,7 @@ class Formatter include ActionView::Helpers::TextHelper - def format(status) + def format(status, options = {}) if status.reblog? prepend_reblog = status.reblog.account.acct status = status.proper @@ -19,7 +19,11 @@ class Formatter raw_content = status.text - return reformat(raw_content) unless status.local? + unless status.local? + html = reformat(raw_content) + html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify] + return html + end linkable_accounts = status.mentions.map(&:account) linkable_accounts << status.account @@ -27,6 +31,7 @@ class Formatter html = raw_content html = "RT @#{prepend_reblog} #{html}" if prepend_reblog html = encode_and_link_urls(html, linkable_accounts) + html = encode_custom_emojis(html, status.emojis) if options[:custom_emojify] html = simple_format(html, {}, sanitize: false) html = html.delete("\n") @@ -39,7 +44,9 @@ class Formatter def plaintext(status) return status.text if status.local? - strip_tags(status.text) + + text = status.text.gsub(/(
|
|<\/p>)+/) { |match| "#{match}\n" } + strip_tags(text) end def simplified_format(account) @@ -76,6 +83,47 @@ class Formatter end end + def encode_custom_emojis(html, emojis) + return html if emojis.empty? + + emoji_map = emojis.map { |e| [e.shortcode, full_asset_url(e.image.url)] }.to_h + + i = -1 + inside_tag = false + inside_shortname = false + shortname_start_index = -1 + + while i + 1 < html.size + i += 1 + + if inside_shortname && html[i] == ':' + shortcode = html[shortname_start_index + 1..i - 1] + emoji = emoji_map[shortcode] + + if emoji + replacement = "\":#{shortcode}:\"" + before_html = shortname_start_index.positive? ? html[0..shortname_start_index - 1] : '' + html = before_html + replacement + html[i + 1..-1] + i += replacement.size - (shortcode.size + 2) - 1 + else + i -= 1 + end + + inside_shortname = false + elsif inside_tag && html[i] == '>' + inside_tag = false + elsif html[i] == '<' + inside_tag = true + inside_shortname = false + elsif !inside_tag && html[i] == ':' + inside_shortname = true + shortname_start_index = i + end + end + + html + end + def rewrite(text, entities) chars = text.to_s.to_char_a diff --git a/app/lib/ostatus/activity/creation.rb b/app/lib/ostatus/activity/creation.rb index 1a23c9efa..d3f1629c4 100644 --- a/app/lib/ostatus/activity/creation.rb +++ b/app/lib/ostatus/activity/creation.rb @@ -42,6 +42,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base save_mentions(status) save_hashtags(status) save_media(status) + save_emojis(status) end if thread? && status.thread.nil? @@ -150,6 +151,25 @@ class OStatus::Activity::Creation < OStatus::Activity::Base end end + def save_emojis(parent) + do_not_download = DomainBlock.find_by(domain: parent.account.domain)&.reject_media? + + return if do_not_download + + @xml.xpath('./xmlns:link[@rel="emoji"]', xmlns: TagManager::XMLNS).each do |link| + next unless link['href'] && link['name'] + + shortcode = link['name'].delete(':') + emoji = CustomEmoji.find_by(shortcode: shortcode, domain: parent.account.domain) + + next unless emoji.nil? + + emoji = CustomEmoji.new(shortcode: shortcode, domain: parent.account.domain) + emoji.image_remote_url = link['href'] + emoji.save + end + end + def account_from_href(href) url = Addressable::URI.parse(href).normalize diff --git a/app/lib/ostatus/atom_serializer.rb b/app/lib/ostatus/atom_serializer.rb index b8e22a381..a6a5cb0c4 100644 --- a/app/lib/ostatus/atom_serializer.rb +++ b/app/lib/ostatus/atom_serializer.rb @@ -368,5 +368,9 @@ class OStatus::AtomSerializer end append_element(entry, 'mastodon:scope', status.visibility) + + status.emojis.each do |emoji| + append_element(entry, 'link', nil, rel: :emoji, href: full_asset_url(emoji.image.url), name: emoji.shortcode) + end end end diff --git a/app/models/custom_emoji.rb b/app/models/custom_emoji.rb new file mode 100644 index 000000000..f4d3b16a0 --- /dev/null +++ b/app/models/custom_emoji.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true +# == Schema Information +# +# Table name: custom_emojis +# +# id :integer not null, primary key +# shortcode :string default(""), not null +# domain :string +# image_file_name :string +# image_content_type :string +# image_file_size :integer +# image_updated_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# + +class CustomEmoji < ApplicationRecord + SHORTCODE_RE_FRAGMENT = '[a-zA-Z0-9_]{2,}' + + SCAN_RE = /(?<=[^[:alnum:]:]|\n|^) + :(#{SHORTCODE_RE_FRAGMENT}): + (?=[^[:alnum:]:]|$)/x + + has_attached_file :image + + validates_attachment :image, content_type: { content_type: 'image/png' }, presence: true, size: { in: 0..50.kilobytes } + validates :shortcode, uniqueness: { scope: :domain }, format: { with: /\A#{SHORTCODE_RE_FRAGMENT}\z/ }, length: { minimum: 2 } + + include Remotable + + class << self + def from_text(text, domain) + return [] if text.blank? + shortcodes = text.scan(SCAN_RE).map(&:first) + where(shortcode: shortcodes, domain: domain) + end + end +end diff --git a/app/models/status.rb b/app/models/status.rb index 2a2cdcf6e..326d128d6 100644 --- a/app/models/status.rb +++ b/app/models/status.rb @@ -131,6 +131,10 @@ class Status < ApplicationRecord !sensitive? && media_attachments.any? end + def emojis + CustomEmoji.from_text(text, account.domain) + end + after_create :store_uri, if: :local? before_validation :prepare_contents, if: :local? diff --git a/app/serializers/activitypub/note_serializer.rb b/app/serializers/activitypub/note_serializer.rb index 166214eee..e5d8e3f03 100644 --- a/app/serializers/activitypub/note_serializer.rb +++ b/app/serializers/activitypub/note_serializer.rb @@ -57,7 +57,7 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer end def virtual_tags - object.mentions + object.tags + object.mentions + object.tags + object.emojis end def atom_uri @@ -137,4 +137,22 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer "##{object.name}" end end + + class CustomEmojiSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :type, :href, :name + + def type + 'Emoji' + end + + def href + full_asset_url(object.image.url) + end + + def name + ":#{object.shortcode}:" + end + end end diff --git a/app/serializers/rest/status_serializer.rb b/app/serializers/rest/status_serializer.rb index 298a3bb40..d8efa8e60 100644 --- a/app/serializers/rest/status_serializer.rb +++ b/app/serializers/rest/status_serializer.rb @@ -17,6 +17,7 @@ class REST::StatusSerializer < ActiveModel::Serializer has_many :media_attachments, serializer: REST::MediaAttachmentSerializer has_many :mentions has_many :tags + has_many :emojis def current_user? !current_user.nil? @@ -106,4 +107,14 @@ class REST::StatusSerializer < ActiveModel::Serializer tag_url(object) end end + + class CustomEmojiSerializer < ActiveModel::Serializer + include RoutingHelper + + attributes :shortcode, :url + + def url + full_asset_url(object.image.url) + end + end end diff --git a/app/views/stream_entries/_detailed_status.html.haml b/app/views/stream_entries/_detailed_status.html.haml index dd9456260..692d5a6d5 100644 --- a/app/views/stream_entries/_detailed_status.html.haml +++ b/app/views/stream_entries/_detailed_status.html.haml @@ -17,7 +17,7 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary> #{status.spoiler_text}  %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true) - if !status.media_attachments.empty? - if status.media_attachments.first.video? diff --git a/app/views/stream_entries/_simple_status.html.haml b/app/views/stream_entries/_simple_status.html.haml index 55aa97f32..f9a530d38 100644 --- a/app/views/stream_entries/_simple_status.html.haml +++ b/app/views/stream_entries/_simple_status.html.haml @@ -18,7 +18,7 @@ %p{ style: 'margin-bottom: 0' }< %span.p-summary> #{status.spoiler_text}  %a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') - .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status) + .e-content{ lang: status.language, style: "display: #{status.spoiler_text? ? 'none' : 'block'}; direction: #{rtl_status?(status) ? 'rtl' : 'ltr'}" }= Formatter.instance.format(status, custom_emojify: true) - unless status.media_attachments.empty? - if status.media_attachments.first.video? diff --git a/db/migrate/20170917153509_create_custom_emojis.rb b/db/migrate/20170917153509_create_custom_emojis.rb new file mode 100644 index 000000000..4040c8312 --- /dev/null +++ b/db/migrate/20170917153509_create_custom_emojis.rb @@ -0,0 +1,13 @@ +class CreateCustomEmojis < ActiveRecord::Migration[5.1] + def change + create_table :custom_emojis do |t| + t.string :shortcode, null: false, default: '' + t.string :domain + t.attachment :image + + t.timestamps + end + + add_index :custom_emojis, [:shortcode, :domain], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index f2ca2af69..9f42d46dd 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: 20170913000752) do +ActiveRecord::Schema.define(version: 20170917153509) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -89,6 +89,18 @@ ActiveRecord::Schema.define(version: 20170913000752) do t.index ["uri"], name: "index_conversations_on_uri", unique: true end + create_table "custom_emojis", force: :cascade do |t| + t.string "shortcode", default: "", null: false + t.string "domain" + t.string "image_file_name" + t.string "image_content_type" + t.integer "image_file_size" + t.datetime "image_updated_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["shortcode", "domain"], name: "index_custom_emojis_on_shortcode_and_domain", unique: true + end + create_table "domain_blocks", id: :serial, force: :cascade do |t| t.string "domain", default: "", null: false t.datetime "created_at", null: false diff --git a/spec/fabricators/custom_emoji_fabricator.rb b/spec/fabricators/custom_emoji_fabricator.rb new file mode 100644 index 000000000..18a7d23dc --- /dev/null +++ b/spec/fabricators/custom_emoji_fabricator.rb @@ -0,0 +1,5 @@ +Fabricator(:custom_emoji) do + shortcode 'coolcat' + domain nil + image { File.open(Rails.root.join('spec', 'fixtures', 'files', 'emojo.png')) } +end diff --git a/spec/fixtures/files/emojo.png b/spec/fixtures/files/emojo.png new file mode 100644 index 0000000000000000000000000000000000000000..cb5993499f059fa4118504aee1e71c9181810e3e GIT binary patch literal 29814 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 literal 0 HcmV?d00001 diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index fcb044ebc..1a9520f04 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -17,6 +17,7 @@ RSpec.describe ActivityPub::Activity::Create do before do stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) + stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) end describe '#perform' do @@ -217,5 +218,29 @@ RSpec.describe ActivityPub::Activity::Create do expect(status.tags.map(&:name)).to include('test') end end + + context 'with emojis' do + let(:object_json) do + { + id: 'bar', + type: 'Note', + content: 'Lorem ipsum :tinking:', + tag: [ + { + type: 'Emoji', + href: 'http://example.com/emoji.png', + name: 'tinking', + }, + ], + } + end + + it 'creates status' do + status = sender.statuses.first + + expect(status).to_not be_nil + expect(status.emojis.map(&:shortcode)).to include('tinking') + end + end end end diff --git a/spec/lib/formatter_spec.rb b/spec/lib/formatter_spec.rb index b714b317a..71b6b78d2 100644 --- a/spec/lib/formatter_spec.rb +++ b/spec/lib/formatter_spec.rb @@ -223,6 +223,45 @@ RSpec.describe Formatter do include_examples 'encode and link URLs' end + + context 'with custom_emojify option' do + let!(:emoji) { Fabricate(:custom_emoji) } + let(:status) { Fabricate(:status, account: local_account, text: text) } + + subject { Formatter.instance.format(status, custom_emojify: true) } + + context 'with emoji at the start' do + let(:text) { ':coolcat: Beep boop' } + + it 'converts shortcode to image tag' do + is_expected.to match(/

:coolcat::coolcat: Beep boop
' } + + it 'converts shortcode to image tag' do + is_expected.to match(/

:coolcat:Beep :coolcat: boop

' } + + it 'converts shortcode to image tag' do + is_expected.to match(/Beep :coolcat::coolcat::coolcat:

' } + + it 'does not touch the shortcodes' do + is_expected.to match(/

:coolcat::coolcat:<\/p>/) + end + end + + context 'with emoji at the end' do + let(:text) { '

Beep boop
:coolcat:

' } + + it 'converts shortcode to image tag' do + is_expected.to match(/
:coolcat:Hello :coolcat:

' } + + it 'returns records used via shortcodes in text' do + is_expected.to include(emojo) + end + end + end +end