Introduce comments below events

Also add tomstones

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-11-15 18:36:47 +01:00
parent 45155a3bde
commit dc07f34d78
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
71 changed files with 2642 additions and 879 deletions

View File

@ -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.

View File

@ -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": {

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.9 KiB

View 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>

View 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>

View File

@ -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;

View File

@ -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>

View File

@ -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 {

View File

@ -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>

View File

@ -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>

View File

@ -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
View 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
View 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
}
}
`;

View File

@ -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`

View File

@ -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
} }
} }

View File

@ -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"
} }

View File

@ -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"
} }

View File

@ -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([

View File

@ -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, {

View File

@ -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 };
} }

View File

@ -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;

View 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;
}
}

View File

@ -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();

View File

@ -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 {

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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>

View File

@ -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>

File diff suppressed because it is too large Load Diff

View File

@ -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()

View File

@ -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)

View File

@ -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(

View File

@ -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

View 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

View File

@ -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

View File

@ -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
""" """

View File

@ -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}),

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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))

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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,

View File

@ -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
], ],

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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],

View File

@ -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

View File

@ -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

View File

@ -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),