fix: various fixes

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2023-11-20 09:35:21 +01:00
parent 3c288c5858
commit b635937091
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
33 changed files with 579 additions and 129 deletions

View File

@ -147,6 +147,12 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Conversations do
(args |> Map.get(:mentions, []) |> prepare_mentions()) ++ (args |> Map.get(:mentions, []) |> prepare_mentions()) ++
ConverterUtils.fetch_mentions(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 if Enum.empty?(mentions) do
{:error, :empty_participants} {:error, :empty_participants}
else else

View File

@ -11,8 +11,8 @@ defmodule Mobilizon.GraphQL.Resolvers.Conversation do
alias Mobilizon.Storage.Page alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
# alias Mobilizon.Users.User
import Mobilizon.Web.Gettext, only: [dgettext: 2] import Mobilizon.Web.Gettext, only: [dgettext: 2]
require Logger
@spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) :: @spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) ::
{:ok, Page.t(ConversationView.t())} | {:error, :unauthenticated} {: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)} {:ok, conversation_to_view(conversation, conversation_participant_actor)}
{:error, :empty_participants} -> {: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 end
else else
Logger.debug(
"Actor #{current_actor.id} is not authorized to reply to conversation #{inspect(Map.get(args, :conversation_id))}"
)
{:error, :unauthorized} {:error, :unauthorized}
end end
end end
@ -259,7 +267,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Conversation do
%Conversation{participants: participants} -> %Conversation{participants: participants} ->
participant_ids = Enum.map(participants, fn participant -> to_string(participant.id) end) 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 -> Enum.any?(participant_ids, fn participant_id ->
Actors.is_member?(current_actor_id, participant_id) and Actors.is_member?(current_actor_id, participant_id) and
attributed_to_id == participant_id attributed_to_id == participant_id

View File

@ -2,8 +2,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
@moduledoc """ @moduledoc """
Handles the participation-related GraphQL calls. Handles the participation-related GraphQL calls.
""" """
# alias Mobilizon.Conversations.ConversationParticipant alias Mobilizon.{Actors, Config, Conversations, Crypto, Events}
alias Mobilizon.{Actors, Config, Crypto, Events}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Conversations.{Conversation, ConversationView} alias Mobilizon.Conversations.{Conversation, ConversationView}
alias Mobilizon.Events.{Event, Participant} alias Mobilizon.Events.{Event, Participant}
@ -386,6 +385,13 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
{:member, false} -> {:member, false} ->
{:error, :unauthorized} {:error, :unauthorized}
{:error, :empty_participants} ->
{:error,
dgettext(
"errors",
"There are no participants matching the audience you've selected."
)}
{:error, err} -> {:error, err} ->
{:error, err} {:error, err}
end end
@ -394,11 +400,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
def send_private_messages_to_participants(_parent, _args, _resolution), def send_private_messages_to_participants(_parent, _args, _resolution),
do: {:error, :unauthorized} 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 = value =
conversation conversation
|> Map.from_struct() |> Map.from_struct()
|> Map.put(:actor, actor) |> 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) struct(ConversationView, value)
end end

View File

@ -77,7 +77,7 @@ defmodule Mobilizon.Discussions.Comment do
belongs_to(:conversation, Conversation) belongs_to(:conversation, Conversation)
has_many(:replies, Comment, foreign_key: :origin_comment_id) has_many(:replies, Comment, foreign_key: :origin_comment_id)
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete) 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) many_to_many(:media, Media, join_through: "comments_medias", on_replace: :delete)
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)

View File

