Merge branch 'feature/expire-mutes' into 'develop'

Expiring mutes for users and activities

Closes #1817

See merge request pleroma/pleroma!2971
This commit is contained in:
lain 2020-11-05 12:44:16 +00:00
commit 294628d981
15 changed files with 191 additions and 35 deletions

View file

@ -43,6 +43,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Admin API: (`GET /api/pleroma/admin/users`) added filters user by `actor_type` - Admin API: (`GET /api/pleroma/admin/users`) added filters user by `actor_type`
- Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending. - Pleroma API: Add `idempotency_key` to the chat message entity that can be used for optimistic message sending.
- Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances. - Pleroma API: (`GET /api/v1/pleroma/federation_status`) Add a way to get a list of unreachable instances.
- Mastodon API: User and conversation mutes can now auto-expire if `expires_in` parameter was given while adding the mute.
</details> </details>

View file

@ -562,7 +562,8 @@
background: 5, background: 5,
remote_fetcher: 2, remote_fetcher: 2,
attachments_cleanup: 5, attachments_cleanup: 5,
new_users_digest: 1 new_users_digest: 1,
mute_expire: 5
], ],
plugins: [Oban.Plugins.Pruner], plugins: [Oban.Plugins.Pruner],
crontab: [ crontab: [

View file

@ -255,6 +255,10 @@ There is an additional `user:pleroma_chat` stream. Incoming chat messages will m
For viewing remote server timelines, there are `public:remote` and `public:remote:media` streams. Each of these accept a parameter like `?instance=lain.com`. For viewing remote server timelines, there are `public:remote` and `public:remote:media` streams. Each of these accept a parameter like `?instance=lain.com`.
## User muting and thread muting
Both user muting and thread muting can be done for only a certain time by adding an `expires_in` parameter to the API calls and giving the expiration time in seconds.
## Not implemented ## Not implemented
Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer features and non-essential features are omitted. These features usually return an HTTP 200 status code, but with an empty response. While they may be added in the future, they are considered low priority. Pleroma is generally compatible with the Mastodon 2.7.2 API, but some newer features and non-essential features are omitted. These features usually return an HTTP 200 status code, but with an empty response. While they may be added in the future, they are considered low priority.

View file

@ -1324,14 +1324,48 @@ def get_recipients_from_activity(%Activity{recipients: to, actor: actor}) do
|> Repo.all() |> Repo.all()
end end
@spec mute(User.t(), User.t(), boolean()) :: @spec mute(User.t(), User.t(), map()) ::
{:ok, list(UserRelationship.t())} | {:error, String.t()} {:ok, list(UserRelationship.t())} | {:error, String.t()}
def mute(%User{} = muter, %User{} = mutee, notifications? \\ true) do def mute(%User{} = muter, %User{} = mutee, params \\ %{}) do
add_to_mutes(muter, mutee, notifications?) notifications? = Map.get(params, :notifications, true)
expires_in = Map.get(params, :expires_in, 0)
with {:ok, user_mute} <- UserRelationship.create_mute(muter, mutee),
{:ok, user_notification_mute} <-
(notifications? && UserRelationship.create_notification_mute(muter, mutee)) ||
{:ok, nil} do
if expires_in > 0 do
Pleroma.Workers.MuteExpireWorker.enqueue(
"unmute_user",
%{"muter_id" => muter.id, "mutee_id" => mutee.id},
schedule_in: expires_in
)
end
{:ok, Enum.filter([user_mute, user_notification_mute], & &1)}
end
end end
def unmute(%User{} = muter, %User{} = mutee) do def unmute(%User{} = muter, %User{} = mutee) do
remove_from_mutes(muter, mutee) with {:ok, user_mute} <- UserRelationship.delete_mute(muter, mutee),
{:ok, user_notification_mute} <-
UserRelationship.delete_notification_mute(muter, mutee) do
{:ok, [user_mute, user_notification_mute]}
end
end
def unmute(muter_id, mutee_id) do
with {:muter, %User{} = muter} <- {:muter, User.get_by_id(muter_id)},
{:mutee, %User{} = mutee} <- {:mutee, User.get_by_id(mutee_id)} do
unmute(muter, mutee)
else
{who, result} = error ->
Logger.warn(
"User.unmute/2 failed. #{who}: #{result}, muter_id: #{muter_id}, mutee_id: #{mutee_id}"
)
{:error, error}
end
end end
def subscribe(%User{} = subscriber, %User{} = target) do def subscribe(%User{} = subscriber, %User{} = target) do
@ -2320,23 +2354,6 @@ defp remove_from_block(%User{} = user, %User{} = blocked) do
UserRelationship.delete_block(user, blocked) UserRelationship.delete_block(user, blocked)
end end
defp add_to_mutes(%User{} = user, %User{} = muted_user, notifications?) do
with {:ok, user_mute} <- UserRelationship.create_mute(user, muted_user),
{:ok, user_notification_mute} <-
(notifications? && UserRelationship.create_notification_mute(user, muted_user)) ||
{:ok, nil} do
{:ok, Enum.filter([user_mute, user_notification_mute], & &1)}
end
end
defp remove_from_mutes(user, %User{} = muted_user) do
with {:ok, user_mute} <- UserRelationship.delete_mute(user, muted_user),
{:ok, user_notification_mute} <-
UserRelationship.delete_notification_mute(user, muted_user) do
{:ok, [user_mute, user_notification_mute]}
end
end
def set_invisible(user, invisible) do def set_invisible(user, invisible) do
params = %{invisible: invisible} params = %{invisible: invisible}

View file

@ -262,6 +262,12 @@ def mute_operation do
:query, :query,
%Schema{allOf: [BooleanLike], default: true}, %Schema{allOf: [BooleanLike], default: true},
"Mute notifications in addition to statuses? Defaults to `true`." "Mute notifications in addition to statuses? Defaults to `true`."
),
Operation.parameter(
:expires_in,
:query,
%Schema{type: :integer, default: 0},
"Expire the mute in `expires_in` seconds. Default 0 for infinity"
) )
], ],
responses: %{ responses: %{
@ -723,10 +729,17 @@ defp mute_request do
nullable: true, nullable: true,
description: "Mute notifications in addition to statuses? Defaults to true.", description: "Mute notifications in addition to statuses? Defaults to true.",
default: true default: true
},
expires_in: %Schema{
type: :integer,
nullable: true,
description: "Expire the mute in `expires_in` seconds. Default 0 for infinity",
default: 0
} }
}, },
example: %{ example: %{
"notifications" => true "notifications" => true,
"expires_in" => 86_400
} }
} }
end end

