Merge branch 'pleroma-conversations' into 'develop'

Extended Pleroma Conversations

See merge request pleroma/pleroma!1535
This commit is contained in:
lain 2019-08-16 12:55:33 +00:00
commit 7ab2dbbdb6
24 changed files with 655 additions and 99 deletions

View file

@ -46,6 +46,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- MRF: fix use of unserializable keyword lists in describe() implementations - MRF: fix use of unserializable keyword lists in describe() implementations
### Added ### Added
- Conversations: Add Pleroma-specific conversation endpoints and status posting extensions. Run the `bump_all_conversations` task again to create the necessary data.
- **Breaking:** MRF describe API, which adds support for exposing configuration information about MRF policies to NodeInfo. - **Breaking:** MRF describe API, which adds support for exposing configuration information about MRF policies to NodeInfo.
Custom modules will need to be updated by adding, at the very least, `def describe, do: {:ok, %{}}` to the MRF policy modules. Custom modules will need to be updated by adding, at the very least, `def describe, do: {:ok, %{}}` to the MRF policy modules.
- MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`) - MRF: Support for priming the mediaproxy cache (`Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy`)

View file

@ -59,12 +59,19 @@ Has these additional fields under the `pleroma` object:
- `show_role`: boolean, nullable, true when the user wants his role (e.g admin, moderator) to be shown - `show_role`: boolean, nullable, true when the user wants his role (e.g admin, moderator) to be shown
- `no_rich_text` - boolean, nullable, true when html tags are stripped from all statuses requested from the API - `no_rich_text` - boolean, nullable, true when html tags are stripped from all statuses requested from the API
## Conversations
Has an additional field under the `pleroma` object:
- `recipients`: The list of the recipients of this Conversation. These will be addressed when replying to this conversation.
## Account Search ## Account Search
Behavior has changed: Behavior has changed:
- `/api/v1/accounts/search`: Does not require authentication - `/api/v1/accounts/search`: Does not require authentication
## Notifications ## Notifications
Has these additional fields under the `pleroma` object: Has these additional fields under the `pleroma` object:
@ -79,6 +86,7 @@ Additional parameters can be added to the JSON body/Form data:
- `content_type`: string, contain the MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint. - `content_type`: string, contain the MIME type of the status, it is transformed into HTML by the backend. You can get the list of the supported MIME types with the nodeinfo endpoint.
- `to`: A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply. - `to`: A list of nicknames (like `lain@soykaf.club` or `lain` on the local server) that will be used to determine who is going to be addressed by this post. Using this will disable the implicit addressing by mentioned names in the `status` body, only the people in the `to` list will be addressed. The normal rules for for post visibility are not affected by this and will still apply.
- `visibility`: string, besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`. - `visibility`: string, besides standard MastoAPI values (`direct`, `private`, `unlisted` or `public`) it can be used to address a List by setting it to `list:LIST_ID`.
- `in_reply_to_conversation_id`: Will reply to a given conversation, addressing only the people who are part of the recipient set of that conversation. Sets the visibility to `direct`.
## PATCH `/api/v1/update_credentials` ## PATCH `/api/v1/update_credentials`

View file

@ -319,3 +319,38 @@ See [Admin-API](Admin-API.md)
"healthy": true # Instance state "healthy": true # Instance state
} }
``` ```
# Pleroma Conversations
Pleroma Conversations have the same general structure that Mastodon Conversations have. The behavior differs in the following ways when using these endpoints:
1. Pleroma Conversations never add or remove recipients, unless explicitly changed by the user.
2. Pleroma Conversations statuses can be requested by Conversation id.
3. Pleroma Conversations can be replied to.
Conversations have the additional field "recipients" under the "pleroma" key. This holds a list of all the accounts that will receive a message in this conversation.
The status posting endpoint takes an additional parameter, `in_reply_to_conversation_id`, which, when set, will set the visiblity to direct and address only the people who are the recipients of that Conversation.
## `GET /api/v1/pleroma/conversations/:id/statuses`
### Timeline for a given conversation
* Method `GET`
* Authentication: required
* Params: Like other timelines
* Response: JSON, statuses (200 - healthy, 503 unhealthy).
## `GET /api/v1/pleroma/conversations/:id`
### The conversation with the given ID.
* Method `GET`
* Authentication: required
* Params: None
* Response: JSON, statuses (200 - healthy, 503 unhealthy).
## `PATCH /api/v1/pleroma/conversations/:id`
### Update a conversation. Used to change the set of recipients.
* Method `PATCH`
* Authentication: required
* Params:
* `recipients`: A list of ids of users that should receive posts to this conversation. This will replace the current list of recipients, so submit the full list. The owner of owner of the conversation will always be part of the set of recipients, though.
* Response: JSON, statuses (200 - healthy, 503 unhealthy)

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Conversation do defmodule Pleroma.Conversation do
alias Pleroma.Conversation.Participation alias Pleroma.Conversation.Participation
alias Pleroma.Conversation.Participation.RecipientShip
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
use Ecto.Schema use Ecto.Schema
@ -39,6 +40,15 @@ def get_for_ap_id(ap_id) do
Repo.get_by(__MODULE__, ap_id: ap_id) Repo.get_by(__MODULE__, ap_id: ap_id)
end end
def maybe_create_recipientships(participation, activity) do
participation = Repo.preload(participation, :recipients)
if participation.recipients |> Enum.empty?() do
recipients = User.get_all_by_ap_id(activity.recipients)
RecipientShip.create(recipients, participation)
end
end
@doc """ @doc """
This will This will
1. Create a conversation if there isn't one already 1. Create a conversation if there isn't one already
@ -60,6 +70,7 @@ def create_or_bump_for(activity, opts \\ []) do
{:ok, participation} = {:ok, participation} =
Participation.create_for_user_and_conversation(user, conversation, opts) Participation.create_for_user_and_conversation(user, conversation, opts)
maybe_create_recipientships(participation, activity)
participation participation
end) end)

