Add basic notifications

This commit is contained in:
Shadowfacts 2020-05-24 16:26:10 -04:00
parent 47bb420c81
commit fd336f0bed
Signed by: shadowfacts
GPG Key ID: 94A5AB95422746E5
15 changed files with 284 additions and 27 deletions

View File

@ -97,7 +97,8 @@ header {
}
}
ul.status-list {
ul.status-list,
ul.notifications-list {
padding: 0;
margin: 0;
@ -111,7 +112,7 @@ ul.status-list {
text-align: center;
}
.status {
.status, .notification {
padding: 0.5rem;
border: 1px solid #ddd;
background-color: #f2f2f2;
@ -131,10 +132,14 @@ ul.status-list {
.status-meta-right {
display: inline;
margin: 0;
margin-right: 1rem;
font-size: 1rem;
}
.status-author-nickname,
.status-author-username {
margin-right: 1rem;
}
.status-author-username {
font-weight: normal;
}
@ -156,6 +161,18 @@ ul.status-list {
}
}
.notification {
.notification-info, .notification-info-right {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.notification-info-right {
flex-grow: 1;
text-align: right;
}
}
.compose-status {
textarea {
display: block;

View File

@ -1,6 +1,6 @@
defmodule Clacks.Inbox do
require Logger
alias Clacks.{Repo, Activity, Object, Actor, ActivityPub}
alias Clacks.{Repo, Activity, Object, Actor, ActivityPub, Notification}
defp store_activity(%{"actor" => actor, "id" => ap_id} = activity, local \\ false)
when is_binary(actor) do
@ -11,7 +11,15 @@ defmodule Clacks.Inbox do
actor: actor
})
Repo.insert_or_update(changeset)
case Repo.insert_or_update(changeset) do
{:ok, activity} ->
Notification.process_notifications_for_incoming(activity)
{:ok, activity}
{:error, reason} ->
{:error, reason}
end
end
@spec handle(activity :: map()) :: :ok | {:error, reason :: any()}

View File

@ -0,0 +1,89 @@
defmodule Clacks.Notification do
use Ecto.Schema
import Ecto.Changeset
alias Clacks.{Activity, Actor, Repo}
@type t() :: %__MODULE__{}
@valid_types ["follow", "mention", "announce", "like"]
schema "notifications" do
field :type, :string
belongs_to :user, Clacks.User
belongs_to :activity, Clacks.Activity, type: FlakeId.Ecto.Type
timestamps()
end
def changeset(%__MODULE__{} = schema, attrs) do
schema
|> cast(attrs, [:type, :user_id, :activity_id])
|> validate_required([:type, :user_id])
|> validate_inclusion(:type, @valid_types)
end
@spec create(type :: String.t(), activity :: Activity.t(), actor :: Actor.t()) ::
{:ok, Notification.t()} | {:error, any()}
def create(type, _, _) when not (type in @valid_types) do
{:error, "invalid notification type '#{type}'"}
end
def create(type, activity, actor) do
changeset =
changeset(%__MODULE__{}, %{
type: type,
user_id: actor.user_id,
activity_id: activity.id
})
Repo.insert(changeset)
end
def process_notifications_for_incoming(
%Activity{
data: %{"type" => "Follow", "object" => followee_ap_id}
} = activity
) do
case Actor.get_cached_by_ap_id(followee_ap_id) do
%Actor{local: true} = followee ->
create("follow", activity, followee)
_ ->
:ok
end
end
def process_notifications_for_incoming(
%Activity{data: %{"type" => "Create", "object" => %{"type" => "Note", "tag" => tags}}} =
activity
) do
Enum.each(tags, fn %{"href" => mentioned_actor_id} ->
case Actor.get_cached_by_ap_id(mentioned_actor_id) do
%Actor{local: true} = mentioned_actor ->
create("mention", activity, mentioned_actor)
_ ->
:ok
end
end)
end
def process_notifications_for_incoming(
%Activity{data: %{"type" => type, "object" => original_object_ap_id}} = activity
)
when type in ["Announce", "Like"] do
with %Activity{local: true, actor: local_actor_id} <-
Activity.get_by_object_ap_id(original_object_ap_id),
%Actor{local: true} = original_activity_actor <-
Actor.get_cached_by_ap_id(local_actor_id) do
create(String.downcase(type), activity, original_activity_actor)
else
_ ->
:ok
end
end
def process_notifications_for_incoming(_), do: :ok
end

View File

@ -1,10 +1,11 @@
defmodule Clacks.Timeline do
alias Clacks.{Repo, Actor, Activity, User}
alias Clacks.{Repo, Actor, Activity, User, Notification}
import Clacks.Paginator
import Ecto.Query
@public "https://www.w3.org/ns/activitystreams#Public"
@timeline_types ["Create", "Announce"]
@notification_types ["Create", "Announce", "Like", "Follow"]
@spec actor_timeline(
actor :: Actor.t(),
@ -20,7 +21,7 @@ defmodule Clacks.Timeline do
|> restrict_to_public(only_public)
|> paginate(params)
|> limit(^Map.get(params, "limit", 20))
|> join_with_announces()
|> join_with_announced_or_liked()
|> select(
[activity, announced, announced_actor],
{activity, {announced, announced_actor}}
@ -51,7 +52,7 @@ defmodule Clacks.Timeline do
|> restrict_to_types(@timeline_types)
|> paginate(params)
|> limit(^Map.get(params, "limit", 20))
|> join_with_announces()
|> join_with_announced_or_liked()
|> select(
[activity, actor, announced, announced_actor],
{activity, actor, {announced, announced_actor}}
@ -70,7 +71,7 @@ defmodule Clacks.Timeline do
|> paginate(params)
|> limit(^Map.get(params, "limit", 20))
|> join_with_actors()
|> join_with_announces()
|> join_with_announced_or_liked()
|> select(
[activity, actor, announced, announced_actor],
{activity, actor, {announced, announced_actor}}
@ -78,6 +79,36 @@ defmodule Clacks.Timeline do
|> Repo.all()
end
@spec notifications(actor :: Actor.t(), params :: map()) :: [
{:follow, activity :: Activity.t(), actor :: Actor.t()}
| {:mention, activity :: Activity.t(), actor :: Actor.t()}
| {:announce, announce :: Activity.t(), announce_actor :: Actor.t(),
activity :: Activity.t(), actor :: Actor.t()}
| {:like, like :: Activity.t(), like_actor :: Actor.t(), activity :: Activity.t(),
actor :: Actor.t()}
]
def notifications(actor, params) do
Notification
|> where([n], n.user_id == ^actor.user_id)
|> join(:inner, [n], activity in Activity, on: activity.id == n.activity_id)
|> join(:inner, [n, activity], actor in Actor, on: activity.actor == actor.ap_id)
|> join(:left, [n, activity, actor], other in Activity,
on:
n.type in ["announce", "like"] and fragment("?->>'type'", other.data) == "Create" and
fragment("?->>'object'", activity.data) == fragment("?->'object'->>'id'", other.data)
)
|> paginate(params)
|> limit(^Map.get(params, "limit", 20))
|> select(
[notification, activity, actor, original_activity],
{notification, activity, actor, original_activity}
)
|> Repo.all()
|> Enum.map(fn {notification, activity, actor, original_activity} ->
{String.to_existing_atom(notification.type), activity, actor, original_activity}
end)
end
defp restrict_to_actor(query, actor_id) do
where(query, [a], fragment("?->>'actor'", a.data) == ^actor_id)
end
@ -101,11 +132,11 @@ defmodule Clacks.Timeline do
|> join(:left, [o], a in Actor, on: a.ap_id == fragment("?->>'actor'", o.data))
end
defp join_with_announces(query) do
defp join_with_announced_or_liked(query) do
query
|> join(:left, [a], other in Activity,
on:
fragment("?->>'type'", a.data) == "Announce" and
fragment("?->>'type'", a.data) in ["Announce", "Like"] and
fragment("?->>'type'", other.data) == "Create" and
fragment("?->>'object'", a.data) == fragment("?->'object'->>'id'", other.data)
)

View File

@ -1,5 +1,5 @@
defmodule Clacks.UserActionsHelper do
alias Clacks.{User, Repo, Activity, Object, ActivityPub, Actor}
alias Clacks.{User, Repo, Activity, Object, ActivityPub, Actor, Notification}
@public "https://www.w3.org/ns/activitystreams#Public"
@ -37,6 +37,8 @@ defmodule Clacks.UserActionsHelper do
case ActivityPub.Helper.save_and_federate(create, author.actor) do
{:ok, activity} ->
Notification.process_notifications_for_incoming(activity)
{:ok, activity}
:error ->

View File

@ -263,6 +263,17 @@ defmodule ClacksWeb.FrontendController do
})
end
def notifications(conn, params) do
current_user = conn.assigns[:user] |> Repo.preload(:actor)
notifications = Timeline.notifications(current_user.actor, params)
render(conn, "notifications.html", %{
current_user: current_user,
notifications: notifications
})
end
def post_status(conn, %{"content" => content} = params) do
current_user = conn.assigns[:user] |> Repo.preload(:actor)

View File

@ -63,6 +63,7 @@ defmodule ClacksWeb.Router do
get "/actors/:id", FrontendController, :actor
post "/actors/:id/follow", FrontendController, :follow
post "/actors/:id/unfollow", FrontendController, :unfollow
get "/notifications", FrontendController, :notifications
end
scope "/", ClacksWeb do

View File

@ -0,0 +1,39 @@
<% past_tense =
case @action do
:like -> "Liked"
:announce -> "Announced"
end
%>
<div class="<%= assigns[:class] || "status" %>">
<div class="status-meta">
<p class="status-announcer">
<%= past_tense %> by <a href="<%= local_actor_link(@action_actor) %>"><%= @action_actor.data["preferredUsername"] %></a>
</p>
<p class="status-meta-right">
<time datetime="<%= @action_activity.data["published"] %>"><%= display_timestamp(@action_activity.data["published"]) %></time>
</p>
</div>
<div class="status-meta">
<h2 class="status-author-nickname">
<a href="<%= local_actor_link(@original_actor) %>">
<%= @original_actor.data["preferredUsername"] %>
</a>
</h2>
<h3 class="status-author-username">
<a href="<%= @original_actor.ap_id %>">
<%= display_username(@original_actor) %>
</a>
</h3>
<p class="status-meta-right">
<time datetime="<%= @original_note["published"] %>"><%= display_timestamp(@original_note["published"]) %></time>
<a href="<%= @original_note["url"] || @original_note["id"] %>" class="status-permalink">Permalink</a>
</p>
</div>
<div class="status-content">
<%= raw(@original_note["content"]) %>
</div>
<div class="status-actions">
<a href="<%= Routes.frontend_path(@conn, :reply, @original_activity.id) %>">Reply</a>
</div>
</div>

View File

@ -0,0 +1,10 @@
<div class="notification">
<div class="status-meta">
<p class="notification-info">
Followed by <a href="<%= local_actor_link(@actor) %>"><%= @actor.data["preferredUsername"] %></a>
</p>
<p class="notification-info-right">
<time datetime="<%= @activity.data["published"] %>"><%= display_timestamp(@activity.data["published"]) %></time>
</p>
</div>
</div>

View File

@ -1,9 +1,4 @@
<div class="status h-entry">
<%= if assigns[:announcer] do %>
<p class="status-announcer">
Announced by <a href="<%= local_actor_link(@announcer) %>"><%= @announcer.data["preferredUsername"] %></a>
</p>
<% end %>
<div class="<%= assigns[:class] || "status h-entry" %>">
<div class="status-meta">
<h2 class="status-author-nickname">
<a href="<%= local_actor_link(@author) %>" class="p-author">

View File

@ -10,7 +10,7 @@
<li>
<%= if status.data["type"] == "Announce" do %>
<% {announced_status, announced_actor} = announced %>
<%= render "_status.html", conn: @conn, announcer: author, status: announced_status, note: announced_status.data["object"], author: announced_actor %>
<%= render "_action_status.html", conn: @conn, action: :announce, action_activity: status, action_actor: author, original_activity: announced_status, original_note: announced_status.data["object"], original_actor: announced_actor %>
<% else %>
<%= render "_status.html", conn: @conn, author: author, status: status, note: status.data["object"] %>
<% end %>

View File

@ -0,0 +1,35 @@
<h1>Notifications</h1>
<% newer = prev_page_path(@conn, @notifications) %>
<%= if newer do %>
<p class="pagination-link">
<a href="<%= newer %>">Newer</a>
</p>
<% end %>
<ul class="notifications-list">
<%= for notification <- @notifications do %>
<li>
<%= case notification do %>
<% {:like, like_activity, actor, original_activity} -> %>
<%= render "_action_status.html", class: "notification", conn: @conn, action: :like, action_activity: like_activity, action_actor: actor, original_activity: original_activity, original_note: original_activity.data["object"], original_actor: @current_user.actor %>
<% {:announce, announce_activity, actor, original_activity} -> %>
<%= render "_action_status.html", class: "notification", conn: @conn, action: :announce, action_activity: announce_activity, action_actor: actor, original_activity: original_activity, original_note: original_activity.data["object"], original_actor: @current_user.actor %>
<% {:mention, mention_activity, actor, _} -> %>
<%= render "_status.html", class: "notification", conn: @conn, author: actor, status: mention_activity, note: mention_activity.data["object"] %>
<% {:follow, follow_activity, actor, _} -> %>
<%= render "_follow_notification.html", activity: follow_activity, actor: actor %>
<% end %>
</li>
<% end %>
</ul>
<% older = next_page_path(@conn, @notifications) %>
<%= if older do %>
<p class="pagination-link">
<a href="<%= older %>">Older</a>
</p>
<% end %>

View File

@ -14,6 +14,7 @@
<ul>
<li><a href="/"><%= instance_name() %></a></li>
<%= if @conn.assigns[:user] do %>
<li><a href="<%= Routes.frontend_path(@conn, :notifications) %>">Notifications</a></li>
<li><a href="<%= Routes.frontend_path(@conn, :search) %>">Search</a></li>
<% end %>
</ul>

View File

@ -1,6 +1,6 @@
defmodule ClacksWeb.FrontendView do
use ClacksWeb, :view
alias Clacks.{Actor, Activity, Repo}
alias Clacks.{Actor, Activity, Repo, Notification}
alias ClacksWeb.Router.Helpers, as: Routes
alias ClacksWeb.Endpoint
@ -60,20 +60,24 @@ defmodule ClacksWeb.FrontendView do
end
end
@spec prev_page_path(conn :: Plug.Conn.t(), [Activity.t() | {Activity.t(), Actor.t()}]) ::
@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() |> activity_id()
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()}]) ::
@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
@ -81,14 +85,15 @@ defmodule ClacksWeb.FrontendView do
nil
else
Phoenix.Controller.current_path(conn, %{
max_id: activities |> List.last() |> activity_id()
max_id: activities |> List.last() |> timeline_id()
})
end
end
defp activity_id(%Activity{id: id}), do: id
defp activity_id({%Activity{id: id}, _}), do: id
defp activity_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({%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{

View File

@ -0,0 +1,13 @@
defmodule Clacks.Repo.Migrations.CreateNotifications do
use Ecto.Migration
def change do
create table(:notifications) do
add :type, :string
add :user_id, references(:users)
add :activity_id, references(:activities, type: :uuid)
timestamps()
end
end
end