View file

@ -223,7 +223,27 @@ def mute_conversation_operation do
security: [%{"oAuth" => ["write:mutes"]}], security: [%{"oAuth" => ["write:mutes"]}],
description: "Do not receive notifications for the thread that this status is part of.", description: "Do not receive notifications for the thread that this status is part of.",
operationId: "StatusController.mute_conversation", operationId: "StatusController.mute_conversation",
parameters: [id_param()], requestBody:
request_body("Parameters", %Schema{
type: :object,
properties: %{
expires_in: %Schema{
type: :integer,
nullable: true,
description: "Expire the mute in `expires_in` seconds. Default 0 for infinity",
default: 0
}
}
}),
parameters: [
id_param(),
Operation.parameter(
:expires_in,
:query,
%Schema{type: :integer, default: 0},
"Expire the mute in `expires_in` seconds. Default 0 for infinity"
)
],
responses: %{ responses: %{
200 => status_response(), 200 => status_response(),
400 => Operation.response("Error", "application/json", ApiError) 400 => Operation.response("Error", "application/json", ApiError)

View file

@ -454,20 +454,46 @@ def unpin(id, user) do
end end
end end
def add_mute(user, activity) do def add_mute(user, activity, params \\ %{}) do
expires_in = Map.get(params, :expires_in, 0)
with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]), with {:ok, _} <- ThreadMute.add_mute(user.id, activity.data["context"]),
_ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do _ <- Pleroma.Notification.mark_context_as_read(user, activity.data["context"]) do
if expires_in > 0 do
Pleroma.Workers.MuteExpireWorker.enqueue(
"unmute_conversation",
%{"user_id" => user.id, "activity_id" => activity.id},
schedule_in: expires_in
)
end
{:ok, activity} {:ok, activity}
else else
{:error, _} -> {:error, dgettext("errors", "conversation is already muted")} {:error, _} -> {:error, dgettext("errors", "conversation is already muted")}
end end
end end
def remove_mute(user, activity) do def remove_mute(%User{} = user, %Activity{} = activity) do
ThreadMute.remove_mute(user.id, activity.data["context"]) ThreadMute.remove_mute(user.id, activity.data["context"])
{:ok, activity} {:ok, activity}
end end
def remove_mute(user_id, activity_id) do
with {:user, %User{} = user} <- {:user, User.get_by_id(user_id)},
{:activity, %Activity{} = activity} <- {:activity, Activity.get_by_id(activity_id)} do
remove_mute(user, activity)
else
{what, result} = error ->
Logger.warn(
"CommonAPI.remove_mute/2 failed. #{what}: #{result}, user_id: #{user_id}, activity_id: #{
activity_id
}"
)
{:error, error}
end
end
def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}}) def thread_muted?(%User{id: user_id}, %{data: %{"context" => context}})
when is_binary(context) do when is_binary(context) do
ThreadMute.exists?(user_id, context) ThreadMute.exists?(user_id, context)

