Compare commits

...

14 Commits

18 changed files with 537 additions and 73 deletions

View File

@ -22,6 +22,7 @@ button.btn-link {
border: none; border: none;
color: $link-color; color: $link-color;
text-decoration: none; text-decoration: none;
padding: 0;
&:hover { &:hover {
cursor: pointer; cursor: pointer;
@ -29,9 +30,14 @@ button.btn-link {
} }
} }
button[type=submit]:not(.btn-link) { input, button:not(.btn-link) {
box-sizing: border-box;
height: 1.75rem;
}
button:not(.btn-link) {
padding: 0 2rem;
font-family: $sans-serif; font-family: $sans-serif;
padding: 0.25rem 2rem;
background-color: $tint-color; background-color: $tint-color;
border: 1px solid darken($tint-color, 20%); border: 1px solid darken($tint-color, 20%);
color: white; color: white;
@ -56,6 +62,19 @@ h1, h2, h3 {
margin: 0.5rem 0; margin: 0.5rem 0;
} }
.flash-info {
padding: 0.5rem;
background-color: lighten(blue, 35%);
border: 1px solid darken(blue, 20%);
color: darken(blue, 35%);
}
.flash-error {
padding: 0.5rem;
background-color: lighten(red, 30%);
border: 1px solid darken(red, 20%);
color: darken(red, 35%);
}
header { header {
nav { nav {
display: flex; display: flex;
@ -69,6 +88,10 @@ header {
li { li {
display: inline; display: inline;
list-style: none; list-style: none;
&:not(:last-child) {
margin-right: 1rem;
}
} }
} }
} }
@ -141,3 +164,29 @@ ul.status-list {
box-sizing: border-box; box-sizing: border-box;
} }
} }
.search-form {
width: 100%;
display: flex;
input[type=text] {
flex-grow: 1;
margin-right: 1rem;
}
}
.login-form {
.input-group {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
label {
width: 100px;
}
}
}
.actor-actions {
margin-bottom: 1rem;
}

View File

@ -110,6 +110,41 @@ defmodule Clacks.ActivityPub do
} }
end end
@spec follow(
actor :: String.t(),
followee :: String.t(),
published :: NaiveDateTime.t(),
state :: String.t()
) ::
map()
def follow(actor, followee, published \\ DateTime.utc_now(), state \\ "pending") do
%{
"@context" => @context,
"type" => "Follow",
"id" => activity_id(Ecto.UUID.generate()),
"actor" => actor,
"object" => followee,
"context" => context_id(Ecto.UUID.generate()),
"to" => [followee],
"cc" => [@public],
"published" => published |> DateTime.to_iso8601(),
"state" => state
}
end
@spec undo_follow(actor :: String.t(), follow_activity :: map()) :: map()
def undo_follow(actor, %{"object" => followee} = follow_activity) do
%{
"@context" => @context,
"type" => "Undo",
"id" => activity_id(Ecto.UUID.generate()),
"actor" => actor,
"object" => follow_activity,
"to" => [followee],
"cc" => []
}
end
@spec object_id(id :: String.t()) :: String.t() @spec object_id(id :: String.t()) :: String.t()
def object_id(id) do def object_id(id) do
url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url] url = Application.get_env(:clacks, ClacksWeb.Endpoint)[:url]

View File

@ -67,7 +67,7 @@ defmodule Clacks.ActivityPub.Federator do
@public in activity["to"] or @public in activity["cc"] -> @public in activity["to"] or @public in activity["cc"] ->
shared_inbox_for(actor) shared_inbox_for(actor)
actor.data["followers"] in activity["to"] or actor.data["followers"] in activity["cc"] -> actor["followers"] in activity["to"] or actor["followers"] in activity["cc"] ->
shared_inbox_for(actor) shared_inbox_for(actor)
true -> true ->

View File

