diff --git a/lib/federation/activity_pub/types/conversation.ex b/lib/federation/activity_pub/types/conversation.ex index b72cef022..6bb8c7918 100644 --- a/lib/federation/activity_pub/types/conversation.ex +++ b/lib/federation/activity_pub/types/conversation.ex @@ -147,6 +147,12 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Conversations do (args |> Map.get(:mentions, []) |> prepare_mentions()) ++ ConverterUtils.fetch_mentions(mentions) + # Can't create a conversation with just ourselves + mentions = + Enum.filter(mentions, fn %{actor_id: actor_id} -> + to_string(actor_id) != to_string(args.actor_id) + end) + if Enum.empty?(mentions) do {:error, :empty_participants} else diff --git a/lib/graphql/resolvers/conversation.ex b/lib/graphql/resolvers/conversation.ex index 00f95de53..33c76c333 100644 --- a/lib/graphql/resolvers/conversation.ex +++ b/lib/graphql/resolvers/conversation.ex @@ -11,8 +11,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Conversation do alias Mobilizon.Storage.Page alias Mobilizon.Users.User alias Mobilizon.Web.Endpoint - # alias Mobilizon.Users.User import Mobilizon.Web.Gettext, only: [dgettext: 2] + require Logger @spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) :: {:ok, Page.t(ConversationView.t())} | {:error, :unauthenticated} @@ -157,9 +157,17 @@ defmodule Mobilizon.GraphQL.Resolvers.Conversation do {:ok, conversation_to_view(conversation, conversation_participant_actor)} {:error, :empty_participants} -> - {:error, dgettext("errors", "Conversation needs to mention at least one participant")} + {:error, + dgettext( + "errors", + "Conversation needs to mention at least one participant that's not yourself" + )} end else + Logger.debug( + "Actor #{current_actor.id} is not authorized to reply to conversation #{inspect(Map.get(args, :conversation_id))}" + ) + {:error, :unauthorized} end end @@ -259,7 +267,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Conversation do %Conversation{participants: participants} -> participant_ids = Enum.map(participants, fn participant -> to_string(participant.id) end) - current_actor_id in participant_ids or + to_string(current_actor_id) in participant_ids or Enum.any?(participant_ids, fn participant_id -> Actors.is_member?(current_actor_id, participant_id) and attributed_to_id == participant_id diff --git a/lib/graphql/resolvers/participant.ex b/lib/graphql/resolvers/participant.ex index 520ecb149..23e739873 100644 --- a/lib/graphql/resolvers/participant.ex +++ b/lib/graphql/resolvers/participant.ex @@ -2,8 +2,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do @moduledoc """ Handles the participation-related GraphQL calls. """ - # alias Mobilizon.Conversations.ConversationParticipant - alias Mobilizon.{Actors, Config, Crypto, Events} + alias Mobilizon.{Actors, Config, Conversations, Crypto, Events} alias Mobilizon.Actors.Actor alias Mobilizon.Conversations.{Conversation, ConversationView} alias Mobilizon.Events.{Event, Participant} @@ -386,6 +385,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do {:member, false} -> {:error, :unauthorized} + {:error, :empty_participants} -> + {:error, + dgettext( + "errors", + "There are no participants matching the audience you've selected." + )} + {:error, err} -> {:error, err} end @@ -394,11 +400,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do def send_private_messages_to_participants(_parent, _args, _resolution), do: {:error, :unauthorized} - defp conversation_to_view(%Conversation{} = conversation, %Actor{} = actor) do + defp conversation_to_view( + %Conversation{id: conversation_id} = conversation, + %Actor{id: actor_id} = actor + ) do value = conversation |> Map.from_struct() |> Map.put(:actor, actor) + |> Map.put(:unread, false) + |> Map.put( + :conversation_participant_id, + Conversations.get_participant_by_conversation_and_actor(conversation_id, actor_id).id + ) struct(ConversationView, value) end diff --git a/lib/mobilizon/discussions/comment.ex b/lib/mobilizon/discussions/comment.ex index 0bd20f245..151f45218 100644 --- a/lib/mobilizon/discussions/comment.ex +++ b/lib/mobilizon/discussions/comment.ex @@ -77,7 +77,7 @@ defmodule Mobilizon.Discussions.Comment do belongs_to(:conversation, Conversation) has_many(:replies, Comment, foreign_key: :origin_comment_id) many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete) - has_many(:mentions, Mention) + has_many(:mentions, Mention, on_replace: :delete) many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete) timestamps(type: :utc_datetime) diff --git a/lib/service/activity/conversation.ex b/lib/service/activity/conversation.ex index f9dfe9738..3f31f1db0 100644 --- a/lib/service/activity/conversation.ex +++ b/lib/service/activity/conversation.ex @@ -79,11 +79,16 @@ defmodule Mobilizon.Service.Activity.Conversation do defp send_participant_notifications(_, _, _, _), do: {:ok, :skipped} defp event_subject_params(%Conversation{ - event: %Event{id: conversation_event_id, title: conversation_event_title} + event: %Event{ + id: conversation_event_id, + title: conversation_event_title, + uuid: conversation_event_uuid + } }), do: %{ conversation_event_id: conversation_event_id, - conversation_event_title: conversation_event_title + conversation_event_title: conversation_event_title, + conversation_event_uuid: conversation_event_uuid } defp event_subject_params(_), do: %{} diff --git a/lib/service/formatter/html.ex b/lib/service/formatter/html.ex index 5544eeded..67948d030 100644 --- a/lib/service/formatter/html.ex +++ b/lib/service/formatter/html.ex @@ -14,6 +14,8 @@ defmodule Mobilizon.Service.Formatter.HTML do def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler) + defdelegate basic_html(html), to: FastSanitize + @spec strip_tags(String.t()) :: String.t() | no_return() def strip_tags(html) do case FastSanitize.strip_tags(html) do @@ -39,5 +41,17 @@ defmodule Mobilizon.Service.Formatter.HTML do def strip_tags_and_insert_spaces(html), do: html + @spec html_to_text(String.t()) :: String.t() + def html_to_text(html) do + html + |> String.replace(~r/<li>/, "\\g{1}- ", global: true) + |> String.replace( + ~r/<\/?\s?br>|<\/\s?p>|<\/\s?li>|<\/\s?div>|<\/\s?h.>/, + "\\g{1}\n\r", + global: true + ) + |> strip_tags() + end + def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed) end diff --git a/lib/service/formatter/text.ex b/lib/service/formatter/text.ex new file mode 100644 index 000000000..140502f16 --- /dev/null +++ b/lib/service/formatter/text.ex @@ -0,0 +1,37 @@ +defmodule Mobilizon.Service.Formatter.Text do + @moduledoc """ + Helps to format text blocks + + Inspired from https://elixirforum.com/t/is-there-are-text-wrapping-library-for-elixir/21733/4 + Using the Knuth-Plass Line Wrapping Algorithm https://www.students.cs.ubc.ca/~cs-490/2015W2/lectures/Knuth.pdf + """ + + def quote_paragraph(string, max_line_length) do + paragraph(string, max_line_length, "> ") + end + + def paragraph(string, max_line_length, prefix \\ "") do + string + |> String.split("\n\n", trim: true) + |> Enum.map(&subparagraph(&1, max_line_length, prefix)) + |> Enum.join("\n#{prefix}\n") + end + + defp subparagraph(string, max_line_length, prefix) do + [word | rest] = String.split(string, ~r/\s+/, trim: true) + + lines_assemble(rest, max_line_length - String.length(prefix), String.length(word), word, []) + |> Enum.map(&"#{prefix}#{&1}") + |> Enum.join("\n") + end + + defp lines_assemble([], _, _, line, acc), do: [line | acc] |> Enum.reverse() + + defp lines_assemble([word | rest], max, line_length, line, acc) do + if line_length + 1 + String.length(word) > max do + lines_assemble(rest, max, String.length(word), word, [line | acc]) + else + lines_assemble(rest, max, line_length + 1 + String.length(word), line <> " " <> word, acc) + end + end +end diff --git a/lib/service/workers/legacy_notifier_builder.ex b/lib/service/workers/legacy_notifier_builder.ex index 4c0f776c7..a00e19260 100644 --- a/lib/service/workers/legacy_notifier_builder.ex +++ b/lib/service/workers/legacy_notifier_builder.ex @@ -22,6 +22,13 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity) end + if args["subject"] == "conversation_created" do + notify_anonymous_participants( + get_in(args, ["subject_params", "conversation_event_id"]), + activity + ) + end + args |> users_to_notify(author_id: args["author_id"], group_id: Map.get(args, "group_id")) |> Enum.each(¬ify_user(&1, activity)) diff --git a/lib/web/email/activity.ex b/lib/web/email/activity.ex index 4f3f1a9f1..686360e0f 100644 --- a/lib/web/email/activity.ex +++ b/lib/web/email/activity.ex @@ -10,6 +10,7 @@ defmodule Mobilizon.Web.Email.Activity do alias Mobilizon.Actors.Actor alias Mobilizon.Config alias Mobilizon.Web.Email + require Logger @spec direct_activity(String.t(), list(), Keyword.t()) :: Swoosh.Email.t() def direct_activity( @@ -39,6 +40,36 @@ defmodule Mobilizon.Web.Email.Activity do end @spec anonymous_activity(String.t(), Activity.t(), Keyword.t()) :: Swoosh.Email.t() + def anonymous_activity( + email, + %Activity{subject_params: subject_params, type: :conversation} = activity, + options + ) do + locale = Keyword.get(options, :locale, "en") + + subject = + dgettext( + "activity", + "Informations about your event %{event}", + event: subject_params["conversation_event_title"] + ) + + conversation = Mobilizon.Conversations.get_conversation(activity.object_id) + + Logger.debug("Going to send anonymous activity of type #{activity.type} to #{email}") + + [to: email, subject: subject] + |> Email.base_email() + |> render_body(:email_anonymous_activity, %{ + subject: subject, + activity: activity, + locale: locale, + extra: %{ + "conversation" => conversation + } + }) + end + def anonymous_activity(email, %Activity{subject_params: subject_params} = activity, options) do locale = Keyword.get(options, :locale, "en") @@ -49,6 +80,8 @@ defmodule Mobilizon.Web.Email.Activity do event: subject_params["event_title"] ) + Logger.debug("Going to send anonymous activity of type #{activity.type} to #{email}") + [to: email, subject: subject] |> Email.base_email() |> render_body(:email_anonymous_activity, %{ diff --git a/lib/web/templates/email/email_anonymous_activity.html.heex b/lib/web/templates/email/email_anonymous_activity.html.heex index 4e0d9421e..b08a0652f 100644 --- a/lib/web/templates/email/email_anonymous_activity.html.heex +++ b/lib/web/templates/email/email_anonymous_activity.html.heex @@ -35,61 +35,164 @@ <tr> <td align="center" valign="top" width="600"> <![endif]--> - <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;"> - <!-- COPY --> - <tr> - <td bgcolor="#ffffff" align="left"> - <table width="100%" border="0" cellspacing="0" cellpadding="0"> - <tr> - <td - align="center" - style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;" - > - <%= dgettext( - "activity", - "%{profile} has posted an announcement under event %{event}.", - %{ - profile: "<b>#{escape_html(display_name_and_username(@activity.author))}</b>", - event: - "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, - :event, - @activity.subject_params["event_uuid"]) |> URI.decode()}\"> + <%= case @activity.type do %> + <% :comment -> %> + <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;"> + <!-- COPY --> + <tr> + <td bgcolor="#ffffff" align="left"> + <table width="100%" border="0" cellspacing="0" cellpadding="0"> + <tr> + <td + align="center" + style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;" + > + <%= dgettext( + "activity", + "%{profile} has posted a public announcement under event %{event}.", + %{ + profile: + "<b>#{escape_html(display_name_and_username(@activity.author))}</b>", + event: + "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, + :event, + @activity.subject_params["event_uuid"]) |> URI.decode()}\"> #{escape_html(@activity.subject_params["event_title"])} </a>" - } - ) - |> raw %> - </td> - </tr> - </table> - </td> - </tr> - <tr> - <td bgcolor="#ffffff" align="left"> - <table width="100%" border="0" cellspacing="0" cellpadding="0"> - <tr> - <td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;"> - <table border="0" cellspacing="0" cellpadding="0"> - <tr> - <td align="center" style="border-radius: 3px;" bgcolor="#3C376E"> - <a - href={ + } + ) + |> raw %> + </td> + </tr> + </table> + </td> + </tr> + <tr> + <td bgcolor="#ffffff" align="left"> + <table width="100%" border="0" cellspacing="0" cellpadding="0"> + <tr> + <td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;"> + <table border="0" cellspacing="0" cellpadding="0"> + <tr> + <td align="center" style="border-radius: 3px;" bgcolor="#3C376E"> + <a + href={ "#{Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"])}" } - target="_blank" - style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;" - > - <%= gettext("Visit event page") %> - </a> - </td> - </tr> - </table> - </td> - </tr> - </table> - </td> - </tr> - </table> + target="_blank" + style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;" + > + <%= gettext("Visit event page") %> + </a> + </td> + </tr> + </table> + </td> + </tr> + </table> + </td> + </tr> + </table> + <% :conversation -> %> + <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;"> + <!-- COPY --> + <tr> + <td bgcolor="#ffffff" align="left"> + <table width="100%" border="0" cellspacing="0" cellpadding="0"> + <tr> + <td + align="center" + style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;" + > + <%= dgettext( + "activity", + "%{profile} has posted a private announcement about event %{event}.", + %{ + profile: + "<b>#{escape_html(display_name_and_username(@activity.author))}</b>", + event: + "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, + :event, + @activity.subject_params["conversation_event_uuid"]) |> URI.decode()}\"> + #{escape_html(@activity.subject_params["conversation_event_title"])} + </a>" + } + ) + |> raw %> + <%= dgettext( + "activity", + "It might give details on how to join the event, so make sure to read it appropriately." + ) %> + </td> + </tr> + </table> + </td> + </tr> + <tr> + <td bgcolor="#ffffff" align="left"> + <table width="100%" border="0" cellspacing="0" cellpadding="0"> + <tr> + <td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;"> + <table border="0" cellspacing="0" cellpadding="0"> + <tr> + <td align="center"> + <blockquote style="border-left-width: 0.25rem;border-left-color: #e2e8f0;border-left-style: solid;padding-left: 1em;margin: 0;text-align: start;color: #474467;font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;"> + <%= @extra["conversation"].last_comment.text + |> sanitize_to_basic_html() + |> raw() %> + </blockquote> + </td> + </tr> + </table> + </td> + </tr> + </table> + </td> + </tr> + <tr> + <td bgcolor="#ffffff" align="left"> + <table width="100%" border="0" cellspacing="0" cellpadding="0"> + <tr> + <td + align="center" + style="border-radius: 3px; text-align: left; padding: 10px 5% 0px 30px; color: #474467; font-family: 'Roboto', Helvetica, Arial, sans-serif; font-size: 16px; font-weight: 400;line-height: 25px;" + > + <%= dgettext( + "activity", + "This information is sent privately to you as a person who registered for this event. Share the informations above with other people with caution." + ) %> + </td> + </tr> + </table> + </td> + </tr> + <tr> + <td bgcolor="#ffffff" align="left"> + <table width="100%" border="0" cellspacing="0" cellpadding="0"> + <tr> + <td bgcolor="#ffffff" align="center" style="padding: 20px 30px 60px 30px;"> + <table border="0" cellspacing="0" cellpadding="0"> + <tr> + <td align="center" style="border-radius: 3px;" bgcolor="#3C376E"> + <a + href={ + "#{Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["conversation_event_uuid"])}" + } + target="_blank" + style="font-size: 20px; font-family: Helvetica, Arial, sans-serif; color: #ffffff; text-decoration: none; padding: 15px 25px; border-radius: 2px; border: 1px solid #3C376E; display: inline-block;" + > + <%= gettext("Visit event page") %> + </a> + </td> + </tr> + </table> + </td> + </tr> + </table> + </td> + </tr> + </table> + <% end %> <!--[if (gte mso 9)|(IE)]> </td> </tr> diff --git a/lib/web/templates/email/email_anonymous_activity.text.eex b/lib/web/templates/email/email_anonymous_activity.text.eex index c19b43834..106d81bd4 100644 --- a/lib/web/templates/email/email_anonymous_activity.text.eex +++ b/lib/web/templates/email/email_anonymous_activity.text.eex @@ -1,11 +1,30 @@ <%= @subject %> == - -<%= dgettext("activity", "%{profile} has posted an announcement under event %{event}.", +<%= case @activity.type do %> +<% :comment -> %> +<%= dgettext("activity", "%{profile} has posted a public announcement under event %{event}.", %{ profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), event: @activity.subject_params["event_title"] } ) %> -<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %> \ No newline at end of file +<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %> +<% :conversation -> %> +<%= dgettext("activity", "%{profile} has posted a private announcement about event %{event}.", + %{ + profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), + event: @activity.subject_params["conversation_event_title"] + } +) %> +<%= dgettext("activity", "It might give details on how to join the event, so make sure to read it appropriately.") %> + +-- + +<%= @extra["conversation"].last_comment.text |> html_to_text() |> mail_quote() %> + +-- + +<%= dgettext("activity", "This information is sent privately to you as a person who registered for this event. Share the informations above with other people with caution.") %> +<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["conversation_event_uuid"]) |> URI.decode() %> +<% end %> \ No newline at end of file diff --git a/lib/web/views/email_view.ex b/lib/web/views/email_view.ex index 82b0e74d9..c24aa87f7 100644 --- a/lib/web/views/email_view.ex +++ b/lib/web/views/email_view.ex @@ -7,6 +7,7 @@ defmodule Mobilizon.Web.EmailView do alias Mobilizon.Actors.Actor alias Mobilizon.Service.Address alias Mobilizon.Service.DateTime, as: DateTimeRenderer + alias Mobilizon.Service.Formatter.{HTML, Text} alias Mobilizon.Web.Router.Helpers, as: Routes import Mobilizon.Web.Gettext import Mobilizon.Service.Metadata.Utils, only: [process_description: 1] @@ -35,6 +36,21 @@ defmodule Mobilizon.Web.EmailView do |> safe_to_string() end + @spec sanitize_to_basic_html(String.t()) :: String.t() + def sanitize_to_basic_html(html) do + case HTML.basic_html(html) do + {:ok, html} -> html + _ -> "" + end + end + + defdelegate html_to_text(html), to: HTML + + def mail_quote(text) do + # https://www.emailonacid.com/blog/article/email-development/line-length-in-html-email/ + Text.quote_paragraph(text, 78) + end + def escaped_display_name_and_username(actor) do actor |> display_name_and_username() diff --git a/src/assets/oruga-tailwindcss.css b/src/assets/oruga-tailwindcss.css index 2583e89fc..619c5af46 100644 --- a/src/assets/oruga-tailwindcss.css +++ b/src/assets/oruga-tailwindcss.css @@ -42,13 +42,13 @@ body { @apply bg-transparent text-black dark:text-white font-semibold py-2 px-4 border border-mbz-bluegreen dark:border-violet-3; } .btn-outlined-success { - @apply border-mbz-success hover:bg-mbz-success; + @apply border-2 border-mbz-success bg-transparent text-mbz-success hover:bg-mbz-success; } .btn-outlined-warning { @apply bg-transparent border dark:text-white hover:dark:text-slate-900 hover:bg-mbz-warning border-mbz-warning; } .btn-outlined-danger { - @apply border-mbz-danger hover:bg-mbz-danger; + @apply border-2 bg-transparent border-mbz-danger text-mbz-danger hover:bg-mbz-danger; } .btn-outlined-text { @apply bg-transparent hover:text-slate-900; @@ -161,15 +161,18 @@ body { } .dropdown-item-active { - @apply bg-white dark:bg-zinc-700 dark:text-zinc-100 text-black; + @apply bg-mbz-yellow-500 dark:bg-mbz-yellow-900 dark:text-zinc-100 text-black; } .dropdown-button { @apply inline-flex gap-1; } /* Checkbox */ - .checkbox { + margin-inline-end: 1rem; +} + +.checkbox-check { @apply appearance-none bg-primary border-primary; } diff --git a/src/components/Account/ActorAutoComplete.vue b/src/components/Account/ActorAutoComplete.vue index b95c37055..47da387aa 100644 --- a/src/components/Account/ActorAutoComplete.vue +++ b/src/components/Account/ActorAutoComplete.vue @@ -1,6 +1,6 @@ <template> <o-inputitems - :modelValue="modelValue" + :modelValue="modelValueWithDisplayName" @update:modelValue="(val: IActor[]) => $emit('update:modelValue', val)" :data="availableActors" :allow-autocomplete="true" @@ -21,10 +21,10 @@ import { SEARCH_PERSON_AND_GROUPS } from "@/graphql/search"; import { IActor, IGroup, IPerson, displayName } from "@/types/actor"; import { Paginate } from "@/types/paginate"; import { useLazyQuery } from "@vue/apollo-composable"; -import { ref } from "vue"; +import { computed, ref } from "vue"; import ActorInline from "./ActorInline.vue"; -defineProps<{ +const props = defineProps<{ modelValue: IActor[]; }>(); @@ -32,6 +32,15 @@ defineEmits<{ "update:modelValue": [value: IActor[]]; }>(); +const modelValue = computed(() => props.modelValue); + +const modelValueWithDisplayName = computed(() => + modelValue.value.map((actor) => ({ + ...actor, + displayName: displayName(actor), + })) +); + const { load: loadSearchPersonsAndGroupsQuery, refetch: refetchSearchPersonsAndGroupsQuery, diff --git a/src/components/Account/ActorCard.vue b/src/components/Account/ActorCard.vue index c4d49710d..2124b746d 100644 --- a/src/components/Account/ActorCard.vue +++ b/src/components/Account/ActorCard.vue @@ -39,8 +39,18 @@ v-html="actor.summary" /> </div> - <div class="flex pr-2"> - <Email /> + <div class="flex pr-2" v-if="actor.type === ActorType.PERSON"> + <router-link + :to="{ + name: RouteName.CONVERSATION_LIST, + query: { + newMessage: 'true', + personMentions: usernameWithDomain(actor), + }, + }" + > + <Email /> + </router-link> </div> </div> <!-- <div @@ -85,6 +95,8 @@ import { displayName, IActor, usernameWithDomain } from "../../types/actor"; import AccountCircle from "vue-material-design-icons/AccountCircle.vue"; import Email from "vue-material-design-icons/Email.vue"; +import RouteName from "@/router/name"; +import { ActorType } from "@/types/enums"; withDefaults( defineProps<{ diff --git a/src/components/Account/ActorInline.vue b/src/components/Account/ActorInline.vue index b99a7d381..54e518b87 100644 --- a/src/components/Account/ActorInline.vue +++ b/src/components/Account/ActorInline.vue @@ -24,15 +24,11 @@ @{{ usernameWithDomain(actor) }} </p> </div> - <div class="flex pr-2 self-center"> - <Email /> - </div> </div> </template> <script lang="ts" setup> import { displayName, IActor, usernameWithDomain } from "../../types/actor"; import AccountCircle from "vue-material-design-icons/AccountCircle.vue"; -import Email from "vue-material-design-icons/Email.vue"; defineProps<{ actor: IActor; diff --git a/src/components/Conversations/ConversationListItem.vue b/src/components/Conversations/ConversationListItem.vue index cdc4a8110..8ac1913b1 100644 --- a/src/components/Conversations/ConversationListItem.vue +++ b/src/components/Conversations/ConversationListItem.vue @@ -1,6 +1,6 @@ <template> <router-link - class="flex gap-2 w-full items-center px-2 py-4 border-b-stone-200 border-b bg-white dark:bg-transparent" + class="flex gap-4 w-full items-center px-2 py-4 border-b-stone-200 border-b bg-white dark:bg-transparent" dir="auto" :to="{ name: RouteName.CONVERSATION, diff --git a/src/components/Conversations/NewConversation.vue b/src/components/Conversations/NewConversation.vue index 4895dea28..9e800cd94 100644 --- a/src/components/Conversations/NewConversation.vue +++ b/src/components/Conversations/NewConversation.vue @@ -9,6 +9,15 @@ :currentActor="currentActor" :placeholder="t('Write a new message')" /> + <o-notification + class="my-2" + variant="danger" + :closable="false" + v-for="error in errors" + :key="error" + > + {{ error }} + </o-notification> <footer class="flex gap-2 py-3 mx-2 justify-end"> <o-button :disabled="!canSend" nativeType="submit">{{ t("Send") @@ -18,13 +27,14 @@ </template> <script lang="ts" setup> -import { IActor, IPerson, usernameWithDomain } from "@/types/actor"; +import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor"; import { computed, defineAsyncComponent, provide, ref } from "vue"; import { useI18n } from "vue-i18n"; import ActorAutoComplete from "../../components/Account/ActorAutoComplete.vue"; import { DefaultApolloClient, provideApolloClient, + useLazyQuery, useMutation, } from "@vue/apollo-composable"; import { apolloClient } from "@/vue-apollo"; @@ -34,12 +44,15 @@ import { IConversation } from "@/types/conversation"; import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useRouter } from "vue-router"; import RouteName from "@/router/name"; +import { FETCH_PERSON } from "@/graphql/actor"; +import { FETCH_GROUP_PUBLIC } from "@/graphql/group"; const props = withDefaults( defineProps<{ - mentions?: IActor[]; + personMentions?: string[]; + groupMentions?: string[]; }>(), - { mentions: () => [] } + { personMentions: () => [], groupMentions: () => [] } ); provide(DefaultApolloClient, apolloClient); @@ -48,15 +61,36 @@ const router = useRouter(); const emit = defineEmits(["close"]); -const actorMentions = ref(props.mentions); +const errors = ref<string[]>([]); -const textMentions = computed(() => - (props.mentions ?? []).map((actor) => usernameWithDomain(actor)).join(" ") +const textPersonMentions = computed(() => props.personMentions); +const textGroupMentions = computed(() => props.groupMentions); +const actorMentions = ref<IActor[]>([]); + +const { load: fetchPerson } = provideApolloClient(apolloClient)(() => + useLazyQuery<{ fetchPerson: IPerson }, { username: string }>(FETCH_PERSON) ); +const { load: fetchGroup } = provideApolloClient(apolloClient)(() => + useLazyQuery<{ group: IGroup }, { name: string }>(FETCH_GROUP_PUBLIC) +); +textPersonMentions.value.forEach(async (textPersonMention) => { + const result = await fetchPerson(FETCH_PERSON, { + username: textPersonMention, + }); + if (!result) return; + actorMentions.value.push(result.fetchPerson); +}); +textGroupMentions.value.forEach(async (textGroupMention) => { + const result = await fetchGroup(FETCH_GROUP_PUBLIC, { + name: textGroupMention, + }); + if (!result) return; + actorMentions.value.push(result.group); +}); const { t } = useI18n({ useScope: "global" }); -const text = ref(textMentions.value); +const text = ref(""); const Editor = defineAsyncComponent( () => import("../../components/TextEditor.vue") @@ -70,8 +104,8 @@ const canSend = computed(() => { return actorMentions.value.length > 0 || /@.+/.test(text.value); }); -const { mutate: postPrivateMessageMutate } = provideApolloClient(apolloClient)( - () => +const { mutate: postPrivateMessageMutate, onError: onPrivateMessageError } = + provideApolloClient(apolloClient)(() => useMutation< { postPrivateMessage: IConversation; @@ -116,7 +150,13 @@ const { mutate: postPrivateMessageMutate } = provideApolloClient(apolloClient)( }); }, }) -); + ); + +onPrivateMessageError((err) => { + err.graphQLErrors.forEach((error) => { + errors.value.push(error.message); + }); +}); const sendForm = async (e: Event) => { e.preventDefault(); diff --git a/src/components/Participation/NewPrivateMessage.vue b/src/components/Participation/NewPrivateMessage.vue index 9bfb7e001..ccc3225bc 100644 --- a/src/components/Participation/NewPrivateMessage.vue +++ b/src/components/Participation/NewPrivateMessage.vue @@ -1,5 +1,30 @@ <template> <form @submit="sendForm"> + <h2>{{ t("New announcement") }}</h2> + <p> + {{ + t( + "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you." + ) + }} + </p> + <o-field class="mt-2 mb-4"> + <o-checkbox + v-model="selectedRoles" + :native-value="ParticipantRole.PARTICIPANT" + :label="t('Participant')" + /> + <o-checkbox + v-model="selectedRoles" + :native-value="ParticipantRole.NOT_APPROVED" + :label="t('Not approved')" + /> + <o-checkbox + v-model="selectedRoles" + :native-value="ParticipantRole.REJECTED" + :label="t('Rejected')" + /> + </o-field> <Editor v-model="text" mode="basic" @@ -8,6 +33,15 @@ :currentActor="currentActor" :placeholder="t('Write a new message')" /> + <o-notification + class="my-2" + variant="danger" + :closable="true" + v-for="error in errors" + :key="error" + > + {{ error }} + </o-notification> <o-button class="mt-3" nativeType="submit">{{ t("Send") }}</o-button> </form> </template> @@ -32,9 +66,15 @@ const props = defineProps<{ const event = computed(() => props.event); const text = ref(""); + +const errors = ref<string[]>([]); + +const selectedRoles = ref<ParticipantRole[]>([ParticipantRole.PARTICIPANT]); + const { mutate: eventPrivateMessageMutate, onDone: onEventPrivateMessageMutated, + onError: onEventPrivateMessageError, } = useMutation< { sendEventPrivateMessage: IConversation; @@ -43,8 +83,7 @@ const { text: string; actorId: string; eventId: string; - roles?: string; - inReplyToActorId?: ParticipantRole[]; + roles?: ParticipantRole[]; language?: string; } >(SEND_EVENT_PRIVATE_MESSAGE_MUTATION, { @@ -96,6 +135,7 @@ const sendForm = (e: Event) => { event.value.organizerActor?.id ?? currentActor.value?.id, eventId: event.value.id, + roles: selectedRoles.value, }); }; @@ -103,6 +143,12 @@ onEventPrivateMessageMutated(() => { text.value = ""; }); +onEventPrivateMessageError((err) => { + err.graphQLErrors.forEach((error) => { + errors.value.push(error.message); + }); +}); + const Editor = defineAsyncComponent( () => import("../../components/TextEditor.vue") ); diff --git a/src/components/Participation/UnloggedParticipation.vue b/src/components/Participation/UnloggedParticipation.vue index b73cf14ee..8be6313a6 100644 --- a/src/components/Participation/UnloggedParticipation.vue +++ b/src/components/Participation/UnloggedParticipation.vue @@ -9,7 +9,7 @@ <router-link :to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT }"> <figure class="flex justify-center my-2"> <img - src="../../../public/img/undraw_profile.svg" + src="/img/undraw_profile.svg" alt="Profile illustration" width="128" height="128" @@ -55,7 +55,7 @@ <img width="128" height="128" - src="../../../public/img/undraw_mail_2.svg" + src="/img/undraw_mail_2.svg" alt="Privacy illustration" /> </figure> @@ -66,7 +66,7 @@ <a :href="`${event.url}/participate/without-account`" v-else> <figure class="flex justify-center my-2"> <img - src="../../../public/img/undraw_mail_2.svg" + src="/img/undraw_mail_2.svg" width="128" height="128" alt="Privacy illustration" diff --git a/src/graphql/conversations.ts b/src/graphql/conversations.ts index 92069d81d..32af7f823 100644 --- a/src/graphql/conversations.ts +++ b/src/graphql/conversations.ts @@ -54,7 +54,6 @@ export const SEND_EVENT_PRIVATE_MESSAGE_MUTATION = gql` $actorId: ID! $eventId: ID! $roles: [ParticipantRoleEnum] - $attributedToId: ID $language: String ) { sendEventPrivateMessage( @@ -62,7 +61,6 @@ export const SEND_EVENT_PRIVATE_MESSAGE_MUTATION = gql` actorId: $actorId eventId: $eventId roles: $roles - attributedToId: $attributedToId language: $language ) { ...ConversationQuery diff --git a/src/i18n/en_US.json b/src/i18n/en_US.json index b9d5ae6f8..781aacae4 100644 --- a/src/i18n/en_US.json +++ b/src/i18n/en_US.json @@ -1625,5 +1625,8 @@ "You have access to this conversation as a member of the {group} group": "You have access to this conversation as a member of the {group} group", "Comment from an event announcement": "Comment from an event announcement", "Comment from a private conversation": "Comment from a private conversation", - "I've been mentionned in a conversation": "I've been mentionned in a conversation" + "I've been mentionned in a conversation": "I've been mentionned in a conversation", + "New announcement": "New announcement", + "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.": "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.", + "The following participants are groups, which means group members are able to reply to this conversation:": "The following participants are groups, which means group members are able to reply to this conversation:" } \ No newline at end of file diff --git a/src/i18n/fr_FR.json b/src/i18n/fr_FR.json index 897d13370..028e70b0d 100644 --- a/src/i18n/fr_FR.json +++ b/src/i18n/fr_FR.json @@ -1621,5 +1621,8 @@ "You have access to this conversation as a member of the {group} group": "Vous avez accès à cette conversation en tant que membre du groupe {group}", "Comment from an event announcement": "Commentaire d'une annonce d'événement", "Comment from a private conversation": "Commentaire d'une conversation privée", - "I've been mentionned in a conversation": "J'ai été mentionnée dans une conversation" + "I've been mentionned in a conversation": "J'ai été mentionnée dans une conversation", + "New announcement": "Nouvelle annonce", + "This announcement will be send to all participants with the statuses selected below. They will not be allowed to reply to your announcement, but they can create a new conversation with you.": "Cette annonce sera envoyée à tous les participant·es ayant le statut sélectionné ci-dessous. Iels ne pourront pas répondre à votre annonce, mais iels peuvent créer une nouvelle conversation avec vous.", + "The following participants are groups, which means group members are able to reply to this conversation:": "Les participants suivants sont des groupes, ce qui signifie que les membres du groupes peuvent répondre dans cette conversation:" } diff --git a/src/oruga-config.ts b/src/oruga-config.ts index 165c37a39..38db635ec 100644 --- a/src/oruga-config.ts +++ b/src/oruga-config.ts @@ -35,7 +35,8 @@ export const orugaConfig = { variantClass: "icon-", }, checkbox: { - checkClass: "checkbox", + rootClass: "checkbox", + checkClass: "checkbox-check", checkCheckedClass: "checkbox-checked", labelClass: "checkbox-label", }, diff --git a/src/utils/route.ts b/src/utils/route.ts new file mode 100644 index 000000000..2c3f332b2 --- /dev/null +++ b/src/utils/route.ts @@ -0,0 +1,10 @@ +import { RouteQueryTransformer } from "vue-use-route-query"; + +export const arrayTransformer: RouteQueryTransformer<string[]> = { + fromQuery(query: string) { + return query.split(","); + }, + toQuery(value: string[]) { + return value.join(","); + }, +}; diff --git a/src/views/Admin/InstancesView.vue b/src/views/Admin/InstancesView.vue index a62f09cea..3dad71a07 100644 --- a/src/views/Admin/InstancesView.vue +++ b/src/views/Admin/InstancesView.vue @@ -80,7 +80,7 @@ <img class="w-12" v-if="instance.hasRelay" - src="../../../public/img/logo.svg" + src="/img/logo.svg" alt="" /> <CloudQuestion v-else :size="36" /> diff --git a/src/views/Conversations/ConversationListView.vue b/src/views/Conversations/ConversationListView.vue index 14c702dad..f903d147f 100644 --- a/src/views/Conversations/ConversationListView.vue +++ b/src/views/Conversations/ConversationListView.vue @@ -44,19 +44,28 @@ <script lang="ts" setup> import RouteName from "../../router/name"; import { useQuery } from "@vue/apollo-composable"; -import { computed, defineAsyncComponent, ref } from "vue"; +import { computed, defineAsyncComponent, ref, watchEffect } from "vue"; import { useI18n } from "vue-i18n"; -import { integerTransformer, useRouteQuery } from "vue-use-route-query"; +import { + booleanTransformer, + integerTransformer, + useRouteQuery, +} from "vue-use-route-query"; import { PROFILE_CONVERSATIONS } from "@/graphql/event"; import ConversationListItem from "../../components/Conversations/ConversationListItem.vue"; import EmptyContent from "../../components/Utils/EmptyContent.vue"; import { useHead } from "@vueuse/head"; import { IPerson } from "@/types/actor"; import { useProgrammatic } from "@oruga-ui/oruga-next"; +import { arrayTransformer } from "@/utils/route"; const page = useRouteQuery("page", 1, integerTransformer); const CONVERSATIONS_PER_PAGE = 10; +const showModal = useRouteQuery("newMessage", false, booleanTransformer); +const personMentions = useRouteQuery("personMentions", [], arrayTransformer); +const groupMentions = useRouteQuery("groupMentions", [], arrayTransformer); + const { t } = useI18n({ useScope: "global" }); useHead({ @@ -69,6 +78,7 @@ const { result: conversationsResult } = useQuery<{ loggedPerson: Pick<IPerson, "conversations">; }>(PROFILE_CONVERSATIONS, () => ({ page: page.value, + limit: CONVERSATIONS_PER_PAGE, })); const conversations = computed( @@ -88,7 +98,17 @@ const NewConversation = defineAsyncComponent( const openNewMessageModal = () => { oruga.modal.open({ component: NewConversation, + props: { + personMentions: personMentions.value, + groupMentions: groupMentions.value, + }, trapFocus: true, }); }; + +watchEffect(() => { + if (showModal.value) { + openNewMessageModal(); + } +}); </script> diff --git a/src/views/Conversations/ConversationView.vue b/src/views/Conversations/ConversationView.vue index d568f0b82..f92d53640 100644 --- a/src/views/Conversations/ConversationView.vue +++ b/src/views/Conversations/ConversationView.vue @@ -14,13 +14,13 @@ ]" /> <div - v-if="conversation.event" - class="bg-mbz-yellow p-6 mb-6 rounded flex gap-2 items-center" + v-if="conversation.event && !isCurrentActorAuthor" + class="bg-mbz-yellow p-6 mb-3 rounded flex gap-2 items-center" > <Calendar :size="36" /> <i18n-t tag="p" - keypath="This is a announcement from the organizers of event {event}" + keypath="This is a announcement from the organizers of event {event}. You can't reply to it, but you can send a private message to event organizers." > <template #event> <b> @@ -35,10 +35,7 @@ </template> </i18n-t> </div> - <div - v-if="currentActor && currentActor.id !== conversation.actor?.id" - class="bg-mbz-info p-6 rounded flex gap-2 items-center my-3" - > + <o-notification v-if="isCurrentActorAuthor" variant="info" closable> <i18n-t keypath="You have access to this conversation as a member of the {group} group" tag="p" @@ -55,7 +52,36 @@ > </template> </i18n-t> - </div> + </o-notification> + <o-notification + v-else-if="groupParticipants.length > 0 && !conversation.event" + variant="info" + closable + > + <p> + {{ + t( + "The following participants are groups, which means group members are able to reply to this conversation:" + ) + }} + </p> + <ul class="list-disc"> + <li + v-for="groupParticipant in groupParticipants" + :key="groupParticipant.id" + > + <router-link + :to="{ + name: RouteName.GROUP, + params: { + preferredUsername: usernameWithDomain(groupParticipant), + }, + }" + ><b>{{ displayName(groupParticipant) }}</b></router-link + > + </li> + </ul> + </o-notification> <o-notification v-if="error" variant="danger"> {{ error }} </o-notification> @@ -107,7 +133,7 @@ </form> <div v-else-if="conversation.event" - class="bg-mbz-yellow p-6 rounded flex gap-2 items-center mt-6" + class="bg-mbz-yellow p-6 rounded flex gap-2 items-center mt-3" > <Calendar :size="36" /> <i18n-t @@ -239,6 +265,12 @@ const otherParticipants = computed( ) ?? [] ); +const groupParticipants = computed(() => { + return otherParticipants.value.filter( + (participant) => participant.type === ActorType.GROUP + ); +}); + const Editor = defineAsyncComponent( () => import("../../components/TextEditor.vue") ); @@ -253,8 +285,15 @@ const title = computed(() => }) ); +const isCurrentActorAuthor = computed( + () => + currentActor.value && + conversation.value && + currentActor.value.id !== conversation.value?.actor?.id +); + useHead({ - title: title.value, + title: () => title.value, }); const newComment = ref(""); diff --git a/src/views/Event/EditView.vue b/src/views/Event/EditView.vue index a83fb61b3..c18eba759 100644 --- a/src/views/Event/EditView.vue +++ b/src/views/Event/EditView.vue @@ -650,12 +650,6 @@ const FullAddressAutoComplete = defineAsyncComponent( const { t } = useI18n({ useScope: "global" }); -useHead({ - title: computed(() => - props.isUpdate ? t("Event edition") : t("Event creation") - ), -}); - const props = withDefaults( defineProps<{ eventId?: undefined | string; @@ -667,6 +661,12 @@ const props = withDefaults( const eventId = computed(() => props.eventId); +useHead({ + title: computed(() => + props.isUpdate ? t("Event edition") : t("Event creation") + ), +}); + const event = ref<IEditableEvent>(new EventModel()); const unmodifiedEvent = ref<IEditableEvent>(new EventModel()); diff --git a/src/views/Event/ParticipantsView.vue b/src/views/Event/ParticipantsView.vue index 50447c2cb..4630b581f 100644 --- a/src/views/Event/ParticipantsView.vue +++ b/src/views/Event/ParticipantsView.vue @@ -225,6 +225,7 @@ @click="acceptParticipants(checkedRows)" variant="success" :disabled="!canAcceptParticipants" + outlined > {{ t( @@ -238,6 +239,7 @@ @click="refuseParticipants(checkedRows)" variant="danger" :disabled="!canRefuseParticipants" + outlined > {{ t( diff --git a/src/views/Group/GroupView.vue b/src/views/Group/GroupView.vue index e9532c49f..1ccc52e5e 100644 --- a/src/views/Group/GroupView.vue +++ b/src/views/Group/GroupView.vue @@ -266,6 +266,21 @@ : t("Deactivate notifications") }}</span> </o-button> + <o-button + outlined + tag="router-link" + :to="{ + name: RouteName.CONVERSATION_LIST, + query: { + newMessage: 'true', + groupMentions: usernameWithDomain(group), + }, + }" + icon-left="email" + v-if="!isCurrentActorAGroupMember || previewPublic" + > + {{ t("Contact") }} + </o-button> <o-button outlined icon-left="share" diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index e1f435275..15cacd6f3 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -5,19 +5,19 @@ <div class="-z-10 overflow-hidden"> <img alt="" - src="../../public/img/shape-1.svg" + src="/img/shape-1.svg" class="-z-10 absolute left-[2%] top-36" width="300" /> <img alt="" - src="../../public/img/shape-2.svg" + src="/img/shape-2.svg" class="-z-10 absolute left-[50%] top-[5%] -translate-x-2/4 opacity-60" width="800" /> <img alt="" - src="../../public/img/shape-3.svg" + src="/img/shape-3.svg" class="-z-10 absolute top-0 right-36" width="200" /> diff --git a/src/views/SearchView.vue b/src/views/SearchView.vue index 46faf5bf3..d1d2cc385 100644 --- a/src/views/SearchView.vue +++ b/src/views/SearchView.vue @@ -749,7 +749,6 @@ import { useRouteQuery, enumTransformer, booleanTransformer, - RouteQueryTransformer, } from "vue-use-route-query"; import Calendar from "vue-material-design-icons/Calendar.vue"; import AccountMultiple from "vue-material-design-icons/AccountMultiple.vue"; @@ -776,6 +775,7 @@ import lodashSortBy from "lodash/sortBy"; import EmptyContent from "@/components/Utils/EmptyContent.vue"; import SkeletonGroupResultList from "@/components/Group/SkeletonGroupResultList.vue"; import SkeletonEventResultList from "@/components/Event/SkeletonEventResultList.vue"; +import { arrayTransformer } from "@/utils/route"; const EventMarkerMap = defineAsyncComponent( () => import("@/components/Search/EventMarkerMap.vue") @@ -840,15 +840,6 @@ enum SortValues { MEMBER_COUNT_DESC = "MEMBER_COUNT_DESC", } -const arrayTransformer: RouteQueryTransformer<string[]> = { - fromQuery(query: string) { - return query.split(","); - }, - toQuery(value: string[]) { - return value.join(","); - }, -}; - const props = defineProps<{ tag?: string; }>();