View file

@ -394,7 +394,7 @@ def unfollow(%{assigns: %{user: follower, account: followed}} = conn, _params) d
@doc "POST /api/v1/accounts/:id/mute" @doc "POST /api/v1/accounts/:id/mute"
def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do def mute(%{assigns: %{user: muter, account: muted}, body_params: params} = conn, _params) do
with {:ok, _user_relationships} <- User.mute(muter, muted, params.notifications) do with {:ok, _user_relationships} <- User.mute(muter, muted, params) do
render(conn, "relationship.json", user: muter, target: muted) render(conn, "relationship.json", user: muter, target: muted)
else else
{:error, message} -> json_response(conn, :forbidden, %{error: message}) {:error, message} -> json_response(conn, :forbidden, %{error: message})

View file

@ -284,9 +284,9 @@ def unbookmark(%{assigns: %{user: user}} = conn, %{id: id}) do
end end
@doc "POST /api/v1/statuses/:id/mute" @doc "POST /api/v1/statuses/:id/mute"
def mute_conversation(%{assigns: %{user: user}} = conn, %{id: id}) do def mute_conversation(%{assigns: %{user: user}, body_params: params} = conn, %{id: id}) do
with %Activity{} = activity <- Activity.get_by_id(id), with %Activity{} = activity <- Activity.get_by_id(id),
{:ok, activity} <- CommonAPI.add_mute(user, activity) do {:ok, activity} <- CommonAPI.add_mute(user, activity, params) do
try_render(conn, "show.json", activity: activity, for: user, as: :activity) try_render(conn, "show.json", activity: activity, for: user, as: :activity)
end end
end end

View file

@ -0,0 +1,20 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Workers.MuteExpireWorker do
use Pleroma.Workers.WorkerHelper, queue: "mute_expire"
@impl Oban.Worker
def perform(%Job{args: %{"op" => "unmute_user", "muter_id" => muter_id, "mutee_id" => mutee_id}}) do
Pleroma.User.unmute(muter_id, mutee_id)
:ok
end
def perform(%Job{
args: %{"op" => "unmute_conversation", "user_id" => user_id, "activity_id" => activity_id}
}) do
Pleroma.Web.CommonAPI.remove_mute(user_id, activity_id)
:ok
end
end

View file

@ -229,7 +229,7 @@ test "notification created if user is muted without notifications" do
muter = insert(:user) muter = insert(:user)
muted = insert(:user) muted = insert(:user)
{:ok, _user_relationships} = User.mute(muter, muted, false) {:ok, _user_relationships} = User.mute(muter, muted, %{notifications: false})
{:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"}) {:ok, activity} = CommonAPI.post(muted, %{status: "Hi @#{muter.nickname}"})
@ -1015,7 +1015,7 @@ test "move activity generates a notification" do
test "it returns notifications for muted user without notifications", %{user: user} do test "it returns notifications for muted user without notifications", %{user: user} do
muted = insert(:user) muted = insert(:user)
{:ok, _user_relationships} = User.mute(user, muted, false) {:ok, _user_relationships} = User.mute(user, muted, %{notifications: false})
{:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"}) {:ok, _activity} = CommonAPI.post(muted, %{status: "hey @#{user.nickname}"})

View file

@ -1008,6 +1008,27 @@ test "it mutes people" do
assert User.muted_notifications?(user, muted_user) assert User.muted_notifications?(user, muted_user)
end end
test "expiring" do
user = insert(:user)
muted_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, muted_user, %{expires_in: 60})
assert User.mutes?(user, muted_user)
worker = Pleroma.Workers.MuteExpireWorker
args = %{"op" => "unmute_user", "muter_id" => user.id, "mutee_id" => muted_user.id}
assert_enqueued(
worker: worker,
args: args
)
assert :ok = perform_job(worker, args)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
end
test "it unmutes users" do test "it unmutes users" do
user = insert(:user) user = insert(:user)
muted_user = insert(:user) muted_user = insert(:user)
@ -1019,6 +1040,17 @@ test "it unmutes users" do
refute User.muted_notifications?(user, muted_user) refute User.muted_notifications?(user, muted_user)
end end
test "it unmutes users by id" do
user = insert(:user)
muted_user = insert(:user)
{:ok, _user_relationships} = User.mute(user, muted_user)
{:ok, _user_mute} = User.unmute(user.id, muted_user.id)
refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user)
end
test "it mutes user without notifications" do test "it mutes user without notifications" do
user = insert(:user) user = insert(:user)
muted_user = insert(:user) muted_user = insert(:user)
@ -1026,7 +1058,7 @@ test "it mutes user without notifications" do
refute User.mutes?(user, muted_user) refute User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user) refute User.muted_notifications?(user, muted_user)
{:ok, _user_relationships} = User.mute(user, muted_user, false) {:ok, _user_relationships} = User.mute(user, muted_user, %{notifications: false})
assert User.mutes?(user, muted_user) assert User.mutes?(user, muted_user)
refute User.muted_notifications?(user, muted_user) refute User.muted_notifications?(user, muted_user)