View file

@ -5,6 +5,7 @@
defmodule Pleroma.Conversation.Participation do defmodule Pleroma.Conversation.Participation do
use Ecto.Schema use Ecto.Schema
alias Pleroma.Conversation alias Pleroma.Conversation
alias Pleroma.Conversation.Participation.RecipientShip
alias Pleroma.Repo alias Pleroma.Repo
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
@ -17,6 +18,9 @@ defmodule Pleroma.Conversation.Participation do
field(:read, :boolean, default: false) field(:read, :boolean, default: false)
field(:last_activity_id, Pleroma.FlakeId, virtual: true) field(:last_activity_id, Pleroma.FlakeId, virtual: true)
has_many(:recipient_ships, RecipientShip)
has_many(:recipients, through: [:recipient_ships, :user])
timestamps() timestamps()
end end
@ -65,6 +69,14 @@ def for_user(user, params \\ %{}) do
|> Pleroma.Pagination.fetch_paginated(params) |> Pleroma.Pagination.fetch_paginated(params)
end end
def for_user_and_conversation(user, conversation) do
from(p in __MODULE__,
where: p.user_id == ^user.id,
where: p.conversation_id == ^conversation.id
)
|> Repo.one()
end
def for_user_with_last_activity_id(user, params \\ %{}) do def for_user_with_last_activity_id(user, params \\ %{}) do
for_user(user, params) for_user(user, params)
|> Enum.map(fn participation -> |> Enum.map(fn participation ->
@ -81,4 +93,46 @@ def for_user_with_last_activity_id(user, params \\ %{}) do
end) end)
|> Enum.filter(& &1.last_activity_id) |> Enum.filter(& &1.last_activity_id)
end end
def get(_, _ \\ [])
def get(nil, _), do: nil
def get(id, params) do
query =
if preload = params[:preload] do
from(p in __MODULE__,
preload: ^preload
)
else
__MODULE__
end
Repo.get(query, id)
end
def set_recipients(participation, user_ids) do
user_ids =
[participation.user_id | user_ids]
|> Enum.uniq()
Repo.transaction(fn ->
query =
from(r in RecipientShip,
where: r.participation_id == ^participation.id
)
Repo.delete_all(query)
users =
from(u in User,
where: u.id in ^user_ids
)
|> Repo.all()
RecipientShip.create(users, participation)
:ok
end)
{:ok, Repo.preload(participation, :recipients, force: true)}
end
end end

View file

@ -0,0 +1,34 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Conversation.Participation.RecipientShip do
use Ecto.Schema
alias Pleroma.Conversation.Participation
alias Pleroma.Repo
alias Pleroma.User
import Ecto.Changeset
schema "conversation_participation_recipient_ships" do
belongs_to(:user, User, type: Pleroma.FlakeId)
belongs_to(:participation, Participation)
end
def creation_cng(struct, params) do
struct
|> cast(params, [:user_id, :participation_id])
|> validate_required([:user_id, :participation_id])
end
def create(%User{} = user, participation), do: create([user], participation)
def create(users, participation) do
Enum.each(users, fn user ->
%__MODULE__{}
|> creation_cng(%{user_id: user.id, participation_id: participation.id})
|> Repo.insert!()
end)
end
end

View file

@ -485,6 +485,13 @@ def get_by_ap_id(ap_id) do
Repo.get_by(User, ap_id: ap_id) Repo.get_by(User, ap_id: ap_id)
end end
def get_all_by_ap_id(ap_ids) do
from(u in __MODULE__,
where: u.ap_id in ^ap_ids
)
|> Repo.all()
end
# This is mostly an SPC migration fix. This guesses the user nickname by taking the last part # This is mostly an SPC migration fix. This guesses the user nickname by taking the last part
# of the ap_id and the domain and tries to get that user # of the ap_id and the domain and tries to get that user
def get_by_guessed_nickname(ap_id) do def get_by_guessed_nickname(ap_id) do

View file