@ -7,7 +7,7 @@ defmodule Clacks.ActivityPub.Fetcher do
with %{"type" => type, "id" => remote_id} = actor <- fetch(id), with %{"type" => type, "id" => remote_id} = actor <- fetch(id),
"person" <- String.downcase(type), "person" <- String.downcase(type),
%{host: actor_host} when actor_host == id_host <- URI.parse(remote_id) do %{host: ^id_host} <- URI.parse(remote_id) do
actor actor
else else
_ -> _ ->
@ -20,7 +20,7 @@ defmodule Clacks.ActivityPub.Fetcher do
%{host: id_host} = URI.parse(id) %{host: id_host} = URI.parse(id)
with %{"actor" => remote_actor} = object <- fetch(id), with %{"actor" => remote_actor} = object <- fetch(id),
%{host: actor_host} when actor_host == id_host <- URI.parse(remote_actor) do %{host: ^id_host} <- URI.parse(remote_actor) do
object object
else else
_ -> _ ->
@ -40,15 +40,10 @@ defmodule Clacks.ActivityPub.Fetcher do
headers = [Accept: "application/activity+json, application/ld+json"] headers = [Accept: "application/activity+json, application/ld+json"]
opts = [hackney: Application.get_env(:clacks, :hackney_opts, [])] opts = [hackney: Application.get_env(:clacks, :hackney_opts, [])]
with {:ok, %HTTPoison.Response{status_code: status_code, body: body}} with {:ok, %HTTPoison.Response{body: body}} <- Clacks.HTTP.get(uri, headers, opts),
when status_code in 200..299 <-
HTTPoison.get(uri, headers, opts),
{:ok, data} <- Jason.decode(body) do {:ok, data} <- Jason.decode(body) do
data data
else else
{:ok, %HTTPoison.Response{}} ->
nil
{:error, reason} -> {:error, reason} ->
Logger.warn("Couldn't fetch AP object at #{uri}: #{inspect(reason)}") Logger.warn("Couldn't fetch AP object at #{uri}: #{inspect(reason)}")
nil nil

View File

@ -0,0 +1,30 @@
defmodule Clacks.ActivityPub.Helper do
alias Clacks.{Activity, Actor, Repo}
require Logger
@spec save_and_federate(activity_data :: map(), actor :: Actor.t()) ::
{:ok, Activity.t()} | :error
def save_and_federate(activity_data, actor) do
changeset = Activity.changeset_for_creating(activity_data, true)
case Repo.insert(changeset) do
{:error, changeset} ->
Logger.error("Couldn't save activity: #{inspect(changeset)}")
:error
{:ok, activity} ->
worker =
%{id: activity.id, actor_id: actor.id}
|> Clacks.Worker.Federate.new()
case Oban.insert(worker) do
{:ok, _} ->
{:ok, activity}
{:error, changeset} ->
Logger.error("Couldn't save federate job: #{inspect(changeset)}")
:error
end
end
end
end

46
lib/clacks/http.ex Normal file
View File

@ -0,0 +1,46 @@
defmodule Clacks.HTTP do
require Logger
def get(url, headers \\ [], options \\ []) do
case HTTPoison.get(url, headers, options) do
{:ok, %HTTPoison.Response{status_code: status_code} = response}
when status_code in 200..299 ->
{:ok, response}
{:ok, %HTTPoison.Response{status_code: status_code, headers: resp_headers}}
when status_code in [301, 302] ->
resp_headers
|> Enum.find(fn {name, _value} -> String.downcase(name) == "location" end)
|> case do
{_, new_url} ->
new_url =
case URI.parse(new_url) do
%URI{host: nil, path: path} ->
# relative path
%URI{URI.parse(url) | path: path} |> URI.to_string()
uri ->
uri
end
Logger.debug("Got 301 redirect from #{url} to #{new_url}")
get(new_url, headers, options)
_ ->
{:error, "Missing Location header for redirect"}
end
{:ok, %HTTPoison.Response{status_code: 403}} ->
{:error, "403 Forbidden"}
{:ok, %HTTPoison.Response{status_code: 404}} ->
{:error, "404 Not Found"}
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, "HTTP #{status_code}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, reason}
end
end
end

View File

