Merge pull request 'Add grouped notifications API' (#7910) from issue-7905-grouped-notifications into develop

Reviewed-on: https://git.pleroma.social/pleroma/pleroma/pulls/7910
Reviewed-by: Phantasm <phnt@noreply.git.pleroma.social>
This commit is contained in:
lain 2026-06-05 09:23:26 +00:00
commit 98cd1bbb9a
11 changed files with 1255 additions and 32 deletions

View file

@ -0,0 +1 @@
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 # remember to add a migration to add it to the `notifications_type` enum
# as well. # as well.
field(:type, :string) field(:type, :string)
field(:group_key, :string)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType) belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
@ -49,7 +50,7 @@ defmodule Pleroma.Notification do
|> type_from_activity() |> type_from_activity()
notification notification
|> changeset(%{type: type}) |> changeset(%{type: type, group_key: grouped_notification_key(type, activity)})
|> Repo.update() |> Repo.update()
end end
end end
@ -62,6 +63,99 @@ defmodule Pleroma.Notification do
|> Repo.aggregate(:count, :id) |> Repo.aggregate(:count, :id)
end 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{ @notification_types ~w{
favourite favourite
follow follow
@ -79,7 +173,7 @@ defmodule Pleroma.Notification do
def changeset(%Notification{} = notification, attrs) do def changeset(%Notification{} = notification, attrs) do
notification notification
|> cast(attrs, [:seen, :type]) |> cast(attrs, [:seen, :type, :group_key])
|> validate_inclusion(:type, @notification_types) |> validate_inclusion(:type, @notification_types)
end end
@ -458,7 +552,8 @@ defmodule Pleroma.Notification do
user_id: user.id, user_id: user.id,
activity: activity, activity: activity,
seen: mark_as_read?(activity, user), 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") |> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction() |> Repo.transaction()

View file

@ -3,7 +3,6 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.ApiSpec.NotificationOperation do defmodule Pleroma.Web.ApiSpec.NotificationOperation do
alias OpenApiSpex.Operation
alias OpenApiSpex.Operation alias OpenApiSpex.Operation
alias OpenApiSpex.Schema alias OpenApiSpex.Schema
alias Pleroma.Web.ApiSpec.Schemas.Account alias Pleroma.Web.ApiSpec.Schemas.Account
@ -91,6 +90,79 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
} }
end 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 def clear_operation do
%Operation{ %Operation{
tags: ["Notifications"], tags: ["Notifications"],
@ -114,6 +186,18 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
} }
end 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 def dismiss_via_body_operation do
%Operation{ %Operation{
tags: ["Notifications"], tags: ["Notifications"],
@ -184,7 +268,7 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
}, },
example: %{ example: %{
"id" => "34975861", "id" => "34975861",
"group-key" => "ungrouped-34975861", "group_key" => "ungrouped-34975861",
"type" => "mention", "type" => "mention",
"created_at" => "2019-11-23T07:49:02.064Z", "created_at" => "2019-11-23T07:49:02.064Z",
"account" => Account.schema().example, "account" => Account.schema().example,
@ -194,6 +278,73 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
} }
end 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 defp notification_type do
%Schema{ %Schema{
type: :string, type: :string,
@ -239,4 +390,11 @@ defmodule Pleroma.Web.ApiSpec.NotificationOperation do
required: true required: true
) )
end end
defp group_key_param do
Operation.parameter(:group_key, :path, :string, "Notification group key",
example: "favourite-123",
required: true
)
end
end end

View file

@ -55,16 +55,28 @@ defmodule Pleroma.Web.ControllerHelper do
# TODO: Only fetch the params from open_api_spex when everything is converted # TODO: Only fetch the params from open_api_spex when everything is converted
@id_keys Pagination.page_keys() -- ["limit", "order"] @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 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 = params =
if Map.has_key?(conn.private, :open_api_spex) do if Map.has_key?(conn.private, :open_api_spex) do
get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)]) get_in(conn, [Access.key(:private), Access.key(:open_api_spex), Access.key(:params)])
else else
conn.params conn.params
end 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.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}} = {{next_id, nid}, {prev_id, pid}} =
if order == :desc, if order == :desc,

View file