@ -4,6 +4,7 @@
defmodule Pleroma.Web.CommonAPI do defmodule Pleroma.Web.CommonAPI do
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Conversation.Participation
alias Pleroma.Formatter alias Pleroma.Formatter
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.ThreadMute alias Pleroma.ThreadMute
@ -171,21 +172,25 @@ defp normalize_and_validate_choice_indices(choices, count) do
end) end)
end end
def get_visibility(%{"visibility" => visibility}, in_reply_to) def get_visibility(_, _, %Participation{}) do
{"direct", "direct"}
end
def get_visibility(%{"visibility" => visibility}, in_reply_to, _)
when visibility in ~w{public unlisted private direct}, when visibility in ~w{public unlisted private direct},
do: {visibility, get_replied_to_visibility(in_reply_to)} do: {visibility, get_replied_to_visibility(in_reply_to)}
def get_visibility(%{"visibility" => "list:" <> list_id}, in_reply_to) do def get_visibility(%{"visibility" => "list:" <> list_id}, in_reply_to, _) do
visibility = {:list, String.to_integer(list_id)} visibility = {:list, String.to_integer(list_id)}
{visibility, get_replied_to_visibility(in_reply_to)} {visibility, get_replied_to_visibility(in_reply_to)}
end end
def get_visibility(_, in_reply_to) when not is_nil(in_reply_to) do def get_visibility(_, in_reply_to, _) when not is_nil(in_reply_to) do
visibility = get_replied_to_visibility(in_reply_to) visibility = get_replied_to_visibility(in_reply_to)
{visibility, visibility} {visibility, visibility}
end end
def get_visibility(_, in_reply_to), do: {"public", get_replied_to_visibility(in_reply_to)} def get_visibility(_, in_reply_to, _), do: {"public", get_replied_to_visibility(in_reply_to)}
def get_replied_to_visibility(nil), do: nil def get_replied_to_visibility(nil), do: nil
@ -201,7 +206,9 @@ def post(user, %{"status" => status} = data) do
with status <- String.trim(status), with status <- String.trim(status),
attachments <- attachments_from_ids(data), attachments <- attachments_from_ids(data),
in_reply_to <- get_replied_to_activity(data["in_reply_to_status_id"]), in_reply_to <- get_replied_to_activity(data["in_reply_to_status_id"]),
{visibility, in_reply_to_visibility} <- get_visibility(data, in_reply_to), in_reply_to_conversation <- Participation.get(data["in_reply_to_conversation_id"]),
{visibility, in_reply_to_visibility} <-
get_visibility(data, in_reply_to, in_reply_to_conversation),
{_, false} <- {_, false} <-
{:private_to_public, in_reply_to_visibility == "direct" && visibility != "direct"}, {:private_to_public, in_reply_to_visibility == "direct" && visibility != "direct"},
{content_html, mentions, tags} <- {content_html, mentions, tags} <-
@ -214,8 +221,9 @@ def post(user, %{"status" => status} = data) do
mentioned_users <- for({_, mentioned_user} <- mentions, do: mentioned_user.ap_id), mentioned_users <- for({_, mentioned_user} <- mentions, do: mentioned_user.ap_id),
addressed_users <- get_addressed_users(mentioned_users, data["to"]), addressed_users <- get_addressed_users(mentioned_users, data["to"]),
{poll, poll_emoji} <- make_poll_data(data), {poll, poll_emoji} <- make_poll_data(data),
{to, cc} <- get_to_and_cc(user, addressed_users, in_reply_to, visibility), {to, cc} <-
context <- make_context(in_reply_to), get_to_and_cc(user, addressed_users, in_reply_to, visibility, in_reply_to_conversation),
context <- make_context(in_reply_to, in_reply_to_conversation),
cw <- data["spoiler_text"] || "", cw <- data["spoiler_text"] || "",
sensitive <- data["sensitive"] || Enum.member?(tags, {"#nsfw", "nsfw"}), sensitive <- data["sensitive"] || Enum.member?(tags, {"#nsfw", "nsfw"}),
full_payload <- String.trim(status <> cw), full_payload <- String.trim(status <> cw),

View file

