mirror of
https://github.com/iv-org/invidious.git
synced 2026-08-16 21:10:53 -05:00
Render custom channel emoji in comments and descriptions
Custom channel emoji are only carried in the attachment runs. The text content just holds the ":shortcode:" placeholder, which was rendered as-is because parse_description only handled commandRuns. Parse attachmentRuns alongside commandRuns, walking both in positional order since they index into the same string, and substitute the image. Standard unicode emoji are sent as attachment runs too, but their text content is already the emoji character and renders fine, so those are left as text rather than proxying an image for every one of them.
This commit is contained in:
parent
8fde720f94
commit
f18762bcde
103
spec/invidious/videos/description_spec.cr
Normal file
103
spec/invidious/videos/description_spec.cr
Normal file
@ -0,0 +1,103 @@
|
||||
require "../../parsers_helper.cr"
|
||||
|
||||
Spectator.describe "parse_description" do
|
||||
it "renders custom channel emoji as an image" do
|
||||
# Custom emoji only exist in the attachment run; the text content carries
|
||||
# the ":shortcode:" placeholder, which must not be shown as-is.
|
||||
raw = {
|
||||
"content" => ":face-red-heart-shape: hello",
|
||||
"attachmentRuns" => [
|
||||
{
|
||||
"startIndex" => 0,
|
||||
"length" => 22,
|
||||
"element" => {
|
||||
"type" => {
|
||||
"imageType" => {
|
||||
"image" => {
|
||||
"sources" => [
|
||||
{"url" => "https://lh3.googleusercontent.com/abc=s16-w24-h24-c-k-nd", "width" => 16, "height" => 16},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"properties" => {
|
||||
"accessibilityProperties" => {"label" => "face-red-heart-shape"},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = parse_description(JSON.parse(raw.to_json), "")
|
||||
|
||||
expect(result).to eq(
|
||||
%(<img alt="face-red-heart-shape" src="/ggpht/abc=s16-w24-h24-c-k-nd" ) +
|
||||
%(title="face-red-heart-shape" width="16" height="16" class="channel-emoji" /> hello)
|
||||
)
|
||||
end
|
||||
|
||||
it "leaves standard unicode emoji untouched" do
|
||||
# Standard emoji also come with an attachment run, but the text content is
|
||||
# already the emoji character, so it must be kept as text.
|
||||
raw = {
|
||||
"content" => "Nice sharing 🎉",
|
||||
"attachmentRuns" => [
|
||||
{
|
||||
"startIndex" => 13,
|
||||
"length" => 2,
|
||||
"element" => {
|
||||
"type" => {
|
||||
"imageType" => {
|
||||
"image" => {
|
||||
"sources" => [
|
||||
{"url" => "https://www.youtube.com/s/gaming/emoji/7ff574f2/emoji_u1f389.png", "width" => 16, "height" => 16},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"properties" => {
|
||||
"accessibilityProperties" => {"label" => "🎉"},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = parse_description(JSON.parse(raw.to_json), "")
|
||||
|
||||
expect(result).to eq("Nice sharing 🎉")
|
||||
end
|
||||
|
||||
it "keeps text offsets correct when an emoji precedes other content" do
|
||||
# The emoji run is consumed from the same iterator as the surrounding
|
||||
# text, so a wrong length here would shift everything after it.
|
||||
raw = {
|
||||
"content" => "a:face-red-heart-shape:b",
|
||||
"attachmentRuns" => [
|
||||
{
|
||||
"startIndex" => 1,
|
||||
"length" => 22,
|
||||
"element" => {
|
||||
"type" => {
|
||||
"imageType" => {
|
||||
"image" => {
|
||||
"sources" => [
|
||||
{"url" => "https://lh3.googleusercontent.com/abc", "width" => 16, "height" => 16},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
"properties" => {
|
||||
"accessibilityProperties" => {"label" => "face-red-heart-shape"},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
result = parse_description(JSON.parse(raw.to_json), "")
|
||||
|
||||
expect(result).to start_with("a<img ")
|
||||
expect(result).to end_with("/>b")
|
||||
end
|
||||
end
|
||||
@ -40,6 +40,37 @@ private def copy_string(str : String::Builder, iter : Iterator, count : Int) : I
|
||||
return copied
|
||||
end
|
||||
|
||||
# Custom channel emoji are sent as an attachment run holding the image, while
|
||||
# the text content only carries a ":shortcode:" placeholder. If the image is
|
||||
# not substituted in, that raw placeholder is what gets displayed.
|
||||
#
|
||||
# Standard Unicode emoji are sent as attachment runs too, but their text
|
||||
# content is the emoji character itself, which already renders correctly. Those
|
||||
# are left as-is, so we don't proxy an image for every single emoji.
|
||||
private def attachment_run_to_html(run : JSON::Any, text : String) : String
|
||||
return text if !(text.starts_with?(':') && text.ends_with?(':') && text.size > 2)
|
||||
|
||||
source = run.dig?("element", "type", "imageType", "image", "sources", 0)
|
||||
return text if source.nil?
|
||||
|
||||
url = source["url"]?.try &.as_s
|
||||
return text if url.nil?
|
||||
|
||||
label = run.dig?("element", "properties", "accessibilityProperties", "label")
|
||||
.try &.as_s || text
|
||||
width = source["width"]?.try &.as_i? || 16
|
||||
height = source["height"]?.try &.as_i? || 16
|
||||
|
||||
return String.build do |str|
|
||||
str << %(<img alt=") << HTML.escape(label) << %(" )
|
||||
str << %(src="/ggpht) << URI.parse(url).request_target << %(" )
|
||||
str << %(title=") << HTML.escape(label) << %(" )
|
||||
str << %(width=") << width << %(" )
|
||||
str << %(height=") << height << %(" )
|
||||
str << %(class="channel-emoji" />)
|
||||
end
|
||||
end
|
||||
|
||||
def parse_description(desc, video_id : String) : String?
|
||||
return "" if desc.nil?
|
||||
|
||||
@ -47,7 +78,9 @@ def parse_description(desc, video_id : String) : String?
|
||||
return "" if content.empty?
|
||||
|
||||
commands = desc["commandRuns"]?.try &.as_a
|
||||
if commands.nil?
|
||||
attachments = desc["attachmentRuns"]?.try &.as_a
|
||||
|
||||
if commands.nil? && attachments.nil?
|
||||
# Slightly faster than HTML.escape, as we're only doing one pass on
|
||||
# the string instead of five for the standard library
|
||||
return String.build do |str|
|
||||
@ -56,6 +89,17 @@ def parse_description(desc, video_id : String) : String?
|
||||
end
|
||||
end
|
||||
|
||||
# Both kinds of run index into the same string, so they have to be walked
|
||||
# together in positional order.
|
||||
runs = [] of {Int32, Int32, Bool, JSON::Any}
|
||||
commands.try &.each do |command|
|
||||
runs << {command["startIndex"].as_i, command["length"].as_i, false, command}
|
||||
end
|
||||
attachments.try &.each do |attachment|
|
||||
runs << {attachment["startIndex"].as_i, attachment["length"].as_i, true, attachment}
|
||||
end
|
||||
runs.sort_by! { |run| run[0] }
|
||||
|
||||
# Not everything is stored in UTF-8 on youtube's side. The SMP codepoints
|
||||
# (0x10000 and above) are encoded as UTF-16 surrogate pairs, which are
|
||||
# automatically decoded by the JSON parser. It means that we need to count
|
||||
@ -65,26 +109,32 @@ def parse_description(desc, video_id : String) : String?
|
||||
index = 0
|
||||
|
||||
return String.build do |str|
|
||||
commands.each do |command|
|
||||
cmd_start = command["startIndex"].as_i
|
||||
cmd_length = command["length"].as_i
|
||||
runs.each do |(run_start, run_length, is_attachment, run)|
|
||||
# A command and an attachment can cover the same characters. The
|
||||
# iterator can only move forward, so skip anything already consumed.
|
||||
next if run_start < index
|
||||
|
||||
# Copy the text chunk between this command and the previous if needed.
|
||||
length = cmd_start - index
|
||||
# Copy the text chunk between this run and the previous if needed.
|
||||
length = run_start - index
|
||||
index += copy_string(str, iter, length)
|
||||
|
||||
# We need to copy the command's text using the iterator
|
||||
# We need to copy the run's text using the iterator
|
||||
# and the special function defined above.
|
||||
cmd_content = String.build(cmd_length) do |str2|
|
||||
copy_string(str2, iter, cmd_length)
|
||||
run_content = String.build(run_length) do |str2|
|
||||
copy_string(str2, iter, run_length)
|
||||
end
|
||||
|
||||
link = cmd_content
|
||||
if on_tap = command.dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, cmd_content, video_id)
|
||||
if is_attachment
|
||||
str << attachment_run_to_html(run, run_content)
|
||||
else
|
||||
link = run_content
|
||||
if on_tap = run.dig?("onTap", "innertubeCommand")
|
||||
link = parse_link_endpoint(on_tap, run_content, video_id)
|
||||
end
|
||||
str << link
|
||||
index += cmd_length
|
||||
end
|
||||
|
||||
index += run_length
|
||||
end
|
||||
|
||||
# Copy the end of the string (past the last command).
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user