Persist notification group keys

This commit is contained in:
Lain Soykaf 2026-05-23 11:38:33 +04:00
commit add3844c6d
No known key found for this signature in database
10 changed files with 505 additions and 116 deletions

View file

@ -1 +1 @@
Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications`
Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications` for newly-created notifications

View file

@ -35,6 +35,7 @@ defmodule Pleroma.Notification do
# 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)
@ -49,7 +50,7 @@ defmodule Pleroma.Notification do
|> type_from_activity()
notification
|> changeset(%{type: type})
|> changeset(%{type: type, group_key: grouped_notification_key(type, activity)})
|> Repo.update()
end
end
@ -97,14 +98,11 @@ defmodule Pleroma.Notification do
group_key(notification, @groupable_notification_types)
end
def group_key(%Notification{type: type} = notification, grouped_types) do
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 do
case group_target_id(notification) do
nil -> ungrouped_group_key(notification)
target_id -> "#{type}-#{target_id}-#{group_time_bucket(notification)}"
end
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
@ -116,15 +114,27 @@ defmodule Pleroma.Notification do
defp ungrouped_group_key(%Notification{id: id}), do: "ungrouped-#{id}"
defp group_time_bucket(%Notification{inserted_at: inserted_at}) do
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_target_id(%Notification{type: type, activity: activity})
when type in ["favourite", "reblog"] do
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)
@ -133,14 +143,14 @@ defmodule Pleroma.Notification do
end
end
defp group_target_id(%Notification{type: "follow", activity: %{data: %{"object" => ap_id}}}) do
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 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
@ -163,7 +173,7 @@ defmodule Pleroma.Notification do
def changeset(%Notification{} = notification, attrs) do
notification
|> cast(attrs, [:seen, :type])
|> cast(attrs, [:seen, :type, :group_key])
|> validate_inclusion(:type, @notification_types)
end
@ -542,7 +552,8 @@ defmodule Pleroma.Notification do
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()

View file

@ -3,7 +3,6 @@
# 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
@ -125,7 +124,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
tags: ["Notifications"],
summary: "Retrieve notification group accounts",
operationId: "NotificationController.group_accounts",
security: [%{"oAuth" => ["write:notifications"]}],
security: [%{"oAuth" => ["read:notifications"]}],
parameters: [group_key_param()],
responses: %{
200 =>
@ -269,7 +268,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
},
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,

View file

@ -55,16 +55,24 @@ defmodule Pleroma.Web.ControllerHelper do
# 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)
defp build_pagination_fields(conn, min_id, max_id, extra_params, order) do
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)
# OpenApiSpex casts params to atoms while uncast conn params are string-keyed. Drop both so
# generated links replace pagination cursors instead of preserving stale request cursors.
|> Map.drop(@id_keys ++ @id_key_atoms)
{{next_id, nid}, {prev_id, pid}} =
if order == :desc,

View file

@ -13,7 +13,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
alias Pleroma.Web.MastodonAPI.MastodonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug
@oauth_read_actions [:show, :index, :grouped_index, :show_group, :unread_count]
# 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)
@ -92,7 +94,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
conn,
_
) do
notifications = MastodonAPI.get_notification_group(user, group_key, %{})
{notifications, notification_group_counts, notification_group_bounds} =
MastodonAPI.get_notification_group_result(user, group_key, %{})
if Enum.empty?(notifications) do
conn
@ -103,8 +106,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
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
grouped_types: grouped_types,
include_page_metadata: false
)
end
end
@ -115,10 +121,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
conn,
_
) do
users =
user
|> MastodonAPI.get_notification_group(group_key, %{})
|> notification_actors()
# 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
@ -154,12 +159,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
conn,
_
) do
ids =
user
|> MastodonAPI.get_notification_group(group_key, %{})
|> Enum.map(& &1.id)
Notification.destroy_multiple(user, ids)
MastodonAPI.dismiss_notification_group(user, group_key)
json(conn, %{})
end
@ -179,7 +179,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
defp do_get_grouped_notifications(%{assigns: %{user: user}} = conn, params) do
params = normalize_notification_params(params)
{notification_groups, page_notifications, notification_group_counts} =
{notification_groups, page_notifications, notification_group_counts,
notification_group_bounds} =
MastodonAPI.get_grouped_notification_page(user, params)
conn
@ -187,6 +188,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
|> 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"]
)
@ -203,13 +205,6 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
|> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
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
# 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

View file