@ -2,15 +2,16 @@ defmodule Clacks.Inbox do
require Logger require Logger
alias Clacks.{Repo, Activity, Object, Actor, ActivityPub} alias Clacks.{Repo, Activity, Object, Actor, ActivityPub}
defp store_activity(%{"actor" => actor} = activity, local \\ false) when is_binary(actor) do defp store_activity(%{"actor" => actor, "id" => ap_id} = activity, local \\ false)
when is_binary(actor) do
changeset = changeset =
Activity.changeset(%Activity{}, %{ Activity.changeset(Activity.get_cached_by_ap_id(ap_id) || %Activity{}, %{
data: activity, data: activity,
local: local, local: local,
actor: actor actor: actor
}) })
Repo.insert(changeset) Repo.insert_or_update(changeset)
end end
@spec handle(activity :: map()) :: :ok | {:error, reason :: any()} @spec handle(activity :: map()) :: :ok | {:error, reason :: any()}
@ -42,12 +43,13 @@ defmodule Clacks.Inbox do
store_activity(activity) store_activity(activity)
changeset = Actor.changeset(followed, %{followers: [follower_id | followed.followers]}) new_followers = [follower_id | followed.followers] |> Enum.uniq()
changeset = Actor.changeset(followed, %{followers: new_followers})
case Repo.update(changeset) do case Repo.update(changeset) do
{:error, changeset} -> {:error, changeset} ->
Logger.error("Couldn't store Follow activity: #{inspect(changeset)}") Logger.error("Couldn't store updated followers: #{inspect(changeset)}")
{:error, "Couldn't store Follow activity"} {:error, "Couldn't store updated followers"}
{:ok, _followed} -> {:ok, _followed} ->
accept = ActivityPub.accept_follow(activity) accept = ActivityPub.accept_follow(activity)
@ -63,8 +65,49 @@ defmodule Clacks.Inbox do
end end
end end
def handle(
%{
"type" => "Accept",
"actor" => followee_id,
"object" => %{"type" => "Follow", "id" => follow_activity_id, "actor" => follower_id}
} = activity
) do
followee = Actor.get_by_ap_id(followee_id)
follower = Actor.get_by_ap_id(follower_id)
store_activity(activity)
follow_activity = Activity.get_cached_by_ap_id(follow_activity_id)
changeset =
Activity.changeset(follow_activity, %{
data: %{follow_activity.data | "state" => "accepted"}
})
case Repo.update(changeset) do
{:error, changeset} ->
Logger.error("Couldn't store updated Follow activity: #{inspect(changeset)}")
{:error, "Couldn't store updated Follow activity"}
{:ok, _follow_activity} ->
new_followers = [follower_id | followee.followers] |> Enum.uniq()
changeset = Actor.changeset(followee, %{followers: new_followers})
case Repo.update(changeset) do
{:error, changeset} ->
Logger.error("Couldn't store updated followers: #{inspect(changeset)}")
{:error, "Couldn't store updated followers"}
{:ok, _followee} ->
:ok
end
end
end
# as a fallback, just store the activity # as a fallback, just store the activity
def handle(activity) do def handle(activity) do
Logger.debug("Unhandled activity: #{inspect(activity)}")
case store_activity(activity) do case store_activity(activity) do
{:error, changeset} -> {:error, changeset} ->
Logger.error("Could not store activity: #{inspect(changeset)}") Logger.error("Could not store activity: #{inspect(changeset)}")

View File

