mobilizon/src/views/Discussions/DiscussionView.vue

504 lines
13 KiB
Vue

<template>
<div class="container mx-auto" v-if="discussion">
<breadcrumbs-nav
v-if="group"
:links="[
{
name: RouteName.MY_GROUPS,
text: t('My groups'),
},
{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group) },
text: displayName(group),
},
{
name: RouteName.DISCUSSION_LIST,
params: { preferredUsername: usernameWithDomain(group) },
text: t('Discussions'),
},
{
name: RouteName.DISCUSSION,
params: { id: discussion.id },
text: discussion.title,
},
]"
/>
<o-notification v-if="error" variant="danger">
{{ error }}
</o-notification>
<section v-if="currentActor">
<div class="flex items-center gap-2" dir="auto">
<h1 class="" v-if="discussion.title && !editTitleMode">
{{ discussion.title }}
</h1>
<o-button
icon-right="pencil"
size="small"
:title="t('Update discussion title')"
v-if="
discussion.creator &&
!editTitleMode &&
(currentActor?.id === discussion.creator.id ||
isCurrentActorAGroupModerator)
"
@click="
() => {
newTitle = discussion?.title ?? '';
editTitleMode = true;
}
"
>
</o-button>
<o-skeleton
v-else-if="!editTitleMode && discussionLoading"
height="50px"
animated
/>
<form
v-else-if="!discussionLoading && !error"
v-show="editTitleMode"
@submit.prevent="updateDiscussion"
class="w-full"
>
<o-field :label="t('Title')" label-for="discussion-title">
<o-input
:value="discussion.title"
v-model="newTitle"
expanded
id="discussion-title"
/>
</o-field>
<div class="flex gap-2 mt-2">
<o-button
variant="primary"
native-type="submit"
icon-right="check"
:title="t('Update discussion title')"
/>
<o-button
@click="
() => {
editTitleMode = false;
newTitle = '';
}
"
outlined
icon-right="close"
:title="t('Cancel discussion title edition')"
/>
<o-button
@click="openDeleteDiscussionConfirmation"
variant="danger"
native-type="button"
icon-left="delete"
>{{ t("Delete conversation") }}</o-button
>
</div>
</form>
</div>
<discussion-comment
class="border rounded-md p-2 mt-4"
v-for="comment in discussion.comments.elements"
:key="comment.id"
:model-value="comment"
:current-actor="currentActor"
@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="discussion.comments.elements.length < discussion.comments.total"
@click="loadMoreComments"
>{{ t("Fetch more") }}</o-button
>
<form @submit.prevent="reply" v-if="!error">
<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>
</section>
</div>
</template>
<script lang="ts" setup>
import {
GET_DISCUSSION,
REPLY_TO_DISCUSSION,
UPDATE_DISCUSSION,
DELETE_DISCUSSION,
DISCUSSION_COMMENT_CHANGED,
} from "@/graphql/discussion";
import { IDiscussion } from "@/types/discussions";
import { displayName, IPerson, usernameWithDomain } from "@/types/actor";
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,
onMounted,
onUnmounted,
ref,
computed,
inject,
} from "vue";
import { useHead } from "@unhead/vue";
import { useRouter } from "vue-router";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { AbsintheGraphQLError } from "@/types/errors.model";
import { MemberRole } from "@/types/enums";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Dialog } from "@/plugins/dialog";
import { useI18n } from "vue-i18n";
const props = defineProps<{ slug: string }>();
const page = ref(1);
const COMMENTS_PER_PAGE = 10;
const { currentActor } = useCurrentActorClient();
const {
result: discussionResult,
onError: onDiscussionError,
subscribeToMore,
fetchMore,
loading: discussionLoading,
} = useQuery<{ discussion: IDiscussion }>(
GET_DISCUSSION,
() => ({
slug: props.slug,
page: page.value,
limit: COMMENTS_PER_PAGE,
}),
() => ({
enabled: props.slug !== undefined,
})
);
subscribeToMore({
document: DISCUSSION_COMMENT_CHANGED,
variables: () => ({
slug: props.slug,
page: page.value,
limit: COMMENTS_PER_PAGE,
}),
updateQuery(
previousResult: any,
{ subscriptionData }: { subscriptionData: any }
) {
const previousDiscussion = previousResult.discussion;
const lastComment =
subscriptionData.data.discussionCommentChanged.lastComment;
hasMoreComments.value = !previousDiscussion.comments.elements.some(
(comment: IComment) => comment.id === lastComment.id
);
if (hasMoreComments.value) {
return {
discussion: {
...previousDiscussion,
lastComment: lastComment,
comments: {
elements: [
...previousDiscussion.comments.elements.filter(
({ id }: { id: string }) => id !== lastComment.id
),
lastComment,
],
total: previousDiscussion.comments.total + 1,
},
},
};
}
return previousDiscussion;
},
});
const discussion = computed(() => discussionResult.value?.discussion);
const group = computed(() => discussion.value?.actor);
const Editor = defineAsyncComponent(
() => import("@/components/TextEditor.vue")
);
useHead({
title: computed(() => discussion.value?.title ?? ""),
});
const newComment = ref("");
const newTitle = ref("");
const editTitleMode = ref(false);
const hasMoreComments = ref(true);
const error = ref<string | null>(null);
const { mutate: replyToDiscussionMutation } = useMutation<{
replyToDiscussion: IDiscussion;
}>(REPLY_TO_DISCUSSION, () => ({
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
const discussionData = store.readQuery<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: props.slug,
page: page.value,
},
});
if (!discussionData) return;
const { discussion: discussionCached } = discussionData;
store.writeQuery({
query: GET_DISCUSSION,
variables: { slug: props.slug, page: page.value },
data: {
discussion: {
...discussionCached,
lastComment: data?.replyToDiscussion.lastComment,
comments: {
elements: [
...discussionCached.comments.elements,
data?.replyToDiscussion.lastComment,
],
total: discussionCached.comments.total + 1,
},
},
},
});
},
}));
const reply = () => {
if (newComment.value === "") return;
replyToDiscussionMutation({
discussionId: discussion.value?.id,
text: newComment.value,
});
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<{
discussion: IDiscussion;
}>({
query: GET_DISCUSSION,
variables: {
slug: props.slug,
page: page.value,
},
});
if (!discussionData) return;
const { discussion: 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_DISCUSSION,
variables: { slug: props.slug, page: page.value },
data: { discussion: 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;
page.value++;
try {
await fetchMore({
// New variables
variables: {
slug: props.slug,
page: page.value,
limit: COMMENTS_PER_PAGE,
},
});
hasMoreComments.value = !discussion.value?.comments.elements
.map(({ id }) => id)
.includes(discussion.value?.lastComment?.id);
} catch (e) {
console.error(e);
}
};
const { mutate: updateDiscussionMutation } = useMutation<{
updateDiscussion: IDiscussion;
}>(UPDATE_DISCUSSION);
const updateDiscussion = async (): Promise<void> => {
updateDiscussionMutation({
discussionId: discussion.value?.id,
title: newTitle.value,
});
editTitleMode.value = false;
};
const { t } = useI18n({ useScope: "global" });
const dialog = inject<Dialog>("dialog");
const openDeleteDiscussionConfirmation = (): void => {
dialog?.confirm({
variant: "danger",
title: t("Delete this discussion"),
message: t("Are you sure you want to delete this entire discussion?"),
confirmText: t("Delete discussion"),
cancelText: t("Cancel"),
onConfirm: () =>
deleteConversation({
discussionId: discussion.value?.id,
}),
});
};
const router = useRouter();
const { mutate: deleteConversation, onDone: deleteConversationDone } =
useMutation(DELETE_DISCUSSION);
deleteConversationDone(() => {
if (discussion.value?.actor) {
router.push({
name: RouteName.DISCUSSION_LIST,
params: {
preferredUsername: usernameWithDomain(discussion.value.actor),
},
});
}
});
onDiscussionError((discussionError) =>
handleErrors(discussionError.graphQLErrors as AbsintheGraphQLError[])
);
const handleErrors = async (errors: AbsintheGraphQLError[]): Promise<void> => {
if (errors[0].message.includes("No such discussion")) {
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 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) {
loadMoreComments();
}
};
const isCurrentActorAGroupModerator = computed((): boolean => {
return hasCurrentActorThisRole([
MemberRole.MODERATOR,
MemberRole.ADMINISTRATOR,
]);
});
const { result: membershipsResult } = useQuery<{
person: Pick<IPerson, "memberships">;
}>(
PERSON_MEMBERSHIPS,
() => ({ id: currentActor.value?.id }),
() => ({ enabled: currentActor.value?.id !== undefined })
);
const memberships = computed(() => membershipsResult.value?.person.memberships);
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole)
? givenRole
: ([givenRole] as MemberRole[]);
return (
(memberships.value?.total ?? 0) > 0 &&
roles.includes(memberships.value?.elements[0].role as MemberRole)
);
};
</script>