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
This commit is contained in:
Eugen Rochko 2017-09-19 02:42:40 +02:00 committed by GitHub
parent c155d843f4
commit 81cec35dbf
20 changed files with 382 additions and 31 deletions

View file

@ -3,28 +3,48 @@ import Trie from 'substring-trie';
const trie = new Trie(Object.keys(unicodeMapping)); const trie = new Trie(Object.keys(unicodeMapping));
const emojify = str => { const emojify = (str, customEmojis = {}) => {
let rtn = ''; // This walks through the string from start to end, ignoring any tags (<p>, <br>, etc.)
for (;;) { // and replacing valid unicode strings
let match, i = 0; // that _aren't_ within tags with an <img> version.
while (i < str.length && str[i] !== '<' && !(match = trie.search(str.slice(i)))) { // The goal is to be the same as an emojione.regUnicode replacement, but faster.
i += str.codePointAt(i) < 65536 ? 1 : 2; let i = -1;
} let insideTag = false;
if (i === str.length) let insideShortname = false;
break; let shortnameStartIndex = -1;
else if (str[i] === '<') { let match;
let tagend = str.indexOf('>', i + 1) + 1; while (++i < str.length) {
if (!tagend) const char = str.charAt(i);
break; if (insideShortname && char === ':') {
rtn += str.slice(0, tagend); const shortname = str.substring(shortnameStartIndex, i + 1);
str = str.slice(tagend); if (shortname in customEmojis) {
} else { const replacement = `<img draggable="false" class="emojione" alt="${shortname}" title="${shortname}" src="${customEmojis[shortname]}" />`;
const [filename, shortCode] = unicodeMapping[match]; str = str.substring(0, shortnameStartIndex) + replacement + str.substring(i + 1);
rtn += str.slice(0, i) + `<img draggable="false" class="emojione" alt="${match}" title=":${shortCode}:" src="/emoji/${filename}.svg" />`; i += (replacement.length - shortname.length - 1); // jump ahead the length we've added to the string
str = str.slice(i + match.length); } 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; export default emojify;

View file

@ -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 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.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent;
normalStatus.contentHtml = emojify(normalStatus.content); normalStatus.contentHtml = emojify(normalStatus.content, emojiMap);
normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || '')); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(normalStatus.spoiler_text || ''), emojiMap);
return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus))); return state.update(status.id, ImmutableMap(), map => map.mergeDeep(fromJS(normalStatus)));
}; };

View file

@ -61,6 +61,8 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
process_hashtag tag, status process_hashtag tag, status
when 'Mention' when 'Mention'
process_mention tag, status process_mention tag, status
when 'Emoji'
process_emoji tag, status
end end
end end
end end
@ -79,6 +81,17 @@ class ActivityPub::Activity::Create < ActivityPub::Activity
account.mentions.create(status: status) account.mentions.create(status: status)
end 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) def process_attachments(status)
return unless @object['attachment'].is_a?(Array) return unless @object['attachment'].is_a?(Array)

View file

@ -9,7 +9,7 @@ class Formatter
include ActionView::Helpers::TextHelper include ActionView::Helpers::TextHelper
def format(status) def format(status, options = {})
if status.reblog? if status.reblog?
prepend_reblog = status.reblog.account.acct prepend_reblog = status.reblog.account.acct
status = status.proper status = status.proper
@ -19,7 +19,11 @@ class Formatter
raw_content = status.text 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.mentions.map(&:account)
linkable_accounts << status.account linkable_accounts << status.account
@ -27,6 +31,7 @@ class Formatter
html = raw_content html = raw_content
html = "RT @#{prepend_reblog} #{html}" if prepend_reblog html = "RT @#{prepend_reblog} #{html}" if prepend_reblog
html = encode_and_link_urls(html, linkable_accounts) 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 = simple_format(html, {}, sanitize: false)
html = html.delete("\n") html = html.delete("\n")
@ -39,7 +44,9 @@ class Formatter
def plaintext(status) def plaintext(status)
return status.text if status.local? return status.text if status.local?
strip_tags(status.text)
text = status.text.gsub(/(<br \/>|<br>|<\/p>)+/) { |match| "#{match}\n" }
strip_tags(text)
end end
def simplified_format(account) def simplified_format(account)
@ -76,6 +83,47 @@ class Formatter
end end
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) def rewrite(text, entities)
chars = text.to_s.to_char_a chars = text.to_s.to_char_a

View file

@ -42,6 +42,7 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
save_mentions(status) save_mentions(status)
save_hashtags(status) save_hashtags(status)
save_media(status) save_media(status)
save_emojis(status)
end end
if thread? && status.thread.nil? if thread? && status.thread.nil?
@ -150,6 +151,25 @@ class OStatus::Activity::Creation < OStatus::Activity::Base
end end
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) def account_from_href(href)
url = Addressable::URI.parse(href).normalize url = Addressable::URI.parse(href).normalize

