commit: 81cec35dbf1b348d23363559e3f4e6b1ec3415c5
parent: c155d843f4d523d52ce4ce67491578385c06fd1b
Author: Eugen Rochko <eugen@zeonfederated.com>
Date: Tue, 19 Sep 2017 02:42:40 +0200
Custom emoji (#4988)
* Custom emoji
- In OStatus: `<link rel="emoji" name="coolcat" href="http://..." />`
- 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 `<img />` 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
Diffstat:
20 files changed, 382 insertions(+), 31 deletions(-)
diff --git 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) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
- str = str.slice(i + match.length);
+const emojify = (str, customEmojis = {}) => {
+ // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
+ // and replacing valid unicode strings
+ // that _aren't_ within tags with an <img> 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 = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[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 = `<img draggable="false" class="emojione" alt="${alt}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`;
+ 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
@@ -58,9 +58,14 @@ const normalizeStatus = (state, status) => {
}
const searchContent = [status.spoiler_text, status.content].join(' ').replace(/<br \/>/g, '\n').replace(/<\/p><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
@@ -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
@@ -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(/(<br \/>|<br>|<\/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 = "<img draggable=\"false\" class=\"emojione\" alt=\":#{shortcode}:\" title=\":#{shortcode}:\" src=\"#{emoji}\" />"
+ 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
Binary files differ.
diff --git 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
@@ -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(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
+ end
+ end
+
+ context 'with emoji in the middle' do
+ let(:text) { 'Beep :coolcat: boop' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
+ end
+ end
+
+ context 'with concatenated emoji' do
+ let(:text) { ':coolcat::coolcat:' }
+
+ it 'does not touch the shortcodes' do
+ is_expected.to match(/:coolcat::coolcat:/)
+ 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(/boop <img draggable="false" class="emojione" alt=":coolcat:"/)
+ end
+ end
+ end
end
context 'with remote status' do
@@ -231,6 +270,45 @@ RSpec.describe Formatter do
it 'reformats' do
is_expected.to eq 'Beep boop'
end
+
+ context 'with custom_emojify option' do
+ let!(:emoji) { Fabricate(:custom_emoji, domain: remote_account.domain) }
+ let(:status) { Fabricate(:status, account: remote_account, text: text) }
+
+ subject { Formatter.instance.format(status, custom_emojify: true) }
+
+ context 'with emoji at the start' do
+ let(:text) { '<p>:coolcat: Beep boop<br />' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/<p><img draggable="false" class="emojione" alt=":coolcat:"/)
+ end
+ end
+
+ context 'with emoji in the middle' do
+ let(:text) { '<p>Beep :coolcat: boop</p>' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/Beep <img draggable="false" class="emojione" alt=":coolcat:"/)
+ end
+ end
+
+ context 'with concatenated emoji' do
+ let(:text) { '<p>:coolcat::coolcat:</p>' }
+
+ it 'does not touch the shortcodes' do
+ is_expected.to match(/<p>:coolcat::coolcat:<\/p>/)
+ end
+ end
+
+ context 'with emoji at the end' do
+ let(:text) { '<p>Beep boop<br />:coolcat:</p>' }
+
+ it 'converts shortcode to image tag' do
+ is_expected.to match(/<br><img draggable="false" class="emojione" alt=":coolcat:"/)
+ end
+ end
+ end
end
end
diff --git a/spec/lib/ostatus/atom_serializer_spec.rb b/spec/lib/ostatus/atom_serializer_spec.rb
@@ -97,11 +97,23 @@ RSpec.describe OStatus::AtomSerializer do
mentioned = element.nodes.find do |node|
node.name == 'link' &&
- node[:rel] == 'mentioned' &&
- node['ostatus:object-type'] == TagManager::TYPES[:person]
+ node[:rel] == 'mentioned' &&
+ node['ostatus:object-type'] == TagManager::TYPES[:person]
end
+
expect(mentioned[:href]).to eq 'https://cb6e6126.ngrok.io/users/username'
end
+
+ it 'appends link elements for emojis' do
+ Fabricate(:custom_emoji)
+
+ status = Fabricate(:status, text: ':coolcat:')
+ element = serialize(status)
+ emoji = element.nodes.find { |node| node.name == 'link' && node[:rel] == 'emoji' }
+
+ expect(emoji[:name]).to eq 'coolcat'
+ expect(emoji[:href]).to_not be_blank
+ end
end
describe 'render' do
diff --git a/spec/models/custom_emoji_spec.rb b/spec/models/custom_emoji_spec.rb
@@ -0,0 +1,25 @@
+require 'rails_helper'
+
+RSpec.describe CustomEmoji, type: :model do
+ describe '.from_text' do
+ let!(:emojo) { Fabricate(:custom_emoji) }
+
+ subject { described_class.from_text(text, nil) }
+
+ context 'with plain text' do
+ let(:text) { 'Hello :coolcat:' }
+
+ it 'returns records used via shortcodes in text' do
+ is_expected.to include(emojo)
+ end
+ end
+
+ context 'with html' do
+ let(:text) { '<p>Hello :coolcat:</p>' }
+
+ it 'returns records used via shortcodes in text' do
+ is_expected.to include(emojo)
+ end
+ end
+ end
+end