mirror of
https://github.com/funamitech/mastodon
synced 2024-11-23 22:57:05 +09:00
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
@ -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;
|
||||
|
@ -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)));
|
||||
};
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
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?
|
||||
end
|
||||
|
||||
def emojis
|
||||
CustomEmoji.from_text(text, account.domain)
|
||||
end
|
||||
|
||||
after_create :store_uri, if: :local?
|
||||
|
||||
before_validation :prepare_contents, if: :local?
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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?
|
||||
|
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.
|
||||
|
||||
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
|
||||
|
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
|
||||
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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
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 New Issue
Block a user