Merge branch 'feature/comments' into 'master'
Comments See merge request framasoft/mobilizon!335
This commit is contained in:
commit
d0a82c6172
|
@ -52,7 +52,7 @@ config :mobilizon, MobilizonWeb.Endpoint,
|
||||||
# Do not include metadata nor timestamps in development logs
|
# Do not include metadata nor timestamps in development logs
|
||||||
config :logger, :console, format: "[$level] $message\n", level: :debug
|
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
|
# Set a higher stacktrace during development. Avoid configuring such
|
||||||
# in production as building large stacktraces may be expensive.
|
# in production as building large stacktraces may be expensive.
|
||||||
|
|
|
@ -25,12 +25,13 @@
|
||||||
"graphql": "^14.5.8",
|
"graphql": "^14.5.8",
|
||||||
"graphql-tag": "^2.10.1",
|
"graphql-tag": "^2.10.1",
|
||||||
"intersection-observer": "^0.7.0",
|
"intersection-observer": "^0.7.0",
|
||||||
|
"javascript-time-ago": "^2.0.4",
|
||||||
"leaflet": "^1.4.0",
|
"leaflet": "^1.4.0",
|
||||||
"leaflet.locatecontrol": "^0.68.0",
|
"leaflet.locatecontrol": "^0.68.0",
|
||||||
"lodash": "^4.17.11",
|
"lodash": "^4.17.11",
|
||||||
"ngeohash": "^0.6.3",
|
"ngeohash": "^0.6.3",
|
||||||
"register-service-worker": "^1.6.2",
|
"register-service-worker": "^1.6.2",
|
||||||
"tippy.js": "^5.0.2",
|
"tippy.js": "4.3.5",
|
||||||
"tiptap": "^1.26.0",
|
"tiptap": "^1.26.0",
|
||||||
"tiptap-extensions": "^1.28.0",
|
"tiptap-extensions": "^1.28.0",
|
||||||
"vue": "^2.6.10",
|
"vue": "^2.6.10",
|
||||||
|
@ -40,6 +41,7 @@
|
||||||
"vue-meta": "^2.3.1",
|
"vue-meta": "^2.3.1",
|
||||||
"vue-property-decorator": "^8.1.0",
|
"vue-property-decorator": "^8.1.0",
|
||||||
"vue-router": "^3.0.6",
|
"vue-router": "^3.0.6",
|
||||||
|
"vue-scrollto": "^2.17.1",
|
||||||
"vue2-leaflet": "^2.0.3"
|
"vue2-leaflet": "^2.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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>
|
<template>
|
||||||
<div v-if="editor">
|
<div v-if="editor">
|
||||||
<div class="editor" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
|
<div class="editor" :class="{ mode_description: isDescriptionMode }" id="tiptab-editor" :data-actor-id="currentActor && currentActor.id">
|
||||||
<editor-menu-bar :editor="editor" v-slot="{ commands, isActive, focused }">
|
<editor-menu-bar v-if="isDescriptionMode" :editor="editor" v-slot="{ commands, isActive, focused }">
|
||||||
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
|
<div class="menubar bar-is-hidden" :class="{ 'is-focused': focused }">
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
@ -121,6 +121,33 @@
|
||||||
</div>
|
</div>
|
||||||
</editor-menu-bar>
|
</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" />
|
<editor-content class="editor__content" :editor="editor" />
|
||||||
</div>
|
</div>
|
||||||
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
|
<div class="suggestion-list" v-show="showSuggestions" ref="suggestions">
|
||||||
|
@ -129,14 +156,14 @@
|
||||||
v-for="(actor, index) in filteredActors"
|
v-for="(actor, index) in filteredActors"
|
||||||
:key="actor.id"
|
:key="actor.id"
|
||||||
class="suggestion-list__item"
|
class="suggestion-list__item"
|
||||||
:class="{ 'is-selected': navigatedUserIndex === index }"
|
:class="{ 'is-selected': navigatedActorIndex === index }"
|
||||||
@click="selectActor(actor)"
|
@click="selectActor(actor)"
|
||||||
>
|
>
|
||||||
{{ actor.name }}
|
{{ actor.name }}
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div v-else class="suggestion-list__item is-empty">
|
<div v-else class="suggestion-list__item is-empty">
|
||||||
No actors found
|
{{ $t('No actors found') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -165,11 +192,12 @@ import {
|
||||||
} from 'tiptap-extensions';
|
} from 'tiptap-extensions';
|
||||||
import tippy, { Instance } from 'tippy.js';
|
import tippy, { Instance } from 'tippy.js';
|
||||||
import { SEARCH_PERSONS } from '@/graphql/search';
|
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 Image from '@/components/Editor/Image';
|
||||||
import { UPLOAD_PICTURE } from '@/graphql/upload';
|
import { UPLOAD_PICTURE } from '@/graphql/upload';
|
||||||
import { listenFileUpload } from '@/utils/upload';
|
import { listenFileUpload } from '@/utils/upload';
|
||||||
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||||
|
import { IComment } from '@/types/comment.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
|
components: { EditorContent, EditorMenuBar, EditorMenuBubble },
|
||||||
|
@ -181,6 +209,7 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||||
})
|
})
|
||||||
export default class EditorComponent extends Vue {
|
export default class EditorComponent extends Vue {
|
||||||
@Prop({ required: true }) value!: string;
|
@Prop({ required: true }) value!: string;
|
||||||
|
@Prop({ required: false, default: 'description' }) mode!: string;
|
||||||
|
|
||||||
currentActor!: IPerson;
|
currentActor!: IPerson;
|
||||||
|
|
||||||
|
@ -192,9 +221,17 @@ export default class EditorComponent extends Vue {
|
||||||
query!: string|null;
|
query!: string|null;
|
||||||
filteredActors: IActor[] = [];
|
filteredActors: IActor[] = [];
|
||||||
suggestionRange!: object|null;
|
suggestionRange!: object|null;
|
||||||
navigatedUserIndex: number = 0;
|
navigatedActorIndex: number = 0;
|
||||||
popup!: Instance|null;
|
popup!: Instance|null;
|
||||||
|
|
||||||
|
get isDescriptionMode() {
|
||||||
|
return this.mode === 'description';
|
||||||
|
}
|
||||||
|
|
||||||
|
get isCommentMode() {
|
||||||
|
return this.mode === 'comment';
|
||||||
|
}
|
||||||
|
|
||||||
get hasResults() {
|
get hasResults() {
|
||||||
return this.filteredActors.length;
|
return this.filteredActors.length;
|
||||||
}
|
}
|
||||||
|
@ -232,7 +269,7 @@ export default class EditorComponent extends Vue {
|
||||||
this.query = query;
|
this.query = query;
|
||||||
this.filteredActors = items;
|
this.filteredActors = items;
|
||||||
this.suggestionRange = range;
|
this.suggestionRange = range;
|
||||||
this.navigatedUserIndex = 0;
|
this.navigatedActorIndex = 0;
|
||||||
this.renderPopup(virtualNode);
|
this.renderPopup(virtualNode);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -244,7 +281,7 @@ export default class EditorComponent extends Vue {
|
||||||
this.query = null;
|
this.query = null;
|
||||||
this.filteredActors = [];
|
this.filteredActors = [];
|
||||||
this.suggestionRange = null;
|
this.suggestionRange = null;
|
||||||
this.navigatedUserIndex = 0;
|
this.navigatedActorIndex = 0;
|
||||||
this.destroyPopup();
|
this.destroyPopup();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -335,7 +372,7 @@ export default class EditorComponent extends Vue {
|
||||||
}
|
}
|
||||||
|
|
||||||
upHandler() {
|
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
|
* if it's the last item, navigate to the first one
|
||||||
*/
|
*/
|
||||||
downHandler() {
|
downHandler() {
|
||||||
this.navigatedUserIndex = (this.navigatedUserIndex + 1) % this.filteredActors.length;
|
this.navigatedActorIndex = (this.navigatedActorIndex + 1) % this.filteredActors.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
enterHandler() {
|
enterHandler() {
|
||||||
const actor = this.filteredActors[this.navigatedUserIndex];
|
const actor = this.filteredActors[this.navigatedActorIndex];
|
||||||
if (actor) {
|
if (actor) {
|
||||||
this.selectActor(actor);
|
this.selectActor(actor);
|
||||||
}
|
}
|
||||||
|
@ -359,17 +396,26 @@ export default class EditorComponent extends Vue {
|
||||||
* @param actor IActor
|
* @param actor IActor
|
||||||
*/
|
*/
|
||||||
selectActor(actor: IActor) {
|
selectActor(actor: IActor) {
|
||||||
|
const actorModel = new Actor(actor);
|
||||||
this.insertMention({
|
this.insertMention({
|
||||||
range: this.suggestionRange,
|
range: this.suggestionRange,
|
||||||
attrs: {
|
attrs: {
|
||||||
id: actor.id,
|
id: actorModel.id,
|
||||||
label: actor.name,
|
label: actorModel.usernameWithDomain().substring(1), // usernameWithDomain returns with a @ prefix and tiptap adds one itself
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!this.editor) return;
|
if (!this.editor) return;
|
||||||
this.editor.focus();
|
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
|
* renders a popup with suggestions
|
||||||
* tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
|
* 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>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@import "@/variables.scss";
|
||||||
|
|
||||||
$color-black: #000;
|
$color-black: #000;
|
||||||
$color-white: #eee;
|
$color-white: #eee;
|
||||||
|
|
||||||
|
@ -474,7 +522,6 @@ export default class EditorComponent extends Vue {
|
||||||
|
|
||||||
.editor {
|
.editor {
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 0 1rem;
|
|
||||||
|
|
||||||
p.is-empty:first-child::before {
|
p.is-empty:first-child::before {
|
||||||
content: attr(data-empty-text);
|
content: attr(data-empty-text);
|
||||||
|
@ -485,18 +532,25 @@ export default class EditorComponent extends Vue {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__content {
|
&.mode_description {
|
||||||
div.ProseMirror {
|
div.ProseMirror {
|
||||||
min-height: 10rem;
|
min-height: 10rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__content {
|
||||||
|
div.ProseMirror {
|
||||||
|
min-height: 2.5rem;
|
||||||
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
|
box-shadow: inset 0 1px 2px rgba(10, 10, 10, 0.1);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
color: #363636;
|
color: #363636;
|
||||||
border: 1px solid #dbdbdb;
|
border: 1px solid #dbdbdb;
|
||||||
|
padding: 12px 6px;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
border-color: $primary;
|
||||||
|
outline: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -607,7 +661,7 @@ export default class EditorComponent extends Vue {
|
||||||
.mention {
|
.mention {
|
||||||
background: rgba($color-black, 0.1);
|
background: rgba($color-black, 0.1);
|
||||||
color: rgba($color-black, 0.6);
|
color: rgba($color-black, 0.6);
|
||||||
font-size: 0.8rem;
|
font-size: 0.9rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
padding: 0.2rem 0.5rem;
|
padding: 0.2rem 0.5rem;
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
<label class="label">{{ label }}</label>
|
<label class="label">{{ label }}</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-body">
|
<div class="field-body">
|
||||||
<div class="field is-narrow is-grouped">
|
<div class="field is-narrow is-grouped calendar-picker">
|
||||||
<b-datepicker
|
<b-datepicker
|
||||||
:day-names="localeShortWeekDayNamesProxy"
|
:day-names="localeShortWeekDayNamesProxy"
|
||||||
:month-names="localeMonthNamesProxy"
|
:month-names="localeMonthNamesProxy"
|
||||||
|
@ -108,4 +108,10 @@ export default class DateTimePicker extends Vue {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.calendar-picker {
|
||||||
|
/deep/ .dropdown-menu {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -108,7 +108,7 @@ export default class EventCard extends Vue {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
@import "../../variables";
|
@import "../../variables";
|
||||||
|
|
||||||
a.card {
|
a.card {
|
||||||
|
|
|
@ -9,8 +9,9 @@
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<div class="media-left">
|
<div class="media-left">
|
||||||
<figure class="image is-48x48" v-if="report.reported.avatar">
|
<figure class="image is-48x48" v-if="report.reported.avatar">
|
||||||
<img :src="report.reported.avatar.url" />
|
<img alt="" :src="report.reported.avatar.url" />
|
||||||
</figure>
|
</figure>
|
||||||
|
<b-icon v-else size="is-large" icon="account-circle" />
|
||||||
</div>
|
</div>
|
||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<p class="title is-4">{{ report.reported.name }}</p>
|
<p class="title is-4">{{ report.reported.name }}</p>
|
||||||
|
@ -19,12 +20,8 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="content columns">
|
<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 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 box" v-if="report.event">
|
<div class="column" v-if="report.content">{{ report.content }}</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,6 +16,23 @@
|
||||||
size="is-large"/>
|
size="is-large"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="media-content">
|
<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>
|
<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">
|
<div class="control">
|
||||||
|
@ -57,7 +74,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import { Component, Prop, Vue } from 'vue-property-decorator';
|
||||||
import { removeElement } from 'buefy/src/utils/helpers';
|
import { IComment } from '@/types/comment.model';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
mounted() {
|
mounted() {
|
||||||
|
@ -67,6 +84,7 @@ import { removeElement } from 'buefy/src/utils/helpers';
|
||||||
export default class ReportModal extends Vue {
|
export default class ReportModal extends Vue {
|
||||||
@Prop({ type: Function, default: () => {} }) onConfirm;
|
@Prop({ type: Function, default: () => {} }) onConfirm;
|
||||||
@Prop({ type: String }) title;
|
@Prop({ type: String }) title;
|
||||||
|
@Prop({ type: Object }) comment!: IComment;
|
||||||
@Prop({ type: String, default: '' }) outsideDomain;
|
@Prop({ type: String, default: '' }) outsideDomain;
|
||||||
@Prop({ type: String }) cancelText;
|
@Prop({ type: String }) cancelText;
|
||||||
@Prop({ type: String }) confirmText;
|
@Prop({ type: String }) confirmText;
|
||||||
|
@ -97,8 +115,23 @@ export default class ReportModal extends Vue {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
.modal-card .modal-card-foot {
|
.modal-card .modal-card-foot {
|
||||||
justify-content: flex-end;
|
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 { formatDateString, formatTimeString, formatDateTimeString } from './datetime';
|
||||||
|
import { nl2br } from '@/filters/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
install(vue) {
|
install(vue) {
|
||||||
vue.filter('formatDateString', formatDateString);
|
vue.filter('formatDateString', formatDateString);
|
||||||
vue.filter('formatTimeString', formatTimeString);
|
vue.filter('formatTimeString', formatTimeString);
|
||||||
vue.filter('formatDateTimeString', formatDateTimeString);
|
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 gql from 'graphql-tag';
|
||||||
|
import { COMMENT_FIELDS_FRAGMENT } from '@/graphql/comment';
|
||||||
|
|
||||||
const participantQuery = `
|
const participantQuery = `
|
||||||
role,
|
role,
|
||||||
|
@ -135,6 +136,7 @@ export const FETCH_EVENT = gql`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
${COMMENT_FIELDS_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const FETCH_EVENTS = gql`
|
export const FETCH_EVENTS = gql`
|
||||||
|
|
|
@ -29,7 +29,8 @@ export const REPORTS = gql`
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
status
|
status,
|
||||||
|
content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
@ -63,10 +64,23 @@ const REPORT_FRAGMENT = gql`
|
||||||
url
|
url
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
comments {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
actor {
|
||||||
|
id,
|
||||||
|
preferredUsername,
|
||||||
|
name,
|
||||||
|
avatar {
|
||||||
|
url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
notes {
|
notes {
|
||||||
id,
|
id,
|
||||||
content
|
content
|
||||||
moderator {
|
moderator {
|
||||||
|
id,
|
||||||
preferredUsername,
|
preferredUsername,
|
||||||
name,
|
name,
|
||||||
avatar {
|
avatar {
|
||||||
|
@ -94,11 +108,12 @@ export const REPORT = gql`
|
||||||
export const CREATE_REPORT = gql`
|
export const CREATE_REPORT = gql`
|
||||||
mutation CreateReport(
|
mutation CreateReport(
|
||||||
$eventId: ID!,
|
$eventId: ID!,
|
||||||
$reporterActorId: ID!,
|
$reporterId: ID!,
|
||||||
$reportedActorId: ID!,
|
$reportedId: ID!,
|
||||||
$content: String
|
$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
|
id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,7 +40,8 @@
|
||||||
"Click to select": "Click to select",
|
"Click to select": "Click to select",
|
||||||
"Click to upload": "Click to upload",
|
"Click to upload": "Click to upload",
|
||||||
"Close comments for all (except for admins)": "Close comments for all (except for admins)",
|
"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",
|
"Comments": "Comments",
|
||||||
"Confirm my particpation": "Confirm my particpation",
|
"Confirm my particpation": "Confirm my particpation",
|
||||||
"Confirmed: Will happen": "Confirmed: Will happen",
|
"Confirmed: Will happen": "Confirmed: Will happen",
|
||||||
|
@ -117,6 +118,7 @@
|
||||||
"Group {displayName} created": "Group {displayName} created",
|
"Group {displayName} created": "Group {displayName} created",
|
||||||
"Groups": "Groups",
|
"Groups": "Groups",
|
||||||
"Headline picture": "Headline picture",
|
"Headline picture": "Headline picture",
|
||||||
|
"Hide replies": "Hide replies",
|
||||||
"I create an identity": "I create an identity",
|
"I create an identity": "I create an identity",
|
||||||
"I participate": "I participate",
|
"I participate": "I participate",
|
||||||
"I want to approve every participation request": "I want to approve every participation request",
|
"I want to approve every participation request": "I want to approve every participation request",
|
||||||
|
@ -155,7 +157,9 @@
|
||||||
"My identities": "My identities",
|
"My identities": "My identities",
|
||||||
"Name": "Name",
|
"Name": "Name",
|
||||||
"New password": "New password",
|
"New password": "New password",
|
||||||
|
"No actors found": "No actors found",
|
||||||
"No address defined": "No address defined",
|
"No address defined": "No address defined",
|
||||||
|
"No comments yet": "No comments yet",
|
||||||
"No end date": "No end date",
|
"No end date": "No end date",
|
||||||
"No events found": "No events found",
|
"No events found": "No events found",
|
||||||
"No group found": "No group 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 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 read the full rules": "Please read the full rules",
|
||||||
"Please refresh the page and retry.": "Please refresh the page and retry.",
|
"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",
|
"Postal Code": "Postal Code",
|
||||||
"Private event": "Private event",
|
"Private event": "Private event",
|
||||||
"Private feeds": "Private feeds",
|
"Private feeds": "Private feeds",
|
||||||
|
@ -216,6 +222,7 @@
|
||||||
"Reject": "Reject",
|
"Reject": "Reject",
|
||||||
"Rejected participations": "Rejected participations",
|
"Rejected participations": "Rejected participations",
|
||||||
"Rejected": "Rejected",
|
"Rejected": "Rejected",
|
||||||
|
"Report this comment": "Report this comment",
|
||||||
"Report this event": "Report this event",
|
"Report this event": "Report this event",
|
||||||
"Report": "Report",
|
"Report": "Report",
|
||||||
"Requests": "Requests",
|
"Requests": "Requests",
|
||||||
|
@ -276,6 +283,7 @@
|
||||||
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "User accounts and every other data is currently deleted every 48 hours, so you may want to register again.",
|
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "User accounts and every other data is currently deleted every 48 hours, so you may want to register again.",
|
||||||
"Username": "Username",
|
"Username": "Username",
|
||||||
"Users": "Users",
|
"Users": "Users",
|
||||||
|
"View a reply": "|View one reply|View {totalReplies} replies",
|
||||||
"View event page": "View event page",
|
"View event page": "View event page",
|
||||||
"View everything": "View everything",
|
"View everything": "View everything",
|
||||||
"View page on {hostname} (in a new window)": "View page on {hostname} (in a new window)",
|
"View page on {hostname} (in a new window)": "View page on {hostname} (in a new window)",
|
||||||
|
@ -313,6 +321,8 @@
|
||||||
"Your local administrator resumed its policy:": "Your local administrator resumed its policy:",
|
"Your local administrator resumed its policy:": "Your local administrator resumed its policy:",
|
||||||
"Your participation has been confirmed": "Your participation has been confirmed",
|
"Your participation has been confirmed": "Your participation has been confirmed",
|
||||||
"Your participation has been requested": "Your participation has been requested",
|
"Your participation has been requested": "Your participation has been requested",
|
||||||
|
"[This comment has been deleted]": "[This comment has been deleted]",
|
||||||
|
"[deleted]": "[deleted]",
|
||||||
"a decentralised federation protocol": "a decentralised federation protocol",
|
"a decentralised federation protocol": "a decentralised federation protocol",
|
||||||
"e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot",
|
"e.g. 10 Rue Jangot": "e.g. 10 Rue Jangot",
|
||||||
"firstDayOfWeek": "0",
|
"firstDayOfWeek": "0",
|
||||||
|
@ -330,4 +340,4 @@
|
||||||
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
|
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
|
||||||
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
|
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
|
||||||
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
|
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
|
||||||
}
|
}
|
|
@ -40,6 +40,8 @@
|
||||||
"Click to select": "Cliquez pour sélectionner",
|
"Click to select": "Cliquez pour sélectionner",
|
||||||
"Click to upload": "Cliquez pour uploader",
|
"Click to upload": "Cliquez pour uploader",
|
||||||
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)",
|
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateurs)",
|
||||||
|
"Comment from @{username} reported": "Commentaire de @{username} signalé",
|
||||||
|
"Comments have been closed.": "Les commentaires sont fermés.",
|
||||||
"Comments on the event page": "Commentaires sur la page de l'événement",
|
"Comments on the event page": "Commentaires sur la page de l'événement",
|
||||||
"Comments": "Commentaires",
|
"Comments": "Commentaires",
|
||||||
"Confirm my particpation": "Confirmer ma participation",
|
"Confirm my particpation": "Confirmer ma participation",
|
||||||
|
@ -59,6 +61,7 @@
|
||||||
"Create": "Créer",
|
"Create": "Créer",
|
||||||
"Creator": "Créateur",
|
"Creator": "Créateur",
|
||||||
"Current identity has been changed to {identityName} in order to manage this event.": "L'identité actuelle a été changée à {identityName} pour pouvoir gérer cet événement.",
|
"Current identity has been changed to {identityName} in order to manage this event.": "L'identité actuelle a été changée à {identityName} pour pouvoir gérer cet événement.",
|
||||||
|
"Dashboard": "Tableau de bord",
|
||||||
"Date and time settings": "Paramètres de date et d'heure",
|
"Date and time settings": "Paramètres de date et d'heure",
|
||||||
"Date parameters": "Paramètres de date",
|
"Date parameters": "Paramètres de date",
|
||||||
"Delete event": "Supprimer un événement",
|
"Delete event": "Supprimer un événement",
|
||||||
|
@ -117,6 +120,7 @@
|
||||||
"Group {displayName} created": "Groupe {displayName} créé",
|
"Group {displayName} created": "Groupe {displayName} créé",
|
||||||
"Groups": "Groupes",
|
"Groups": "Groupes",
|
||||||
"Headline picture": "Image à la une",
|
"Headline picture": "Image à la une",
|
||||||
|
"Hide replies": "Masquer les réponses",
|
||||||
"I create an identity": "Je crée une identité",
|
"I create an identity": "Je crée une identité",
|
||||||
"I participate": "Je participe",
|
"I participate": "Je participe",
|
||||||
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
|
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
|
||||||
|
@ -155,7 +159,9 @@
|
||||||
"My identities": "Mes identités",
|
"My identities": "Mes identités",
|
||||||
"Name": "Nom",
|
"Name": "Nom",
|
||||||
"New password": "Nouveau mot de passe",
|
"New password": "Nouveau mot de passe",
|
||||||
|
"No actors found": "Aucun acteur trouvé",
|
||||||
"No address defined": "Aucune adresse définie",
|
"No address defined": "Aucune adresse définie",
|
||||||
|
"No comments yet": "Pas encore de commentaires",
|
||||||
"No end date": "Pas de date de fin",
|
"No end date": "Pas de date de fin",
|
||||||
"No events found": "Aucun événement trouvé",
|
"No events found": "Aucun événement trouvé",
|
||||||
"No group found": "Aucun groupe trouvé",
|
"No group found": "Aucun groupe trouvé",
|
||||||
|
@ -197,6 +203,8 @@
|
||||||
"Please read the full rules": "Merci de lire les règles complètes",
|
"Please read the full rules": "Merci de lire les règles complètes",
|
||||||
"Please refresh the page and retry.": "Merci de rafraîchir la page puis réessayer.",
|
"Please refresh the page and retry.": "Merci de rafraîchir la page puis réessayer.",
|
||||||
"Please type at least 5 characters": "Merci d'entrer au moins 5 caractères",
|
"Please type at least 5 characters": "Merci d'entrer au moins 5 caractères",
|
||||||
|
"Post a comment": "Ajouter un commentaire",
|
||||||
|
"Post a reply": "Envoyer une réponse",
|
||||||
"Postal Code": "Code postal",
|
"Postal Code": "Code postal",
|
||||||
"Private event": "Événement privé",
|
"Private event": "Événement privé",
|
||||||
"Private feeds": "Flux privés",
|
"Private feeds": "Flux privés",
|
||||||
|
@ -217,8 +225,10 @@
|
||||||
"Reject": "Rejetter",
|
"Reject": "Rejetter",
|
||||||
"Rejected participations": "Participations rejetées",
|
"Rejected participations": "Participations rejetées",
|
||||||
"Rejected": "Rejetés",
|
"Rejected": "Rejetés",
|
||||||
|
"Report this comment": "Signaler ce commentaire",
|
||||||
"Report this event": "Signaler cet événement",
|
"Report this event": "Signaler cet événement",
|
||||||
"Report": "Signaler",
|
"Report": "Signalement",
|
||||||
|
"Reports": "Signalements",
|
||||||
"Requests": "Requêtes",
|
"Requests": "Requêtes",
|
||||||
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
|
"Resend confirmation email": "Envoyer à nouveau l'email de confirmation",
|
||||||
"Reset my password": "Réinitialiser mon mot de passe",
|
"Reset my password": "Réinitialiser mon mot de passe",
|
||||||
|
@ -277,6 +287,7 @@
|
||||||
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "Les comptes utilisateurs et toutes les autres données sont actuellement supprimées toutes les 48 heures, donc vous voulez peut-être vous inscrire à nouveau.",
|
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "Les comptes utilisateurs et toutes les autres données sont actuellement supprimées toutes les 48 heures, donc vous voulez peut-être vous inscrire à nouveau.",
|
||||||
"Username": "Pseudo",
|
"Username": "Pseudo",
|
||||||
"Users": "Utilisateurs",
|
"Users": "Utilisateurs",
|
||||||
|
"View a reply": "Aucune réponse | Voir une réponse | Voir {totalReplies} réponses",
|
||||||
"View event page": "Voir la page de l'événement",
|
"View event page": "Voir la page de l'événement",
|
||||||
"View everything": "Voir tout",
|
"View everything": "Voir tout",
|
||||||
"View page on {hostname} (in a new window)": "Voir la page sur {hostname} (dans une nouvelle fenêtre)",
|
"View page on {hostname} (in a new window)": "Voir la page sur {hostname} (dans une nouvelle fenêtre)",
|
||||||
|
@ -314,6 +325,8 @@
|
||||||
"Your local administrator resumed its policy:": "Votre administrateur local a résumé sa politique ainsi :",
|
"Your local administrator resumed its policy:": "Votre administrateur local a résumé sa politique ainsi :",
|
||||||
"Your participation has been confirmed": "Votre participation a été confirmée",
|
"Your participation has been confirmed": "Votre participation a été confirmée",
|
||||||
"Your participation has been requested": "Votre participation a été demandée",
|
"Your participation has been requested": "Votre participation a été demandée",
|
||||||
|
"[This comment has been deleted]": "[Ce commentaire a été supprimé]",
|
||||||
|
"[deleted]": "[supprimé]",
|
||||||
"a decentralised federation protocol": "un protocole de fédération décentralisée",
|
"a decentralised federation protocol": "un protocole de fédération décentralisée",
|
||||||
"e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot",
|
"e.g. 10 Rue Jangot": "par exemple : 10 Rue Jangot",
|
||||||
"firstDayOfWeek": "1",
|
"firstDayOfWeek": "1",
|
||||||
|
@ -331,4 +344,4 @@
|
||||||
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
|
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
|
||||||
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
|
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
|
||||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
|
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
|
||||||
}
|
}
|
|
@ -3,6 +3,7 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import Buefy from 'buefy';
|
import Buefy from 'buefy';
|
||||||
import Component from 'vue-class-component';
|
import Component from 'vue-class-component';
|
||||||
|
import VueScrollTo from 'vue-scrollto';
|
||||||
import App from '@/App.vue';
|
import App from '@/App.vue';
|
||||||
import router from '@/router';
|
import router from '@/router';
|
||||||
import { apolloProvider } from './vue-apollo';
|
import { apolloProvider } from './vue-apollo';
|
||||||
|
@ -17,6 +18,7 @@ Vue.use(Buefy);
|
||||||
Vue.use(NotifierPlugin);
|
Vue.use(NotifierPlugin);
|
||||||
Vue.use(filters);
|
Vue.use(filters);
|
||||||
Vue.use(VueMeta);
|
Vue.use(VueMeta);
|
||||||
|
Vue.use(VueScrollTo);
|
||||||
|
|
||||||
// Register the router hooks with their names
|
// Register the router hooks with their names
|
||||||
Component.registerHooks([
|
Component.registerHooks([
|
||||||
|
|
|
@ -7,7 +7,8 @@ import { IPerson } from '@/types/actor';
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
export default class EventMixin extends mixins(Vue) {
|
export default class EventMixin extends mixins(Vue) {
|
||||||
async openDeleteEventModal (event: IEvent, currentActor: IPerson) {
|
|
||||||
|
async openDeleteEventModal(event: IEvent, currentActor: IPerson) {
|
||||||
const participantsLength = event.participantStats.participant;
|
const participantsLength = event.participantStats.participant;
|
||||||
const prefix = participantsLength
|
const prefix = participantsLength
|
||||||
? this.$tc('There are {participants} participants.', event.participantStats.participant, {
|
? this.$tc('There are {participants} participants.', event.participantStats.participant, {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
import Router from 'vue-router';
|
import Router from 'vue-router';
|
||||||
|
import VueScrollTo from 'vue-scrollto';
|
||||||
import PageNotFound from '@/views/PageNotFound.vue';
|
import PageNotFound from '@/views/PageNotFound.vue';
|
||||||
import Home from '@/views/Home.vue';
|
import Home from '@/views/Home.vue';
|
||||||
import { UserRouteName, userRoutes } from './user';
|
import { UserRouteName, userRoutes } from './user';
|
||||||
|
@ -20,13 +21,18 @@ enum GlobalRouteName {
|
||||||
SEARCH = 'Search',
|
SEARCH = 'Search',
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollBehavior(to) {
|
function scrollBehavior(to, from, savedPosition) {
|
||||||
if (to.hash) {
|
if (to.hash) {
|
||||||
|
VueScrollTo.scrollTo(to.hash, 700);
|
||||||
return {
|
return {
|
||||||
selector: to.hash,
|
selector: to.hash,
|
||||||
// , offset: { x: 0, y: 10 }
|
offset: { x: 0, y: 10 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (savedPosition) {
|
||||||
|
return savedPosition;
|
||||||
|
}
|
||||||
|
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
4
js/src/shims-vue.d.ts
vendored
4
js/src/shims-vue.d.ts
vendored
|
@ -1,4 +1,8 @@
|
||||||
|
import { Vue } from 'vue/types/vue';
|
||||||
|
|
||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
export default Vue;
|
export default Vue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Refs<T extends object> = Vue['$refs'] & T;
|
||||||
|
|
50
js/src/types/comment.model.ts
Normal file
50
js/src/types/comment.model.ts
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
import { Actor, IActor } from '@/types/actor';
|
||||||
|
import { EventModel, IEvent } from '@/types/event.model';
|
||||||
|
|
||||||
|
export interface IComment {
|
||||||
|
id?: string;
|
||||||
|
uuid?: string;
|
||||||
|
url?: string;
|
||||||
|
text: string;
|
||||||
|
actor: IActor;
|
||||||
|
inReplyToComment?: IComment;
|
||||||
|
originComment?: IComment;
|
||||||
|
replies: IComment[];
|
||||||
|
event?: IEvent;
|
||||||
|
updatedAt?: Date;
|
||||||
|
deletedAt?: Date;
|
||||||
|
totalReplies: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CommentModel implements IComment {
|
||||||
|
actor: IActor = new Actor();
|
||||||
|
id?: string;
|
||||||
|
text: string = '';
|
||||||
|
url?: string;
|
||||||
|
uuid?: string;
|
||||||
|
inReplyToComment?: IComment = undefined;
|
||||||
|
originComment?: IComment = undefined;
|
||||||
|
replies: IComment[] = [];
|
||||||
|
event?: IEvent = undefined;
|
||||||
|
updatedAt?: Date = undefined;
|
||||||
|
deletedAt?: Date = undefined;
|
||||||
|
totalReplies: number = 0;
|
||||||
|
|
||||||
|
constructor(hash?: IComment) {
|
||||||
|
if (!hash) return;
|
||||||
|
|
||||||
|
this.id = hash.id;
|
||||||
|
this.uuid = hash.uuid;
|
||||||
|
this.url = hash.url;
|
||||||
|
this.text = hash.text;
|
||||||
|
this.inReplyToComment = hash.inReplyToComment;
|
||||||
|
this.originComment = hash.originComment;
|
||||||
|
this.actor = new Actor(hash.actor);
|
||||||
|
this.event = new EventModel(hash.event);
|
||||||
|
this.replies = hash.replies;
|
||||||
|
this.updatedAt = hash.updatedAt;
|
||||||
|
this.deletedAt = hash.deletedAt;
|
||||||
|
this.totalReplies = hash.totalReplies;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ import { Actor, IActor, IPerson } from './actor';
|
||||||
import { Address, IAddress } from '@/types/address.model';
|
import { Address, IAddress } from '@/types/address.model';
|
||||||
import { ITag } from '@/types/tag.model';
|
import { ITag } from '@/types/tag.model';
|
||||||
import { IPicture } from '@/types/picture.model';
|
import { IPicture } from '@/types/picture.model';
|
||||||
|
import { IComment } from '@/types/comment.model';
|
||||||
|
|
||||||
export enum EventStatus {
|
export enum EventStatus {
|
||||||
TENTATIVE = 'TENTATIVE',
|
TENTATIVE = 'TENTATIVE',
|
||||||
|
@ -129,6 +130,7 @@ export interface IEvent {
|
||||||
participants: IParticipant[];
|
participants: IParticipant[];
|
||||||
|
|
||||||
relatedEvents: IEvent[];
|
relatedEvents: IEvent[];
|
||||||
|
comments: IComment[];
|
||||||
|
|
||||||
onlineAddress?: string;
|
onlineAddress?: string;
|
||||||
phoneAddress?: string;
|
phoneAddress?: string;
|
||||||
|
@ -199,9 +201,10 @@ export class EventModel implements IEvent {
|
||||||
participants: IParticipant[] = [];
|
participants: IParticipant[] = [];
|
||||||
|
|
||||||
relatedEvents: IEvent[] = [];
|
relatedEvents: IEvent[] = [];
|
||||||
|
comments: IComment[] = [];
|
||||||
|
|
||||||
attributedTo = new Actor();
|
attributedTo = new Actor();
|
||||||
organizerActor?: IActor;
|
organizerActor?: IActor = new Actor();
|
||||||
|
|
||||||
tags: ITag[] = [];
|
tags: ITag[] = [];
|
||||||
options: IEventOptions = new EventOptions();
|
options: IEventOptions = new EventOptions();
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import { IActor, IPerson } from '@/types/actor';
|
import { IActor, IPerson } from '@/types/actor';
|
||||||
import { IEvent } from '@/types/event.model';
|
import { IEvent } from '@/types/event.model';
|
||||||
|
import { IComment } from '@/types/comment.model';
|
||||||
|
|
||||||
export enum ReportStatusEnum {
|
export enum ReportStatusEnum {
|
||||||
OPEN = 'OPEN',
|
OPEN = 'OPEN',
|
||||||
|
@ -12,6 +13,7 @@ export interface IReport extends IActionLogObject {
|
||||||
reported: IActor;
|
reported: IActor;
|
||||||
reporter: IPerson;
|
reporter: IPerson;
|
||||||
event?: IEvent;
|
event?: IEvent;
|
||||||
|
comments: IComment[];
|
||||||
content: string;
|
content: string;
|
||||||
notes: IReportNote[];
|
notes: IReportNote[];
|
||||||
insertedAt: Date;
|
insertedAt: Date;
|
||||||
|
@ -36,6 +38,7 @@ export enum ActionLogAction {
|
||||||
REPORT_UPDATE_OPENED = 'REPORT_UPDATE_OPENED',
|
REPORT_UPDATE_OPENED = 'REPORT_UPDATE_OPENED',
|
||||||
REPORT_UPDATE_RESOLVED = 'REPORT_UPDATE_RESOLVED',
|
REPORT_UPDATE_RESOLVED = 'REPORT_UPDATE_RESOLVED',
|
||||||
EVENT_DELETION = 'EVENT_DELETION',
|
EVENT_DELETION = 'EVENT_DELETION',
|
||||||
|
COMMENT_DELETION = 'COMMENT_DELETION',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IActionLog {
|
export interface IActionLog {
|
||||||
|
|
3
js/src/typings/tiptap.d.ts
vendored
3
js/src/typings/tiptap.d.ts
vendored
|
@ -2,6 +2,9 @@ declare module 'tiptap' {
|
||||||
import Vue from 'vue';
|
import Vue from 'vue';
|
||||||
export class Editor {
|
export class Editor {
|
||||||
public constructor({});
|
public constructor({});
|
||||||
|
public commands: {
|
||||||
|
mention: Function,
|
||||||
|
};
|
||||||
|
|
||||||
public setOptions({}): void;
|
public setOptions({}): void;
|
||||||
public setContent(content: string): void;
|
public setContent(content: string): void;
|
||||||
|
|
|
@ -32,6 +32,3 @@ $navbar-height: 4rem;
|
||||||
// Footer
|
// Footer
|
||||||
$footer-padding: 3rem 1.5rem 4rem;
|
$footer-padding: 3rem 1.5rem 4rem;
|
||||||
$footer-background-color: $primary;
|
$footer-background-color: $primary;
|
||||||
|
|
||||||
// Card
|
|
||||||
$card-background-color: $secondary;
|
|
||||||
|
|
|
@ -7,7 +7,8 @@
|
||||||
<div class="list is-hoverable">
|
<div class="list is-hoverable">
|
||||||
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
|
<a class="list-item" v-for="identity in identities" :class="{ 'is-active': identity.id === currentIdentity.id }" @click="changeCurrentIdentity(identity)">
|
||||||
<div class="media">
|
<div class="media">
|
||||||
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" />
|
<img class="media-left image" v-if="identity.avatar" :src="identity.avatar.url" alt="" />
|
||||||
|
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
|
||||||
<div class="media-content">
|
<div class="media-content">
|
||||||
<h3>@{{ identity.preferredUsername }}</h3>
|
<h3>@{{ identity.preferredUsername }}</h3>
|
||||||
<small>{{ identity.name }}</small>
|
<small>{{ identity.name }}</small>
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="identity-picker">
|
<div class="identity-picker">
|
||||||
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
|
<span v-if="inline" class="inline">
|
||||||
<b-button type="is-text" @click="isComponentModalActive = true">
|
<img class="image" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/> {{ currentIdentity.name || `@${currentIdentity.preferredUsername}` }}
|
||||||
{{ $t('Change') }}
|
<b-button type="is-text" @click="isComponentModalActive = true">
|
||||||
</b-button>
|
{{ $t('Change') }}
|
||||||
|
</b-button>
|
||||||
|
</span>
|
||||||
|
<span v-else class="block" @click="isComponentModalActive = true">
|
||||||
|
<img class="image is-48x48" v-if="currentIdentity.avatar" :src="currentIdentity.avatar.url" :alt="currentIdentity.avatar.alt"/>
|
||||||
|
<b-icon v-else size="is-large" icon="account-circle" />
|
||||||
|
</span>
|
||||||
<b-modal :active.sync="isComponentModalActive" has-modal-card>
|
<b-modal :active.sync="isComponentModalActive" has-modal-card>
|
||||||
<identity-picker v-model="currentIdentity" @input="relay" />
|
<identity-picker v-model="currentIdentity" @input="relay" />
|
||||||
</b-modal>
|
</b-modal>
|
||||||
|
@ -19,6 +25,7 @@ import IdentityPicker from './IdentityPicker.vue';
|
||||||
})
|
})
|
||||||
export default class IdentityPickerWrapper extends Vue {
|
export default class IdentityPickerWrapper extends Vue {
|
||||||
@Prop() value!: IActor;
|
@Prop() value!: IActor;
|
||||||
|
@Prop({ default: true, type: Boolean }) inline!: boolean;
|
||||||
isComponentModalActive: boolean = false;
|
isComponentModalActive: boolean = false;
|
||||||
currentIdentity: IActor = this.value;
|
currentIdentity: IActor = this.value;
|
||||||
|
|
||||||
|
@ -36,9 +43,16 @@ export default class IdentityPickerWrapper extends Vue {
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.identity-picker img.image {
|
.identity-picker {
|
||||||
display: inline;
|
|
||||||
height: 1.5em;
|
.block {
|
||||||
vertical-align: text-bottom;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline img.image {
|
||||||
|
display: inline;
|
||||||
|
height: 1.5em;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -94,7 +94,7 @@
|
||||||
<b-field :label="$t('Number of places')">
|
<b-field :label="$t('Number of places')">
|
||||||
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity"></b-numberinput>
|
<b-numberinput controls-position="compact" min="0" v-model="event.options.maximumAttendeeCapacity"></b-numberinput>
|
||||||
</b-field>
|
</b-field>
|
||||||
<!--
|
<!--
|
||||||
<b-field>
|
<b-field>
|
||||||
<b-switch v-model="event.options.showRemainingAttendeeCapacity">
|
<b-switch v-model="event.options.showRemainingAttendeeCapacity">
|
||||||
{{ $t('Show remaining number of places') }}
|
{{ $t('Show remaining number of places') }}
|
||||||
|
@ -108,12 +108,10 @@
|
||||||
</b-field> -->
|
</b-field> -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- <h2 class="subtitle">
|
<h2 class="subtitle">
|
||||||
{{ $t('Public comment moderation') }}
|
{{ $t('Public comment moderation') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<label>{{ $t('Comments on the event page') }}</label>
|
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<b-radio v-model="event.options.commentModeration"
|
<b-radio v-model="event.options.commentModeration"
|
||||||
name="commentModeration"
|
name="commentModeration"
|
||||||
|
@ -122,13 +120,13 @@
|
||||||
</b-radio>
|
</b-radio>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<!-- <div class="field">-->
|
||||||
<b-radio v-model="event.options.commentModeration"
|
<!-- <b-radio v-model="event.options.commentModeration"-->
|
||||||
name="commentModeration"
|
<!-- name="commentModeration"-->
|
||||||
:native-value="CommentModeration.MODERATED">
|
<!-- :native-value="CommentModeration.MODERATED">-->
|
||||||
{{ $t('Moderated comments (shown after approval)') }}
|
<!-- {{ $t('Moderated comments (shown after approval)') }}-->
|
||||||
</b-radio>
|
<!-- </b-radio>-->
|
||||||
</div>
|
<!-- </div>-->
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<b-radio v-model="event.options.commentModeration"
|
<b-radio v-model="event.options.commentModeration"
|
||||||
|
@ -136,7 +134,7 @@
|
||||||
:native-value="CommentModeration.CLOSED">
|
:native-value="CommentModeration.CLOSED">
|
||||||
{{ $t('Close comments for all (except for admins)') }}
|
{{ $t('Close comments for all (except for admins)') }}
|
||||||
</b-radio>
|
</b-radio>
|
||||||
</div> -->
|
</div>
|
||||||
|
|
||||||
<h2 class="subtitle">
|
<h2 class="subtitle">
|
||||||
{{ $t('Status') }}
|
{{ $t('Status') }}
|
||||||
|
@ -542,13 +540,17 @@ export default class EditEvent extends Vue {
|
||||||
const pictureObj = buildFileVariable(this.pictureFile, 'picture');
|
const pictureObj = buildFileVariable(this.pictureFile, 'picture');
|
||||||
res = Object.assign({}, res, pictureObj);
|
res = Object.assign({}, res, pictureObj);
|
||||||
|
|
||||||
if (this.event.picture) {
|
try {
|
||||||
const oldPictureFile = await buildFileFromIPicture(this.event.picture) as File;
|
if (this.event.picture) {
|
||||||
const oldPictureFileContent = await readFileAsync(oldPictureFile);
|
const oldPictureFile = await buildFileFromIPicture(this.event.picture) as File;
|
||||||
const newPictureFileContent = await readFileAsync(this.pictureFile as File);
|
const oldPictureFileContent = await readFileAsync(oldPictureFile);
|
||||||
if (oldPictureFileContent === newPictureFileContent) {
|
const newPictureFileContent = await readFileAsync(this.pictureFile as File);
|
||||||
res.picture = { pictureId: this.event.picture.id };
|
if (oldPictureFileContent === newPictureFileContent) {
|
||||||
|
res.picture = { pictureId: this.event.picture.id };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<b-loading :active.sync="$apollo.loading"></b-loading>
|
<b-loading :active.sync="$apollo.loading"></b-loading>
|
||||||
<transition appear name="fade" mode="out-in">
|
<transition appear name="fade" mode="out-in">
|
||||||
<div v-if="event">
|
<div>
|
||||||
<div class="header-picture" v-if="event.picture" :style="`background-image: url('${event.picture.url}')`" />
|
<div class="header-picture" v-if="event.picture" :style="`background-image: url('${event.picture.url}')`" />
|
||||||
<div class="header-picture-default" v-else />
|
<div class="header-picture-default" v-else />
|
||||||
<section>
|
<section>
|
||||||
|
@ -160,7 +160,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="description">
|
<div class="description" :class="{ exists: event.description }">
|
||||||
<div class="description-container container">
|
<div class="description-container container">
|
||||||
<h3 class="title">
|
<h3 class="title">
|
||||||
{{ $t('About this event') }}
|
{{ $t('About this event') }}
|
||||||
|
@ -174,6 +174,12 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<section class="comments" ref="commentsObserver">
|
||||||
|
<a href="#comments">
|
||||||
|
<h3 class="title" id="comments">{{ $t('Comments') }}</h3>
|
||||||
|
</a>
|
||||||
|
<comment-tree v-if="loadComments" :event="event" />
|
||||||
|
</section>
|
||||||
<section class="share" v-if="!event.draft">
|
<section class="share" v-if="!event.draft">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="columns is-centered is-multiline">
|
<div class="columns is-centered is-multiline">
|
||||||
|
@ -199,7 +205,7 @@
|
||||||
</div>
|
</div>
|
||||||
<hr />
|
<hr />
|
||||||
<div class="column is-half-widescreen has-text-right add-to-calendar">
|
<div class="column is-half-widescreen has-text-right add-to-calendar">
|
||||||
<img src="../../assets/undraw_events.svg" class="is-hidden-mobile is-hidden-tablet-only" />
|
<img src="../../assets/undraw_events.svg" class="is-hidden-mobile is-hidden-tablet-only" alt="" />
|
||||||
<h3 @click="downloadIcsEvent()">
|
<h3 @click="downloadIcsEvent()">
|
||||||
{{ $t('Add to my calendar') }}
|
{{ $t('Add to my calendar') }}
|
||||||
</h3>
|
</h3>
|
||||||
|
@ -247,7 +253,7 @@
|
||||||
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
|
import { EVENT_PERSON_PARTICIPATION, FETCH_EVENT, JOIN_EVENT, LEAVE_EVENT } from '@/graphql/event';
|
||||||
import { Component, Prop } from 'vue-property-decorator';
|
import { Component, Prop } from 'vue-property-decorator';
|
||||||
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||||
import { EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
|
import { EventModel, EventStatus, EventVisibility, IEvent, IParticipant, ParticipantRole } from '@/types/event.model';
|
||||||
import { IPerson, Person } from '@/types/actor';
|
import { IPerson, Person } from '@/types/actor';
|
||||||
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
|
import { GRAPHQL_API_ENDPOINT } from '@/api/_entrypoint';
|
||||||
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
|
import DateCalendarIcon from '@/components/Event/DateCalendarIcon.vue';
|
||||||
|
@ -264,6 +270,8 @@ import ParticipationButton from '@/components/Event/ParticipationButton.vue';
|
||||||
import { GraphQLError } from 'graphql';
|
import { GraphQLError } from 'graphql';
|
||||||
import { RouteName } from '@/router';
|
import { RouteName } from '@/router';
|
||||||
import { Address } from '@/types/address.model';
|
import { Address } from '@/types/address.model';
|
||||||
|
import CommentTree from '@/components/Comment/CommentTree.vue';
|
||||||
|
import 'intersection-observer';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: {
|
components: {
|
||||||
|
@ -275,6 +283,7 @@ import { Address } from '@/types/address.model';
|
||||||
ReportModal,
|
ReportModal,
|
||||||
IdentityPicker,
|
IdentityPicker,
|
||||||
ParticipationButton,
|
ParticipationButton,
|
||||||
|
CommentTree,
|
||||||
// tslint:disable:space-in-parens
|
// tslint:disable:space-in-parens
|
||||||
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
|
'map-leaflet': () => import(/* webpackChunkName: "map" */ '@/components/Map.vue'),
|
||||||
// tslint:enable
|
// tslint:enable
|
||||||
|
@ -328,7 +337,7 @@ import { Address } from '@/types/address.model';
|
||||||
export default class Event extends EventMixin {
|
export default class Event extends EventMixin {
|
||||||
@Prop({ type: String, required: true }) uuid!: string;
|
@Prop({ type: String, required: true }) uuid!: string;
|
||||||
|
|
||||||
event!: IEvent;
|
event: IEvent = new EventModel();
|
||||||
currentActor!: IPerson;
|
currentActor!: IPerson;
|
||||||
identity: IPerson = new Person();
|
identity: IPerson = new Person();
|
||||||
participations: IParticipant[] = [];
|
participations: IParticipant[] = [];
|
||||||
|
@ -338,6 +347,8 @@ export default class Event extends EventMixin {
|
||||||
EventVisibility = EventVisibility;
|
EventVisibility = EventVisibility;
|
||||||
EventStatus = EventStatus;
|
EventStatus = EventStatus;
|
||||||
RouteName = RouteName;
|
RouteName = RouteName;
|
||||||
|
observer!: IntersectionObserver;
|
||||||
|
loadComments: boolean = false;
|
||||||
|
|
||||||
get eventTitle() {
|
get eventTitle() {
|
||||||
if (!this.event) return undefined;
|
if (!this.event) return undefined;
|
||||||
|
@ -351,10 +362,24 @@ export default class Event extends EventMixin {
|
||||||
|
|
||||||
mounted() {
|
mounted() {
|
||||||
this.identity = this.currentActor;
|
this.identity = this.currentActor;
|
||||||
|
if (this.$route.hash.includes('#comment-')) {
|
||||||
|
this.loadComments = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.observer = new IntersectionObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry) {
|
||||||
|
this.loadComments = entry.isIntersecting || this.loadComments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
rootMargin: '-50px 0px -50px',
|
||||||
|
});
|
||||||
|
this.observer.observe(this.$refs.commentsObserver as Element);
|
||||||
|
|
||||||
this.$watch('eventDescription', function (eventDescription) {
|
this.$watch('eventDescription', function (eventDescription) {
|
||||||
if (!eventDescription) return;
|
if (!eventDescription) return;
|
||||||
const eventDescriptionElement = this.$refs['eventDescriptionElement'] as HTMLElement;
|
const eventDescriptionElement = this.$refs.eventDescriptionElement as HTMLElement;
|
||||||
|
|
||||||
eventDescriptionElement.addEventListener('click', ($event) => {
|
eventDescriptionElement.addEventListener('click', ($event) => {
|
||||||
// TODO: Find the right type for target
|
// TODO: Find the right type for target
|
||||||
|
@ -404,8 +429,8 @@ export default class Event extends EventMixin {
|
||||||
mutation: CREATE_REPORT,
|
mutation: CREATE_REPORT,
|
||||||
variables: {
|
variables: {
|
||||||
eventId: this.event.id,
|
eventId: this.event.id,
|
||||||
reporterActorId: this.currentActor.id,
|
reporterId: this.currentActor.id,
|
||||||
reportedActorId: this.event.organizerActor.id,
|
reportedId: this.event.organizerActor.id,
|
||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -845,19 +870,24 @@ export default class Event extends EventMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
h3.title {
|
h3.title {
|
||||||
font-size: 3rem;
|
font-weight: 300;
|
||||||
font-weight: 300;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.description {
|
.description {
|
||||||
padding-top: 10px;
|
padding: 10px 0;
|
||||||
min-height: 40rem;
|
min-height: 7rem;
|
||||||
|
|
||||||
|
&.exists {
|
||||||
|
min-height: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1216px) {
|
@media screen and (min-width: 1216px) {
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
background-size: 600px;
|
background-size: 600px;
|
||||||
background-position: 95% 101%;
|
background-position: 95% 101%;
|
||||||
background-image: url('../../assets/texting.svg');
|
&.exists {
|
||||||
|
background-image: url('../../assets/texting.svg');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
border-top: solid 1px #111;
|
border-top: solid 1px #111;
|
||||||
border-bottom: solid 1px #111;
|
border-bottom: solid 1px #111;
|
||||||
|
@ -906,8 +936,17 @@ export default class Event extends EventMixin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.comments {
|
||||||
|
margin: 1rem auto 2rem;
|
||||||
|
|
||||||
|
a h3#comments {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.share {
|
.share {
|
||||||
border-bottom: solid 1px #111;
|
border-bottom: solid 1px $primary;
|
||||||
|
border-top: solid 1px $primary;
|
||||||
|
|
||||||
.diaspora span svg {
|
.diaspora span svg {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
|
@ -917,7 +956,7 @@ export default class Event extends EventMixin {
|
||||||
.columns {
|
.columns {
|
||||||
|
|
||||||
& > * {
|
& > * {
|
||||||
padding: 10rem 0;
|
padding: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
|
@ -957,7 +996,7 @@ export default class Event extends EventMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
img {
|
img {
|
||||||
max-width: 400px;
|
max-width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
|
@ -965,7 +1004,6 @@ export default class Event extends EventMixin {
|
||||||
background: #B3B3B2;
|
background: #B3B3B2;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 25%;
|
bottom: 25%;
|
||||||
left: 0;
|
|
||||||
height: 40%;
|
height: 40%;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,68 +4,81 @@
|
||||||
<div class="container" v-if="report">
|
<div class="container" v-if="report">
|
||||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><router-link :to="{ name: RouteName.DASHBOARD }">Dashboard</router-link></li>
|
<li><router-link :to="{ name: RouteName.DASHBOARD }">{{ $t('Dashboard') }}</router-link></li>
|
||||||
<li><router-link :to="{ name: RouteName.REPORTS }">Reports</router-link></li>
|
<li><router-link :to="{ name: RouteName.REPORTS }">{{ $t('Reports') }}</router-link></li>
|
||||||
<li class="is-active"><router-link :to="{ name: RouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">Report</router-link></li>
|
<li class="is-active"><router-link :to="{ name: RouteName.REPORT, params: { reportId: this.report.id} }" aria-current="page">{{ $t('Report') }}</router-link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="buttons">
|
<div class="buttons">
|
||||||
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">Mark as resolved</b-button>
|
<b-button v-if="report.status !== ReportStatusEnum.RESOLVED" @click="updateReport(ReportStatusEnum.RESOLVED)" type="is-primary">{{ $t('Mark as resolved') }}</b-button>
|
||||||
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">Reopen</b-button>
|
<b-button v-if="report.status !== ReportStatusEnum.OPEN" @click="updateReport(ReportStatusEnum.OPEN)" type="is-success">{{ $t('Reopen') }}</b-button>
|
||||||
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">Close</b-button>
|
<b-button v-if="report.status !== ReportStatusEnum.CLOSED" @click="updateReport(ReportStatusEnum.CLOSED)" type="is-danger">{{ $t('Close') }}</b-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="columns">
|
<div class="table-container">
|
||||||
<div class="column">
|
<table class="table is-striped is-fullwidth">
|
||||||
<div class="table-container">
|
<tbody>
|
||||||
<table class="box table is-striped">
|
<tr>
|
||||||
<tbody>
|
<td>{{ $t('Reported identity') }}</td>
|
||||||
<tr>
|
<td>
|
||||||
<td>Compte signalé</td>
|
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
|
||||||
<td>
|
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
|
||||||
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reported.preferredUsername } }">
|
</router-link>
|
||||||
<img v-if="report.reported.avatar" class="image" :src="report.reported.avatar.url" /> @{{ report.reported.preferredUsername }}
|
</td>
|
||||||
</router-link>
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td>{{ $t('Reported by') }}</td>
|
||||||
<tr>
|
<td>
|
||||||
<td>Signalé par</td>
|
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
|
||||||
<td>
|
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
|
||||||
<router-link :to="{ name: RouteName.PROFILE, params: { name: report.reporter.preferredUsername } }">
|
</router-link>
|
||||||
<img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}
|
</td>
|
||||||
</router-link>
|
</tr>
|
||||||
</td>
|
<tr>
|
||||||
</tr>
|
<td>{{ $t('Reported')}}</td>
|
||||||
<tr>
|
<td>{{ report.insertedAt | formatDateTimeString }}</td>
|
||||||
<td>Signalé</td>
|
</tr>
|
||||||
<td>{{ report.insertedAt | formatDateTimeString }}</td>
|
<tr v-if="report.updatedAt !== report.insertedAt">
|
||||||
</tr>
|
<td>{{ $t('Updated') }}</td>
|
||||||
<tr v-if="report.updatedAt !== report.insertedAt">
|
<td>{{ report.updatedAt | formatDateTimeString }}</td>
|
||||||
<td>Mis à jour</td>
|
</tr>
|
||||||
<td>{{ report.updatedAt | formatDateTimeString }}</td>
|
<tr>
|
||||||
</tr>
|
<td>{{ $t('Status') }}</td>
|
||||||
<tr>
|
<td>
|
||||||
<td>Statut</td>
|
<span v-if="report.status === ReportStatusEnum.OPEN">{{ $t('Open') }}</span>
|
||||||
<td>
|
<span v-else-if="report.status === ReportStatusEnum.CLOSED">{{ $t('Closed') }}</span>
|
||||||
<span v-if="report.status === ReportStatusEnum.OPEN">Ouvert</span>
|
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">{{ $t('Resolved') }}</span>
|
||||||
<span v-else-if="report.status === ReportStatusEnum.CLOSED">Fermé</span>
|
<span v-else>{{ $t('Unknown') }}</span>
|
||||||
<span v-else-if="report.status === ReportStatusEnum.RESOLVED">Résolu</span>
|
</td>
|
||||||
<span v-else>Inconnu</span>
|
</tr>
|
||||||
</td>
|
<tr v-if="report.event && report.comments.length > 0">
|
||||||
</tr>
|
<td>{{ $t('Event') }}</td>
|
||||||
</tbody>
|
<td>
|
||||||
</table>
|
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">{{ report.event.title }}</router-link>
|
||||||
</div>
|
<span class="is-pulled-right">
|
||||||
</div>
|
<b-button
|
||||||
|
tag="router-link"
|
||||||
<div class="column">
|
type="is-primary"
|
||||||
<div class="box">
|
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
|
||||||
<p v-if="report.content">{{ report.content }}</p>
|
icon-left="pencil"
|
||||||
<p v-else>Pas de commentaire</p>
|
size="is-small">{{ $t('Edit') }}</b-button>
|
||||||
</div>
|
<b-button
|
||||||
</div>
|
type="is-danger"
|
||||||
|
@click="confirmDelete()"
|
||||||
|
icon-left="delete"
|
||||||
|
size="is-small">{{ $t('Delete') }}</b-button>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box" v-if="report.event">
|
<div class="box report-content">
|
||||||
|
<p v-if="report.content" v-html="nl2br(report.content)"></p>
|
||||||
|
<p v-else>{{ $t('No comment') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="box" v-if="report.event && report.comments.length === 0">
|
||||||
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">
|
<router-link :to="{ name: RouteName.EVENT, params: { uuid: report.event.uuid }}">
|
||||||
<h3 class="title">{{ report.event.title }}</h3>
|
<h3 class="title">{{ report.event.title }}</h3>
|
||||||
<p v-html="report.event.description"></p>
|
<p v-html="report.event.description"></p>
|
||||||
|
@ -75,28 +88,50 @@
|
||||||
type="is-primary"
|
type="is-primary"
|
||||||
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
|
:to="{ name: RouteName.EDIT_EVENT, params: {eventId: report.event.uuid } }"
|
||||||
icon-left="pencil"
|
icon-left="pencil"
|
||||||
size="is-small">Edit</b-button>
|
size="is-small">{{ $t('Edit') }}</b-button>
|
||||||
<b-button
|
<b-button
|
||||||
type="is-danger"
|
type="is-danger"
|
||||||
@click="confirmDelete()"
|
@click="confirmDelete()"
|
||||||
icon-left="delete"
|
icon-left="delete"
|
||||||
size="is-small">Delete</b-button>
|
size="is-small">{{ $t('Delete') }}</b-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 class="title" v-if="report.notes.length > 0">Notes</h2>
|
<ul v-for="comment in report.comments" v-if="report.comments.length > 0">
|
||||||
|
<li>
|
||||||
|
<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>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h2 class="title" v-if="report.notes.length > 0">{{ $t('Notes') }}</h2>
|
||||||
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`">
|
<div class="box note" v-for="note in report.notes" :id="`note-${note.id}`">
|
||||||
<p>{{ note.content }}</p>
|
<p>{{ note.content }}</p>
|
||||||
<router-link :to="{ name: RouteName.PROFILE, params: { name: note.moderator.preferredUsername } }">
|
<router-link :to="{ name: RouteName.PROFILE, params: { name: note.moderator.preferredUsername } }">
|
||||||
<img class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
|
<img alt="" class="image" :src="note.moderator.avatar.url" /> @{{ note.moderator.preferredUsername }}
|
||||||
</router-link><br />
|
</router-link><br />
|
||||||
<small><a :href="`#note-${note.id}`" v-if="note.insertedAt">{{ note.insertedAt | formatDateTimeString }}</a></small>
|
<small><a :href="`#note-${note.id}`" v-if="note.insertedAt">{{ note.insertedAt | formatDateTimeString }}</a></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @submit="addNote()">
|
<form @submit="addNote()">
|
||||||
<b-field label="Nouvelle note">
|
<b-field :label="$t('New note')">
|
||||||
<b-input type="textarea" v-model="noteContent"></b-input>
|
<b-input type="textarea" v-model="noteContent"></b-input>
|
||||||
</b-field>
|
</b-field>
|
||||||
<b-button type="submit" @click="addNote">Ajouter une note</b-button>
|
<b-button type="submit" @click="addNote">{{ $t('Ajouter une note') }}</b-button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
@ -110,6 +145,7 @@ import { CURRENT_ACTOR_CLIENT } from '@/graphql/actor';
|
||||||
import { IPerson } from '@/types/actor';
|
import { IPerson } from '@/types/actor';
|
||||||
import { DELETE_EVENT } from '@/graphql/event';
|
import { DELETE_EVENT } from '@/graphql/event';
|
||||||
import { uniq } from 'lodash';
|
import { uniq } from 'lodash';
|
||||||
|
import { nl2br } from '@/utils/html';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
apollo: {
|
apollo: {
|
||||||
|
@ -137,6 +173,7 @@ export default class Report extends Vue {
|
||||||
|
|
||||||
ReportStatusEnum = ReportStatusEnum;
|
ReportStatusEnum = ReportStatusEnum;
|
||||||
RouteName = RouteName;
|
RouteName = RouteName;
|
||||||
|
nl2br = nl2br;
|
||||||
|
|
||||||
noteContent: string = '';
|
noteContent: string = '';
|
||||||
|
|
||||||
|
@ -175,9 +212,9 @@ export default class Report extends Vue {
|
||||||
|
|
||||||
confirmDelete() {
|
confirmDelete() {
|
||||||
this.$buefy.dialog.confirm({
|
this.$buefy.dialog.confirm({
|
||||||
title: 'Deleting event',
|
title: this.$t('Deleting event') as string,
|
||||||
message: 'Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.',
|
message: this.$t('Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.') as string,
|
||||||
confirmText: 'Delete Event',
|
confirmText: this.$t('Delete Event') as string,
|
||||||
type: 'is-danger',
|
type: 'is-danger',
|
||||||
hasIcon: true,
|
hasIcon: true,
|
||||||
onConfirm: () => this.deleteEvent(),
|
onConfirm: () => this.deleteEvent(),
|
||||||
|
@ -232,6 +269,7 @@ export default class Report extends Vue {
|
||||||
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
|
store.writeQuery({ query: REPORT, variables: { id: this.report.id }, data: { report } });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await this.$router.push({ name: RouteName.REPORTS });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
|
@ -248,7 +286,9 @@ export default class Report extends Vue {
|
||||||
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<style lang="scss">
|
<style lang="scss" scoped>
|
||||||
|
@import "@/variables.scss";
|
||||||
|
|
||||||
.container li {
|
.container li {
|
||||||
margin: 10px auto;
|
margin: 10px auto;
|
||||||
}
|
}
|
||||||
|
@ -262,4 +302,8 @@ export default class Report extends Vue {
|
||||||
.dialog .modal-card-foot {
|
.dialog .modal-card-foot {
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-content {
|
||||||
|
border-left: 4px solid $primary;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
|
@ -2,8 +2,8 @@
|
||||||
<section class="container">
|
<section class="container">
|
||||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
<ul>
|
<ul>
|
||||||
<li><router-link :to="{ name: RouteName.DASHBOARD }">Dashboard</router-link></li>
|
<li><router-link :to="{ name: RouteName.DASHBOARD }">{{ $t('Dashboard') }}</router-link></li>
|
||||||
<li class="is-active"><router-link :to="{ name: RouteName.REPORTS }" aria-current="page">Reports</router-link></li>
|
<li class="is-active"><router-link :to="{ name: RouteName.REPORTS }" aria-current="page">{{ $t('Reports') }}</router-link></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
<b-field>
|
<b-field>
|
||||||
|
|
1051
js/yarn.lock
1051
js/yarn.lock
File diff suppressed because it is too large
Load diff
|
@ -29,8 +29,19 @@ defmodule Mobilizon.Events.Comment do
|
||||||
origin_comment: t
|
origin_comment: t
|
||||||
}
|
}
|
||||||
|
|
||||||
@required_attrs [:text, :actor_id, :url]
|
# When deleting an event we only nihilify everything
|
||||||
@optional_attrs [:event_id, :in_reply_to_comment_id, :origin_comment_id, :attributed_to_id]
|
@required_attrs [:url]
|
||||||
|
@creation_required_attrs @required_attrs ++ [:text, :actor_id]
|
||||||
|
@deletion_required_attrs @required_attrs ++ [:deleted_at]
|
||||||
|
@optional_attrs [
|
||||||
|
:text,
|
||||||
|
:actor_id,
|
||||||
|
:event_id,
|
||||||
|
:in_reply_to_comment_id,
|
||||||
|
:origin_comment_id,
|
||||||
|
:attributed_to_id,
|
||||||
|
:deleted_at
|
||||||
|
]
|
||||||
@attrs @required_attrs ++ @optional_attrs
|
@attrs @required_attrs ++ @optional_attrs
|
||||||
|
|
||||||
schema "comments" do
|
schema "comments" do
|
||||||
|
@ -39,12 +50,15 @@ defmodule Mobilizon.Events.Comment do
|
||||||
field(:local, :boolean, default: true)
|
field(:local, :boolean, default: true)
|
||||||
field(:visibility, CommentVisibility, default: :public)
|
field(:visibility, CommentVisibility, default: :public)
|
||||||
field(:uuid, Ecto.UUID)
|
field(:uuid, Ecto.UUID)
|
||||||
|
field(:total_replies, :integer, virtual: true, default: 0)
|
||||||
|
field(:deleted_at, :utc_datetime)
|
||||||
|
|
||||||
belongs_to(:actor, Actor, foreign_key: :actor_id)
|
belongs_to(:actor, Actor, foreign_key: :actor_id)
|
||||||
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
|
belongs_to(:attributed_to, Actor, foreign_key: :attributed_to_id)
|
||||||
belongs_to(:event, Event, foreign_key: :event_id)
|
belongs_to(:event, Event, foreign_key: :event_id)
|
||||||
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
|
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
|
||||||
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
|
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
|
||||||
|
has_many(:replies, Comment, foreign_key: :in_reply_to_comment_id)
|
||||||
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
|
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
|
||||||
has_many(:mentions, Mention)
|
has_many(:mentions, Mention)
|
||||||
|
|
||||||
|
@ -62,16 +76,56 @@ defmodule Mobilizon.Events.Comment do
|
||||||
@doc false
|
@doc false
|
||||||
@spec changeset(t, map) :: Ecto.Changeset.t()
|
@spec changeset(t, map) :: Ecto.Changeset.t()
|
||||||
def changeset(%__MODULE__{} = comment, attrs) do
|
def changeset(%__MODULE__{} = comment, attrs) do
|
||||||
uuid = Map.get(attrs, :uuid) || Ecto.UUID.generate()
|
comment
|
||||||
url = Map.get(attrs, :url) || generate_url(uuid)
|
|> common_changeset(attrs)
|
||||||
|
|> validate_required(@creation_required_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec delete_changeset(t, map) :: Ecto.Changeset.t()
|
||||||
|
def delete_changeset(%__MODULE__{} = comment, attrs) do
|
||||||
|
comment
|
||||||
|
|> common_changeset(attrs)
|
||||||
|
|> validate_required(@deletion_required_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Checks whether an comment can be managed.
|
||||||
|
"""
|
||||||
|
@spec can_be_managed_by(t, integer | String.t()) :: boolean
|
||||||
|
def can_be_managed_by(%__MODULE__{actor_id: creator_actor_id}, actor_id)
|
||||||
|
when creator_actor_id == actor_id do
|
||||||
|
{:comment_can_be_managed, true}
|
||||||
|
end
|
||||||
|
|
||||||
|
def can_be_managed_by(_comment, _actor), do: {:comment_can_be_managed, false}
|
||||||
|
|
||||||
|
defp common_changeset(%__MODULE__{} = comment, attrs) do
|
||||||
comment
|
comment
|
||||||
|> cast(attrs, @attrs)
|
|> cast(attrs, @attrs)
|
||||||
|> put_change(:uuid, uuid)
|
|> maybe_generate_uuid()
|
||||||
|> put_change(:url, url)
|
|> maybe_generate_url()
|
||||||
|> put_tags(attrs)
|
|> put_tags(attrs)
|
||||||
|> put_mentions(attrs)
|
|> put_mentions(attrs)
|
||||||
|> validate_required(@required_attrs)
|
end
|
||||||
|
|
||||||
|
@spec maybe_generate_uuid(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
||||||
|
defp maybe_generate_uuid(%Ecto.Changeset{} = changeset) do
|
||||||
|
case fetch_field(changeset, :uuid) do
|
||||||
|
:error -> put_change(changeset, :uuid, Ecto.UUID.generate())
|
||||||
|
{:data, nil} -> put_change(changeset, :uuid, Ecto.UUID.generate())
|
||||||
|
_ -> changeset
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
||||||
|
defp maybe_generate_url(%Ecto.Changeset{} = changeset) do
|
||||||
|
with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url),
|
||||||
|
{changes, uuid} when changes in [:changes, :data] <- fetch_field(changeset, :uuid),
|
||||||
|
url <- generate_url(uuid) do
|
||||||
|
put_change(changeset, :url, url)
|
||||||
|
else
|
||||||
|
_ -> changeset
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec generate_url(String.t()) :: String.t()
|
@spec generate_url(String.t()) :: String.t()
|
||||||
|
|
|
@ -14,6 +14,7 @@ defmodule Mobilizon.Events.Event do
|
||||||
alias Mobilizon.Addresses
|
alias Mobilizon.Addresses
|
||||||
|
|
||||||
alias Mobilizon.Events.{
|
alias Mobilizon.Events.{
|
||||||
|
Comment,
|
||||||
EventOptions,
|
EventOptions,
|
||||||
EventStatus,
|
EventStatus,
|
||||||
EventVisibility,
|
EventVisibility,
|
||||||
|
@ -111,6 +112,7 @@ defmodule Mobilizon.Events.Event do
|
||||||
has_many(:tracks, Track)
|
has_many(:tracks, Track)
|
||||||
has_many(:sessions, Session)
|
has_many(:sessions, Session)
|
||||||
has_many(:mentions, Mention)
|
has_many(:mentions, Mention)
|
||||||
|
has_many(:comments, Comment)
|
||||||
many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete)
|
many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete)
|
||||||
many_to_many(:participants, Actor, join_through: Participant)
|
many_to_many(:participants, Actor, join_through: Participant)
|
||||||
|
|
||||||
|
|
|
@ -89,12 +89,21 @@ defmodule Mobilizon.Events do
|
||||||
:sessions,
|
:sessions,
|
||||||
:tracks,
|
:tracks,
|
||||||
:tags,
|
:tags,
|
||||||
|
:comments,
|
||||||
:participants,
|
:participants,
|
||||||
:physical_address,
|
:physical_address,
|
||||||
:picture
|
:picture
|
||||||
]
|
]
|
||||||
|
|
||||||
@comment_preloads [:actor, :attributed_to, :in_reply_to_comment, :tags, :mentions]
|
@comment_preloads [
|
||||||
|
:actor,
|
||||||
|
:attributed_to,
|
||||||
|
:in_reply_to_comment,
|
||||||
|
:origin_comment,
|
||||||
|
:replies,
|
||||||
|
:tags,
|
||||||
|
:mentions
|
||||||
|
]
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single event.
|
Gets a single event.
|
||||||
|
@ -1001,6 +1010,29 @@ defmodule Mobilizon.Events do
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def data() do
|
||||||
|
Dataloader.Ecto.new(Repo, query: &query/2)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Query for comment dataloader
|
||||||
|
|
||||||
|
We only get first comment of thread, and count replies.
|
||||||
|
Read: https://hexdocs.pm/absinthe/ecto.html#dataloader
|
||||||
|
"""
|
||||||
|
def query(Comment, _params) do
|
||||||
|
Comment
|
||||||
|
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|
||||||
|
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|
||||||
|
|> where([_, r], is_nil(r.deleted_at))
|
||||||
|
|> group_by([c], c.id)
|
||||||
|
|> select([c, r], %{c | total_replies: count(r.id)})
|
||||||
|
end
|
||||||
|
|
||||||
|
def query(queryable, _) do
|
||||||
|
queryable
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a single comment.
|
Gets a single comment.
|
||||||
"""
|
"""
|
||||||
|
@ -1015,6 +1047,15 @@ defmodule Mobilizon.Events do
|
||||||
@spec get_comment!(integer | String.t()) :: Comment.t()
|
@spec get_comment!(integer | String.t()) :: Comment.t()
|
||||||
def get_comment!(id), do: Repo.get!(Comment, id)
|
def get_comment!(id), do: Repo.get!(Comment, id)
|
||||||
|
|
||||||
|
def get_comment_with_preload(nil), do: nil
|
||||||
|
|
||||||
|
def get_comment_with_preload(id) do
|
||||||
|
Comment
|
||||||
|
|> where(id: ^id)
|
||||||
|
|> preload_for_comment()
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Gets a comment by its URL.
|
Gets a comment by its URL.
|
||||||
"""
|
"""
|
||||||
|
@ -1071,6 +1112,25 @@ defmodule Mobilizon.Events do
|
||||||
|> Repo.preload(@comment_preloads)
|
|> Repo.preload(@comment_preloads)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_threads(event_id) do
|
||||||
|
Comment
|
||||||
|
|> where([c, _], c.event_id == ^event_id and is_nil(c.origin_comment_id))
|
||||||
|
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|
||||||
|
|> group_by([c], c.id)
|
||||||
|
|> select([c, r], %{c | total_replies: count(r.id)})
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets paginated replies for root comment
|
||||||
|
"""
|
||||||
|
@spec get_thread_replies(integer()) :: [Comment.t()]
|
||||||
|
def get_thread_replies(parent_id) do
|
||||||
|
parent_id
|
||||||
|
|> public_replies_for_thread_query()
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
def get_or_create_comment(%{"url" => url} = attrs) do
|
def get_or_create_comment(%{"url" => url} = attrs) do
|
||||||
case Repo.get_by(Comment, url: url) do
|
case Repo.get_by(Comment, url: url) do
|
||||||
%Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)}
|
%Comment{} = comment -> {:ok, Repo.preload(comment, @comment_preloads)}
|
||||||
|
@ -1103,10 +1163,20 @@ defmodule Mobilizon.Events do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Deletes a comment.
|
Deletes a comment
|
||||||
|
|
||||||
|
But actually just empty the fields so that threads are not broken.
|
||||||
"""
|
"""
|
||||||
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
|
@spec delete_comment(Comment.t()) :: {:ok, Comment.t()} | {:error, Changeset.t()}
|
||||||
def delete_comment(%Comment{} = comment), do: Repo.delete(comment)
|
def delete_comment(%Comment{} = comment) do
|
||||||
|
comment
|
||||||
|
|> Comment.delete_changeset(%{
|
||||||
|
text: nil,
|
||||||
|
actor_id: nil,
|
||||||
|
deleted_at: DateTime.utc_now()
|
||||||
|
})
|
||||||
|
|> Repo.update()
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of public comments.
|
Returns the list of public comments.
|
||||||
|
@ -1119,7 +1189,7 @@ defmodule Mobilizon.Events do
|
||||||
@doc """
|
@doc """
|
||||||
Returns the list of public comments for the actor.
|
Returns the list of public comments for the actor.
|
||||||
"""
|
"""
|
||||||
@spec list_public_events_for_actor(Actor.t(), integer | nil, integer | nil) ::
|
@spec list_public_comments_for_actor(Actor.t(), integer | nil, integer | nil) ::
|
||||||
{:ok, [Comment.t()], integer}
|
{:ok, [Comment.t()], integer}
|
||||||
def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
|
def list_public_comments_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
|
||||||
comments =
|
comments =
|
||||||
|
@ -1480,6 +1550,13 @@ defmodule Mobilizon.Events do
|
||||||
|> preload_for_comment()
|
|> preload_for_comment()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp public_replies_for_thread_query(comment_id) do
|
||||||
|
Comment
|
||||||
|
|> where([c], c.origin_comment_id == ^comment_id and c.visibility in ^@public_visibility)
|
||||||
|
|> group_by([c], [c.in_reply_to_comment_id, c.id])
|
||||||
|
|> preload_for_comment()
|
||||||
|
end
|
||||||
|
|
||||||
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
|
@spec list_participants_for_event_query(String.t()) :: Ecto.Query.t()
|
||||||
defp list_participants_for_event_query(event_id) do
|
defp list_participants_for_event_query(event_id) do
|
||||||
from(
|
from(
|
||||||
|
|
|
@ -14,7 +14,7 @@ defmodule Mobilizon.Reports.Report do
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
content: String.t(),
|
content: String.t(),
|
||||||
status: ReportStatus.t(),
|
status: ReportStatus.t(),
|
||||||
uri: String.t(),
|
url: String.t(),
|
||||||
reported: Actor.t(),
|
reported: Actor.t(),
|
||||||
reporter: Actor.t(),
|
reporter: Actor.t(),
|
||||||
manager: Actor.t(),
|
manager: Actor.t(),
|
||||||
|
@ -23,17 +23,18 @@ defmodule Mobilizon.Reports.Report do
|
||||||
notes: [Note.t()]
|
notes: [Note.t()]
|
||||||
}
|
}
|
||||||
|
|
||||||
@required_attrs [:uri, :reported_id, :reporter_id]
|
@required_attrs [:url, :reported_id, :reporter_id]
|
||||||
@optional_attrs [:content, :status, :manager_id, :event_id]
|
@optional_attrs [:content, :status, :manager_id, :event_id, :local]
|
||||||
@attrs @required_attrs ++ @optional_attrs
|
@attrs @required_attrs ++ @optional_attrs
|
||||||
|
|
||||||
@timestamps_opts [type: :utc_datetime]
|
@timestamps_opts [type: :utc_datetime]
|
||||||
|
|
||||||
@derive {Jason.Encoder, only: [:status, :uri]}
|
@derive {Jason.Encoder, only: [:status, :url]}
|
||||||
schema "reports" do
|
schema "reports" do
|
||||||
field(:content, :string)
|
field(:content, :string)
|
||||||
field(:status, ReportStatus, default: :open)
|
field(:status, ReportStatus, default: :open)
|
||||||
field(:uri, :string)
|
field(:url, :string)
|
||||||
|
field(:local, :boolean, default: true)
|
||||||
|
|
||||||
# The reported actor
|
# The reported actor
|
||||||
belongs_to(:reported, Actor)
|
belongs_to(:reported, Actor)
|
||||||
|
@ -56,14 +57,24 @@ defmodule Mobilizon.Reports.Report do
|
||||||
def changeset(%__MODULE__{} = report, attrs) do
|
def changeset(%__MODULE__{} = report, attrs) do
|
||||||
report
|
report
|
||||||
|> cast(attrs, @attrs)
|
|> cast(attrs, @attrs)
|
||||||
|
|> maybe_generate_url()
|
||||||
|
|> maybe_put_comments(attrs)
|
||||||
|> validate_required(@required_attrs)
|
|> validate_required(@required_attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc false
|
defp maybe_put_comments(%Ecto.Changeset{} = changeset, %{comments: comments}) do
|
||||||
@spec creation_changeset(t, map) :: Ecto.Changeset.t()
|
put_assoc(changeset, :comments, comments)
|
||||||
def creation_changeset(%__MODULE__{} = report, attrs) do
|
end
|
||||||
report
|
|
||||||
|> changeset(attrs)
|
defp maybe_put_comments(%Ecto.Changeset{} = changeset, _), do: changeset
|
||||||
|> put_assoc(:comments, attrs["comments"])
|
|
||||||
|
@spec maybe_generate_url(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
||||||
|
defp maybe_generate_url(%Ecto.Changeset{} = changeset) do
|
||||||
|
with res when res in [:error, {:data, nil}] <- fetch_field(changeset, :url),
|
||||||
|
url <- "#{MobilizonWeb.Endpoint.url()}/report/#{Ecto.UUID.generate()}" do
|
||||||
|
put_change(changeset, :url, url)
|
||||||
|
else
|
||||||
|
_ -> changeset
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
45
lib/mobilizon/tombstone.ex
Normal file
45
lib/mobilizon/tombstone.ex
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
defmodule Mobilizon.Tombstone do
|
||||||
|
@moduledoc """
|
||||||
|
Represent tombstones for deleted objects. Saves only URI
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Schema
|
||||||
|
import Ecto.Changeset
|
||||||
|
alias Mobilizon.Actors.Actor
|
||||||
|
alias Mobilizon.Storage.Repo
|
||||||
|
|
||||||
|
@type t :: %__MODULE__{
|
||||||
|
uri: String.t(),
|
||||||
|
actor: Actor.t()
|
||||||
|
}
|
||||||
|
|
||||||
|
@required_attrs [:uri, :actor_id]
|
||||||
|
@optional_attrs []
|
||||||
|
@attrs @required_attrs ++ @optional_attrs
|
||||||
|
|
||||||
|
schema "tombstones" do
|
||||||
|
field(:uri, :string)
|
||||||
|
belongs_to(:actor, Actor)
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc false
|
||||||
|
def changeset(%__MODULE__{} = tombstone, attrs) do
|
||||||
|
tombstone
|
||||||
|
|> cast(attrs, @attrs)
|
||||||
|
|> validate_required(@attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec create_tombstone(map()) :: {:ok, Ecto.Schema.t()} | {:error, Ecto.Changeset.t()}
|
||||||
|
def create_tombstone(attrs) do
|
||||||
|
%__MODULE__{}
|
||||||
|
|> changeset(attrs)
|
||||||
|
|> Repo.insert(on_conflict: :replace_all, conflict_target: :uri)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec find_tombstone(String.t()) :: Ecto.Schema.t() | nil
|
||||||
|
def find_tombstone(uri) do
|
||||||
|
Repo.get_by(__MODULE__, uri: uri)
|
||||||
|
end
|
||||||
|
end
|
|
@ -9,11 +9,22 @@ defmodule MobilizonWeb.API.Comments do
|
||||||
@doc """
|
@doc """
|
||||||
Create a comment
|
Create a comment
|
||||||
|
|
||||||
Creates a comment from an actor and a status
|
Creates a comment from an actor
|
||||||
"""
|
"""
|
||||||
@spec create_comment(map()) ::
|
@spec create_comment(map()) ::
|
||||||
{:ok, Activity.t(), Comment.t()} | any()
|
{:ok, Activity.t(), Comment.t()} | any()
|
||||||
def create_comment(args) do
|
def create_comment(args) do
|
||||||
ActivityPub.create(:comment, args, true)
|
ActivityPub.create(:comment, args, true)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes a comment
|
||||||
|
|
||||||
|
Deletes a comment from an actor
|
||||||
|
"""
|
||||||
|
@spec delete_comment(Comment.t()) ::
|
||||||
|
{:ok, Activity.t(), Comment.t()} | any()
|
||||||
|
def delete_comment(%Comment{} = comment) do
|
||||||
|
ActivityPub.delete(comment, true)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -5,11 +5,7 @@ defmodule MobilizonWeb.API.Reports do
|
||||||
|
|
||||||
import Mobilizon.Service.Admin.ActionLogService
|
import Mobilizon.Service.Admin.ActionLogService
|
||||||
|
|
||||||
import MobilizonWeb.API.Utils
|
|
||||||
|
|
||||||
alias Mobilizon.Actors
|
|
||||||
alias Mobilizon.Actors.Actor
|
alias Mobilizon.Actors.Actor
|
||||||
alias Mobilizon.Events
|
|
||||||
alias Mobilizon.Reports, as: ReportsAction
|
alias Mobilizon.Reports, as: ReportsAction
|
||||||
alias Mobilizon.Reports.{Note, Report, ReportStatus}
|
alias Mobilizon.Reports.{Note, Report, ReportStatus}
|
||||||
alias Mobilizon.Service.ActivityPub
|
alias Mobilizon.Service.ActivityPub
|
||||||
|
@ -20,44 +16,16 @@ defmodule MobilizonWeb.API.Reports do
|
||||||
@doc """
|
@doc """
|
||||||
Create a report/flag on an actor, and optionally on an event or on comments.
|
Create a report/flag on an actor, and optionally on an event or on comments.
|
||||||
"""
|
"""
|
||||||
def report(
|
def report(args) do
|
||||||
%{
|
case {:make_activity, ActivityPub.flag(args, Map.get(args, :local, false) == false)} do
|
||||||
reporter_actor_id: reporter_actor_id,
|
{:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} ->
|
||||||
reported_actor_id: reported_actor_id
|
{:ok, activity, report}
|
||||||
} = args
|
|
||||||
) do
|
{:make_activity, err} ->
|
||||||
with {:reporter, %Actor{url: reporter_url} = _reporter_actor} <-
|
{:error, err}
|
||||||
{:reporter, Actors.get_actor!(reporter_actor_id)},
|
|
||||||
{:reported, %Actor{url: reported_actor_url} = reported_actor} <-
|
|
||||||
{:reported, Actors.get_actor!(reported_actor_id)},
|
|
||||||
{:ok, content} <- args |> Map.get(:content, nil) |> make_report_content_text(),
|
|
||||||
{:ok, event} <- args |> Map.get(:event_id, nil) |> get_event(),
|
|
||||||
{:get_report_comments, comments_urls} <-
|
|
||||||
get_report_comments(reported_actor, Map.get(args, :comments_ids, [])),
|
|
||||||
{:make_activity, {:ok, %Activity{} = activity, %Report{} = report}} <-
|
|
||||||
{:make_activity,
|
|
||||||
ActivityPub.flag(%{
|
|
||||||
reporter_url: reporter_url,
|
|
||||||
reported_actor_url: reported_actor_url,
|
|
||||||
event_url: (!is_nil(event) && event.url) || nil,
|
|
||||||
comments_url: comments_urls,
|
|
||||||
content: content,
|
|
||||||
forward: args[:forward] || false,
|
|
||||||
local: args[:local] || args[:forward] || false
|
|
||||||
})} do
|
|
||||||
{:ok, activity, report}
|
|
||||||
else
|
|
||||||
{:make_activity, err} -> {:error, err}
|
|
||||||
{:error, err} -> {:error, err}
|
|
||||||
{:actor_id, %{}} -> {:error, "Valid `actor_id` required"}
|
|
||||||
{:reporter, nil} -> {:error, "Reporter Actor not found"}
|
|
||||||
{:reported, nil} -> {:error, "Reported Actor not found"}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_event(nil), do: {:ok, nil}
|
|
||||||
defp get_event(event_id), do: Events.get_event(event_id)
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Update the state of a report
|
Update the state of a report
|
||||||
"""
|
"""
|
||||||
|
@ -72,13 +40,6 @@ defmodule MobilizonWeb.API.Reports do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp get_report_comments(%Actor{id: actor_id}, comment_ids) do
|
|
||||||
{:get_report_comments,
|
|
||||||
actor_id |> Events.list_comments_by_actor_and_ids(comment_ids) |> Enum.map(& &1.url)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp get_report_comments(_, _), do: {:get_report_comments, nil}
|
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Create a note on a report
|
Create a note on a report
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -7,7 +7,7 @@ defmodule MobilizonWeb.Resolvers.Admin do
|
||||||
|
|
||||||
alias Mobilizon.Admin.ActionLog
|
alias Mobilizon.Admin.ActionLog
|
||||||
alias Mobilizon.Events
|
alias Mobilizon.Events
|
||||||
alias Mobilizon.Events.Event
|
alias Mobilizon.Events.{Event, Comment}
|
||||||
alias Mobilizon.Reports.{Note, Report}
|
alias Mobilizon.Reports.{Note, Report}
|
||||||
alias Mobilizon.Service.Statistics
|
alias Mobilizon.Service.Statistics
|
||||||
alias Mobilizon.Users.User
|
alias Mobilizon.Users.User
|
||||||
|
@ -90,6 +90,15 @@ defmodule MobilizonWeb.Resolvers.Admin do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp transform_action_log(Comment, :delete, %ActionLog{
|
||||||
|
changes: changes
|
||||||
|
}) do
|
||||||
|
%{
|
||||||
|
action: :comment_deletion,
|
||||||
|
object: convert_changes_to_struct(Comment, changes)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
# Changes are stored as %{"key" => "value"} so we need to convert them back as struct
|
# Changes are stored as %{"key" => "value"} so we need to convert them back as struct
|
||||||
defp convert_changes_to_struct(struct, %{"report_id" => _report_id} = changes) do
|
defp convert_changes_to_struct(struct, %{"report_id" => _report_id} = changes) do
|
||||||
with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}),
|
with data <- for({key, val} <- changes, into: %{}, do: {String.to_atom(key), val}),
|
||||||
|
|
|
@ -3,25 +3,73 @@ defmodule MobilizonWeb.Resolvers.Comment do
|
||||||
Handles the comment-related GraphQL calls.
|
Handles the comment-related GraphQL calls.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Mobilizon.Events.Comment
|
alias Mobilizon.Events
|
||||||
|
alias Mobilizon.Events.Comment, as: CommentModel
|
||||||
alias Mobilizon.Users.User
|
alias Mobilizon.Users.User
|
||||||
alias Mobilizon.Actors.Actor
|
alias Mobilizon.Actors.Actor
|
||||||
|
alias Mobilizon.Actors
|
||||||
|
|
||||||
alias MobilizonWeb.API.Comments
|
alias MobilizonWeb.API.Comments
|
||||||
|
import Mobilizon.Service.Admin.ActionLogService
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
def create_comment(_parent, %{text: text, actor_id: actor_id}, %{
|
def get_thread(_parent, %{id: thread_id}, _context) do
|
||||||
|
{:ok, Events.get_thread_replies(thread_id)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_comment(_parent, %{actor_id: actor_id} = args, %{
|
||||||
context: %{current_user: %User{} = user}
|
context: %{current_user: %User{} = user}
|
||||||
}) do
|
}) do
|
||||||
with {:is_owned, %Actor{} = _organizer_actor} <- User.owns_actor(user, actor_id),
|
with {:is_owned, %Actor{} = _organizer_actor} <- User.owns_actor(user, actor_id),
|
||||||
{:ok, _, %Comment{} = comment} <-
|
{:ok, _, %CommentModel{} = comment} <-
|
||||||
Comments.create_comment(%{actor_id: actor_id, text: text}) do
|
Comments.create_comment(args) do
|
||||||
{:ok, comment}
|
{:ok, comment}
|
||||||
|
else
|
||||||
|
{:is_owned, nil} ->
|
||||||
|
{:error, "Actor id is not owned by authenticated user"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_comment(_parent, _args, %{}) do
|
def create_comment(_parent, _args, _context) do
|
||||||
{:error, "You are not allowed to create a comment if not connected"}
|
{:error, "You are not allowed to create a comment if not connected"}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_comment(_parent, %{actor_id: actor_id, comment_id: comment_id}, %{
|
||||||
|
context: %{current_user: %User{role: role} = user}
|
||||||
|
}) do
|
||||||
|
with {actor_id, ""} <- Integer.parse(actor_id),
|
||||||
|
{:is_owned, %Actor{} = _organizer_actor} <- User.owns_actor(user, actor_id),
|
||||||
|
%CommentModel{} = comment <- Events.get_comment_with_preload(comment_id) do
|
||||||
|
cond do
|
||||||
|
{:comment_can_be_managed, true} == CommentModel.can_be_managed_by(comment, actor_id) ->
|
||||||
|
do_delete_comment(comment)
|
||||||
|
|
||||||
|
role in [:moderator, :administrator] ->
|
||||||
|
with {:ok, res} <- do_delete_comment(comment),
|
||||||
|
%Actor{} = actor <- Actors.get_actor(actor_id) do
|
||||||
|
log_action(actor, "delete", comment)
|
||||||
|
|
||||||
|
{:ok, res}
|
||||||
|
end
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:error, "You cannot delete this comment"}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
{:is_owned, nil} ->
|
||||||
|
{:error, "Actor id is not owned by authenticated user"}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def delete_comment(_parent, _args, %{}) do
|
||||||
|
{:error, "You are not allowed to delete a comment if not connected"}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_delete_comment(%CommentModel{} = comment) do
|
||||||
|
with {:ok, _, %CommentModel{} = comment} <-
|
||||||
|
Comments.delete_comment(comment) do
|
||||||
|
{:ok, comment}
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -46,10 +46,10 @@ defmodule MobilizonWeb.Resolvers.Report do
|
||||||
"""
|
"""
|
||||||
def create_report(
|
def create_report(
|
||||||
_parent,
|
_parent,
|
||||||
%{reporter_actor_id: reporter_actor_id} = args,
|
%{reporter_id: reporter_id} = args,
|
||||||
%{context: %{current_user: user}} = _resolution
|
%{context: %{current_user: user}} = _resolution
|
||||||
) do
|
) do
|
||||||
with {:is_owned, %Actor{}} <- User.owns_actor(user, reporter_actor_id),
|
with {:is_owned, %Actor{}} <- User.owns_actor(user, reporter_id),
|
||||||
{:ok, _, %Report{} = report} <- ReportsAPI.report(args) do
|
{:ok, _, %Report{} = report} <- ReportsAPI.report(args) do
|
||||||
{:ok, report}
|
{:ok, report}
|
||||||
else
|
else
|
||||||
|
|
|
@ -49,7 +49,11 @@ defmodule MobilizonWeb.Router do
|
||||||
scope "/api" do
|
scope "/api" do
|
||||||
pipe_through(:graphql)
|
pipe_through(:graphql)
|
||||||
|
|
||||||
forward("/", Absinthe.Plug, schema: MobilizonWeb.Schema)
|
forward("/", Absinthe.Plug,
|
||||||
|
schema: MobilizonWeb.Schema,
|
||||||
|
analyze_complexity: true,
|
||||||
|
max_complexity: 200
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
forward("/graphiql", Absinthe.Plug.GraphiQL, schema: MobilizonWeb.Schema)
|
forward("/graphiql", Absinthe.Plug.GraphiQL, schema: MobilizonWeb.Schema)
|
||||||
|
|
|
@ -96,7 +96,7 @@ defmodule MobilizonWeb.Schema do
|
||||||
Dataloader.new()
|
Dataloader.new()
|
||||||
|> Dataloader.add_source(Actors, default_source)
|
|> Dataloader.add_source(Actors, default_source)
|
||||||
|> Dataloader.add_source(Users, default_source)
|
|> Dataloader.add_source(Users, default_source)
|
||||||
|> Dataloader.add_source(Events, default_source)
|
|> Dataloader.add_source(Events, Events.data())
|
||||||
|> Dataloader.add_source(Addresses, default_source)
|
|> Dataloader.add_source(Addresses, default_source)
|
||||||
|> Dataloader.add_source(Media, default_source)
|
|> Dataloader.add_source(Media, default_source)
|
||||||
|> Dataloader.add_source(Reports, default_source)
|
|> Dataloader.add_source(Reports, default_source)
|
||||||
|
@ -117,6 +117,7 @@ defmodule MobilizonWeb.Schema do
|
||||||
import_fields(:person_queries)
|
import_fields(:person_queries)
|
||||||
import_fields(:group_queries)
|
import_fields(:group_queries)
|
||||||
import_fields(:event_queries)
|
import_fields(:event_queries)
|
||||||
|
import_fields(:comment_queries)
|
||||||
import_fields(:tag_queries)
|
import_fields(:tag_queries)
|
||||||
import_fields(:address_queries)
|
import_fields(:address_queries)
|
||||||
import_fields(:config_queries)
|
import_fields(:config_queries)
|
||||||
|
|
|
@ -5,7 +5,7 @@ defmodule MobilizonWeb.Schema.AdminType do
|
||||||
|
|
||||||
use Absinthe.Schema.Notation
|
use Absinthe.Schema.Notation
|
||||||
|
|
||||||
alias Mobilizon.Events.Event
|
alias Mobilizon.Events.{Event, Comment}
|
||||||
alias Mobilizon.Reports.{Note, Report}
|
alias Mobilizon.Reports.{Note, Report}
|
||||||
|
|
||||||
alias MobilizonWeb.Resolvers.Admin
|
alias MobilizonWeb.Resolvers.Admin
|
||||||
|
@ -26,6 +26,7 @@ defmodule MobilizonWeb.Schema.AdminType do
|
||||||
value(:note_creation)
|
value(:note_creation)
|
||||||
value(:note_deletion)
|
value(:note_deletion)
|
||||||
value(:event_deletion)
|
value(:event_deletion)
|
||||||
|
value(:comment_deletion)
|
||||||
value(:event_update)
|
value(:event_update)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -43,6 +44,9 @@ defmodule MobilizonWeb.Schema.AdminType do
|
||||||
%Event{}, _ ->
|
%Event{}, _ ->
|
||||||
:event
|
:event
|
||||||
|
|
||||||
|
%Comment{}, _ ->
|
||||||
|
:comment
|
||||||
|
|
||||||
_, _ ->
|
_, _ ->
|
||||||
nil
|
nil
|
||||||
end)
|
end)
|
||||||
|
|
|
@ -4,9 +4,12 @@ defmodule MobilizonWeb.Schema.CommentType do
|
||||||
"""
|
"""
|
||||||
use Absinthe.Schema.Notation
|
use Absinthe.Schema.Notation
|
||||||
alias MobilizonWeb.Resolvers.Comment
|
alias MobilizonWeb.Resolvers.Comment
|
||||||
|
alias Mobilizon.{Actors, Events}
|
||||||
|
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
||||||
|
|
||||||
@desc "A comment"
|
@desc "A comment"
|
||||||
object :comment do
|
object :comment do
|
||||||
|
interfaces([:action_log_object])
|
||||||
field(:id, :id, description: "Internal ID for this comment")
|
field(:id, :id, description: "Internal ID for this comment")
|
||||||
field(:uuid, :uuid)
|
field(:uuid, :uuid)
|
||||||
field(:url, :string)
|
field(:url, :string)
|
||||||
|
@ -14,8 +17,20 @@ defmodule MobilizonWeb.Schema.CommentType do
|
||||||
field(:visibility, :comment_visibility)
|
field(:visibility, :comment_visibility)
|
||||||
field(:text, :string)
|
field(:text, :string)
|
||||||
field(:primaryLanguage, :string)
|
field(:primaryLanguage, :string)
|
||||||
field(:replies, list_of(:comment))
|
|
||||||
|
field(:replies, list_of(:comment)) do
|
||||||
|
resolve(dataloader(Events))
|
||||||
|
end
|
||||||
|
|
||||||
|
field(:total_replies, :integer)
|
||||||
|
field(:in_reply_to_comment, :comment, resolve: dataloader(Events))
|
||||||
|
field(:event, :event, resolve: dataloader(Events))
|
||||||
|
field(:origin_comment, :comment, resolve: dataloader(Events))
|
||||||
field(:threadLanguages, non_null(list_of(:string)))
|
field(:threadLanguages, non_null(list_of(:string)))
|
||||||
|
field(:actor, :person, resolve: dataloader(Actors))
|
||||||
|
field(:inserted_at, :datetime)
|
||||||
|
field(:updated_at, :datetime)
|
||||||
|
field(:deleted_at, :datetime)
|
||||||
end
|
end
|
||||||
|
|
||||||
@desc "The list of visibility options for a comment"
|
@desc "The list of visibility options for a comment"
|
||||||
|
@ -31,13 +46,30 @@ defmodule MobilizonWeb.Schema.CommentType do
|
||||||
value(:invite, description: "visible only to people invited")
|
value(:invite, description: "visible only to people invited")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
object :comment_queries do
|
||||||
|
@desc "Get replies for thread"
|
||||||
|
field :thread, type: list_of(:comment) do
|
||||||
|
arg(:id, :id)
|
||||||
|
resolve(&Comment.get_thread/3)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
object :comment_mutations do
|
object :comment_mutations do
|
||||||
@desc "Create a comment"
|
@desc "Create a comment"
|
||||||
field :create_comment, type: :comment do
|
field :create_comment, type: :comment do
|
||||||
arg(:text, non_null(:string))
|
arg(:text, non_null(:string))
|
||||||
|
arg(:event_id, :id)
|
||||||
|
arg(:in_reply_to_comment_id, :id)
|
||||||
arg(:actor_id, non_null(:id))
|
arg(:actor_id, non_null(:id))
|
||||||
|
|
||||||
resolve(&Comment.create_comment/3)
|
resolve(&Comment.create_comment/3)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
field :delete_comment, type: :comment do
|
||||||
|
arg(:comment_id, non_null(:id))
|
||||||
|
arg(:actor_id, non_null(:id))
|
||||||
|
|
||||||
|
resolve(&Comment.delete_comment/3)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,7 +8,7 @@ defmodule MobilizonWeb.Schema.EventType do
|
||||||
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
||||||
import MobilizonWeb.Schema.Utils
|
import MobilizonWeb.Schema.Utils
|
||||||
|
|
||||||
alias Mobilizon.{Actors, Addresses}
|
alias Mobilizon.{Actors, Addresses, Events}
|
||||||
|
|
||||||
alias MobilizonWeb.Resolvers.{Event, Picture, Tag}
|
alias MobilizonWeb.Resolvers.{Event, Picture, Tag}
|
||||||
|
|
||||||
|
@ -78,6 +78,10 @@ defmodule MobilizonWeb.Schema.EventType do
|
||||||
description: "Events related to this one"
|
description: "Events related to this one"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
field(:comments, list_of(:comment), description: "The comments in reply to the event") do
|
||||||
|
resolve(dataloader(Events))
|
||||||
|
end
|
||||||
|
|
||||||
# field(:tracks, list_of(:track))
|
# field(:tracks, list_of(:track))
|
||||||
# field(:sessions, list_of(:session))
|
# field(:sessions, list_of(:session))
|
||||||
|
|
||||||
|
|
|
@ -71,8 +71,8 @@ defmodule MobilizonWeb.Schema.ReportType do
|
||||||
@desc "Create a report"
|
@desc "Create a report"
|
||||||
field :create_report, type: :report do
|
field :create_report, type: :report do
|
||||||
arg(:content, :string)
|
arg(:content, :string)
|
||||||
arg(:reporter_actor_id, non_null(:id))
|
arg(:reporter_id, non_null(:id))
|
||||||
arg(:reported_actor_id, non_null(:id))
|
arg(:reported_id, non_null(:id))
|
||||||
arg(:event_id, :id, default_value: nil)
|
arg(:event_id, :id, default_value: nil)
|
||||||
arg(:comments_ids, list_of(:id), default_value: [])
|
arg(:comments_ids, list_of(:id), default_value: [])
|
||||||
resolve(&Report.create_report/3)
|
resolve(&Report.create_report/3)
|
||||||
|
|
|
@ -11,15 +11,18 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
import Mobilizon.Service.ActivityPub.Utils
|
import Mobilizon.Service.ActivityPub.Utils
|
||||||
import Mobilizon.Service.ActivityPub.Visibility
|
import Mobilizon.Service.ActivityPub.Visibility
|
||||||
|
|
||||||
alias Mobilizon.{Actors, Config, Events}
|
alias Mobilizon.{Actors, Config, Events, Reports, Users}
|
||||||
alias Mobilizon.Actors.{Actor, Follower}
|
alias Mobilizon.Actors.{Actor, Follower}
|
||||||
alias Mobilizon.Events.{Comment, Event, Participant}
|
alias Mobilizon.Events.{Comment, Event, Participant}
|
||||||
|
alias Mobilizon.Reports.Report
|
||||||
|
alias Mobilizon.Tombstone
|
||||||
alias Mobilizon.Service.ActivityPub.{Activity, Converter, Convertible, Relay, Transmogrifier}
|
alias Mobilizon.Service.ActivityPub.{Activity, Converter, Convertible, Relay, Transmogrifier}
|
||||||
alias Mobilizon.Service.{Federator, WebFinger}
|
alias Mobilizon.Service.{Federator, WebFinger}
|
||||||
alias Mobilizon.Service.HTTPSignatures.Signature
|
alias Mobilizon.Service.HTTPSignatures.Signature
|
||||||
alias MobilizonWeb.API.Utils, as: APIUtils
|
alias MobilizonWeb.API.Utils, as: APIUtils
|
||||||
alias Mobilizon.Service.ActivityPub.Audience
|
alias Mobilizon.Service.ActivityPub.Audience
|
||||||
alias Mobilizon.Service.ActivityPub.Converter.Utils, as: ConverterUtils
|
alias Mobilizon.Service.ActivityPub.Converter.Utils, as: ConverterUtils
|
||||||
|
alias MobilizonWeb.Email.{Admin, Mailer}
|
||||||
|
|
||||||
require Logger
|
require Logger
|
||||||
|
|
||||||
|
@ -133,7 +136,8 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
Logger.debug("creating an activity")
|
Logger.debug("creating an activity")
|
||||||
Logger.debug(inspect(args))
|
Logger.debug(inspect(args))
|
||||||
|
|
||||||
with {:ok, entity, create_data} <-
|
with {:tombstone, nil} <- {:tombstone, check_for_tombstones(args)},
|
||||||
|
{:ok, entity, create_data} <-
|
||||||
(case type do
|
(case type do
|
||||||
:event -> create_event(args, additional)
|
:event -> create_event(args, additional)
|
||||||
:comment -> create_comment(args, additional)
|
:comment -> create_comment(args, additional)
|
||||||
|
@ -345,6 +349,8 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
}
|
}
|
||||||
|
|
||||||
with {:ok, %Event{} = event} <- Events.delete_event(event),
|
with {:ok, %Event{} = event} <- Events.delete_event(event),
|
||||||
|
{:ok, %Tombstone{} = _tombstone} <-
|
||||||
|
Tombstone.create_tombstone(%{uri: event.url, actor_id: actor.id}),
|
||||||
{:ok, activity} <- create_activity(data, local),
|
{:ok, activity} <- create_activity(data, local),
|
||||||
:ok <- maybe_federate(activity) do
|
:ok <- maybe_federate(activity) do
|
||||||
{:ok, activity, event}
|
{:ok, activity, event}
|
||||||
|
@ -361,6 +367,8 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
}
|
}
|
||||||
|
|
||||||
with {:ok, %Comment{} = comment} <- Events.delete_comment(comment),
|
with {:ok, %Comment{} = comment} <- Events.delete_comment(comment),
|
||||||
|
{:ok, %Tombstone{} = _tombstone} <-
|
||||||
|
Tombstone.create_tombstone(%{uri: comment.url, actor_id: actor.id}),
|
||||||
{:ok, activity} <- create_activity(data, local),
|
{:ok, activity} <- create_activity(data, local),
|
||||||
:ok <- maybe_federate(activity) do
|
:ok <- maybe_federate(activity) do
|
||||||
{:ok, activity, comment}
|
{:ok, activity, comment}
|
||||||
|
@ -383,25 +391,25 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def flag(params) do
|
def flag(args, local \\ false, _additional \\ %{}) do
|
||||||
# only accept false as false value
|
with {:build_args, args} <- {:build_args, prepare_args_for_report(args)},
|
||||||
local = !(params[:local] == false)
|
{:create_report, {:ok, %Report{} = report}} <-
|
||||||
forward = !(params[:forward] == false)
|
{:create_report, Reports.create_report(args)},
|
||||||
|
report_as_data <- Convertible.model_to_as(report),
|
||||||
additional = params[:additional] || %{}
|
{:ok, activity} <- create_activity(report_as_data, local),
|
||||||
|
|
||||||
additional =
|
|
||||||
if forward do
|
|
||||||
Map.merge(additional, %{"to" => [], "cc" => [params.reported_actor_url]})
|
|
||||||
else
|
|
||||||
Map.merge(additional, %{"to" => [], "cc" => []})
|
|
||||||
end
|
|
||||||
|
|
||||||
with flag_data <- make_flag_data(params, additional),
|
|
||||||
{:ok, activity} <- create_activity(flag_data, local),
|
|
||||||
{:ok, object} <- insert_full_object(flag_data),
|
|
||||||
:ok <- maybe_federate(activity) do
|
:ok <- maybe_federate(activity) do
|
||||||
{:ok, activity, object}
|
Enum.each(Users.list_moderators(), fn moderator ->
|
||||||
|
moderator
|
||||||
|
|> Admin.report(report)
|
||||||
|
|> Mailer.deliver_later()
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, activity, report}
|
||||||
|
else
|
||||||
|
err ->
|
||||||
|
Logger.error("Something went wrong while creating an activity")
|
||||||
|
Logger.debug(inspect(err))
|
||||||
|
err
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -776,6 +784,10 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec check_for_tombstones(map()) :: Tombstone.t() | nil
|
||||||
|
defp check_for_tombstones(%{url: url}), do: Tombstone.find_tombstone(url)
|
||||||
|
defp check_for_tombstones(_), do: nil
|
||||||
|
|
||||||
@spec update_event(Event.t(), map(), map()) ::
|
@spec update_event(Event.t(), map(), map()) ::
|
||||||
{:ok, Event.t(), Activity.t()} | any()
|
{:ok, Event.t(), Activity.t()} | any()
|
||||||
defp update_event(
|
defp update_event(
|
||||||
|
@ -930,7 +942,12 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
tags: tags,
|
tags: tags,
|
||||||
in_reply_to_comment: in_reply_to_comment,
|
in_reply_to_comment: in_reply_to_comment,
|
||||||
in_reply_to_comment_id:
|
in_reply_to_comment_id:
|
||||||
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id))
|
if(is_nil(in_reply_to_comment), do: nil, else: Map.get(in_reply_to_comment, :id)),
|
||||||
|
origin_comment_id:
|
||||||
|
if(is_nil(in_reply_to_comment),
|
||||||
|
do: nil,
|
||||||
|
else: Comment.get_thread_id(in_reply_to_comment)
|
||||||
|
)
|
||||||
}) do
|
}) do
|
||||||
args
|
args
|
||||||
end
|
end
|
||||||
|
@ -945,4 +962,27 @@ defmodule Mobilizon.Service.ActivityPub do
|
||||||
%{args | preferred_username: preferred_username, summary: summary}
|
%{args | preferred_username: preferred_username, summary: summary}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp prepare_args_for_report(args) do
|
||||||
|
with {:reporter, %Actor{} = reporter_actor} <-
|
||||||
|
{:reporter, Actors.get_actor!(args.reporter_id)},
|
||||||
|
{:reported, %Actor{} = reported_actor} <-
|
||||||
|
{:reported, Actors.get_actor!(args.reported_id)},
|
||||||
|
content <- HtmlSanitizeEx.strip_tags(args.content),
|
||||||
|
event <- Events.get_comment(Map.get(args, :event_id)),
|
||||||
|
{:get_report_comments, comments} <-
|
||||||
|
{:get_report_comments,
|
||||||
|
Events.list_comments_by_actor_and_ids(
|
||||||
|
reported_actor.id,
|
||||||
|
Map.get(args, :comments_ids, [])
|
||||||
|
)} do
|
||||||
|
Map.merge(args, %{
|
||||||
|
reporter: reporter_actor,
|
||||||
|
reported: reported_actor,
|
||||||
|
content: content,
|
||||||
|
event: event,
|
||||||
|
comments: comments
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -73,7 +73,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
|
||||||
|
|
||||||
# Anything else is kind of a MP
|
# Anything else is kind of a MP
|
||||||
{:error, parent} ->
|
{:error, parent} ->
|
||||||
Logger.debug("Parent object is something we don't handle")
|
Logger.warn("Parent object is something we don't handle")
|
||||||
Logger.debug(inspect(parent))
|
Logger.debug(inspect(parent))
|
||||||
data
|
data
|
||||||
end
|
end
|
||||||
|
@ -95,7 +95,7 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
|
||||||
"""
|
"""
|
||||||
@impl Converter
|
@impl Converter
|
||||||
@spec model_to_as(CommentModel.t()) :: map
|
@spec model_to_as(CommentModel.t()) :: map
|
||||||
def model_to_as(%CommentModel{} = comment) do
|
def model_to_as(%CommentModel{deleted_at: nil} = comment) do
|
||||||
to =
|
to =
|
||||||
if comment.visibility == :public,
|
if comment.visibility == :public,
|
||||||
do: ["https://www.w3.org/ns/activitystreams#Public"],
|
do: ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
@ -120,4 +120,17 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Comment do
|
||||||
object
|
object
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl Converter
|
||||||
|
@spec model_to_as(CommentModel.t()) :: map
|
||||||
|
def model_to_as(%CommentModel{} = comment) do
|
||||||
|
%{
|
||||||
|
"type" => "Tombstone",
|
||||||
|
"uuid" => comment.uuid,
|
||||||
|
"id" => comment.url,
|
||||||
|
"published" => comment.inserted_at,
|
||||||
|
"updated" => comment.updated_at,
|
||||||
|
"deleted" => comment.deleted_at
|
||||||
|
}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -14,9 +14,16 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
|
||||||
alias Mobilizon.Events.Event
|
alias Mobilizon.Events.Event
|
||||||
alias Mobilizon.Reports.Report
|
alias Mobilizon.Reports.Report
|
||||||
alias Mobilizon.Service.ActivityPub.Converter
|
alias Mobilizon.Service.ActivityPub.Converter
|
||||||
|
alias Mobilizon.Service.ActivityPub.Convertible
|
||||||
|
|
||||||
@behaviour Converter
|
@behaviour Converter
|
||||||
|
|
||||||
|
defimpl Convertible, for: Report do
|
||||||
|
alias Mobilizon.Service.ActivityPub.Converter.Flag, as: FlagConverter
|
||||||
|
|
||||||
|
defdelegate model_to_as(report), to: FlagConverter
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Converts an AP object data to our internal data structure.
|
Converts an AP object data to our internal data structure.
|
||||||
"""
|
"""
|
||||||
|
@ -35,18 +42,29 @@ defmodule Mobilizon.Service.ActivityPub.Converter.Flag do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@audience %{"to" => ["https://www.w3.org/ns/activitystreams#Public"], "cc" => []}
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Convert an event struct to an ActivityStream representation
|
Convert an event struct to an ActivityStream representation
|
||||||
"""
|
"""
|
||||||
@impl Converter
|
@impl Converter
|
||||||
@spec model_to_as(EventModel.t()) :: map
|
@spec model_to_as(Report.t()) :: map
|
||||||
def model_to_as(%Report{} = report) do
|
def model_to_as(%Report{} = report) do
|
||||||
|
object = [report.reported.url] ++ Enum.map(report.comments, fn comment -> comment.url end)
|
||||||
|
|
||||||
|
object = if report.event, do: object ++ [report.event.url], else: object
|
||||||
|
|
||||||
|
audience =
|
||||||
|
if report.local, do: @audience, else: Map.put(@audience, "cc", [report.reported.url])
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"type" => "Flag",
|
"type" => "Flag",
|
||||||
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
|
|
||||||
"actor" => report.reporter.url,
|
"actor" => report.reporter.url,
|
||||||
"id" => report.url
|
"id" => report.url,
|
||||||
|
"content" => report.content,
|
||||||
|
"object" => object
|
||||||
}
|
}
|
||||||
|
|> Map.merge(audience)
|
||||||
end
|
end
|
||||||
|
|
||||||
@spec as_to_model(map) :: map
|
@spec as_to_model(map) :: map
|
||||||
|
|
|
@ -49,7 +49,8 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
|
||||||
object
|
object
|
||||||
|> Map.put("actor", object["attributedTo"])
|
|> Map.put("actor", object["attributedTo"])
|
||||||
|> fix_attachments
|
|> fix_attachments
|
||||||
|> fix_in_reply_to
|
|
||||||
|
# |> fix_in_reply_to
|
||||||
|
|
||||||
# |> fix_tag
|
# |> fix_tag
|
||||||
end
|
end
|
||||||
|
@ -127,16 +128,17 @@ defmodule Mobilizon.Service.ActivityPub.Transmogrifier do
|
||||||
def handle_incoming(%{"type" => "Flag"} = data) do
|
def handle_incoming(%{"type" => "Flag"} = data) do
|
||||||
with params <- Converter.Flag.as_to_model(data) do
|
with params <- Converter.Flag.as_to_model(data) do
|
||||||
params = %{
|
params = %{
|
||||||
reporter_url: params["reporter"].url,
|
reporter_id: params["reporter"].id,
|
||||||
reported_actor_url: params["reported"].url,
|
reported_id: params["reported"].id,
|
||||||
comments_url: params["comments"] |> Enum.map(& &1.url),
|
comments_ids: params["comments"] |> Enum.map(& &1.id),
|
||||||
content: params["content"] || "",
|
content: params["content"] || "",
|
||||||
additional: %{
|
additional: %{
|
||||||
"cc" => [params["reported"].url]
|
"cc" => [params["reported"].url]
|
||||||
}
|
},
|
||||||
|
local: false
|
||||||
}
|
}
|
||||||
|
|
||||||
ActivityPub.flag(params)
|
ActivityPub.flag(params, false)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ defmodule Mobilizon.Service.ActivityPub.Visibility do
|
||||||
Utility functions related to content visibility
|
Utility functions related to content visibility
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
alias Mobilizon.Events.Comment
|
||||||
alias Mobilizon.Service.ActivityPub.Activity
|
alias Mobilizon.Service.ActivityPub.Activity
|
||||||
|
|
||||||
@public "https://www.w3.org/ns/activitystreams#Public"
|
@public "https://www.w3.org/ns/activitystreams#Public"
|
||||||
|
@ -17,5 +18,6 @@ defmodule Mobilizon.Service.ActivityPub.Visibility do
|
||||||
def is_public?(%{data: data}), do: is_public?(data)
|
def is_public?(%{data: data}), do: is_public?(data)
|
||||||
def is_public?(%Activity{data: data}), do: is_public?(data)
|
def is_public?(%Activity{data: data}), do: is_public?(data)
|
||||||
def is_public?(data) when is_map(data), do: @public in (data["to"] ++ (data["cc"] || []))
|
def is_public?(data) when is_map(data), do: @public in (data["to"] ++ (data["cc"] || []))
|
||||||
|
def is_public?(%Comment{deleted_at: deleted_at}), do: !is_nil(deleted_at)
|
||||||
def is_public?(err), do: raise(ArgumentError, message: "Invalid argument #{inspect(err)}")
|
def is_public?(err), do: raise(ArgumentError, message: "Invalid argument #{inspect(err)}")
|
||||||
end
|
end
|
||||||
|
|
|
@ -34,7 +34,12 @@ defmodule Mobilizon.Service.Formatter do
|
||||||
|
|
||||||
def mention_handler("@" <> nickname, buffer, _opts, acc) do
|
def mention_handler("@" <> nickname, buffer, _opts, acc) do
|
||||||
case Actors.get_actor_by_name(nickname) do
|
case Actors.get_actor_by_name(nickname) do
|
||||||
%Actor{id: id, url: url, preferred_username: preferred_username} = actor ->
|
%Actor{preferred_username: preferred_username} = actor ->
|
||||||
|
link = "<span class='h-card mention'>@<span>#{preferred_username}</span></span>"
|
||||||
|
|
||||||
|
{link, %{acc | mentions: MapSet.put(acc.mentions, {"@" <> nickname, actor})}}
|
||||||
|
|
||||||
|
%Actor{type: :Person, id: id, url: url, preferred_username: preferred_username} = actor ->
|
||||||
link =
|
link =
|
||||||
"<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{url}'>@<span>#{
|
"<span class='h-card'><a data-user='#{id}' class='u-url mention' href='#{url}'>@<span>#{
|
||||||
preferred_username
|
preferred_username
|
||||||
|
|
|
@ -19,7 +19,7 @@ defmodule Mobilizon.Service.Workers.BuildSearchWorker do
|
||||||
|
|
||||||
def perform(%{"op" => "update_search_event", "event_id" => event_id}, _job) do
|
def perform(%{"op" => "update_search_event", "event_id" => event_id}, _job) do
|
||||||
with {:ok, %Event{} = event} <- Events.get_event_with_preload(event_id) do
|
with {:ok, %Event{} = event} <- Events.get_event_with_preload(event_id) do
|
||||||
update_search_event(event)
|
insert_search_event(event)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -33,30 +33,12 @@ defmodule Mobilizon.Service.Workers.BuildSearchWorker do
|
||||||
setweight(to_tsvector(unaccent(coalesce($4, ' '))), 'B') ||
|
setweight(to_tsvector(unaccent(coalesce($4, ' '))), 'B') ||
|
||||||
setweight(to_tsvector(unaccent($3)), 'C')
|
setweight(to_tsvector(unaccent($3)), 'C')
|
||||||
)
|
)
|
||||||
);
|
) ON CONFLICT (id) DO UPDATE SET title = $2, document = (
|
||||||
""",
|
SELECT
|
||||||
[
|
setweight(to_tsvector(unaccent($2)), 'A') ||
|
||||||
event.id,
|
setweight(to_tsvector(unaccent(coalesce($4, ' '))), 'B') ||
|
||||||
event.title,
|
setweight(to_tsvector(unaccent($3)), 'C')
|
||||||
HtmlSanitizeEx.strip_tags(event.description),
|
);
|
||||||
get_tags_string(event)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_search_event(%Event{} = event) do
|
|
||||||
SQL.query(
|
|
||||||
Repo,
|
|
||||||
"""
|
|
||||||
UPDATE event_search
|
|
||||||
SET document =
|
|
||||||
(SELECT
|
|
||||||
setweight(to_tsvector(unaccent($2)), 'A') ||
|
|
||||||
setweight(to_tsvector(unaccent(coalesce($4, ' '))), 'B') ||
|
|
||||||
setweight(to_tsvector(unaccent($3)), 'C')
|
|
||||||
),
|
|
||||||
title = $2
|
|
||||||
WHERE id = $1;
|
|
||||||
""",
|
""",
|
||||||
[
|
[
|
||||||
event.id,
|
event.id,
|
||||||
|
|
1
mix.exs
1
mix.exs
|
@ -134,7 +134,6 @@ defmodule Mobilizon.Mixfile do
|
||||||
"ecto.setup"
|
"ecto.setup"
|
||||||
],
|
],
|
||||||
test: [
|
test: [
|
||||||
"ecto.create --quiet",
|
|
||||||
"ecto.migrate",
|
"ecto.migrate",
|
||||||
&run_test/1
|
&run_test/1
|
||||||
],
|
],
|
||||||
|
|
|
@ -19,8 +19,8 @@ defmodule Mobilizon.Repo.Migrations.FixCommentsReferences do
|
||||||
drop(constraint(:comments, "comments_origin_comment_id_fkey"))
|
drop(constraint(:comments, "comments_origin_comment_id_fkey"))
|
||||||
|
|
||||||
alter table(:comments) do
|
alter table(:comments) do
|
||||||
modify(:in_reply_to_comment_id, references(:categories, on_delete: :nothing))
|
modify(:in_reply_to_comment_id, references(:comments, on_delete: :nothing))
|
||||||
modify(:origin_comment_id, references(:addresses, on_delete: :delete_all))
|
modify(:origin_comment_id, references(:comments, on_delete: :delete_all))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
defmodule Mobilizon.Storage.Repo.Migrations.CascadeCommentDeletion do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
drop(constraint(:comments, "comments_in_reply_to_comment_id_fkey"))
|
||||||
|
drop(constraint(:comments, "comments_origin_comment_id_fkey"))
|
||||||
|
|
||||||
|
alter table(:comments) do
|
||||||
|
modify(:in_reply_to_comment_id, references(:comments, on_delete: :nilify_all))
|
||||||
|
modify(:origin_comment_id, references(:comments, on_delete: :nilify_all))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop(constraint(:comments, "comments_in_reply_to_comment_id_fkey"))
|
||||||
|
drop(constraint(:comments, "comments_origin_comment_id_fkey"))
|
||||||
|
|
||||||
|
alter table(:comments) do
|
||||||
|
modify(:in_reply_to_comment_id, references(:comments, on_delete: :nothing))
|
||||||
|
modify(:origin_comment_id, references(:comments, on_delete: :nothing))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule Mobilizon.Storage.Repo.Migrations.AddDeletedAtOnComments do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
drop_if_exists(constraint(:comments, "comments_actor_id_fkey"))
|
||||||
|
|
||||||
|
alter table(:comments) do
|
||||||
|
add(:deleted_at, :utc_datetime, null: true)
|
||||||
|
modify(:actor_id, references(:actors, on_delete: :nilify_all), null: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists(constraint(:comments, "comments_actor_id_fkey"))
|
||||||
|
|
||||||
|
alter table(:comments) do
|
||||||
|
remove(:deleted_at)
|
||||||
|
modify(:actor_id, references(:actors, on_delete: :nilify_all), null: false)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,19 @@
|
||||||
|
defmodule Mobilizon.Storage.Repo.Migrations.AddLocalFieldToReports do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
alter table(:reports) do
|
||||||
|
add(:local, :boolean, default: true, null: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
rename(table(:reports), :uri, to: :url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
alter table(:reports) do
|
||||||
|
remove(:local)
|
||||||
|
end
|
||||||
|
|
||||||
|
rename(table(:reports), :url, to: :uri)
|
||||||
|
end
|
||||||
|
end
|
14
priv/repo/migrations/20191127163737_create_tombstones.exs
Normal file
14
priv/repo/migrations/20191127163737_create_tombstones.exs
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
defmodule Mobilizon.Repo.Migrations.CreateTombstones do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def change do
|
||||||
|
create table(:tombstones) do
|
||||||
|
add(:uri, :string)
|
||||||
|
add(:actor_id, references(:actors, on_delete: :delete_all))
|
||||||
|
|
||||||
|
timestamps()
|
||||||
|
end
|
||||||
|
|
||||||
|
create(unique_index(:tombstones, [:uri]))
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,5 @@
|
||||||
# source: http://localhost:4000/api
|
# source: http://localhost:4000/api
|
||||||
# timestamp: Thu Nov 21 2019 15:58:08 GMT+0100 (Central European Standard Time)
|
# timestamp: Fri Nov 22 2019 18:34:33 GMT+0100 (Central European Standard Time)
|
||||||
|
|
||||||
schema {
|
schema {
|
||||||
query: RootQueryType
|
query: RootQueryType
|
||||||
|
@ -157,13 +157,22 @@ input AddressInput {
|
||||||
|
|
||||||
"""A comment"""
|
"""A comment"""
|
||||||
type Comment {
|
type Comment {
|
||||||
|
actor: Person
|
||||||
|
deletedAt: DateTime
|
||||||
|
event: Event
|
||||||
|
|
||||||
"""Internal ID for this comment"""
|
"""Internal ID for this comment"""
|
||||||
id: ID
|
id: ID
|
||||||
|
inReplyToComment: Comment
|
||||||
|
insertedAt: DateTime
|
||||||
local: Boolean
|
local: Boolean
|
||||||
|
originComment: Comment
|
||||||
primaryLanguage: String
|
primaryLanguage: String
|
||||||
replies: [Comment]
|
replies: [Comment]
|
||||||
text: String
|
text: String
|
||||||
threadLanguages: [String]!
|
threadLanguages: [String]!
|
||||||
|
totalReplies: Int
|
||||||
|
updatedAt: DateTime
|
||||||
url: String
|
url: String
|
||||||
uuid: UUID
|
uuid: UUID
|
||||||
visibility: CommentVisibility
|
visibility: CommentVisibility
|
||||||
|
@ -259,6 +268,9 @@ type Event implements ActionLogObject {
|
||||||
"""The event's category"""
|
"""The event's category"""
|
||||||
category: String
|
category: String
|
||||||
|
|
||||||
|
"""The comments in reply to the event"""
|
||||||
|
comments: [Comment]
|
||||||
|
|
||||||
"""When the event was created"""
|
"""When the event was created"""
|
||||||
createdAt: DateTime
|
createdAt: DateTime
|
||||||
|
|
||||||
|
@ -845,7 +857,7 @@ input PictureInputObject {
|
||||||
}
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
The `Point` scalar type represents Point geographic information compliant string data,
|
The `Point` scalar type represents Point geographic information compliant string data,
|
||||||
represented as floats separated by a semi-colon. The geodetic system is WGS 84
|
represented as floats separated by a semi-colon. The geodetic system is WGS 84
|
||||||
"""
|
"""
|
||||||
scalar Point
|
scalar Point
|
||||||
|
@ -933,7 +945,7 @@ type RootMutationType {
|
||||||
changePassword(newPassword: String!, oldPassword: String!): User
|
changePassword(newPassword: String!, oldPassword: String!): User
|
||||||
|
|
||||||
"""Create a comment"""
|
"""Create a comment"""
|
||||||
createComment(actorId: ID!, text: String!): Comment
|
createComment(actorId: ID!, eventId: ID, inReplyToCommentId: ID, text: String!): Comment
|
||||||
|
|
||||||
"""Create an event"""
|
"""Create an event"""
|
||||||
createEvent(
|
createEvent(
|
||||||
|
@ -1018,6 +1030,7 @@ type RootMutationType {
|
||||||
|
|
||||||
"""Create an user"""
|
"""Create an user"""
|
||||||
createUser(email: String!, locale: String, password: String!): User
|
createUser(email: String!, locale: String, password: String!): User
|
||||||
|
deleteComment(actorId: ID!, commentId: ID!): DeletedObject
|
||||||
|
|
||||||
"""Delete an event"""
|
"""Delete an event"""
|
||||||
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
|
deleteEvent(actorId: ID!, eventId: ID!): DeletedObject
|
||||||
|
@ -1208,6 +1221,9 @@ type RootQueryType {
|
||||||
"""Get the list of tags"""
|
"""Get the list of tags"""
|
||||||
tags(limit: Int = 10, page: Int = 1): [Tag]!
|
tags(limit: Int = 10, page: Int = 1): [Tag]!
|
||||||
|
|
||||||
|
"""Get replies for thread"""
|
||||||
|
thread(id: ID): [Comment]
|
||||||
|
|
||||||
"""Get an user"""
|
"""Get an user"""
|
||||||
user(id: ID!): User
|
user(id: ID!): User
|
||||||
|
|
||||||
|
|
|
@ -581,7 +581,7 @@ defmodule Mobilizon.EventsTest do
|
||||||
test "delete_comment/1 deletes the comment" do
|
test "delete_comment/1 deletes the comment" do
|
||||||
comment = insert(:comment)
|
comment = insert(:comment)
|
||||||
assert {:ok, %Comment{}} = Events.delete_comment(comment)
|
assert {:ok, %Comment{}} = Events.delete_comment(comment)
|
||||||
assert_raise Ecto.NoResultsError, fn -> Events.get_comment!(comment.id) end
|
refute is_nil(Events.get_comment!(comment.id).deleted_at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -148,13 +148,14 @@ defmodule Mobilizon.Service.ActivityPub.ActivityPubTest do
|
||||||
test "it creates a delete activity and deletes the original comment" do
|
test "it creates a delete activity and deletes the original comment" do
|
||||||
comment = insert(:comment)
|
comment = insert(:comment)
|
||||||
comment = Events.get_comment_from_url_with_preload!(comment.url)
|
comment = Events.get_comment_from_url_with_preload!(comment.url)
|
||||||
|
assert is_nil(Events.get_comment_from_url(comment.url).deleted_at)
|
||||||
{:ok, delete, _} = ActivityPub.delete(comment)
|
{:ok, delete, _} = ActivityPub.delete(comment)
|
||||||
|
|
||||||
assert delete.data["type"] == "Delete"
|
assert delete.data["type"] == "Delete"
|
||||||
assert delete.data["actor"] == comment.actor.url
|
assert delete.data["actor"] == comment.actor.url
|
||||||
assert delete.data["object"] == comment.url
|
assert delete.data["object"] == comment.url
|
||||||
|
|
||||||
assert Events.get_comment_from_url(comment.url) == nil
|
refute is_nil(Events.get_comment_from_url(comment.url).deleted_at)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -91,31 +91,50 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do
|
||||||
# assert activity == returned_activity.data
|
# assert activity == returned_activity.data
|
||||||
# end
|
# end
|
||||||
|
|
||||||
# test "it fetches replied-to activities if we don't have them" do
|
test "it fetches replied-to activities if we don't have them" do
|
||||||
# data =
|
data =
|
||||||
# File.read!("test/fixtures/mastodon-post-activity.json")
|
File.read!("test/fixtures/mastodon-post-activity.json")
|
||||||
# |> Jason.decode!()
|
|> Jason.decode!()
|
||||||
|
|
||||||
# object =
|
object =
|
||||||
# data["object"]
|
data["object"]
|
||||||
# |> Map.put("inReplyTo", "https://shitposter.club/notice/2827873")
|
|> Map.put("inReplyTo", "https://blob.cat/objects/02fdea3d-932c-4348-9ecb-3f9eb3fbdd94")
|
||||||
|
|
||||||
# data =
|
data =
|
||||||
# data
|
data
|
||||||
# |> Map.put("object", object)
|
|> Map.put("object", object)
|
||||||
|
|
||||||
# {:ok, returned_activity, _} = Transmogrifier.handle_incoming(data)
|
{:ok, returned_activity, _} = Transmogrifier.handle_incoming(data)
|
||||||
|
|
||||||
# assert activity =
|
%Comment{} =
|
||||||
# Activity.get_create_activity_by_object_ap_id(
|
origin_comment =
|
||||||
# "tag:shitposter.club,2017-05-05:noticeId=2827873:objectType=comment"
|
Events.get_comment_from_url(
|
||||||
# )
|
"https://blob.cat/objects/02fdea3d-932c-4348-9ecb-3f9eb3fbdd94"
|
||||||
|
)
|
||||||
|
|
||||||
# assert returned_activity.data["object"]["inReplyToAtomUri"] ==
|
assert returned_activity.data["object"]["inReplyTo"] ==
|
||||||
# "https://shitposter.club/notice/2827873"
|
"https://blob.cat/objects/02fdea3d-932c-4348-9ecb-3f9eb3fbdd94"
|
||||||
|
|
||||||
# assert returned_activity.data["object"]["inReplyToStatusId"] == activity.id
|
assert returned_activity.data["object"]["inReplyTo"] == origin_comment.url
|
||||||
# end
|
end
|
||||||
|
|
||||||
|
test "it does not crash if the object in inReplyTo can't be fetched" do
|
||||||
|
data =
|
||||||
|
File.read!("test/fixtures/mastodon-post-activity.json")
|
||||||
|
|> Poison.decode!()
|
||||||
|
|
||||||
|
object =
|
||||||
|
data["object"]
|
||||||
|
|> Map.put("inReplyTo", "https://404.site/whatever")
|
||||||
|
|
||||||
|
data =
|
||||||
|
data
|
||||||
|
|> Map.put("object", object)
|
||||||
|
|
||||||
|
assert ExUnit.CaptureLog.capture_log([level: :warn], fn ->
|
||||||
|
{:ok, _returned_activity, _entity} = Transmogrifier.handle_incoming(data)
|
||||||
|
end) =~ "[warn] Parent object is something we don't handle"
|
||||||
|
end
|
||||||
|
|
||||||
test "it works for incoming notices" do
|
test "it works for incoming notices" do
|
||||||
use_cassette "activity_pub/mastodon_post_activity" do
|
use_cassette "activity_pub/mastodon_post_activity" do
|
||||||
|
@ -440,10 +459,11 @@ defmodule Mobilizon.Service.ActivityPub.TransmogrifierTest do
|
||||||
|> Map.put("actor", actor_url)
|
|> Map.put("actor", actor_url)
|
||||||
|
|
||||||
assert Events.get_comment_from_url(comment_url)
|
assert Events.get_comment_from_url(comment_url)
|
||||||
|
assert is_nil(Events.get_comment_from_url(comment_url).deleted_at)
|
||||||
|
|
||||||
{:ok, %Activity{local: false}, _} = Transmogrifier.handle_incoming(data)
|
{:ok, %Activity{local: false}, _} = Transmogrifier.handle_incoming(data)
|
||||||
|
|
||||||
refute Events.get_comment_from_url(comment_url)
|
refute is_nil(Events.get_comment_from_url(comment_url).deleted_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
# TODO : make me ASAP
|
# TODO : make me ASAP
|
||||||
|
|
|
@ -117,37 +117,32 @@ defmodule Mobilizon.Service.FormatterTest do
|
||||||
describe "add_user_links" do
|
describe "add_user_links" do
|
||||||
test "gives a replacement for user links, using local nicknames in user links text" do
|
test "gives a replacement for user links, using local nicknames in user links text" do
|
||||||
text = "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme@archae.me"
|
text = "@gsimg According to @archa_eme_, that is @daggsy. Also hello @archaeme@archae.me"
|
||||||
gsimg = insert(:actor, preferred_username: "gsimg")
|
_gsimg = insert(:actor, preferred_username: "gsimg")
|
||||||
|
|
||||||
archaeme =
|
_archaeme =
|
||||||
insert(:actor, preferred_username: "archa_eme_", url: "https://archeme/@archa_eme_")
|
insert(:actor, preferred_username: "archa_eme_", url: "https://archeme/@archa_eme_")
|
||||||
|
|
||||||
archaeme_remote = insert(:actor, preferred_username: "archaeme", domain: "archae.me")
|
_archaeme_remote = insert(:actor, preferred_username: "archaeme", domain: "archae.me")
|
||||||
|
|
||||||
{text, mentions, []} = Formatter.linkify(text)
|
{text, mentions, []} = Formatter.linkify(text)
|
||||||
|
|
||||||
assert length(mentions) == 3
|
assert length(mentions) == 3
|
||||||
|
|
||||||
expected_text =
|
expected_text =
|
||||||
"<span class='h-card'><a data-user='#{gsimg.id}' class='u-url mention' href='#{gsimg.url}'>@<span>gsimg</span></a></span> According to <span class='h-card'><a data-user='#{
|
"<span class='h-card mention'>@<span>gsimg</span></span> According to <span class='h-card mention'>@<span>archa_eme_</span></span>, that is @daggsy. Also hello <span class='h-card mention'>@<span>archaeme</span></span>"
|
||||||
archaeme.id
|
|
||||||
}' class='u-url mention' href='#{"https://archeme/@archa_eme_"}'>@<span>archa_eme_</span></a></span>, that is @daggsy. Also hello <span class='h-card'><a data-user='#{
|
|
||||||
archaeme_remote.id
|
|
||||||
}' class='u-url mention' href='#{archaeme_remote.url}'>@<span>archaeme</span></a></span>"
|
|
||||||
|
|
||||||
assert expected_text == text
|
assert expected_text == text
|
||||||
end
|
end
|
||||||
|
|
||||||
test "gives a replacement for single-character local nicknames" do
|
test "gives a replacement for single-character local nicknames" do
|
||||||
text = "@o hi"
|
text = "@o hi"
|
||||||
o = insert(:actor, preferred_username: "o")
|
_o = insert(:actor, preferred_username: "o")
|
||||||
|
|
||||||
{text, mentions, []} = Formatter.linkify(text)
|
{text, mentions, []} = Formatter.linkify(text)
|
||||||
|
|
||||||
assert length(mentions) == 1
|
assert length(mentions) == 1
|
||||||
|
|
||||||
expected_text =
|
expected_text = "<span class='h-card mention'>@<span>o</span></span> hi"
|
||||||
"<span class='h-card'><a data-user='#{o.id}' class='u-url mention' href='#{o.url}'>@<span>o</span></a></span> hi"
|
|
||||||
|
|
||||||
assert expected_text == text
|
assert expected_text == text
|
||||||
end
|
end
|
||||||
|
|
|
@ -7,7 +7,6 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
alias Mobilizon.Events.{Comment, Event}
|
alias Mobilizon.Events.{Comment, Event}
|
||||||
alias Mobilizon.Reports.{Note, Report}
|
alias Mobilizon.Reports.{Note, Report}
|
||||||
alias Mobilizon.Service.ActivityPub.Activity
|
alias Mobilizon.Service.ActivityPub.Activity
|
||||||
alias Mobilizon.Service.Formatter
|
|
||||||
alias Mobilizon.Users
|
alias Mobilizon.Users
|
||||||
alias Mobilizon.Users.User
|
alias Mobilizon.Users.User
|
||||||
|
|
||||||
|
@ -24,11 +23,12 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
|
|
||||||
assert {:ok, %Activity{} = flag_activity, _} =
|
assert {:ok, %Activity{} = flag_activity, _} =
|
||||||
Reports.report(%{
|
Reports.report(%{
|
||||||
reporter_actor_id: reporter_id,
|
reporter_id: reporter_id,
|
||||||
reported_actor_id: reported_id,
|
reported_id: reported_id,
|
||||||
content: comment,
|
content: comment,
|
||||||
event_id: event_id,
|
event_id: event_id,
|
||||||
comments_ids: []
|
comments_ids: [],
|
||||||
|
local: true
|
||||||
})
|
})
|
||||||
|
|
||||||
assert %Activity{
|
assert %Activity{
|
||||||
|
@ -37,8 +37,7 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
"type" => "Flag",
|
"type" => "Flag",
|
||||||
"cc" => [],
|
"cc" => [],
|
||||||
"content" => ^comment,
|
"content" => ^comment,
|
||||||
"object" => [^reported_url, ^event_url],
|
"object" => [^reported_url, ^event_url]
|
||||||
"state" => "open"
|
|
||||||
}
|
}
|
||||||
} = flag_activity
|
} = flag_activity
|
||||||
end
|
end
|
||||||
|
@ -57,8 +56,8 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
|
|
||||||
assert {:ok, %Activity{} = flag_activity, _} =
|
assert {:ok, %Activity{} = flag_activity, _} =
|
||||||
Reports.report(%{
|
Reports.report(%{
|
||||||
reporter_actor_id: reporter_id,
|
reporter_id: reporter_id,
|
||||||
reported_actor_id: reported_id,
|
reported_id: reported_id,
|
||||||
content: comment,
|
content: comment,
|
||||||
event_id: nil,
|
event_id: nil,
|
||||||
comments_ids: [comment_1_id, comment_2_id]
|
comments_ids: [comment_1_id, comment_2_id]
|
||||||
|
@ -68,10 +67,11 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
actor: ^reporter_url,
|
actor: ^reporter_url,
|
||||||
data: %{
|
data: %{
|
||||||
"type" => "Flag",
|
"type" => "Flag",
|
||||||
"cc" => [],
|
|
||||||
"content" => ^comment,
|
"content" => ^comment,
|
||||||
"object" => [^reported_url, ^comment_1_url, ^comment_2_url],
|
"object" => [^reported_url, ^comment_1_url, ^comment_2_url],
|
||||||
"state" => "open"
|
"to" => ["https://www.w3.org/ns/activitystreams#Public"],
|
||||||
|
"cc" => [],
|
||||||
|
"actor" => ^reporter_url
|
||||||
}
|
}
|
||||||
} = flag_activity
|
} = flag_activity
|
||||||
end
|
end
|
||||||
|
@ -87,16 +87,16 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
_comment_2 = insert(:comment, actor: reported)
|
_comment_2 = insert(:comment, actor: reported)
|
||||||
|
|
||||||
comment = "This is really not acceptable, remote admin I don't know"
|
comment = "This is really not acceptable, remote admin I don't know"
|
||||||
encoded_comment = Formatter.html_escape(comment, "text/plain")
|
encoded_comment = HtmlSanitizeEx.strip_tags(comment)
|
||||||
|
|
||||||
assert {:ok, %Activity{} = flag_activity, _} =
|
assert {:ok, %Activity{} = flag_activity, _} =
|
||||||
Reports.report(%{
|
Reports.report(%{
|
||||||
reporter_actor_id: reporter_id,
|
reporter_id: reporter_id,
|
||||||
reported_actor_id: reported_id,
|
reported_id: reported_id,
|
||||||
content: comment,
|
content: comment,
|
||||||
event_id: nil,
|
event_id: nil,
|
||||||
comments_ids: [comment_1_id, comment_2_id],
|
comments_ids: [comment_1_id, comment_2_id],
|
||||||
forward: true
|
local: false
|
||||||
})
|
})
|
||||||
|
|
||||||
assert %Activity{
|
assert %Activity{
|
||||||
|
@ -107,8 +107,10 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
"cc" => [^reported_url],
|
"cc" => [^reported_url],
|
||||||
"content" => ^encoded_comment,
|
"content" => ^encoded_comment,
|
||||||
"object" => [^reported_url, ^comment_1_url, ^comment_2_url],
|
"object" => [^reported_url, ^comment_1_url, ^comment_2_url],
|
||||||
"state" => "open"
|
"to" => ["https://www.w3.org/ns/activitystreams#Public"]
|
||||||
}
|
},
|
||||||
|
local: true,
|
||||||
|
recipients: ["https://www.w3.org/ns/activitystreams#Public", ^reported_url]
|
||||||
} = flag_activity
|
} = flag_activity
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -120,8 +122,8 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
|
|
||||||
assert {:ok, %Activity{} = flag_activity, %Report{id: report_id} = _report} =
|
assert {:ok, %Activity{} = flag_activity, %Report{id: report_id} = _report} =
|
||||||
Reports.report(%{
|
Reports.report(%{
|
||||||
reporter_actor_id: reporter_id,
|
reporter_id: reporter_id,
|
||||||
reported_actor_id: reported_id,
|
reported_id: reported_id,
|
||||||
content: "This is not a nice thing",
|
content: "This is not a nice thing",
|
||||||
event_id: nil,
|
event_id: nil,
|
||||||
comments_ids: [comment_1_id],
|
comments_ids: [comment_1_id],
|
||||||
|
@ -146,8 +148,8 @@ defmodule MobilizonWeb.API.ReportTest do
|
||||||
|
|
||||||
assert {:ok, %Activity{} = flag_activity, %Report{id: report_id} = _report} =
|
assert {:ok, %Activity{} = flag_activity, %Report{id: report_id} = _report} =
|
||||||
Reports.report(%{
|
Reports.report(%{
|
||||||
reporter_actor_id: reporter_id,
|
reporter_id: reporter_id,
|
||||||
reported_actor_id: reported_id,
|
reported_id: reported_id,
|
||||||
content: "This is not a nice thing",
|
content: "This is not a nice thing",
|
||||||
event_id: nil,
|
event_id: nil,
|
||||||
comments_ids: [comment_1_id],
|
comments_ids: [comment_1_id],
|
||||||
|
|
|
@ -8,12 +8,42 @@ defmodule MobilizonWeb.Resolvers.CommentResolverTest do
|
||||||
setup %{conn: conn} do
|
setup %{conn: conn} do
|
||||||
user = insert(:user)
|
user = insert(:user)
|
||||||
actor = insert(:actor, user: user)
|
actor = insert(:actor, user: user)
|
||||||
|
event = insert(:event)
|
||||||
|
|
||||||
{:ok, conn: conn, actor: actor, user: user}
|
{:ok, conn: conn, actor: actor, user: user, event: event}
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "Comment Resolver" do
|
describe "Comment Resolver" do
|
||||||
test "create_comment/3 creates a comment", %{conn: conn, actor: actor, user: user} do
|
test "create_comment/3 creates a comment", %{
|
||||||
|
conn: conn,
|
||||||
|
actor: actor,
|
||||||
|
user: user,
|
||||||
|
event: event
|
||||||
|
} do
|
||||||
|
mutation = """
|
||||||
|
mutation {
|
||||||
|
createComment(
|
||||||
|
text: "#{@comment.text}",
|
||||||
|
actor_id: "#{actor.id}",
|
||||||
|
event_id: "#{event.id}"
|
||||||
|
) {
|
||||||
|
text,
|
||||||
|
uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user)
|
||||||
|
|> AbsintheHelpers.graphql_query(query: mutation, variables: %{})
|
||||||
|
|
||||||
|
assert res["data"]["createComment"]["text"] == @comment.text
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_comment/3 checks that user owns actor", %{conn: conn, user: user} do
|
||||||
|
actor = insert(:actor)
|
||||||
|
|
||||||
mutation = """
|
mutation = """
|
||||||
mutation {
|
mutation {
|
||||||
createComment(
|
createComment(
|
||||||
|
@ -29,9 +59,202 @@ defmodule MobilizonWeb.Resolvers.CommentResolverTest do
|
||||||
res =
|
res =
|
||||||
conn
|
conn
|
||||||
|> auth_conn(user)
|
|> auth_conn(user)
|
||||||
|> post("/api", AbsintheHelpers.mutation_skeleton(mutation))
|
|> AbsintheHelpers.graphql_query(query: mutation, variables: %{})
|
||||||
|
|
||||||
assert json_response(res, 200)["data"]["createComment"]["text"] == @comment.text
|
assert hd(res["errors"])["message"] ==
|
||||||
|
"Actor id is not owned by authenticated user"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_comment/3 requires that the user needs to be authenticated", %{conn: conn} do
|
||||||
|
actor = insert(:actor)
|
||||||
|
|
||||||
|
mutation = """
|
||||||
|
mutation {
|
||||||
|
createComment(
|
||||||
|
text: "#{@comment.text}",
|
||||||
|
actor_id: "#{actor.id}"
|
||||||
|
) {
|
||||||
|
text,
|
||||||
|
uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> AbsintheHelpers.graphql_query(query: mutation, variables: %{})
|
||||||
|
|
||||||
|
assert hd(res["errors"])["message"] ==
|
||||||
|
"You are not allowed to create a comment if not connected"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "create_comment/3 creates a reply to a comment", %{
|
||||||
|
conn: conn,
|
||||||
|
actor: actor,
|
||||||
|
user: user,
|
||||||
|
event: event
|
||||||
|
} do
|
||||||
|
comment = insert(:comment)
|
||||||
|
|
||||||
|
mutation = """
|
||||||
|
mutation {
|
||||||
|
createComment(
|
||||||
|
text: "#{@comment.text}",
|
||||||
|
actor_id: "#{actor.id}",
|
||||||
|
event_id: "#{event.id}",
|
||||||
|
in_reply_to_comment_id: "#{comment.id}"
|
||||||
|
) {
|
||||||
|
id,
|
||||||
|
text,
|
||||||
|
uuid,
|
||||||
|
in_reply_to_comment {
|
||||||
|
id,
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user)
|
||||||
|
|> AbsintheHelpers.graphql_query(query: mutation, variables: %{})
|
||||||
|
|
||||||
|
assert res["errors"] == nil
|
||||||
|
assert res["data"]["createComment"]["text"] == @comment.text
|
||||||
|
uuid = res["data"]["createComment"]["uuid"]
|
||||||
|
|
||||||
|
assert res["data"]["createComment"]["in_reply_to_comment"]["id"] ==
|
||||||
|
to_string(comment.id)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
thread(id: #{comment.id}) {
|
||||||
|
text,
|
||||||
|
uuid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user)
|
||||||
|
|> AbsintheHelpers.graphql_query(query: query, variables: %{})
|
||||||
|
|
||||||
|
assert res["errors"] == nil
|
||||||
|
assert res["data"]["thread"] == [%{"uuid" => uuid, "text" => @comment.text}]
|
||||||
|
end
|
||||||
|
|
||||||
|
@delete_comment """
|
||||||
|
mutation DeleteComment($commentId: ID!, $actorId: ID!) {
|
||||||
|
deleteComment(commentId: $commentId, actorId: $actorId) {
|
||||||
|
id,
|
||||||
|
deletedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
test "deletes a comment", %{conn: conn, user: user, actor: actor} do
|
||||||
|
comment = insert(:comment, actor: actor)
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> AbsintheHelpers.graphql_query(
|
||||||
|
query: @delete_comment,
|
||||||
|
variables: %{commentId: comment.id, actorId: actor.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hd(res["errors"])["message"] ==
|
||||||
|
"You are not allowed to delete a comment if not connected"
|
||||||
|
|
||||||
|
actor2 = insert(:actor, user: user)
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user)
|
||||||
|
|> AbsintheHelpers.graphql_query(
|
||||||
|
query: @delete_comment,
|
||||||
|
variables: %{commentId: comment.id, actorId: actor2.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert hd(res["errors"])["message"] ==
|
||||||
|
"You cannot delete this comment"
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user)
|
||||||
|
|> AbsintheHelpers.graphql_query(
|
||||||
|
query: @delete_comment,
|
||||||
|
variables: %{commentId: comment.id, actorId: actor.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["errors"] == nil
|
||||||
|
assert res["data"]["deleteComment"]["id"] == to_string(comment.id)
|
||||||
|
refute is_nil(res["data"]["deleteComment"]["deletedAt"])
|
||||||
|
end
|
||||||
|
|
||||||
|
test "delete_comment/3 allows a comment being deleted by a moderator and creates a entry in actionLogs",
|
||||||
|
%{
|
||||||
|
conn: conn,
|
||||||
|
user: _user,
|
||||||
|
actor: _actor
|
||||||
|
} do
|
||||||
|
user_moderator = insert(:user, role: :moderator)
|
||||||
|
actor_moderator = insert(:actor, user: user_moderator)
|
||||||
|
|
||||||
|
actor2 = insert(:actor)
|
||||||
|
comment = insert(:comment, actor: actor2)
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user_moderator)
|
||||||
|
|> AbsintheHelpers.graphql_query(
|
||||||
|
query: @delete_comment,
|
||||||
|
variables: %{commentId: comment.id, actorId: actor_moderator.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res["data"]["deleteComment"]["id"] == to_string(comment.id)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
{
|
||||||
|
actionLogs {
|
||||||
|
action,
|
||||||
|
actor {
|
||||||
|
preferredUsername
|
||||||
|
},
|
||||||
|
object {
|
||||||
|
... on Report {
|
||||||
|
id,
|
||||||
|
status
|
||||||
|
},
|
||||||
|
... on ReportNote {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
... on Event {
|
||||||
|
id,
|
||||||
|
title
|
||||||
|
},
|
||||||
|
... on Comment {
|
||||||
|
id,
|
||||||
|
text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
res =
|
||||||
|
conn
|
||||||
|
|> auth_conn(user_moderator)
|
||||||
|
|> get("/api", AbsintheHelpers.query_skeleton(query, "actionLogs"))
|
||||||
|
|
||||||
|
refute json_response(res, 200)["errors"]
|
||||||
|
|
||||||
|
assert hd(json_response(res, 200)["data"]["actionLogs"]) == %{
|
||||||
|
"action" => "COMMENT_DELETION",
|
||||||
|
"actor" => %{"preferredUsername" => actor_moderator.preferred_username},
|
||||||
|
"object" => %{"text" => comment.text, "id" => to_string(comment.id)}
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -20,8 +20,8 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||||
mutation = """
|
mutation = """
|
||||||
mutation {
|
mutation {
|
||||||
createReport(
|
createReport(
|
||||||
reporter_actor_id: #{reporter.id},
|
reporter_id: #{reporter.id},
|
||||||
reported_actor_id: #{reported.id},
|
reported_id: #{reported.id},
|
||||||
event_id: #{event.id},
|
event_id: #{event.id},
|
||||||
content: "This is an issue"
|
content: "This is an issue"
|
||||||
) {
|
) {
|
||||||
|
@ -57,8 +57,8 @@ defmodule MobilizonWeb.Resolvers.ReportResolverTest do
|
||||||
mutation = """
|
mutation = """
|
||||||
mutation {
|
mutation {
|
||||||
createReport(
|
createReport(
|
||||||
reported_actor_id: #{reported.id},
|
reported_id: #{reported.id},
|
||||||
reporter_actor_id: 5,
|
reporter_id: 5,
|
||||||
content: "This is an issue"
|
content: "This is an issue"
|
||||||
) {
|
) {
|
||||||
content
|
content
|
||||||
|
|
|
@ -224,7 +224,7 @@ defmodule Mobilizon.Factory do
|
||||||
%Mobilizon.Reports.Report{
|
%Mobilizon.Reports.Report{
|
||||||
content: "This is problematic",
|
content: "This is problematic",
|
||||||
status: :open,
|
status: :open,
|
||||||
uri: "http://mobilizon.test/report/deae1020-54b8-47df-9eea-d8c0e943e57f/activity",
|
url: "http://mobilizon.test/report/deae1020-54b8-47df-9eea-d8c0e943e57f/activity",
|
||||||
reported: build(:actor),
|
reported: build(:actor),
|
||||||
reporter: build(:actor),
|
reporter: build(:actor),
|
||||||
event: build(:event),
|
event: build(:event),
|
||||||
|
|
Loading…
Reference in a new issue