@ -24,6 +24,17 @@ defmodule Clacks.Object do
changeset(%__MODULE__{}, %{data: data}) changeset(%__MODULE__{}, %{data: data})
end end
@spec get_actor(object :: t()) :: Clacks.Actor.t() | nil
def get_actor(object) do
case object.data["actor"] || object.data["attributedTo"] do
nil ->
nil
id when is_binary(id) ->
Clacks.Actor.get_by_ap_id(id)
end
end
@spec get_by_ap_id( @spec get_by_ap_id(
ap_id :: String.t(), ap_id :: String.t(),
force_refetch :: boolean(), force_refetch :: boolean(),
@ -42,8 +53,9 @@ defmodule Clacks.Object do
Repo.one(from o in __MODULE__, where: fragment("?->>'id'", o.data) == ^ap_id) Repo.one(from o in __MODULE__, where: fragment("?->>'id'", o.data) == ^ap_id)
end end
@spec fetch(ap_id :: String.t(), synthesize_create :: boolean()) :: t() | nil @spec fetch(ap_id :: String.t(), synthesize_create :: boolean(), return :: :object | :activity) ::
def fetch(ap_id, synthesize_create \\ true) do t() | Activity.t() | nil
def fetch(ap_id, synthesize_create \\ true, return \\ :object) do
case Clacks.ActivityPub.Fetcher.fetch_object(ap_id) do case Clacks.ActivityPub.Fetcher.fetch_object(ap_id) do
nil -> nil ->
nil nil
@ -61,21 +73,34 @@ defmodule Clacks.Object do
actor = data["actor"] || data["attributedTo"] actor = data["actor"] || data["attributedTo"]
_ = Clacks.Actor.get_by_ap_id(actor) _ = Clacks.Actor.get_by_ap_id(actor)
if synthesize_create do activity =
create = Clacks.ActivityPub.synthesized_create(data) case Clacks.Activity.get_by_object_ap_id(ap_id) do
nil ->
if synthesize_create do
create = Clacks.ActivityPub.synthesized_create(data)
changeset = changeset =
Clacks.Activity.changeset(%Clacks.Activity{}, %{ Clacks.Activity.changeset(%Clacks.Activity{}, %{
data: create, data: create,
local: false, local: false,
actor: actor actor: actor
}) })
{:ok, _create} = Repo.insert_or_update(changeset) {:ok, create} = Repo.insert_or_update(changeset)
create
else
nil
end
%Clacks.Activity{} = activity ->
activity
end
case return do
:object -> object
:activity -> activity
end end
object
{:error, changeset} -> {:error, changeset} ->
Logger.error("Couldn't store remote object #{ap_id}: #{inspect(changeset)}") Logger.error("Couldn't store remote object #{ap_id}: #{inspect(changeset)}")
nil nil

View File

@ -9,26 +9,24 @@ defmodule Clacks.Timeline do
@spec actor_timeline( @spec actor_timeline(
actor :: Actor.t(), actor :: Actor.t(),
params :: map(), params :: map(),
only_public :: boolean(), only_public :: boolean()
actors :: boolean()
) :: [ ) :: [
Activity.t() Activity.t()
] ]
def actor_timeline(actor, params, only_public \\ true, actors \\ false) do def actor_timeline(actor, params, only_public \\ true) do
Activity Activity
|> restrict_to_actor(actor.ap_id) |> restrict_to_actor(actor.ap_id)
|> restrict_to_types(@timeline_types) |> restrict_to_types(@timeline_types)
|> restrict_to_public(only_public) |> restrict_to_public(only_public)
|> paginate(params) |> paginate(params)
|> limit(^Map.get(params, "limit", 20)) |> limit(^Map.get(params, "limit", 20))
|> join_with_actors(actors)
|> Repo.all() |> Repo.all()
end end
@spec home_timeline(user :: User.t(), params :: map(), actors :: boolean()) :: [ @spec home_timeline(user :: User.t(), params :: map()) :: [
Activity.t() {Activity.t(), Actor.t()}
] ]
def home_timeline(user, params, actors \\ false) do def home_timeline(user, params) do
user = user =
case user.actor do case user.actor do
%Ecto.Association.NotLoaded{} -> %Ecto.Association.NotLoaded{} ->
@ -39,15 +37,27 @@ defmodule Clacks.Timeline do
end end
Activity Activity
|> join_with_actors()
|> where( |> where(
[a], [activity, actor],
fragment("?->>'actor'", a.data) == ^user.actor.ap_id or fragment("?->>'actor'", activity.data) == ^user.actor.ap_id or
fragment("?->>'actor'", a.data) in ^user.actor.followers ^user.actor.ap_id in actor.followers
) )
|> restrict_to_types(@timeline_types) |> restrict_to_types(@timeline_types)
|> paginate(params) |> paginate(params)
|> limit(^Map.get(params, "limit", 20)) |> limit(^Map.get(params, "limit", 20))
|> join_with_actors(actors) |> Repo.all()
end
@spec local_timeline(params :: map()) :: [{Activity.t(), Actor.t()}]
def local_timeline(params) do
Activity
|> where([a], a.local)
|> restrict_to_public(true)
|> restrict_to_types(@timeline_types)
|> paginate(params)
|> limit(^Map.get(params, "limit", 20))
|> join_with_actors()
|> Repo.all() |> Repo.all()
end end
@ -69,11 +79,9 @@ defmodule Clacks.Timeline do
defp restrict_to_public(query, false), do: query defp restrict_to_public(query, false), do: query
defp join_with_actors(query, true) do defp join_with_actors(query) do
query query
|> join(:left, [o], a in Actor, on: a.ap_id == fragment("?->>'actor'", o.data)) |> join(:left, [o], a in Actor, on: a.ap_id == fragment("?->>'actor'", o.data))
|> select([o, a], {o, a}) |> select([o, a], {o, a})
end end
defp join_with_actors(query, false), do: query
end end

View File

@ -3,6 +3,7 @@ defmodule ClacksWeb.FrontendController do
alias Clacks.{Actor, User, Timeline, Repo, ActivityPub, Activity, Object} alias Clacks.{Actor, User, Timeline, Repo, ActivityPub, Activity, Object}
alias ClacksWeb.Router.Helpers, as: Routes alias ClacksWeb.Router.Helpers, as: Routes
alias ClacksWeb.Endpoint alias ClacksWeb.Endpoint
import Ecto.Query
@public "https://www.w3.org/ns/activitystreams#Public" @public "https://www.w3.org/ns/activitystreams#Public"
@ -12,18 +13,20 @@ defmodule ClacksWeb.FrontendController do
render(conn, "home.html", %{ render(conn, "home.html", %{
user: user, user: user,
actor: user.actor, actor: user.actor,
statuses_with_authors: Timeline.home_timeline(user, params, true) statuses_with_authors: Timeline.home_timeline(user, params)
}) })
end end
def index(conn, params) do def index(conn, params) do
Application.get_env(:clacks, :frontend, %{}) Application.get_env(:clacks, :frontend, %{})
|> Keyword.get(:unauthenticated_homepage, :public_timeline) |> Keyword.get(:unauthenticated_homepage, :local_timeline)
|> index(conn, params) |> index(conn, params)
end end
defp index(:public_timeline, conn, params) do defp index(:local_timeline, conn, params) do
# tood: show public timeline render(conn, "local_timeline.html", %{
statuses_with_authors: Timeline.local_timeline(params)
})
end end
defp index({:profile, nickname}, conn, params) do defp index({:profile, nickname}, conn, params) do
@ -36,8 +39,8 @@ defmodule ClacksWeb.FrontendController do
}) })
_ -> _ ->
# otherwise show public timeline # otherwise show local timeline
index(:public_timeline, conn) index(:local_timeline, conn)
end end
end end
@ -53,7 +56,7 @@ defmodule ClacksWeb.FrontendController do
data: data:
%{ %{
"type" => "Create", "type" => "Create",
"object" => %{"type" => "Note", "attributedTo" => author_id} = note "object" => %{"type" => "Note", "attributedTo" => author_id}
} = data } = data
} = activity <- Activity.get(id), } = activity <- Activity.get(id),
%Actor{} = author <- Actor.get_by_ap_id(author_id) do %Actor{} = author <- Actor.get_by_ap_id(author_id) do
@ -119,7 +122,7 @@ defmodule ClacksWeb.FrontendController do
case User.get_by_username(username) do case User.get_by_username(username) do
nil -> nil ->
put_status(conn, 404) resp(conn, 404, "Not Found")
user -> user ->
user = Repo.preload(user, :actor) user = Repo.preload(user, :actor)
@ -127,11 +130,127 @@ defmodule ClacksWeb.FrontendController do
render(conn, "profile.html", %{ render(conn, "profile.html", %{
current_user: current_user, current_user: current_user,
actor: user.actor, actor: user.actor,
statuses: actor_statuses(user.actor, params, only_public: true) statuses: actor_statuses(user.actor, params, only_public: true),
following_state: following_state(current_user.actor, user.actor)
}) })
end end
end end
def actor(conn, %{"id" => id} = params) do
current_user = conn.assigns[:user] |> Repo.preload(:actor)
case Repo.get(Actor, id) do
nil ->
resp(conn, 404, "Not Found")
actor ->
render(conn, "profile.html", %{
current_user: current_user,
actor: actor,
statuses: actor_statuses(actor, params, only_public: true),
following_state: following_state(current_user.actor, actor)
})
end
end
def follow(conn, %{"id" => id}) do
current_user = conn.assigns[:user] |> Repo.preload(:actor)
case Repo.get(Actor, id) do
nil ->
resp(conn, 404, "Not Found")
followee ->
if current_user.actor.ap_id in followee.followers do
redirect(conn, to: ClacksWeb.FrontendView.local_actor_link(followee))
else
follow = ActivityPub.follow(current_user.actor.ap_id, followee.ap_id)
ActivityPub.Helper.save_and_federate(follow, current_user.actor)
conn
|> put_flash(:info, "Follow request sent")
|> redirect(to: ClacksWeb.FrontendView.local_actor_link(followee))
end
end
end
def unfollow(conn, %{"id" => id}) do
current_user = conn.assigns[:user] |> Repo.preload(:actor)
case Repo.get(Actor, id) do
nil ->
resp(conn, 404, "Not Found")
followee ->
unless current_user.actor.ap_id in followee.followers do
redirect(conn, to: ClacksWeb.FrontendView.local_actor_link(followee))
else
new_followers = List.delete(followee.followers, current_user.actor.ap_id)
changeset = Actor.changeset(followee, %{followers: new_followers})
{:ok, followee} = Repo.update(changeset)
follow_activity = follow_activity(current_user.actor, followee)
changeset =
Activity.changeset(follow_activity, %{
data: %{follow_activity.data | "state" => "unfollowed"}
})
{:ok, follow_activity} = Repo.update(changeset)
undo_follow = ActivityPub.undo_follow(current_user.actor.ap_id, follow_activity.data)
ActivityPub.Helper.save_and_federate(undo_follow, current_user.actor)
conn
|> put_flash(:info, "Unfollowed")
|> redirect(to: ClacksWeb.FrontendView.local_actor_link(followee))
end
end
end
def search(conn, %{"q" => q}) when is_binary(q) do
current_user = conn.assigns[:user]
actor_results =
case Actor.fetch(q) do
%Actor{} = actor ->
[actor]
_ ->
[]
end
status_results =
with %Activity{
actor: actor_id,
data: %{"type" => "Create", "object" => %{"type" => "Note"}}
} = activity <- Object.fetch(q, true, :activity),
actor <- Actor.get_by_ap_id(actor_id) do
[{activity, actor}]
else
_ ->
[]
end
render(conn, "search.html", %{
current_user: current_user,
q: q,
status_results: status_results,
actor_results: actor_results
})
end
def search(conn, _params) do
current_user = conn.assigns[:user]
render(conn, "search.html", %{
current_user: current_user,
q: "",
status_results: [],
actor_results: []
})
end
def post_status(conn, %{"content" => content} = params) do def post_status(conn, %{"content" => content} = params) do
current_user = conn.assigns[:user] |> Repo.preload(:actor) current_user = conn.assigns[:user] |> Repo.preload(:actor)
@ -140,12 +259,7 @@ defmodule ClacksWeb.FrontendController do
{:ok, object} = Repo.insert(note_changeset) {:ok, object} = Repo.insert(note_changeset)
create = ActivityPub.create(note) create = ActivityPub.create(note)
create_changeset = Activity.changeset_for_creating(create, true) {:ok, activity} = ActivityPub.Helper.save_and_federate(create, current_user.actor)
{:ok, activity} = Repo.insert(create_changeset)
%{id: activity.id, actor_id: current_user.actor.id}
|> Clacks.Worker.Federate.new()
|> Oban.insert()
path = Map.get(params, "continue", Routes.frontend_path(Endpoint, :status, activity.id)) path = Map.get(params, "continue", Routes.frontend_path(Endpoint, :status, activity.id))
redirect(conn, to: path) redirect(conn, to: path)
@ -177,4 +291,32 @@ defmodule ClacksWeb.FrontendController do
defp note_for_posting(current_user, %{"content" => content}) do defp note_for_posting(current_user, %{"content" => content}) do
ActivityPub.note(current_user.actor.ap_id, content) ActivityPub.note(current_user.actor.ap_id, content)
end end
@spec follow_activity(follower :: Actor.t(), followee :: Actor.t()) :: map()
def follow_activity(follower, followee) do
# todo: get latest
query =
Activity
|> where([a], fragment("?->>'type'", a.data) == "Follow")
|> where([a], fragment("?->>'actor'", a.data) == ^follower.ap_id)
|> where([a], fragment("?->>'object'", a.data) == ^followee.ap_id)
|> order_by(desc: :inserted_at)
|> limit(1)
Repo.one(query)
end
@spec following_state(follower :: Actor.t(), followee :: Actor.t()) :: boolean()
defp following_state(follower, followee) do
case follow_activity(follower, followee) do
%Activity{data: %{"state" => "pending"}} ->
:pending
%Activity{data: %{"state" => "accepted"}} ->
:following
_ ->
:not_following
end
end
end end

