160
js/src/components/Conversations/ConversationListItem.vue
Normal file
160
js/src/components/Conversations/ConversationListItem.vue
Normal 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>
|
||||
69
js/src/components/Conversations/EventConversations.vue
Normal file
69
js/src/components/Conversations/EventConversations.vue
Normal 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>
|
||||
137
js/src/components/Conversations/NewConversation.vue
Normal file
137
js/src/components/Conversations/NewConversation.vue
Normal 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>
|
||||
Reference in New Issue
Block a user