@ -8,6 +8,7 @@ defmodule Pleroma.Web.CommonAPI.Utils do
alias Calendar.Strftime alias Calendar.Strftime
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Config alias Pleroma.Config
alias Pleroma.Conversation.Participation
alias Pleroma.Formatter alias Pleroma.Formatter
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Plugs.AuthenticationPlug alias Pleroma.Plugs.AuthenticationPlug
@ -86,9 +87,21 @@ def attachments_from_ids_descs(ids, descs_str) do
|> Enum.filter(& &1) |> Enum.filter(& &1)
end end
@spec get_to_and_cc(User.t(), list(String.t()), Activity.t() | nil, String.t()) :: @spec get_to_and_cc(
User.t(),
list(String.t()),
Activity.t() | nil,
String.t(),
Participation.t() | nil
) ::
{list(String.t()), list(String.t())} {list(String.t()), list(String.t())}
def get_to_and_cc(user, mentioned_users, inReplyTo, "public") do
def get_to_and_cc(_, _, _, _, %Participation{} = participation) do
participation = Repo.preload(participation, :recipients)
{Enum.map(participation.recipients, & &1.ap_id), []}
end
def get_to_and_cc(user, mentioned_users, inReplyTo, "public", _) do
to = [Pleroma.Constants.as_public() | mentioned_users] to = [Pleroma.Constants.as_public() | mentioned_users]
cc = [user.follower_address] cc = [user.follower_address]
@ -99,7 +112,7 @@ def get_to_and_cc(user, mentioned_users, inReplyTo, "public") do
end end
end end
def get_to_and_cc(user, mentioned_users, inReplyTo, "unlisted") do def get_to_and_cc(user, mentioned_users, inReplyTo, "unlisted", _) do
to = [user.follower_address | mentioned_users] to = [user.follower_address | mentioned_users]
cc = [Pleroma.Constants.as_public()] cc = [Pleroma.Constants.as_public()]
@ -110,12 +123,12 @@ def get_to_and_cc(user, mentioned_users, inReplyTo, "unlisted") do
end end
end end
def get_to_and_cc(user, mentioned_users, inReplyTo, "private") do def get_to_and_cc(user, mentioned_users, inReplyTo, "private", _) do
{to, cc} = get_to_and_cc(user, mentioned_users, inReplyTo, "direct") {to, cc} = get_to_and_cc(user, mentioned_users, inReplyTo, "direct", nil)
{[user.follower_address | to], cc} {[user.follower_address | to], cc}
end end
def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct") do def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct", _) do
if inReplyTo do if inReplyTo do
{Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []} {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []}
else else
@ -123,7 +136,7 @@ def get_to_and_cc(_user, mentioned_users, inReplyTo, "direct") do
end end
end end
def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}), do: {mentions, []} def get_to_and_cc(_user, mentions, _inReplyTo, {:list, _}, _), do: {mentions, []}
def get_addressed_users(_, to) when is_list(to) do def get_addressed_users(_, to) when is_list(to) do
User.get_ap_ids_by_nicknames(to) User.get_ap_ids_by_nicknames(to)
@ -253,8 +266,12 @@ defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
defp maybe_add_nsfw_tag(data, _), do: data defp maybe_add_nsfw_tag(data, _), do: data
def make_context(%Activity{data: %{"context" => context}}), do: context def make_context(_, %Participation{} = participation) do
def make_context(_), do: Utils.generate_context_id() Repo.preload(participation, :conversation).conversation.ap_id
end
def make_context(%Activity{data: %{"context" => context}}, _), do: context
def make_context(_, _), do: Utils.generate_context_id()
def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed

View file