@ -5,13 +5,17 @@
defmodule Pleroma.Web.MastodonAPI.NotificationController do defmodule Pleroma.Web.MastodonAPI.NotificationController do
use Pleroma.Web, :controller 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.Notification
alias Pleroma.User
alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.MastodonAPI alias Pleroma.Web.MastodonAPI.MastodonAPI
alias Pleroma.Web.Plugs.OAuthScopesPlug 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(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
@ -39,7 +43,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
# GET /api/v1/notifications # GET /api/v1/notifications
def index(%{private: %{open_api_spex: %{params: %{account_id: account_id} = params}}} = conn, _) do 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} -> %{ap_id: account_ap_id} ->
params = params =
params params
@ -59,10 +63,108 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
do_get_notifications(conn, params) do_get_notifications(conn, params)
end 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 defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do
params = params = normalize_notification_params(params)
Map.new(params, fn {k, v} -> {to_string(k), v} end)
|> Map.put_new("types", Map.get(params, :include_types, @default_notification_types))
notifications = MastodonAPI.get_notifications(user, params) notifications = MastodonAPI.get_notifications(user, params)
@ -74,6 +176,35 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
) )
end 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 # GET /api/v1/notifications/:id
def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do def show(%{assigns: %{user: user}, private: %{open_api_spex: %{params: %{id: id}}}} = conn, _) do
with {:ok, notification} <- Notification.get(user, id) do with {:ok, notification} <- Notification.get(user, id) do

View file

@ -6,12 +6,16 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
import Ecto.Query import Ecto.Query
import Ecto.Changeset import Ecto.Changeset
alias Pleroma.Marker
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Pagination alias Pleroma.Pagination
alias Pleroma.Repo
alias Pleroma.ScheduledActivity alias Pleroma.ScheduledActivity
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
@notification_group_sample_limit 8
@spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()} @spec follow(User.t(), User.t(), map) :: {:ok, User.t()} | {:error, String.t()}
def follow(follower, followed, params \\ %{}) do def follow(follower, followed, params \\ %{}) do
result = result =
@ -61,29 +65,356 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
end end
def get_notifications(user, params \\ %{}) do def get_notifications(user, params \\ %{}) do
options = user
cast_params(params) |> Map.update(:include_types, [], fn include_types -> include_types end) |> notifications_query(params)
|> Pagination.fetch_paginated(params)
end
options = def get_grouped_notification_page(user, params \\ %{}) do
if ("pleroma:report" not in options.include_types and grouped_types =
User.privileged?(user, :reports_manage_reports)) or params
User.privileged?(user, :reports_manage_reports) do |> Map.get("grouped_types")
options |> Notification.normalize_grouped_types()
else
options query = notifications_query(user, params)
|> Map.update(:exclude_types, ["pleroma:report"], fn current_exclude_types -> {order, cursor_filters} = group_pagination(params)
current_exclude_types ++ ["pleroma:report"]
end) group_rows =
end 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, &notification_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 user
|> Notification.for_user_query(options) |> Notification.for_user_query(options)
|> restrict(:types, options) |> restrict(:types, options)
|> restrict(:exclude_types, options) |> restrict(:exclude_types, options)
|> restrict(:account_ap_id, options) |> restrict(:account_ap_id, options)
|> Pagination.fetch_paginated(params)
end 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 def get_scheduled_activities(user, params \\ %{}) do
user user
|> ScheduledActivity.for_user_query() |> ScheduledActivity.for_user_query()
@ -95,6 +426,8 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
exclude_types: {:array, :string}, exclude_types: {:array, :string},
types: {:array, :string}, types: {:array, :string},
exclude_visibilities: {:array, :string}, exclude_visibilities: {:array, :string},
grouped_types: {:array, :string},
limit: :integer,
reblogs: :boolean, reblogs: :boolean,
with_muted: :boolean, with_muted: :boolean,
account_ap_id: :string, account_ap_id: :string,

View file

