diff options
author | Eugen Rochko <eugen@zeonfederated.com> | 2017-09-19 02:42:40 +0200 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-09-19 02:42:40 +0200 |
commit | 81cec35dbf1b348d23363559e3f4e6b1ec3415c5 (patch) | |
tree | d449c3c19a8147bd1bc44fc703a1fccb9b9b894d /app | |
parent | c155d843f4d523d52ce4ce67491578385c06fd1b (diff) |
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 (limited to 'app')
-rw-r--r-- | app/javascript/mastodon/emoji.js | 60 | ||||
-rw-r--r-- | app/javascript/mastodon/reducers/statuses.js | 9 | ||||
-rw-r--r-- | app/lib/activitypub/activity/create.rb | 13 | ||||
-rw-r--r-- | app/lib/formatter.rb | 54 | ||||
-rw-r--r-- | app/lib/ostatus/activity/creation.rb | 20 | ||||
-rw-r--r-- | app/lib/ostatus/atom_serializer.rb | 4 | ||||
-rw-r--r-- | app/models/custom_emoji.rb | 38 | ||||
-rw-r--r-- | app/models/status.rb | 4 | ||||
-rw-r--r-- | app/serializers/activitypub/note_serializer.rb | 20 | ||||
-rw-r--r-- | app/serializers/rest/status_serializer.rb | 11 | ||||
-rw-r--r-- | app/views/stream_entries/_detailed_status.html.haml | 2 | ||||
-rw-r--r-- | app/views/stream_entries/_simple_status.html.haml | 2 |
12 files changed, 209 insertions, 28 deletions
diff --git a/app/javascript/mastodon/emoji.js b/app/javascript/mastodon/emoji.js index a41dfdd1d..865b85b61 100644 --- 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 index 7f906bef6..38b23504e 100644 --- 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 index 894759d9a..41f2b0bad 100644 --- 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 index 575830190..29fea27de 100644 --- 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 index 1a23c9efa..d3f1629c4 100644 --- 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 index b8e22a381..a6a5cb0c4 100644 --- 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 new file mode 100644 index 000000000..f4d3b16a0 --- /dev/null +++ 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 index 2a2cdcf6e..326d128d6 100644 --- 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 index 166214eee..e5d8e3f03 100644 --- 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 index 298a3bb40..d8efa8e60 100644 --- 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 index dd9456260..692d5a6d5 100644 --- 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 index 55aa97f32..f9a530d38 100644 --- 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? |