Introduce comments below events
Also add tomstones Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
45155a3bde
commit
dc07f34d78
@ -52,7 +52,7 @@ config :mobilizon, MobilizonWeb.Endpoint,
|
||||
# Do not include metadata nor timestamps in development logs
|
||||
config :logger, :console, format: "[$level] $message\n", level: :debug
|
||||
|
||||
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Mimirsbrunn
|
||||
config :mobilizon, Mobilizon.Service.Geospatial, service: Mobilizon.Service.Geospatial.Nominatim
|
||||
|
||||
# Set a higher stacktrace during development. Avoid configuring such
|
||||
# in production as building large stacktraces may be expensive.
|
||||
|
@ -25,12 +25,13 @@
|
||||
"graphql": "^14.5.8",
|
||||
"graphql-tag": "^2.10.1",
|
||||
"intersection-observer": "^0.7.0",
|
||||
"javascript-time-ago": "^2.0.4",
|
||||
"leaflet": "^1.4.0",
|
||||
"leaflet.locatecontrol": "^0.68.0",
|
||||
"lodash": "^4.17.11",
|
||||
"ngeohash": "^0.6.3",
|
||||
"register-service-worker": "^1.6.2",
|
||||
"tippy.js": "^5.0.2",
|
||||
"tippy.js": "4.3.5",
|
||||
"tiptap": "^1.26.0",
|
||||
"tiptap-extensions": "^1.28.0",
|
||||
"vue": "^2.6.10",
|
||||
@ -40,6 +41,7 @@
|
||||
"vue-meta": "^2.3.1",
|
||||
"vue-property-decorator": "^8.1.0",
|
||||
"vue-router": "^3.0.6",
|
||||
"vue-scrollto": "^2.17.1",
|
||||
"vue2-leaflet": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
1
js/src/assets/undraw_just_saying.svg
Normal file
1
js/src/assets/undraw_just_saying.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 6.9 KiB |
351
js/src/components/Comment/Comment.vue
Normal file
351
js/src/components/Comment/Comment.vue
Normal file
@ -0,0 +1,351 @@
|
||||
<template>
|
||||
<li :class="{ reply: comment.inReplyToComment }">
|
||||
<article class="media" :class="{ selected: commentSelected, organizer: commentFromOrganizer }" :id="commentId">
|
||||
<figure class="media-left" v-if="!comment.deletedAt && comment.actor.avatar">
|
||||
<p class="image is-48x48">
|
||||
<img :src="comment.actor.avatar.url" alt="">
|
||||
</p>
|
||||
</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" v-if="!comment.deletedAt">
|
||||
<strong>{{ comment.actor.name }}</strong>
|
||||
<small>@{{ comment.actor.preferredUsername }}</small>
|
||||
<a class="comment-link has-text-grey" :href="commentId">
|
||||
<small>{{ timeago(new Date(comment.updatedAt)) }}</small>
|
||||
</a>
|
||||
</span>
|
||||
<a v-else class="comment-link has-text-grey" :href="commentId">
|
||||
<span>{{ $t('[deleted]') }}</span>
|
||||
</a>
|
||||
<span class="icons" v-if="!comment.deletedAt">
|
||||
<span v-if="comment.actor.id === currentActor.id"
|
||||
@click="$emit('delete-comment', comment)">
|
||||
<b-icon
|
||||
icon="delete"
|
||||
size="is-small"
|
||||
/>
|
||||
</span>
|
||||
<span @click="reportModal()">
|
||||
<b-icon
|
||||
icon="alert"
|
||||
size="is-small"
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
<br>
|
||||
<div v-if="!comment.deletedAt" v-html="comment.text" />
|
||||
<div v-else>{{ $t('[This comment has been deleted]') }}</div>
|
||||
<span class="load-replies" v-if="comment.totalReplies">
|
||||
<span v-if="!showReplies" @click="fetchReplies">
|
||||
{{ $tc('View a reply', comment.totalReplies, { totalReplies: comment.totalReplies }) }}
|
||||
</span>
|
||||
<span v-else-if="comment.totalReplies && showReplies" @click="showReplies = false">
|
||||
{{ $t('Hide replies') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<nav class="reply-action level is-mobile" v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED">
|
||||
<div class="level-left">
|
||||
<span style="cursor: pointer" class="level-item" @click="createReplyToComment(comment)">
|
||||
<span class="icon is-small">
|
||||
<b-icon icon="reply" />
|
||||
</span>
|
||||
{{ $t('Reply') }}
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</article>
|
||||
<form class="reply" @submit.prevent="replyToComment" 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>
|
||||
</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>@{{ currentActor.preferredUsername }}</small>
|
||||
</span>
|
||||
<br>
|
||||
<span class="editor-line">
|
||||
<editor class="editor" ref="commenteditor" v-model="newComment.text" mode="comment" />
|
||||
<b-button :disabled="newComment.text.trim().length === 0" native-type="submit" type="is-info">{{ $t('Post a reply') }}</b-button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
<transition-group name="comment-replies" v-if="showReplies" class="comment-replies" tag="ul">
|
||||
<comment
|
||||
class="reply"
|
||||
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)" />
|
||||
</transition-group>
|
||||
</li>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { CommentModel, IComment } from '@/types/comment.model';
|
||||
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||
import { IPerson } from '@/types/actor';
|
||||
import { Refs } from '@/shims-vue';
|
||||
import EditorComponent from '@/components/Editor.vue';
|
||||
import TimeAgo from 'javascript-time-ago';
|
||||
import { COMMENTS_THREADS, FETCH_THREAD_REPLIES } from '@/graphql/comment';
|
||||
import { IEvent, CommentModeration } from '@/types/event.model';
|
||||
import ReportModal from '@/components/Report/ReportModal.vue';
|
||||
import { IReport } from '@/types/report.model';
|
||||
import { CREATE_REPORT } from '@/graphql/report';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
},
|
||||
components: {
|
||||
editor: () => import(/* webpackChunkName: "editor" */ '@/components/Editor.vue'),
|
||||
Comment,
|
||||
},
|
||||
})
|
||||
export default class Comment extends Vue {
|
||||
@Prop({ required: true, type: Object }) comment!: IComment;
|
||||
@Prop({ required: true, type: Object }) event!: IEvent;
|
||||
|
||||
$refs!: Refs<{
|
||||
commenteditor: EditorComponent,
|
||||
}>;
|
||||
|
||||
currentActor!: IPerson;
|
||||
newComment: IComment = new CommentModel();
|
||||
replyTo: boolean = false;
|
||||
showReplies: boolean = false;
|
||||
timeAgoInstance = null;
|
||||
CommentModeration = CommentModeration;
|
||||
|
||||
async mounted() {
|
||||
const localeName = this.$i18n.locale;
|
||||
const locale = await import(`javascript-time-ago/locale/${localeName}`);
|
||||
TimeAgo.addLocale(locale);
|
||||
this.timeAgoInstance = new TimeAgo(localeName);
|
||||
|
||||
const hash = this.$route.hash;
|
||||
if (hash.includes(`#comment-${this.comment.uuid}`)) {
|
||||
this.fetchReplies();
|
||||
}
|
||||
}
|
||||
|
||||
async createReplyToComment(comment: IComment) {
|
||||
if (this.replyTo) {
|
||||
this.replyTo = false;
|
||||
this.newComment = new CommentModel();
|
||||
return;
|
||||
}
|
||||
this.replyTo = true;
|
||||
// this.newComment.inReplyToComment = comment;
|
||||
await this.$nextTick();
|
||||
await this.$nextTick(); // For some reason commenteditor needs two $nextTick() to fully render
|
||||
const commentEditor = this.$refs.commenteditor;
|
||||
commentEditor.replyToComment(comment);
|
||||
}
|
||||
|
||||
replyToComment() {
|
||||
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;
|
||||
}
|
||||
|
||||
async fetchReplies() {
|
||||
const parentId = this.comment.id;
|
||||
const { data } = await this.$apollo.query<{ thread: IComment[] }>({
|
||||
query: FETCH_THREAD_REPLIES,
|
||||
variables: {
|
||||
threadId: parentId,
|
||||
},
|
||||
});
|
||||
if (!data) return;
|
||||
const { thread } = data;
|
||||
const eventData = this.$apollo.getClient().readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
if (!eventData) return;
|
||||
const { event } = eventData;
|
||||
const { comments } = event;
|
||||
const parentCommentIndex = comments.findIndex(oldComment => oldComment.id === parentId);
|
||||
const parentComment = comments[parentCommentIndex];
|
||||
if (!parentComment) return;
|
||||
parentComment.replies = thread;
|
||||
comments[parentCommentIndex] = parentComment;
|
||||
event.comments = comments;
|
||||
this.$apollo.getClient().writeQuery({
|
||||
query: COMMENTS_THREADS,
|
||||
data: { event },
|
||||
});
|
||||
this.showReplies = true;
|
||||
}
|
||||
|
||||
timeago(dateTime): String {
|
||||
if (this.timeAgoInstance != null) {
|
||||
// @ts-ignore
|
||||
return this.timeAgoInstance.format(dateTime);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
get commentSelected(): boolean {
|
||||
return this.commentId === this.$route.hash;
|
||||
}
|
||||
|
||||
get commentFromOrganizer(): boolean {
|
||||
return this.event.organizerActor !== undefined && this.comment.actor.id === this.event.organizerActor.id;
|
||||
}
|
||||
|
||||
get commentId(): String {
|
||||
if (this.comment.originComment) return `#comment-${this.comment.originComment.uuid}:${this.comment.uuid}`;
|
||||
return `#comment-${this.comment.uuid}`;
|
||||
}
|
||||
|
||||
reportModal() {
|
||||
console.log('report modal');
|
||||
this.$buefy.modal.open({
|
||||
parent: this,
|
||||
component: ReportModal,
|
||||
props: {
|
||||
title: this.$t('Report this comment'),
|
||||
comment: this.comment,
|
||||
onConfirm: this.reportComment,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async reportComment(content: String, forward: boolean) {
|
||||
try {
|
||||
await this.$apollo.mutate<IReport>({
|
||||
mutation: CREATE_REPORT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
reporterId: this.currentActor.id,
|
||||
reportedId: this.comment.actor.id,
|
||||
commentsIds: [this.comment.id],
|
||||
content,
|
||||
},
|
||||
});
|
||||
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 (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@import "@/variables.scss";
|
||||
|
||||
.first-line {
|
||||
* {
|
||||
padding: 0 5px 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-line {
|
||||
display: flex;
|
||||
max-width: calc(80rem - 64px);
|
||||
|
||||
.editor {
|
||||
flex: 1;
|
||||
padding-right: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.comment-link small:hover {
|
||||
color: hsl(0, 0%, 21%);
|
||||
}
|
||||
|
||||
.root-comment .comment-replies > .reply {
|
||||
padding-left: 3rem;
|
||||
}
|
||||
|
||||
.media .media-content {
|
||||
|
||||
.content .editor-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.media:hover .media-content .icons {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.load-replies {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
article {
|
||||
border-radius: 4px;
|
||||
|
||||
&.selected {
|
||||
background-color: lighten($secondary, 30%);
|
||||
}
|
||||
&.organizer:not(.selected) {
|
||||
background-color: lighten($primary, 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.comment-replies-enter-active,
|
||||
.comment-replies-leave-active,
|
||||
.comment-replies-move {
|
||||
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
|
||||
transition-property: opacity, transform;
|
||||
}
|
||||
|
||||
.comment-replies-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(50px) scaleY(0.5);
|
||||
}
|
||||
|
||||
.comment-replies-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scaleY(1);
|
||||
}
|
||||
|
||||
.comment-replies-leave-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.comment-replies-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
.reply-action .icon {
|
||||
padding-right: 0.4rem;
|
||||
}
|
||||
</style>
|
328
js/src/components/Comment/CommentTree.vue
Normal file
328
js/src/components/Comment/CommentTree.vue
Normal file
@ -0,0 +1,328 @@
|
||||
<template>
|
||||
<div class="columns">
|
||||
<div class="column is-two-thirds-desktop">
|
||||
<form class="new-comment" v-if="currentActor.id && event.options.commentModeration !== CommentModeration.CLOSED" @submit.prevent="createCommentForEvent(newComment)" @keyup.ctrl.enter="createCommentForEvent(newComment)">
|
||||
<article class="media">
|
||||
<figure class="media-left">
|
||||
<identity-picker-wrapper :inline="false" v-model="newComment.actor" />
|
||||
</figure>
|
||||
<div class="media-content">
|
||||
<div class="field">
|
||||
<p class="control">
|
||||
<editor ref="commenteditor" mode="comment" v-model="newComment.text" />
|
||||
</p>
|
||||
</div>
|
||||
<div class="send-comment">
|
||||
<b-button native-type="submit" type="is-info">{{ $t('Post a comment') }}</b-button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</form>
|
||||
<b-notification v-else-if="event.options.commentModeration === CommentModeration.CLOSED" :closable="false">
|
||||
{{ $t('Comments have been closed.') }}
|
||||
</b-notification>
|
||||
<transition name="comment-empty-list" mode="out-in">
|
||||
<transition-group name="comment-list" v-if="comments.length" class="comment-list" tag="ul">
|
||||
<comment
|
||||
class="root-comment"
|
||||
:comment="comment"
|
||||
:event="event"
|
||||
v-for="comment in orderedComments"
|
||||
v-if="!comment.deletedAt || comment.totalReplies > 0"
|
||||
:key="comment.id"
|
||||
@create-comment="createCommentForEvent"
|
||||
@delete-comment="deleteComment"
|
||||
/>
|
||||
</transition-group>
|
||||
<div v-else class="no-comments">
|
||||
<span>{{ $t('No comments yet') }}</span>
|
||||
<img src="../../assets/undraw_just_saying.svg" alt="" />
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { Prop, Vue, Component, Watch } from 'vue-property-decorator';
|
||||
import { CommentModel, IComment } from '@/types/comment.model';
|
||||
import {
|
||||
CREATE_COMMENT_FROM_EVENT,
|
||||
DELETE_COMMENT, COMMENTS_THREADS, FETCH_THREAD_REPLIES,
|
||||
} from '@/graphql/comment';
|
||||
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||
import { IPerson } from '@/types/actor';
|
||||
import Comment from '@/components/Comment/Comment.vue';
|
||||
import { IEvent, CommentModeration } from '@/types/event.model';
|
||||
import IdentityPickerWrapper from '@/views/Account/IdentityPickerWrapper.vue';
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
currentActor: {
|
||||
query: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
comments: {
|
||||
query: COMMENTS_THREADS,
|
||||
variables() {
|
||||
return {
|
||||
eventUUID: this.event.uuid,
|
||||
};
|
||||
},
|
||||
update(data) {
|
||||
return data.event.comments.map((comment) => new CommentModel(comment));
|
||||
},
|
||||
skip() {
|
||||
return !this.event.uuid;
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
Comment,
|
||||
IdentityPickerWrapper,
|
||||
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;
|
||||
|
||||
@Watch('currentActor')
|
||||
watchCurrentActor(currentActor: IPerson) {
|
||||
this.newComment.actor = currentActor;
|
||||
}
|
||||
|
||||
async createCommentForEvent(comment: IComment) {
|
||||
try {
|
||||
await this.$apollo.mutate({
|
||||
mutation: CREATE_COMMENT_FROM_EVENT,
|
||||
variables: {
|
||||
eventId: this.event.id,
|
||||
actorId: comment.actor.id,
|
||||
text: comment.text,
|
||||
inReplyToCommentId: comment.inReplyToComment ? comment.inReplyToComment.id : null,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (data == null) return;
|
||||
const newComment = data.createComment;
|
||||
|
||||
// comments are attached to the event, so we can pass it to replies later
|
||||
newComment.event = this.event;
|
||||
|
||||
// we load all existing threads
|
||||
const commentThreadsData = store.readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
if (!commentThreadsData) return;
|
||||
const { event } = commentThreadsData;
|
||||
const { comments: oldComments } = event;
|
||||
|
||||
// if it's no a root comment, we first need to find existing replies and add the new reply to it
|
||||
if (comment.originComment) {
|
||||
// @ts-ignore
|
||||
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
|
||||
const parentComment = oldComments[parentCommentIndex];
|
||||
|
||||
let oldReplyList: IComment[] = [];
|
||||
try {
|
||||
const threadData = store.readQuery<{ thread: IComment[] }>({
|
||||
query: FETCH_THREAD_REPLIES,
|
||||
variables: {
|
||||
threadId: parentComment.id,
|
||||
},
|
||||
});
|
||||
if (!threadData) return;
|
||||
oldReplyList = threadData.thread;
|
||||
} catch (e) {
|
||||
// This simply means there's no loaded replies yet
|
||||
} finally {
|
||||
oldReplyList.push(newComment);
|
||||
|
||||
// save the updated list of replies (with the one we've just added)
|
||||
store.writeQuery({
|
||||
query: FETCH_THREAD_REPLIES,
|
||||
data: { thread: oldReplyList },
|
||||
variables: {
|
||||
threadId: parentComment.id,
|
||||
},
|
||||
});
|
||||
|
||||
// replace the root comment with has the updated list of replies in the thread list
|
||||
parentComment.replies = oldReplyList;
|
||||
event.comments.splice(parentCommentIndex, 1, parentComment);
|
||||
}
|
||||
} else {
|
||||
// otherwise it's simply a new thread and we add it to the list
|
||||
oldComments.push(newComment);
|
||||
}
|
||||
|
||||
// finally we save the thread list
|
||||
event.comments = oldComments;
|
||||
store.writeQuery({
|
||||
query: COMMENTS_THREADS,
|
||||
data: { event },
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// and reset the new comment field
|
||||
this.newComment = new CommentModel();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteComment(comment: IComment) {
|
||||
await this.$apollo.mutate({
|
||||
mutation: DELETE_COMMENT,
|
||||
variables: {
|
||||
commentId: comment.id,
|
||||
actorId: this.currentActor.id,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (data == null) return;
|
||||
const deletedCommentId = data.deleteComment.id;
|
||||
|
||||
const commentsData = store.readQuery<{ event: IEvent }>({
|
||||
query: COMMENTS_THREADS,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
});
|
||||
if (!commentsData) return;
|
||||
const { event } = commentsData;
|
||||
const { comments: oldComments } = event;
|
||||
|
||||
if (comment.originComment) {
|
||||
// we have deleted a reply to a thread
|
||||
const data = store.readQuery<{ thread: IComment[] }>({
|
||||
query: FETCH_THREAD_REPLIES,
|
||||
variables: {
|
||||
threadId: comment.originComment.id,
|
||||
},
|
||||
});
|
||||
if (!data) return;
|
||||
const { thread: oldReplyList } = data;
|
||||
const replies = oldReplyList.filter(reply => reply.id !== deletedCommentId);
|
||||
store.writeQuery({
|
||||
query: FETCH_THREAD_REPLIES,
|
||||
variables: {
|
||||
threadId: comment.originComment.id,
|
||||
},
|
||||
data: { thread: replies },
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
|
||||
const parentComment = oldComments[parentCommentIndex];
|
||||
parentComment.replies = replies;
|
||||
parentComment.totalReplies -= 1;
|
||||
oldComments.splice(parentCommentIndex, 1, parentComment);
|
||||
event.comments = oldComments;
|
||||
} else {
|
||||
// we have deleted a thread itself
|
||||
event.comments = oldComments.filter(reply => reply.id !== deletedCommentId);
|
||||
}
|
||||
store.writeQuery({
|
||||
query: COMMENTS_THREADS,
|
||||
variables: {
|
||||
eventUUID: this.event.uuid,
|
||||
},
|
||||
data: { event },
|
||||
});
|
||||
},
|
||||
});
|
||||
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
|
||||
}
|
||||
|
||||
get orderedComments(): IComment[] {
|
||||
return this.comments.filter((comment => comment.inReplyToComment == null)).sort((a, b) => {
|
||||
if (a.updatedAt && b.updatedAt) {
|
||||
return (new Date(b.updatedAt)).getTime() - (new Date(a.updatedAt)).getTime();
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.new-comment {
|
||||
.media-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
|
||||
.field {
|
||||
flex: 1;
|
||||
padding-right: 10px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-comments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
span {
|
||||
text-align: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 250px;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
ul.comment-list li {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.comment-list-enter-active,
|
||||
.comment-list-leave-active,
|
||||
.comment-list-move {
|
||||
transition: 500ms cubic-bezier(0.59, 0.12, 0.34, 0.95);
|
||||
transition-property: opacity, transform;
|
||||
}
|
||||
|
||||
.comment-list-enter {
|
||||
opacity: 0;
|
||||
transform: translateX(50px) scaleY(0.5);
|
||||
}
|
||||
|
||||
.comment-list-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateX(0) scaleY(1);
|
||||
}
|
||||
|
||||
.comment-list-leave-active,
|
||||
.comment-empty-list-active {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.comment-list-leave-to,
|
||||
.comment-empty-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scaleY(0);
|
||||
transform-origin: center top;
|
||||
}
|
||||
|
||||
/*.comment-empty-list-enter-active {*/
|
||||
/* transition: opacity .5s;*/
|
||||
/*}*/
|
||||
|
||||
/*.comment-empty-list-enter {*/
|
||||
/* opacity: 0;*/
|
||||
/*}*/
|
||||
</style>
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div v-if="editor">
|
||||
<div class="editor" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
|
||||
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, focused }">
|
||||
<div class="editor" :class="{ mode_description: isDescriptionMode }" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
|
||||
<editor-menu-bar v-if="isDescriptionMode" :editor="editor" v-slot="{ commands, isActive, focused }">
|
||||
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
|
||||
|
||||
<button
|
||||
@ -121,6 +121,33 @@
|
||||
</div>
|
||||
</editor-menu-bar>
|
||||
|
||||
<editor-menu-bubble v-if="isCommentMode" :editor="editor" :keep-in-bounds="true" v-slot="{ commands, isActive, menu }">
|
||||
<div
|
||||
class="menububble"
|
||||
:class="{ 'is-active': menu.isActive }"
|
||||
:style="`left: ${menu.left}px; bottom: ${menu.bottom}px;`"
|
||||
>
|
||||
|
||||
<button
|
||||
class="menububble__button"
|
||||
:class="{ 'is-active': isActive.bold() }"
|
||||
@click="commands.bold"
|
||||
type="button"
|
||||
>
|
||||
<b-icon icon="format-bold" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="menububble__button"
|
||||
:class="{ 'is-active': isActive.italic() }"
|
||||
@click="commands.italic"
|
||||
type="button"
|
||||
>
|
||||
<b-icon icon="format-italic" />
|
||||
</button>
|
||||
</div>
|
||||
</editor-menu-bubble>
|
||||
|
||||
<editor-content class="editor__content" :editor="editor" />
|
||||
</div>
|
||||
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
|
||||
@ -129,14 +156,14 @@
|
||||
v-for="(actor, index) in filteredActors"
|
||||
:key="actor.id"
|
||||
class="suggestion-list__item"
|
||||
:class="{ 'is-selected': navigatedUserIndex === index }"
|
||||
:class="{ 'is-selected': navigatedActorIndex === index }"
|
||||
@click="selectActor(actor)"
|
||||
>
|
||||
{{ actor.name }}
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="suggestion-list__item is-empty">
|
||||
No actors found
|
||||
{{ $t('No actors found') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -165,11 +192,12 @@ import {
|
||||
} from 'tiptap-extensions';
|
||||
import tippy, { Instance } from 'tippy.js';
|
||||
import { SEARCH_PERSONS } from '@/graphql/search';
|
||||
import { IActor, IPerson } from '@/types/actor';
|
||||
import { Actor, IActor, IPerson } from '@/types/actor';
|
||||
import Image from '@/components/Editor/Image';
|
||||
import { UPLOAD_PICTURE } from '@/graphql/upload';
|
||||
import { listenFileUpload } from '@/utils/upload';
|
||||
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||
import { IComment } from '@/types/comment.model';
|
||||
|
||||
@Component({
|
||||
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
|
||||
@ -181,6 +209,7 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||
})
|
||||
export default class EditorComponent extends Vue {
|
||||
@Prop({ required: true }) value!: string;
|
||||
@Prop({ required: false, default: 'description' }) mode!: string;
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
@ -192,9 +221,17 @@ export default class EditorComponent extends Vue {
|
||||
query!: string|null;
|
||||
filteredActors: IActor[] = [];
|
||||
suggestionRange!: object|null;
|
||||
navigatedUserIndex: number = 0;
|
||||
navigatedActorIndex: number = 0;
|
||||
popup!: Instance|null;
|
||||
|
||||
get isDescriptionMode() {
|
||||
return this.mode === 'description';
|
||||
}
|
||||
|
||||
get isCommentMode() {
|
||||
return this.mode === 'comment';
|
||||
}
|
||||
|
||||
get hasResults() {
|
||||
return this.filteredActors.length;
|
||||
}
|
||||
@ -232,7 +269,7 @@ export default class EditorComponent extends Vue {
|
||||
this.query = query;
|
||||
this.filteredActors = items;
|
||||
this.suggestionRange = range;
|
||||
this.navigatedUserIndex = 0;
|
||||
this.navigatedActorIndex = 0;
|
||||
this.renderPopup(virtualNode);
|
||||
},
|
||||
|
||||
@ -244,7 +281,7 @@ export default class EditorComponent extends Vue {
|
||||
this.query = null;
|
||||
this.filteredActors = [];
|
||||
this.suggestionRange = null;
|
||||
this.navigatedUserIndex = 0;
|
||||
this.navigatedActorIndex = 0;
|
||||
this.destroyPopup();
|
||||
},
|
||||
|
||||
@ -335,7 +372,7 @@ export default class EditorComponent extends Vue {
|
||||
}
|
||||
|
||||
upHandler() {
|
||||
this.navigatedUserIndex = ((this.navigatedUserIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
|
||||
this.navigatedActorIndex = ((this.navigatedActorIndex + this.filteredActors.length) - 1) % this.filteredActors.length;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -343,11 +380,11 @@ export default class EditorComponent extends Vue {
|
||||
* if it's the last item, navigate to the first one
|
||||
*/
|
||||
downHandler() {
|
||||
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredActors.length;
|
||||
this.navigatedActorIndex = (this.navigatedActorIndex + 1) % this.filteredActors.length;
|
||||
}
|
||||
|
||||
enterHandler() {
|
||||
const actor = this.filteredActors[this.navigatedUserIndex];
|
||||
const actor = this.filteredActors[this.navigatedActorIndex];
|
||||
if (actor) {
|
||||
this.selectActor(actor);
|
||||
}
|
||||
@ -359,17 +396,26 @@ export default class EditorComponent extends Vue {
|
||||
* @param actor IActor
|
||||
*/
|
||||
selectActor(actor: IActor) {
|
||||
const actorModel = new Actor(actor);
|
||||
this.insertMention({
|
||||
range: this.suggestionRange,
|
||||
attrs: {
|
||||
id: actor.id,
|
||||
label: actor.name,
|
||||
id: actorModel.id,
|
||||
label: actorModel.usernameWithDomain().substring(1), // usernameWithDomain returns with a @ prefix and tiptap adds one itself
|
||||
},
|
||||
});
|
||||
if (!this.editor) return;
|
||||
this.editor.focus();
|
||||
}
|
||||
|
||||
replyToComment(comment: IComment) {
|
||||
console.log('called replyToComment', comment);
|
||||
const actorModel = new Actor(comment.actor);
|
||||
if (!this.editor) return;
|
||||
this.editor.commands.mention({ id: actorModel.id, label: actorModel.usernameWithDomain().substring(1) });
|
||||
this.editor.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* renders a popup with suggestions
|
||||
* tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
|
||||
@ -443,6 +489,8 @@ export default class EditorComponent extends Vue {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
@import "@/variables.scss";
|
||||
|
||||
$color-black: #000;
|
||||
$color-white: #eee;
|
||||
|
||||
@ -474,7 +522,6 @@ export default class EditorComponent extends Vue {
|
||||
|
||||
.editor {
|
||||
position: relative;
|
||||
margin: 0 0 1rem;
|
||||
|
||||
p.is-empty:first-child::before {
|
||||
content: attr(data-empty-text);
|
||||
@ -485,18 +532,25 @@ export default class EditorComponent extends Vue {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
&__content {
|
||||
&.mode_description {
|
||||
div.ProseMirror {
|
||||
min-height: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
&__content {
|
||||
div.ProseMirror {
|
||||
min-height: 2.5rem;
|
||||
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
color: #363636;
|
||||
border: 1px solid #dbdbdb;
|
||||
padding: 12px 6px;
|
||||
|
||||
&:focus {
|
||||
|
||||
border-color: $primary;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@ -607,7 +661,7 @@ export default class EditorComponent extends Vue {
|
||||
.mention {
|
||||
background: rgba($color-black, 0.1);
|
||||
color: rgba($color-black, 0.6);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
padding: 0.2rem 0.5rem;
|
||||
|
@ -17,7 +17,7 @@
|
||||
<label class="label">{{ label }}</label>
|
||||
</div>
|
||||
<div class="field-body">
|
||||
<div class="field is-narrow is-grouped">
|
||||
<div class="field is-narrow is-grouped calendar-picker">
|
||||
<b-datepicker
|
||||
:day-names="localeShortWeekDayNamesProxy"
|
||||
:month-names="localeMonthNamesProxy"
|
||||
@ -108,4 +108,10 @@ export default class DateTimePicker extends Vue {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-picker {
|
||||
/deep/ .dropdown-menu {
|
||||
z-index: 200;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -108,7 +108,7 @@ export default class EventCard extends Vue {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
@import "../../variables";
|
||||
|
||||
a.card {
|
||||
|
@ -9,8 +9,9 @@
|
||||
<div class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48" v-if="report.reported.avatar">
|
||||
<img :src="report.reported.avatar.url" />
|
||||
<img alt="" :src="report.reported.avatar.url" />
|
||||
</figure>
|
||||
<b-icon v-else size="is-large" icon="account-circle" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<p class="title is-4">{{ report.reported.name }}</p>
|
||||
@ -19,12 +20,8 @@
|
||||
</div>
|
||||
|
||||
<div class="content columns">
|
||||
<div class="column is-one-quarter box">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
|
||||
<div class="column box" v-if="report.event">
|
||||
<img class="image" v-if="report.event.picture" :src="report.event.picture.url" />
|
||||
<span>{{ report.event.title }}</span>
|
||||
</div>
|
||||
<div class="column box" v-if="report.reportContent">{{ report.reportContent }}</div>
|
||||
<div class="column is-one-quarter-desktop">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
|
||||
<div class="column" v-if="report.content">{{ report.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -16,6 +16,23 @@
|
||||
size="is-large"/>
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="box" v-if="comment">
|
||||
<article class="media">
|
||||
<div class="media-left">
|
||||
<figure class="image is-48x48" v-if="comment.actor.avatar">
|
||||
<img :src="comment.actor.avatar.url" alt="Image">
|
||||
</figure>
|
||||
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
|
||||
</div>
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<strong>{{ comment.actor.name }}</strong> <small>@{{ comment.actor.preferredUsername }}</small>
|
||||
<br>
|
||||
<p v-html="comment.text"></p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p>{{ $t('The report will be sent to the moderators of your instance. You can explain why you report this content below.') }}</p>
|
||||
|
||||
<div class="control">
|
||||
@ -57,7 +74,7 @@
|
||||
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||
import { removeElement } from 'buefy/src/utils/helpers';
|
||||
import { IComment } from '@/types/comment.model';
|
||||
|
||||
@Component({
|
||||
mounted() {
|
||||
@ -67,6 +84,7 @@ import { removeElement } from 'buefy/src/utils/helpers';
|
||||
export default class ReportModal extends Vue {
|
||||
@Prop({ type: Function, default: () => {} }) onConfirm;
|
||||
@Prop({ type: String }) title;
|
||||
@Prop({ type: Object }) comment!: IComment;
|
||||
@Prop({ type: String, default: '' }) outsideDomain;
|
||||
@Prop({ type: String }) cancelText;
|
||||
@Prop({ type: String }) confirmText;
|
||||
@ -97,8 +115,23 @@ export default class ReportModal extends Vue {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss">
|
||||
<style lang="scss" scoped>
|
||||
.modal-card .modal-card-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
</style>
|
||||
|
||||
.modal-card-body {
|
||||
.media-content {
|
||||
.box {
|
||||
.media {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > p {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { formatDateString, formatTimeString, formatDateTimeString } from './datetime';
|
||||
import { nl2br } from '@/filters/utils';
|
||||
|
||||
export default {
|
||||
install(vue) {
|
||||
vue.filter('formatDateString', formatDateString);
|
||||
vue.filter('formatTimeString', formatTimeString);
|
||||
vue.filter('formatDateTimeString', formatDateTimeString);
|
||||
vue.filter('nl2br', nl2br);
|
||||
},
|
||||
};
|
||||
|
9
js/src/filters/utils.ts
Normal file
9
js/src/filters/utils.ts
Normal file
@ -0,0 +1,9 @@
|
||||
/**
|
||||
* New Line to <br>
|
||||
*
|
||||
* @param {string} str Input text
|
||||
* @return {string} Filtered text
|
||||
*/
|
||||
export function nl2br(str: String): String {
|
||||
return `${str}`.replace(/([^>\r\n]?)(\r\n|\n\r|\r|\n)/g, '$1<br>');
|
||||
}
|
83
js/src/graphql/comment.ts
Normal file
83
js/src/graphql/comment.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const COMMENT_FIELDS_FRAGMENT_NAME = 'CommentFields';
|
||||
export const COMMENT_FIELDS_FRAGMENT = gql`
|
||||
fragment CommentFields on Comment {
|
||||
id,
|
||||
uuid,
|
||||
url,
|
||||
text,
|
||||
visibility,
|
||||
actor {
|
||||
avatar {
|
||||
url
|
||||
},
|
||||
id,
|
||||
preferredUsername,
|
||||
name
|
||||
},
|
||||
totalReplies,
|
||||
updatedAt,
|
||||
deletedAt
|
||||
},
|
||||
`;
|
||||
|
||||
export const COMMENT_RECURSIVE_FRAGMENT = gql`
|
||||
fragment CommentRecursive on Comment {
|
||||
...CommentFields
|
||||
inReplyToComment {
|
||||
...CommentFields
|
||||
},
|
||||
originComment {
|
||||
...CommentFields
|
||||
},
|
||||
replies {
|
||||
...CommentFields
|
||||
replies {
|
||||
...CommentFields
|
||||
}
|
||||
},
|
||||
},
|
||||
${COMMENT_FIELDS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const FETCH_THREAD_REPLIES = gql`
|
||||
query($threadId: ID!) {
|
||||
thread(id: $threadId) {
|
||||
...CommentRecursive
|
||||
}
|
||||
}
|
||||
${COMMENT_RECURSIVE_FRAGMENT}
|
||||
`;
|
||||
|
||||
|
||||
export const COMMENTS_THREADS = gql`
|
||||
query($eventUUID: UUID!) {
|
||||
event(uuid: $eventUUID) {
|
||||
id,
|
||||
uuid,
|
||||
comments {
|
||||
...CommentFields,
|
||||
}
|
||||
}
|
||||
}
|
||||
${COMMENT_RECURSIVE_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CREATE_COMMENT_FROM_EVENT = gql`
|
||||
mutation CreateCommentFromEvent($eventId: ID!, $actorId: ID!, $text: String!, $inReplyToCommentId: ID) {
|
||||
createComment(eventId: $eventId, actorId: $actorId, text: $text, inReplyToCommentId: $inReplyToCommentId) {
|
||||
...CommentRecursive
|
||||
}
|
||||
}
|
||||
${COMMENT_RECURSIVE_FRAGMENT}
|
||||
`;
|
||||
|
||||
|
||||
export const DELETE_COMMENT = gql`
|
||||
mutation DeleteComment($commentId: ID!, $actorId: ID!) {
|
||||
deleteComment(commentId: $commentId, actorId: $actorId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
@ -1,4 +1,5 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment';
|
||||
|
||||
const participantQuery = `
|
||||
role,
|
||||
@ -135,6 +136,7 @@ export const FETCH_EVENT = gql`
|
||||
}
|
||||
}
|
||||
}
|
||||
${COMMENT_FIELDS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const FETCH_EVENTS = gql`
|
||||
|
@ -29,7 +29,8 @@ export const REPORTS = gql`
|
||||
url
|
||||
}
|
||||
},
|
||||
status
|
||||
status,
|
||||
content
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -63,10 +64,23 @@ const REPORT_FRAGMENT = gql`
|
||||
url
|
||||
}
|
||||
},
|
||||
comments {
|
||||
id,
|
||||
text,
|
||||
actor {
|
||||
id,
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
notes {
|
||||
id,
|
||||
content
|
||||
moderator {
|
||||
id,
|
||||
preferredUsername,
|
||||
name,
|
||||
avatar {
|
||||
@ -94,11 +108,12 @@ export const REPORT = gql`
|
||||
export const CREATE_REPORT = gql`
|
||||
mutation CreateReport(
|
||||
$eventId: ID!,
|
||||
$reporterActorId: ID!,
|
||||
$reportedActorId: ID!,
|
||||
$content: String
|
||||
$reporterId: ID!,
|
||||
$reportedId: ID!,
|
||||
$content: String,
|
||||
$commentsIds: [ID]
|
||||
) {
|
||||
createReport(eventId: $eventId, reporterActorId: $reporterActorId, reportedActorId: $reportedActorId, content: $content) {
|
||||
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds) {
|
||||
id
|
||||
}
|
||||
}
|
||||
|
@ -40,7 +40,8 @@
|
||||
"Click to select": "Click to select",
|
||||
"Click to upload": "Click to upload",
|
||||
"Close comments for all (except for admins)": "Close comments for all (except for admins)",
|
||||
"Comments on the event page": "Comments on the event page",
|
||||
"Comment from @{username} reported": "Comment from @{username} reported",
|
||||
"Comments have been closed.": "Comments have been closed.",
|
||||
"Comments": "Comments",
|
||||
"Confirm my particpation": "Confirm my particpation",
|
||||
"Confirmed: Will happen": "Confirmed: Will happen",
|
||||
@ -117,6 +118,7 @@
|
||||
"Group {displayName} created": "Group {displayName} created",
|
||||
"Groups": "Groups",
|
||||
"Headline picture": "Headline picture",
|
||||
"Hide replies": "Hide replies",
|
||||
"I create an identity": "I create an identity",
|
||||
"I participate": "I participate",
|
||||
"I want to approve every participation request": "I want to approve every participation request",
|
||||
@ -155,7 +157,9 @@
|
||||
"My identities": "My identities",
|
||||
"Name": "Name",
|
||||
"New password": "New password",
|
||||
"No actors found": "No actors found",
|
||||
"No address defined": "No address defined",
|
||||
"No comments yet": "No comments yet",
|
||||
"No end date": "No end date",
|
||||
"No events found": "No events found",
|
||||
"No group found": "No group found",
|
||||
@ -196,6 +200,8 @@
|
||||
"Please make sure the address is correct and that the page hasn't been moved.": "Please make sure the address is correct and that the page hasn't been moved.",
|
||||
"Please read the full rules": "Please read the full rules",
|
||||
"Please refresh the page and retry.": "Please refresh the page and retry.",
|
||||
"Post a comment": "Post a comment",
|
||||
"Post a reply": "Post a reply",
|
||||
"Postal Code": "Postal Code",
|
||||
"Private event": "Private event",
|
||||
"Private feeds": "Private feeds",
|
||||
@ -216,6 +222,7 @@
|
||||
"Reject": "Reject",
|
||||
"Rejected participations": "Rejected participations",
|
||||
"Rejected": "Rejected",
|
||||
"Report this comment": "Report this comment",
|
||||
"Report this event": "Report this event",
|
||||
"Report": "Report",
|
||||
"Requests": "Requests",
|
||||
@ -276,6 +283,7 @@
|
||||