defmodule Twittertex do
@moduledoc """
Twittertex formats the raw JSON of a tweet into HTML by reconstructing pretty
links and so on from the entities embedded.
"""
import Phoenix.HTML
import Phoenix.HTML.Link
@type opt :: {:link_opts, Keyword.t()}
@type opts :: [opt()]
@doc """
Formats a tweet (classic or extended) into HTML.
Returns a String of HTML.
"""
@spec format_tweet(%{}) :: String.t()
@spec format_tweet(%{}, opts()) :: String.t()
def format_tweet(tweet, opts \\ []) do
case tweet["text"] do
nil -> format_extended_tweet(tweet, opts)
_ -> format_classic_tweet(tweet, opts)
end
end
@doc """
Formats a classic (non-extended) tweet into HTML.
Returns a String of HTML.
"""
@spec format_classic_tweet(%{}) :: String.t()
@spec format_classic_tweet(%{}, opts()) :: String.t()
def format_classic_tweet(tweet, opts \\ []) do
text = tweet["text"]
format_tweet(tweet, text, opts)
end
@doc """
Formats an extended tweet into HTML.
Returns a String of HTML.
"""
@spec format_extended_tweet(%{}, opts()) :: String.t()
def format_extended_tweet(tweet, opts \\ []) do
text = tweet["full_text"]
format_tweet(tweet, text, opts)
end
defp format_tweet(tweet, text, opts) do
typed_entities = type_entities(tweet["entities"])
format_entities(text, typed_entities, opts) |> format_linebreaks()
end
defp format_linebreaks(text) do
text |> String.replace("\n", "<br />")
end
defp format_entities(text, [], _opts) do
text
end
defp format_entities(text, [{type, entity} | entities], opts) do
{text, position, offset} =
case type do
"urls" -> format_url_entity(text, entity, opts)
"user_mentions" -> format_user_mention(text, entity, opts)
"media" -> format_media_entity(text, entity, opts)
"hashtags" -> format_hashtag(text, entity, opts)
_ -> {text, 0, 0}
end
entities = adjust_indices(entities, position, offset)
format_entities(text, entities, opts)
end
defp format_user_mention(text, entity, opts) do
{start, finish} = extract_indices(entity)
username = Map.fetch!(entity, "screen_name")
link_opts = build_link_opts(opts, "https://twitter.com/#{username}")
l = link("@#{username}", link_opts) |> safe_to_string()
splice(text, start, finish, l)
end
defp format_hashtag(text, entity, opts) do
{start, finish} = extract_indices(entity)
hashtag = Map.fetch!(entity, "text")
link_opts = build_link_opts(opts, "https://twitter.com/hashtag/#{hashtag}")
l = link("##{hashtag}", link_opts) |> safe_to_string()
splice(text, start, finish, l)
end
defp format_url_entity(text, entity, opts) do
{start, finish} = extract_indices(entity)
display_url = Map.fetch!(entity, "display_url")
expanded_url = Map.fetch!(entity, "expanded_url")
link_opts = build_link_opts(opts, expanded_url)
l = link(display_url, link_opts) |> safe_to_string()
splice(text, start, finish, l)
end
defp format_media_entity(text, entity, opts) do
format_url_entity(text, entity, opts)
end
defp splice(text, start, finish, new_text) do
length = finish - start
# For some reason Twitter actually uses codepoint indexes for indices, not
# graphemes.
codepoints = String.codepoints(text)
{remaining, suffix} = Enum.split(codepoints, finish)
{prefix, _} = Enum.split(remaining, start)
text = Enum.join(prefix ++ String.codepoints(new_text) ++ suffix)
offset = String.length(new_text) - length
{text, start, offset}
end
defp extract_indices(entity) do
case Map.get(entity, "indices") do
nil ->
nil
indices ->
start = Enum.at(indices, 0)
finish = Enum.at(indices, 1)
{start, finish}
end
end
defp adjust_indices(entities, position, offset) do
Enum.map(entities, fn {type, entity} ->
entity =
case extract_indices(entity) do
nil ->
entity
{start, finish} ->
if start > position do
Map.put(entity, "indices", [start + offset, finish + offset])
else
entity
end
end
{type, entity}
end)
end
defp type_entities(entities) do
keys = Map.keys(entities)
Enum.flat_map(keys, fn k ->
Map.fetch!(entities, k) |> Enum.map(fn e -> {k, e} end)
end)
end
defp build_link_opts(opts, to) do
opts
|> Keyword.get(:link_opts, [])
|> Keyword.put(:to, to)
end
end