@ -72,6 +72,58 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
safe_render_many(notifications, NotificationView, "show.json", opts) safe_render_many(notifications, NotificationView, "show.json", opts)
end 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( def render(
"show.json", "show.json",
%{ %{
@ -95,7 +147,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
response = %{ response = %{
id: to_string(notification.id), id: to_string(notification.id),
group_key: "ungrouped-" <> to_string(notification.id), group_key: Notification.group_key(notification),
type: notification.type, type: notification.type,
created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at), created_at: CommonAPI.Utils.to_masto_date(notification.inserted_at),
account: account, account: account,
@ -137,6 +189,104 @@ defmodule Pleroma.Web.MastodonAPI.NotificationView do
Map.put(response, :report, report_render) Map.put(response, :report, report_render)
end 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 defp put_emoji(response, activity) do
response response
|> Map.put(:emoji, activity.data["content"]) |> Map.put(:emoji, activity.data["content"])

View file

@ -844,6 +844,16 @@ defmodule Pleroma.Web.Router do
get("/directory", DirectoryController, :index) get("/directory", DirectoryController, :index)
end 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 scope "/api/v2", Pleroma.Web.MastodonAPI do
pipe_through(:api) pipe_through(:api)

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

@ -577,6 +577,298 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result assert [%{"id" => ^notification4_id}, %{"id" => ^notification3_id}] = result
end 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 test "doesn't see notifications after muting user with notifications" do
%{user: user, conn: conn} = oauth_access(["read:notifications"]) %{user: user, conn: conn} = oauth_access(["read:notifications"])
user2 = insert(:user) user2 = insert(:user)
@ -708,6 +1000,29 @@ defmodule Pleroma.Web.MastodonAPI.NotificationControllerTest do
|> to_string() |> to_string()
end 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 defp params_to_query(%{} = params) do
Enum.map_join(params, "&", fn Enum.map_join(params, "&", fn
{k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}") {k, v} when is_list(v) -> Enum.map_join(v, "&", &"#{k}[]=#{&1}")

View file

@ -98,10 +98,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
{:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user) {:ok, favorite_activity} = CommonAPI.favorite(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(favorite_activity) {:ok, [notification]} = Notification.create_notifications(favorite_activity)
create_activity = Activity.get_by_id(create_activity.id) create_activity = Activity.get_by_id(create_activity.id)
assert is_binary(notification.group_key)
expected = %{ expected = %{
id: to_string(notification.id), 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}, pleroma: %{is_seen: false, is_muted: false},
type: "favourite", type: "favourite",
account: AccountView.render("show.json", %{user: another_user, for: user}), account: AccountView.render("show.json", %{user: another_user, for: user}),
@ -119,10 +120,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
{:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, another_user) {:ok, reblog_activity} = CommonAPI.repeat(create_activity.id, another_user)
{:ok, [notification]} = Notification.create_notifications(reblog_activity) {:ok, [notification]} = Notification.create_notifications(reblog_activity)
reblog_activity = Activity.get_by_id(create_activity.id) reblog_activity = Activity.get_by_id(create_activity.id)
assert is_binary(notification.group_key)
expected = %{ expected = %{
id: to_string(notification.id), 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}, pleroma: %{is_seen: false, is_muted: false},
type: "reblog", type: "reblog",
account: AccountView.render("show.json", %{user: another_user, for: user}), account: AccountView.render("show.json", %{user: another_user, for: user}),
@ -138,10 +140,11 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
followed = insert(:user) followed = insert(:user)
{:ok, followed, follower, _activity} = CommonAPI.follow(followed, follower) {:ok, followed, follower, _activity} = CommonAPI.follow(followed, follower)
notification = Notification |> Repo.one() |> Repo.preload(:activity) notification = Notification |> Repo.one() |> Repo.preload(:activity)
assert is_binary(notification.group_key)
expected = %{ expected = %{
id: to_string(notification.id), 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}, pleroma: %{is_seen: false, is_muted: false},
type: "follow", type: "follow",
account: AccountView.render("show.json", %{user: follower, for: followed}), account: AccountView.render("show.json", %{user: follower, for: followed}),
@ -333,7 +336,7 @@ defmodule Pleroma.Web.MastodonAPI.NotificationViewTest do
expected = %{ expected = %{
id: to_string(notification.id), 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}, pleroma: %{is_seen: true, is_muted: true},
type: "favourite", type: "favourite",
account: AccountView.render("show.json", %{user: another_user, for: user}), account: AccountView.render("show.json", %{user: another_user, for: user}),