View File

@ -58,8 +58,11 @@ defmodule ClacksWeb.Router do
pipe_through :browser_authenticated pipe_through :browser_authenticated
post "/post", FrontendController, :post_status post "/post", FrontendController, :post_status
get "/status/:id/reply", FrontendController, :reply get "/status/:id/reply", FrontendController, :reply
get "/search", FrontendController, :search
get "/actors/:id", FrontendController, :actor
post "/actors/:id/follow", FrontendController, :follow
post "/actors/:id/unfollow", FrontendController, :unfollow
end end
scope "/", ClacksWeb do scope "/", ClacksWeb do

View File

@ -1,7 +1,7 @@
<div class="status"> <div class="status">
<div class="status-meta"> <div class="status-meta">
<h2 class="status-author-nickname"> <h2 class="status-author-nickname">
<a href="<%= @author.ap_id %>"> <a href="<%= local_actor_link(@author) %>">
<%= @author.data["preferredUsername"] %> <%= @author.data["preferredUsername"] %>
</a> </a>
</h2> </h2>
@ -12,7 +12,7 @@
</h3> </h3>
<p class="status-meta-right"> <p class="status-meta-right">
<span><%= display_timestamp(@note["published"]) %></span> <span><%= display_timestamp(@note["published"]) %></span>
<a href="<%= @note["url"] %>" class="status-permalink">Permalink</a> <a href="<%= @note["url"] || @note["id"] %>" class="status-permalink">Permalink</a>
</p> </p>
</div> </div>
<div class="status-content"> <div class="status-content">