@ -79,11 +79,16 @@ defmodule Mobilizon.Service.Activity.Conversation do
defp send_participant_notifications(_, _, _, _), do: {:ok, :skipped} defp send_participant_notifications(_, _, _, _), do: {:ok, :skipped}
defp event_subject_params(%Conversation{ 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: %{ do: %{
conversation_event_id: conversation_event_id, 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: %{} defp event_subject_params(_), do: %{}

View File

@ -14,6 +14,8 @@ defmodule Mobilizon.Service.Formatter.HTML do
def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler) def filter_tags(html), do: Sanitizer.scrub(html, DefaultScrubbler)
defdelegate basic_html(html), to: FastSanitize
@spec strip_tags(String.t()) :: String.t() | no_return() @spec strip_tags(String.t()) :: String.t() | no_return()
def strip_tags(html) do def strip_tags(html) do
case FastSanitize.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 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) def filter_tags_for_oembed(html), do: Sanitizer.scrub(html, OEmbed)
end end

View File

@ -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

View File

@ -22,6 +22,13 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity) notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity)
end end
if args["subject"] == "conversation_created" do
notify_anonymous_participants(
get_in(args, ["subject_params", "conversation_event_id"]),
activity
)
end
args args
|> users_to_notify(author_id: args["author_id"], group_id: Map.get(args, "group_id")) |> users_to_notify(author_id: args["author_id"], group_id: Map.get(args, "group_id"))
|> Enum.each(&notify_user(&1, activity)) |> Enum.each(&notify_user(&1, activity))

View File

