[#1364] Improved notification-related tests.
[akkoma] / lib / pleroma / notification.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Notification do
6 use Ecto.Schema
7
8 alias Pleroma.Activity
9 alias Pleroma.Notification
10 alias Pleroma.Object
11 alias Pleroma.Pagination
12 alias Pleroma.Repo
13 alias Pleroma.ThreadMute
14 alias Pleroma.User
15 alias Pleroma.Web.CommonAPI.Utils
16 alias Pleroma.Web.Push
17 alias Pleroma.Web.Streamer
18
19 import Ecto.Query
20 import Ecto.Changeset
21
22 require Logger
23
24 @type t :: %__MODULE__{}
25
26 @include_muted_option :with_muted
27
28 schema "notifications" do
29 field(:seen, :boolean, default: false)
30 belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
31 belongs_to(:activity, Activity, type: FlakeId.Ecto.CompatType)
32
33 timestamps()
34 end
35
36 def changeset(%Notification{} = notification, attrs) do
37 notification
38 |> cast(attrs, [:seen])
39 end
40
41 defp for_user_query_ap_id_opts(user, opts) do
42 ap_id_relations =
43 [:block] ++
44 if opts[@include_muted_option], do: [], else: [:notification_mute]
45
46 preloaded_ap_ids = User.outgoing_relations_ap_ids(user, ap_id_relations)
47
48 exclude_blocked_opts = Map.merge(%{blocked_users_ap_ids: preloaded_ap_ids[:block]}, opts)
49
50 exclude_notification_muted_opts =
51 Map.merge(%{notification_muted_users_ap_ids: preloaded_ap_ids[:notification_mute]}, opts)
52
53 {exclude_blocked_opts, exclude_notification_muted_opts}
54 end
55
56 def for_user_query(user, opts \\ %{}) do
57 {exclude_blocked_opts, exclude_notification_muted_opts} =
58 for_user_query_ap_id_opts(user, opts)
59
60 Notification
61 |> where(user_id: ^user.id)
62 |> where(
63 [n, a],
64 fragment(
65 "? not in (SELECT ap_id FROM users WHERE deactivated = 'true')",
66 a.actor
67 )
68 )
69 |> join(:inner, [n], activity in assoc(n, :activity))
70 |> join(:left, [n, a], object in Object,
71 on:
72 fragment(
73 "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)",
74 object.data,
75 a.data
76 )
77 )
78 |> preload([n, a, o], activity: {a, object: o})
79 |> exclude_notification_muted(user, exclude_notification_muted_opts)
80 |> exclude_blocked(user, exclude_blocked_opts)
81 |> exclude_visibility(opts)
82 |> exclude_move(opts)
83 end
84
85 defp exclude_blocked(query, user, opts) do
86 blocked_ap_ids = opts[:blocked_users_ap_ids] || User.blocked_users_ap_ids(user)
87
88 query
89 |> where([n, a], a.actor not in ^blocked_ap_ids)
90 |> where(
91 [n, a],
92 fragment("substring(? from '.*://([^/]*)')", a.actor) not in ^user.domain_blocks
93 )
94 end
95
96 defp exclude_notification_muted(query, _, %{@include_muted_option => true}) do
97 query
98 end
99
100 defp exclude_notification_muted(query, user, opts) do
101 notification_muted_ap_ids =
102 opts[:notification_muted_users_ap_ids] || User.notification_muted_users_ap_ids(user)
103
104 query
105 |> where([n, a], a.actor not in ^notification_muted_ap_ids)
106 |> join(:left, [n, a], tm in ThreadMute,
107 on: tm.user_id == ^user.id and tm.context == fragment("?->>'context'", a.data)
108 )
109 |> where([n, a, o, tm], is_nil(tm.user_id))
110 end
111
112 defp exclude_move(query, %{with_move: true}) do
113 query
114 end
115
116 defp exclude_move(query, _opts) do
117 where(query, [n, a], fragment("?->>'type' != 'Move'", a.data))
118 end
119
120 @valid_visibilities ~w[direct unlisted public private]
121
122 defp exclude_visibility(query, %{exclude_visibilities: visibility})
123 when is_list(visibility) do
124 if Enum.all?(visibility, &(&1 in @valid_visibilities)) do
125 query
126 |> join(:left, [n, a], mutated_activity in Pleroma.Activity,
127 on:
128 fragment("?->>'context'", a.data) ==
129 fragment("?->>'context'", mutated_activity.data) and
130 fragment("(?->>'type' = 'Like' or ?->>'type' = 'Announce')", a.data, a.data) and
131 fragment("?->>'type'", mutated_activity.data) == "Create",
132 as: :mutated_activity
133 )
134 |> where(
135 [n, a, mutated_activity: mutated_activity],
136 not fragment(
137 """
138 CASE WHEN (?->>'type') = 'Like' or (?->>'type') = 'Announce'
139 THEN (activity_visibility(?, ?, ?) = ANY (?))
140 ELSE (activity_visibility(?, ?, ?) = ANY (?)) END
141 """,
142 a.data,
143 a.data,
144 mutated_activity.actor,
145 mutated_activity.recipients,
146 mutated_activity.data,
147 ^visibility,
148 a.actor,
149 a.recipients,
150 a.data,
151 ^visibility
152 )
153 )
154 else
155 Logger.error("Could not exclude visibility to #{visibility}")
156 query
157 end
158 end
159
160 defp exclude_visibility(query, %{exclude_visibilities: visibility})
161 when visibility in @valid_visibilities do
162 exclude_visibility(query, [visibility])
163 end
164
165 defp exclude_visibility(query, %{exclude_visibilities: visibility})
166 when visibility not in @valid_visibilities do
167 Logger.error("Could not exclude visibility to #{visibility}")
168 query
169 end
170
171 defp exclude_visibility(query, _visibility), do: query
172
173 def for_user(user, opts \\ %{}) do
174 user
175 |> for_user_query(opts)
176 |> Pagination.fetch_paginated(opts)
177 end
178
179 @doc """
180 Returns notifications for user received since given date.
181
182 ## Examples
183
184 iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-13 11:22:33])
185 [%Pleroma.Notification{}, %Pleroma.Notification{}]
186
187 iex> Pleroma.Notification.for_user_since(%Pleroma.User{}, ~N[2019-04-15 11:22:33])
188 []
189 """
190 @spec for_user_since(Pleroma.User.t(), NaiveDateTime.t()) :: [t()]
191 def for_user_since(user, date) do
192 from(n in for_user_query(user),
193 where: n.updated_at > ^date
194 )
195 |> Repo.all()
196 end
197
198 def set_read_up_to(%{id: user_id} = _user, id) do
199 query =
200 from(
201 n in Notification,
202 where: n.user_id == ^user_id,
203 where: n.id <= ^id,
204 where: n.seen == false,
205 update: [
206 set: [
207 seen: true,
208 updated_at: ^NaiveDateTime.utc_now()
209 ]
210 ],
211 # Ideally we would preload object and activities here
212 # but Ecto does not support preloads in update_all
213 select: n.id
214 )
215
216 {_, notification_ids} = Repo.update_all(query, [])
217
218 Notification
219 |> where([n], n.id in ^notification_ids)
220 |> join(:inner, [n], activity in assoc(n, :activity))
221 |> join(:left, [n, a], object in Object,
222 on:
223 fragment(
224 "(?->>'id') = COALESCE((? -> 'object'::text) ->> 'id'::text)",
225 object.data,
226 a.data
227 )
228 )
229 |> preload([n, a, o], activity: {a, object: o})
230 |> Repo.all()
231 end
232
233 def read_one(%User{} = user, notification_id) do
234 with {:ok, %Notification{} = notification} <- get(user, notification_id) do
235 notification
236 |> changeset(%{seen: true})
237 |> Repo.update()
238 end
239 end
240
241 def get(%{id: user_id} = _user, id) do
242 query =
243 from(
244 n in Notification,
245 where: n.id == ^id,
246 join: activity in assoc(n, :activity),
247 preload: [activity: activity]
248 )
249
250 notification = Repo.one(query)
251
252 case notification do
253 %{user_id: ^user_id} ->
254 {:ok, notification}
255
256 _ ->
257 {:error, "Cannot get notification"}
258 end
259 end
260
261 def clear(user) do
262 from(n in Notification, where: n.user_id == ^user.id)
263 |> Repo.delete_all()
264 end
265
266 def destroy_multiple(%{id: user_id} = _user, ids) do
267 from(n in Notification,
268 where: n.id in ^ids,
269 where: n.user_id == ^user_id
270 )
271 |> Repo.delete_all()
272 end
273
274 def dismiss(%{id: user_id} = _user, id) do
275 notification = Repo.get(Notification, id)
276
277 case notification do
278 %{user_id: ^user_id} ->
279 Repo.delete(notification)
280
281 _ ->
282 {:error, "Cannot dismiss notification"}
283 end
284 end
285
286 def create_notifications(%Activity{data: %{"to" => _, "type" => "Create"}} = activity) do
287 object = Object.normalize(activity)
288
289 if object && object.data["type"] == "Answer" do
290 {:ok, []}
291 else
292 do_create_notifications(activity)
293 end
294 end
295
296 def create_notifications(%Activity{data: %{"type" => type}} = activity)
297 when type in ["Like", "Announce", "Follow", "Move", "EmojiReact"] do
298 do_create_notifications(activity)
299 end
300
301 def create_notifications(_), do: {:ok, []}
302
303 defp do_create_notifications(%Activity{} = activity) do
304 {enabled_receivers, disabled_receivers} = get_notified_from_activity(activity)
305 potential_receivers = enabled_receivers ++ disabled_receivers
306
307 notifications =
308 Enum.map(potential_receivers, fn user ->
309 do_send = user in enabled_receivers
310 create_notification(activity, user, do_send)
311 end)
312
313 {:ok, notifications}
314 end
315
316 # TODO move to sql, too.
317 def create_notification(%Activity{} = activity, %User{} = user, do_send \\ true) do
318 unless skip?(activity, user) do
319 notification = %Notification{user_id: user.id, activity: activity}
320 {:ok, notification} = Repo.insert(notification)
321
322 if do_send do
323 Streamer.stream(["user", "user:notification"], notification)
324 Push.send(notification)
325 end
326
327 notification
328 end
329 end
330
331 @doc """
332 Returns a tuple with 2 elements:
333 {enabled notification receivers, currently disabled receivers (blocking / [thread] muting)}
334 """
335 def get_notified_from_activity(activity, local_only \\ true)
336
337 def get_notified_from_activity(%Activity{data: %{"type" => type}} = activity, local_only)
338 when type in ["Create", "Like", "Announce", "Follow", "Move", "EmojiReact"] do
339 potential_receiver_ap_ids =
340 []
341 |> Utils.maybe_notify_to_recipients(activity)
342 |> Utils.maybe_notify_mentioned_recipients(activity)
343 |> Utils.maybe_notify_subscribers(activity)
344 |> Utils.maybe_notify_followers(activity)
345 |> Enum.uniq()
346
347 # Since even subscribers and followers can mute / thread-mute, filtering all above AP IDs
348 notification_enabled_ap_ids =
349 potential_receiver_ap_ids
350 |> exclude_relation_restricting_ap_ids(activity)
351 |> exclude_thread_muter_ap_ids(activity)
352
353 potential_receivers =
354 potential_receiver_ap_ids
355 |> Enum.uniq()
356 |> User.get_users_from_set(local_only)
357
358 notification_enabled_users =
359 Enum.filter(potential_receivers, fn u -> u.ap_id in notification_enabled_ap_ids end)
360
361 {notification_enabled_users, potential_receivers -- notification_enabled_users}
362 end
363
364 def get_notified_from_activity(_, _local_only), do: {[], []}
365
366 @doc "Filters out AP IDs of users basing on their relationships with activity actor user"
367 def exclude_relation_restricting_ap_ids([], _activity), do: []
368
369 def exclude_relation_restricting_ap_ids(ap_ids, %Activity{} = activity) do
370 relation_restricted_ap_ids =
371 activity
372 |> Activity.user_actor()
373 |> User.incoming_relations_ungrouped_ap_ids([
374 :block,
375 :notification_mute
376 ])
377
378 Enum.uniq(ap_ids) -- relation_restricted_ap_ids
379 end
380
381 @doc "Filters out AP IDs of users who mute activity thread"
382 def exclude_thread_muter_ap_ids([], _activity), do: []
383
384 def exclude_thread_muter_ap_ids(ap_ids, %Activity{} = activity) do
385 thread_muter_ap_ids = ThreadMute.muter_ap_ids(activity.data["context"])
386
387 Enum.uniq(ap_ids) -- thread_muter_ap_ids
388 end
389
390 @spec skip?(Activity.t(), User.t()) :: boolean()
391 def skip?(%Activity{} = activity, %User{} = user) do
392 [
393 :self,
394 :followers,
395 :follows,
396 :non_followers,
397 :non_follows,
398 :recently_followed
399 ]
400 |> Enum.find(&skip?(&1, activity, user))
401 end
402
403 def skip?(_, _), do: false
404
405 @spec skip?(atom(), Activity.t(), User.t()) :: boolean()
406 def skip?(:self, %Activity{} = activity, %User{} = user) do
407 activity.data["actor"] == user.ap_id
408 end
409
410 def skip?(
411 :followers,
412 %Activity{} = activity,
413 %User{notification_settings: %{followers: false}} = user
414 ) do
415 actor = activity.data["actor"]
416 follower = User.get_cached_by_ap_id(actor)
417 User.following?(follower, user)
418 end
419
420 def skip?(
421 :non_followers,
422 %Activity{} = activity,
423 %User{notification_settings: %{non_followers: false}} = user
424 ) do
425 actor = activity.data["actor"]
426 follower = User.get_cached_by_ap_id(actor)
427 !User.following?(follower, user)
428 end
429
430 def skip?(
431 :follows,
432 %Activity{} = activity,
433 %User{notification_settings: %{follows: false}} = user
434 ) do
435 actor = activity.data["actor"]
436 followed = User.get_cached_by_ap_id(actor)
437 User.following?(user, followed)
438 end
439
440 def skip?(
441 :non_follows,
442 %Activity{} = activity,
443 %User{notification_settings: %{non_follows: false}} = user
444 ) do
445 actor = activity.data["actor"]
446 followed = User.get_cached_by_ap_id(actor)
447 !User.following?(user, followed)
448 end
449
450 # To do: consider defining recency in hours and checking FollowingRelationship with a single SQL
451 def skip?(:recently_followed, %Activity{data: %{"type" => "Follow"}} = activity, %User{} = user) do
452 actor = activity.data["actor"]
453
454 Notification.for_user(user)
455 |> Enum.any?(fn
456 %{activity: %{data: %{"type" => "Follow", "actor" => ^actor}}} -> true
457 _ -> false
458 end)
459 end
460
461 def skip?(_, _, _), do: false
462 end