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;
}
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)));
};
process_hashtag tag, status
when 'Mention'
process_mention tag, status
+ when 'Emoji'
+ process_emoji tag, status
end
end
end
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)
include ActionView::Helpers::TextHelper
- def format(status)
+ def format(status, options = {})
if status.reblog?
prepend_reblog = status.reblog.account.acct
status = status.proper
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
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")
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)
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
save_mentions(status)
save_hashtags(status)
save_media(status)
+ save_emojis(status)
end
if thread? && status.thread.nil?
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
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
--- /dev/null
+# 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
!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?
end
def virtual_tags
- object.mentions + object.tags
+ object.mentions + object.tags + object.emojis
end
def atom_uri
"##{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
has_many :media_attachments, serializer: REST::MediaAttachmentSerializer
has_many :mentions
has_many :tags
+ has_many :emojis
def current_user?
!current_user.nil?
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
%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?
%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?
--- /dev/null
+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
#
# 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"
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
--- /dev/null
+Fabricator(:custom_emoji) do
+ shortcode 'coolcat'
+ domain nil
+ image { File.open(Rails.root.join('spec', 'fixtures', 'files', 'emojo.png')) }
+end
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
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
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
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
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
--- /dev/null
+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