567 lines
15 KiB
Vue
567 lines
15 KiB
Vue
<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 && !isCurrentActorAuthor"
|
|
class="bg-mbz-yellow p-6 mb-3 rounded flex gap-2 items-center"
|
|
>
|
|
<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>
|
|
<o-notification v-if="isCurrentActorAuthor" variant="info" closable>
|
|
<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>
|
|
</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">
|
|
{{ 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-3"
|
|
>
|
|
<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 "@unhead/vue";
|
|
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 groupParticipants = computed(() => {
|
|
return otherParticipants.value.filter(
|
|
(participant) => participant.type === ActorType.GROUP
|
|
);
|
|
});
|
|
|
|
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))
|
|
),
|
|
})
|
|
);
|
|
|
|
const isCurrentActorAuthor = computed(
|
|
() =>
|
|
currentActor.value &&
|
|
conversation.value &&
|
|
currentActor.value.id !== conversation.value?.actor?.id
|
|
);
|
|
|
|
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>
|