diff --git a/assets/css/clacks.scss b/assets/css/clacks.scss index 92a630b..ca59608 100644 --- a/assets/css/clacks.scss +++ b/assets/css/clacks.scss @@ -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; diff --git a/lib/clacks/inbox.ex b/lib/clacks/inbox.ex index 6aaca3b..a014af8 100644 --- a/lib/clacks/inbox.ex +++ b/lib/clacks/inbox.ex @@ -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()} diff --git a/lib/clacks/notification.ex b/lib/clacks/notification.ex new file mode 100644 index 0000000..a45fc86 --- /dev/null +++ b/lib/clacks/notification.ex @@ -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 diff --git a/lib/clacks/timeline.ex b/lib/clacks/timeline.ex index 2bf3236..c94855d 100644 --- a/lib/clacks/timeline.ex +++ b/lib/clacks/timeline.ex @@ -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) ) diff --git a/lib/clacks/user_actions_helper.ex b/lib/clacks/user_actions_helper.ex index 3290307..08c1754 100644 --- a/lib/clacks/user_actions_helper.ex +++ b/lib/clacks/user_actions_helper.ex @@ -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 -> diff --git a/lib/clacks_web/controllers/frontend_controller.ex b/lib/clacks_web/controllers/frontend_controller.ex index 30cbdb6..2a0d0fb 100644 --- a/lib/clacks_web/controllers/frontend_controller.ex +++ b/lib/clacks_web/controllers/frontend_controller.ex @@ -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) diff --git a/lib/clacks_web/router.ex b/lib/clacks_web/router.ex index c3d3f18..e93a15c 100644 --- a/lib/clacks_web/router.ex +++ b/lib/clacks_web/router.ex @@ -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 diff --git a/lib/clacks_web/templates/frontend/_action_status.html.eex b/lib/clacks_web/templates/frontend/_action_status.html.eex new file mode 100644 index 0000000..4c79cc5 --- /dev/null +++ b/lib/clacks_web/templates/frontend/_action_status.html.eex @@ -0,0 +1,39 @@ +<% past_tense = + case @action do + :like -> "Liked" + :announce -> "Announced" + end +%> + +
"> +
+

+ <%= past_tense %> by <%= @action_actor.data["preferredUsername"] %> +

+

+ +

+
+
+

+ + <%= @original_actor.data["preferredUsername"] %> + +

+

+ + <%= display_username(@original_actor) %> + +

+

+ + " class="status-permalink">Permalink +

+
+
+ <%= raw(@original_note["content"]) %> +
+
+ Reply +
+
diff --git a/lib/clacks_web/templates/frontend/_follow_notification.html.eex b/lib/clacks_web/templates/frontend/_follow_notification.html.eex new file mode 100644 index 0000000..2a05c76 --- /dev/null +++ b/lib/clacks_web/templates/frontend/_follow_notification.html.eex @@ -0,0 +1,10 @@ +
+
+

+ Followed by <%= @actor.data["preferredUsername"] %> +

+

+ +

+
+
diff --git a/lib/clacks_web/templates/frontend/_status.html.eex b/lib/clacks_web/templates/frontend/_status.html.eex index a90cb65..2ab78e5 100644 --- a/lib/clacks_web/templates/frontend/_status.html.eex +++ b/lib/clacks_web/templates/frontend/_status.html.eex @@ -1,9 +1,4 @@ -
- <%= if assigns[:announcer] do %> -

- Announced by <%= @announcer.data["preferredUsername"] %> -

- <% end %> +
">

diff --git a/lib/clacks_web/templates/frontend/_timeline.html.eex b/lib/clacks_web/templates/frontend/_timeline.html.eex index 8715570..672afe2 100644 --- a/lib/clacks_web/templates/frontend/_timeline.html.eex +++ b/lib/clacks_web/templates/frontend/_timeline.html.eex @@ -10,7 +10,7 @@
  • <%= 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 %> diff --git a/lib/clacks_web/templates/frontend/notifications.html.eex b/lib/clacks_web/templates/frontend/notifications.html.eex new file mode 100644 index 0000000..331d1e1 --- /dev/null +++ b/lib/clacks_web/templates/frontend/notifications.html.eex @@ -0,0 +1,35 @@ +

    Notifications

    + +<% newer = prev_page_path(@conn, @notifications) %> +<%= if newer do %> +
    +<% end %> + +
      + <%= for notification <- @notifications do %> +
    • + <%= 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 %> +
    • + <% end %> +
    + +<% older = next_page_path(@conn, @notifications) %> +<%= if older do %> + +<% end %> diff --git a/lib/clacks_web/templates/layout/app.html.eex b/lib/clacks_web/templates/layout/app.html.eex index bb47bb7..f18a146 100644 --- a/lib/clacks_web/templates/layout/app.html.eex +++ b/lib/clacks_web/templates/layout/app.html.eex @@ -14,6 +14,7 @@ diff --git a/lib/clacks_web/views/frontend_view.ex b/lib/clacks_web/views/frontend_view.ex index 20a7bbe..34a7179 100644 --- a/lib/clacks_web/views/frontend_view.ex +++ b/lib/clacks_web/views/frontend_view.ex @@ -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{ diff --git a/priv/repo/migrations/20200523200009_create_notifications.exs b/priv/repo/migrations/20200523200009_create_notifications.exs new file mode 100644 index 0000000..debb213 --- /dev/null +++ b/priv/repo/migrations/20200523200009_create_notifications.exs @@ -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