Page Menu
Home
Phorge
Search
Configure Global Search
Log In
Files
F85649071
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Award Token
Flag For Later
Size
172 KB
Referenced Files
None
Subscribers
None
View Options
diff --git a/changelog.d/grouped-notifications-api.add b/changelog.d/grouped-notifications-api.add
new file mode 100644
index 000000000..8c2cc5397
--- /dev/null
+++ b/changelog.d/grouped-notifications-api.add
@@ -0,0 +1 @@
+Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications` for newly-created notifications
diff --git a/lib/pleroma/notification.ex b/lib/pleroma/notification.ex
index b8693c3a8..afd750823 100644
--- a/lib/pleroma/notification.ex
+++ b/lib/pleroma/notification.ex
@@ -1,772 +1,867 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Notification do
use Ecto.Schema
alias Ecto.Multi
alias Pleroma.Activity
alias Pleroma.FollowingRelationship
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.ThreadMute
alias Pleroma.User
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.Push
alias Pleroma.Web.Streamer
import Ecto.Query
import Ecto.Changeset
require Logger
@type t :: %__MODULE__{}
@include_muted_option :with_muted
schema "notifications" do
field(:seen, :boolean, default: false)
# This is an enum type in the database. If you add a new notification type,
# remember to add a migration to add it to the `notifications_type` enum
# as well.
field(:type, :string)
+ field(:group_key, :string)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
timestamps()
end
def update_notification_type(user, activity) do
with %__MODULE__{} = notification <-
Repo.get_by(__MODULE__, user_id: user.id, activity_id: activity.id) do
type =
activity
|> type_from_activity()
notification
- |> changeset(%{type: type})
+ |> changeset(%{type: type, group_key: grouped_notification_key(type, activity)})
|> Repo.update()
end
end
@spec unread_notifications_count(User.t()) :: integer()
def unread_notifications_count(%User{id: user_id}) do
from(q in __MODULE__,
where: q.user_id == ^user_id and q.seen == false
)
|> Repo.aggregate(:count, :id)
end
+ @groupable_notification_types ~w{favourite follow reblog}
+ @group_bucket_seconds 12 * 60 * 60
+
+ def groupable_notification_types, do: @groupable_notification_types
+
+ def group_notifications(notifications, grouped_types \\ nil) do
+ grouped_types = normalize_grouped_types(grouped_types)
+
+ {group_keys, grouped_notifications} =
+ Enum.reduce(notifications, {[], %{}}, fn notification,
+ {group_keys, grouped_notifications} ->
+ group_key = group_key(notification, grouped_types)
+
+ if Map.has_key?(grouped_notifications, group_key) do
+ {group_keys, Map.update!(grouped_notifications, group_key, &[notification | &1])}
+ else
+ {[group_key | group_keys], Map.put(grouped_notifications, group_key, [notification])}
+ end
+ end)
+
+ group_keys
+ |> Enum.reverse()
+ |> Enum.map(fn group_key ->
+ grouped_notifications
+ |> Map.fetch!(group_key)
+ |> Enum.reverse()
+ end)
+ end
+
+ def group_key(notification, grouped_types \\ nil)
+
+ def group_key(%Notification{} = notification, nil) do
+ group_key(notification, @groupable_notification_types)
+ end
+
+ def group_key(%Notification{type: type, group_key: group_key} = notification, grouped_types) do
+ grouped_types = normalize_grouped_types(grouped_types)
+
+ if type in @groupable_notification_types and type in grouped_types and is_binary(group_key) do
+ group_key
+ else
+ ungrouped_group_key(notification)
+ end
+ end
+
+ def normalize_grouped_types(nil), do: @groupable_notification_types
+ def normalize_grouped_types(types) when is_list(types), do: types
+ def normalize_grouped_types(type), do: [type]
+
+ defp ungrouped_group_key(%Notification{id: id}), do: "ungrouped-#{id}"
+
+ defp grouped_notification_key(type, activity) when type in @groupable_notification_types do
+ with target_id when is_binary(target_id) <- group_target_id(type, activity) do
+ # Mastodon uses Redis to reuse a recent bucket for rolling 12h windows. Pleroma keeps keys
+ # deterministic and self-contained; clients must treat group_key as opaque either way.
+ "#{type}-#{target_id}-#{group_time_bucket(activity)}"
+ end
+ end
+
+ defp grouped_notification_key(_type, _activity), do: nil
+
+ defp group_time_bucket(%Activity{inserted_at: inserted_at}) when not is_nil(inserted_at) do
+ inserted_at
+ |> NaiveDateTime.to_erl()
+ |> :calendar.datetime_to_gregorian_seconds()
+ |> div(@group_bucket_seconds)
+ end
+
+ defp group_time_bucket(_),
+ do: group_time_bucket(%Activity{inserted_at: NaiveDateTime.utc_now()})
+
+ defp group_target_id(type, activity) when type in ["favourite", "reblog"] do
+ with object_id when is_binary(object_id) <- object_id_for(activity),
+ %Activity{id: id} <- Activity.get_create_by_object_ap_id(object_id) do
+ to_string(id)
+ else
+ _ -> nil
+ end
+ end
+
+ defp group_target_id("follow", %{data: %{"object" => ap_id}}) do
+ case User.get_cached_by_ap_id(ap_id) do
+ %User{id: id} -> to_string(id)
+ _ -> nil
+ end
+ end
+
+ defp group_target_id(_, _), do: nil
+
+ defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
+ defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
+ defp object_id_for(_), do: nil
+
@notification_types ~w{
favourite
follow
follow_request
mention
move
pleroma:chat_mention
pleroma:emoji_reaction
pleroma:report
reblog
poll
status
update
}
def changeset(%Notification{} = notification, attrs) do
notification
- |> cast(attrs, [:seen, :type])
+ |> cast(attrs, [:seen, :type, :group_key])
|> validate_inclusion(:type, @notification_types)
end
@spec last_read_query(User.t()) :: Ecto.Queryable.t()
def last_read_query(user) do
from(q in Pleroma.Notification,
where: q.user_id == ^user.id,
where: q.seen == true,
select: type(q.id, :string),
limit: 1,
order_by: fragment("? desc nulls last", q.id)
)
end
defp for_user_query_ap_id_opts(user, opts) do
ap_id_relationships =
[:block] ++
if opts[@include_muted_option], do: [], else: [:notification_mute]
preloaded_ap_ids = User.outgoing_relationships_ap_ids(user, ap_id_relationships)
exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts)
exclude_notification_muted_opts =
Map.merge(%{notification_muted_users_ap_ids: preloaded_ap_ids[:notification_mute]}, opts)
{exclude_blocked_opts, exclude_notification_muted_opts}
end
def for_user_query(user, opts \\ %{}) do
{exclude_blocked_opts, exclude_notification_muted_opts} =
for_user_query_ap_id_opts(user, opts)
Notification
|> where(user_id: ^user.id)
|> join(:inner, [n], activity in assoc(n, :activity))
|> join(:left, [n, a], object in Object,
on:
fragment(
"(?->>'id') = associated_object_id(?)",
object.data,
a.data
)
)
|> join(:inner, [_n, a], u in User, on: u.ap_id == a.actor, as: :user_actor)
|> preload([n, a, o], activity: {a, object: o})
|> where([user_actor: user_actor], user_actor.is_active)
|> exclude_notification_muted(user, exclude_notification_muted_opts)
|> exclude_blocked(user, exclude_blocked_opts)
|> exclude_blockers(user)
|> exclude_filtered(user)
|> exclude_visibility(opts)
end
# Excludes blocked users and non-followed domain-blocked users
defp exclude_blocked(query, user, opts) do
blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user)
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocked_ap_ids)
|> FollowingRelationship.keep_following_or_not_domain_blocked(user)
end
defp exclude_blockers(query, user) do
if Pleroma.Config.get([:activitypub, :blockers_visible]) == true do
query
else
blocker_ap_ids = User.incoming_relationships_ungrouped_ap_ids(user, [:block])
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^blocker_ap_ids)
end
end
defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do
query
end
defp exclude_notification_muted(query, user, opts) do
notification_muted_ap_ids =
opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user)
query
|> where([..., user_actor: user_actor], user_actor.ap_id not in ^notification_muted_ap_ids)
|> join(:left, [n, a], tm in ThreadMute,
on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data),
as: :thread_mute
)
|> where([thread_mute: thread_mute], is_nil(thread_mute.user_id))
end
defp exclude_filtered(query, user) do
case Pleroma.Filter.compose_regex(user) do
nil ->
query
regex ->
from([_n, a, o] in query,
where:
fragment("not(?->>'content' ~* ?)", o.data, ^regex) or
fragment("?->>'content' is null", o.data) or
fragment("?->>'actor' = ?", o.data, ^user.ap_id)
)
end
end
@valid_visibilities ~w[direct unlisted public private]
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when is_list(visibility) do
if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
query
|> join(:left, [n, a], mutated_activity in Pleroma.Activity,
on:
fragment(
"associated_object_id(?)",
a.data
) ==
fragment(
"associated_object_id(?)",
mutated_activity.data
) and
fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and
fragment("?->>'type'", mutated_activity.data) == "Create",
as: :mutated_activity
)
|> where(
[n, a, mutated_activity: mutated_activity],
not fragment(
"""
CASE WHEN (?->>'type') = 'Like' or (?->>'type') = 'Announce'
THEN (activity_visibility(?, ?, ?) = ANY (?))
ELSE (activity_visibility(?, ?, ?) = ANY (?)) END
""",
a.data,
a.data,
mutated_activity.actor,
mutated_activity.recipients,
mutated_activity.data,
^visibility,
a.actor,
a.recipients,
a.data,
^visibility
)
)
else
Logger.error("Could not exclude visibility to #{visibility}")
query
end
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility in @valid_visibilities do
exclude_visibility(query, [visibility])
end
defp exclude_visibility(query, %{exclude_visibilities: visibility})
when visibility not in @valid_visibilities do
Logger.error("Could not exclude visibility to #{visibility}")
query
end
defp exclude_visibility(query, _visibility), do: query
def for_user(user, opts \\ %{}) do
user
|> for_user_query(opts)
|> Pagination.fetch_paginated(opts)
end
@doc """
Returns notifications for user received since given date.
## Examples
iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33])
[%Pleroma.Notification{}, %Pleroma.Notification{}]
iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33])
[]
"""
@spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()]
def for_user_since(user, date) do
from(n in for_user_query(user),
where: n.updated_at > ^date
)
|> Repo.all()
end
def set_read_up_to(%{id: user_id} = user, id) do
query =
from(
n in Notification,
where: n.user_id == ^user_id,
where: n.id <= ^id,
where: n.seen == false,
# Ideally we would preload object and activities here
# but Ecto does not support preloads in update_all
select: n.id
)
{:ok, %{marker: marker}} =
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
Streamer.stream(["user", "user:notification"], marker)
{:ok, %{marker: marker}}
end
@spec read_one(User.t(), String.t()) ::
{:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil
def read_one(%User{} = user, notification_id) do
with {:ok, %Notification{} = notification} <- get(user, notification_id) do
Multi.new()
|> Multi.update(:update, changeset(notification, %{seen: true}))
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
end
end
def get(%{id: user_id} = _user, id) do
query =
from(
n in Notification,
where: n.id == ^id,
join: activity in assoc(n, :activity),
preload: [activity: activity]
)
notification = Repo.one(query)
case notification do
%{user_id: ^user_id} ->
{:ok, notification}
_ ->
{:error, "Cannot get notification"}
end
end
def clear(user) do
from(n in Notification, where: n.user_id == ^user.id)
|> Repo.delete_all()
end
def destroy_multiple(%{id: user_id} = _user, ids) do
from(n in Notification,
where: n.id in ^ids,
where: n.user_id == ^user_id
)
|> Repo.delete_all()
end
def dismiss(%Pleroma.Activity{} = activity) do
Notification
|> where([n], n.activity_id == ^activity.id)
|> Repo.delete_all()
|> case do
{_, notifications} -> {:ok, notifications}
_ -> {:error, "Cannot dismiss notification"}
end
end
def dismiss(%{id: user_id} = _user, id) do
notification = Repo.get(Notification, id)
case notification do
%{user_id: ^user_id} ->
Repo.delete(notification)
_ ->
{:error, "Cannot dismiss notification"}
end
end
@spec create_notifications(Activity.t()) :: {:ok, [Notification.t()] | []}
def create_notifications(activity)
def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do
object = Object.normalize(activity, fetch: false)
if object && object.data["type"] == "Answer" do
{:ok, []}
else
do_create_notifications(activity)
end
end
def create_notifications(%Activity{data: %{"type" => type}} = activity)
when type in ["Follow", "Like", "Announce", "Move", "EmojiReact", "Flag", "Update"] do
do_create_notifications(activity)
end
def create_notifications(_), do: {:ok, []}
defp do_create_notifications(%Activity{} = activity) do
enabled_receivers = get_notified_from_activity(activity)
enabled_subscribers = get_notified_subscribers_from_activity(activity)
notifications =
(Enum.map(enabled_receivers, fn user ->
create_notification(activity, user)
end) ++
Enum.map(enabled_subscribers -- enabled_receivers, fn user ->
create_notification(activity, user, type: "status")
end))
|> Enum.reject(&is_nil/1)
{:ok, notifications}
end
defp type_from_activity(%{data: %{"type" => type}} = activity) do
case type do
"Follow" ->
if Activity.follow_accepted?(activity) do
"follow"
else
"follow_request"
end
"Announce" ->
"reblog"
"Like" ->
"favourite"
"Move" ->
"move"
"EmojiReact" ->
"pleroma:emoji_reaction"
"Flag" ->
"pleroma:report"
# Compatibility with old reactions
"EmojiReaction" ->
"pleroma:emoji_reaction"
"Create" ->
activity
|> type_from_activity_object()
"Update" ->
"update"
t ->
raise "No notification type for activity type #{t}"
end
end
defp type_from_activity_object(%{data: %{"type" => "Create", "object" => %{}}}), do: "mention"
defp type_from_activity_object(%{data: %{"type" => "Create"}} = activity) do
object = Object.get_by_ap_id(activity.data["object"])
case object && object.data["type"] do
"ChatMessage" -> "pleroma:chat_mention"
_ -> "mention"
end
end
# TODO move to sql, too.
def create_notification(%Activity{} = activity, %User{} = user, opts \\ []) do
type = Keyword.get(opts, :type, type_from_activity(activity))
unless skip?(activity, user, opts) do
{:ok, %{notification: notification}} =
Multi.new()
|> Multi.insert(:notification, %Notification{
user_id: user.id,
activity: activity,
seen: mark_as_read?(activity, user),
- type: type
+ type: type,
+ group_key: grouped_notification_key(type, activity)
})
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
notification
end
end
def create_poll_notifications(%Activity{} = activity) do
with %Object{data: %{"type" => "Question", "actor" => actor} = data} <-
Object.normalize(activity) do
voters =
case data do
%{"voters" => voters} when is_list(voters) -> voters
_ -> []
end
notifications =
Enum.reduce([actor | voters], [], fn ap_id, acc ->
with %User{local: true} = user <- User.get_by_ap_id(ap_id) do
[create_notification(activity, user, type: "poll") | acc]
else
_ -> acc
end
end)
{:ok, notifications}
end
end
@doc """
Returns a tuple with 2 elements:
{notification-enabled receivers, currently disabled receivers (blocking / [thread] muting)}
NOTE: might be called for FAKE Activities, see ActivityPub.Utils.get_notified_from_object/1
"""
@spec get_notified_from_activity(Activity.t(), boolean()) :: list(User.t())
def get_notified_from_activity(activity, local_only \\ true)
def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
when type in [
"Create",
"Like",
"Announce",
"Follow",
"Move",
"EmojiReact",
"Flag",
"Update"
] do
potential_receiver_ap_ids = get_potential_receiver_ap_ids(activity)
potential_receivers =
User.get_users_from_set(potential_receiver_ap_ids, local_only: local_only)
notification_enabled_ap_ids =
potential_receiver_ap_ids
|> exclude_domain_blocker_ap_ids(activity, potential_receivers)
|> exclude_relationship_restricted_ap_ids(activity)
|> exclude_thread_muter_ap_ids(activity)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_from_activity(_, _local_only), do: []
def get_notified_subscribers_from_activity(activity, local_only \\ true)
def get_notified_subscribers_from_activity(
%Activity{data: %{"type" => "Create"}} = activity,
local_only
) do
notification_enabled_ap_ids = Utils.get_notified_subscribers(activity)
potential_receivers =
User.get_users_from_set(notification_enabled_ap_ids, local_only: local_only)
Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
end
def get_notified_subscribers_from_activity(_, _), do: []
# For some activities, only notify the author of the object
def get_potential_receiver_ap_ids(%{data: %{"type" => type, "object" => object_id}})
when type in ~w{Like Announce EmojiReact} do
case Object.get_cached_by_ap_id(object_id) do
%Object{data: %{"actor" => actor}} ->
[actor]
_ ->
[]
end
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Follow", "object" => object_id}}) do
[object_id]
end
def get_potential_receiver_ap_ids(%{data: %{"type" => "Flag", "actor" => actor}}) do
(User.all_users_with_privilege(:reports_manage_reports)
|> Enum.map(fn user -> user.ap_id end)) --
[actor]
end
# Update activity: notify all who repeated this
def get_potential_receiver_ap_ids(%{data: %{"type" => "Update", "actor" => actor}} = activity) do
with %Object{data: %{"id" => object_id}} <- Object.normalize(activity, fetch: false) do
repeaters =
Activity.Queries.by_type("Announce")
|> Activity.Queries.by_object_id(object_id)
|> Activity.with_joined_user_actor()
|> where([a, u], u.local)
|> select([a, u], u.ap_id)
|> Repo.all()
repeaters -- [actor]
end
end
def get_potential_receiver_ap_ids(activity) do
[]
|> Utils.maybe_notify_to_recipients(activity)
|> Utils.maybe_notify_mentioned_recipients(activity)
|> Utils.maybe_notify_followers(activity)
|> Enum.uniq()
end
@doc "Filters out AP IDs domain-blocking and not following the activity's actor"
def exclude_domain_blocker_ap_ids(ap_ids, activity, preloaded_users \\ [])
def exclude_domain_blocker_ap_ids([], _activity, _preloaded_users), do: []
def exclude_domain_blocker_ap_ids(ap_ids, %Activity{} = activity, preloaded_users) do
activity_actor_domain = activity.actor && URI.parse(activity.actor).host
users =
ap_ids
|> Enum.map(fn ap_id ->
Enum.find(preloaded_users, &(&1.ap_id == ap_id)) ||
User.get_cached_by_ap_id(ap_id)
end)
|> Enum.filter(& &1)
domain_blocker_ap_ids = for u <- users, activity_actor_domain in u.domain_blocks, do: u.ap_id
domain_blocker_follower_ap_ids =
if Enum.any?(domain_blocker_ap_ids) do
activity
|> Activity.user_actor()
|> FollowingRelationship.followers_ap_ids(domain_blocker_ap_ids)
else
[]
end
ap_ids
|> Kernel.--(domain_blocker_ap_ids)
|> Kernel.++(domain_blocker_follower_ap_ids)
end
@doc "Filters out AP IDs of users basing on their relationships with activity actor user"
def exclude_relationship_restricted_ap_ids([], _activity), do: []
def exclude_relationship_restricted_ap_ids(ap_ids, %Activity{} = activity) do
relationship_restricted_ap_ids =
activity
|> Activity.user_actor()
|> User.incoming_relationships_ungrouped_ap_ids([
:block,
:notification_mute
])
Enum.uniq(ap_ids) -- relationship_restricted_ap_ids
end
@doc "Filters out AP IDs of users who mute activity thread"
def exclude_thread_muter_ap_ids([], _activity), do: []
def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do
thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"])
Enum.uniq(ap_ids) -- thread_muter_ap_ids
end
def skip?(activity, user, opts \\ [])
@spec skip?(Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(%Activity{} = activity, %User{} = user, opts) do
[
:self,
:internal,
:invisible,
:block_from_strangers,
:recently_followed,
:filtered
]
|> Enum.find(&skip?(&1, activity, user, opts))
end
def skip?(_activity, _user, _opts), do: false
@spec skip?(atom(), Activity.t(), User.t(), Keyword.t()) :: boolean()
def skip?(:self, %Activity{} = activity, %User{} = user, opts) do
cond do
opts[:type] == "poll" -> false
activity.data["actor"] == user.ap_id -> true
true -> false
end
end
def skip?(:internal, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor)
User.internal?(user)
end
def skip?(:invisible, %Activity{} = activity, _user, _opts) do
actor = activity.data["actor"]
user = User.get_cached_by_ap_id(actor)
User.invisible?(user)
end
def skip?(
:block_from_strangers,
%Activity{} = activity,
%User{notification_settings: %{block_from_strangers: true}} = user,
opts
) do
actor = activity.data["actor"]
follower = User.get_cached_by_ap_id(actor)
cond do
opts[:type] == "poll" -> false
user.ap_id == actor -> false
!User.following?(user, follower) -> true
true -> false
end
end
# To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
def skip?(
:recently_followed,
%Activity{data: %{"type" => "Follow"}} = activity,
%User{} = user,
_opts
) do
actor = activity.data["actor"]
Notification.for_user(user)
|> Enum.any?(fn
%{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true
_ -> false
end)
end
def skip?(:filtered, %{data: %{"type" => type}}, _user, _opts) when type in ["Follow", "Move"],
do: false
def skip?(:filtered, activity, user, _opts) do
object = Object.normalize(activity, fetch: false)
cond do
is_nil(object) ->
false
object.data["actor"] == user.ap_id ->
false
not is_nil(regex = Pleroma.Filter.compose_regex(user, :re)) ->
Regex.match?(regex, object.data["content"])
true ->
false
end
end
def skip?(_type, _activity, _user, _opts), do: false
def mark_as_read?(activity, target_user) do
user = Activity.user_actor(activity)
User.mutes_user?(target_user, user) || CommonAPI.thread_muted?(activity, target_user)
end
def for_user_and_activity(user, activity) do
from(n in __MODULE__,
where: n.user_id == ^user.id,
where: n.activity_id == ^activity.id
)
|> Repo.one()
end
@spec mark_context_as_read(User.t(), String.t()) :: {integer(), nil | [term()]}
def mark_context_as_read(%User{id: id}, context) do
from(
n in Notification,
join: a in assoc(n, :activity),
where: n.user_id == ^id,
where: n.seen == false,
where: fragment("?->>'context'", a.data) == ^context
)
|> Repo.update_all(set: [seen: true])
end
@doc "Streams a list of notifications over websockets and web push"
@spec stream(list(Notification.t())) :: :ok
def stream(notifications) do
Enum.each(notifications, fn notification ->
Streamer.stream(["user", "user:notification"], notification)
Push.send(notification)
end)
end
end
diff --git a/lib/pleroma/web/api_spec/operations/notification_operation.ex b/lib/pleroma/web/api_spec/operations/notification_operation.ex
index 94d1f6b82..6c8f7d5e9 100644
--- a/lib/pleroma/web/api_spec/operations/notification_operation.ex
+++ b/lib/pleroma/web/api_spec/operations/notification_operation.ex
@@ -1,242 +1,400 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.NotificationOperation do
- alias OpenApiSpex.Operation
alias OpenApiSpex.Operation
alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account
alias Pleroma.Web.ApiSpec.Schemas.ApiError
alias Pleroma.Web.ApiSpec.Schemas.BooleanLike
alias Pleroma.Web.ApiSpec.Schemas.Status
alias Pleroma.Web.ApiSpec.Schemas.VisibilityScope
import Pleroma.Web.ApiSpec.Helpers
def open_api_operation(action) do
operation = String.to_existing_atom("#{action}_operation")
apply(__MODULE__, operation, [])
end
def index_operation do
%Operation{
tags: ["Notifications"],
summary: "Retrieve a list of notifications",
description:
"Notifications concerning the user. This API returns Link headers containing links to the next/previous page. However, the links can also be constructed dynamically using query params and `id` values.",
operationId: "NotificationController.index",
security: [%{"oAuth" => ["read:notifications"]}],
parameters:
[
Operation.parameter(
:exclude_types,
:query,
%Schema{type: :array, items: notification_type()},
"Array of types to exclude"
),
Operation.parameter(
:account_id,
:query,
%Schema{type: :string},
"Return only notifications received from this account"
),
Operation.parameter(
:exclude_visibilities,
:query,
%Schema{type: :array, items: VisibilityScope},
"Exclude the notifications for activities with the given visibilities"
),
Operation.parameter(
:include_types,
:query,
%Schema{type: :array, items: notification_type()},
"Deprecated, use `types` instead"
),
Operation.parameter(
:types,
:query,
%Schema{type: :array, items: notification_type()},
"Include the notifications for activities with the given types"
),
Operation.parameter(
:with_muted,
:query,
BooleanLike.schema(),
"Include the notifications from muted users"
)
] ++ pagination_params(),
responses: %{
200 =>
Operation.response("Array of notifications", "application/json", %Schema{
type: :array,
items: notification()
}),
404 => Operation.response("Error", "application/json", ApiError)
}
}
end
def show_operation do
%Operation{
tags: ["Notifications"],
summary: "Retrieve a notification",
description: "View information about a notification with a given ID.",
operationId: "NotificationController.show",
security: [%{"oAuth" => ["read:notifications"]}],
parameters: [id_param()],
responses: %{
200 => Operation.response("Notification", "application/json", notification())
}
}
end
+ def grouped_index_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve grouped notifications",
+ description: "Notifications concerning the user, grouped by supported notification types.",
+ operationId: "NotificationController.grouped_index",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters: grouped_notification_params() ++ pagination_params(),
+ responses: %{
+ 200 => Operation.response("Grouped notifications", "application/json", grouped_result()),
+ 404 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
+ def show_group_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve a notification group",
+ operationId: "NotificationController.show_group",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters: [group_key_param()],
+ responses: %{
+ 200 => Operation.response("Grouped notifications", "application/json", grouped_result()),
+ 404 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
+ def group_accounts_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve notification group accounts",
+ operationId: "NotificationController.group_accounts",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters: [group_key_param()],
+ responses: %{
+ 200 =>
+ Operation.response("Accounts", "application/json", %Schema{
+ type: :array,
+ items: Account
+ })
+ }
+ }
+ end
+
+ def unread_count_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Retrieve unread grouped notification count",
+ operationId: "NotificationController.unread_count",
+ security: [%{"oAuth" => ["read:notifications"]}],
+ parameters:
+ grouped_notification_params() ++
+ [
+ Operation.parameter(
+ :limit,
+ :query,
+ %Schema{type: :integer},
+ "Maximum number of notifications to count"
+ )
+ ],
+ responses: %{
+ 200 =>
+ Operation.response("Unread notification group count", "application/json", %Schema{
+ type: :object,
+ properties: %{count: %Schema{type: :integer}}
+ }),
+ 404 => Operation.response("Error", "application/json", ApiError)
+ }
+ }
+ end
+
def clear_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss all notifications",
description: "Clear all notifications from the server.",
operationId: "NotificationController.clear",
security: [%{"oAuth" => ["write:notifications"]}],
responses: %{200 => empty_object_response()}
}
end
def dismiss_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss a notification",
description: "Clear a single notification from the server.",
operationId: "NotificationController.dismiss",
parameters: [id_param()],
security: [%{"oAuth" => ["write:notifications"]}],
responses: %{200 => empty_object_response()}
}
end
+ def dismiss_group_operation do
+ %Operation{
+ tags: ["Notifications"],
+ summary: "Dismiss a notification group",
+ description: "Clear all notifications in a notification group from the server.",
+ operationId: "NotificationController.dismiss_group",
+ parameters: [group_key_param()],
+ security: [%{"oAuth" => ["write:notifications"]}],
+ responses: %{200 => empty_object_response()}
+ }
+ end
+
def dismiss_via_body_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss a single notification",
deprecated: true,
description: "Clear a single notification from the server.",
operationId: "NotificationController.dismiss_via_body",
requestBody:
request_body(
"Parameters",
%Schema{type: :object, properties: %{id: %Schema{type: :string}}},
required: true
),
security: [%{"oAuth" => ["write:notifications"]}],
responses: %{200 => empty_object_response()}
}
end
def destroy_multiple_operation do
%Operation{
tags: ["Notifications"],
summary: "Dismiss multiple notifications",
operationId: "NotificationController.destroy_multiple",
security: [%{"oAuth" => ["write:notifications"]}],
parameters: [
Operation.parameter(
:ids,
:query,
%Schema{type: :array, items: %Schema{type: :string}},
"Array of notification IDs to dismiss",
required: true
)
],
responses: %{200 => empty_object_response()}
}
end
def notification do
%Schema{
title: "Notification",
description: "Response schema for a notification",
type: :object,
properties: %{
id: %Schema{type: :string},
group_key: %Schema{
type: :string,
description: "Group key shared by similar notifications"
},
type: notification_type(),
created_at: %Schema{type: :string, format: :"date-time"},
account: %Schema{
allOf: [Account],
description: "The account that performed the action that generated the notification."
},
status: %Schema{
allOf: [Status],
description:
"Status that was the object of the notification, e.g. in mentions, reblogs, favourites, or polls.",
nullable: true
},
pleroma: %Schema{
type: :object,
properties: %{
is_seen: %Schema{type: :boolean},
is_muted: %Schema{type: :boolean}
}
}
},
example: %{
"id" => "34975861",
- "group-key" => "ungrouped-34975861",
+ "group_key" => "ungrouped-34975861",
"type" => "mention",
"created_at" => "2019-11-23T07:49:02.064Z",
"account" => Account.schema().example,
"status" => Status.schema().example,
"pleroma" => %{"is_seen" => false, "is_muted" => false}
}
}
end
+ defp grouped_result do
+ %Schema{
+ title: "GroupedNotificationsResults",
+ type: :object,
+ properties: %{
+ accounts: %Schema{type: :array, items: Account},
+ statuses: %Schema{type: :array, items: Status},
+ notification_groups: %Schema{type: :array, items: notification_group()}
+ },
+ required: [:accounts, :statuses, :notification_groups]
+ }
+ end
+
+ defp notification_group do
+ %Schema{
+ title: "NotificationGroup",
+ type: :object,
+ properties: %{
+ group_key: %Schema{type: :string},
+ notifications_count: %Schema{type: :integer},
+ type: notification_type(),
+ most_recent_notification_id: %Schema{type: :string},
+ page_min_id: %Schema{type: :string, nullable: true},
+ page_max_id: %Schema{type: :string, nullable: true},
+ latest_page_notification_at: %Schema{type: :string, format: :"date-time", nullable: true},
+ sample_account_ids: %Schema{type: :array, items: %Schema{type: :string}},
+ status_id: %Schema{type: :string, nullable: true}
+ },
+ required: [
+ :group_key,
+ :notifications_count,
+ :type,
+ :most_recent_notification_id,
+ :sample_account_ids
+ ]
+ }
+ end
+
+ defp grouped_notification_params do
+ [
+ Operation.parameter(
+ :exclude_types,
+ :query,
+ %Schema{type: :array, items: notification_type()},
+ "Array of types to exclude"
+ ),
+ Operation.parameter(
+ :account_id,
+ :query,
+ %Schema{type: :string},
+ "Return only notifications received from this account"
+ ),
+ Operation.parameter(
+ :types,
+ :query,
+ %Schema{type: :array, items: notification_type()},
+ "Include the notifications for activities with the given types"
+ ),
+ Operation.parameter(
+ :grouped_types,
+ :query,
+ %Schema{type: :array, items: notification_type()},
+ "Notification types that may be grouped"
+ )
+ ]
+ end
+
defp notification_type do
%Schema{
type: :string,
enum: [
"follow",
"favourite",
"reblog",
"mention",
"pleroma:emoji_reaction",
"pleroma:chat_mention",
"pleroma:report",
"move",
"follow_request",
"poll",
"status",
"update",
"admin.sign_up",
"admin.report"
],
description: """
The type of event that resulted in the notification.
- `follow` - Someone followed you
- `mention` - Someone mentioned you in their status
- `reblog` - Someone boosted one of your statuses
- `favourite` - Someone favourited one of your statuses
- `poll` - A poll you have voted in or created has ended
- `move` - Someone moved their account
- `pleroma:emoji_reaction` - Someone reacted with emoji to your status
- `pleroma:chat_mention` - Someone mentioned you in a chat message
- `pleroma:report` - Someone was reported
- `status` - Someone you are subscribed to created a status
- `update` - A status you boosted has been edited
- `admin.sign_up` - Someone signed up (optionally sent to admins)
- `admin.report` - A new report has been filed
"""
}
end
defp id_param do
Operation.parameter(:id, :path, :string, "Notification ID",
example: "123",
required: true
)
end
+
+ defp group_key_param do
+ Operation.parameter(:group_key, :path, :string, "Notification group key",
+ example: "favourite-123",
+ required: true
+ )
+ end
end
diff --git a/lib/pleroma/web/controller_helper.ex b/lib/pleroma/web/controller_helper.ex
index b15b0ea81..0c85e79f9 100644
--- a/lib/pleroma/web/controller_helper.ex
+++ b/lib/pleroma/web/controller_helper.ex
@@ -1,143 +1,155 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ControllerHelper do
use Pleroma.Web, :controller
alias Pleroma.Pagination
alias Pleroma.Web.Utils.Params
def json_response(conn, status, _) when status in [204, :no_content] do
conn
|> put_resp_header("content-type", "application/json")
|> send_resp(status, "")
end
def json_response(conn, status, json) do
conn
|> put_status(status)
|> json(json)
end
@spec fetch_integer_param(map(), String.t() | atom(), integer() | nil) :: integer() | nil
def fetch_integer_param(params, name, default \\ nil) do
params
|> Map.get(name, default)
|> param_to_integer(default)
end
defp param_to_integer(val, _) when is_integer(val), do: val
defp param_to_integer(val, default) when is_binary(val) do
case Integer.parse(val) do
{res, _} -> res
_ -> default
end
end
defp param_to_integer(_, default), do: default
def add_link_headers(conn, entries, extra_params \\ %{})
def add_link_headers(%{assigns: %{skip_link_headers: true}} = conn, _entries, _extra_params),
do: conn
def add_link_headers(conn, entries, extra_params) do
case get_pagination_fields(conn, entries, extra_params) do
%{"next" => next_url, "prev" => prev_url} ->
put_resp_header(conn, "link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
_ ->
conn
end
end
# TODO: Only fetch the params from open_api_spex when everything is converted
@id_keys Pagination.page_keys() -- ["limit", "order"]
+ @id_key_atoms Enum.map(@id_keys, &String.to_atom/1)
+ @drop_id_params_key :drop_id_params
defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do
+ drop_id_params? = Map.get(extra_params, @drop_id_params_key, false)
+ extra_params = Map.delete(extra_params, @drop_id_params_key)
+
+ path_param_keys =
+ conn.path_params
+ |> Map.keys()
+ |> Enum.flat_map(&[&1, String.to_existing_atom(&1)])
+
params =
if Map.has_key?(conn.private, :open_api_spex) do
get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)])
else
conn.params
end
- |> Map.drop(Map.keys(conn.path_params) |> Enum.map(&String.to_existing_atom/1))
+ |> Map.drop(path_param_keys)
|> Map.merge(extra_params)
- |> Map.drop(@id_keys)
+ # Some OpenApiSpex routes cast cursor params to atoms. Keep the historical default for
+ # existing endpoints, but allow grouped notifications to opt out of stale request cursors.
+ |> Map.drop(@id_keys ++ if(drop_id_params?, do: @id_key_atoms, else: []))
{{next_id, nid}, {prev_id, pid}} =
if order == :desc,
do: {{:max_id, max_id}, {:min_id, min_id}},
else: {{:min_id, min_id}, {:max_id, max_id}}
id = Phoenix.Controller.current_url(conn)
base_id = %{URI.parse(id) | query: nil} |> URI.to_string()
%{
"next" => current_url(conn, Map.put(params, next_id, nid)),
"prev" => current_url(conn, Map.put(params, prev_id, pid)),
"id" => id,
"partOf" => base_id
}
end
defp get_first_last_pagination_id(entries) do
case List.last(entries) do
%{pagination_id: last_id} when not is_nil(last_id) ->
%{pagination_id: first_id} = List.first(entries)
{first_id, last_id}
%{id: last_id} ->
%{id: first_id} = List.first(entries)
{first_id, last_id}
_ ->
nil
end
end
def get_pagination_fields(conn, entries, extra_params \\ %{}, order \\ :desc)
def get_pagination_fields(conn, entries, extra_params, :desc) do
case get_first_last_pagination_id(entries) do
nil -> %{}
{min_id, max_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :desc)
end
end
def get_pagination_fields(conn, entries, extra_params, :asc) do
case get_first_last_pagination_id(entries) do
nil -> %{}
{max_id, min_id} -> build_pagination_fields(conn, min_id, max_id, extra_params, :asc)
end
end
def assign_account_by_id(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
case Pleroma.User.get_cached_by_id(id) do
%Pleroma.User{} = account -> assign(conn, :account, account)
nil -> Pleroma.Web.MastodonAPI.FallbackController.call(conn, {:error, :not_found}) |> halt()
end
end
def try_render(conn, target, params) when is_binary(target) do
render(conn, target, params)
end
def try_render(conn, _, _) do
render_error(conn, :not_implemented, "Can't display this activity")
end
@doc """
Returns true if request specifies to include embedded relationships in account objects.
May only be used in selected account-related endpoints; has no effect for status- or
notification-related endpoints.
"""
# Intended for PleromaFE: https://git.pleroma.social/pleroma/pleroma-fe/-/issues/838
def embed_relationships?(params) do
# To do once OpenAPI transition mess is over: just `truthy_param?(params[:with_relationships])`
params
|> Map.get(:with_relationships, params["with_relationships"])
|> Params.truthy_param?()
end
end
diff --git a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
index afd83b785..8a5f8e7b6 100644
--- a/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
+++ b/lib/pleroma/web/mastodon_api/controllers/notification_controller.ex
@@ -1,128 +1,259 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationController do
use Pleroma.Web, :controller
- import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
+ import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2, add_link_headers: 3]
alias Pleroma.Notification
+ alias Pleroma.User
+ alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.MastodonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
- @oauth_read_actions [:show, :index]
+ # Mastodon's docs currently list write:notifications for group accounts, but the endpoint is
+ # read-only and Mastodon's implementation accepts read:notifications. Prefer least privilege.
+ @oauth_read_actions [:show, :index, :grouped_index, :show_group, :group_accounts, :unread_count]
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
plug(
OAuthScopesPlug,
%{scopes: ["read:notifications"]} when action in @oauth_read_actions
)
plug(OAuthScopesPlug, %{scopes: ["write:notifications"]} when action not in @oauth_read_actions)
defdelegate open_api_operation(action), to: Pleroma.Web.ApiSpec.NotificationOperation
@default_notification_types ~w{
mention
follow
follow_request
reblog
favourite
move
pleroma:emoji_reaction
poll
update
status
}
# GET /api/v1/notifications
def index(%{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, _) do
- case Pleroma.User.get_cached_by_id(account_id) do
+ case User.get_cached_by_id(account_id) do
%{ap_id: account_ap_id} ->
params =
params
|> Map.delete(:account_id)
|> Map.put(:account_ap_id, account_ap_id)
do_get_notifications(conn, params)
_ ->
conn
|> put_status(:not_found)
|> json(%{"error" => "Account is not found"})
end
end
def index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do
do_get_notifications(conn, params)
end
+ # GET /api/v2/notifications
+ def grouped_index(
+ %{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn,
+ _
+ ) do
+ case User.get_cached_by_id(account_id) do
+ %{ap_id: account_ap_id} ->
+ params =
+ params
+ |> Map.delete(:account_id)
+ |> Map.put(:account_ap_id, account_ap_id)
+
+ do_get_grouped_notifications(conn, params)
+
+ _ ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Account is not found"})
+ end
+ end
+
+ def grouped_index(%{private: %{open_api_spex: %{params: params}}} = conn, _) do
+ do_get_grouped_notifications(conn, params)
+ end
+
+ # GET /api/v2/notifications/:group_key
+ def show_group(
+ %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} =
+ conn,
+ _
+ ) do
+ {notifications, notification_group_counts, notification_group_bounds} =
+ MastodonAPI.get_notification_group_result(user, group_key, %{})
+
+ if Enum.empty?(notifications) do
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Notification group is not found"})
+ else
+ grouped_types = if String.starts_with?(group_key, "ungrouped-"), do: [], else: nil
+
+ render(conn, "grouped_index.json",
+ notification_groups: [notifications],
+ notification_group_counts: notification_group_counts,
+ notification_group_bounds: notification_group_bounds,
+ for: user,
+ grouped_types: grouped_types,
+ include_page_metadata: false
+ )
+ end
+ end
+
+ # GET /api/v2/notifications/:group_key/accounts
+ def group_accounts(
+ %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} =
+ conn,
+ _
+ ) do
+ # Mastodon paginates this endpoint in code, but the public docs say it returns accounts of all
+ # notifications in the group and do not document cursor params here. Follow the documented API.
+ users = MastodonAPI.get_notification_group_accounts(user, group_key)
+
+ json(conn, AccountView.render("index.json", %{users: users, for: user}))
+ end
+
+ # GET /api/v2/notifications/unread_count
+ def unread_count(
+ %{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn,
+ _
+ ) do
+ case User.get_cached_by_id(account_id) do
+ %{ap_id: account_ap_id} ->
+ params =
+ params
+ |> Map.delete(:account_id)
+ |> Map.put(:account_ap_id, account_ap_id)
+
+ do_get_unread_group_count(conn, params)
+
+ _ ->
+ conn
+ |> put_status(:not_found)
+ |> json(%{"error" => "Account is not found"})
+ end
+ end
+
+ def unread_count(%{private: %{open_api_spex: %{params: params}}} = conn, _) do
+ do_get_unread_group_count(conn, params)
+ end
+
+ # POST /api/v2/notifications/:group_key/dismiss
+ def dismiss_group(
+ %{assigns: %{user: user}, private: %{open_api_spex: %{params: %{group_key: group_key}}}} =
+ conn,
+ _
+ ) do
+ MastodonAPI.dismiss_notification_group(user, group_key)
+ json(conn, %{})
+ end
+
defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do
- params =
- Map.new(params, fn {k, v} -> {to_string(k), v} end)
- |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
+ params = normalize_notification_params(params)
notifications = MastodonAPI.get_notifications(user, params)
conn
|> add_link_headers(notifications)
|> render("index.json",
notifications: notifications,
for: user
)
end
+ defp do_get_grouped_notifications(%{assigns: %{user: user}} = conn, params) do
+ params = normalize_notification_params(params)
+
+ {notification_groups, page_notifications, notification_group_counts,
+ notification_group_bounds} =
+ MastodonAPI.get_grouped_notification_page(user, params)
+
+ conn
+ |> add_link_headers(page_notifications, %{drop_id_params: true})
+ |> render("grouped_index.json",
+ notification_groups: notification_groups,
+ notification_group_counts: notification_group_counts,
+ notification_group_bounds: notification_group_bounds,
+ for: user,
+ grouped_types: params["grouped_types"]
+ )
+ end
+
+ defp do_get_unread_group_count(%{assigns: %{user: user}} = conn, params) do
+ params = normalize_notification_params(params)
+ json(conn, %{count: MastodonAPI.unread_notification_group_count(user, params)})
+ end
+
+ defp normalize_notification_params(params) do
+ params
+ |> Map.new(fn {k, v} -> {to_string(k), v} end)
+ |> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
+ end
+
# GET /api/v1/notifications/:id
def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, notification} <- Notification.get(user, id) do
render(conn, "show.json", notification: notification, for: user)
else
{:error, reason} ->
conn
|> put_status(:forbidden)
|> json(%{"error" => reason})
end
end
# POST /api/v1/notifications/clear
def clear(%{assigns: %{user: user}} = conn, _params) do
Notification.clear(user)
json(conn, %{})
end
# POST /api/v1/notifications/:id/dismiss
def dismiss(%{private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
do_dismiss(conn, id)
end
# POST /api/v1/notifications/dismiss (deprecated)
def dismiss_via_body(
%{private: %{open_api_spex: %{body_params: %{id: id}}}} = conn,
_
) do
do_dismiss(conn, id)
end
defp do_dismiss(%{assigns: %{user: user}} = conn, notification_id) do
with {:ok, _notif} <- Notification.dismiss(user, notification_id) do
json(conn, %{})
else
{:error, reason} ->
conn
|> put_status(:forbidden)
|> json(%{"error" => reason})
end
end
# DELETE /api/v1/notifications/destroy_multiple
def destroy_multiple(
%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{ids: ids}}}} = conn,
_
) do
Notification.destroy_multiple(user, ids)
json(conn, %{})
end
end
diff --git a/lib/pleroma/web/mastodon_api/mastodon_api.ex b/lib/pleroma/web/mastodon_api/mastodon_api.ex
index c9e045d23..d6f2dd3a3 100644
--- a/lib/pleroma/web/mastodon_api/mastodon_api.ex
+++ b/lib/pleroma/web/mastodon_api/mastodon_api.ex
@@ -1,121 +1,454 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
import Ecto.Query
import Ecto.Changeset
+ alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Pagination
+ alias Pleroma.Repo
alias Pleroma.ScheduledActivity
alias Pleroma.User
alias Pleroma.Web.CommonAPI
+ @notification_group_sample_limit 8
+
@spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()}
def follow(follower, followed, params \\ %{}) do
result =
if not User.following?(follower, followed) do
CommonAPI.follow(followed, follower)
else
{:ok, followed, follower, nil}
end
with {:ok, _followed, follower, _} <- result do
options = cast_params(params)
set_reblogs_visibility(options[:reblogs], result)
set_subscription(options[:notify], result)
{:ok, follower}
end
end
defp set_reblogs_visibility(false, {:ok, followed, follower, _}) do
CommonAPI.hide_reblogs(followed, follower)
end
defp set_reblogs_visibility(_, {:ok, followed, follower, _}) do
CommonAPI.show_reblogs(followed, follower)
end
defp set_subscription(true, {:ok, followed, follower, _}) do
User.subscribe(follower, followed)
end
defp set_subscription(false, {:ok, followed, follower, _}) do
User.unsubscribe(follower, followed)
end
defp set_subscription(_, _), do: {:ok, nil}
@spec get_followers(User.t(), map()) :: list(User.t())
def get_followers(user, params \\ %{}) do
user
|> User.get_followers_query()
|> Pagination.fetch_paginated(params)
end
def get_friends(user, params \\ %{}) do
user
|> User.get_friends_query()
|> Pagination.fetch_paginated(params)
end
def get_notifications(user, params \\ %{}) do
- options =
- cast_params(params) |> Map.update(:include_types, [], fn include_types -> include_types end)
+ user
+ |> notifications_query(params)
+ |> Pagination.fetch_paginated(params)
+ end
- options =
- if ("pleroma:report" not in options.include_types and
- User.privileged?(user, :reports_manage_reports)) or
- User.privileged?(user, :reports_manage_reports) do
- options
- else
- options
- |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types ->
- current_exclude_types ++ ["pleroma:report"]
- end)
- end
+ def get_grouped_notification_page(user, params \\ %{}) do
+ grouped_types =
+ params
+ |> Map.get("grouped_types")
+ |> Notification.normalize_grouped_types()
+
+ query = notifications_query(user, params)
+ {order, cursor_filters} = group_pagination(params)
+
+ group_rows =
+ query
+ |> notification_group_rows(grouped_types, grouped_limit(params), order, cursor_filters)
+
+ group_rows = if order == :asc, do: Enum.reverse(group_rows), else: group_rows
+
+ page_notifications = representative_notifications(query, group_rows)
+
+ notification_groups =
+ Enum.map(group_rows, ¬ification_group_sample(user, params, &1.group_key))
+
+ {
+ notification_groups,
+ page_notifications,
+ notification_group_counts(group_rows),
+ notification_group_bounds(group_rows)
+ }
+ end
+
+ def get_notification_group_result(user, group_key, params \\ %{}) do
+ notifications = notification_group_sample(user, params, group_key)
+ metadata = notification_group_metadata(user, group_key, params)
+
+ if Enum.empty?(notifications) or is_nil(metadata) do
+ {[], %{}, %{}}
+ else
+ {
+ notifications,
+ %{group_key => metadata.notifications_count},
+ %{group_key => Map.drop(metadata, [:notifications_count])}
+ }
+ end
+ end
+
+ def get_notification_group(user, group_key, params \\ %{})
+
+ def get_notification_group(user, "ungrouped-" <> notification_id, _params) do
+ case Notification.get(user, notification_id) do
+ {:ok, notification} -> [notification]
+ _ -> []
+ end
+ end
+
+ def get_notification_group(user, group_key, params) do
+ notification_group_sample(user, params, group_key)
+ end
+
+ def get_notification_group_accounts(user, "ungrouped-" <> notification_id) do
+ with {:ok, notification} <- Notification.get(user, notification_id),
+ %User{} = actor <- User.get_cached_by_ap_id(notification.activity.data["actor"]) do
+ [actor]
+ else
+ _ -> []
+ end
+ end
+
+ def get_notification_group_accounts(user, group_key) do
+ user
+ |> notification_group_query(group_key, %{})
+ |> exclude(:preload)
+ |> distinct(true)
+ |> select([user_actor: user_actor], user_actor)
+ |> Repo.all()
+ end
+
+ def dismiss_notification_group(user, "ungrouped-" <> notification_id) do
+ Notification.destroy_multiple(user, [notification_id])
+ end
+
+ def dismiss_notification_group(%User{id: user_id}, group_key) do
+ Notification
+ |> where([n], n.user_id == ^user_id and n.group_key == ^group_key)
+ |> Repo.delete_all()
+ end
+
+ defp group_pagination(params) do
+ cond do
+ min_id = Map.get(params, "min_id") ->
+ cursor_filters = [{:gt, min_id}]
+
+ cursor_filters =
+ case Map.get(params, "max_id") do
+ nil -> cursor_filters
+ max_id -> [{:lt, max_id} | cursor_filters]
+ end
+
+ {:asc, cursor_filters}
+
+ since_id = Map.get(params, "since_id") ->
+ {:desc, [{:gt, since_id}]}
+
+ max_id = Map.get(params, "max_id") ->
+ {:desc, [{:lt, max_id}]}
+
+ true ->
+ {:desc, []}
+ end
+ end
+
+ defp grouped_limit(params) do
+ params
+ |> Map.get("limit", 40)
+ |> parse_limit(40)
+ |> min(80)
+ end
+
+ def unread_notification_group_count(user, params \\ %{}) do
+ grouped_types =
+ params
+ |> Map.get("grouped_types")
+ |> Notification.normalize_grouped_types()
+
+ limit = unread_count_limit(params)
+
+ user
+ |> notifications_query(params)
+ # The grouped API docs define unread by the notifications marker, not by Pleroma's per-row
+ # seen flag used by the v1 unread count. Keep this marker-based for Mastodon clients.
+ |> restrict_after_marker(notification_marker_last_read_id(user))
+ |> notification_group_rows(grouped_types, limit, :desc, [])
+ |> length()
+ end
+
+ defp notification_group_rows(query, grouped_types, group_limit, :desc, cursor_filters) do
+ query
+ |> notification_group_keyed_query(grouped_types)
+ |> group_by([n], n.group_key)
+ |> apply_group_cursor_filters(cursor_filters)
+ |> select([n], %{
+ group_key: n.group_key,
+ representative_id: max(n.id),
+ notifications_count: count(n.id),
+ page_min_id: min(n.id),
+ page_max_id: max(n.id),
+ latest_page_notification_at: max(n.inserted_at)
+ })
+ |> order_by([n], desc: max(n.id))
+ |> limit(^group_limit)
+ |> Repo.all()
+ end
+
+ defp notification_group_rows(query, grouped_types, group_limit, :asc, cursor_filters) do
+ query
+ |> notification_group_keyed_query(grouped_types)
+ |> group_by([n], n.group_key)
+ |> apply_group_cursor_filters(cursor_filters)
+ |> select([n], %{
+ group_key: n.group_key,
+ representative_id: max(n.id),
+ notifications_count: count(n.id),
+ page_min_id: min(n.id),
+ page_max_id: max(n.id),
+ latest_page_notification_at: max(n.inserted_at)
+ })
+ |> order_by([n], asc: max(n.id))
+ |> limit(^group_limit)
+ |> Repo.all()
+ end
+
+ defp apply_group_cursor_filters(query, []), do: query
+
+ defp apply_group_cursor_filters(query, [{:gt, id} | rest]) do
+ query
+ |> having([n], max(n.id) > ^id)
+ |> apply_group_cursor_filters(rest)
+ end
+
+ defp apply_group_cursor_filters(query, [{:lt, id} | rest]) do
+ query
+ |> having([n], max(n.id) < ^id)
+ |> apply_group_cursor_filters(rest)
+ end
+
+ defp notification_group_keyed_query(query, grouped_types) do
+ query
+ |> exclude(:preload)
+ |> select([n], %{
+ id: n.id,
+ inserted_at: n.inserted_at,
+ group_key:
+ fragment(
+ "CASE WHEN ? IS NOT NULL AND ?::text = ANY(?) THEN ? ELSE 'ungrouped-' || ?::text END",
+ n.group_key,
+ n.type,
+ type(^grouped_types, {:array, :string}),
+ n.group_key,
+ n.id
+ )
+ })
+ |> subquery()
+ end
+
+ defp representative_notifications(_query, []), do: []
+
+ defp representative_notifications(query, group_rows) do
+ representative_ids = Enum.map(group_rows, & &1.representative_id)
+
+ notifications_by_id =
+ query
+ |> where([n], n.id in ^representative_ids)
+ |> Repo.all()
+ |> Map.new(&{to_string(&1.id), &1})
+
+ group_rows
+ |> Enum.map(&Map.get(notifications_by_id, to_string(&1.representative_id)))
+ |> Enum.filter(& &1)
+ end
+
+ defp notification_group_counts(group_rows) do
+ Map.new(group_rows, &{&1.group_key, &1.notifications_count})
+ end
+
+ defp notification_group_bounds(group_rows) do
+ Map.new(group_rows, fn row ->
+ {row.group_key,
+ %{
+ page_min_id: row.page_min_id,
+ page_max_id: row.page_max_id,
+ latest_page_notification_at: row.latest_page_notification_at
+ }}
+ end)
+ end
+
+ defp notification_group_sample(user, _params, "ungrouped-" <> notification_id) do
+ case Notification.get(user, notification_id) do
+ {:ok, notification} -> [notification]
+ _ -> []
+ end
+ end
+
+ defp notification_group_sample(user, params, group_key) do
+ user
+ |> notification_group_query(group_key, params)
+ |> order_by([n], desc: n.id)
+ |> limit(^@notification_group_sample_limit)
+ |> Repo.all()
+ end
+
+ defp notification_group_metadata(user, "ungrouped-" <> notification_id, _params) do
+ case Notification.get(user, notification_id) do
+ {:ok, notification} ->
+ %{
+ notifications_count: 1,
+ page_min_id: notification.id,
+ page_max_id: notification.id,
+ latest_page_notification_at: notification.inserted_at
+ }
+
+ _ ->
+ nil
+ end
+ end
+
+ defp notification_group_metadata(user, group_key, params) do
+ user
+ |> notification_group_query(group_key, params)
+ |> exclude(:preload)
+ |> select([n], %{
+ notifications_count: count(n.id),
+ page_min_id: min(n.id),
+ page_max_id: max(n.id),
+ latest_page_notification_at: max(n.inserted_at)
+ })
+ |> Repo.one()
+ |> case do
+ %{notifications_count: 0} -> nil
+ metadata -> metadata
+ end
+ end
+
+ defp notification_group_query(user, group_key, params) do
+ user
+ |> notifications_query(params)
+ |> where([n], n.group_key == ^group_key)
+ end
+
+ defp notification_marker_last_read_id(user) do
+ Marker
+ |> where([m], m.user_id == ^user.id and m.timeline == "notifications")
+ |> select([m], m.last_read_id)
+ |> Repo.one()
+ end
+
+ defp restrict_after_marker(query, last_read_id)
+ when is_binary(last_read_id) and last_read_id != "" do
+ where(query, [n], n.id > ^last_read_id)
+ end
+
+ defp restrict_after_marker(query, _last_read_id), do: query
+
+ defp notifications_query(user, params) do
+ options = notification_options(user, params)
user
|> Notification.for_user_query(options)
|> restrict(:types, options)
|> restrict(:exclude_types, options)
|> restrict(:account_ap_id, options)
- |> Pagination.fetch_paginated(params)
end
+ defp notification_options(user, params) do
+ options =
+ params
+ |> cast_params()
+ |> Map.update(:include_types, [], fn include_types -> include_types end)
+
+ if ("pleroma:report" not in options.include_types and
+ User.privileged?(user, :reports_manage_reports)) or
+ User.privileged?(user, :reports_manage_reports) do
+ options
+ else
+ options
+ |> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types ->
+ current_exclude_types ++ ["pleroma:report"]
+ end)
+ end
+ end
+
+ defp unread_count_limit(params) do
+ params
+ |> Map.get("limit", 100)
+ |> parse_limit(100)
+ |> min(1000)
+ end
+
+ defp parse_limit(limit, _default) when is_integer(limit) and limit > 0, do: limit
+
+ defp parse_limit(limit, default) when is_binary(limit) do
+ case Integer.parse(limit) do
+ {limit, _} when limit > 0 -> limit
+ _ -> default
+ end
+ end
+
+ defp parse_limit(_, default), do: default
+
def get_scheduled_activities(user, params \\ %{}) do
user
|> ScheduledActivity.for_user_query()
|> Pagination.fetch_paginated(params)
end
defp cast_params(params) do
param_types = %{
exclude_types: {:array, :string},
types: {:array, :string},
exclude_visibilities: {:array, :string},
+ grouped_types: {:array, :string},
+ limit: :integer,
reblogs: :boolean,
with_muted: :boolean,
account_ap_id: :string,
notify: :boolean
}
changeset = cast({%{}, param_types}, params, Map.keys(param_types))
changeset.changes
end
defp restrict(query, :types, %{types: mastodon_types = [_ | _]}) do
where(query, [n], n.type in ^mastodon_types)
end
defp restrict(query, :exclude_types, %{exclude_types: mastodon_types = [_ | _]}) do
where(query, [n], n.type not in ^mastodon_types)
end
defp restrict(query, :account_ap_id, %{account_ap_id: account_ap_id}) do
where(query, [n, a], a.actor == ^account_ap_id)
end
defp restrict(query, _, _), do: query
end
diff --git a/lib/pleroma/web/mastodon_api/views/notification_view.ex b/lib/pleroma/web/mastodon_api/views/notification_view.ex
index 27532c42a..33d4b2924 100644
--- a/lib/pleroma/web/mastodon_api/views/notification_view.ex
+++ b/lib/pleroma/web/mastodon_api/views/notification_view.ex
@@ -1,171 +1,321 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationView do
use Pleroma.Web, :view
alias Pleroma.Activity
alias Pleroma.Chat.MessageReference
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.User
alias Pleroma.UserRelationship
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.AdminAPI.ReportView
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.MediaProxy
alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
defp object_id_for(%{data: %{"object" => %{"id" => id}}}) when is_binary(id), do: id
defp object_id_for(%{data: %{"object" => id}}) when is_binary(id), do: id
@parent_types ~w{Like Announce EmojiReact Update}
def render("index.json", %{notifications: notifications, for: reading_user} = opts) do
activities = Enum.map(notifications, & &1.activity)
parent_activities =
activities
|> Enum.filter(fn
%{data: %{"type" => type}} ->
type in @parent_types
end)
|> Enum.map(&object_id_for/1)
|> Activity.create_by_object_ap_id()
|> Activity.with_preloaded_object(:left)
|> Pleroma.Repo.all()
relationships_opt =
cond do
Map.has_key?(opts, :relationships) ->
opts[:relationships]
is_nil(reading_user) ->
UserRelationship.view_relationships_option(nil, [])
true ->
move_activities_targets =
activities
|> Enum.filter(&(&1.data["type"] == "Move"))
|> Enum.map(&User.get_cached_by_ap_id(&1.data["target"]))
|> Enum.filter(& &1)
actors =
activities
|> Enum.map(fn a -> User.get_cached_by_ap_id(a.data["actor"]) end)
|> Enum.filter(& &1)
|> Kernel.++(move_activities_targets)
UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
end
opts =
opts
|> Map.put(:parent_activities, parent_activities)
|> Map.put(:relationships, relationships_opt)
safe_render_many(notifications, NotificationView, "show.json", opts)
end
+ def render("grouped_index.json", %{notifications: notifications} = opts) do
+ grouped_types = Notification.normalize_grouped_types(opts[:grouped_types])
+
+ opts
+ |> Map.delete(:notifications)
+ |> Map.put(
+ :notification_groups,
+ Notification.group_notifications(notifications, grouped_types)
+ )
+ |> then(&render("grouped_index.json", &1))
+ end
+
+ def render(
+ "grouped_index.json",
+ %{notification_groups: notification_groups, for: reading_user} = opts
+ ) do
+ grouped_types = Notification.normalize_grouped_types(opts[:grouped_types])
+ notification_group_counts = Map.get(opts, :notification_group_counts, %{})
+ notification_group_bounds = Map.get(opts, :notification_group_bounds, %{})
+ include_page_metadata = Map.get(opts, :include_page_metadata, true)
+
+ statuses =
+ notification_groups
+ |> Enum.map(&List.first/1)
+ |> Enum.map(&render("show.json", %{notification: &1, for: reading_user}))
+ |> Enum.map(& &1[:status])
+ |> Enum.filter(& &1)
+ |> Enum.uniq_by(& &1[:id])
+
+ actors =
+ notification_groups
+ |> List.flatten()
+ |> notification_actors()
+
+ %{
+ accounts: AccountView.render("index.json", %{users: actors, for: reading_user}),
+ statuses: statuses,
+ notification_groups:
+ Enum.map(
+ notification_groups,
+ &render_group(
+ &1,
+ reading_user,
+ grouped_types,
+ notification_group_counts,
+ notification_group_bounds,
+ include_page_metadata
+ )
+ )
+ }
+ end
+
def render(
"show.json",
%{
notification: %Notification{activity: activity} = notification,
for: reading_user
} = opts
) do
actor = User.get_cached_by_ap_id(activity.data["actor"])
parent_activity_fn = fn ->
if opts[:parent_activities] do
Activity.Queries.find_by_object_ap_id(opts[:parent_activities], object_id_for(activity))
else
Activity.get_create_by_object_ap_id(object_id_for(activity))
end
end
# Note: :relationships contain user mutes (needed for :muted flag in :status)
status_render_opts = %{relationships: opts[:relationships]}
account = AccountView.render("show.json", %{user: actor, for: reading_user})
response = %{
id: to_string(notification.id),
- group_key: "ungrouped-" <> to_string(notification.id),
+ group_key: Notification.group_key(notification),
type: notification.type,
created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at),
account: account,
pleroma: %{
is_muted: User.mutes?(reading_user, actor),
is_seen: notification.seen
}
}
case notification.type do
type when type in ["mention", "status", "poll"] ->
put_status(response, activity, reading_user, status_render_opts)
type when type in ["favourite", "reblog", "update"] ->
put_status(response, parent_activity_fn.(), reading_user, status_render_opts)
"move" ->
put_target(response, activity, reading_user, %{})
"pleroma:emoji_reaction" ->
response
|> put_status(parent_activity_fn.(), reading_user, status_render_opts)
|> put_emoji(activity)
"pleroma:chat_mention" ->
put_chat_message(response, activity, reading_user, status_render_opts)
"pleroma:report" ->
put_report(response, activity)
type when type in ["follow", "follow_request"] ->
response
end
end
defp put_report(response, activity) do
report_render = ReportView.render("show.json", Report.extract_report_info(activity))
Map.put(response, :report, report_render)
end
+ defp render_group(
+ [%Notification{} = notification | _] = notifications,
+ _reading_user,
+ grouped_types,
+ notification_group_counts,
+ notification_group_bounds,
+ include_page_metadata
+ ) do
+ latest_notification = List.first(notifications)
+ oldest_notification = List.last(notifications)
+ status_activity = status_activity_for_group(notifications, grouped_types)
+ group_key = Notification.group_key(notification, grouped_types)
+ bounds = Map.get(notification_group_bounds, group_key, %{})
+
+ response = %{
+ group_key: group_key,
+ notifications_count: Map.get(notification_group_counts, group_key, length(notifications)),
+ type: notification.type,
+ most_recent_notification_id:
+ bounds
+ |> Map.get(:page_max_id, latest_notification.id)
+ |> to_string(),
+ sample_account_ids:
+ notifications
+ |> notification_actors()
+ |> Enum.map(&to_string(&1.id))
+ }
+
+ response =
+ if include_page_metadata do
+ Map.merge(response, %{
+ page_min_id:
+ bounds
+ |> Map.get(:page_min_id, oldest_notification.id)
+ |> to_string(),
+ page_max_id:
+ bounds
+ |> Map.get(:page_max_id, latest_notification.id)
+ |> to_string(),
+ latest_page_notification_at:
+ bounds
+ |> Map.get(:latest_page_notification_at, latest_notification.inserted_at)
+ |> CommonAPI.Utils.to_masto_date()
+ })
+ else
+ response
+ end
+
+ if status_activity do
+ Map.put(response, :status_id, to_string(status_activity.id))
+ else
+ response
+ end
+ end
+
+ defp status_activity_for_group([%Notification{} = notification | _], grouped_types) do
+ status_activity_for(notification, grouped_types)
+ end
+
+ defp status_activity_for(%Notification{type: type, activity: activity}, _grouped_types)
+ when type in ["mention", "status", "poll"] do
+ activity
+ end
+
+ defp status_activity_for(%Notification{type: type} = notification, grouped_types)
+ when type in ["favourite", "reblog"] do
+ group_key = Notification.group_key(notification, grouped_types)
+
+ case String.split(group_key, "-", parts: 3) do
+ [^type, activity_id, _bucket] ->
+ case Activity.create_by_id_with_object(activity_id) do
+ %Activity{} = activity -> activity
+ _ -> parent_status_activity(notification.activity)
+ end
+
+ _ ->
+ parent_status_activity(notification.activity)
+ end
+ end
+
+ defp status_activity_for(%Notification{type: type, activity: activity}, _grouped_types)
+ when type in ["update", "pleroma:emoji_reaction"] do
+ parent_status_activity(activity)
+ end
+
+ defp status_activity_for(_, _grouped_types), do: nil
+
+ defp parent_status_activity(activity) do
+ Activity.get_create_by_object_ap_id(object_id_for(activity))
+ end
+
+ defp notification_actors(notifications) do
+ notifications
+ |> Enum.map(&User.get_cached_by_ap_id(&1.activity.data["actor"]))
+ |> Enum.filter(& &1)
+ |> Enum.uniq_by(& &1.id)
+ end
+
defp put_emoji(response, activity) do
response
|> Map.put(:emoji, activity.data["content"])
|> Map.put(:emoji_url, MediaProxy.url(Pleroma.Emoji.emoji_url(activity.data)))
end
defp put_chat_message(response, activity, reading_user, opts) do
object = Object.normalize(activity, fetch: false)
author = User.get_cached_by_ap_id(object.data["actor"])
chat = Pleroma.Chat.get(reading_user.id, author.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
render_opts = Map.merge(opts, %{for: reading_user, chat_message_reference: cm_ref})
chat_message_render = MessageReferenceView.render("show.json", render_opts)
Map.put(response, :chat_message, chat_message_render)
end
defp put_status(response, activity, reading_user, opts) do
status_render_opts = Map.merge(opts, %{activity: activity, for: reading_user})
status_render = StatusView.render("show.json", status_render_opts)
Map.put(response, :status, status_render)
end
defp put_target(response, activity, reading_user, opts) do
target_user = User.get_cached_by_ap_id(activity.data["target"])
target_render_opts = Map.merge(opts, %{user: target_user, for: reading_user})
target_render = AccountView.render("show.json", target_render_opts)
Map.put(response, :target, target_render)
end
end
diff --git a/lib/pleroma/web/router.ex b/lib/pleroma/web/router.ex
index 813c5c482..5657bcad7 100644
--- a/lib/pleroma/web/router.ex
+++ b/lib/pleroma/web/router.ex
@@ -1,1120 +1,1130 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.Router do
use Pleroma.Web, :router
import Phoenix.LiveDashboard.Router
import Oban.Web.Router
pipeline :accepts_html do
plug(:accepts, ["html"])
end
pipeline :accepts_html_xml do
plug(:accepts, ["html", "xml", "rss", "atom"])
end
pipeline :accepts_html_json do
plug(:accepts, ["html", "activity+json", "json"])
end
pipeline :accepts_html_xml_json do
plug(:accepts, ["html", "xml", "rss", "atom", "activity+json", "json"])
end
pipeline :accepts_xml_rss_atom do
plug(:accepts, ["xml", "rss", "atom"])
end
pipeline :browser do
plug(:accepts, ["html"])
plug(:fetch_session)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :oauth do
plug(:fetch_session)
plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
end
# Note: expects _user_ authentication (user-unbound app-bound tokens don't qualify)
pipeline :expect_user_authentication do
plug(Pleroma.Web.Plugs.ExpectAuthenticatedCheckPlug)
end
# Note: expects public instance or _user_ authentication (user-unbound tokens don't qualify)
pipeline :expect_public_instance_or_user_authentication do
plug(Pleroma.Web.Plugs.ExpectPublicOrAuthenticatedCheckPlug)
end
pipeline :authenticate do
plug(Pleroma.Web.Plugs.OAuthPlug)
plug(Pleroma.Web.Plugs.BasicAuthDecoderPlug)
plug(Pleroma.Web.Plugs.UserFetcherPlug)
plug(Pleroma.Web.Plugs.AuthenticationPlug)
end
pipeline :after_auth do
plug(Pleroma.Web.Plugs.UserEnabledPlug)
plug(Pleroma.Web.Plugs.SetUserSessionIdPlug)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
plug(Pleroma.Web.Plugs.UserTrackingPlug)
end
pipeline :base_api do
plug(:accepts, ["json"])
plug(:fetch_session)
plug(:authenticate)
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :no_auth_or_privacy_expectations_api do
plug(:base_api)
plug(:after_auth)
plug(Pleroma.Web.Plugs.IdempotencyPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
# Pipeline for app-related endpoints (no user auth checks — app-bound tokens must be supported)
pipeline :app_api do
plug(:no_auth_or_privacy_expectations_api)
end
pipeline :api do
plug(:expect_public_instance_or_user_authentication)
plug(:no_auth_or_privacy_expectations_api)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :authenticated_api do
plug(:expect_user_authentication)
plug(:no_auth_or_privacy_expectations_api)
plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :admin_api do
plug(:expect_user_authentication)
plug(:base_api)
plug(Pleroma.Web.Plugs.AdminSecretAuthenticationPlug)
plug(:after_auth)
plug(Pleroma.Web.Plugs.EnsureAuthenticatedPlug)
plug(Pleroma.Web.Plugs.UserIsStaffPlug)
plug(Pleroma.Web.Plugs.IdempotencyPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :require_admin do
plug(Pleroma.Web.Plugs.UserIsAdminPlug)
end
pipeline :require_privileged_role_users_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_delete)
end
pipeline :require_privileged_role_users_manage_credentials do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_credentials)
end
pipeline :require_privileged_role_messages_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :messages_read)
end
pipeline :require_privileged_role_users_manage_tags do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_tags)
end
pipeline :require_privileged_role_users_manage_activation_state do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_activation_state)
end
pipeline :require_privileged_role_users_manage_invites do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_manage_invites)
end
pipeline :require_privileged_role_reports_manage_reports do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :reports_manage_reports)
end
pipeline :require_privileged_role_users_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :users_read)
end
pipeline :require_privileged_role_messages_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :messages_delete)
end
pipeline :require_privileged_role_emoji_manage_emoji do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :emoji_manage_emoji)
end
pipeline :require_privileged_role_instances_delete do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :instances_delete)
end
pipeline :require_privileged_role_moderation_log_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :moderation_log_read)
end
pipeline :require_privileged_role_statistics_read do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :statistics_read)
end
pipeline :require_privileged_role_announcements_manage_announcements do
plug(:admin_api)
plug(Pleroma.Web.Plugs.EnsurePrivilegedPlug, :announcements_manage_announcements)
end
pipeline :pleroma_html do
plug(:browser)
plug(:authenticate)
plug(Pleroma.Web.Plugs.EnsureUserTokenAssignsPlug)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :well_known do
plug(:accepts, ["activity+json", "json", "jrd", "jrd+json", "xml", "xrd+xml", "html"])
end
pipeline :config do
plug(:accepts, ["json", "xml"])
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
end
pipeline :pleroma_api do
plug(:accepts, ["html", "json"])
plug(OpenApiSpex.Plug.PutApiSpec, module: Pleroma.Web.ApiSpec)
plug(Pleroma.Web.Plugs.LoggerMetadataUser)
end
pipeline :mailbox_preview do
plug(:accepts, ["html"])
plug(:put_secure_browser_headers, %{
"content-security-policy" =>
"default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' 'unsafe-eval'"
})
end
pipeline :http_signature do
plug(Pleroma.Web.Plugs.HTTPSignaturePlug)
plug(Pleroma.Web.Plugs.MappedSignatureToIdentityPlug)
plug(Pleroma.Web.Plugs.EnsureHostMatchesPlug)
end
pipeline :inbox_guard do
plug(Pleroma.Web.Plugs.InboxGuardPlug)
end
pipeline :static_fe do
plug(Pleroma.Web.Plugs.StaticFEPlug)
end
scope "/api/v1/pleroma", Pleroma.Web.OAuth do
pipe_through(:pleroma_api)
get("/password_reset/:token", PasswordController, :reset, as: :reset_password)
post("/password_reset", PasswordController, :do_reset, as: :reset_password)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:pleroma_api)
get("/emoji", UtilController, :emoji)
get("/captcha", UtilController, :captcha)
get("/healthcheck", UtilController, :healthcheck)
get("/federation_status", InstancesController, :show)
end
scope "/api/v1/pleroma", Pleroma.Web.RemoteInteraction do
pipe_through(:pleroma_api)
post("/remote_interaction", RemoteInteractionController, :remote_interaction)
end
scope "/api/v1/pleroma", Pleroma.Web do
pipe_through(:pleroma_api)
post("/uploader_callback/:upload_path", UploaderController, :callback)
end
# AdminAPI: only admins can perform these actions
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through([:admin_api, :require_admin])
get("/users/:nickname/permission_group", AdminAPIController, :right_get)
get("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_get)
post("/users/:nickname/permission_group/:permission_group", AdminAPIController, :right_add)
delete(
"/users/:nickname/permission_group/:permission_group",
AdminAPIController,
:right_delete
)
post("/users/permission_group/:permission_group", AdminAPIController, :right_add_multiple)
delete(
"/users/permission_group/:permission_group",
AdminAPIController,
:right_delete_multiple
)
post("/users/follow", UserController, :follow)
post("/users/unfollow", UserController, :unfollow)
post("/users", UserController, :create)
patch("/users/suggest", UserController, :suggest)
patch("/users/unsuggest", UserController, :unsuggest)
get("/relay", RelayController, :index)
post("/relay", RelayController, :follow)
delete("/relay", RelayController, :unfollow)
get("/instance_document/:name", InstanceDocumentController, :show)
patch("/instance_document/:name", InstanceDocumentController, :update)
delete("/instance_document/:name", InstanceDocumentController, :delete)
get("/config", ConfigController, :show)
post("/config", ConfigController, :update)
get("/config/descriptions", ConfigController, :descriptions)
get("/need_reboot", AdminAPIController, :need_reboot)
get("/restart", AdminAPIController, :restart)
get("/oauth_app", OAuthAppController, :index)
post("/oauth_app", OAuthAppController, :create)
patch("/oauth_app/:id", OAuthAppController, :update)
delete("/oauth_app/:id", OAuthAppController, :delete)
get("/media_proxy_caches", MediaProxyCacheController, :index)
post("/media_proxy_caches/delete", MediaProxyCacheController, :delete)
post("/media_proxy_caches/purge", MediaProxyCacheController, :purge)
get("/frontends", FrontendController, :index)
post("/frontends/install", FrontendController, :install)
post("/backups", AdminAPIController, :create_backup)
get("/rules", RuleController, :index)
post("/rules", RuleController, :create)
patch("/rules/:id", RuleController, :update)
delete("/rules/:id", RuleController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_announcements_manage_announcements)
get("/announcements", AnnouncementController, :index)
post("/announcements", AnnouncementController, :create)
get("/announcements/:id", AnnouncementController, :show)
patch("/announcements/:id", AnnouncementController, :change)
delete("/announcements/:id", AnnouncementController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_delete)
delete("/users", UserController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_credentials)
get("/users/:nickname/password_reset", AdminAPIController, :get_password_reset)
get("/users/:nickname/credentials", AdminAPIController, :show_user_credentials)
patch("/users/:nickname/credentials", AdminAPIController, :update_user_credentials)
put("/users/disable_mfa", AdminAPIController, :disable_mfa)
patch("/users/force_password_reset", AdminAPIController, :force_password_reset)
patch("/users/confirm_email", AdminAPIController, :confirm_email)
patch("/users/resend_confirmation_email", AdminAPIController, :resend_confirmation_email)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_messages_read)
get("/users/:nickname/statuses", AdminAPIController, :list_user_statuses)
get("/users/:nickname/chats", AdminAPIController, :list_user_chats)
get("/statuses", StatusController, :index)
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
get("/instances/:instance/statuses", InstanceController, :list_statuses)
get("/statuses/:id", StatusController, :show)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_tags)
put("/users/tag", AdminAPIController, :tag_users)
delete("/users/tag", AdminAPIController, :untag_users)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_activation_state)
patch("/users/:nickname/toggle_activation", UserController, :toggle_activation)
patch("/users/activate", UserController, :activate)
patch("/users/deactivate", UserController, :deactivate)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_manage_invites)
patch("/users/approve", UserController, :approve)
post("/users/invite_token", InviteController, :create)
get("/users/invites", InviteController, :index)
post("/users/revoke_invite", InviteController, :revoke)
post("/users/email_invite", InviteController, :email)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_reports_manage_reports)
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
patch("/reports", ReportController, :update)
post("/reports/assign_account", ReportController, :assign_account)
post("/reports/:id/notes", ReportController, :notes_create)
delete("/reports/:report_id/notes/:id", ReportController, :notes_delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_users_read)
get("/users", UserController, :index)
get("/users/:nickname", UserController, :show)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_messages_delete)
put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_emoji_manage_emoji)
post("/reload_emoji", AdminAPIController, :reload_emoji)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_instances_delete)
delete("/instances/:instance", InstanceController, :delete)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_moderation_log_read)
get("/moderation_log", AdminAPIController, :list_log)
end
# AdminAPI: admins and mods (staff) can perform these actions (if privileged by role)
scope "/api/v1/pleroma/admin", Pleroma.Web.AdminAPI do
pipe_through(:require_privileged_role_statistics_read)
get("/stats", AdminAPIController, :stats)
end
scope "/api/v1/pleroma/emoji", Pleroma.Web.PleromaAPI do
scope "/pack" do
pipe_through(:require_privileged_role_emoji_manage_emoji)
post("/", EmojiPackController, :create)
patch("/", EmojiPackController, :update)
delete("/", EmojiPackController, :delete)
end
scope "/pack" do
pipe_through(:api)
get("/", EmojiPackController, :show)
end
# Modifying packs
scope "/packs" do
pipe_through(:require_privileged_role_emoji_manage_emoji)
get("/import", EmojiPackController, :import_from_filesystem)
get("/remote", EmojiPackController, :remote)
post("/download", EmojiPackController, :download)
post("/download_zip", EmojiPackController, :download_zip)
post("/files", EmojiFileController, :create)
patch("/files", EmojiFileController, :update)
delete("/files", EmojiFileController, :delete)
end
# Pack info / downloading
scope "/packs" do
pipe_through(:api)
get("/", EmojiPackController, :index)
get("/archive", EmojiPackController, :archive)
end
end
scope "/", Pleroma.Web.RemoteInteraction do
pipe_through(:pleroma_html)
post("/main/ostatus", RemoteInteractionController, :remote_subscribe)
get("/main/ostatus", RemoteInteractionController, :show_subscribe_form)
get("/ostatus_subscribe", RemoteInteractionController, :follow)
post("/ostatus_subscribe", RemoteInteractionController, :do_follow)
get("/authorize_interaction", RemoteInteractionController, :authorize_interaction)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
post("/change_email", UtilController, :change_email)
post("/change_password", UtilController, :change_password)
post("/delete_account", UtilController, :delete_account)
put("/notification_settings", UtilController, :update_notification_settings)
post("/disable_account", UtilController, :disable_account)
post("/move_account", UtilController, :move_account)
put("/aliases", UtilController, :add_alias)
get("/aliases", UtilController, :list_aliases)
delete("/aliases", UtilController, :delete_alias)
end
scope "/api/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
post("/mutes_import", UserImportController, :mutes)
post("/blocks_import", UserImportController, :blocks)
post("/follow_import", UserImportController, :follow)
get("/accounts/mfa", TwoFactorAuthenticationController, :settings)
get("/accounts/mfa/backup_codes", TwoFactorAuthenticationController, :backup_codes)
get("/accounts/mfa/setup/:method", TwoFactorAuthenticationController, :setup)
post("/accounts/mfa/confirm/:method", TwoFactorAuthenticationController, :confirm)
delete("/accounts/mfa/:method", TwoFactorAuthenticationController, :disable)
end
scope "/oauth", Pleroma.Web.OAuth do
# Note: use /api/v1/accounts/verify_credentials for userinfo of signed-in user
get("/registration_details", OAuthController, :registration_details)
post("/mfa/verify", MFAController, :verify, as: :mfa_verify)
get("/mfa", MFAController, :show)
scope [] do
pipe_through(:oauth)
get("/authorize", OAuthController, :authorize)
post("/authorize", OAuthController, :create_authorization)
end
scope [] do
pipe_through(:fetch_session)
post("/token", OAuthController, :token_exchange)
post("/revoke", OAuthController, :token_revoke)
post("/mfa/challenge", MFAController, :challenge)
end
scope [] do
pipe_through(:browser)
get("/prepare_request", OAuthController, :prepare_request)
get("/:provider", OAuthController, :request)
get("/:provider/callback", OAuthController, :callback)
post("/register", OAuthController, :register)
end
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:api)
get("/apps", AppController, :index)
get("/statuses/:id/reactions/:emoji", EmojiReactionController, :index)
get("/statuses/:id/reactions", EmojiReactionController, :index)
get(
"/preferred_frontend/available",
FrontendSettingsController,
:available_frontends
)
put(
"/preferred_frontend",
FrontendSettingsController,
:update_preferred_frontend
)
end
scope "/api/v0/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
get("/reports", ReportController, :index)
get("/reports/:id", ReportController, :show)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
post("/chats/by-account-id/:id", ChatController, :create)
get("/chats", ChatController, :index)
get("/chats/:id", ChatController, :show)
get("/chats/:id/messages", ChatController, :messages)
post("/chats/:id/messages", ChatController, :post_chat_message)
delete("/chats/:id/messages/:message_id", ChatController, :delete_message)
post("/chats/:id/read", ChatController, :mark_as_read)
post("/chats/:id/messages/:message_id/read", ChatController, :mark_message_as_read)
post("/chats/:id/pin", ChatController, :pin)
post("/chats/:id/unpin", ChatController, :unpin)
get("/conversations/:id/statuses", ConversationController, :statuses)
get("/conversations/:id", ConversationController, :show)
post("/conversations/read", ConversationController, :mark_as_read)
patch("/conversations/:id", ConversationController, :update)
put("/statuses/:id/reactions/:emoji", EmojiReactionController, :create)
delete("/statuses/:id/reactions/:emoji", EmojiReactionController, :delete)
post("/notifications/read", NotificationController, :mark_as_read)
get("/mascot", MascotController, :show)
put("/mascot", MascotController, :update)
post("/scrobble", ScrobbleController, :create)
get("/backups", BackupController, :index)
post("/backups", BackupController, :create)
get("/bookmark_folders", BookmarkFolderController, :index)
post("/bookmark_folders", BookmarkFolderController, :create)
patch("/bookmark_folders/:id", BookmarkFolderController, :update)
delete("/bookmark_folders/:id", BookmarkFolderController, :delete)
get("/outgoing_follow_requests", FollowRequestController, :outgoing)
end
scope [] do
pipe_through(:api)
get("/accounts/:id/favourites", AccountController, :favourites)
get("/statuses/:id/quotes", StatusController, :quotes)
end
scope [] do
pipe_through(:authenticated_api)
post("/accounts/:id/subscribe", AccountController, :subscribe)
post("/accounts/:id/unsubscribe", AccountController, :unsubscribe)
get("/birthdays", AccountController, :birthdays)
end
scope [] do
pipe_through(:authenticated_api)
get("/settings/:app", SettingsController, :show)
patch("/settings/:app", SettingsController, :update)
end
post("/accounts/confirmation_resend", AccountController, :confirmation_resend)
end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:api)
get("/accounts/:id/scrobbles", ScrobbleController, :index)
end
scope "/api/v1/pleroma", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/:id/endorsements", AccountController, :endorsements)
end
scope "/api/v2/pleroma", Pleroma.Web.PleromaAPI do
scope [] do
pipe_through(:authenticated_api)
get("/chats", ChatController, :index2)
end
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api)
get("/accounts/verify_credentials", AccountController, :verify_credentials)
patch("/accounts/update_credentials", AccountController, :update_credentials)
get("/accounts/relationships", AccountController, :relationships)
get("/accounts/familiar_followers", AccountController, :familiar_followers)
get("/accounts/:id/lists", AccountController, :lists)
get("/endorsements", AccountController, :own_endorsements)
get("/blocks", AccountController, :blocks)
get("/mutes", AccountController, :mutes)
post("/follows", AccountController, :follow_by_uri)
post("/accounts/:id/follow", AccountController, :follow)
post("/accounts/:id/unfollow", AccountController, :unfollow)
post("/accounts/:id/block", AccountController, :block)
post("/accounts/:id/unblock", AccountController, :unblock)
post("/accounts/:id/mute", AccountController, :mute)
post("/accounts/:id/unmute", AccountController, :unmute)
post("/accounts/:id/note", AccountController, :note)
post("/accounts/:id/pin", AccountController, :endorse)
post("/accounts/:id/unpin", AccountController, :unendorse)
post("/accounts/:id/endorse", AccountController, :endorse)
post("/accounts/:id/unendorse", AccountController, :unendorse)
post("/accounts/:id/remove_from_followers", AccountController, :remove_from_followers)
get("/conversations", ConversationController, :index)
post("/conversations/:id/read", ConversationController, :mark_as_read)
delete("/conversations/:id", ConversationController, :delete)
get("/domain_blocks", DomainBlockController, :index)
post("/domain_blocks", DomainBlockController, :create)
delete("/domain_blocks", DomainBlockController, :delete)
get("/filters", FilterController, :index)
post("/filters", FilterController, :create)
get("/filters/:id", FilterController, :show)
put("/filters/:id", FilterController, :update)
delete("/filters/:id", FilterController, :delete)
get("/follow_requests", FollowRequestController, :index)
post("/follow_requests/:id/authorize", FollowRequestController, :authorize)
post("/follow_requests/:id/reject", FollowRequestController, :reject)
get("/lists", ListController, :index)
get("/lists/:id", ListController, :show)
get("/lists/:id/accounts", ListController, :list_accounts)
delete("/lists/:id", ListController, :delete)
post("/lists", ListController, :create)
put("/lists/:id", ListController, :update)
post("/lists/:id/accounts", ListController, :add_to_list)
delete("/lists/:id/accounts", ListController, :remove_from_list)
get("/markers", MarkerController, :index)
post("/markers", MarkerController, :upsert)
post("/media", MediaController, :create)
get("/media/:id", MediaController, :show)
put("/media/:id", MediaController, :update)
get("/notifications", NotificationController, :index)
get("/notifications/:id", NotificationController, :show)
post("/notifications/:id/dismiss", NotificationController, :dismiss)
post("/notifications/clear", NotificationController, :clear)
delete("/notifications/destroy_multiple", NotificationController, :destroy_multiple)
# Deprecated: was removed in Mastodon v3, use `/notifications/:id/dismiss` instead
post("/notifications/dismiss", NotificationController, :dismiss_via_body)
post("/polls/:id/votes", PollController, :vote)
post("/reports", ReportController, :create)
get("/scheduled_statuses", ScheduledActivityController, :index)
get("/scheduled_statuses/:id", ScheduledActivityController, :show)
put("/scheduled_statuses/:id", ScheduledActivityController, :update)
delete("/scheduled_statuses/:id", ScheduledActivityController, :delete)
# Unlike `GET /api/v1/accounts/:id/favourites`, demands authentication
get("/favourites", StatusController, :favourites)
get("/bookmarks", StatusController, :bookmarks)
post("/statuses", StatusController, :create)
put("/statuses/:id", StatusController, :update)
delete("/statuses/:id", StatusController, :delete)
post("/statuses/:id/reblog", StatusController, :reblog)
post("/statuses/:id/unreblog", StatusController, :unreblog)
post("/statuses/:id/favourite", StatusController, :favourite)
post("/statuses/:id/unfavourite", StatusController, :unfavourite)
post("/statuses/:id/pin", StatusController, :pin)
post("/statuses/:id/unpin", StatusController, :unpin)
post("/statuses/:id/bookmark", StatusController, :bookmark)
post("/statuses/:id/unbookmark", StatusController, :unbookmark)
post("/statuses/:id/mute", StatusController, :mute_conversation)
post("/statuses/:id/unmute", StatusController, :unmute_conversation)
post("/statuses/:id/translate", StatusController, :translate)
get("/statuses/:id/quotes", StatusController, :quotes)
post("/push/subscription", SubscriptionController, :create)
get("/push/subscription", SubscriptionController, :show)
put("/push/subscription", SubscriptionController, :update)
delete("/push/subscription", SubscriptionController, :delete)
get("/suggestions", SuggestionController, :index)
delete("/suggestions/:account_id", SuggestionController, :dismiss)
get("/timelines/home", TimelineController, :home)
get("/timelines/direct", TimelineController, :direct)
get("/timelines/list/:list_id", TimelineController, :list)
get("/announcements", AnnouncementController, :index)
post("/announcements/:id/dismiss", AnnouncementController, :mark_read)
get("/tags/:id", TagController, :show)
post("/tags/:id/follow", TagController, :follow)
post("/tags/:id/unfollow", TagController, :unfollow)
get("/followed_tags", TagController, :show_followed)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:app_api)
post("/apps", AppController, :create)
get("/apps/verify_credentials", AppController, :verify_credentials)
end
scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/accounts/search", SearchController, :account_search)
get("/search", SearchController, :search)
get("/accounts/lookup", AccountController, :lookup)
get("/accounts/:id/statuses", AccountController, :statuses)
get("/accounts/:id/followers", AccountController, :followers)
get("/accounts/:id/following", AccountController, :following)
get("/accounts/:id/endorsements", AccountController, :endorsements)
get("/accounts/:id", AccountController, :show)
post("/accounts", AccountController, :create)
get("/instance", InstanceController, :show)
get("/instance/peers", InstanceController, :peers)
get("/instance/rules", InstanceController, :rules)
get("/instance/domain_blocks", InstanceController, :domain_blocks)
get("/instance/translation_languages", InstanceController, :translation_languages)
get("/statuses", StatusController, :index)
get("/statuses/:id", StatusController, :show)
get("/statuses/:id/context", StatusController, :context)
get("/statuses/:id/favourited_by", StatusController, :favourited_by)
get("/statuses/:id/reblogged_by", StatusController, :reblogged_by)
get("/statuses/:id/history", StatusController, :show_history)
get("/statuses/:id/source", StatusController, :show_source)
get("/custom_emojis", CustomEmojiController, :index)
get("/trends", MastodonAPIController, :empty_array)
get("/timelines/public", TimelineController, :public)
get("/timelines/tag/:tag", TimelineController, :hashtag)
get("/polls/:id", PollController, :show)
get("/directory", DirectoryController, :index)
end
+ scope "/api/v2", Pleroma.Web.MastodonAPI do
+ pipe_through(:authenticated_api)
+
+ get("/notifications", NotificationController, :grouped_index)
+ get("/notifications/unread_count", NotificationController, :unread_count)
+ get("/notifications/:group_key/accounts", NotificationController, :group_accounts)
+ get("/notifications/:group_key", NotificationController, :show_group)
+ post("/notifications/:group_key/dismiss", NotificationController, :dismiss_group)
+ end
+
scope "/api/v2", Pleroma.Web.MastodonAPI do
pipe_through(:api)
get("/search", SearchController, :search2)
post("/media", MediaController, :create2)
get("/suggestions", SuggestionController, :index2)
get("/instance", InstanceController, :show2)
end
scope "/api", Pleroma.Web do
pipe_through(:config)
get("/pleroma/frontend_configurations", PleromaAPI.UtilController, :frontend_configurations)
end
scope "/api", Pleroma.Web do
pipe_through(:api)
get(
"/account/confirm_email/:user_id/:token",
OAuth.TokenController,
:confirm_email,
as: :confirm_email
)
end
scope "/api" do
pipe_through(:base_api)
get("/openapi", OpenApiSpex.Plug.RenderSpec, [])
end
scope "/api", Pleroma.Web, as: :authenticated_pleroma_api do
pipe_through(:authenticated_api)
get("/oauth_tokens", OAuth.TokenController, :oauth_tokens)
delete("/oauth_tokens/:id", OAuth.TokenController, :revoke_token)
end
scope "/", Pleroma.Web do
# Note: html format is supported only if static FE is enabled
# Note: http signature is only considered for json requests (no auth for non-json requests)
pipe_through([:accepts_html_json, :http_signature, :static_fe])
get("/objects/:uuid", OStatus.OStatusController, :object)
get("/activities/:uuid", OStatus.OStatusController, :activity)
get("/notice/:id", OStatus.OStatusController, :notice)
# Mastodon compatibility routes
get("/users/:nickname/statuses/:id", OStatus.OStatusController, :object)
get("/users/:nickname/statuses/:id/activity", OStatus.OStatusController, :activity)
end
scope "/", Pleroma.Web do
# Note: html format is supported only if static FE is enabled
# Note: http signature is only considered for json requests (no auth for non-json requests)
pipe_through([:accepts_html_xml_json, :http_signature, :static_fe])
# Note: returns user _profile_ for json requests, redirects to user _feed_ for non-json ones
get("/users/:nickname", Feed.UserController, :feed_redirect, as: :user_feed)
end
scope "/", Pleroma.Web do
pipe_through([:accepts_html_xml])
get("/users/:nickname/feed", Feed.UserController, :feed, as: :user_feed)
end
scope "/", Pleroma.Web do
pipe_through(:accepts_html)
get("/notice/:id/embed_player", OStatus.OStatusController, :notice_player)
end
scope "/", Pleroma.Web do
pipe_through(:accepts_xml_rss_atom)
get("/tags/:tag", Feed.TagController, :feed, as: :tag_feed)
end
scope "/", Pleroma.Web do
pipe_through(:browser)
get("/mailer/unsubscribe/:token", Mailer.SubscriptionController, :unsubscribe)
get("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :switch)
post("/frontend_switcher", FrontendSwitcher.FrontendSwitcherController, :do_switch)
end
pipeline :ap_service_actor do
plug(:accepts, ["activity+json", "json"])
end
# Server to Server (S2S) AP interactions
pipeline :activitypub do
plug(:ap_service_actor)
plug(:http_signature)
end
# Client to Server (C2S) AP interactions
pipeline :activitypub_client do
plug(:ap_service_actor)
plug(Pleroma.Web.Plugs.APClientApiEnabledPlug)
plug(:fetch_session)
plug(:authenticate)
plug(:after_auth)
end
# AP interactions used by both S2S and C2S
pipeline :activitypub_server_or_client do
plug(:ap_service_actor)
plug(:fetch_session)
plug(:authenticate)
plug(Pleroma.Web.Plugs.APClientApiEnabledPlug, allow_server: true)
plug(:after_auth)
plug(:http_signature)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_client])
get("/api/ap/whoami", ActivityPubController, :whoami)
get("/users/:nickname/inbox", ActivityPubController, :read_inbox)
post("/users/:nickname/outbox", ActivityPubController, :update_outbox)
post("/api/ap/upload_media", ActivityPubController, :upload_media)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub_server_or_client])
get("/users/:nickname/outbox", ActivityPubController, :outbox)
get("/users/:nickname/followers", ActivityPubController, :followers)
get("/users/:nickname/following", ActivityPubController, :following)
get("/users/:nickname/collections/featured", ActivityPubController, :pinned)
get("/objects/:uuid/replies", ActivityPubController, :object_replies)
end
scope "/", Pleroma.Web.ActivityPub do
pipe_through([:activitypub, :inbox_guard])
post("/inbox", ActivityPubController, :inbox)
post("/users/:nickname/inbox", ActivityPubController, :inbox)
end
scope "/relay", Pleroma.Web.ActivityPub do
pipe_through(:ap_service_actor)
get("/", ActivityPubController, :relay)
scope [] do
pipe_through(:http_signature)
post("/inbox", ActivityPubController, :inbox)
end
get("/following", ActivityPubController, :relay_following)
get("/followers", ActivityPubController, :relay_followers)
end
scope "/internal/fetch", Pleroma.Web.ActivityPub do
pipe_through(:ap_service_actor)
get("/", ActivityPubController, :internal_fetch)
post("/inbox", ActivityPubController, :inbox)
end
scope "/.well-known", Pleroma.Web do
pipe_through(:well_known)
get("/host-meta", WebFinger.WebFingerController, :host_meta)
get("/webfinger", WebFinger.WebFingerController, :webfinger)
get("/nodeinfo", Nodeinfo.NodeinfoController, :schemas)
end
scope "/nodeinfo", Pleroma.Web do
get("/:version", Nodeinfo.NodeinfoController, :nodeinfo)
end
scope "/", Pleroma.Web do
pipe_through(:api)
get("/manifest.json", ManifestController, :show)
end
scope "/", Pleroma.Web do
pipe_through(:pleroma_html)
post("/auth/password", OAuth.PasswordController, :request)
get("/embed/:id", EmbedController, :show)
end
scope "/proxy/", Pleroma.Web do
get("/preview/:sig/:url", MediaProxy.MediaProxyController, :preview)
get("/preview/:sig/:url/:filename", MediaProxy.MediaProxyController, :preview)
get("/:sig/:url", MediaProxy.MediaProxyController, :remote)
get("/:sig/:url/:filename", MediaProxy.MediaProxyController, :remote)
end
if Pleroma.Config.get(:env) == :dev do
scope "/dev" do
pipe_through([:mailbox_preview])
forward("/mailbox", Plug.Swoosh.MailboxPreview, base_path: "/dev/mailbox")
end
end
scope "/" do
pipe_through([:pleroma_html, :authenticate, :require_admin])
live_dashboard("/pleroma/live_dashboard", additional_pages: [oban: Oban.LiveDashboard])
oban_dashboard("/pleroma/oban")
end
# Test-only routes needed to test action dispatching and plug chain execution
if Pleroma.Config.get(:env) == :test do
@test_actions [
:do_oauth_check,
:fallback_oauth_check,
:skip_oauth_check,
:fallback_oauth_skip_publicity_check,
:skip_oauth_skip_publicity_check,
:missing_oauth_check_definition
]
scope "/test/api", Pleroma.Tests do
pipe_through(:api)
for action <- @test_actions do
get("/#{action}", AuthTestController, action)
end
end
scope "/test/authenticated_api", Pleroma.Tests do
pipe_through(:authenticated_api)
for action <- @test_actions do
get("/#{action}", AuthTestController, action)
end
end
end
scope "/", Pleroma.Web.MongooseIM do
get("/user_exists", MongooseIMController, :user_exists)
get("/check_password", MongooseIMController, :check_password)
end
scope "/", Pleroma.Web.Fallback do
get("/registration/:token", RedirectController, :registration_page)
get("/:maybe_nickname_or_id", RedirectController, :redirector_with_meta)
match(:*, "/api/pleroma/*path", LegacyPleromaApiRerouterPlug, [])
get("/api/*path", RedirectController, :api_not_implemented)
get("/phoenix/live_dashboard/*path", RedirectController, :live_dashboard)
get("/*path", RedirectController, :redirector_with_preload)
options("/*path", RedirectController, :empty)
end
# /pleroma/{phoenix,oban}/* need to get filtered out from api routes for frontend configuration
# to not drop admin overrides for /pleroma/admin.
@non_api_routes ["/pleroma/live_dashboard", "/pleroma/oban"]
def get_api_routes do
Phoenix.Router.routes(__MODULE__)
|> Enum.reject(fn r -> r.plug == Pleroma.Web.Fallback.RedirectController end)
|> Enum.reject(fn r -> String.starts_with?(r.path, @non_api_routes) end)
|> Enum.map(fn r ->
r.path
|> String.split("/", trim: true)
|> List.first()
end)
|> Enum.uniq()
end
end
diff --git a/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs b/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs
new file mode 100644
index 000000000..7c7d529e0
--- /dev/null
+++ b/priv/repo/migrations/20200602125217_add_group_key_to_notifications.exs
@@ -0,0 +1,15 @@
+defmodule Pleroma.Repo.Migrations.AddGroupKeyToNotifications do
+ use Ecto.Migration
+
+ def change do
+ alter table(:notifications) do
+ add(:group_key, :string)
+ end
+
+ create_if_not_exists(
+ index(:notifications, [:user_id, :group_key, "id desc nulls last"],
+ where: "group_key IS NOT NULL"
+ )
+ )
+ end
+end
diff --git a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs
index dd4b7389e..55df41ec6 100644
--- a/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs
+++ b/test/pleroma/web/mastodon_api/controllers/notification_controller_test.exs
@@ -1,717 +1,1032 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
use Pleroma.Web.ConnCase, async: false
alias Pleroma.Notification
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
test "does NOT render account/pleroma/relationship by default" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
response =
conn
|> assign(:user, user)
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(200)
assert Enum.all?(response, fn n ->
get_in(n, ["account", "pleroma", "relationship"]) == %{}
end)
end
test "list of notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/notifications")
expected_response =
"hi <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{user.id}\" href=\"#{user.ap_id}\" rel=\"ugc\">@<span>#{user.nickname}</span></a></span>"
assert [%{"status" => %{"content" => response}} | _rest] =
json_response_and_validate_schema(conn, 200)
assert response == expected_response
end
test "by default, does not contain pleroma:chat_mention" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, _activity} = CommonAPI.post_chat_message(other_user, user, "hey")
result =
conn
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(200)
assert [] == result
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:chat_mention")
|> json_response_and_validate_schema(200)
assert [_] = result
end
test "by default, does not contain pleroma:report" do
clear_config([:instance, :moderator_privileges], [:reports_manage_reports])
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, user} = user |> User.admin_api_update(%{is_moderator: true})
%{conn: conn} = oauth_access(["read:notifications"], user: user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
{:ok, _report} =
CommonAPI.report(third_user, %{account_id: other_user.id, status_ids: [activity.id]})
result =
conn
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(200)
assert [] == result
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:report")
|> json_response_and_validate_schema(200)
assert [_] = result
end
test "Pleroma:report is hidden for non-privileged users" do
clear_config([:instance, :moderator_privileges], [:reports_manage_reports])
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, user} = user |> User.admin_api_update(%{is_moderator: true})
%{conn: conn} = oauth_access(["read:notifications"], user: user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hey"})
{:ok, _report} =
CommonAPI.report(third_user, %{account_id: other_user.id, status_ids: [activity.id]})
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:report")
|> json_response_and_validate_schema(200)
assert [_] = result
clear_config([:instance, :moderator_privileges], [])
result =
conn
|> get("/api/v1/notifications?include_types[]=pleroma:report")
|> json_response_and_validate_schema(200)
assert [] == result
end
test "excludes mentions from blockers when blockers_visible is false" do
clear_config([:activitypub, :blockers_visible], false)
%{user: user, conn: conn} = oauth_access(["read:notifications"])
blocker = insert(:user)
{:ok, _} = CommonAPI.block(user, blocker)
{:ok, activity} = CommonAPI.post(blocker, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/notifications")
assert [] == json_response_and_validate_schema(conn, 200)
end
test "getting a single notification" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn = get(conn, "/api/v1/notifications/#{notification.id}")
expected_response =
"hi <span class=\"h-card\"><a class=\"u-url mention\" data-user=\"#{user.id}\" href=\"#{user.ap_id}\" rel=\"ugc\">@<span>#{user.nickname}</span></a></span>"
assert %{"status" => %{"content" => response}} = json_response_and_validate_schema(conn, 200)
assert response == expected_response
end
test "dismissing a single notification (deprecated endpoint)" do
%{user: user, conn: conn} = oauth_access(["write:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> put_req_header("content-type", "application/json")
|> post("/api/v1/notifications/dismiss", %{"id" => to_string(notification.id)})
assert %{} = json_response_and_validate_schema(conn, 200)
end
test "dismissing a single notification" do
%{user: user, conn: conn} = oauth_access(["write:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
conn =
conn
|> assign(:user, user)
|> post("/api/v1/notifications/#{notification.id}/dismiss")
assert %{} = json_response_and_validate_schema(conn, 200)
end
test "clearing all notifications" do
%{user: user, conn: conn} = oauth_access(["write:notifications", "read:notifications"])
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, [_notification]} = Notification.create_notifications(activity)
ret_conn = post(conn, "/api/v1/notifications/clear")
assert %{} = json_response_and_validate_schema(ret_conn, 200)
ret_conn = get(conn, "/api/v1/notifications")
assert all = json_response_and_validate_schema(ret_conn, 200)
assert all == []
end
test "paginates notifications using min_id, since_id, max_id, and limit" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity3} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity4} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
notification1_id = get_notification_id_by_activity(activity1)
notification2_id = get_notification_id_by_activity(activity2)
notification3_id = get_notification_id_by_activity(activity3)
notification4_id = get_notification_id_by_activity(activity4)
conn = assign(conn, :user, user)
# min_id
result =
conn
|> get("/api/v1/notifications?limit=2&min_id=#{notification1_id}")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
# since_id
result =
conn
|> get("/api/v1/notifications?limit=2&since_id=#{notification1_id}")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
# max_id
result =
conn
|> get("/api/v1/notifications?limit=2&max_id=#{notification4_id}")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification3_id}, %{"id" => ^notification2_id}] = result
end
describe "exclude_visibilities" do
test "filters notifications for mentions" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, public_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "public"})
{:ok, direct_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"})
{:ok, unlisted_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "unlisted"})
{:ok, private_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "private"})
query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "private"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == direct_activity.id
query = params_to_query(%{exclude_visibilities: ["public", "unlisted", "direct"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == private_activity.id
query = params_to_query(%{exclude_visibilities: ["public", "private", "direct"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == unlisted_activity.id
query = params_to_query(%{exclude_visibilities: ["unlisted", "private", "direct"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"status" => %{"id" => id}}] = json_response_and_validate_schema(conn_res, 200)
assert id == public_activity.id
end
test "filters notifications for Like activities" do
user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["read:notifications"])
{:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(other_user, user)
{:ok, _, _, %{data: %{"state" => "accept"}}} = CommonAPI.follow(user, other_user)
{:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"})
{:ok, direct_activity} =
CommonAPI.post(other_user, %{status: "@#{user.nickname}", visibility: "direct"})
{:ok, unlisted_activity} =
CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"})
{:ok, private_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "private"})
{:ok, _} = CommonAPI.favorite(public_activity.id, user)
{:ok, _} = CommonAPI.favorite(direct_activity.id, user)
{:ok, _} = CommonAPI.favorite(unlisted_activity.id, user)
{:ok, _} = CommonAPI.favorite(private_activity.id, user)
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=direct")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
assert unlisted_activity.id in activity_ids
assert private_activity.id in activity_ids
refute direct_activity.id in activity_ids
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=unlisted")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
refute unlisted_activity.id in activity_ids
assert private_activity.id in activity_ids
assert direct_activity.id in activity_ids
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=private")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
assert unlisted_activity.id in activity_ids
refute private_activity.id in activity_ids
assert direct_activity.id in activity_ids
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=public")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
refute public_activity.id in activity_ids
assert unlisted_activity.id in activity_ids
assert private_activity.id in activity_ids
assert direct_activity.id in activity_ids
end
test "filters notifications for Announce activities" do
user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["read:notifications"])
{:ok, public_activity} = CommonAPI.post(other_user, %{status: ".", visibility: "public"})
{:ok, unlisted_activity} =
CommonAPI.post(other_user, %{status: ".", visibility: "unlisted"})
{:ok, _} = CommonAPI.repeat(public_activity.id, user)
{:ok, _} = CommonAPI.repeat(unlisted_activity.id, user)
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=unlisted")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert public_activity.id in activity_ids
refute unlisted_activity.id in activity_ids
end
test "doesn't return less than the requested amount of records when the user's reply is liked" do
user = insert(:user)
%{user: other_user, conn: conn} = oauth_access(["read:notifications"])
{:ok, mention} =
CommonAPI.post(user, %{status: "@#{other_user.nickname}", visibility: "public"})
{:ok, activity} = CommonAPI.post(user, %{status: ".", visibility: "public"})
{:ok, reply} =
CommonAPI.post(other_user, %{
status: ".",
visibility: "public",
in_reply_to_status_id: activity.id
})
{:ok, _favorite} = CommonAPI.favorite(reply.id, user)
activity_ids =
conn
|> get("/api/v1/notifications?exclude_visibilities[]=direct&limit=2")
|> json_response_and_validate_schema(200)
|> Enum.map(& &1["status"]["id"])
assert [reply.id, mention.id] == activity_ids
end
end
test "filters notifications using exclude_types" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, other_user)
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, other_user)
mention_notification_id = get_notification_id_by_activity(mention_activity)
favorite_notification_id = get_notification_id_by_activity(favorite_activity)
reblog_notification_id = get_notification_id_by_activity(reblog_activity)
follow_notification_id = get_notification_id_by_activity(follow_activity)
query = params_to_query(%{exclude_types: ["mention", "favourite", "reblog"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
query = params_to_query(%{exclude_types: ["favourite", "reblog", "follow"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^mention_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
query = params_to_query(%{exclude_types: ["reblog", "follow", "mention"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^favorite_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
query = params_to_query(%{exclude_types: ["follow", "mention", "favourite"]})
conn_res = get(conn, "/api/v1/notifications?" <> query)
assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200)
end
test "filters notifications using types" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, mention_activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, other_user)
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, other_user)
mention_notification_id = get_notification_id_by_activity(mention_activity)
favorite_notification_id = get_notification_id_by_activity(favorite_activity)
reblog_notification_id = get_notification_id_by_activity(reblog_activity)
follow_notification_id = get_notification_id_by_activity(follow_activity)
conn_res = get(conn, "/api/v1/notifications?types[]=follow")
assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
conn_res = get(conn, "/api/v1/notifications?types[]=mention")
assert [%{"id" => ^mention_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
conn_res = get(conn, "/api/v1/notifications?types[]=favourite")
assert [%{"id" => ^favorite_notification_id}] =
json_response_and_validate_schema(conn_res, 200)
conn_res = get(conn, "/api/v1/notifications?types[]=reblog")
assert [%{"id" => ^reblog_notification_id}] = json_response_and_validate_schema(conn_res, 200)
result = conn |> get("/api/v1/notifications") |> json_response_and_validate_schema(200)
assert length(result) == 4
query = params_to_query(%{types: ["follow", "mention", "favourite", "reblog"]})
result =
conn
|> get("/api/v1/notifications?" <> query)
|> json_response_and_validate_schema(200)
assert length(result) == 4
end
test "filtering falls back to include_types" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, _activity} = CommonAPI.post(other_user, %{status: "hey @#{user.nickname}"})
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, _activity} = CommonAPI.favorite(create_activity.id, other_user)
{:ok, _activity} = CommonAPI.repeat(create_activity.id, other_user)
{:ok, _, _, follow_activity} = CommonAPI.follow(user, other_user)
follow_notification_id = get_notification_id_by_activity(follow_activity)
conn_res = get(conn, "/api/v1/notifications?include_types[]=follow")
assert [%{"id" => ^follow_notification_id}] = json_response_and_validate_schema(conn_res, 200)
end
test "destroy multiple" do
%{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"])
other_user = insert(:user)
{:ok, activity1} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity2} = CommonAPI.post(other_user, %{status: "hi @#{user.nickname}"})
{:ok, activity3} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"})
{:ok, activity4} = CommonAPI.post(user, %{status: "hi @#{other_user.nickname}"})
notification1_id = get_notification_id_by_activity(activity1)
notification2_id = get_notification_id_by_activity(activity2)
notification3_id = get_notification_id_by_activity(activity3)
notification4_id = get_notification_id_by_activity(activity4)
result =
conn
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification2_id}, %{"id" => ^notification1_id}] = result
conn2 =
conn
|> assign(:user, other_user)
|> assign(:token, insert(:oauth_token, user: other_user, scopes: ["read:notifications"]))
result =
conn2
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
query = params_to_query(%{ids: [notification1_id, notification2_id]})
conn_destroy = delete(conn, "/api/v1/notifications/destroy_multiple?" <> query)
assert json_response_and_validate_schema(conn_destroy, 200) == %{}
result =
conn2
|> get("/api/v1/notifications")
|> json_response_and_validate_schema(:ok)
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
end
+ describe "GET /api/v2/notifications" do
+ test "groups favourite notifications for the same status" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, _} = CommonAPI.favorite(status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(status.id, other_user2)
+
+ notifications = Repo.all(Notification)
+ notification_ids = Enum.map(notifications, &to_string(&1.id))
+ assert [persisted_group_key] = notifications |> Enum.map(& &1.group_key) |> Enum.uniq()
+ assert is_binary(persisted_group_key)
+
+ result =
+ conn
+ |> get("/api/v2/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert [%{"id" => account_id1}, %{"id" => account_id2}] = result["accounts"]
+ assert account_id1 in [other_user1.id, other_user2.id]
+ assert account_id2 in [other_user1.id, other_user2.id]
+ assert [%{"id" => status_id}] = result["statuses"]
+ assert status_id == status.id
+
+ assert [group] = result["notification_groups"]
+ assert group["type"] == "favourite"
+ assert group["notifications_count"] == 2
+ assert group["status_id"] == status.id
+ assert group["most_recent_notification_id"] in notification_ids
+ assert group["page_min_id"] in notification_ids
+ assert group["page_max_id"] in notification_ids
+ assert group["latest_page_notification_at"]
+ assert Enum.sort(group["sample_account_ids"]) == Enum.sort([other_user1.id, other_user2.id])
+ group_key = group["group_key"]
+ assert group_key == persisted_group_key
+
+ assert [%{"group_key" => ^group_key}, %{"group_key" => ^group_key}] =
+ conn
+ |> get("/api/v1/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert %{"notification_groups" => [shown_group]} =
+ conn
+ |> get("/api/v2/notifications/#{group_key}")
+ |> json_response_and_validate_schema(200)
+
+ assert shown_group["group_key"] == group_key
+ assert shown_group["notifications_count"] == 2
+ refute Map.has_key?(shown_group, "page_min_id")
+ refute Map.has_key?(shown_group, "page_max_id")
+ refute Map.has_key?(shown_group, "latest_page_notification_at")
+ end
+
+ test "round-trips ungrouped group keys when grouped_types excludes a type" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, _} = CommonAPI.favorite(status.id, other_user)
+
+ %{"notification_groups" => [%{"group_key" => "ungrouped-" <> _ = group_key}]} =
+ conn
+ |> get("/api/v2/notifications?grouped_types[]=reblog")
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "notification_groups" => [%{"group_key" => ^group_key, "notifications_count" => 1}]
+ } =
+ conn
+ |> get("/api/v2/notifications/#{group_key}")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "keeps legacy notifications without persisted group keys ungrouped" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, favorite1} = CommonAPI.favorite(status.id, other_user1)
+ {:ok, favorite2} = CommonAPI.favorite(status.id, other_user2)
+
+ notification_ids =
+ [favorite1, favorite2]
+ |> Enum.map(&get_notification_id_by_activity/1)
+
+ Repo.update_all(Notification, set: [group_key: nil])
+
+ expected_group_keys = Enum.map(notification_ids, &"ungrouped-#{&1}")
+
+ assert [%{"group_key" => group_key1}, %{"group_key" => group_key2}] =
+ conn
+ |> get("/api/v1/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.sort([group_key1, group_key2]) == Enum.sort(expected_group_keys)
+
+ %{"notification_groups" => groups} =
+ conn
+ |> get("/api/v2/notifications")
+ |> json_response_and_validate_schema(200)
+
+ assert Enum.sort(Enum.map(groups, & &1["group_key"])) == Enum.sort(expected_group_keys)
+ assert Enum.all?(groups, &(&1["notifications_count"] == 1))
+
+ group_key = List.first(expected_group_keys)
+
+ assert %{
+ "notification_groups" => [%{"group_key" => ^group_key, "notifications_count" => 1}]
+ } =
+ conn
+ |> get("/api/v2/notifications/#{group_key}")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "paginates notification groups instead of raw notifications" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ other_user3 = insert(:user)
+
+ {:ok, older_status} = CommonAPI.post(user, %{status: "older"})
+ {:ok, _} = CommonAPI.favorite(older_status.id, other_user3)
+
+ {:ok, newer_status} = CommonAPI.post(user, %{status: "newer"})
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user2)
+ older_status_id = older_status.id
+ newer_status_id = newer_status.id
+
+ %{"notification_groups" => groups} =
+ conn
+ |> get("/api/v2/notifications?limit=2")
+ |> json_response_and_validate_schema(200)
+
+ assert [
+ %{"status_id" => ^newer_status_id, "notifications_count" => 2},
+ %{"status_id" => ^older_status_id, "notifications_count" => 1}
+ ] = groups
+ end
+
+ test "uses group-level cursors when paginating notification groups" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ other_user3 = insert(:user)
+
+ {:ok, older_status} = CommonAPI.post(user, %{status: "older"})
+ {:ok, _} = CommonAPI.favorite(older_status.id, other_user3)
+
+ {:ok, newer_status} = CommonAPI.post(user, %{status: "newer"})
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(newer_status.id, other_user2)
+ older_status_id = older_status.id
+ newer_status_id = newer_status.id
+
+ assert %{
+ "notification_groups" => [
+ %{
+ "status_id" => ^newer_status_id,
+ "notifications_count" => 2,
+ "most_recent_notification_id" => cursor
+ }
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications?limit=1")
+ |> json_response_and_validate_schema(200)
+
+ assert %{
+ "notification_groups" => [
+ %{"status_id" => ^older_status_id, "notifications_count" => 1}
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications?limit=1&max_id=#{cursor}")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "returns total notification count for a partially represented group" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ other_user3 = insert(:user)
+
+ {:ok, grouped_status} = CommonAPI.post(user, %{status: "grouped"})
+ {:ok, _} = CommonAPI.favorite(grouped_status.id, other_user1)
+
+ {:ok, other_status} = CommonAPI.post(user, %{status: "other"})
+ {:ok, _} = CommonAPI.favorite(other_status.id, other_user3)
+ {:ok, _} = CommonAPI.favorite(grouped_status.id, other_user2)
+ grouped_status_id = grouped_status.id
+
+ assert %{
+ "notification_groups" => [
+ %{"status_id" => ^grouped_status_id, "notifications_count" => 2}
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications?limit=1")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "counts unread notification groups" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user1 = insert(:user)
+ other_user2 = insert(:user)
+ mentioner = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+ {:ok, _} = CommonAPI.favorite(status.id, other_user1)
+ {:ok, _} = CommonAPI.favorite(status.id, other_user2)
+ {:ok, _} = CommonAPI.post(mentioner, %{status: "hi @#{user.nickname}"})
+
+ assert %{"count" => 2} =
+ conn
+ |> get("/api/v2/notifications/unread_count")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "counts unread notification groups newer than the notification marker" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+ mentioner = insert(:user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "older"})
+ {:ok, favorite_activity} = CommonAPI.favorite(status.id, other_user)
+ marker_id = get_notification_id_by_activity(favorite_activity)
+
+ {:ok, _} = CommonAPI.post(mentioner, %{status: "newer @#{user.nickname}"})
+
+ {:ok, _} =
+ Pleroma.Marker.upsert(user, %{"notifications" => %{"last_read_id" => marker_id}})
+
+ assert %{"count" => 1} =
+ conn
+ |> get("/api/v2/notifications/unread_count")
+ |> json_response_and_validate_schema(200)
+ end
+
+ test "does not preserve stale cursor params in grouped notification link headers" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+ other_user = insert(:user)
+
+ {:ok, activity1} = CommonAPI.post(other_user, %{status: "one @#{user.nickname}"})
+ {:ok, _activity2} = CommonAPI.post(other_user, %{status: "two @#{user.nickname}"})
+ {:ok, activity3} = CommonAPI.post(other_user, %{status: "three @#{user.nickname}"})
+
+ notification1_id = get_notification_id_by_activity(activity1)
+ notification3_id = get_notification_id_by_activity(activity3)
+
+ conn = get(conn, "/api/v2/notifications?since_id=#{notification1_id}&limit=1")
+
+ assert [link_header] = get_resp_header(conn, "link")
+ assert link_header =~ ~r/max_id=#{notification3_id}/
+ refute link_header =~ "since_id="
+ end
+
+ test "lists accounts from a notification group" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications"])
+
+ {other_users, %{"group_key" => group_key, "sample_account_ids" => sample_account_ids}} =
+ create_favourite_notification_group(user, conn)
+
+ assert length(sample_account_ids) == 8
+
+ account_ids =
+ conn
+ |> get("/api/v2/notifications/#{group_key}/accounts")
+ |> json_response_and_validate_schema(200)
+ |> Enum.map(& &1["id"])
+
+ assert Enum.sort(account_ids) == Enum.sort(Enum.map(other_users, & &1.id))
+ end
+
+ test "dismisses a notification group" do
+ %{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"])
+
+ {_other_users, %{"group_key" => group_key}} =
+ create_favourite_notification_group(user, conn)
+
+ assert %{} =
+ conn
+ |> post("/api/v2/notifications/#{group_key}/dismiss")
+ |> json_response_and_validate_schema(200)
+
+ assert [] = Notification.for_user(user)
+ end
+ end
+
test "doesn't see notifications after muting user with notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user2, user)
{:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
ret_conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2)
conn = get(conn, "/api/v1/notifications")
assert json_response_and_validate_schema(conn, 200) == []
end
test "see notifications after muting user without notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user2, user)
{:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
ret_conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2, %{notifications: false})
conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(conn, 200)) == 1
end
test "see notifications after muting user with notifications and with_muted parameter" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user)
{:ok, _, _, _} = CommonAPI.follow(user2, user)
{:ok, _} = CommonAPI.post(user2, %{status: "hey @#{user.nickname}"})
ret_conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2)
conn = get(conn, "/api/v1/notifications?with_muted=true")
assert length(json_response_and_validate_schema(conn, 200)) == 1
end
test "see move notifications" do
old_user = insert(:user)
new_user = insert(:user, also_known_as: [old_user.ap_id])
%{user: follower, conn: conn} = oauth_access(["read:notifications"])
User.follow(follower, old_user)
Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
Pleroma.Tests.ObanHelpers.perform_all()
conn = get(conn, "/api/v1/notifications")
assert length(json_response_and_validate_schema(conn, 200)) == 1
end
describe "link headers" do
test "preserves parameters in link headers" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
other_user = insert(:user)
{:ok, activity1} =
CommonAPI.post(other_user, %{
status: "hi @#{user.nickname}",
visibility: "public"
})
{:ok, activity2} =
CommonAPI.post(other_user, %{
status: "hi @#{user.nickname}",
visibility: "public"
})
notification1 = Repo.get_by(Notification, activity_id: activity1.id)
notification2 = Repo.get_by(Notification, activity_id: activity2.id)
conn =
conn
|> assign(:user, user)
|> get("/api/v1/notifications?limit=5")
assert [link_header] = get_resp_header(conn, "link")
assert link_header =~ ~r/limit=5/
assert link_header =~ ~r/min_id=#{notification2.id}/
assert link_header =~ ~r/max_id=#{notification1.id}/
end
end
describe "from specified user" do
test "account_id" do
%{user: user, conn: conn} = oauth_access(["read:notifications"])
%{id: account_id} = other_user1 = insert(:user)
other_user2 = insert(:user)
{:ok, _activity} = CommonAPI.post(other_user1, %{status: "hi @#{user.nickname}"})
{:ok, _activity} = CommonAPI.post(other_user2, %{status: "bye @#{user.nickname}"})
assert [%{"account" => %{"id" => ^account_id}}] =
conn
|> assign(:user, user)
|> get("/api/v1/notifications?account_id=#{account_id}")
|> json_response_and_validate_schema(200)
assert %{"error" => "Account is not found"} =
conn
|> assign(:user, user)
|> get("/api/v1/notifications?account_id=cofe")
|> json_response_and_validate_schema(404)
end
end
defp get_notification_id_by_activity(%{id: id}) do
Notification
|> Repo.get_by(activity_id: id)
|> Map.get(:id)
|> to_string()
end
+ defp create_favourite_notification_group(user, conn) do
+ other_users = insert_list(9, :user)
+
+ {:ok, status} = CommonAPI.post(user, %{status: "hello"})
+
+ Enum.each(other_users, fn other_user ->
+ {:ok, _} = CommonAPI.favorite(status.id, other_user)
+ end)
+
+ %{
+ "notification_groups" => [
+ %{
+ "notifications_count" => 9
+ } = group
+ ]
+ } =
+ conn
+ |> get("/api/v2/notifications")
+ |> json_response_and_validate_schema(200)
+
+ {other_users, group}
+ end
+
defp params_to_query(%{} = params) do
Enum.map_join(params, "&", fn
{k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}")
{k, v} -> k <> "=" <> v
end)
end
end
diff --git a/test/pleroma/web/mastodon_api/views/notification_view_test.exs b/test/pleroma/web/mastodon_api/views/notification_view_test.exs
index c8287bb79..2d0801589 100644
--- a/test/pleroma/web/mastodon_api/views/notification_view_test.exs
+++ b/test/pleroma/web/mastodon_api/views/notification_view_test.exs
@@ -1,374 +1,377 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
use Pleroma.DataCase, async: false
alias Pleroma.Activity
alias Pleroma.Chat
alias Pleroma.Chat.MessageReference
alias Pleroma.Notification
alias Pleroma.Object
alias Pleroma.Repo
alias Pleroma.User
alias Pleroma.Web.AdminAPI.Report
alias Pleroma.Web.AdminAPI.ReportView
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.CommonAPI.Utils
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.NotificationView
alias Pleroma.Web.MastodonAPI.StatusView
alias Pleroma.Web.PleromaAPI.Chat.MessageReferenceView
import Pleroma.Factory
setup do
Mox.stub_with(Pleroma.UnstubbedConfigMock, Pleroma.Test.StaticConfig)
:ok
end
defp test_notifications_rendering(notifications, user, expected_result) do
result = NotificationView.render("index.json", %{notifications: notifications, for: user})
assert expected_result == result
result =
NotificationView.render("index.json", %{
notifications: notifications,
for: user,
relationships: nil
})
assert expected_result == result
end
test "ChatMessage notification" do
user = insert(:user)
recipient = insert(:user)
{:ok, activity} = CommonAPI.post_chat_message(user, recipient, "what's up my dude")
{:ok, [notification]} = Notification.create_notifications(activity)
object = Object.normalize(activity, fetch: false)
chat = Chat.get(recipient.id, user.ap_id)
cm_ref = MessageReference.for_chat_and_object(chat, object)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:chat_mention",
account: AccountView.render("show.json", %{user: user, for: recipient}),
chat_message: MessageReferenceView.render("show.json", %{chat_message_reference: cm_ref}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], recipient, [expected])
end
test "Mention notification" do
user = insert(:user)
mentioned_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "hey @#{mentioned_user.nickname}"})
{:ok, [notification]} = Notification.create_notifications(activity)
user = User.get_cached_by_id(user.id)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "mention",
account:
AccountView.render("show.json", %{
user: user,
for: mentioned_user
}),
status: StatusView.render("show.json", %{activity: activity, for: mentioned_user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], mentioned_user, [expected])
end
test "Favourite notification" do
user = insert(:user)
another_user = insert(:user)
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(favorite_activity)
create_activity = Activity.get_by_id(create_activity.id)
+ assert is_binary(notification.group_key)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: false, is_muted: false},
type: "favourite",
account: AccountView.render("show.json", %{user: another_user, for: user}),
status: StatusView.render("show.json", %{activity: create_activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], user, [expected])
end
test "Reblog notification" do
user = insert(:user)
another_user = insert(:user)
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(reblog_activity)
reblog_activity = Activity.get_by_id(create_activity.id)
+ assert is_binary(notification.group_key)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: false, is_muted: false},
type: "reblog",
account: AccountView.render("show.json", %{user: another_user, for: user}),
status: StatusView.render("show.json", %{activity: reblog_activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], user, [expected])
end
test "Follow notification" do
follower = insert(:user)
followed = insert(:user)
{:ok, followed, follower, _activity} = CommonAPI.follow(followed, follower)
notification = Notification |> Repo.one() |> Repo.preload(:activity)
+ assert is_binary(notification.group_key)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: false, is_muted: false},
type: "follow",
account: AccountView.render("show.json", %{user: follower, for: followed}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], followed, [expected])
User.perform(:delete, follower)
refute Repo.one(Notification)
end
test "Move notification" do
old_user = insert(:user)
new_user = insert(:user, also_known_as: [old_user.ap_id])
follower = insert(:user)
User.follow(follower, old_user)
Pleroma.Web.ActivityPub.ActivityPub.move(old_user, new_user)
Pleroma.Tests.ObanHelpers.perform_all()
old_user = refresh_record(old_user)
new_user = refresh_record(new_user)
[notification] = Notification.for_user(follower)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "move",
account: AccountView.render("show.json", %{user: old_user, for: follower}),
target: AccountView.render("show.json", %{user: new_user, for: follower}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], follower, [expected])
end
test "EmojiReact notification" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "#cofe"})
{:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "☕")
activity = Repo.get(Activity, activity.id)
[notification] = Notification.for_user(user)
assert notification
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:emoji_reaction",
emoji: "☕",
account: AccountView.render("show.json", %{user: other_user, for: user}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at),
emoji_url: nil
}
test_notifications_rendering([notification], user, [expected])
end
test "EmojiReact custom emoji notification" do
user = insert(:user)
other_user = insert(:user)
note =
insert(:note,
user: user,
data: %{
"reactions" => [
["👍", [user.ap_id], nil],
["dinosaur", [user.ap_id], "http://localhost:4001/emoji/dino%20walking.gif"]
]
}
)
activity = insert(:note_activity, note: note, user: user)
{:ok, _activity} = CommonAPI.react_with_emoji(activity.id, other_user, "dinosaur")
activity = Repo.get(Activity, activity.id)
[notification] = Notification.for_user(user)
assert notification
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:emoji_reaction",
emoji: ":dinosaur:",
account: AccountView.render("show.json", %{user: other_user, for: user}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at),
emoji_url: "http://localhost:4001/emoji/dino%20walking.gif"
}
test_notifications_rendering([notification], user, [expected])
end
test "Poll notification" do
user = insert(:user)
activity = insert(:question_activity, user: user)
{:ok, [notification]} = Notification.create_poll_notifications(activity)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "poll",
account:
AccountView.render("show.json", %{
user: user,
for: user
}),
status: StatusView.render("show.json", %{activity: activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], user, [expected])
end
test "Report notification" do
clear_config([:instance, :moderator_privileges], [:reports_manage_reports])
reporting_user = insert(:user)
reported_user = insert(:user)
moderator_user = insert(:user, is_moderator: true)
{:ok, activity} = CommonAPI.report(reporting_user, %{account_id: reported_user.id})
{:ok, [notification]} = Notification.create_notifications(activity)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "pleroma:report",
account: AccountView.render("show.json", %{user: reporting_user, for: moderator_user}),
created_at: Utils.to_masto_date(notification.inserted_at),
report: ReportView.render("show.json", Report.extract_report_info(activity))
}
test_notifications_rendering([notification], moderator_user, [expected])
end
test "Edit notification" do
user = insert(:user)
repeat_user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{status: "mew"})
{:ok, _} = CommonAPI.repeat(activity.id, repeat_user)
{:ok, update} = CommonAPI.update(activity, user, %{status: "mew mew"})
user = Pleroma.User.get_by_ap_id(user.ap_id)
activity = Pleroma.Activity.normalize(activity)
update = Pleroma.Activity.normalize(update)
{:ok, [notification]} = Notification.create_notifications(update)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "update",
account: AccountView.render("show.json", %{user: user, for: repeat_user}),
created_at: Utils.to_masto_date(notification.inserted_at),
status: StatusView.render("show.json", %{activity: activity, for: repeat_user})
}
test_notifications_rendering([notification], repeat_user, [expected])
end
test "muted notification" do
user = insert(:user)
another_user = insert(:user)
{:ok, _} = Pleroma.UserRelationship.create_mute(user, another_user)
{:ok, create_activity} = CommonAPI.post(user, %{status: "hey"})
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(favorite_activity)
create_activity = Activity.get_by_id(create_activity.id)
expected = %{
id: to_string(notification.id),
- group_key: "ungrouped-#{to_string(notification.id)}",
+ group_key: Notification.group_key(notification),
pleroma: %{is_seen: true, is_muted: true},
type: "favourite",
account: AccountView.render("show.json", %{user: another_user, for: user}),
status: StatusView.render("show.json", %{activity: create_activity, for: user}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], user, [expected])
end
test "Subscribed status notification" do
user = insert(:user)
subscriber = insert(:user)
User.subscribe(subscriber, user)
{:ok, activity} = CommonAPI.post(user, %{status: "hi"})
{:ok, [notification]} = Notification.create_notifications(activity)
user = User.get_cached_by_id(user.id)
expected = %{
id: to_string(notification.id),
group_key: "ungrouped-#{to_string(notification.id)}",
pleroma: %{is_seen: false, is_muted: false},
type: "status",
account:
AccountView.render("show.json", %{
user: user,
for: subscriber
}),
status: StatusView.render("show.json", %{activity: activity, for: subscriber}),
created_at: Utils.to_masto_date(notification.inserted_at)
}
test_notifications_rendering([notification], subscriber, [expected])
end
end
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Sat, Aug 29, 5:36 AM (1 d, 11 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1736807
Default Alt Text
(172 KB)
Attached To
Mode
rPUBE pleroma-upstream
Attached
Detach File
Event Timeline
Log In to Comment