defmodule ClacksWeb.FrontendView do use ClacksWeb, :view alias Clacks.{Actor, Activity, Repo, Notification, Object} alias ClacksWeb.Router.Helpers, as: Routes alias ClacksWeb.Endpoint require Logger @spec display_username(actor :: Actor.t()) :: String.t() def display_username(%Actor{local: true, data: %{"preferredUsername" => username}}) do "@" <> username end def display_username(%Actor{ local: false, ap_id: ap_id, data: %{"preferredUsername" => username} }) do %URI{host: host} = URI.parse(ap_id) "@" <> username <> "@" <> host end @spec display_name(actor :: Actor.t()) :: String.t() def display_name(%Actor{data: %{"name" => name, "preferredUsername" => username}}) do if is_binary(name) && String.length(name) > 0 do name else username end end def local_actor_link(%Actor{local: true, ap_id: ap_id}), do: ap_id def local_actor_link(%Actor{local: false, id: id}), do: Routes.frontend_path(Endpoint, :actor, id) @spec display_timestamp( datetime :: String.t() | DateTime.t() | NaiveDateTime.t() | Activity.t() ) :: String.t() def display_timestamp(str) when is_binary(str) do display_timestamp(Timex.parse!(str, "{ISO:Extended}")) end def display_timestamp(%Activity{data: data, inserted_at: inserted_at}) do display_timestamp(Map.get(data, "published", inserted_at)) end def display_timestamp(%{__struct__: struct} = datetime) when struct == DateTime or struct == NaiveDateTime do diff = Timex.diff(Timex.now(), datetime, :seconds) cond do diff < 60 -> # less than a minute, seconds "#{diff}sec" diff < 60 * 60 -> # less than an hour, minutes "#{Integer.floor_div(diff, 60)}min" diff < 60 * 60 * 24 -> # less than a day, hours "#{Integer.floor_div(diff, 60 * 60)}hr" diff < 60 * 60 * 24 * 7 -> # less than a week, days "#{Integer.floor_div(diff, 60 * 60 * 24)}d" diff < 60 * 60 * 24 * 30 -> # less than a month(ish), weeks "#{Integer.floor_div(diff, 60 * 60 * 24 * 7)}wk" diff < 60 * 60 * 24 * 365 -> # less than a year, months(ish) # todo: figure out actually how many months "#{Integer.floor_div(diff, 60 * 60 * 24 * 30)}mo" true -> Timex.format!(datetime, "%F", :strftime) end end @spec iso_datetime(datetime :: String.t() | DateTime.t() | NaiveDateTime.t() | Activity.t()) :: String.t() def iso_datetime(str) when is_binary(str) do str end def iso_datetime(%Activity{data: data, inserted_at: inserted_at}) do iso_datetime(Map.get(data, "published", inserted_at)) end def iso_datetime(%{__struct__: struct} = datetime) when struct == DateTime or struct == NaiveDateTime do Timex.format!(datetime, "{ISO:Extended:Z}") end @spec prev_page_path(conn :: Plug.Conn.t(), [ Activity.t() | {Activity.t(), Actor.t()} | Notification.t() ]) :: String.t() | nil def prev_page_path(conn, activities) do if Map.has_key?(conn.query_params, "max_id") do Phoenix.Controller.current_path(conn, %{ since_id: activities |> List.first() |> timeline_id() }) else nil end end @spec next_page_path(conn :: Plug.Conn.t(), [ Activity.t() | {Activity.t(), Actor.t()} | Notification.t() ]) :: String.t() | nil def next_page_path(conn, activities) do if length(activities) < 20 do nil else Phoenix.Controller.current_path(conn, %{ max_id: activities |> List.last() |> timeline_id() }) end end defp timeline_id(%Activity{id: id}), do: id defp timeline_id({%Activity{id: id}, _}), do: id defp timeline_id({%Activity{id: id}, _, _}), do: id defp timeline_id(%Notification{id: id}), do: id @spec mentions_for_replying_to(Activity.t()) :: String.t() defp mentions_for_replying_to(conn, %Activity{ object: %Object{data: %{"actor" => actor, "tag" => tags}} }) do current_user = conn.assigns[:user] |> Repo.preload(:actor) tag_actors = tags |> Enum.filter(fn %{"type" => "Mention"} -> true _ -> false end) |> Enum.map(fn %{"href" => ap_id} -> ap_id end) actors = [actor | tag_actors] |> List.delete(current_user.actor.ap_id) |> Enum.uniq() mentions = actors |> Enum.map(fn ap_id -> actor = Actor.get_cached_by_ap_id(ap_id) "@#{actor.nickname}" end) case mentions do [] -> "" _ -> Enum.join(mentions, " ") <> "" end end defp mentions_for_replying_to(_), do: "" @spec render_status_content(activity :: Activity.t()) :: String.t() defp render_status_content(%Activity{ object: %Object{data: %{"type" => "Note", "content" => content} = note} }) do with %{"tag" => tags} <- note, {:ok, tree} <- Floki.parse_fragment(content) do tree |> Floki.traverse_and_update(fn {"a", attrs, _children} = orig_tree -> {"href", href} = Enum.find(attrs, fn {name, _} -> name == "href" end) has_matching_tag = Enum.any?(tags, fn %{"type" => "Mention", "href" => ^href} -> true _ -> false end) with true <- has_matching_tag, %Actor{local: false} = actor <- Actor.get_cached_by_ap_id(href) do { "span", [], [ orig_tree, {"a", [{"href", local_actor_link(actor)}, {"class", "local-actor-link"}], ["🔗"]} ] } else _ -> orig_tree end tree -> tree end) |> Floki.raw_html() # remove the and from the floki rendered output |> String.slice(6..-8) |> raw() else _ -> content end end end