@ -6,6 +6,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
import Ecto.Query
import Ecto.Changeset
alias Pleroma.Marker
alias Pleroma.Notification
alias Pleroma.Pagination
alias Pleroma.Repo
@ -13,6 +14,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
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 =
@ -68,38 +71,55 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
end
def get_grouped_notification_page(user, params \\ %{}) do
grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types))
{query, order} = group_pagination_query(notifications_query(user, params), params)
grouped_types =
params
|> Map.get("grouped_types", Map.get(params, :grouped_types))
|> Notification.normalize_grouped_types()
notifications =
query = notifications_query(user, params)
{order, cursor_filters} = group_pagination(params)
group_rows =
query
|> order_by([n], [{^order, n.id}])
|> Repo.all()
|> notification_group_rows(grouped_types, grouped_limit(params), order, cursor_filters)
notification_group_counts =
Enum.frequencies_by(notifications, &Notification.group_key(&1, grouped_types))
group_rows = if order == :asc, do: Enum.reverse(group_rows), else: group_rows
page_notifications =
notifications
|> take_grouped_page_notifications(grouped_types, grouped_limit(params))
page_notifications = representative_notifications(query, group_rows)
page_notifications =
if order == :asc, do: Enum.reverse(page_notifications), else: page_notifications
notification_groups =
Enum.map(group_rows, &notification_group_sample(user, params, &1.group_key))
{
Notification.group_notifications(page_notifications, grouped_types),
notification_groups,
page_notifications,
notification_group_counts
notification_group_counts(group_rows),
notification_group_bounds(group_rows)
}
end
def get_grouped_notification_groups(user, params \\ %{}) do
{groups, _notifications, _notification_group_counts} =
{groups, _notifications, _notification_group_counts, _notification_group_bounds} =
get_grouped_notification_page(user, params)
groups
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
@ -110,36 +130,58 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
end
def get_notification_group(user, group_key, params) do
grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types))
user
|> notifications_query(params)
|> order_by([n], desc: n.id)
|> Repo.all()
|> Enum.filter(&(Notification.group_key(&1, grouped_types) == group_key))
notification_group_sample(user, params, group_key)
end
defp group_pagination_query(query, params) do
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", Map.get(params, :min_id)) ->
query = where(query, [n], n.id > ^min_id)
cursor_filters = [{:gt, min_id}]
query =
cursor_filters =
case Map.get(params, "max_id", Map.get(params, :max_id)) do
nil -> query
max_id -> where(query, [n], n.id < ^max_id)
nil -> cursor_filters
max_id -> [{:lt, max_id} | cursor_filters]
end
{query, :asc}
{:asc, cursor_filters}
since_id = Map.get(params, "since_id", Map.get(params, :since_id)) ->
{where(query, [n], n.id > ^since_id), :desc}
{:desc, [{:gt, since_id}]}
max_id = Map.get(params, "max_id", Map.get(params, :max_id)) ->
{where(query, [n], n.id < ^max_id), :desc}
{:desc, [{:lt, max_id}]}
true ->
{query, :desc}
{:desc, []}
end
end
@ -150,41 +192,190 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
|> min(80)
end
defp take_grouped_page_notifications(notifications, grouped_types, group_limit) do
{notifications, _group_keys} =
Enum.reduce_while(notifications, {[], MapSet.new()}, fn notification,
{notifications, group_keys} ->
group_key = Notification.group_key(notification, grouped_types)
cond do
MapSet.member?(group_keys, group_key) ->
{:cont, {[notification | notifications], group_keys}}
MapSet.size(group_keys) < group_limit ->
{:cont, {[notification | notifications], MapSet.put(group_keys, group_key)}}
true ->
{:halt, {notifications, group_keys}}
end
end)
Enum.reverse(notifications)
end
def unread_notification_group_count(user, params \\ %{}) do
grouped_types = Map.get(params, "grouped_types", Map.get(params, :grouped_types))
grouped_types =
params
|> Map.get("grouped_types", Map.get(params, :grouped_types))
|> Notification.normalize_grouped_types()
limit = unread_count_limit(params)
user
|> notifications_query(params)
|> where([n], n.seen == false)
|> order_by([n], desc: n.id)
|> limit(^limit)
|> Repo.all()
|> Notification.group_notifications(grouped_types)
# 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)

View file

@ -90,6 +90,8 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
) 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
@ -110,7 +112,14 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
notification_groups:
Enum.map(
notification_groups,
&render_group(&1, reading_user, grouped_types, notification_group_counts)
&render_group(
&1,
reading_user,
grouped_types,
notification_group_counts,
notification_group_bounds,
include_page_metadata
)
)
}
end
@ -184,27 +193,50 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
[%Notification{} = notification | _] = notifications,
_reading_user,
grouped_types,
notification_group_counts
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: to_string(latest_notification.id),
page_min_id: to_string(oldest_notification.id),
page_max_id: to_string(latest_notification.id),
latest_page_notification_at: CommonAPI.Utils.to_masto_date(latest_notification.inserted_at),
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

View file

@ -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

View file

@ -587,10 +587,10 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
{:ok, _} = CommonAPI.favorite(status.id, other_user1)
{:ok, _} = CommonAPI.favorite(status.id, other_user2)
notification_ids =
Notification
|> Repo.all()
|> Enum.map(&to_string(&1.id))
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
@ -613,6 +613,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
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
@ -626,6 +627,9 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
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
@ -648,6 +652,48 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
|> 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)
@ -674,6 +720,44 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
] = 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)
@ -715,27 +799,78 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
|> 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 and dismisses a notification group" do
%{user: user, conn: conn} = oauth_access(["read:notifications", "write:notifications"])
other_user1 = insert(:user)
other_user2 = insert(:user)
other_users = insert_list(9, :user)
{:ok, status} = CommonAPI.post(user, %{status: "hello"})
{:ok, _} = CommonAPI.favorite(status.id, other_user1)
{:ok, _} = CommonAPI.favorite(status.id, other_user2)
%{"notification_groups" => [%{"group_key" => group_key}]} =
Enum.each(other_users, fn other_user ->
{:ok, _} = CommonAPI.favorite(status.id, other_user)
end)
%{
"notification_groups" => [
%{
"group_key" => group_key,
"notifications_count" => 9,
"sample_account_ids" => sample_account_ids
}
]
} =
conn
|> get("/api/v2/notifications")
|> json_response_and_validate_schema(200)
assert length(sample_account_ids) == 8
%{conn: read_conn} = oauth_access(["read:notifications"], user: user)
account_ids =
conn
read_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([other_user1.id, other_user2.id])
assert Enum.sort(account_ids) == Enum.sort(Enum.map(other_users, & &1.id))
assert %{} =
conn

View file

@ -98,6 +98,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
{: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),
@ -119,6 +120,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
{: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),
@ -138,6 +140,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
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),