@ -33,4 +33,80 @@ defp param_to_integer(val, default) when is_binary(val) do
end end
defp param_to_integer(_, default), do: default defp param_to_integer(_, default), do: default
def add_link_headers(
conn,
method,
activities,
param \\ nil,
params \\ %{},
func3 \\ nil,
func4 \\ nil
) do
params =
conn.params
|> Map.drop(["since_id", "max_id", "min_id"])
|> Map.merge(params)
last = List.last(activities)
func3 = func3 || (&mastodon_api_url/3)
func4 = func4 || (&mastodon_api_url/4)
if last do
max_id = last.id
limit =
params
|> Map.get("limit", "20")
|> String.to_integer()
min_id =
if length(activities) <= limit do
activities
|> List.first()
|> Map.get(:id)
else
activities
|> Enum.at(limit * -1)
|> Map.get(:id)
end
{next_url, prev_url} =
if param do
{
func4.(
Pleroma.Web.Endpoint,
method,
param,
Map.merge(params, %{max_id: max_id})
),
func4.(
Pleroma.Web.Endpoint,
method,
param,
Map.merge(params, %{min_id: min_id})
)
}
else
{
func3.(
Pleroma.Web.Endpoint,
method,
Map.merge(params, %{max_id: max_id})
),
func3.(
Pleroma.Web.Endpoint,
method,
Map.merge(params, %{min_id: min_id})
)
}
end
conn
|> put_resp_header("link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
else
conn
end
end
end end

View file

@ -5,7 +5,8 @@
defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do defmodule Pleroma.Web.MastodonAPI.MastodonAPIController do
use Pleroma.Web, :controller use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [json_response: 3] import Pleroma.Web.ControllerHelper,
only: [json_response: 3, add_link_headers: 5, add_link_headers: 4, add_link_headers: 3]
alias Ecto.Changeset alias Ecto.Changeset
alias Pleroma.Activity alias Pleroma.Activity
@ -342,71 +343,6 @@ def custom_emojis(conn, _params) do
json(conn, mastodon_emoji) json(conn, mastodon_emoji)
end end
defp add_link_headers(conn, method, activities, param \\ nil, params \\ %{}) do
params =
conn.params
|> Map.drop(["since_id", "max_id", "min_id"])
|> Map.merge(params)
last = List.last(activities)
if last do
max_id = last.id
limit =
params
|> Map.get("limit", "20")
|> String.to_integer()
min_id =
if length(activities) <= limit do
activities
|> List.first()
|> Map.get(:id)
else
activities
|> Enum.at(limit * -1)
|> Map.get(:id)
end
{next_url, prev_url} =
if param do
{
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
param,
Map.merge(params, %{max_id: max_id})
),
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
param,
Map.merge(params, %{min_id: min_id})
)
}
else
{
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
Map.merge(params, %{max_id: max_id})
),
mastodon_api_url(
Pleroma.Web.Endpoint,
method,
Map.merge(params, %{min_id: min_id})
)
}
end
conn
|> put_resp_header("link", "<#{next_url}>; rel=\"next\", <#{prev_url}>; rel=\"prev\"")
else
conn
end
end
def home_timeline(%{assigns: %{user: user}} = conn, params) do def home_timeline(%{assigns: %{user: user}} = conn, params) do
params = params =
params params
@ -1797,7 +1733,7 @@ def conversations(%{assigns: %{user: user}} = conn, params) do
conversations = conversations =
Enum.map(participations, fn participation -> Enum.map(participations, fn participation ->
ConversationView.render("participation.json", %{participation: participation, user: user}) ConversationView.render("participation.json", %{participation: participation, for: user})
end) end)
conn conn
@ -1810,7 +1746,7 @@ def conversation_read(%{assigns: %{user: user}} = conn, %{"id" => participation_
Repo.get_by(Participation, id: participation_id, user_id: user.id), Repo.get_by(Participation, id: participation_id, user_id: user.id),
{:ok, participation} <- Participation.mark_as_read(participation) do {:ok, participation} <- Participation.mark_as_read(participation) do
participation_view = participation_view =
ConversationView.render("participation.json", %{participation: participation, user: user}) ConversationView.render("participation.json", %{participation: participation, for: user})
conn conn
|> json(participation_view) |> json(participation_view)

View file

@ -11,8 +11,8 @@ defmodule Pleroma.Web.MastodonAPI.ConversationView do
alias Pleroma.Web.MastodonAPI.AccountView alias Pleroma.Web.MastodonAPI.AccountView
alias Pleroma.Web.MastodonAPI.StatusView alias Pleroma.Web.MastodonAPI.StatusView
def render("participation.json", %{participation: participation, user: user}) do def render("participation.json", %{participation: participation, for: user}) do
participation = Repo.preload(participation, conversation: :users) participation = Repo.preload(participation, conversation: [], recipients: [])
last_activity_id = last_activity_id =
with nil <- participation.last_activity_id do with nil <- participation.last_activity_id do
@ -28,7 +28,7 @@ def render("participation.json", %{participation: participation, user: user}) do
# Conversations return all users except the current user. # Conversations return all users except the current user.
users = users =
participation.conversation.users participation.recipients
|> Enum.reject(&(&1.id == user.id)) |> Enum.reject(&(&1.id == user.id))
accounts = accounts =

View file

@ -8,6 +8,8 @@ defmodule Pleroma.Web.MastodonAPI.StatusView do
require Pleroma.Constants require Pleroma.Constants
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Conversation
alias Pleroma.Conversation.Participation
alias Pleroma.HTML alias Pleroma.HTML
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.Repo alias Pleroma.Repo
@ -235,6 +237,19 @@ def render("status.json", %{activity: %{data: %{"object" => _object}} = activity
object.data["url"] || object.data["external_url"] || object.data["id"] object.data["url"] || object.data["external_url"] || object.data["id"]
end end
direct_conversation_id =
with {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
{_, %User{} = for_user} <- {:for_user, opts[:for]},
%{data: %{"context" => context}} when is_binary(context) <- activity,
%Conversation{} = conversation <- Conversation.get_for_ap_id(context),
%Participation{id: participation_id} <-
Participation.for_user_and_conversation(for_user, conversation) do
participation_id
else
_e ->
nil
end
%{ %{
id: to_string(activity.id), id: to_string(activity.id),
uri: object.data["id"], uri: object.data["id"],
@ -272,7 +287,8 @@ def render("status.json", %{activity: %{data: %{"object" => _object}} = activity
conversation_id: get_context_id(activity), conversation_id: get_context_id(activity),
in_reply_to_account_acct: reply_to_user && reply_to_user.nickname, in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
content: %{"text/plain" => content_plaintext}, content: %{"text/plain" => content_plaintext},
spoiler_text: %{"text/plain" => summary_plaintext} spoiler_text: %{"text/plain" => summary_plaintext},
direct_conversation_id: direct_conversation_id
} }
} }
end end

View file

@ -0,0 +1,73 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.PleromaAPIController do
use Pleroma.Web, :controller
import Pleroma.Web.ControllerHelper, only: [add_link_headers: 7]
alias Pleroma.Conversation.Participation
alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.MastodonAPI.ConversationView
alias Pleroma.Web.MastodonAPI.StatusView
def conversation(%{assigns: %{user: user}} = conn, %{"id" => participation_id}) do
with %Participation{} = participation <- Participation.get(participation_id),
true <- user.id == participation.user_id do
conn
|> put_view(ConversationView)
|> render("participation.json", %{participation: participation, for: user})
end
end
def conversation_statuses(
%{assigns: %{user: user}} = conn,
%{"id" => participation_id} = params
) do
params =
params
|> Map.put("blocking_user", user)
|> Map.put("muting_user", user)
|> Map.put("user", user)
participation =
participation_id
|> Participation.get(preload: [:conversation])
if user.id == participation.user_id do
activities =
participation.conversation.ap_id
|> ActivityPub.fetch_activities_for_context(params)
|> Enum.reverse()
conn
|> add_link_headers(
:conversation_statuses,
activities,
participation_id,
params,
nil,
&pleroma_api_url/4
)
|> put_view(StatusView)
|> render("index.json", %{activities: activities, for: user, as: :activity})
end
end
def update_conversation(
%{assigns: %{user: user}} = conn,
%{"id" => participation_id, "recipients" => recipients}
) do
participation =
participation_id
|> Participation.get()
with true <- user.id == participation.user_id,
{:ok, participation} <- Participation.set_recipients(participation, recipients) do
conn
|> put_view(ConversationView)
|> render("participation.json", %{participation: participation, for: user})
end
end
end

View file

@ -259,6 +259,21 @@ defmodule Pleroma.Web.Router do
end end
end end
scope "/api/v1/pleroma", Pleroma.Web.PleromaAPI do
pipe_through(:authenticated_api)
scope [] do
pipe_through(:oauth_read)
get("/conversations/:id/statuses", PleromaAPIController, :conversation_statuses)
get("/conversations/:id", PleromaAPIController, :conversation)
end
scope [] do
pipe_through(:oauth_write)
patch("/conversations/:id", PleromaAPIController, :update_conversation)
end
end
scope "/api/v1", Pleroma.Web.MastodonAPI do scope "/api/v1", Pleroma.Web.MastodonAPI do
pipe_through(:authenticated_api) pipe_through(:authenticated_api)

View file

@ -202,7 +202,7 @@ def represent_conversation(%Participation{} = participation) do
payload: payload:
Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{ Pleroma.Web.MastodonAPI.ConversationView.render("participation.json", %{
participation: participation, participation: participation,
user: participation.user for: participation.user
}) })
|> Jason.encode!() |> Jason.encode!()
} }

View file

@ -6,7 +6,7 @@ def change do
add :user_id, references(:users, type: :uuid, on_delete: :delete_all) add :user_id, references(:users, type: :uuid, on_delete: :delete_all)
add :context, :string add :context, :string
end end
create_if_not_exists unique_index(:thread_mutes, [:user_id, :context], name: :unique_index) create_if_not_exists unique_index(:thread_mutes, [:user_id, :context], name: :unique_index)
end end
end end

View file

@ -0,0 +1,13 @@
defmodule Pleroma.Repo.Migrations.CreateConversationParticipationRecipientShips do
use Ecto.Migration
def change do
create_if_not_exists table(:conversation_participation_recipient_ships) do
add(:user_id, references(:users, type: :uuid, on_delete: :delete_all))
add(:participation_id, references(:conversation_participations, on_delete: :delete_all))
end
create_if_not_exists index(:conversation_participation_recipient_ships, [:user_id])
create_if_not_exists index(:conversation_participation_recipient_ships, [:participation_id])
end
end

View file

@ -8,6 +8,50 @@ defmodule Pleroma.Conversation.ParticipationTest do
alias Pleroma.Conversation.Participation alias Pleroma.Conversation.Participation
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
test "getting a participation will also preload things" do
user = insert(:user)
other_user = insert(:user)
{:ok, _activity} =
CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"})
[participation] = Participation.for_user(user)
participation = Participation.get(participation.id, preload: [:conversation])
assert %Pleroma.Conversation{} = participation.conversation
end
test "for a new conversation, it sets the recipents of the participation" do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{"status" => "Hey @#{other_user.nickname}.", "visibility" => "direct"})
[participation] = Participation.for_user(user)
participation = Pleroma.Repo.preload(participation, :recipients)
assert length(participation.recipients) == 2
assert user in participation.recipients
assert other_user in participation.recipients
# Mentioning another user in the same conversation will not add a new recipients.
{:ok, _activity} =
CommonAPI.post(user, %{
"in_reply_to_status_id" => activity.id,
"status" => "Hey @#{third_user.nickname}.",
"visibility" => "direct"
})
[participation] = Participation.for_user(user)
participation = Pleroma.Repo.preload(participation, :recipients)
assert length(participation.recipients) == 2
end
test "it creates a participation for a conversation and a user" do test "it creates a participation for a conversation and a user" do
user = insert(:user) user = insert(:user)
conversation = insert(:conversation) conversation = insert(:conversation)
@ -102,4 +146,23 @@ test "Doesn't die when the conversation gets empty" do
[] = Participation.for_user_with_last_activity_id(user) [] = Participation.for_user_with_last_activity_id(user)
end end
test "it sets recipients, always keeping the owner of the participation even when not explicitly set" do
user = insert(:user)
other_user = insert(:user)
{:ok, _activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
[participation] = Participation.for_user_with_last_activity_id(user)
participation = Repo.preload(participation, :recipients)
assert participation.recipients |> length() == 1
assert user in participation.recipients
{:ok, participation} = Participation.set_recipients(participation, [other_user.id])
assert participation.recipients |> length() == 2
assert user in participation.recipients
assert other_user in participation.recipients
end
end end

View file

@ -5,13 +5,58 @@
defmodule Pleroma.Web.CommonAPITest do defmodule Pleroma.Web.CommonAPITest do
use Pleroma.DataCase use Pleroma.DataCase
alias Pleroma.Activity alias Pleroma.Activity
alias Pleroma.Conversation.Participation
alias Pleroma.Object alias Pleroma.Object
alias Pleroma.User alias Pleroma.User
alias Pleroma.Web.ActivityPub.ActivityPub alias Pleroma.Web.ActivityPub.ActivityPub
alias Pleroma.Web.ActivityPub.Visibility
alias Pleroma.Web.CommonAPI alias Pleroma.Web.CommonAPI
import Pleroma.Factory import Pleroma.Factory
test "when replying to a conversation / participation, it will set the correct context id even if no explicit reply_to is given" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => ".", "visibility" => "direct"})
[participation] = Participation.for_user(user)
{:ok, convo_reply} =
CommonAPI.post(user, %{"status" => ".", "in_reply_to_conversation_id" => participation.id})
assert Visibility.is_direct?(convo_reply)
assert activity.data["context"] == convo_reply.data["context"]
end
test "when replying to a conversation / participation, it only mentions the recipients explicitly declared in the participation" do
har = insert(:user)
jafnhar = insert(:user)
tridi = insert(:user)
{:ok, activity} =
CommonAPI.post(har, %{
"status" => "@#{jafnhar.nickname} hey",
"visibility" => "direct"
})
assert har.ap_id in activity.recipients
assert jafnhar.ap_id in activity.recipients
[participation] = Participation.for_user(har)
{:ok, activity} =
CommonAPI.post(har, %{
"status" => "I don't really like @#{tridi.nickname}",
"visibility" => "direct",
"in_reply_to_status_id" => activity.id,
"in_reply_to_conversation_id" => participation.id
})
assert har.ap_id in activity.recipients
assert jafnhar.ap_id in activity.recipients
refute tridi.ap_id in activity.recipients
end
test "with the safe_dm_mention option set, it does not mention people beyond the initial tags" do test "with the safe_dm_mention option set, it does not mention people beyond the initial tags" do
har = insert(:user) har = insert(:user)
jafnhar = insert(:user) jafnhar = insert(:user)

