Merge branch 'issue/1276-2' into 'develop'

[#1276] added an endpoint for getting unread notification count

See merge request pleroma/pleroma!2392
This commit is contained in:
lain 2020-05-08 09:23:01 +00:00
commit 0cf43391f2
13 changed files with 237 additions and 26 deletions

View file

@ -156,6 +156,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Mastodon API: `pleroma.thread_muted` to the Status entity - Mastodon API: `pleroma.thread_muted` to the Status entity
- Mastodon API: Mark the direct conversation as read for the author when they send a new direct message - Mastodon API: Mark the direct conversation as read for the author when they send a new direct message
- Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload. - Mastodon API, streaming: Add `pleroma.direct_conversation_id` to the `conversation` stream event payload.
- Mastodon API: Add `pleroma.unread_count` to the Marker entity
- Admin API: Render whole status in grouped reports - Admin API: Render whole status in grouped reports
- Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise). - Mastodon API: User timelines will now respect blocks, unless you are getting the user timeline of somebody you blocked (which would be empty otherwise).
- Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try. - Mastodon API: Favoriting / Repeating a post multiple times will now return the identical response every time. Before, executing that action twice would return an error ("already favorited") on the second try.

View file

@ -61,6 +61,7 @@ Has these additional fields under the `pleroma` object:
- `deactivated`: boolean, true when the user is deactivated - `deactivated`: boolean, true when the user is deactivated
- `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts - `allow_following_move`: boolean, true when the user allows automatically follow moved following accounts
- `unread_conversation_count`: The count of unread conversations. Only returned to the account owner. - `unread_conversation_count`: The count of unread conversations. Only returned to the account owner.
- `unread_notifications_count`: The count of unread notifications. Only returned to the account owner.
### Source ### Source
@ -218,3 +219,9 @@ Has theses additional parameters (which are the same as in Pleroma-API):
- `pleroma.metadata.features`: A list of supported features - `pleroma.metadata.features`: A list of supported features
- `pleroma.metadata.federation`: The federation restrictions of this instance - `pleroma.metadata.federation`: The federation restrictions of this instance
- `vapid_public_key`: The public key needed for push messages - `vapid_public_key`: The public key needed for push messages
## Markers
Has these additional fields under the `pleroma` object:
- `unread_count`: contains number unread notifications

View file

@ -9,24 +9,34 @@ defmodule Pleroma.Marker do
import Ecto.Query import Ecto.Query
alias Ecto.Multi alias Ecto.Multi
alias Pleroma.Notification
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias __MODULE__
@timelines ["notifications"] @timelines ["notifications"]
@type t :: %__MODULE__{}
schema "markers" do schema "markers" do
field(:last_read_id, :string, default: "") field(:last_read_id, :string, default: "")
field(:timeline, :string, default: "") field(:timeline, :string, default: "")
field(:lock_version, :integer, default: 0) field(:lock_version, :integer, default: 0)
field(:unread_count, :integer, default: 0, virtual: true)
belongs_to(:user, User, type: FlakeId.Ecto.CompatType) belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
timestamps() timestamps()
end end
@doc "Gets markers by user and timeline."
@spec get_markers(User.t(), list(String)) :: list(t())
def get_markers(user, timelines \\ []) do def get_markers(user, timelines \\ []) do
Repo.all(get_query(user, timelines)) user
|> get_query(timelines)
|> unread_count_query()
|> Repo.all()
end end
@spec upsert(User.t(), map()) :: {:ok | :error, any()}
def upsert(%User{} = user, attrs) do def upsert(%User{} = user, attrs) do
attrs attrs
|> Map.take(@timelines) |> Map.take(@timelines)
@ -45,6 +55,27 @@ def upsert(%User{} = user, attrs) do
|> Repo.transaction() |> Repo.transaction()
end end
@spec multi_set_last_read_id(Multi.t(), User.t(), String.t()) :: Multi.t()
def multi_set_last_read_id(multi, %User{} = user, "notifications") do
multi
|> Multi.run(:counters, fn _repo, _changes ->
{:ok, %{last_read_id: Repo.one(Notification.last_read_query(user))}}
end)
|> Multi.insert(
:marker,
fn %{counters: attrs} ->
%Marker{timeline: "notifications", user_id: user.id}
|> struct(attrs)
|> Ecto.Changeset.change()
end,
returning: true,
on_conflict: {:replace, [:last_read_id]},
conflict_target: [:user_id, :timeline]
)
end
def multi_set_last_read_id(multi, _, _), do: multi
defp get_marker(user, timeline) do defp get_marker(user, timeline) do
case Repo.find_resource(get_query(user, timeline)) do case Repo.find_resource(get_query(user, timeline)) do
{:ok, marker} -> %__MODULE__{marker | user: user} {:ok, marker} -> %__MODULE__{marker | user: user}
@ -71,4 +102,16 @@ defp get_query(user, timelines) do
|> by_user_id(user.id) |> by_user_id(user.id)
|> by_timeline(timelines) |> by_timeline(timelines)
end end
defp unread_count_query(query) do
from(
q in query,
left_join: n in "notifications",
on: n.user_id == q.user_id and n.seen == false,
group_by: [:id],
select_merge: %{
unread_count: fragment("count(?)", n.id)
}
)
end
end end

View file

@ -5,8 +5,10 @@
defmodule Pleroma.Notification do defmodule Pleroma.Notification do
use Ecto.Schema use Ecto.Schema
alias Ecto.Multi
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.FollowingRelationship alias Pleroma.FollowingRelationship
alias Pleroma.Marker
alias Pleroma.Notification alias Pleroma.Notification
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Pagination alias Pleroma.Pagination
@ -34,11 +36,30 @@ defmodule Pleroma.Notification do
timestamps() timestamps()
end end
@spec unread_notifications_count(User.t()) :: integer()
def unread_notifications_count(%User{id: user_id}) do
from(q in __MODULE__,
where: q.user_id == ^user_id and q.seen == false
)
|> Repo.aggregate(:count, :id)
end
def changeset(%Notification{} = notification, attrs) do def changeset(%Notification{} = notification, attrs) do
notification notification
|> cast(attrs, [:seen]) |> cast(attrs, [:seen])
end end
@spec last_read_query(User.t()) :: Ecto.Queryable.t()
def last_read_query(user) do
from(q in Pleroma.Notification,
where: q.user_id == ^user.id,
where: q.seen == true,
select: type(q.id, :string),
limit: 1,
order_by: [desc: :id]
)
end
defp for_user_query_ap_id_opts(user, opts) do defp for_user_query_ap_id_opts(user, opts) do
ap_id_relationships = ap_id_relationships =
[:block] ++ [:block] ++
@ -185,25 +206,23 @@ def for_user_since(user, date) do
|> Repo.all() |> Repo.all()
end end
def set_read_up_to(%{id: user_id} = _user, id) do def set_read_up_to(%{id: user_id} = user, id) do
query = query =
from( from(
n in Notification, n in Notification,
where: n.user_id == ^user_id, where: n.user_id == ^user_id,
where: n.id <= ^id, where: n.id <= ^id,
where: n.seen == false, where: n.seen == false,
update: [
set: [
seen: true,
updated_at: ^NaiveDateTime.utc_now()
]
],
# Ideally we would preload object and activities here # Ideally we would preload object and activities here
# but Ecto does not support preloads in update_all # but Ecto does not support preloads in update_all
select: n.id select: n.id
) )
{_, notification_ids} = Repo.update_all(query, []) {:ok, %{ids: {_, notification_ids}}} =
Multi.new()
|> Multi.update_all(:ids, query, set: [seen: true, updated_at: NaiveDateTime.utc_now()])
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
Notification Notification
|> where([n], n.id in ^notification_ids) |> where([n], n.id in ^notification_ids)
@ -220,11 +239,18 @@ def set_read_up_to(%{id: user_id} = _user, id) do
|> Repo.all() |> Repo.all()
end end
@spec read_one(User.t(), String.t()) ::
{:ok, Notification.t()} | {:error, Ecto.Changeset.t()} | nil
def read_one(%User{} = user, notification_id) do def read_one(%User{} = user, notification_id) do
with {:ok, %Notification{} = notification} <- get(user, notification_id) do with {:ok, %Notification{} = notification} <- get(user, notification_id) do
notification Multi.new()
|> changeset(%{seen: true}) |> Multi.update(:update, changeset(notification, %{seen: true}))
|> Repo.update() |> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
|> case do
{:ok, %{update: notification}} -> {:ok, notification}
{:error, :update, changeset, _} -> {:error, changeset}
end
end end
end end
@ -316,8 +342,11 @@ defp do_create_notifications(%Activity{} = activity) do
# TODO move to sql, too. # TODO move to sql, too.
def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do
unless skip?(activity, user) do unless skip?(activity, user) do
notification = %Notification{user_id: user.id, activity: activity} {:ok, %{notification: notification}} =
{:ok, notification} = Repo.insert(notification) Multi.new()
|> Multi.insert(:notification, %Notification{user_id: user.id, activity: activity})
|> Marker.multi_set_last_read_id(user, "notifications")
|> Repo.transaction()
if do_send do if do_send do
Streamer.stream(["user", "user:notification"], notification) Streamer.stream(["user", "user:notification"], notification)

View file

@ -36,9 +36,11 @@ def render("index.json", %{users: users} = opts) do
end end
def render("show.json", %{user: user} = opts) do def render("show.json", %{user: user} = opts) do
if User.visible_for?(user, opts[:for]), if User.visible_for?(user, opts[:for]) do
do: do_render("show.json", opts), do_render("show.json", opts)
else: %{} else
%{}
end
end end
def render("mention.json", %{user: user}) do def render("mention.json", %{user: user}) do
@ -221,7 +223,7 @@ defp do_render("show.json", %{user: user} = opts) do
fields: user.fields, fields: user.fields,
bot: bot, bot: bot,
source: %{ source: %{
note: (user.bio || "") |> String.replace(~r(<br */?>), "\n") |> Pleroma.HTML.strip_tags(), note: prepare_user_bio(user),
sensitive: false, sensitive: false,
fields: user.raw_fields, fields: user.raw_fields,
pleroma: %{ pleroma: %{
@ -253,8 +255,17 @@ defp do_render("show.json", %{user: user} = opts) do
|> maybe_put_follow_requests_count(user, opts[:for]) |> maybe_put_follow_requests_count(user, opts[:for])
|> maybe_put_allow_following_move(user, opts[:for]) |> maybe_put_allow_following_move(user, opts[:for])
|> maybe_put_unread_conversation_count(user, opts[:for]) |> maybe_put_unread_conversation_count(user, opts[:for])
|> maybe_put_unread_notification_count(user, opts[:for])
end end
defp prepare_user_bio(%User{bio: ""}), do: ""
defp prepare_user_bio(%User{bio: bio}) when is_binary(bio) do
bio |> String.replace(~r(<br */?>), "\n") |> Pleroma.HTML.strip_tags()
end
defp prepare_user_bio(_), do: ""
defp username_from_nickname(string) when is_binary(string) do defp username_from_nickname(string) when is_binary(string) do
hd(String.split(string, "@")) hd(String.split(string, "@"))
end end
@ -350,6 +361,16 @@ defp maybe_put_unread_conversation_count(data, %User{id: user_id} = user, %User{
defp maybe_put_unread_conversation_count(data, _, _), do: data defp maybe_put_unread_conversation_count(data, _, _), do: data
defp maybe_put_unread_notification_count(data, %User{id: user_id}, %User{id: user_id} = user) do
Kernel.put_in(
data,
[:pleroma, :unread_notifications_count],
Pleroma.Notification.unread_notifications_count(user)
)
end
defp maybe_put_unread_notification_count(data, _, _), do: data
defp image_url(%{"url" => [%{"href" => href} | _]}), do: href defp image_url(%{"url" => [%{"href" => href} | _]}), do: href
defp image_url(_), do: nil defp image_url(_), do: nil
end end

View file

@ -11,7 +11,10 @@ def render("markers.json", %{markers: markers}) do
%{ %{
last_read_id: m.last_read_id, last_read_id: m.last_read_id,
version: m.lock_version, version: m.lock_version,
updated_at: NaiveDateTime.to_iso8601(m.updated_at) updated_at: NaiveDateTime.to_iso8601(m.updated_at),
pleroma: %{
unread_count: m.unread_count
}
}} }}
end) end)
end end

View file

@ -0,0 +1,40 @@
defmodule Pleroma.Repo.Migrations.UpdateMarkers do
use Ecto.Migration
import Ecto.Query
alias Pleroma.Repo
def up do
update_markers()
end
def down do
:ok
end
defp update_markers do
now = NaiveDateTime.utc_now()
markers_attrs =
from(q in "notifications",
select: %{
timeline: "notifications",
user_id: q.user_id,
last_read_id:
type(fragment("MAX( CASE WHEN seen = true THEN id ELSE null END )"), :string)
},
group_by: [q.user_id]
)
|> Repo.all()
|> Enum.map(fn %{last_read_id: last_read_id} = attrs ->
attrs
|> Map.put(:last_read_id, last_read_id || "")
|> Map.put_new(:inserted_at, now)
|> Map.put_new(:updated_at, now)
end)
Repo.insert_all("markers", markers_attrs,
on_conflict: {:replace, [:last_read_id]},
conflict_target: [:user_id, :timeline]
)
end
end

View file

@ -8,12 +8,39 @@ defmodule Pleroma.MarkerTest do
import Pleroma.Factory import Pleroma.Factory
describe "multi_set_unread_count/3" do
test "returns multi" do
user = insert(:user)
assert %Ecto.Multi{
operations: [marker: {:run, _}, counters: {:run, _}]
} =
Marker.multi_set_last_read_id(
Ecto.Multi.new(),
user,
"notifications"
)
end
test "return empty multi" do
user = insert(:user)
multi = Ecto.Multi.new()
assert Marker.multi_set_last_read_id(multi, user, "home") == multi
end
end
describe "get_markers/2" do describe "get_markers/2" do
test "returns user markers" do test "returns user markers" do
user = insert(:user) user = insert(:user)
marker = insert(:marker, user: user) marker = insert(:marker, user: user)
insert(:notification, user: user)
insert(:notification, user: user)
insert(:marker, timeline: "home", user: user) insert(:marker, timeline: "home", user: user)
assert Marker.get_markers(user, ["notifications"]) == [refresh_record(marker)]
assert Marker.get_markers(
user,
["notifications"]
) == [%Marker{refresh_record(marker) | unread_count: 2}]
end end
end end

View file

@ -47,6 +47,9 @@ test "notifies someone when they are directly addressed" do
assert notified_ids == [other_user.id, third_user.id] assert notified_ids == [other_user.id, third_user.id]
assert notification.activity_id == activity.id assert notification.activity_id == activity.id
assert other_notification.activity_id == activity.id assert other_notification.activity_id == activity.id
assert [%Pleroma.Marker{unread_count: 2}] =
Pleroma.Marker.get_markers(other_user, ["notifications"])
end end
test "it creates a notification for subscribed users" do test "it creates a notification for subscribed users" do
@ -466,6 +469,16 @@ test "it sets all notifications as read up to a specified notification ID" do
assert n1.seen == true assert n1.seen == true
assert n2.seen == true assert n2.seen == true
assert n3.seen == false assert n3.seen == false
assert %Pleroma.Marker{} =
m =
Pleroma.Repo.get_by(
Pleroma.Marker,
user_id: other_user.id,
timeline: "notifications"
)
assert m.last_read_id == to_string(n2.id)
end end
end end

View file

@ -1196,12 +1196,15 @@ test "returns lists to which the account belongs" do
describe "verify_credentials" do describe "verify_credentials" do
test "verify_credentials" do test "verify_credentials" do
%{user: user, conn: conn} = oauth_access(["read:accounts"]) %{user: user, conn: conn} = oauth_access(["read:accounts"])
[notification | _] = insert_list(7, :notification, user: user)
Pleroma.Notification.set_read_up_to(user, notification.id)
conn = get(conn, "/api/v1/accounts/verify_credentials") conn = get(conn, "/api/v1/accounts/verify_credentials")
response = json_response_and_validate_schema(conn, 200) response = json_response_and_validate_schema(conn, 200)
assert %{"id" => id, "source" => %{"privacy" => "public"}} = response assert %{"id" => id, "source" => %{"privacy" => "public"}} = response
assert response["pleroma"]["chat_token"] assert response["pleroma"]["chat_token"]
assert response["pleroma"]["unread_notifications_count"] == 6
assert id == to_string(user.id) assert id == to_string(user.id)
end end

View file

@ -11,6 +11,7 @@ defmodule Pleroma.Web.MastodonAPI.MarkerControllerTest do
test "gets markers with correct scopes", %{conn: conn} do test "gets markers with correct scopes", %{conn: conn} do
user = insert(:user) user = insert(:user)
token = insert(:oauth_token, user: user, scopes: ["read:statuses"]) token = insert(:oauth_token, user: user, scopes: ["read:statuses"])
insert_list(7, :notification, user: user)
{:ok, %{"notifications" => marker}} = {:ok, %{"notifications" => marker}} =
Pleroma.Marker.upsert( Pleroma.Marker.upsert(
@ -29,7 +30,8 @@ test "gets markers with correct scopes", %{conn: conn} do
"notifications" => %{ "notifications" => %{
"last_read_id" => "69420", "last_read_id" => "69420",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
"version" => 0 "version" => 0,
"pleroma" => %{"unread_count" => 7}
} }
} }
end end
@ -71,7 +73,8 @@ test "creates a marker with correct scopes", %{conn: conn} do
"notifications" => %{ "notifications" => %{
"last_read_id" => "69420", "last_read_id" => "69420",
"updated_at" => _, "updated_at" => _,
"version" => 0 "version" => 0,
"pleroma" => %{"unread_count" => 0}
} }
} = response } = response
end end
@ -101,7 +104,8 @@ test "updates exist marker", %{conn: conn} do
"notifications" => %{ "notifications" => %{
"last_read_id" => "69888", "last_read_id" => "69888",
"updated_at" => NaiveDateTime.to_iso8601(marker.updated_at), "updated_at" => NaiveDateTime.to_iso8601(marker.updated_at),
"version" => 0 "version" => 0,
"pleroma" => %{"unread_count" => 0}
} }
} }
end end

View file

@ -466,6 +466,24 @@ test "shows unread_conversation_count only to the account owner" do
:unread_conversation_count :unread_conversation_count
] == 1 ] == 1
end end
test "shows unread_count only to the account owner" do
user = insert(:user)
insert_list(7, :notification, user: user)
other_user = insert(:user)
user = User.get_cached_by_ap_id(user.ap_id)
assert AccountView.render(
"show.json",
%{user: user, for: other_user}
)[:pleroma][:unread_notifications_count] == nil
assert AccountView.render(
"show.json",
%{user: user, for: user}
)[:pleroma][:unread_notifications_count] == 7
end
end end
describe "follow requests counter" do describe "follow requests counter" do

View file

@ -8,19 +8,21 @@ defmodule Pleroma.Web.MastodonAPI.MarkerViewTest do
import Pleroma.Factory import Pleroma.Factory
test "returns markers" do test "returns markers" do
marker1 = insert(:marker, timeline: "notifications", last_read_id: "17") marker1 = insert(:marker, timeline: "notifications", last_read_id: "17", unread_count: 5)
marker2 = insert(:marker, timeline: "home", last_read_id: "42") marker2 = insert(:marker, timeline: "home", last_read_id: "42")
assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{ assert MarkerView.render("markers.json", %{markers: [marker1, marker2]}) == %{
"home" => %{ "home" => %{
last_read_id: "42", last_read_id: "42",
updated_at: NaiveDateTime.to_iso8601(marker2.updated_at), updated_at: NaiveDateTime.to_iso8601(marker2.updated_at),
version: 0 version: 0,
pleroma: %{unread_count: 0}
}, },
"notifications" => %{ "notifications" => %{
last_read_id: "17", last_read_id: "17",
updated_at: NaiveDateTime.to_iso8601(marker1.updated_at), updated_at: NaiveDateTime.to_iso8601(marker1.updated_at),
version: 0 version: 0,
pleroma: %{unread_count: 5}
} }
} }
end end