Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2023-10-17 16:41:31 +02:00
parent 0613f7f736
commit b5672cee7e
108 changed files with 5221 additions and 1318 deletions

View File

@@ -0,0 +1,77 @@
<template>
<o-inputitems
:modelValue="modelValue"
@update:modelValue="(val: IActor[]) => $emit('update:modelValue', val)"
:data="availableActors"
:allow-autocomplete="true"
:allow-new="false"
:open-on-focus="false"
field="displayName"
placeholder="Add a recipient"
@typing="getActors"
>
<template #default="props">
<ActorInline :actor="props.option" />
</template>
</o-inputitems>
</template>
<script setup lang="ts">
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 ActorInline from "./ActorInline.vue";
defineProps<{
modelValue: IActor[];
}>();
defineEmits<{
"update:modelValue": [value: IActor[]];
}>();
const {
load: loadSearchPersonsAndGroupsQuery,
refetch: refetchSearchPersonsAndGroupsQuery,
} = useLazyQuery<
{ searchPersons: Paginate<IPerson>; searchGroups: Paginate<IGroup> },
{ searchText: string }
>(SEARCH_PERSON_AND_GROUPS);
const availableActors = ref<IActor[]>([]);
const getActors = async (text: string) => {
availableActors.value = await fetchActors(text);
};
const fetchActors = async (text: string): Promise<IActor[]> => {
if (text === "") return [];
try {
const res =
(await loadSearchPersonsAndGroupsQuery(SEARCH_PERSON_AND_GROUPS, {
searchText: text,
})) ||
(
await refetchSearchPersonsAndGroupsQuery({
searchText: text,
})
)?.data;
if (!res) return [];
return [
...res.searchPersons.elements.map((person) => ({
...person,
displayName: displayName(person),
})),
...res.searchGroups.elements.map((group) => ({
...group,
displayName: displayName(group),
})),
];
} catch (e) {
console.error(e);
return [];
}
};
</script>

View File

