build: switch from yarn to npm to manage js dependencies and move js contents to root
yarn v1 is being deprecated and starts to have some issues Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
94
src/views/Conversations/ConversationListView.vue
Normal file
94
src/views/Conversations/ConversationListView.vue
Normal file
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="conversations">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.CONVERSATION_LIST,
|
||||
text: t('Conversations'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<section>
|
||||
<h1>{{ t("Conversations") }}</h1>
|
||||
<o-button @click="openNewMessageModal">{{
|
||||
t("New private message")
|
||||
}}</o-button>
|
||||
<div v-if="conversations.elements.length > 0" class="my-2">
|
||||
<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 RouteName from "../../router/name";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { 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";
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const CONVERSATIONS_PER_PAGE = 10;
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("List of conversations")),
|
||||
});
|
||||
|
||||
const error = ref(false);
|
||||
|
||||
const { result: conversationsResult } = useQuery<{
|
||||
loggedPerson: Pick<IPerson, "conversations">;
|
||||
}>(PROFILE_CONVERSATIONS, () => ({
|
||||
page: page.value,
|
||||
}));
|
||||
|
||||
const conversations = computed(
|
||||
() =>
|
||||
conversationsResult.value?.loggedPerson.conversations || {
|
||||
elements: [],
|
||||
total: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const { oruga } = useProgrammatic();
|
||||
|
||||
const NewConversation = defineAsyncComponent(
|
||||
() => import("@/components/Conversations/NewConversation.vue")
|
||||
);
|
||||
|
||||
const openNewMessageModal = () => {
|
||||
oruga.modal.open({
|
||||
component: NewConversation,
|
||||
trapFocus: true,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
527
src/views/Conversations/ConversationView.vue
Normal file
527
src/views/Conversations/ConversationView.vue
Normal file
@@ -0,0 +1,527 @@
|
||||
<template>
|
||||
<div class="container mx-auto" v-if="conversation">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.CONVERSATION_LIST,
|
||||
text: t('Conversations'),
|
||||
},
|
||||
{
|
||||
name: RouteName.CONVERSATION,
|
||||
params: { id: conversation.id },
|
||||
text: title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="conversation.event"
|
||||
class="bg-mbz-yellow p-6 mb-6 rounded flex gap-2 items-center"
|
||||
>
|
||||
<Calendar :size="36" />
|
||||
<i18n-t
|
||||
tag="p"
|
||||
keypath="This is a announcement from the organizers of event {event}"
|
||||
>
|
||||
<template #event>
|
||||
<b>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: conversation.event.uuid },
|
||||
}"
|
||||
>{{ conversation.event.title }}</router-link
|
||||
>
|
||||
</b>
|
||||
</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"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="You have access to this conversation as a member of the {group} group"
|
||||
tag="p"
|
||||
>
|
||||
<template #group>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(conversation.actor),
|
||||
},
|
||||
}"
|
||||
><b>{{ displayName(conversation.actor) }}</b></router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<section v-if="currentActor">
|
||||
<discussion-comment
|
||||
v-for="comment in conversation.comments.elements"
|
||||
:key="comment.id"
|
||||
:model-value="comment"
|
||||
:current-actor="currentActor"
|
||||
:can-report="true"
|
||||
@update:modelValue="
|
||||
(comment: IComment) =>
|
||||
updateComment({
|
||||
commentId: comment.id as string,
|
||||
text: comment.text,
|
||||
})
|
||||
"
|
||||
@delete-comment="
|
||||
(comment: IComment) =>
|
||||
deleteComment({
|
||||
commentId: comment.id as string,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<o-button
|
||||
v-if="
|
||||
conversation.comments.elements.length < conversation.comments.total
|
||||
"
|
||||
@click="loadMoreComments"
|
||||
>{{ t("Fetch more") }}</o-button
|
||||
>
|
||||
<form @submit.prevent="reply" v-if="!error && !conversation.event">
|
||||
<o-field :label="t('Text')">
|
||||
<Editor
|
||||
v-model="newComment"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button
|
||||
class="my-2"
|
||||
native-type="submit"
|
||||
:disabled="['<p></p>', ''].includes(newComment)"
|
||||
variant="primary"
|
||||
>{{ t("Reply") }}</o-button
|
||||
>
|
||||
</form>
|
||||
<div
|
||||
v-else-if="conversation.event"
|
||||
class="bg-mbz-yellow p-6 rounded flex gap-2 items-center mt-6"
|
||||
>
|
||||
<Calendar :size="36" />
|
||||
<i18n-t
|
||||
tag="p"
|
||||
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>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: conversation.event.uuid },
|
||||
}"
|
||||
>{{ conversation.event.title }}</router-link
|
||||
>
|
||||
</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
CONVERSATION_COMMENT_CHANGED,
|
||||
GET_CONVERSATION,
|
||||
MARK_CONVERSATION_AS_READ,
|
||||
REPLY_TO_PRIVATE_MESSAGE_MUTATION,
|
||||
} from "../../graphql/conversations";
|
||||
import DiscussionComment from "../../components/Discussion/DiscussionComment.vue";
|
||||
import { DELETE_COMMENT, UPDATE_COMMENT } from "../../graphql/comment";
|
||||
import RouteName from "../../router/name";
|
||||
import { IComment } from "../../types/comment.model";
|
||||
import {
|
||||
ApolloCache,
|
||||
FetchResult,
|
||||
InMemoryCache,
|
||||
gql,
|
||||
} from "@apollo/client/core";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from "vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useCurrentActorClient } from "../../composition/apollo/actor";
|
||||
import { AbsintheGraphQLError } from "../../types/errors.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { IConversation } from "@/types/conversation";
|
||||
import { usernameWithDomain, displayName } from "@/types/actor";
|
||||
import { formatList } from "@/utils/i18n";
|
||||
import throttle from "lodash/throttle";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import { ActorType } from "@/types/enums";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const conversationId = computed(() => props.id);
|
||||
|
||||
const page = ref(1);
|
||||
const COMMENTS_PER_PAGE = 10;
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const {
|
||||
result: conversationResult,
|
||||
onResult: onConversationResult,
|
||||
onError: onConversationError,
|
||||
subscribeToMore,
|
||||
fetchMore,
|
||||
} = useQuery<{ conversation: IConversation }>(
|
||||
GET_CONVERSATION,
|
||||
() => ({
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
}),
|
||||
() => ({
|
||||
enabled: conversationId.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
subscribeToMore({
|
||||
document: CONVERSATION_COMMENT_CHANGED,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
updateQuery(
|
||||
previousResult: any,
|
||||
{ subscriptionData }: { subscriptionData: any }
|
||||
) {
|
||||
const previousConversation = previousResult.conversation;
|
||||
const lastComment =
|
||||
subscriptionData.data.conversationCommentChanged.lastComment;
|
||||
hasMoreComments.value = !previousConversation.comments.elements.some(
|
||||
(comment: IComment) => comment.id === lastComment.id
|
||||
);
|
||||
if (hasMoreComments.value) {
|
||||
return {
|
||||
conversation: {
|
||||
...previousConversation,
|
||||
lastComment: lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...previousConversation.comments.elements.filter(
|
||||
({ id }: { id: string }) => id !== lastComment.id
|
||||
),
|
||||
lastComment,
|
||||
],
|
||||
total: previousConversation.comments.total + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return previousConversation;
|
||||
},
|
||||
});
|
||||
|
||||
const conversation = computed(() => conversationResult.value?.conversation);
|
||||
const otherParticipants = computed(
|
||||
() =>
|
||||
conversation.value?.participants.filter(
|
||||
(participant) => participant.id !== currentActor.value?.id
|
||||
) ?? []
|
||||
);
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("../../components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const title = computed(() =>
|
||||
t("Conversation with {participants}", {
|
||||
participants: formatList(
|
||||
otherParticipants.value.map((participant) => displayName(participant))
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: title.value,
|
||||
});
|
||||
|
||||
const newComment = ref("");
|
||||
// const newTitle = ref("");
|
||||
// const editTitleMode = ref(false);
|
||||
const hasMoreComments = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const { mutate: replyToConversationMutation } = useMutation<
|
||||
{
|
||||
postPrivateMessage: IConversation;
|
||||
},
|
||||
{
|
||||
text: string;
|
||||
actorId: string;
|
||||
language?: string;
|
||||
conversationId: string;
|
||||
mentions?: string[];
|
||||
attributedToId?: string;
|
||||
}
|
||||
>(REPLY_TO_PRIVATE_MESSAGE_MUTATION, () => ({
|
||||
update: (store: ApolloCache<InMemoryCache>, { data }) => {
|
||||
console.debug("update after reply to", [conversationId.value, page.value]);
|
||||
const conversationData = store.readQuery<{
|
||||
conversation: IConversation;
|
||||
}>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
});
|
||||
console.debug("update after reply to", conversationData);
|
||||
if (!conversationData) return;
|
||||
const { conversation: conversationCached } = conversationData;
|
||||
|
||||
console.debug("got cache", conversationCached);
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
data: {
|
||||
conversation: {
|
||||
...conversationCached,
|
||||
lastComment: data?.postPrivateMessage.lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...conversationCached.comments.elements,
|
||||
data?.postPrivateMessage.lastComment,
|
||||
],
|
||||
total: conversationCached.comments.total + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const reply = () => {
|
||||
if (
|
||||
newComment.value === "" ||
|
||||
!conversation.value?.id ||
|
||||
!currentActor.value?.id
|
||||
)
|
||||
return;
|
||||
|
||||
replyToConversationMutation({
|
||||
conversationId: conversation.value?.id,
|
||||
text: newComment.value,
|
||||
actorId: currentActor.value?.id,
|
||||
mentions: otherParticipants.value.map((participant) =>
|
||||
usernameWithDomain(participant)
|
||||
),
|
||||
attributedToId:
|
||||
conversation.value?.actor?.type === ActorType.GROUP
|
||||
? conversation.value?.actor.id
|
||||
: undefined,
|
||||
});
|
||||
|
||||
newComment.value = "";
|
||||
};
|
||||
|
||||
const { mutate: updateComment } = useMutation<
|
||||
{ updateComment: IComment },
|
||||
{ commentId: string; text: string }
|
||||
>(UPDATE_COMMENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{ deleteComment: IComment }>,
|
||||
{ data }: FetchResult
|
||||
) => {
|
||||
if (!data || !data.deleteComment) return;
|
||||
const discussionData = store.readQuery<{
|
||||
conversation: IConversation;
|
||||
}>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
},
|
||||
});
|
||||
if (!discussionData) return;
|
||||
const { conversation: discussionCached } = discussionData;
|
||||
const index = discussionCached.comments.elements.findIndex(
|
||||
({ id }) => id === data.deleteComment.id
|
||||
);
|
||||
if (index > -1) {
|
||||
discussionCached.comments.elements.splice(index, 1);
|
||||
discussionCached.comments.total -= 1;
|
||||
}
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: { id: conversationId.value, page: page.value },
|
||||
data: { conversation: discussionCached },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const { mutate: deleteComment } = useMutation<
|
||||
{ deleteComment: { id: string } },
|
||||
{ commentId: string }
|
||||
>(DELETE_COMMENT, () => ({
|
||||
update: (store: ApolloCache<{ deleteComment: IComment }>, { data }) => {
|
||||
const id = data?.deleteComment?.id;
|
||||
if (!id) return;
|
||||
store.writeFragment({
|
||||
id: `Comment:${id}`,
|
||||
fragment: gql`
|
||||
fragment CommentDeleted on Comment {
|
||||
deletedAt
|
||||
actor {
|
||||
id
|
||||
}
|
||||
text
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
text: "",
|
||||
actor: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const loadMoreComments = async (): Promise<void> => {
|
||||
if (!hasMoreComments.value) return;
|
||||
console.debug("Loading more comments");
|
||||
page.value++;
|
||||
try {
|
||||
await fetchMore({
|
||||
// New variables
|
||||
variables: () => ({
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
}),
|
||||
});
|
||||
hasMoreComments.value = !conversation.value?.comments.elements
|
||||
.map(({ id }) => id)
|
||||
.includes(conversation.value?.lastComment?.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// const dialog = inject<Dialog>("dialog");
|
||||
|
||||
// const openDeleteDiscussionConfirmation = (): void => {
|
||||
// dialog?.confirm({
|
||||
// variant: "danger",
|
||||
// title: t("Delete this conversation"),
|
||||
// message: t("Are you sure you want to delete this entire conversation?"),
|
||||
// confirmText: t("Delete conversation"),
|
||||
// cancelText: t("Cancel"),
|
||||
// onConfirm: () =>
|
||||
// deleteConversation({
|
||||
// discussionId: conversation.value?.id,
|
||||
// }),
|
||||
// });
|
||||
// };
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// const { mutate: deleteConversation, onDone: deleteConversationDone } =
|
||||
// useMutation(DELETE_DISCUSSION);
|
||||
|
||||
// deleteConversationDone(() => {
|
||||
// if (conversation.value?.actor) {
|
||||
// router.push({
|
||||
// name: RouteName.DISCUSSION_LIST,
|
||||
// params: {
|
||||
// preferredUsername: usernameWithDomain(conversation.value.actor),
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
onConversationError((discussionError) =>
|
||||
handleErrors(discussionError.graphQLErrors as AbsintheGraphQLError[])
|
||||
);
|
||||
|
||||
onConversationResult(({ data }) => {
|
||||
if (
|
||||
page.value === 1 &&
|
||||
data?.conversation?.comments?.total &&
|
||||
data?.conversation?.comments?.total < COMMENTS_PER_PAGE
|
||||
) {
|
||||
markConversationAsRead();
|
||||
}
|
||||
});
|
||||
|
||||
const handleErrors = async (errors: AbsintheGraphQLError[]): Promise<void> => {
|
||||
if (errors[0].code === "not_found") {
|
||||
await router.push({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
if (errors[0].code === "unauthorized") {
|
||||
error.value = errors[0].message;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
const { mutate: markConversationAsRead } = useMutation<
|
||||
{
|
||||
updateConversation: IConversation;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
read: boolean;
|
||||
}
|
||||
>(MARK_CONVERSATION_AS_READ, {
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loadMoreCommentsThrottled = throttle(async () => {
|
||||
console.log("Throttled");
|
||||
await loadMoreComments();
|
||||
if (!hasMoreComments.value && conversation.value?.unread) {
|
||||
console.debug("marking as read");
|
||||
markConversationAsRead();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const handleScroll = (): void => {
|
||||
const scrollTop =
|
||||
(document.documentElement && document.documentElement.scrollTop) ||
|
||||
document.body.scrollTop;
|
||||
const scrollHeight =
|
||||
(document.documentElement && document.documentElement.scrollHeight) ||
|
||||
document.body.scrollHeight;
|
||||
const clientHeight =
|
||||
document.documentElement.clientHeight || window.innerHeight;
|
||||
const scrolledToBottom =
|
||||
Math.ceil(scrollTop + clientHeight + 800) >= scrollHeight;
|
||||
if (scrolledToBottom) {
|
||||
console.debug("Scrolled to bottom");
|
||||
loadMoreCommentsThrottled();
|
||||
}
|
||||
};
|
||||
</script>
|
||||
Reference in New Issue
Block a user