@ -10,6 +10,7 @@ defmodule Mobilizon.Web.Email.Activity do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Config alias Mobilizon.Config
alias Mobilizon.Web.Email alias Mobilizon.Web.Email
require Logger
@spec direct_activity(String.t(), list(), Keyword.t()) :: Swoosh.Email.t() @spec direct_activity(String.t(), list(), Keyword.t()) :: Swoosh.Email.t()
def direct_activity( def direct_activity(
@ -39,6 +40,36 @@ defmodule Mobilizon.Web.Email.Activity do
end end
@spec anonymous_activity(String.t(), Activity.t(), Keyword.t()) :: Swoosh.Email.t() @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 def anonymous_activity(email, %Activity{subject_params: subject_params} = activity, options) do
locale = Keyword.get(options, :locale, "en") locale = Keyword.get(options, :locale, "en")
@ -49,6 +80,8 @@ defmodule Mobilizon.Web.Email.Activity do
event: subject_params["event_title"] event: subject_params["event_title"]
) )
Logger.debug("Going to send anonymous activity of type #{activity.type} to #{email}")
[to: email, subject: subject] [to: email, subject: subject]
|> Email.base_email() |> Email.base_email()
|> render_body(:email_anonymous_activity, %{ |> render_body(:email_anonymous_activity, %{

View File

@ -35,6 +35,8 @@
<tr> <tr>
<td align="center" valign="top" width="600"> <td align="center" valign="top" width="600">
<![endif]--> <![endif]-->
<%= case @activity.type do %>
<% :comment -> %>
<table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;"> <table border="0" cellpadding="0" cellspacing="0" width="100%" style="max-width: 600px;">
<!-- COPY --> <!-- COPY -->
<tr> <tr>
@ -47,9 +49,10 @@
> >
<%= dgettext( <%= dgettext(
"activity", "activity",
"%{profile} has posted an announcement under event %{event}.", "%{profile} has posted a public announcement under event %{event}.",
%{ %{
profile: "<b>#{escape_html(display_name_and_username(@activity.author))}</b>", profile:
"<b>#{escape_html(display_name_and_username(@activity.author))}</b>",
event: event:
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint, "<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
:event, :event,
@ -90,6 +93,106 @@
</td> </td>
</tr> </tr>
</table> </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)]> <!--[if (gte mso 9)|(IE)]>
</td> </td>
</tr> </tr>

View File

@ -1,11 +1,30 @@
<%= @subject %> <%= @subject %>
== ==
<%= case @activity.type do %>
<%= dgettext("activity", "%{profile} has posted an announcement under event %{event}.", <% :comment -> %>
<%= dgettext("activity", "%{profile} has posted a public announcement under event %{event}.",
%{ %{
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author), profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
event: @activity.subject_params["event_title"] event: @activity.subject_params["event_title"]
} }
) %> ) %>
<%= Routes.page_url(Mobilizon.Web.Endpoint, :event, @activity.subject_params["event_uuid"]) |> URI.decode() %> <%= 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 %>

View File

@ -7,6 +7,7 @@ defmodule Mobilizon.Web.EmailView do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Service.Address alias Mobilizon.Service.Address
alias Mobilizon.Service.DateTime, as: DateTimeRenderer alias Mobilizon.Service.DateTime, as: DateTimeRenderer
alias Mobilizon.Service.Formatter.{HTML, Text}
alias Mobilizon.Web.Router.Helpers, as: Routes alias Mobilizon.Web.Router.Helpers, as: Routes
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
import Mobilizon.Service.Metadata.Utils, only: [process_description: 1] import Mobilizon.Service.Metadata.Utils, only: [process_description: 1]
@ -35,6 +36,21 @@ defmodule Mobilizon.Web.EmailView do
|> safe_to_string() |> safe_to_string()
end 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 def escaped_display_name_and_username(actor) do
actor actor
|> display_name_and_username() |> display_name_and_username()

View File

@ -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; @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 { .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 { .btn-outlined-warning {
@apply bg-transparent border dark:text-white hover:dark:text-slate-900 hover:bg-mbz-warning border-mbz-warning; @apply bg-transparent border dark:text-white hover:dark:text-slate-900 hover:bg-mbz-warning border-mbz-warning;
} }
.btn-outlined-danger { .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 { .btn-outlined-text {
@apply bg-transparent hover:text-slate-900; @apply bg-transparent hover:text-slate-900;
@ -161,15 +161,18 @@ body {
} }
.dropdown-item-active { .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 { .dropdown-button {
@apply inline-flex gap-1; @apply inline-flex gap-1;
} }
/* Checkbox */ /* Checkbox */
.checkbox { .checkbox {
margin-inline-end: 1rem;
}
.checkbox-check {
@apply appearance-none bg-primary border-primary; @apply appearance-none bg-primary border-primary;
} }

View File

@ -1,6 +1,6 @@
<template> <template>
<o-inputitems <o-inputitems
:modelValue="modelValue" :modelValue="modelValueWithDisplayName"
@update:modelValue="(val: IActor[]) => $emit('update:modelValue', val)" @update:modelValue="(val: IActor[]) => $emit('update:modelValue', val)"
:data="availableActors" :data="availableActors"
:allow-autocomplete="true" :allow-autocomplete="true"
@ -21,10 +21,10 @@ import { SEARCH_PERSON_AND_GROUPS } from "@/graphql/search";
import { IActor, IGroup, IPerson, displayName } from "@/types/actor"; import { IActor, IGroup, IPerson, displayName } from "@/types/actor";
import { Paginate } from "@/types/paginate"; import { Paginate } from "@/types/paginate";
import { useLazyQuery } from "@vue/apollo-composable"; import { useLazyQuery } from "@vue/apollo-composable";
import { ref } from "vue"; import { computed, ref } from "vue";
import ActorInline from "./ActorInline.vue"; import ActorInline from "./ActorInline.vue";
defineProps<{ const props = defineProps<{
modelValue: IActor[]; modelValue: IActor[];
}>(); }>();
@ -32,6 +32,15 @@ defineEmits<{
"update:modelValue": [value: IActor[]]; "update:modelValue": [value: IActor[]];
}>(); }>();
const modelValue = computed(() => props.modelValue);
const modelValueWithDisplayName = computed(() =>
modelValue.value.map((actor) => ({
...actor,
displayName: displayName(actor),
}))
);
const { const {
load: loadSearchPersonsAndGroupsQuery, load: loadSearchPersonsAndGroupsQuery,
refetch: refetchSearchPersonsAndGroupsQuery, refetch: refetchSearchPersonsAndGroupsQuery,

View File

@ -39,8 +39,18 @@
v-html="actor.summary" v-html="actor.summary"
/> />
</div> </div>
<div class="flex pr-2"> <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 /> <Email />
</router-link>
</div> </div>
</div> </div>
<!-- <div <!-- <div
@ -85,6 +95,8 @@
import { displayName, IActor, usernameWithDomain } from "../../types/actor"; import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue"; import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Email from "vue-material-design-icons/Email.vue"; import Email from "vue-material-design-icons/Email.vue";
import RouteName from "@/router/name";
import { ActorType } from "@/types/enums";
withDefaults( withDefaults(
defineProps<{ defineProps<{

View File

@ -24,15 +24,11 @@
@{{ usernameWithDomain(actor) }} @{{ usernameWithDomain(actor) }}
</p> </p>
</div> </div>
<div class="flex pr-2 self-center">
<Email />
</div>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { displayName, IActor, usernameWithDomain } from "../../types/actor"; import { displayName, IActor, usernameWithDomain } from "../../types/actor";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue"; import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Email from "vue-material-design-icons/Email.vue";
defineProps<{ defineProps<{
actor: IActor; actor: IActor;

View File

@ -1,6 +1,6 @@
<template> <template>
<router-link <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" dir="auto"
:to="{ :to="{
name: RouteName.CONVERSATION, name: RouteName.CONVERSATION,

View File

@ -9,6 +9,15 @@
:currentActor="currentActor" :currentActor="currentActor"
:placeholder="t('Write a new message')" :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"> <footer class="flex gap-2 py-3 mx-2 justify-end">
<o-button :disabled="!canSend" nativeType="submit">{{ <o-button :disabled="!canSend" nativeType="submit">{{
t("Send") t("Send")
@ -18,13 +27,14 @@
</template> </template>
<script lang="ts" setup> <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 { computed, defineAsyncComponent, provide, ref } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import ActorAutoComplete from "../../components/Account/ActorAutoComplete.vue"; import ActorAutoComplete from "../../components/Account/ActorAutoComplete.vue";
import { import {
DefaultApolloClient, DefaultApolloClient,
provideApolloClient, provideApolloClient,
useLazyQuery,
useMutation, useMutation,
} from "@vue/apollo-composable"; } from "@vue/apollo-composable";
import { apolloClient } from "@/vue-apollo"; import { apolloClient } from "@/vue-apollo";
@ -34,12 +44,15 @@ import { IConversation } from "@/types/conversation";
import { useCurrentActorClient } from "@/composition/apollo/actor"; import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import { FETCH_PERSON } from "@/graphql/actor";
import { FETCH_GROUP_PUBLIC } from "@/graphql/group";
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
mentions?: IActor[]; personMentions?: string[];
groupMentions?: string[];
}>(), }>(),
{ mentions: () => [] } { personMentions: () => [], groupMentions: () => [] }
); );
provide(DefaultApolloClient, apolloClient); provide(DefaultApolloClient, apolloClient);
@ -48,15 +61,36 @@ const router = useRouter();
const emit = defineEmits(["close"]); const emit = defineEmits(["close"]);
const actorMentions = ref(props.mentions); const errors = ref<string[]>([]);
const textMentions = computed(() => const textPersonMentions = computed(() => props.personMentions);
(props.mentions ?? []).map((actor) => usernameWithDomain(actor)).join(" ") 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 { t } = useI18n({ useScope: "global" });
const text = ref(textMentions.value); const text = ref("");
const Editor = defineAsyncComponent( const Editor = defineAsyncComponent(
() => import("../../components/TextEditor.vue") () => import("../../components/TextEditor.vue")
@ -70,8 +104,8 @@ const canSend = computed(() => {
return actorMentions.value.length > 0 || /@.+/.test(text.value); return actorMentions.value.length > 0 || /@.+/.test(text.value);
}); });
const { mutate: postPrivateMessageMutate } = provideApolloClient(apolloClient)( const { mutate: postPrivateMessageMutate, onError: onPrivateMessageError } =
() => provideApolloClient(apolloClient)(() =>
useMutation< useMutation<
{ {
postPrivateMessage: IConversation; 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) => { const sendForm = async (e: Event) => {
e.preventDefault(); e.preventDefault();

View File

@ -1,5 +1,30 @@
<template> <template>
<form @submit="sendForm"> <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 <Editor
v-model="text" v-model="text"
mode="basic" mode="basic"
@ -8,6 +33,15 @@
:currentActor="currentActor" :currentActor="currentActor"
:placeholder="t('Write a new message')" :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> <o-button class="mt-3" nativeType="submit">{{ t("Send") }}</o-button>
</form> </form>
</template> </template>
@ -32,9 +66,15 @@ const props = defineProps<{
const event = computed(() => props.event); const event = computed(() => props.event);
const text = ref(""); const text = ref("");
const errors = ref<string[]>([]);
const selectedRoles = ref<ParticipantRole[]>([ParticipantRole.PARTICIPANT]);
const { const {
mutate: eventPrivateMessageMutate, mutate: eventPrivateMessageMutate,
onDone: onEventPrivateMessageMutated, onDone: onEventPrivateMessageMutated,
onError: onEventPrivateMessageError,
} = useMutation< } = useMutation<
{ {
sendEventPrivateMessage: IConversation; sendEventPrivateMessage: IConversation;
@ -43,8 +83,7 @@ const {
text: string; text: string;
actorId: string; actorId: string;
eventId: string; eventId: string;
roles?: string; roles?: ParticipantRole[];
inReplyToActorId?: ParticipantRole[];
language?: string; language?: string;
} }
>(SEND_EVENT_PRIVATE_MESSAGE_MUTATION, { >(SEND_EVENT_PRIVATE_MESSAGE_MUTATION, {
@ -96,6 +135,7 @@ const sendForm = (e: Event) => {
event.value.organizerActor?.id ?? event.value.organizerActor?.id ??
currentActor.value?.id, currentActor.value?.id,
eventId: event.value.id, eventId: event.value.id,
roles: selectedRoles.value,
}); });
}; };
@ -103,6 +143,12 @@ onEventPrivateMessageMutated(() => {
text.value = ""; text.value = "";
}); });
onEventPrivateMessageError((err) => {
err.graphQLErrors.forEach((error) => {
errors.value.push(error.message);
});
});
const Editor = defineAsyncComponent( const Editor = defineAsyncComponent(
() => import("../../components/TextEditor.vue") () => import("../../components/TextEditor.vue")
); );

View File

@ -9,7 +9,7 @@
<router-link :to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT }"> <router-link :to="{ name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT }">
<figure class="flex justify-center my-2"> <figure class="flex justify-center my-2">
<img <img
src="../../../public/img/undraw_profile.svg" src="/img/undraw_profile.svg"
alt="Profile illustration" alt="Profile illustration"
width="128" width="128"
height="128" height="128"
@ -55,7 +55,7 @@
<img <img
width="128" width="128"
height="128" height="128"
src="../../../public/img/undraw_mail_2.svg" src="/img/undraw_mail_2.svg"
alt="Privacy illustration" alt="Privacy illustration"
/> />
</figure> </figure>
@ -66,7 +66,7 @@
<a :href="`${event.url}/participate/without-account`" v-else> <a :href="`${event.url}/participate/without-account`" v-else>
<figure class="flex justify-center my-2"> <figure class="flex justify-center my-2">
<img <img
src="../../../public/img/undraw_mail_2.svg" src="/img/undraw_mail_2.svg"
width="128" width="128"
height="128" height="128"
alt="Privacy illustration" alt="Privacy illustration"

View File

@ -54,7 +54,6 @@ export const SEND_EVENT_PRIVATE_MESSAGE_MUTATION = gql`
$actorId: ID! $actorId: ID!
$eventId: ID! $eventId: ID!
$roles: [ParticipantRoleEnum] $roles: [ParticipantRoleEnum]
$attributedToId: ID
$language: String $language: String
) { ) {
sendEventPrivateMessage( sendEventPrivateMessage(
@ -62,7 +61,6 @@ export const SEND_EVENT_PRIVATE_MESSAGE_MUTATION = gql`
actorId: $actorId actorId: $actorId
eventId: $eventId eventId: $eventId
roles: $roles roles: $roles
attributedToId: $attributedToId
language: $language language: $language
) { ) {
...ConversationQuery ...ConversationQuery

View File

@ -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", "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 an event announcement": "Comment from an event announcement",
"Comment from a private conversation": "Comment from a private conversation", "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:"
} }

View File

@ -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}", "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 an event announcement": "Commentaire d'une annonce d'événement",
"Comment from a private conversation": "Commentaire d'une conversation privée", "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:"
} }

View File

@ -35,7 +35,8 @@ export const orugaConfig = {
variantClass: "icon-", variantClass: "icon-",
}, },
checkbox: { checkbox: {
checkClass: "checkbox", rootClass: "checkbox",
checkClass: "checkbox-check",
checkCheckedClass: "checkbox-checked", checkCheckedClass: "checkbox-checked",
labelClass: "checkbox-label", labelClass: "checkbox-label",
}, },

10
src/utils/route.ts Normal file
View File

@ -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(",");
},
};

View File

@ -80,7 +80,7 @@
<img <img
class="w-12" class="w-12"
v-if="instance.hasRelay" v-if="instance.hasRelay"
src="../../../public/img/logo.svg" src="/img/logo.svg"
alt="" alt=""
/> />
<CloudQuestion v-else :size="36" /> <CloudQuestion v-else :size="36" />

View File

@ -44,19 +44,28 @@
<script lang="ts" setup> <script lang="ts" setup>
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { useQuery } from "@vue/apollo-composable"; 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 { 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 { PROFILE_CONVERSATIONS } from "@/graphql/event";
import ConversationListItem from "../../components/Conversations/ConversationListItem.vue"; import ConversationListItem from "../../components/Conversations/ConversationListItem.vue";
import EmptyContent from "../../components/Utils/EmptyContent.vue"; import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { useHead } from "@vueuse/head"; import { useHead } from "@vueuse/head";
import { IPerson } from "@/types/actor"; import { IPerson } from "@/types/actor";
import { useProgrammatic } from "@oruga-ui/oruga-next"; import { useProgrammatic } from "@oruga-ui/oruga-next";
import { arrayTransformer } from "@/utils/route";
const page = useRouteQuery("page", 1, integerTransformer); const page = useRouteQuery("page", 1, integerTransformer);
const CONVERSATIONS_PER_PAGE = 10; 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" }); const { t } = useI18n({ useScope: "global" });
useHead({ useHead({
@ -69,6 +78,7 @@ const { result: conversationsResult } = useQuery<{
loggedPerson: Pick<IPerson, "conversations">; loggedPerson: Pick<IPerson, "conversations">;
}>(PROFILE_CONVERSATIONS, () => ({ }>(PROFILE_CONVERSATIONS, () => ({
page: page.value, page: page.value,
limit: CONVERSATIONS_PER_PAGE,
})); }));
const conversations = computed( const conversations = computed(
@ -88,7 +98,17 @@ const NewConversation = defineAsyncComponent(
const openNewMessageModal = () => { const openNewMessageModal = () => {
oruga.modal.open({ oruga.modal.open({
component: NewConversation, component: NewConversation,
props: {
personMentions: personMentions.value,
groupMentions: groupMentions.value,
},
trapFocus: true, trapFocus: true,
}); });
}; };
watchEffect(() => {
if (showModal.value) {
openNewMessageModal();
}
});
</script> </script>

View File

@ -14,13 +14,13 @@
]" ]"
/> />
<div <div
v-if="conversation.event" v-if="conversation.event && !isCurrentActorAuthor"
class="bg-mbz-yellow p-6 mb-6 rounded flex gap-2 items-center" class="bg-mbz-yellow p-6 mb-3 rounded flex gap-2 items-center"
> >
<Calendar :size="36" /> <Calendar :size="36" />
<i18n-t <i18n-t
tag="p" 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> <template #event>
<b> <b>
@ -35,10 +35,7 @@
</template> </template>
</i18n-t> </i18n-t>
</div> </div>
<div <o-notification v-if="isCurrentActorAuthor" variant="info" closable>
v-if="currentActor && currentActor.id !== conversation.actor?.id"
class="bg-mbz-info p-6 rounded flex gap-2 items-center my-3"
>
<i18n-t <i18n-t
keypath="You have access to this conversation as a member of the {group} group" keypath="You have access to this conversation as a member of the {group} group"
tag="p" tag="p"
@ -55,7 +52,36 @@
> >
</template> </template>
</i18n-t> </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"> <o-notification v-if="error" variant="danger">
{{ error }} {{ error }}
</o-notification> </o-notification>
@ -107,7 +133,7 @@
</form> </form>
<div <div
v-else-if="conversation.event" 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" /> <Calendar :size="36" />
<i18n-t <i18n-t
@ -239,6 +265,12 @@ const otherParticipants = computed(
) ?? [] ) ?? []
); );
const groupParticipants = computed(() => {
return otherParticipants.value.filter(
(participant) => participant.type === ActorType.GROUP
);
});
const Editor = defineAsyncComponent( const Editor = defineAsyncComponent(
() => import("../../components/TextEditor.vue") () => 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({ useHead({
title: title.value, title: () => title.value,
}); });
const newComment = ref(""); const newComment = ref("");

View File

@ -650,12 +650,6 @@ const FullAddressAutoComplete = defineAsyncComponent(
const { t } = useI18n({ useScope: "global" }); const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() =>
props.isUpdate ? t("Event edition") : t("Event creation")
),
});
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
eventId?: undefined | string; eventId?: undefined | string;
@ -667,6 +661,12 @@ const props = withDefaults(
const eventId = computed(() => props.eventId); const eventId = computed(() => props.eventId);
useHead({
title: computed(() =>
props.isUpdate ? t("Event edition") : t("Event creation")
),
});
const event = ref<IEditableEvent>(new EventModel()); const event = ref<IEditableEvent>(new EventModel());
const unmodifiedEvent = ref<IEditableEvent>(new EventModel()); const unmodifiedEvent = ref<IEditableEvent>(new EventModel());

View File

@ -225,6 +225,7 @@
@click="acceptParticipants(checkedRows)" @click="acceptParticipants(checkedRows)"
variant="success" variant="success"
:disabled="!canAcceptParticipants" :disabled="!canAcceptParticipants"
outlined
> >
{{ {{
t( t(
@ -238,6 +239,7 @@
@click="refuseParticipants(checkedRows)" @click="refuseParticipants(checkedRows)"
variant="danger" variant="danger"
:disabled="!canRefuseParticipants" :disabled="!canRefuseParticipants"
outlined
> >
{{ {{
t( t(

View File

@ -266,6 +266,21 @@
: t("Deactivate notifications") : t("Deactivate notifications")
}}</span> }}</span>
</o-button> </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 <o-button
outlined outlined
icon-left="share" icon-left="share"

View File

@ -5,19 +5,19 @@
<div class="-z-10 overflow-hidden"> <div class="-z-10 overflow-hidden">
<img <img
alt="" alt=""
src="../../public/img/shape-1.svg" src="/img/shape-1.svg"
class="-z-10 absolute left-[2%] top-36" class="-z-10 absolute left-[2%] top-36"
width="300" width="300"
/> />
<img <img
alt="" 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" class="-z-10 absolute left-[50%] top-[5%] -translate-x-2/4 opacity-60"
width="800" width="800"
/> />
<img <img
alt="" alt=""
src="../../public/img/shape-3.svg" src="/img/shape-3.svg"
class="-z-10 absolute top-0 right-36" class="-z-10 absolute top-0 right-36"
width="200" width="200"
/> />

View File

@ -749,7 +749,6 @@ import {
useRouteQuery, useRouteQuery,
enumTransformer, enumTransformer,
booleanTransformer, booleanTransformer,
RouteQueryTransformer,
} from "vue-use-route-query"; } from "vue-use-route-query";
import Calendar from "vue-material-design-icons/Calendar.vue"; import Calendar from "vue-material-design-icons/Calendar.vue";
import AccountMultiple from "vue-material-design-icons/AccountMultiple.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 EmptyContent from "@/components/Utils/EmptyContent.vue";
import SkeletonGroupResultList from "@/components/Group/SkeletonGroupResultList.vue"; import SkeletonGroupResultList from "@/components/Group/SkeletonGroupResultList.vue";
import SkeletonEventResultList from "@/components/Event/SkeletonEventResultList.vue"; import SkeletonEventResultList from "@/components/Event/SkeletonEventResultList.vue";
import { arrayTransformer } from "@/utils/route";
const EventMarkerMap = defineAsyncComponent( const EventMarkerMap = defineAsyncComponent(
() => import("@/components/Search/EventMarkerMap.vue") () => import("@/components/Search/EventMarkerMap.vue")
@ -840,15 +840,6 @@ enum SortValues {
MEMBER_COUNT_DESC = "MEMBER_COUNT_DESC", 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<{ const props = defineProps<{
tag?: string; tag?: string;
}>(); }>();