View file

@ -239,7 +239,7 @@ test "for public posts, not a reply" do
mentioned_user = insert(:user) mentioned_user = insert(:user)
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, nil, "public") {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "public", nil)
assert length(to) == 2 assert length(to) == 2
assert length(cc) == 1 assert length(cc) == 1
@ -256,7 +256,7 @@ test "for public posts, a reply" do
{:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "public") {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "public", nil)
assert length(to) == 3 assert length(to) == 3
assert length(cc) == 1 assert length(cc) == 1
@ -272,7 +272,7 @@ test "for unlisted posts, not a reply" do
mentioned_user = insert(:user) mentioned_user = insert(:user)
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, nil, "unlisted") {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "unlisted", nil)
assert length(to) == 2 assert length(to) == 2
assert length(cc) == 1 assert length(cc) == 1
@ -289,7 +289,7 @@ test "for unlisted posts, a reply" do
{:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "unlisted") {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "unlisted", nil)
assert length(to) == 3 assert length(to) == 3
assert length(cc) == 1 assert length(cc) == 1
@ -305,7 +305,7 @@ test "for private posts, not a reply" do
mentioned_user = insert(:user) mentioned_user = insert(:user)
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, nil, "private") {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "private", nil)
assert length(to) == 2 assert length(to) == 2
assert length(cc) == 0 assert length(cc) == 0
@ -320,7 +320,7 @@ test "for private posts, a reply" do
{:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "private") {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "private", nil)
assert length(to) == 3 assert length(to) == 3
assert length(cc) == 0 assert length(cc) == 0
@ -335,7 +335,7 @@ test "for direct posts, not a reply" do
mentioned_user = insert(:user) mentioned_user = insert(:user)
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, nil, "direct") {to, cc} = Utils.get_to_and_cc(user, mentions, nil, "direct", nil)
assert length(to) == 1 assert length(to) == 1
assert length(cc) == 0 assert length(cc) == 0
@ -350,7 +350,7 @@ test "for direct posts, a reply" do
{:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"}) {:ok, activity} = CommonAPI.post(third_user, %{"status" => "uguu"})
mentions = [mentioned_user.ap_id] mentions = [mentioned_user.ap_id]
{to, cc} = Utils.get_to_and_cc(user, mentions, activity, "direct") {to, cc} = Utils.get_to_and_cc(user, mentions, activity, "direct", nil)
assert length(to) == 2 assert length(to) == 2
assert length(cc) == 0 assert length(cc) == 0

View file

@ -0,0 +1,34 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.MastodonAPI.ConversationViewTest do
use Pleroma.DataCase
alias Pleroma.Conversation.Participation
alias Pleroma.Web.CommonAPI
alias Pleroma.Web.MastodonAPI.ConversationView
import Pleroma.Factory
test "represents a Mastodon Conversation entity" do
user = insert(:user)
other_user = insert(:user)
{:ok, activity} =
CommonAPI.post(user, %{"status" => "hey @#{other_user.nickname}", "visibility" => "direct"})
[participation] = Participation.for_user_with_last_activity_id(user)
assert participation
conversation =
ConversationView.render("participation.json", %{participation: participation, for: user})
assert conversation.id == participation.id |> to_string()
assert conversation.last_status.id == activity.id
assert [account] = conversation.accounts
assert account.id == other_user.id
end
end

View file

@ -23,6 +23,21 @@ defmodule Pleroma.Web.MastodonAPI.StatusViewTest do
:ok :ok
end end
test "returns the direct conversation id when given the `with_conversation_id` option" do
user = insert(:user)
{:ok, activity} = CommonAPI.post(user, %{"status" => "Hey @shp!", "visibility" => "direct"})
status =
StatusView.render("status.json",
activity: activity,
with_direct_conversation_id: true,
for: user
)
assert status[:pleroma][:direct_conversation_id]
end
test "returns a temporary ap_id based user for activities missing db users" do test "returns a temporary ap_id based user for activities missing db users" do
user = insert(:user) user = insert(:user)
@ -133,7 +148,8 @@ test "a note activity" do
conversation_id: convo_id, conversation_id: convo_id,
in_reply_to_account_acct: nil, in_reply_to_account_acct: nil,
content: %{"text/plain" => HtmlSanitizeEx.strip_tags(object_data["content"])}, content: %{"text/plain" => HtmlSanitizeEx.strip_tags(object_data["content"])},
spoiler_text: %{"text/plain" => HtmlSanitizeEx.strip_tags(object_data["summary"])} spoiler_text: %{"text/plain" => HtmlSanitizeEx.strip_tags(object_data["summary"])},
direct_conversation_id: nil
} }
} }

