Migrate to Vue 3 and Vite
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
177
js/src/components/Comment/Comment.story.vue
Normal file
177
js/src/components/Comment/Comment.story.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<template>
|
||||
<Story :setup-app="setupApp">
|
||||
<Variant title="Basic">
|
||||
<Comment
|
||||
:comment="comment"
|
||||
:event="event"
|
||||
:currentActor="baseActor"
|
||||
@create-comment="hstEvent('Create comment', $event)"
|
||||
@delete-comment="hstEvent('Delete comment', $event)"
|
||||
@report-comment="hstEvent('Report comment', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
<Variant title="Announcement">
|
||||
<Comment
|
||||
:comment="{ ...comment, isAnnouncement: true }"
|
||||
:event="event"
|
||||
:currentActor="baseActor"
|
||||
@create-comment="hstEvent('Create comment', $event)"
|
||||
@delete-comment="hstEvent('Delete comment', $event)"
|
||||
@report-comment="hstEvent('Report comment', $event)"
|
||||
/>
|
||||
</Variant>
|
||||
</Story>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { IActor } from "@/types/actor";
|
||||
import { IComment } from "@/types/comment.model";
|
||||
import {
|
||||
ActorType,
|
||||
CommentModeration,
|
||||
EventJoinOptions,
|
||||
EventStatus,
|
||||
EventVisibility,
|
||||
} from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { reactive } from "vue";
|
||||
import Comment from "./Comment.vue";
|
||||
import FloatingVue from "floating-vue";
|
||||
import "floating-vue/dist/style.css";
|
||||
import { hstEvent } from "histoire/client";
|
||||
|
||||
function setupApp({ app }) {
|
||||
app.use(FloatingVue);
|
||||
}
|
||||
|
||||
const baseActorAvatar = {
|
||||
id: "",
|
||||
name: "",
|
||||
alt: "",
|
||||
metadata: {},
|
||||
url: "https://social.tcit.fr/system/accounts/avatars/000/000/001/original/a28c50ce5f2b13fd.jpg",
|
||||
};
|
||||
|
||||
const baseActor: IActor = {
|
||||
name: "Thomas Citharel",
|
||||
preferredUsername: "tcit",
|
||||
avatar: baseActorAvatar,
|
||||
domain: null,
|
||||
url: "",
|
||||
summary: "",
|
||||
suspended: false,
|
||||
type: ActorType.PERSON,
|
||||
id: "598",
|
||||
};
|
||||
|
||||
const baseEvent: IEvent = {
|
||||
uuid: "",
|
||||
title: "A very interesting event",
|
||||
description: "Things happen",
|
||||
beginsOn: new Date(),
|
||||
endsOn: new Date(),
|
||||
physicalAddress: {
|
||||
description: "Somewhere",
|
||||
street: "",
|
||||
locality: "",
|
||||
region: "",
|
||||
country: "",
|
||||
type: "",
|
||||
postalCode: "",
|
||||
},
|
||||
picture: {
|
||||
id: "",
|
||||
name: "",
|
||||
alt: "",
|
||||
metadata: {},
|
||||
url: "https://mobilizon.fr/media/81d9c76aaf740f84eefb28cf2b9988bdd2495ab1f3246159fd688e242155cb23.png?name=Screenshot_20220315_171848.png",
|
||||
},
|
||||
url: "",
|
||||
local: true,
|
||||
slug: "",
|
||||
publishAt: new Date(),
|
||||
status: EventStatus.CONFIRMED,
|
||||
visibility: EventVisibility.PUBLIC,
|
||||
joinOptions: EventJoinOptions.FREE,
|
||||
draft: false,
|
||||
participantStats: {
|
||||
notApproved: 0,
|
||||
notConfirmed: 0,
|
||||
rejected: 0,
|
||||
participant: 0,
|
||||
creator: 0,
|
||||
moderator: 0,
|
||||
administrator: 0,
|
||||
going: 0,
|
||||
},
|
||||
participants: { total: 0, elements: [] },
|
||||
relatedEvents: [],
|
||||
tags: [{ slug: "something", title: "Something" }],
|
||||
attributedTo: undefined,
|
||||
organizerActor: {
|
||||
...baseActor,
|
||||
name: "Hello",
|
||||
avatar: {
|
||||
...baseActorAvatar,
|
||||
url: "https://mobilizon.fr/media/653c2dcbb830636e0db975798163b85e038dfb7713e866e96d36bd411e105e3c.png?name=festivalsanantes%27s%20avatar.png",
|
||||
},
|
||||
},
|
||||
comments: [],
|
||||
options: {
|
||||
maximumAttendeeCapacity: 0,
|
||||
remainingAttendeeCapacity: 0,
|
||||
showRemainingAttendeeCapacity: false,
|
||||
anonymousParticipation: false,
|
||||
hideOrganizerWhenGroupEvent: false,
|
||||
offers: [],
|
||||
participationConditions: [],
|
||||
attendees: [],
|
||||
program: "",
|
||||
commentModeration: CommentModeration.ALLOW_ALL,
|
||||
showParticipationPrice: false,
|
||||
showStartTime: false,
|
||||
showEndTime: false,
|
||||
timezone: null,
|
||||
isOnline: false,
|
||||
},
|
||||
metadata: [],
|
||||
contacts: [],
|
||||
language: "en",
|
||||
category: "hello",
|
||||
};
|
||||
|
||||
const event = reactive<IEvent>(baseEvent);
|
||||
|
||||
const comment = reactive<IComment>({
|
||||
text: "hello",
|
||||
local: true,
|
||||
actor: baseActor,
|
||||
totalReplies: 5,
|
||||
replies: [
|
||||
{
|
||||
text: "a reply!",
|
||||
id: "90",
|
||||
actor: baseActor,
|
||||
updatedAt: new Date(),
|
||||
url: "http://somewhere.tld",
|
||||
replies: [],
|
||||
totalReplies: 0,
|
||||
isAnnouncement: false,
|
||||
local: false,
|
||||
},
|
||||
{
|
||||
text: "a reply to another reply!",
|
||||
id: "92",
|
||||
actor: baseActor,
|
||||
updatedAt: new Date(),
|
||||
url: "http://somewhere.tld",
|
||||
replies: [],
|
||||
totalReplies: 0,
|
||||
isAnnouncement: false,
|
||||
local: false,
|
||||
},
|
||||
],
|
||||
isAnnouncement: false,
|
||||
updatedAt: new Date(),
|
||||
url: "http://somewhere.tld",
|
||||
});
|
||||
</script>
|
||||
@@ -2,347 +2,348 @@
|
||||
<li
|
||||
:class="{
|
||||
reply: comment.inReplyToComment,
|
||||
announcement: comment.isAnnouncement,
|
||||
selected: commentSelected,
|
||||
'bg-purple-2': comment.isAnnouncement,
|
||||
'bg-violet-1': commentSelected,
|
||||
'shadow-none': !rootComment,
|
||||
}"
|
||||
class="comment-element"
|
||||
class="mbz-card p-2"
|
||||
>
|
||||
<article class="media" :id="commentId" dir="auto">
|
||||
<popover-actor-card
|
||||
:actor="comment.actor"
|
||||
:inline="true"
|
||||
v-if="comment.actor"
|
||||
>
|
||||
<figure
|
||||
class="image is-32x32 media-left"
|
||||
v-if="!comment.deletedAt && comment.actor.avatar"
|
||||
>
|
||||
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon class="media-left" v-else icon="account-circle" />
|
||||
</popover-actor-card>
|
||||
<div v-else class="media-left">
|
||||
<figure
|
||||
class="image is-32x32"
|
||||
v-if="!comment.deletedAt && comment.actor.avatar"
|
||||
>
|
||||
<img class="is-rounded" :src="comment.actor.avatar.url" alt="" />
|
||||
</figure>
|
||||
<b-icon v-else icon="account-circle" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<span class="first-line" v-if="!comment.deletedAt" dir="auto">
|
||||
<strong :class="{ organizer: commentFromOrganizer }">{{
|
||||
comment.actor.name
|
||||
}}</strong>
|
||||
<small dir="ltr">@{{ usernameWithDomain(comment.actor) }}</small>
|
||||
</span>
|
||||
<a v-else class="comment-link" :href="commentURL">
|
||||
<span>{{ $t("[deleted]") }}</span>
|
||||
<article :id="commentId" dir="auto">
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex items-center gap-1" v-if="actorComment">
|
||||
<popover-actor-card
|
||||
:actor="actorComment"
|
||||
:inline="true"
|
||||
v-if="!comment.deletedAt && actorComment.avatar"
|
||||
>
|
||||
<figure>
|
||||
<img
|
||||
class="rounded-xl"
|
||||
:src="actorComment.avatar.url"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</figure>
|
||||
</popover-actor-card>
|
||||
<AccountCircle v-else />
|
||||
<strong
|
||||
v-if="!comment.deletedAt"
|
||||
dir="auto"
|
||||
:class="{ organizer: commentFromOrganizer }"
|
||||
>{{ actorComment?.name }}</strong
|
||||
>
|
||||
</div>
|
||||
|
||||
<a v-else :href="commentURL">
|
||||
<span>{{ t("[deleted]") }}</span>
|
||||
</a>
|
||||
<a class="comment-link" :href="commentURL">
|
||||
<small>{{
|
||||
<a :href="commentURL">
|
||||
<small v-if="comment.updatedAt">{{
|
||||
formatDistanceToNow(new Date(comment.updatedAt), {
|
||||
locale: $dateFnsLocale,
|
||||
locale: dateFnsLocale,
|
||||
addSuffix: true,
|
||||
})
|
||||
}}</small>
|
||||
</a>
|
||||
<span class="icons" v-if="!comment.deletedAt">
|
||||
<div v-if="!comment.deletedAt" class="flex">
|
||||
<button
|
||||
v-if="comment.actor.id === currentActor.id"
|
||||
v-if="actorComment?.id === currentActor?.id"
|
||||
@click="deleteComment"
|
||||
>
|
||||
<b-icon icon="delete" size="is-small" aria-hidden="true" />
|
||||
<span class="visually-hidden">{{ $t("Delete") }}</span>
|
||||
<Delete :size="16" />
|
||||
<span class="sr-only">{{ t("Delete") }}</span>
|
||||
</button>
|
||||
<button @click="reportModal()">
|
||||
<b-icon icon="alert" size="is-small" />
|
||||
<span class="visually-hidden">{{ $t("Report") }}</span>
|
||||
<button @click="reportModal">
|
||||
<Alert :size="16" />
|
||||
<span class="sr-only">{{ t("Report") }}</span>
|
||||
</button>
|
||||
</span>
|
||||
<br />
|
||||
<div
|
||||
v-if="!comment.deletedAt"
|
||||
v-html="comment.text"
|
||||
dir="auto"
|
||||
:lang="comment.language"
|
||||
/>
|
||||
<div v-else>{{ $t("[This comment has been deleted]") }}</div>
|
||||
<div class="load-replies" v-if="comment.totalReplies">
|
||||
<p v-if="!showReplies" @click="fetchReplies">
|
||||
<b-icon icon="chevron-down" class="reply-btn" />
|
||||
<span class="reply-btn">{{
|
||||
$tc("View a reply", comment.totalReplies, {
|
||||
totalReplies: comment.totalReplies,
|
||||
})
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="comment.totalReplies && showReplies"
|
||||
@click="showReplies = false"
|
||||
>
|
||||
<b-icon icon="chevron-up" class="reply-btn" />
|
||||
<span class="reply-btn">{{ $t("Hide replies") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="!comment.deletedAt"
|
||||
v-html="comment.text"
|
||||
dir="auto"
|
||||
:lang="comment.language"
|
||||
/>
|
||||
<div v-else>{{ t("[This comment has been deleted]") }}</div>
|
||||
<div class="" v-if="comment.totalReplies">
|
||||
<p
|
||||
v-if="!showReplies"
|
||||
@click="showReplies = true"
|
||||
class="flex cursor-pointer"
|
||||
>
|
||||
<ChevronDown />
|
||||
<span>{{
|
||||
t(
|
||||
"View a reply",
|
||||
{
|
||||
totalReplies: comment.totalReplies,
|
||||
},
|
||||
comment.totalReplies
|
||||
)
|
||||
}}</span>
|
||||
</p>
|
||||
<p
|
||||
v-else-if="comment.totalReplies && showReplies"
|
||||
@click="showReplies = false"
|
||||
class="flex cursor-pointer"
|
||||
>
|
||||
<ChevronUp />
|
||||
<span>{{ t("Hide replies") }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<nav
|
||||
class="reply-action level is-mobile"
|
||||
v-if="
|
||||
currentActor.id &&
|
||||
currentActor?.id &&
|
||||
event.options.commentModeration !== CommentModeration.CLOSED &&
|
||||
!comment.deletedAt
|
||||
"
|
||||
@click="createReplyToComment()"
|
||||
class="flex gap-1 cursor-pointer"
|
||||
>
|
||||
<div class="level-left">
|
||||
<span
|
||||
style="cursor: pointer"
|
||||
class="level-item reply-btn"
|
||||
@click="createReplyToComment()"
|
||||
>
|
||||
<span class="icon is-small">
|
||||
<b-icon icon="reply" />
|
||||
</span>
|
||||
<span>{{ $t("Reply") }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Reply />
|
||||
<span>{{ t("Reply") }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
</article>
|
||||
<form
|
||||
class="reply"
|
||||
@submit.prevent="replyToComment"
|
||||
v-if="currentActor.id"
|
||||
v-if="currentActor?.id"
|
||||
v-show="replyTo"
|
||||
>
|
||||
<article class="media reply">
|
||||
<figure class="media-left" v-if="currentActor.avatar">
|
||||
<p class="image is-48x48">
|
||||
<img :src="currentActor.avatar.url" alt="" />
|
||||
</p>
|
||||
<article class="flex gap-2">
|
||||
<figure v-if="currentActor?.avatar" class="mt-4">
|
||||
<img
|
||||
:src="currentActor?.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
class="rounded-md"
|
||||
/>
|
||||
</figure>
|
||||
<b-icon
|
||||
class="media-left"
|
||||
v-else
|
||||
size="is-large"
|
||||
icon="account-circle"
|
||||
/>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<span class="first-line">
|
||||
<strong>{{ currentActor.name }}</strong>
|
||||
<small dir="ltr">@{{ currentActor.preferredUsername }}</small>
|
||||
</span>
|
||||
<br />
|
||||
<span class="editor-line">
|
||||
<editor
|
||||
class="editor"
|
||||
ref="commentEditor"
|
||||
v-model="newComment.text"
|
||||
mode="comment"
|
||||
:aria-label="$t('Comment body')"
|
||||
/>
|
||||
<b-button
|
||||
:disabled="newComment.text.trim().length === 0"
|
||||
native-type="submit"
|
||||
type="is-primary"
|
||||
>{{ $t("Post a reply") }}</b-button
|
||||
>
|
||||
</span>
|
||||
<AccountCircle v-else :size="48" />
|
||||
<div class="flex-1">
|
||||
<div class="flex gap-1 items-center">
|
||||
<strong>{{ currentActor?.name }}</strong>
|
||||
<small dir="ltr">@{{ currentActor?.preferredUsername }}</small>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<editor
|
||||
ref="commentEditor"
|
||||
v-model="newComment.text"
|
||||
mode="comment"
|
||||
:current-actor="currentActor"
|
||||
:aria-label="t('Comment body')"
|
||||
class="flex-1"
|
||||
/>
|
||||
<o-button
|
||||
:disabled="newComment.text.trim().length === 0"
|
||||
native-type="submit"
|
||||
variant="primary"
|
||||
class="self-end"
|
||||
>{{ t("Post a reply") }}</o-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
<div class="replies">
|
||||
<div class="left">
|
||||
<div class="vertical-border" @click="showReplies = false" />
|
||||
<div>
|
||||
<div>
|
||||
<div @click="showReplies = false" />
|
||||
</div>
|
||||
<transition-group
|
||||
name="comment-replies"
|
||||
v-if="showReplies"
|
||||
class="comment-replies"
|
||||
tag="ul"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<comment
|
||||
class="reply"
|
||||
<Comment
|
||||
v-for="reply in comment.replies"
|
||||
:key="reply.id"
|
||||
:comment="reply"
|
||||
:event="event"
|
||||
@create-comment="$emit('create-comment', $event)"
|
||||
@delete-comment="$emit('delete-comment', $event)"
|
||||
:currentActor="currentActor"
|
||||
:rootComment="false"
|
||||
@create-comment="emit('create-comment', $event)"
|
||||
@delete-comment="emit('delete-comment', $event)"
|
||||
@report-comment="emit('report-comment', $event)"
|
||||
/>
|
||||
</transition-group>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
|
||||
<script lang="ts" setup>
|
||||
import EditorComponent from "@/components/Editor.vue";
|
||||
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { CommentModeration } from "@/types/enums";
|
||||
import { CommentModel, IComment } from "../../types/comment.model";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
import { IPerson, usernameWithDomain } from "../../types/actor";
|
||||
import { IPerson } from "../../types/actor";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import ReportModal from "../Report/ReportModal.vue";
|
||||
import { IReport } from "../../types/report.model";
|
||||
import { CREATE_REPORT } from "../../graphql/report";
|
||||
import PopoverActorCard from "../Account/PopoverActorCard.vue";
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
inject,
|
||||
onMounted,
|
||||
ref,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Delete from "vue-material-design-icons/Delete.vue";
|
||||
import Alert from "vue-material-design-icons/Alert.vue";
|
||||
import ChevronUp from "vue-material-design-icons/ChevronUp.vue";
|
||||
import ChevronDown from "vue-material-design-icons/ChevronDown.vue";
|
||||
import Reply from "vue-material-design-icons/Reply.vue";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
editor: () =>
|
||||
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
||||
comment: () => import(/* webpackChunkName: "comment" */ "./Comment.vue"),
|
||||
PopoverActorCard,
|
||||
},
|
||||
})
|
||||
export default class Comment extends Vue {
|
||||
@Prop({ required: true, type: Object }) comment!: IComment;
|
||||
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
|
||||
|
||||
@Prop({ required: true, type: Object }) event!: IEvent;
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
comment: IComment;
|
||||
event: IEvent;
|
||||
currentActor: IPerson;
|
||||
rootComment?: boolean;
|
||||
}>(),
|
||||
{ rootComment: true }
|
||||
);
|
||||
|
||||
// Hack because Vue only exports it's own interface.
|
||||
// See https://github.com/kaorun343/vue-property-decorator/issues/257
|
||||
@Ref() readonly commentEditor!: EditorComponent & {
|
||||
replyToComment: (comment: IComment) => void;
|
||||
focus: () => void;
|
||||
};
|
||||
const emit = defineEmits([
|
||||
"create-comment",
|
||||
"delete-comment",
|
||||
"report-comment",
|
||||
]);
|
||||
|
||||
currentActor!: IPerson;
|
||||
const commentEditor = ref<typeof EditorComponent | null>(null);
|
||||
|
||||
newComment: IComment = new CommentModel();
|
||||
// Hack because Vue only exports it's own interface.
|
||||
// See https://github.com/kaorun343/vue-property-decorator/issues/257
|
||||
// @Ref() readonly commentEditor!: EditorComponent & {
|
||||
// replyToComment: (comment: IComment) => void;
|
||||
// focus: () => void;
|
||||
// };
|
||||
|
||||
replyTo = false;
|
||||
const newComment = ref<IComment>(new CommentModel());
|
||||
const replyTo = ref(false);
|
||||
const showReplies = ref(false);
|
||||
const route = useRoute();
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
showReplies = false;
|
||||
|
||||
CommentModeration = CommentModeration;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
formatDistanceToNow = formatDistanceToNow;
|
||||
|
||||
async mounted(): Promise<void> {
|
||||
const { hash } = this.$route;
|
||||
if (hash.includes(`#comment-${this.comment.uuid}`)) {
|
||||
this.fetchReplies();
|
||||
}
|
||||
onMounted(() => {
|
||||
if (route?.hash.includes(`#comment-${props.comment.uuid}`)) {
|
||||
showReplies.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
async createReplyToComment(): Promise<void> {
|
||||
if (this.replyTo) {
|
||||
this.replyTo = false;
|
||||
this.newComment = new CommentModel();
|
||||
return;
|
||||
}
|
||||
this.replyTo = true;
|
||||
if (this.comment.actor) {
|
||||
this.commentEditor.replyToComment(this.comment.actor);
|
||||
await this.$nextTick; // wait for the mention to be injected
|
||||
this.commentEditor.focus();
|
||||
}
|
||||
const createReplyToComment = async (): Promise<void> => {
|
||||
if (replyTo.value) {
|
||||
replyTo.value = false;
|
||||
newComment.value = new CommentModel();
|
||||
return;
|
||||
}
|
||||
replyTo.value = true;
|
||||
if (props.comment.actor) {
|
||||
commentEditor.value?.replyToComment(props.comment.actor);
|
||||
await nextTick(); // wait for the mention to be injected
|
||||
commentEditor.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
replyToComment(): void {
|
||||
this.newComment.inReplyToComment = this.comment;
|
||||
this.newComment.originComment = this.comment.originComment || this.comment;
|
||||
this.newComment.actor = this.currentActor;
|
||||
this.$emit("create-comment", this.newComment);
|
||||
this.newComment = new CommentModel();
|
||||
this.replyTo = false;
|
||||
this.showReplies = true;
|
||||
}
|
||||
const replyToComment = (): void => {
|
||||
newComment.value.inReplyToComment = props.comment;
|
||||
newComment.value.originComment = props.comment.originComment ?? props.comment;
|
||||
newComment.value.actor = props.currentActor;
|
||||
console.log(newComment.value);
|
||||
emit("create-comment", newComment.value);
|
||||
newComment.value = new CommentModel();
|
||||
replyTo.value = false;
|
||||
showReplies.value = true;
|
||||
};
|
||||
|
||||
deleteComment(): void {
|
||||
this.$emit("delete-comment", this.comment);
|
||||
this.showReplies = false;
|
||||
}
|
||||
const deleteComment = (): void => {
|
||||
emit("delete-comment", props.comment);
|
||||
showReplies.value = false;
|
||||
};
|
||||
|
||||
fetchReplies(): void {
|
||||
this.showReplies = true;
|
||||
}
|
||||
const commentSelected = computed((): boolean => {
|
||||
return `#${commentId.value}` === route?.hash;
|
||||
});
|
||||
|
||||
get commentSelected(): boolean {
|
||||
return `#${this.commentId}` === this.$route.hash;
|
||||
}
|
||||
const commentFromOrganizer = computed((): boolean => {
|
||||
const organizerId =
|
||||
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
|
||||
return organizerId !== undefined && props.comment?.actor?.id === organizerId;
|
||||
});
|
||||
|
||||
get commentFromOrganizer(): boolean {
|
||||
const organizerId =
|
||||
this.event?.organizerActor?.id || this.event?.attributedTo?.id;
|
||||
return organizerId !== undefined && this.comment?.actor?.id === organizerId;
|
||||
}
|
||||
const commentId = computed((): string => {
|
||||
if (props.comment.originComment)
|
||||
return `comment-${props.comment.originComment.uuid}-${props.comment.uuid}`;
|
||||
return `comment-${props.comment.uuid}`;
|
||||
});
|
||||
|
||||
get commentId(): string {
|
||||
if (this.comment.originComment)
|
||||
return `comment-${this.comment.originComment.uuid}-${this.comment.uuid}`;
|
||||
return `comment-${this.comment.uuid}`;
|
||||
}
|
||||
const commentURL = computed((): string => {
|
||||
if (!props.comment.local && props.comment.url) return props.comment.url;
|
||||
return `#${commentId.value}`;
|
||||
});
|
||||
|
||||
get commentURL(): string {
|
||||
if (!this.comment.local && this.comment.url) return this.comment.url;
|
||||
return `#${this.commentId}`;
|
||||
}
|
||||
const reportModal = (): void => {
|
||||
if (!props.comment.actor) return;
|
||||
emit("report-comment", props.comment);
|
||||
// this.$buefy.modal.open({
|
||||
// component: ReportModal,
|
||||
// props: {
|
||||
// title: t("Report this comment"),
|
||||
// comment: props.comment,
|
||||
// onConfirm: reportComment,
|
||||
// outsideDomain: props.comment.actor?.domain,
|
||||
// },
|
||||
// // https://github.com/buefy/buefy/pull/3589
|
||||
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// // @ts-ignore
|
||||
// closeButtonAriaLabel: this.t("Close"),
|
||||
// });
|
||||
};
|
||||
|
||||
reportModal(): void {
|
||||
if (!this.comment.actor) return;
|
||||
this.$buefy.modal.open({
|
||||
parent: this,
|
||||
component: ReportModal,
|
||||
props: {
|
||||
title: this.$t("Report this comment"),
|
||||
comment: this.comment,
|
||||
onConfirm: this.reportComment,
|
||||
outsideDomain: this.comment.actor.domain,
|
||||
},
|
||||
// https://github.com/buefy/buefy/pull/3589
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
closeButtonAriaLabel: this.$t("Close"),
|
||||
});
|
||||
}
|
||||
// const reportComment = async (
|
||||
// content: string,
|
||||
// forward: boolean
|
||||
// ): Promise<void> => {
|
||||
// try {
|
||||
// if (!props.comment.actor) return;
|
||||
|
||||
async reportComment(content: string, forward: boolean): Promise<void> {
|
||||
try {
|
||||
if (!this.comment.actor) return;
|
||||
await this.$apollo.mutate<IReport>({
|
||||
mutation: CREATE_REPORT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
reportedId: this.comment.actor.id,
|
||||
commentsIds: [this.comment.id],
|
||||
content,
|
||||
forward,
|
||||
},
|
||||
});
|
||||
this.$buefy.notification.open({
|
||||
message: this.$t("Comment from @{username} reported", {
|
||||
username: this.comment.actor.preferredUsername,
|
||||
}) as string,
|
||||
type: "is-success",
|
||||
position: "is-bottom-right",
|
||||
duration: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (e.message) {
|
||||
Snackbar.open({
|
||||
message: e.message,
|
||||
type: "is-danger",
|
||||
position: "is-bottom",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// const { onError, onDone } = useMutation(CREATE_REPORT, () => ({
|
||||
// variables: {
|
||||
// eventId: props.event.id,
|
||||
// reportedId: props.comment.actor?.id,
|
||||
// commentsIds: [props.comment.id],
|
||||
// content,
|
||||
// forward,
|
||||
// },
|
||||
// }));
|
||||
|
||||
// // this.$buefy.notification.open({
|
||||
// // message: this.t("Comment from @{username} reported", {
|
||||
// // username: this.comment.actor.preferredUsername,
|
||||
// // }) as string,
|
||||
// // type: "is-success",
|
||||
// // position: "is-bottom-right",
|
||||
// // duration: 5000,
|
||||
// // });
|
||||
// } catch (e: any) {
|
||||
// if (e.message) {
|
||||
// // Snackbar.open({
|
||||
// // message: e.message,
|
||||
// // type: "is-danger",
|
||||
// // position: "is-bottom",
|
||||
// // });
|
||||
// }
|
||||
// }
|
||||
// };
|
||||
const actorComment = computed(() => props.comment.actor);
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@@ -364,9 +365,9 @@ form.reply {
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
& > small {
|
||||
@include margin-left(0.3rem);
|
||||
}
|
||||
// & > small {
|
||||
// @include margin-left(0.3rem);
|
||||
// }
|
||||
}
|
||||
|
||||
.editor-line {
|
||||
@@ -375,15 +376,15 @@ form.reply {
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
@include padding-right(10px);
|
||||
// @include padding-right(10px);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
a.comment-link {
|
||||
text-decoration: none;
|
||||
@include margin-left(5px);
|
||||
color: $text;
|
||||
// @include margin-left(5px);
|
||||
color: text;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -425,9 +426,9 @@ a.comment-link {
|
||||
}
|
||||
}
|
||||
|
||||
.media-left {
|
||||
@include margin-right(5px);
|
||||
}
|
||||
// .media-left {
|
||||
// @include margin-right(5px);
|
||||
// }
|
||||
}
|
||||
|
||||
.root-comment .replies {
|
||||
@@ -437,7 +438,7 @@ a.comment-link {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@include margin-right(10px);
|
||||
// @include margin-right(10px);
|
||||
|
||||
.vertical-border {
|
||||
width: 3px;
|
||||
@@ -528,9 +529,9 @@ article {
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.reply-action .icon {
|
||||
@include padding-right(0.4rem);
|
||||
}
|
||||
// .reply-action .icon {
|
||||
// @include padding-right(0.4rem);
|
||||
// }
|
||||
|
||||
.visually-hidden {
|
||||
display: none;
|
||||
|
||||
@@ -1,69 +1,62 @@
|
||||
<template>
|
||||
<div>
|
||||
<form
|
||||
class="new-comment"
|
||||
class=""
|
||||
v-if="isAbleToComment"
|
||||
@submit.prevent="createCommentForEvent(newComment)"
|
||||
@keyup.ctrl.enter="createCommentForEvent(newComment)"
|
||||
>
|
||||
<b-notification
|
||||
<o-notification
|
||||
v-if="isEventOrganiser && !areCommentsClosed"
|
||||
:closable="false"
|
||||
>{{ $t("Comments are closed for everybody else.") }}</b-notification
|
||||
>{{ t("Comments are closed for everybody else.") }}</o-notification
|
||||
>
|
||||
<article class="media">
|
||||
<figure class="media-left" v-if="newComment.actor">
|
||||
<article class="flex flex-wrap items-start gap-2">
|
||||
<figure class="" v-if="newComment.actor">
|
||||
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="field">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<editor
|
||||
ref="commenteditor"
|
||||
mode="comment"
|
||||
v-model="newComment.text"
|
||||
:aria-label="$t('Comment body')"
|
||||
/>
|
||||
</p>
|
||||
<p class="help is-danger" v-if="emptyCommentError">
|
||||
{{ $t("Comment text can't be empty") }}
|
||||
<div class="flex-1">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="editor-wrapper">
|
||||
<Editor
|
||||
ref="commenteditor"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
mode="comment"
|
||||
v-model="newComment.text"
|
||||
:aria-label="t('Comment body')"
|
||||
/>
|
||||
<p class="" v-if="emptyCommentError">
|
||||
{{ t("Comment text can't be empty") }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="field notify-participants" v-if="isEventOrganiser">
|
||||
<b-switch
|
||||
<div class="" v-if="isEventOrganiser">
|
||||
<o-switch
|
||||
aria-labelledby="notify-participants-toggle"
|
||||
v-model="newComment.isAnnouncement"
|
||||
>{{ $t("Notify participants") }}</b-switch
|
||||
>{{ t("Notify participants") }}</o-switch
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="send-comment">
|
||||
<b-button
|
||||
native-type="submit"
|
||||
type="is-primary"
|
||||
class="comment-button-submit"
|
||||
icon-left="send"
|
||||
>{{ $t("Send") }}</b-button
|
||||
>
|
||||
<div class="">
|
||||
<o-button native-type="submit" variant="primary" icon-left="send">{{
|
||||
t("Send")
|
||||
}}</o-button>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
<b-notification v-else-if="isConnected" :closable="false">{{
|
||||
$t("The organiser has chosen to close comments.")
|
||||
}}</b-notification>
|
||||
<p
|
||||
v-if="$apollo.queries.comments.loading"
|
||||
class="loading has-text-centered"
|
||||
>
|
||||
{{ $t("Loading comments…") }}
|
||||
<o-notification v-else-if="isConnected" :closable="false">{{
|
||||
t("The organiser has chosen to close comments.")
|
||||
}}</o-notification>
|
||||
<p v-if="commentsLoading" class="text-center">
|
||||
{{ t("Loading comments…") }}
|
||||
</p>
|
||||
<transition-group tag="div" name="comment-empty-list" v-else>
|
||||
<transition-group
|
||||
key="list"
|
||||
name="comment-list"
|
||||
v-if="filteredOrderedComments.length"
|
||||
v-if="filteredOrderedComments.length && currentActor"
|
||||
class="comment-list"
|
||||
tag="ul"
|
||||
>
|
||||
@@ -71,21 +64,26 @@
|
||||
class="root-comment"
|
||||
:comment="comment"
|
||||
:event="event"
|
||||
:currentActor="currentActor"
|
||||
v-for="comment in filteredOrderedComments"
|
||||
:key="comment.id"
|
||||
@create-comment="createCommentForEvent"
|
||||
@delete-comment="deleteComment"
|
||||
@delete-comment="
|
||||
deleteComment({
|
||||
commentId: comment.id as string,
|
||||
originCommentId: comment.originComment?.id,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</transition-group>
|
||||
<empty-content v-else icon="comment" key="no-comments" :inline="true">
|
||||
<span>{{ $t("No comments yet") }}</span>
|
||||
<span>{{ t("No comments yet") }}</span>
|
||||
</empty-content>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Prop, Vue, Component, Watch } from "vue-property-decorator";
|
||||
<script lang="ts" setup>
|
||||
import Comment from "@/components/Comment/Comment.vue";
|
||||
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
|
||||
import { CommentModeration } from "@/types/enums";
|
||||
@@ -95,328 +93,338 @@ import {
|
||||
DELETE_COMMENT,
|
||||
COMMENTS_THREADS_WITH_REPLIES,
|
||||
} from "../../graphql/comment";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
import { IPerson } from "../../types/actor";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import { computed, defineAsyncComponent, inject, ref, watch } from "vue";
|
||||
import { IPerson } from "@/types/actor";
|
||||
import { AbsintheGraphQLError } from "@/types/errors.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
comments: {
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const { result: commentsResult, loading: commentsLoading } = useQuery<{
|
||||
event: Pick<IEvent, "id" | "uuid" | "comments">;
|
||||
}>(
|
||||
COMMENTS_THREADS_WITH_REPLIES,
|
||||
() => ({ eventUUID: props.event?.uuid }),
|
||||
() => ({ enabled: props.event?.uuid !== undefined })
|
||||
);
|
||||
|
||||
const comments = computed(() => commentsResult.value?.event.comments ?? []);
|
||||
|
||||
const props = defineProps<{
|
||||
event: IEvent;
|
||||
newComment?: IComment;
|
||||
}>();
|
||||
|
||||
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
|
||||
|
||||
const newComment = ref<IComment>(props.newComment ?? new CommentModel());
|
||||
|
||||
const emptyCommentError = ref(false);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
watch(currentActor, () => {
|
||||
newComment.value.actor = currentActor.value as IPerson;
|
||||
});
|
||||
|
||||
watch(newComment, (newCommentUpdated: IComment) => {
|
||||
if (emptyCommentError.value) {
|
||||
emptyCommentError.value = ["", "<p></p>"].includes(newCommentUpdated.text);
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
mutate: createCommentForEventMutation,
|
||||
onDone: createCommentForEventMutationDone,
|
||||
onError: createCommentForEventMutationError,
|
||||
} = useMutation<
|
||||
{ createComment: IComment },
|
||||
{
|
||||
eventId: string;
|
||||
text: string;
|
||||
inReplyToCommentId?: string;
|
||||
isAnnouncement?: boolean;
|
||||
originCommentId?: string | undefined;
|
||||
}
|
||||
>(CREATE_COMMENT_FROM_EVENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<InMemoryCache>,
|
||||
{ data }: FetchResult,
|
||||
{ variables }
|
||||
) => {
|
||||
if (data == null) return;
|
||||
// comments are attached to the event, so we can pass it to replies later
|
||||
const newCommentLocal = { ...data.createComment, event: props.event };
|
||||
|
||||
// we load all existing threads
|
||||
const commentThreadsData = store.readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
variables() {
|
||||
return {
|
||||
eventUUID: this.event.uuid,
|
||||
};
|
||||
variables: {
|
||||
eventUUID: props.event?.uuid,
|
||||
},
|
||||
update: (data) => data.event.comments,
|
||||
skip() {
|
||||
return !this.event.uuid;
|
||||
});
|
||||
if (!commentThreadsData) return;
|
||||
const { event } = commentThreadsData;
|
||||
const oldComments = [...event.comments];
|
||||
|
||||
// if it's no a root comment, we first need to find
|
||||
// existing replies and add the new reply to it
|
||||
if (variables?.originCommentId !== undefined) {
|
||||
const parentCommentIndex = oldComments.findIndex(
|
||||
(oldComment) => oldComment.id === variables.originCommentId
|
||||
);
|
||||
const parentComment = oldComments[parentCommentIndex];
|
||||
|
||||
// replace the root comment with has the updated list of replies in the thread list
|
||||
oldComments.splice(parentCommentIndex, 1, {
|
||||
...parentComment,
|
||||
replies: [...parentComment.replies, newCommentLocal],
|
||||
});
|
||||
} else {
|
||||
// otherwise it's simply a new thread and we add it to the list
|
||||
oldComments.push(newCommentLocal);
|
||||
}
|
||||
|
||||
// finally we save the thread list
|
||||
store.writeQuery({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
data: {
|
||||
event: {
|
||||
...event,
|
||||
comments: oldComments,
|
||||
},
|
||||
},
|
||||
variables: {
|
||||
eventUUID: props.event?.uuid,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
createCommentForEventMutationDone(() => {
|
||||
// and reset the new comment field
|
||||
newComment.value = new CommentModel();
|
||||
});
|
||||
|
||||
const notifier = inject<Notifier>("notifier");
|
||||
|
||||
createCommentForEventMutationError((errors) => {
|
||||
console.error(errors);
|
||||
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
|
||||
const error = errors.graphQLErrors[0] as AbsintheGraphQLError;
|
||||
if (error.field !== "text" && error.message[0] !== "can't be blank") {
|
||||
notifier?.error(error.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const createCommentForEvent = (comment: IComment) => {
|
||||
emptyCommentError.value = ["", "<p></p>"].includes(comment.text);
|
||||
|
||||
if (emptyCommentError.value) return;
|
||||
if (!comment.actor) return;
|
||||
if (!props.event?.id) return;
|
||||
|
||||
createCommentForEventMutation({
|
||||
eventId: props.event?.id,
|
||||
text: comment.text,
|
||||
inReplyToCommentId: comment.inReplyToComment?.id,
|
||||
isAnnouncement: comment.isAnnouncement,
|
||||
originCommentId: comment.originComment?.id,
|
||||
});
|
||||
};
|
||||
|
||||
const { mutate: deleteComment, onError: deleteCommentMutationError } =
|
||||
useMutation<
|
||||
{ deleteComment: { id: string } },
|
||||
{ commentId: string; originCommentId?: string }
|
||||
>(DELETE_COMMENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<InMemoryCache>,
|
||||
{ data }: FetchResult,
|
||||
{ variables }
|
||||
) => {
|
||||
if (data == null) return;
|
||||
const deletedCommentId = data.deleteComment.id;
|
||||
|
||||
const commentsData = store.readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
variables: {
|
||||
eventUUID: props.event?.uuid,
|
||||
},
|
||||
});
|
||||
if (!commentsData) return;
|
||||
const { event } = commentsData;
|
||||
let updatedComments: IComment[] = [...event.comments];
|
||||
|
||||
if (variables?.originCommentId) {
|
||||
// we have deleted a reply to a thread
|
||||
const parentCommentIndex = updatedComments.findIndex(
|
||||
(oldComment) => oldComment.id === variables.originCommentId
|
||||
);
|
||||
const parentComment = updatedComments[parentCommentIndex];
|
||||
const updatedReplies = parentComment.replies.map((reply) => {
|
||||
if (reply.id === deletedCommentId) {
|
||||
return {
|
||||
...reply,
|
||||
deletedAt: new Date().toString(),
|
||||
};
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
updatedComments.splice(parentCommentIndex, 1, {
|
||||
...parentComment,
|
||||
replies: updatedReplies,
|
||||
totalReplies: parentComment.totalReplies - 1,
|
||||
});
|
||||
console.log("updatedComments", updatedComments);
|
||||
} else {
|
||||
// we have deleted a thread itself
|
||||
updatedComments = updatedComments.map((reply) => {
|
||||
if (reply.id === deletedCommentId) {
|
||||
return {
|
||||
...reply,
|
||||
deletedAt: new Date().toString(),
|
||||
};
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
store.writeQuery({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
variables: {
|
||||
eventUUID: props.event?.uuid,
|
||||
},
|
||||
data: {
|
||||
event: {
|
||||
...event,
|
||||
comments: updatedComments,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Comment,
|
||||
IdentityPickerWrapper,
|
||||
EmptyContent,
|
||||
editor: () =>
|
||||
import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
||||
},
|
||||
})
|
||||
export default class CommentTree extends Vue {
|
||||
@Prop({ required: false, type: Object }) event!: IEvent;
|
||||
}));
|
||||
|
||||
newComment: IComment = new CommentModel();
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
comments: IComment[] = [];
|
||||
|
||||
CommentModeration = CommentModeration;
|
||||
|
||||
emptyCommentError = false;
|
||||
|
||||
@Watch("currentActor")
|
||||
watchCurrentActor(currentActor: IPerson): void {
|
||||
this.newComment.actor = currentActor;
|
||||
deleteCommentMutationError((error) => {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
notifier?.error(error.graphQLErrors[0].message);
|
||||
}
|
||||
});
|
||||
|
||||
@Watch("newComment", { deep: true })
|
||||
resetEmptyCommentError(newComment: IComment): void {
|
||||
if (this.emptyCommentError) {
|
||||
this.emptyCommentError = ["", "<p></p>"].includes(newComment.text);
|
||||
}
|
||||
}
|
||||
|
||||
async createCommentForEvent(comment: IComment): Promise<void> {
|
||||
this.emptyCommentError = ["", "<p></p>"].includes(comment.text);
|
||||
if (this.emptyCommentError) return;
|
||||
try {
|
||||
if (!comment.actor) return;
|
||||
await this.$apollo.mutate({
|
||||
mutation: CREATE_COMMENT_FROM_EVENT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
text: comment.text,
|
||||
inReplyToCommentId: comment.inReplyToComment
|
||||
? comment.inReplyToComment.id
|
||||
: null,
|
||||
isAnnouncement: comment.isAnnouncement,
|
||||
},
|
||||
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
|
||||
if (data == null) return;
|
||||
// comments are attached to the event, so we can pass it to replies later
|
||||
const newComment = { ...data.createComment, event: this.event };
|
||||
|
||||
// we load all existing threads
|
||||
const commentThreadsData = store.readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
if (!commentThreadsData) return;
|
||||
const { event } = commentThreadsData;
|
||||
const oldComments = [...event.comments];
|
||||
|
||||
// if it's no a root comment, we first need to find
|
||||
// existing replies and add the new reply to it
|
||||
if (comment.originComment !== undefined) {
|
||||
const { originComment } = comment;
|
||||
const parentCommentIndex = oldComments.findIndex(
|
||||
(oldComment) => oldComment.id === originComment.id
|
||||
);
|
||||
const parentComment = oldComments[parentCommentIndex];
|
||||
|
||||
// replace the root comment with has the updated list of replies in the thread list
|
||||
oldComments.splice(parentCommentIndex, 1, {
|
||||
...parentComment,
|
||||
replies: [...parentComment.replies, newComment],
|
||||
});
|
||||
} else {
|
||||
// otherwise it's simply a new thread and we add it to the list
|
||||
oldComments.push(newComment);
|
||||
}
|
||||
|
||||
// finally we save the thread list
|
||||
store.writeQuery({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
data: {
|
||||
event: {
|
||||
...event,
|
||||
comments: oldComments,
|
||||
},
|
||||
},
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// and reset the new comment field
|
||||
this.newComment = new CommentModel();
|
||||
} catch (errors: any) {
|
||||
console.error(errors);
|
||||
if (errors.graphQLErrors && errors.graphQLErrors.length > 0) {
|
||||
const error = errors.graphQLErrors[0];
|
||||
if (error.field !== "text" && error.message[0] !== "can't be blank") {
|
||||
this.$notifier.error(error.message);
|
||||
}
|
||||
const orderedComments = computed((): IComment[] => {
|
||||
return comments.value
|
||||
.filter((comment: IComment) => comment.inReplyToComment == null)
|
||||
.sort((a: IComment, b: IComment) => {
|
||||
if (a.isAnnouncement !== b.isAnnouncement) {
|
||||
return (
|
||||
(b.isAnnouncement === true ? 1 : 0) -
|
||||
(a.isAnnouncement === true ? 1 : 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteComment(comment: IComment): Promise<void> {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: DELETE_COMMENT,
|
||||
variables: {
|
||||
commentId: comment.id,
|
||||
},
|
||||
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
|
||||
if (data == null) return;
|
||||
const deletedCommentId = data.deleteComment.id;
|
||||
|
||||
const commentsData = store.readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
if (!commentsData) return;
|
||||
const { event } = commentsData;
|
||||
let updatedComments: IComment[] = [...event.comments];
|
||||
|
||||
if (comment.originComment) {
|
||||
// we have deleted a reply to a thread
|
||||
const { originComment } = comment;
|
||||
|
||||
const parentCommentIndex = updatedComments.findIndex(
|
||||
(oldComment) => oldComment.id === originComment.id
|
||||
);
|
||||
const parentComment = updatedComments[parentCommentIndex];
|
||||
const updatedReplies = parentComment.replies.map((reply) => {
|
||||
if (reply.id === deletedCommentId) {
|
||||
return {
|
||||
...reply,
|
||||
deletedAt: new Date().toString(),
|
||||
};
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
updatedComments.splice(parentCommentIndex, 1, {
|
||||
...parentComment,
|
||||
replies: updatedReplies,
|
||||
totalReplies: parentComment.totalReplies - 1,
|
||||
});
|
||||
console.log("updatedComments", updatedComments);
|
||||
} else {
|
||||
// we have deleted a thread itself
|
||||
updatedComments = updatedComments.map((reply) => {
|
||||
if (reply.id === deletedCommentId) {
|
||||
return {
|
||||
...reply,
|
||||
deletedAt: new Date().toString(),
|
||||
};
|
||||
}
|
||||
return reply;
|
||||
});
|
||||
}
|
||||
store.writeQuery({
|
||||
query: COMMENTS_THREADS_WITH_REPLIES,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
data: {
|
||||
event: {
|
||||
...event,
|
||||
comments: updatedComments,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
|
||||
this.$notifier.error(error.graphQLErrors[0].message);
|
||||
if (a.publishedAt && b.publishedAt) {
|
||||
return (
|
||||
new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime()
|
||||
);
|
||||
} else if (a.updatedAt && b.updatedAt) {
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
get orderedComments(): IComment[] {
|
||||
return this.comments
|
||||
.filter((comment) => comment.inReplyToComment == null)
|
||||
.sort((a, b) => {
|
||||
if (a.isAnnouncement !== b.isAnnouncement) {
|
||||
return (
|
||||
(b.isAnnouncement === true ? 1 : 0) -
|
||||
(a.isAnnouncement === true ? 1 : 0)
|
||||
);
|
||||
}
|
||||
if (a.publishedAt && b.publishedAt) {
|
||||
return (
|
||||
new Date(b.publishedAt).getTime() -
|
||||
new Date(a.publishedAt).getTime()
|
||||
);
|
||||
} else if (a.updatedAt && b.updatedAt) {
|
||||
return (
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
const filteredOrderedComments = computed((): IComment[] => {
|
||||
return orderedComments.value.filter(
|
||||
(comment) => !comment.deletedAt || comment.totalReplies > 0
|
||||
);
|
||||
});
|
||||
|
||||
get filteredOrderedComments(): IComment[] {
|
||||
return this.orderedComments.filter(
|
||||
(comment) => !comment.deletedAt || comment.totalReplies > 0
|
||||
);
|
||||
}
|
||||
const isEventOrganiser = computed((): boolean => {
|
||||
const organizerId =
|
||||
props.event?.organizerActor?.id || props.event?.attributedTo?.id;
|
||||
return organizerId !== undefined && currentActor.value?.id === organizerId;
|
||||
});
|
||||
|
||||
get isEventOrganiser(): boolean {
|
||||
const organizerId =
|
||||
this.event?.organizerActor?.id || this.event?.attributedTo?.id;
|
||||
return organizerId !== undefined && this.currentActor?.id === organizerId;
|
||||
}
|
||||
const areCommentsClosed = computed((): boolean => {
|
||||
return (
|
||||
currentActor.value?.id !== undefined &&
|
||||
props.event?.options.commentModeration !== CommentModeration.CLOSED
|
||||
);
|
||||
});
|
||||
|
||||
get areCommentsClosed(): boolean {
|
||||
return (
|
||||
this.currentActor.id !== undefined &&
|
||||
this.event.options.commentModeration !== CommentModeration.CLOSED
|
||||
);
|
||||
const isAbleToComment = computed((): boolean => {
|
||||
if (isConnected.value) {
|
||||
return areCommentsClosed.value || isEventOrganiser.value;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
get isAbleToComment(): boolean {
|
||||
if (this.isConnected) {
|
||||
return this.areCommentsClosed || this.isEventOrganiser;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.currentActor?.id != undefined;
|
||||
}
|
||||
}
|
||||
const isConnected = computed((): boolean => {
|
||||
return currentActor.value?.id != undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
@import "~bulma/sass/utilities/mixins.sass";
|
||||
form.new-comment {
|
||||
padding-bottom: 1rem;
|
||||
// @use "@/styles/_mixins" as *;
|
||||
// // @import "node_modules/bulma/sass/utilities/mixins.sass";
|
||||
// form.new-comment {
|
||||
// padding-bottom: 1rem;
|
||||
|
||||
.media {
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
.media-left {
|
||||
@include mobile {
|
||||
@include margin-right(0.5rem);
|
||||
@include margin-left(0.5rem);
|
||||
}
|
||||
}
|
||||
// .media {
|
||||
// flex-wrap: wrap;
|
||||
// justify-content: center;
|
||||
// // .media-left {
|
||||
// // @include >mobile {
|
||||
// // @include margin-right(0.5rem);
|
||||
// // @include margin-left(0.5rem);
|
||||
// // }
|
||||
// // }
|
||||
|
||||
.media-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
width: min-content;
|
||||
// .media-content {
|
||||
// display: flex;
|
||||
// align-items: center;
|
||||
// align-content: center;
|
||||
// width: min-content;
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
@include padding-right(10px);
|
||||
margin-bottom: 0;
|
||||
// .field {
|
||||
// flex: 1;
|
||||
// // @include padding-right(10px);
|
||||
// margin-bottom: 0;
|
||||
|
||||
&.notify-participants {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// &.notify-participants {
|
||||
// margin-top: 0.5rem;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
.no-comments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// .no-comments {
|
||||
// display: flex;
|
||||
// flex-direction: column;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
// span {
|
||||
// text-align: center;
|
||||
// margin-bottom: 10px;
|
||||
// }
|
||||
|
||||
img {
|
||||
max-width: 250px;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
// img {
|
||||
// max-width: 250px;
|
||||
// align-self: center;
|
||||
// }
|
||||
// }
|
||||
|
||||
ul.comment-list li {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
// ul.comment-list li {
|
||||
// margin-bottom: 16px;
|
||||
// }
|
||||
|
||||
.comment-list-enter-active,
|
||||
.comment-list-leave-active,
|
||||
@@ -447,11 +455,11 @@ ul.comment-list li {
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
/*.comment-empty-list-enter-active {*/
|
||||
/* transition: opacity .5s;*/
|
||||
/*}*/
|
||||
// .comment-empty-list-enter-active {
|
||||
// transition: opacity .5s;
|
||||
// }
|
||||
|
||||
/*.comment-empty-list-enter {*/
|
||||
/* opacity: 0;*/
|
||||
/*}*/
|
||||
// .comment-empty-list-enter {
|
||||
// opacity: 0;
|
||||
// }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user