View file

@ -368,5 +368,9 @@ class OStatus::AtomSerializer
end end
append_element(entry, 'mastodon:scope', status.visibility) 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
end end

View file

@ -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

View file

@ -131,6 +131,10 @@ class Status < ApplicationRecord
!sensitive? && media_attachments.any? !sensitive? && media_attachments.any?
end end
def emojis
CustomEmoji.from_text(text, account.domain)
end
after_create :store_uri, if: :local? after_create :store_uri, if: :local?
before_validation :prepare_contents, if: :local? before_validation :prepare_contents, if: :local?

View file

@ -57,7 +57,7 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
end end
def virtual_tags def virtual_tags
object.mentions + object.tags object.mentions + object.tags + object.emojis
end end
def atom_uri def atom_uri
@ -137,4 +137,22 @@ class ActivityPub::NoteSerializer < ActiveModel::Serializer
"##{object.name}" "##{object.name}"
end end
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 end

View file

@ -17,6 +17,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
has_many :media_attachments, serializer: REST::MediaAttachmentSerializer has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :mentions has_many :mentions
has_many :tags has_many :tags
has_many :emojis
def current_user? def current_user?
!current_user.nil? !current_user.nil?
@ -106,4 +107,14 @@ class REST::StatusSerializer < ActiveModel::Serializer
tag_url(object) tag_url(object)
end end
end end
class CustomEmojiSerializer < ActiveModel::Serializer
include RoutingHelper
attributes :shortcode, :url
def url
full_asset_url(object.image.url)
end
end
end end

View file

@ -17,7 +17,7 @@
%p{ style: 'margin-bottom: 0' }< %p{ style: 'margin-bottom: 0' }<
%span.p-summary> #{status.spoiler_text}&nbsp; %span.p-summary> #{status.spoiler_text}&nbsp;
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') %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.empty?
- if status.media_attachments.first.video? - if status.media_attachments.first.video?

View file

@ -18,7 +18,7 @@
%p{ style: 'margin-bottom: 0' }< %p{ style: 'margin-bottom: 0' }<
%span.p-summary> #{status.spoiler_text}&nbsp; %span.p-summary> #{status.spoiler_text}&nbsp;
%a.status__content__spoiler-link{ href: '#' }= t('statuses.show_more') %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? - unless status.media_attachments.empty?
- if status.media_attachments.first.video? - if status.media_attachments.first.video?

View file

@ -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

View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -89,6 +89,18 @@ ActiveRecord::Schema.define(version: 20170913000752) do
t.index ["uri"], name: "index_conversations_on_uri", unique: true t.index ["uri"], name: "index_conversations_on_uri", unique: true
end 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| create_table "domain_blocks", id: :serial, force: :cascade do |t|
t.string "domain", default: "", null: false t.string "domain", default: "", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false

View file

@ -0,0 +1,5 @@
Fabricator(:custom_emoji) do
shortcode 'coolcat'
domain nil
image { File.open(Rails.root.join('spec', 'fixtures', 'files', 'emojo.png')) }
end

BIN
spec/fixtures/files/emojo.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -17,6 +17,7 @@ RSpec.describe ActivityPub::Activity::Create do
before do before do
stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) 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 end
describe '#perform' do describe '#perform' do
@ -217,5 +218,29 @@ RSpec.describe ActivityPub::Activity::Create do
expect(status.tags.map(&:name)).to include('test') expect(status.tags.map(&:name)).to include('test')
end end
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
end end

View file

@ -223,6 +223,45 @@ RSpec.describe Formatter do
include_examples 'encode and link URLs' include_examples 'encode and link URLs'
end 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 end
context 'with remote status' do context 'with remote status' do
@ -231,6 +270,45 @@ RSpec.describe Formatter do
it 'reformats' do it 'reformats' do
is_expected.to eq 'Beep boop' is_expected.to eq 'Beep boop'
end 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
end end

View file

@ -97,11 +97,23 @@ RSpec.describe OStatus::AtomSerializer do
mentioned = element.nodes.find do |node| mentioned = element.nodes.find do |node|
node.name == 'link' && node.name == 'link' &&
node[:rel] == 'mentioned' && node[:rel] == 'mentioned' &&
node['ostatus:object-type'] == TagManager::TYPES[:person] node['ostatus:object-type'] == TagManager::TYPES[:person]
end end
expect(mentioned[:href]).to eq 'https://cb6e6126.ngrok.io/users/username' expect(mentioned[:href]).to eq 'https://cb6e6126.ngrok.io/users/username'
end 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 end
describe 'render' do describe 'render' do

View file

@ -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