View File

@ -0,0 +1,3 @@
<h1>Local Timeline</h1>
<%= render "_timeline.html", conn: @conn, statuses_with_authors: @statuses_with_authors %>

View File

@ -1,5 +1,35 @@
<h1><%= @actor.data["preferredUsername"] %></h1> <h1><%= @actor.data["preferredUsername"] %></h1>
<h2>@<%= @actor.data["name"] %></h2> <h2>
<%= if @actor.local do %>
<%= display_username(@actor) %>
<% else %>
<a href="<%= @actor.ap_id %>">
<%= display_username(@actor) %>
</a>
<% end %>
</h2>
<p><%= @actor.data["summary"] %></p> <p><%= @actor.data["summary"] %></p>
<div class="actor-actions">
<%= unless @current_user.actor.ap_id == @actor.ap_id do %>
<%= case @following_state do %>
<% :not_following -> %>
<%= form_tag Routes.frontend_path(@conn, :follow, @actor.id), method: :post, class: "follow-form" do %>
<%= submit "Follow" %>
<% end %>
<% :following -> %>
<%= form_tag Routes.frontend_path(@conn, :unfollow, @actor.id), method: :post, class: "follow-form" do %>
<%= submit "Unfollow" %>
<% end %>
<% :pending -> %>
<!-- todo: cancel follow request -->
<button type="button">Pending</button>
<% end %>
<% end %>
</div>
<%= render "_timeline.html", conn: @conn, statuses_with_authors: Enum.map(@statuses, &({&1, @actor})) %> <%= render "_timeline.html", conn: @conn, statuses_with_authors: Enum.map(@statuses, &({&1, @actor})) %>