View file

@ -0,0 +1,94 @@
# Pleroma: A lightweight social networking server
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
# SPDX-License-Identifier: AGPL-3.0-only
defmodule Pleroma.Web.PleromaAPI.PleromaAPIControllerTest do
use Pleroma.Web.ConnCase
alias Pleroma.Conversation.Participation
alias Pleroma.Repo
alias Pleroma.Web.CommonAPI
import Pleroma.Factory
test "/api/v1/pleroma/conversations/:id", %{conn: conn} do
user = insert(:user)
other_user = insert(:user)
{:ok, _activity} =
CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}!", "visibility" => "direct"})
[participation] = Participation.for_user(other_user)
result =
conn
|> assign(:user, other_user)
|> get("/api/v1/pleroma/conversations/#{participation.id}")
|> json_response(200)
assert result["id"] == participation.id |> to_string()
end
test "/api/v1/pleroma/conversations/:id/statuses", %{conn: conn} do
user = insert(:user)
other_user = insert(:user)
third_user = insert(:user)
{:ok, _activity} =
CommonAPI.post(user, %{"status" => "Hi @#{third_user.nickname}!", "visibility" => "direct"})
{:ok, activity} =
CommonAPI.post(user, %{"status" => "Hi @#{other_user.nickname}!", "visibility" => "direct"})
[participation] = Participation.for_user(other_user)
{:ok, activity_two} =
CommonAPI.post(other_user, %{
"status" => "Hi!",
"in_reply_to_status_id" => activity.id,
"in_reply_to_conversation_id" => participation.id
})
result =
conn
|> assign(:user, other_user)
|> get("/api/v1/pleroma/conversations/#{participation.id}/statuses")
|> json_response(200)
assert length(result) == 2
id_one = activity.id
id_two = activity_two.id
assert [%{"id" => ^id_one}, %{"id" => ^id_two}] = result
end
test "PATCH /api/v1/pleroma/conversations/:id", %{conn: conn} do
user = insert(:user)
other_user = insert(:user)
{:ok, _activity} = CommonAPI.post(user, %{"status" => "Hi", "visibility" => "direct"})
[participation] = Participation.for_user(user)
participation = Repo.preload(participation, :recipients)
assert [user] == participation.recipients
assert other_user not in participation.recipients
result =
conn
|> assign(:user, user)
|> patch("/api/v1/pleroma/conversations/#{participation.id}", %{
"recipients" => [user.id, other_user.id]
})
|> json_response(200)
assert result["id"] == participation.id |> to_string
[participation] = Participation.for_user(user)
participation = Repo.preload(participation, :recipients)
assert user in participation.recipients
assert other_user in participation.recipients
end
end