Add grouped notifications API

This commit is contained in:
Lain Soykaf 2026-05-22 20:36:52 +04:00
commit 1e65c7a69e
No known key found for this signature in database
9 changed files with 850 additions and 24 deletions

View file

@ -0,0 +1 @@
Add Mastodon-compatible grouped notifications API endpoints under `/api/v2/notifications`

View file

@ -62,6 +62,90 @@ 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} = 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
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 group_time_bucket(%Notification{inserted_at: 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
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(%Notification{type: "follow", activity: %{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

View file

@ -91,6 +91,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" => ["write: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 +187,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"],
@ -194,6 +279,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 +391,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

@ -8,10 +8,12 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2] import Pleroma.Web.ControllerHelper, only: [add_link_headers: 2]
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] @oauth_read_actions [:show, :index, :grouped_index, :show_group, :unread_count]
plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false) plug(Pleroma.Web.ApiSpec.CastAndValidate, replace_params: false)
@ -39,7 +41,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 +61,110 @@ defmodule Pleroma.Web.MastodonAPI.NotificationController do
do_get_notifications(conn, params) do_get_notifications(conn, params)
end end
defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do # 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.new(params, fn {k, v} -> {to_string(k), v} end) params
|> Map.put_new("types", Map.get(params, :include_types, @default_notification_types)) |> 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 = MastodonAPI.get_notification_group(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],
for: user,
grouped_types: grouped_types
)
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
users =
user
|> MastodonAPI.get_notification_group(group_key, %{})
|> notification_actors()
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
ids =
user
|> MastodonAPI.get_notification_group(group_key, %{})
|> Enum.map(& &1.id)
Notification.destroy_multiple(user, ids)
json(conn, %{})
end
defp do_get_notifications(%{assigns: %{user: user}} = conn, params) do
params = normalize_notification_params(params)
notifications = MastodonAPI.get_notifications(user, params) notifications = MastodonAPI.get_notifications(user, params)
@ -74,6 +176,40 @@ 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} =
MastodonAPI.get_grouped_notification_page(user, params)
conn
|> add_link_headers(page_notifications)
|> render("grouped_index.json",
notification_groups: notification_groups,
notification_group_counts: notification_group_counts,
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
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 # 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

@ -8,6 +8,7 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
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
@ -61,10 +62,145 @@ 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
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)
notifications =
query
|> order_by([n], [{^order, n.id}])
|> Repo.all()
notification_group_counts =
Enum.frequencies_by(notifications, &Notification.group_key(&1, grouped_types))
page_notifications =
notifications
|> take_grouped_page_notifications(grouped_types, grouped_limit(params))
page_notifications =
if order == :asc, do: Enum.reverse(page_notifications), else: page_notifications
{
Notification.group_notifications(page_notifications, grouped_types),
page_notifications,
notification_group_counts
}
end
def get_grouped_notification_groups(user, params \\ %{}) do
{groups, _notifications, _notification_group_counts} =
get_grouped_notification_page(user, params)
groups
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
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))
end
defp group_pagination_query(query, params) do
cond do
min_id = Map.get(params, "min_id", Map.get(params, :min_id)) ->
query = where(query, [n], n.id > ^min_id)
query =
case Map.get(params, "max_id", Map.get(params, :max_id)) do
nil -> query
max_id -> where(query, [n], n.id < ^max_id)
end
{query, :asc}
since_id = Map.get(params, "since_id", Map.get(params, :since_id)) ->
{where(query, [n], n.id > ^since_id), :desc}
max_id = Map.get(params, "max_id", Map.get(params, :max_id)) ->
{where(query, [n], n.id < ^max_id), :desc}
true ->
{query, :desc}
end
end
defp grouped_limit(params) do
params
|> Map.get("limit", Map.get(params, :limit, 40))
|> parse_limit(40)
|> 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))
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)
|> length()
end
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)
end
defp notification_options(user, params) do
options = options =
params
|> cast_params()
|> Map.update(:include_types, [], fn include_types -> include_types end)
if ("pleroma:report" not in options.include_types and if ("pleroma:report" not in options.include_types and
User.privileged?(user, :reports_manage_reports)) or User.privileged?(user, :reports_manage_reports)) or
User.privileged?(user, :reports_manage_reports) do User.privileged?(user, :reports_manage_reports) do
@ -75,15 +211,26 @@ defmodule Pleroma.Web.MastodonAPI.MastodonAPI do
current_exclude_types ++ ["pleroma:report"] current_exclude_types ++ ["pleroma:report"]
end) end)
end end
user
|> Notification.for_user_query(options)
|> restrict(:types, options)
|> restrict(:exclude_types, options)
|> restrict(:account_ap_id, options)
|> Pagination.fetch_paginated(params)
end end
defp unread_count_limit(params) do
params
|> Map.get("limit", Map.get(params, :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 +242,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,49 @@ 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, %{})
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)
)
}
end
def render( def render(
"show.json", "show.json",
%{ %{
@ -95,7 +138,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 +180,81 @@ 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
) 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)
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),
sample_account_ids:
notifications
|> notification_actors()
|> Enum.map(&to_string(&1.id))
}
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

@ -577,6 +577,175 @@ 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)
notification_ids =
Notification
|> Repo.all()
|> Enum.map(&to_string(&1.id))
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" => ^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
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 "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 "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 "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)
{: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}]} =
conn
|> get("/api/v2/notifications")
|> json_response_and_validate_schema(200)
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([other_user1.id, other_user2.id])
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)

View file

@ -101,7 +101,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: 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}),
@ -122,7 +122,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: 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}),
@ -141,7 +141,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: 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 +333,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}),