View File

@ -0,0 +1,31 @@
<%= form_tag Routes.frontend_path(@conn, :search), method: :get, class: "search-form" do %>
<input type="text" name="q" id="q" placeholder="Search Query" value="<%= @q %>">
<%= submit "Search" %>
<% end %>
<%= if length(@actor_results) > 0 do %>
<hr>
<%= for actor <- @actor_results do %>
<div class="actor">
<h2 class="actor-nickname">
<a href="<%= local_actor_link(actor) %>">
<%= actor.data["preferredUsername"] %>
</a>
</h2>
<h3 class="actor-username">
<a href="<%= actor.ap_id %>">
<%= display_username(actor) %>
</a>
</h3>
</div>
<% end %>
<% end %>
<%= if length(@status_results) do %>
<hr>
<%= for {status, author} <- @status_results do %>
<%= render "_status.html", conn: @conn, author: author, status: status, note: status.data["object"] %>
<% end %>
<% end %>

View File

@ -13,11 +13,14 @@
<nav role="navigation"> <nav role="navigation">
<ul> <ul>
<li><a href="/"><%= instance_name() %></a></li> <li><a href="/"><%= instance_name() %></a></li>
<%= if @conn.assigns[:user] do %>
<li><a href="<%= Routes.frontend_path(@conn, :search) %>">Search</a></li>
<% end %>
</ul> </ul>
<ul> <ul>
<%= if @conn.assigns[:user] do %> <%= if @conn.assigns[:user] do %>
<li> <li>
Logged in as <a href="<%= Routes.actor_path(@conn, :get, @conn.assigns[:user].username) %>"><%= @conn.assigns[:user].username %></a>. Logged in as <a href="<%= Routes.actor_path(@conn, :get, @user.username) %>"><%= @user.username %></a>.
</li> </li>
<li> <li>
<%= form_for @conn, Routes.login_path(@conn, :logout), [method: :post], fn f -> %> <%= form_for @conn, Routes.login_path(@conn, :logout), [method: :post], fn f -> %>
@ -34,8 +37,12 @@
</section> </section>
</header> </header>
<main role="main" class="container"> <main role="main" class="container">
<p class="alert alert-info" role="alert"><%= get_flash(@conn, :info) %></p> <%= if get_flash(@conn, :info) do %>
<p class="alert alert-danger" role="alert"><%= get_flash(@conn, :error) %></p> <p class="flash-info" role="alert"><%= get_flash(@conn, :info) %></p>
<% end %>
<%= if get_flash(@conn, :error) do %>
<p class="flash-error" role="alert"><%= get_flash(@conn, :error) %></p>
<% end %>
<%= render @view_module, @view_template, assigns %> <%= render @view_module, @view_template, assigns %>
</main> </main>
<script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script> <script type="text/javascript" src="<%= Routes.static_path(@conn, "/js/app.js") %>"></script>