@@ -39,6 +39,9 @@
v-html="actor.summary"
/>
</div>
<div class="flex pr-2">
<Email />
</div>
</div>
<!-- <div
class="p-4 bg-white rounded-lg shadow-md sm:p-8 flex items-center space-x-4"
@@ -81,6 +84,7 @@
<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";
withDefaults(
defineProps<{

View File

@@ -25,7 +25,7 @@ import { IActor } from "@/types/actor";
import { ActorType } from "@/types/enums";
const avatarUrl = ref<string>(
"https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg"
"https://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.jpg"
);
const stateLocal = reactive<IActor>({

View File

@@ -1,8 +1,8 @@
<template>
<div
class="inline-flex items-start bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
class="inline-flex items-start gap-2 bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
>
<div class="flex-none mr-2">
<div class="flex-none">
<figure v-if="actor.avatar">
<img
class="rounded-xl"
@@ -24,11 +24,15 @@
@{{ 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;

View File

@@ -49,7 +49,7 @@ const group = {
domain: "mobilizon.fr",
avatar: {
...baseActorAvatar,
url: "https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg",
url: "https://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.jpg",
},
};

View File

@@ -0,0 +1,160 @@
<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"
dir="auto"
:to="{
name: RouteName.CONVERSATION,
params: { id: conversation.conversationParticipantId },
}"
>
<div class="relative">
<figure
class="w-12 h-12"
v-if="
conversation.lastComment?.actor &&
conversation.lastComment.actor.avatar
"
>
<img
class="rounded-full"
:src="conversation.lastComment.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<account-circle :size="48" v-else />
<div class="flex absolute -bottom-2 left-6">
<template
v-for="extraParticipant in nonLastCommenterParticipants.slice(0, 2)"
:key="extraParticipant.id"
>
<figure class="w-6 h-6 -mr-3">
<img
v-if="extraParticipant && extraParticipant.avatar"
class="rounded-full h-6"
:src="extraParticipant.avatar.url"
alt=""
width="24"
height="24"
/>
<account-circle :size="24" v-else />
</figure>
</template>
</div>
</div>
<div class="overflow-hidden flex-1">
<div class="flex items-center justify-between">
<i18n-t
keypath="With {participants}"
tag="p"
class="truncate flex-1"
v-if="formattedListOfParticipants"
>
<template #participants>
<span v-html="formattedListOfParticipants" />
</template>
</i18n-t>
<p v-else>{{ t("With unknown participants") }}</p>
<div class="inline-flex items-center px-1.5">
<span
v-if="conversation.unread"
class="bg-primary rounded-full inline-block h-2.5 w-2.5 mx-2"
>
</span>
<time
class="whitespace-nowrap"
:datetime="actualDate.toString()"
:title="formatDateTimeString(actualDate)"
>
{{ distanceToNow }}</time
>
</div>
</div>
<div
class="line-clamp-2 my-1"
dir="auto"
v-if="!conversation.lastComment?.deletedAt"
>
{{ htmlTextEllipsis }}
</div>
<div v-else class="">
{{ t("[This comment has been deleted]") }}
</div>
</div>
</router-link>
</template>
<script lang="ts" setup>
import { formatDistanceToNowStrict } from "date-fns";
import { IConversation } from "../../types/conversation";
import RouteName from "../../router/name";
import { computed, inject } from "vue";
import { formatDateTimeString } from "../../filters/datetime";
import type { Locale } from "date-fns";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { useI18n } from "vue-i18n";
import { formatList } from "@/utils/i18n";
import { displayName } from "@/types/actor";
import { useCurrentActorClient } from "@/composition/apollo/actor";
const props = defineProps<{
conversation: IConversation;
}>();
const conversation = computed(() => props.conversation);
const dateFnsLocale = inject<Locale>("dateFnsLocale");
const { t } = useI18n({ useScope: "global" });
const distanceToNow = computed(() => {
return (
formatDistanceToNowStrict(new Date(actualDate.value), {
locale: dateFnsLocale,
}) ?? t("Right now")
);
});
const htmlTextEllipsis = computed((): string => {
const element = document.createElement("div");
if (conversation.value.lastComment && conversation.value.lastComment.text) {
element.innerHTML = conversation.value.lastComment.text
.replace(/<br\s*\/?>/gi, " ")
.replace(/<p>/gi, " ");
}
return element.innerText;
});
const actualDate = computed((): string => {
if (
conversation.value.updatedAt === conversation.value.insertedAt &&
conversation.value.lastComment?.publishedAt
) {
return conversation.value.lastComment.publishedAt;
}
return conversation.value.updatedAt;
});
const formattedListOfParticipants = computed(() => {
return formatList(
otherParticipants.value.map(
(participant) => `<b>${displayName(participant)}</b>`
)
);
});
const { currentActor } = useCurrentActorClient();
const otherParticipants = computed(
() =>
conversation.value?.participants.filter(
(participant) => participant.id !== currentActor.value?.id
) ?? []
);
const nonLastCommenterParticipants = computed(() =>
otherParticipants.value.filter(
(participant) =>
participant.id !== conversation.value.lastComment?.actor?.id
)
);
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="container mx-auto section">
<breadcrumbs-nav :links="[]" />
<section>
<h1>{{ t("Conversations") }}</h1>
<!-- <o-button
tag="router-link"
:to="{
name: RouteName.CREATE_CONVERSATION,
params: { uuid: event.uuid },
}"
>{{ t("New private message") }}</o-button
> -->
<div v-if="conversations.elements.length > 0">
<conversation-list-item
:conversation="conversation"
v-for="conversation in conversations.elements"
:key="conversation.id"
/>
<o-pagination
v-show="conversations.total > CONVERSATIONS_PER_PAGE"
class="conversation-pagination"
:total="conversations.total"
v-model:current="page"
:per-page="CONVERSATIONS_PER_PAGE"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
>
</o-pagination>
</div>
<empty-content v-else icon="chat">
{{ t("There's no conversations yet") }}
</empty-content>
</section>
</div>
</template>
<script lang="ts" setup>
import ConversationListItem from "../../components/Conversations/ConversationListItem.vue";
// import RouteName from "../../router/name";
import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { useI18n } from "vue-i18n";
import { useRouteQuery, integerTransformer } from "vue-use-route-query";
import { computed } from "vue";
import { IEvent } from "../../types/event.model";
import { EVENT_CONVERSATIONS } from "../../graphql/event";
import { useQuery } from "@vue/apollo-composable";
const page = useRouteQuery("page", 1, integerTransformer);
const CONVERSATIONS_PER_PAGE = 10;
const props = defineProps<{ event: IEvent }>();
const event = computed(() => props.event);
const { t } = useI18n({ useScope: "global" });
const { result: conversationsResult } = useQuery<{
event: Pick<IEvent, "conversations">;
}>(EVENT_CONVERSATIONS, () => ({
uuid: event.value.uuid,
page: page.value,
}));
const conversations = computed(
() =>
conversationsResult.value?.event.conversations || { elements: [], total: 0 }
);
</script>

View File

@@ -0,0 +1,137 @@
<template>
<form @submit="sendForm" class="flex flex-col">
<ActorAutoComplete v-model="actorMentions" />
<Editor
v-model="text"
mode="basic"
:aria-label="t('Message body')"
v-if="currentActor"
:currentActor="currentActor"
:placeholder="t('Write a new message')"
/>
<footer class="flex gap-2 py-3 mx-2 justify-end">
<o-button :disabled="!canSend" nativeType="submit">{{
t("Send")
}}</o-button>
</footer>
</form>
</template>
<script lang="ts" setup>
import { IActor, 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,
useMutation,
} from "@vue/apollo-composable";
import { apolloClient } from "@/vue-apollo";
import { PROFILE_CONVERSATIONS } from "@/graphql/event";
import { POST_PRIVATE_MESSAGE_MUTATION } from "@/graphql/conversations";
import { IConversation } from "@/types/conversation";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useRouter } from "vue-router";
import RouteName from "@/router/name";
const props = withDefaults(
defineProps<{
mentions?: IActor[];
}>(),
{ mentions: () => [] }
);
provide(DefaultApolloClient, apolloClient);
const router = useRouter();
const emit = defineEmits(["close"]);
const actorMentions = ref(props.mentions);
const textMentions = computed(() =>
(props.mentions ?? []).map((actor) => usernameWithDomain(actor)).join(" ")
);
const { t } = useI18n({ useScope: "global" });
const text = ref(textMentions.value);
const Editor = defineAsyncComponent(
() => import("../../components/TextEditor.vue")
);
const { currentActor } = provideApolloClient(apolloClient)(() => {
return useCurrentActorClient();
});
const canSend = computed(() => {
return actorMentions.value.length > 0 || /@.+/.test(text.value);
});
const { mutate: postPrivateMessageMutate } = provideApolloClient(apolloClient)(
() =>
useMutation<
{
postPrivateMessage: IConversation;
},
{
text: string;
actorId: string;
language?: string;
mentions?: string[];
attributedToId?: string;
}
>(POST_PRIVATE_MESSAGE_MUTATION, {
update(cache, result) {
if (!result.data?.postPrivateMessage) return;
const cachedData = cache.readQuery<{
loggedPerson: Pick<IPerson, "conversations" | "id">;
}>({
query: PROFILE_CONVERSATIONS,
variables: {
page: 1,
},
});
if (!cachedData) return;
cache.writeQuery({
query: PROFILE_CONVERSATIONS,
variables: {
page: 1,
},
data: {
loggedPerson: {
...cachedData?.loggedPerson,
conversations: {
...cachedData.loggedPerson.conversations,
total: (cachedData.loggedPerson.conversations?.total ?? 0) + 1,
elements: [
...(cachedData.loggedPerson.conversations?.elements ?? []),
result.data.postPrivateMessage,
],
},
},
},
});
},
})
);
const sendForm = async (e: Event) => {
e.preventDefault();
console.debug("Sending new private message");
if (!currentActor.value?.id) return;
const result = await postPrivateMessageMutate({
actorId: currentActor.value.id,
text: text.value,
mentions: actorMentions.value.map((actor) => usernameWithDomain(actor)),
});
if (!result?.data?.postPrivateMessage.conversationParticipantId) return;
router.push({
name: RouteName.CONVERSATION,
params: { id: result?.data?.postPrivateMessage.conversationParticipantId },
});
emit("close");
};
</script>

View File

@@ -1,5 +1,7 @@
<template>
<article class="flex gap-2 bg-white dark:bg-transparent">
<article
class="flex gap-2 bg-white dark:bg-transparent border rounded-md p-2 mt-2"
>
<div class="">
<figure class="" v-if="comment.actor && comment.actor.avatar">
<img
@@ -29,12 +31,12 @@
v-if="
comment.actor &&
!comment.deletedAt &&
comment.actor.id === currentActor?.id
(comment.actor.id === currentActor.id || canReport)
"
>
<o-dropdown aria-role="list" position="bottom-left">
<template #trigger>
<o-icon role="button" icon="dots-horizontal" />
<DotsHorizontal class="cursor-pointer" />
</template>
<o-dropdown-item
@@ -53,10 +55,14 @@
<o-icon icon="delete"></o-icon>
{{ t("Delete") }}
</o-dropdown-item>
<!-- <o-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
<o-dropdown-item
v-if="canReport"
aria-role="listitem"
@click="isReportModalActive = true"
>
<o-icon icon="flag" />
{{ t("Report") }}
</o-dropdown-item> -->
</o-dropdown-item>
</o-dropdown>
</span>
<div class="self-center">
@@ -124,6 +130,20 @@
</form>
</div>
</article>
<o-modal
v-model:active="isReportModalActive"
has-modal-card
ref="reportModal"
:close-button-aria-label="t('Close')"
:autoFocus="false"
:trapFocus="false"
>
<ReportModal
:on-confirm="reportComment"
:title="t('Report this comment')"
:outside-domain="comment.actor?.domain"
/>
</o-modal>
</template>
<script lang="ts" setup>
import { formatDistanceToNow } from "date-fns";
@@ -132,17 +152,26 @@ import { IPerson, usernameWithDomain } from "../../types/actor";
import { computed, defineAsyncComponent, inject, ref } from "vue";
import { formatDateTimeString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
import type { Locale } from "date-fns";
import { useI18n } from "vue-i18n";
import { useCreateReport } from "@/composition/apollo/report";
import { Snackbar } from "@/plugins/snackbar";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import ReportModal from "@/components/Report/ReportModal.vue";
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
const props = defineProps<{
modelValue: IComment;
currentActor: IPerson;
}>();
const props = withDefaults(
defineProps<{
modelValue: IComment;
currentActor: IPerson;
canReport: boolean;
}>(),
{ canReport: false }
);
const emit = defineEmits(["update:modelValue", "deleteComment"]);
@@ -156,7 +185,7 @@ const updatedComment = ref("");
const dateFnsLocale = inject<Locale>("dateFnsLocale");
// isReportModalActive: boolean = false;
const isReportModalActive = ref(false);
const toggleEditMode = (): void => {
updatedComment.value = comment.value.text;
@@ -170,6 +199,51 @@ const updateComment = (): void => {
});
toggleEditMode();
};
const {
mutate: createReportMutation,
onError: onCreateReportError,
onDone: oneCreateReportDone,
} = useCreateReport();
const reportComment = async (
content: string,
forward: boolean
): Promise<void> => {
if (!props.modelValue.actor) return;
createReportMutation({
reportedId: props.modelValue.actor?.id ?? "",
commentsIds: [props.modelValue.id ?? ""],
content,
forward,
});
};
const snackbar = inject<Snackbar>("snackbar");
const { oruga } = useProgrammatic();
onCreateReportError((e) => {
isReportModalActive.value = false;
if (e.message) {
snackbar?.open({
message: e.message,
variant: "danger",
position: "bottom",
});
}
});
oneCreateReportDone(() => {
isReportModalActive.value = false;
oruga.notification.open({
message: t("Comment from {'@'}{username} reported", {
username: props.modelValue.actor?.preferredUsername,
}),
variant: "success",
position: "bottom-right",
duration: 5000,
});
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;

View File

@@ -2,28 +2,29 @@ import { SEARCH_PERSONS } from "@/graphql/search";
import { VueRenderer } from "@tiptap/vue-3";
import tippy from "tippy.js";
import MentionList from "./MentionList.vue";
import { apolloClient, waitApolloQuery } from "@/vue-apollo";
import { apolloClient } from "@/vue-apollo";
import { IPerson } from "@/types/actor";
import pDebounce from "p-debounce";
import { MentionOptions } from "@tiptap/extension-mention";
import { Editor } from "@tiptap/core";
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
import { provideApolloClient, useLazyQuery } from "@vue/apollo-composable";
import { Paginate } from "@/types/paginate";
const fetchItems = async (query: string): Promise<IPerson[]> => {
try {
if (query === "") return [];
const res = await waitApolloQuery(
provideApolloClient(apolloClient)(() => {
return useQuery<
{ searchPersons: Paginate<IPerson> },
{ searchText: string }
>(SEARCH_PERSONS, () => ({
searchText: query,
}));
})
);
return res.data.searchPersons.elements;
const res = await provideApolloClient(apolloClient)(async () => {
const { load: loadSearchPersonsQuery } = useLazyQuery<
{ searchPersons: Paginate<IPerson> },
{ searchText: string }
>(SEARCH_PERSONS);
return await loadSearchPersonsQuery(SEARCH_PERSONS, {
searchText: query,
});
});
if (!res) return [];
return res.searchPersons.elements;
} catch (e) {
console.error(e);
return [];

View File

@@ -318,18 +318,10 @@ const debounceDelay = computed(() =>
geocodingAutocomplete.value === true ? 200 : 2000
);
const { onResult: onAddressSearchResult, load: searchAddress } = useLazyQuery<{
const { load: searchAddress } = useLazyQuery<{
searchAddress: IAddress[];
}>(ADDRESS);
onAddressSearchResult((result) => {
if (result.loading) return;
const { data } = result;
console.debug("onAddressSearchResult", data.searchAddress);
addressData.value = data.searchAddress;
isFetching.value = false;
});
const asyncData = async (query: string): Promise<void> => {
console.debug("Finding addresses");
if (!query.length) {
@@ -345,11 +337,21 @@ const asyncData = async (query: string): Promise<void> => {
isFetching.value = true;
searchAddress(undefined, {
query,
locale: locale,
type: props.resultType,
});
try {
const result = await searchAddress(undefined, {
query,
locale: locale,
type: props.resultType,
});
if (!result) return;
console.debug("onAddressSearchResult", result.searchAddress);
addressData.value = result.searchAddress;
isFetching.value = false;
} catch (e) {
console.error(e);
return;
}
};
const selectedAddressText = computed(() => {
@@ -393,24 +395,9 @@ const locateMe = async (): Promise<void> => {
gettingLocation.value = false;
};
const { onResult: onReverseGeocodeResult, load: loadReverseGeocode } =
useReverseGeocode();
const { load: loadReverseGeocode } = useReverseGeocode();
onReverseGeocodeResult((result) => {
if (result.loading !== false) return;
const { data } = result;
addressData.value = data.reverseGeocode;
if (addressData.value.length > 0) {
const foundAddress = addressData.value[0];
Object.assign(selected, foundAddress);
console.debug("reverse geocode succeded, setting new address");
queryTextWithDefault.value = addressFullName(foundAddress);
emit("update:modelValue", selected);
}
});
const reverseGeoCode = (e: LatLng, zoom: number) => {
const reverseGeoCode = async (e: LatLng, zoom: number) => {
console.debug("reverse geocode");
// If the details is opened, just update coords, don't reverse geocode
@@ -423,12 +410,26 @@ const reverseGeoCode = (e: LatLng, zoom: number) => {
// If the position has been updated through autocomplete selection, no need to geocode it!
if (!e || checkCurrentPosition(e)) return;
loadReverseGeocode(undefined, {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: locale as unknown as string,
});
try {
const result = await loadReverseGeocode(undefined, {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: locale as unknown as string,
});
if (!result) return;
addressData.value = result.reverseGeocode;
if (addressData.value.length > 0) {
const foundAddress = addressData.value[0];
Object.assign(selected, foundAddress);
console.debug("reverse geocode succeded, setting new address");
queryTextWithDefault.value = addressFullName(foundAddress);
emit("update:modelValue", selected);
}
} catch (err) {
console.error("Failed to load reverse geocode", err);
}
};
// eslint-disable-next-line no-undef

View File

@@ -40,10 +40,10 @@ const basicGroup: IGroup = {
const groupWithMedia: IGroup = {
...basicGroup,
banner: {
url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
url: "https://mobilizon.fr/media/a8227a16cc80b3d20ff5ee549a29c1b20a0ca1547f8861129aae9f00c3c69d12.jpg?name=framasoft%27s%20banner.jpg",
},
avatar: {
url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
url: "https://mobilizon.fr/media/890f5396ef80081a6b1b18a5db969746cf8bb340e8a4e657d665e41f6646c539.jpg?name=framasoft%27s%20avatar.jpg",
},
};

View File

@@ -122,8 +122,8 @@ const events = computed(
() => eventsResult.value?.searchEvents ?? { elements: [], total: 0 }
);
onMounted(() => {
load();
onMounted(async () => {
await load();
});
const loading = computed(() => props.doingGeoloc || loadingEvents.value);

View File

@@ -13,7 +13,24 @@
>
<MobilizonLogo class="w-40" />
</router-link>
<div class="flex items-center md:order-2 ml-auto" v-if="currentActor?.id">
<div
class="flex items-center md:order-2 ml-auto gap-2"
v-if="currentActor?.id"
>
<router-link
:to="{ name: RouteName.CONVERSATION_LIST }"
class="flex sm:mr-3 text-sm md:mr-0 relative"
id="conversations-menu-button"
aria-expanded="false"
>
<span class="sr-only">{{ t("Open conversations") }}</span>
<Inbox :size="32" />
<span
v-show="unreadConversationsCount > 0"
class="absolute bottom-0.5 -left-2 bg-primary rounded-full inline-block h-3 w-3 mx-2"
>
</span>
</router-link>
<o-dropdown position="bottom-left">
<template #trigger>
<button
@@ -202,22 +219,28 @@
import MobilizonLogo from "@/components/MobilizonLogo.vue";
import { ICurrentUserRole } from "@/types/enums";
import { logout } from "../utils/auth";
import { displayName } from "../types/actor";
import { IPerson, displayName } from "../types/actor";
import RouteName from "../router/name";
import { computed, ref, watch } from "vue";
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import Inbox from "vue-material-design-icons/Inbox.vue";
import { useCurrentUserClient } from "@/composition/apollo/user";
import {
useCurrentActorClient,
useCurrentUserIdentities,
} from "@/composition/apollo/actor";
import { useMutation } from "@vue/apollo-composable";
import { useLazyQuery, useMutation } from "@vue/apollo-composable";
import { UPDATE_DEFAULT_ACTOR } from "@/graphql/actor";
import { changeIdentity } from "@/utils/identity";
import { useRegistrationConfig } from "@/composition/apollo/config";
import { useProgrammatic } from "@oruga-ui/oruga-next";
import {
UNREAD_ACTOR_CONVERSATIONS,
UNREAD_ACTOR_CONVERSATIONS_SUBSCRIPTION,
} from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
const { currentUser } = useCurrentUserClient();
const { currentActor } = useCurrentActorClient();
@@ -239,6 +262,61 @@ const canRegister = computed(() => {
const { t } = useI18n({ useScope: "global" });
const unreadConversationsCount = computed(
() =>
unreadActorConversationsResult.value?.loggedUser.defaultActor
?.unreadConversationsCount ?? 0
);
const {
result: unreadActorConversationsResult,
load: loadUnreadConversations,
subscribeToMore,
} = useLazyQuery<{
loggedUser: Pick<ICurrentUser, "id" | "defaultActor">;
}>(UNREAD_ACTOR_CONVERSATIONS);
watch(currentActor, async (currentActorValue, previousActorValue) => {
if (
currentActorValue?.id &&
currentActorValue.preferredUsername !==
previousActorValue?.preferredUsername
) {
await loadUnreadConversations();
subscribeToMore<
{ personId: string },
{ personUnreadConversationsCount: number }
>({
document: UNREAD_ACTOR_CONVERSATIONS_SUBSCRIPTION,
variables: {
personId: currentActor.value?.id as string,
},
updateQuery: (previousResult, { subscriptionData }) => {
console.debug(
"Updating actor unread conversations count query after subscribe to more update",
subscriptionData?.data?.personUnreadConversationsCount
);
return {
...previousResult,
loggedUser: {
id: previousResult.loggedUser.id,
defaultActor: {
...previousResult.loggedUser.defaultActor,
unreadConversationsCount:
subscriptionData?.data?.personUnreadConversationsCount ??
previousResult.loggedUser.defaultActor
?.unreadConversationsCount,
} as IPerson, // no idea why,
},
};
},
});
}
});
onMounted(() => {});
watch(identities, () => {
// If we don't have any identities, the user has validated their account,
// is logging for the first time but didn't create an identity somehow

View File

@@ -0,0 +1,109 @@
<template>
<form @submit="sendForm">
<Editor
v-model="text"
mode="basic"
:aria-label="t('Message body')"
v-if="currentActor"
:currentActor="currentActor"
:placeholder="t('Write a new message')"
/>
<o-button class="mt-3" nativeType="submit">{{ t("Send") }}</o-button>
</form>
</template>
<script lang="ts" setup>
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { SEND_EVENT_PRIVATE_MESSAGE_MUTATION } from "@/graphql/conversations";
import { EVENT_CONVERSATIONS } from "@/graphql/event";
import { IConversation } from "@/types/conversation";
import { ParticipantRole } from "@/types/enums";
import { IEvent } from "@/types/event.model";
import { useMutation } from "@vue/apollo-composable";
import { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n({ useScope: "global" });
const props = defineProps<{
event: IEvent;
}>();
const event = computed(() => props.event);
const text = ref("");
const {
mutate: eventPrivateMessageMutate,
onDone: onEventPrivateMessageMutated,
} = useMutation<
{
sendEventPrivateMessage: IConversation;
},
{
text: string;
actorId: string;
eventId: string;
roles?: string;
inReplyToActorId?: ParticipantRole[];
language?: string;
}
>(SEND_EVENT_PRIVATE_MESSAGE_MUTATION, {
update(cache, result) {
if (!result.data?.sendEventPrivateMessage) return;
const cachedData = cache.readQuery<{
event: Pick<IEvent, "conversations" | "id" | "uuid">;
}>({
query: EVENT_CONVERSATIONS,
variables: {
uuid: event.value.uuid,
page: 1,
},
});
if (!cachedData) return;
cache.writeQuery({
query: EVENT_CONVERSATIONS,
variables: {
uuid: event.value.uuid,
page: 1,
},
data: {
event: {
...cachedData?.event,
conversations: {
...cachedData.event.conversations,
total: cachedData.event.conversations.total + 1,
elements: [
...cachedData.event.conversations.elements,
result.data.sendEventPrivateMessage,
],
},
},
},
});
},
});
const { currentActor } = useCurrentActorClient();
const sendForm = (e: Event) => {
e.preventDefault();
console.debug("Sending new private message");
if (!currentActor.value?.id || !event.value.id) return;
eventPrivateMessageMutate({
text: text.value,
actorId:
event.value?.attributedTo?.id ??
event.value.organizerActor?.id ??
currentActor.value?.id,
eventId: event.value.id,
});
};
onEventPrivateMessageMutated(() => {
text.value = "";
});
const Editor = defineAsyncComponent(
() => import("../../components/TextEditor.vue")
);
</script>

View File

@@ -5,7 +5,9 @@
</header>
<section>
<div class="flex gap-1 flex-row mb-3">
<div
class="flex gap-1 flex-row mb-3 bg-mbz-yellow p-3 rounded items-center"
>
<o-icon
icon="alert"
variant="warning"

View File

@@ -273,7 +273,7 @@ import Placeholder from "@tiptap/extension-placeholder";
const props = withDefaults(
defineProps<{
modelValue: string;
mode?: string;
mode?: "description" | "comment" | "basic";
maxSize?: number;
ariaLabel?: string;
currentActor: IPerson;
@@ -305,12 +305,6 @@ const isBasicMode = computed((): boolean => {
return props.mode === "basic";
});
// const insertMention = (obj: { range: any; attrs: any }) => {
// console.debug("initialize Mention");
// };
// const observer = ref<MutationObserver | null>(null);
const transformPastedHTML = (html: string): string => {
// When using comment mode, limit to acceptable tags
if (isCommentMode.value) {