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:
parent
c155d843f4
commit
81cec35dbf
20 changed files with 382 additions and 31 deletions
|
@ -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;
|
||||||
|
|
|
@ -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)));
|
||||||
};
|
};
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
38
app/models/custom_emoji.rb
Normal file
38
app/models/custom_emoji.rb
Normal 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
|
|
@ -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?
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
%p{ style: 'margin-bottom: 0' }<
|
%p{ style: 'margin-bottom: 0' }<
|
||||||
%span.p-summary> #{status.spoiler_text}
|
%span.p-summary> #{status.spoiler_text}
|
||||||
%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?
|
||||||
|
|
|
@ -18,7 +18,7 @@
|
||||||
%p{ style: 'margin-bottom: 0' }<
|
%p{ style: 'margin-bottom: 0' }<
|
||||||
%span.p-summary> #{status.spoiler_text}
|
%span.p-summary> #{status.spoiler_text}
|
||||||
%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?
|
||||||
|
|
13
db/migrate/20170917153509_create_custom_emojis.rb
Normal file
13
db/migrate/20170917153509_create_custom_emojis.rb
Normal 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
|
14
db/schema.rb
14
db/schema.rb
|
@ -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
|
||||||
|
|
5
spec/fabricators/custom_emoji_fabricator.rb
Normal file
5
spec/fabricators/custom_emoji_fabricator.rb
Normal 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
BIN
spec/fixtures/files/emojo.png
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 29 KiB |
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
25
spec/models/custom_emoji_spec.rb
Normal file
25
spec/models/custom_emoji_spec.rb
Normal 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
|
Loading…
Reference in a new issue