View File

@ -1,13 +1,15 @@
<%= form_tag Routes.login_path(@conn, :login_post), method: :post do %> <%= form_tag Routes.login_path(@conn, :login_post), method: :post, class: "login-form" do %>
<%= if @continue do %> <%= if @continue do %>
<input type="hidden" name="continue" value="<%= @continue %>"> <input type="hidden" name="continue" value="<%= @continue %>">
<% end %> <% end %>
<label for="username">Username</label> <div class="input-group">
<input id="username" type="text" name="username"> <label for="username">Username:</label>
<br> <input id="username" type="text" name="username">
<label for="password">Password</label> </div>
<input id="password" type="password" name="password"> <div class="input-group">
<br> <label for="password">Password:</label>
<input id="password" type="password" name="password">
</div>
<%= submit "Log In" %> <%= submit "Log In" %>
<% end %> <% end %>

View File

@ -1,6 +1,8 @@
defmodule ClacksWeb.FrontendView do defmodule ClacksWeb.FrontendView do
use ClacksWeb, :view use ClacksWeb, :view
alias Clacks.{Actor, Activity} alias Clacks.{Actor, Activity}
alias ClacksWeb.Router.Helpers, as: Routes
alias ClacksWeb.Endpoint
@spec display_username(actor :: Actor.t()) :: String.t() @spec display_username(actor :: Actor.t()) :: String.t()
@ -13,6 +15,13 @@ defmodule ClacksWeb.FrontendView do
"@" <> name <> "@" <> host "@" <> name <> "@" <> host
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(str :: String.t()) :: String.t()
def display_timestamp(str) when is_binary(str) do def display_timestamp(str) when is_binary(str) do
display_timestamp(Timex.parse!(str, "{ISO:Extended}")) display_timestamp(Timex.parse!(str, "{ISO:Extended}"))
end end
@ -42,6 +51,9 @@ defmodule ClacksWeb.FrontendView do
end end
end end
@spec prev_page_path(conn :: Plug.Conn.t(), [Activity.t() | {Activity.t(), Actor.t()}]) ::
String.t()
def prev_page_path(conn, activities) do def prev_page_path(conn, activities) do
if Map.has_key?(conn.query_params, "max_id") do if Map.has_key?(conn.query_params, "max_id") do
Phoenix.Controller.current_path(conn, %{ Phoenix.Controller.current_path(conn, %{
@ -52,6 +64,9 @@ defmodule ClacksWeb.FrontendView do
end end
end end
@spec next_page_path(conn :: Plug.Conn.t(), [Activity.t() | {Activity.t(), Actor.t()}]) ::
String.t()
def next_page_path(conn, activities) do def next_page_path(conn, activities) do
if length(activities) < 20 do if length(activities) < 20 do
nil nil