View file

@ -3,8 +3,8 @@
# SPDX-License-Identifier: AGPL-3.0-only # SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.CommonAPITest do defmodule Pleroma.Web.CommonAPITest do
use Pleroma.DataCase
use Oban.Testing, repo: Pleroma.Repo use Oban.Testing, repo: Pleroma.Repo
use Pleroma.DataCase
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Chat alias Pleroma.Chat
@ -922,12 +922,34 @@ test "add mute", %{user: user, activity: activity} do
assert CommonAPI.thread_muted?(user, activity) assert CommonAPI.thread_muted?(user, activity)
end end
test "add expiring mute", %{user: user, activity: activity} do
{:ok, _} = CommonAPI.add_mute(user, activity, %{expires_in: 60})
assert CommonAPI.thread_muted?(user, activity)
worker = Pleroma.Workers.MuteExpireWorker
args = %{"op" => "unmute_conversation", "user_id" => user.id, "activity_id" => activity.id}
assert_enqueued(
worker: worker,
args: args
)
assert :ok = perform_job(worker, args)
refute CommonAPI.thread_muted?(user, activity)
end
test "remove mute", %{user: user, activity: activity} do test "remove mute", %{user: user, activity: activity} do
CommonAPI.add_mute(user, activity) CommonAPI.add_mute(user, activity)
{:ok, _} = CommonAPI.remove_mute(user, activity) {:ok, _} = CommonAPI.remove_mute(user, activity)
refute CommonAPI.thread_muted?(user, activity) refute CommonAPI.thread_muted?(user, activity)
end end
test "remove mute by ids", %{user: user, activity: activity} do
CommonAPI.add_mute(user, activity)
{:ok, _} = CommonAPI.remove_mute(user.id, activity.id)
refute CommonAPI.thread_muted?(user, activity)
end
test "check that mutes can't be duplicate", %{user: user, activity: activity} do test "check that mutes can't be duplicate", %{user: user, activity: activity} do
CommonAPI.add_mute(user, activity) CommonAPI.add_mute(user, activity)
{:error, _} = CommonAPI.add_mute(user, activity) {:error, _} = CommonAPI.add_mute(user, activity)

View file

@ -502,7 +502,7 @@ test "see notifications after muting user without notifications" do
assert length(json_response_and_validate_schema(ret_conn, 200)) == 1 assert length(json_response_and_validate_schema(ret_conn, 200)) == 1
{:ok, _user_relationships} = User.mute(user, user2, false) {:ok, _user_relationships} = User.mute(user, user2, %{notifications: false})
conn = get(conn, "/api/v1/notifications") conn = get(conn, "/api/v1/notifications")

View file

@ -277,7 +277,7 @@ test "represent a relationship for the following and followed user" do
{:ok, user} = User.follow(user, other_user) {:ok, user} = User.follow(user, other_user)
{:ok, other_user} = User.follow(other_user, user) {:ok, other_user} = User.follow(other_user, user)
{:ok, _subscription} = User.subscribe(user, other_user) {:ok, _subscription} = User.subscribe(user, other_user)
{:ok, _user_relationships} = User.mute(user, other_user, true) {:ok, _user_relationships} = User.mute(user, other_user, %{notifications: true})
{:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, other_user) {:ok, _reblog_mute} = CommonAPI.hide_reblogs(user, other_user)
expected = expected =