Improve member adding and excluding flow
Allow to exclude a member Send emails to the member when it's excluded Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
ad13a57afc
commit
156eba0551
@ -67,14 +67,14 @@
|
||||
"@types/vuedraggable": "^2.23.0",
|
||||
"@typescript-eslint/eslint-plugin": "^2.26.0",
|
||||
"@typescript-eslint/parser": "^2.26.0",
|
||||
"@vue/cli-plugin-babel": "~4.5.2",
|
||||
"@vue/cli-plugin-e2e-cypress": "~4.5.2",
|
||||
"@vue/cli-plugin-eslint": "~4.5.2",
|
||||
"@vue/cli-plugin-pwa": "~4.5.2",
|
||||
"@vue/cli-plugin-router": "~4.5.2",
|
||||
"@vue/cli-plugin-typescript": "~4.5.2",
|
||||
"@vue/cli-plugin-unit-mocha": "~4.5.2",
|
||||
"@vue/cli-service": "~4.5.2",
|
||||
"@vue/cli-plugin-babel": "~4.5.3",
|
||||
"@vue/cli-plugin-e2e-cypress": "~4.5.3",
|
||||
"@vue/cli-plugin-eslint": "~4.5.3",
|
||||
"@vue/cli-plugin-pwa": "~4.5.3",
|
||||
"@vue/cli-plugin-router": "~4.5.3",
|
||||
"@vue/cli-plugin-typescript": "~4.5.3",
|
||||
"@vue/cli-plugin-unit-mocha": "~4.5.3",
|
||||
"@vue/cli-service": "~4.5.3",
|
||||
"@vue/eslint-config-airbnb": "^5.0.2",
|
||||
"@vue/eslint-config-prettier": "^6.0.0",
|
||||
"@vue/eslint-config-typescript": "^5.0.2",
|
||||
|
@ -255,7 +255,7 @@ export default class Comment extends Vue {
|
||||
get commentFromOrganizer(): boolean {
|
||||
return (
|
||||
this.event.organizerActor !== undefined &&
|
||||
this.comment.actor &&
|
||||
this.comment.actor != null &&
|
||||
this.comment.actor.id === this.event.organizerActor.id
|
||||
);
|
||||
}
|
||||
@ -272,6 +272,7 @@ export default class Comment extends Vue {
|
||||
}
|
||||
|
||||
reportModal() {
|
||||
if (!this.comment.actor) return;
|
||||
this.$buefy.modal.open({
|
||||
parent: this,
|
||||
component: ReportModal,
|
||||
@ -286,6 +287,7 @@ export default class Comment extends Vue {
|
||||
|
||||
async reportComment(content: string, forward: boolean) {
|
||||
try {
|
||||
if (!this.comment.actor) return;
|
||||
await this.$apollo.mutate<IReport>({
|
||||
mutation: CREATE_REPORT,
|
||||
variables: {
|
||||
|
@ -106,6 +106,7 @@ export default class CommentTree extends Vue {
|
||||
|
||||
async createCommentForEvent(comment: IComment) {
|
||||
try {
|
||||
if (!comment.actor) return;
|
||||
await this.$apollo.mutate({
|
||||
mutation: CREATE_COMMENT_FROM_EVENT,
|
||||
variables: {
|
||||
|
@ -8,26 +8,102 @@
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="meta">
|
||||
<div class="name">
|
||||
<span>@{{ comment.actor.preferredUsername }}</span>
|
||||
</div>
|
||||
<span class="first-line name" v-if="!comment.deletedAt">
|
||||
<strong>{{ comment.actor.name }}</strong>
|
||||
<small>@{{ usernameWithDomain(comment.actor) }}</small>
|
||||
</span>
|
||||
<a v-else class="name comment-link has-text-grey">
|
||||
<span>{{ $t("[deleted]") }}</span>
|
||||
</a>
|
||||
<span class="icons" v-if="!comment.deletedAt">
|
||||
<b-dropdown aria-role="list">
|
||||
<b-icon slot="trigger" role="button" icon="dots-horizontal" />
|
||||
|
||||
<b-dropdown-item
|
||||
v-if="comment.actor.id === currentActor.id"
|
||||
@click="toggleEditMode"
|
||||
aria-role="menuitem"
|
||||
>
|
||||
<b-icon icon="pencil"></b-icon>
|
||||
{{ $t("Edit") }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item
|
||||
v-if="comment.actor.id === currentActor.id"
|
||||
@click="$emit('delete-comment', comment)"
|
||||
aria-role="menuitem"
|
||||
>
|
||||
<b-icon icon="delete"></b-icon>
|
||||
{{ $t("Delete") }}
|
||||
</b-dropdown-item>
|
||||
<b-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
|
||||
<b-icon icon="flag" />
|
||||
{{ $t("Report") }}
|
||||
</b-dropdown-item>
|
||||
</b-dropdown>
|
||||
</span>
|
||||
<div class="post-infos">
|
||||
<span :title="comment.insertedAt | formatDateTimeString">
|
||||
{{ $timeAgo.format(comment.insertedAt, "twitter") || $t("Right now") }}</span
|
||||
<span :title="comment.updatedAt | formatDateTimeString">
|
||||
{{ $timeAgo.format(new Date(comment.updatedAt), "twitter") || $t("Right now") }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description-content" v-html="comment.text"></div>
|
||||
<div
|
||||
class="description-content"
|
||||
v-html="comment.text"
|
||||
v-if="!editMode && !comment.deletedAt"
|
||||
></div>
|
||||
<div v-else-if="!editMode">{{ $t("[This comment has been deleted]") }}</div>
|
||||
<form v-else class="edition" @submit.prevent="updateComment">
|
||||
<editor v-model="updatedComment" />
|
||||
<div class="buttons">
|
||||
<b-button
|
||||
native-type="submit"
|
||||
:disabled="['<p></p>', '', comment.text].includes(updatedComment)"
|
||||
type="is-primary"
|
||||
>{{ $t("Update") }}</b-button
|
||||
>
|
||||
<b-button native-type="button" @click="toggleEditMode">{{ $t("Cancel") }}</b-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { IComment, CommentModel } from "../../types/comment.model";
|
||||
import { usernameWithDomain, IPerson } from "../../types/actor";
|
||||
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
|
||||
|
||||
@Component
|
||||
@Component({
|
||||
apollo: {
|
||||
currentActor: CURRENT_ACTOR_CLIENT,
|
||||
},
|
||||
components: {
|
||||
editor: () => import(/* webpackChunkName: "editor" */ "@/components/Editor.vue"),
|
||||
},
|
||||
})
|
||||
export default class DiscussionComment extends Vue {
|
||||
@Prop({ required: true, type: Object }) comment!: IComment;
|
||||
|
||||
editMode: boolean = false;
|
||||
updatedComment: string = "";
|
||||
|
||||
currentActor!: IPerson;
|
||||
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
isReportModalActive: boolean = false;
|
||||
|
||||
toggleEditMode() {
|
||||
this.updatedComment = this.comment.text;
|
||||
this.editMode = !this.editMode;
|
||||
}
|
||||
|
||||
updateComment() {
|
||||
this.comment.text = this.updatedComment;
|
||||
this.$emit("update-comment", this.comment);
|
||||
this.toggleEditMode();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@ -52,10 +128,20 @@ article.comment {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
line-height: 1rem;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #3c376e;
|
||||
}
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
div.description-content {
|
||||
@ -108,5 +194,11 @@ article.comment {
|
||||
padding-top: 1rem;
|
||||
flex: 0;
|
||||
}
|
||||
|
||||
.edition {
|
||||
.button {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
@ -4,14 +4,25 @@
|
||||
:to="{ name: RouteName.DISCUSSION, params: { slug: discussion.slug, id: discussion.id } }"
|
||||
>
|
||||
<div class="media-left">
|
||||
<figure class="image is-32x32" v-if="discussion.lastComment.actor.avatar">
|
||||
<figure
|
||||
class="image is-32x32"
|
||||
v-if="discussion.lastComment.actor && discussion.lastComment.actor.avatar"
|
||||
>
|
||||
<img class="is-rounded" :src="discussion.lastComment.actor.avatar.url" alt />
|
||||
</figure>
|
||||
<b-icon v-else size="is-medium" icon="account-circle" />
|
||||
</div>
|
||||
<div class="title-info-wrapper">
|
||||
<p class="discussion-minimalist-title">{{ discussion.title }}</p>
|
||||
<div class="has-text-grey">{{ htmlTextEllipsis }}</div>
|
||||
<div class="title-and-date">
|
||||
<p class="discussion-minimalist-title">{{ discussion.title }}</p>
|
||||
<span :title="discussion.updatedAt | formatDateTimeString">
|
||||
{{ $timeAgo.format(new Date(discussion.updatedAt), "twitter") || $t("Right now") }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="has-text-grey" v-if="!discussion.lastComment.deletedAt">
|
||||
{{ htmlTextEllipsis }}
|
||||
</div>
|
||||
<div v-else class="has-text-grey">{{ $t("[This comment has been deleted]") }}</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
@ -28,7 +39,7 @@ export default class DiscussionListItem extends Vue {
|
||||
|
||||
get htmlTextEllipsis() {
|
||||
const element = document.createElement("div");
|
||||
if (this.discussion.lastComment) {
|
||||
if (this.discussion.lastComment && this.discussion.lastComment.text) {
|
||||
element.innerHTML = this.discussion.lastComment.text
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<p>/gi, " ");
|
||||
@ -53,11 +64,17 @@ export default class DiscussionListItem extends Vue {
|
||||
.title-info-wrapper {
|
||||
flex: 2;
|
||||
|
||||
.discussion-minimalist-title {
|
||||
color: #3c376e;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
.title-and-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.discussion-minimalist-title {
|
||||
color: #3c376e;
|
||||
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial, serif;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
div.has-text-grey {
|
||||
|
@ -446,6 +446,7 @@ export default class EditorComponent extends Vue {
|
||||
|
||||
/** We use this to programatically insert an actor mention when creating a reply to comment */
|
||||
replyToComment(comment: IComment) {
|
||||
if (!comment.actor) return;
|
||||
const actorModel = new Actor(comment.actor);
|
||||
if (!this.editor) return;
|
||||
this.editor.commands.mention({
|
||||
|
@ -2,13 +2,9 @@
|
||||
<div class="media">
|
||||
<div class="media-content">
|
||||
<div class="content">
|
||||
<p>
|
||||
{{
|
||||
$t("You have been invited by {invitedBy} to the following group:", {
|
||||
invitedBy: member.invitedBy.name,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<i18n tag="p" path="You have been invited by {invitedBy} to the following group:">
|
||||
<b slot="invitedBy">{{ member.invitedBy.name }}</b>
|
||||
</i18n>
|
||||
</div>
|
||||
<div class="media subfield">
|
||||
<div class="media-left">
|
||||
@ -43,7 +39,7 @@
|
||||
</b-button>
|
||||
</div>
|
||||
<div class="level-item">
|
||||
<b-button type="is-danger" @click="$emit('decline', member.id)">
|
||||
<b-button type="is-danger" @click="$emit('reject', member.id)">
|
||||
{{ $t("Decline") }}
|
||||
</b-button>
|
||||
</div>
|
||||
|
50
js/src/components/Group/Invitations.vue
Normal file
50
js/src/components/Group/Invitations.vue
Normal file
@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<section v-if="invitations && invitations.length > 0">
|
||||
<InvitationCard
|
||||
v-for="member in invitations"
|
||||
:key="member.id"
|
||||
:member="member"
|
||||
@accept="acceptInvitation"
|
||||
@reject="rejectInvitation"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member";
|
||||
import { IMember } from "@/types/actor";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import InvitationCard from "@/components/Group/InvitationCard.vue";
|
||||
|
||||
@Component({
|
||||
components: {
|
||||
InvitationCard,
|
||||
},
|
||||
})
|
||||
export default class Invitations extends Vue {
|
||||
@Prop({ required: true, type: Array }) invitations!: IMember;
|
||||
|
||||
async acceptInvitation(id: string) {
|
||||
const { data } = await this.$apollo.mutate<{ acceptInvitation: IMember }>({
|
||||
mutation: ACCEPT_INVITATION,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
this.$emit("acceptInvitation", data.acceptInvitation);
|
||||
}
|
||||
}
|
||||
|
||||
async rejectInvitation(id: string) {
|
||||
const { data } = await this.$apollo.mutate<{ rejectInvitation: IMember }>({
|
||||
mutation: REJECT_INVITATION,
|
||||
variables: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
if (data) {
|
||||
this.$emit("rejectInvitation", data.rejectInvitation);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,7 +1,4 @@
|
||||
import gql from "graphql-tag";
|
||||
import { DISCUSSION_BASIC_FIELDS_FRAGMENT } from "@/graphql/discussion";
|
||||
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "@/graphql/resources";
|
||||
import { POST_BASIC_FIELDS } from "./post";
|
||||
|
||||
export const FETCH_PERSON = gql`
|
||||
query($username: String!) {
|
||||
@ -349,6 +346,13 @@ export const PERSON_MEMBERSHIPS = gql`
|
||||
url
|
||||
}
|
||||
}
|
||||
invitedBy {
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
}
|
||||
insertedAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -424,209 +428,6 @@ export const REGISTER_PERSON = gql`
|
||||
}
|
||||
`;
|
||||
|
||||
export const LIST_GROUPS = gql`
|
||||
query {
|
||||
groups {
|
||||
elements {
|
||||
id
|
||||
url
|
||||
name
|
||||
domain
|
||||
summary
|
||||
preferredUsername
|
||||
suspended
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
organizedEvents {
|
||||
elements {
|
||||
uuid
|
||||
title
|
||||
beginsOn
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_GROUP = gql`
|
||||
query($name: String!) {
|
||||
group(preferredUsername: $name) {
|
||||
id
|
||||
url
|
||||
name
|
||||
domain
|
||||
summary
|
||||
preferredUsername
|
||||
suspended
|
||||
visibility
|
||||
physicalAddress {
|
||||
description
|
||||
street
|
||||
locality
|
||||
postalCode
|
||||
region
|
||||
country
|
||||
geom
|
||||
type
|
||||
id
|
||||
originId
|
||||
}
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
organizedEvents {
|
||||
elements {
|
||||
id
|
||||
uuid
|
||||
title
|
||||
beginsOn
|
||||
}
|
||||
total
|
||||
}
|
||||
discussions {
|
||||
total
|
||||
elements {
|
||||
...DiscussionBasicFields
|
||||
}
|
||||
}
|
||||
posts {
|
||||
total
|
||||
elements {
|
||||
...PostBasicFields
|
||||
}
|
||||
}
|
||||
members {
|
||||
elements {
|
||||
role
|
||||
actor {
|
||||
id
|
||||
name
|
||||
domain
|
||||
preferredUsername
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
insertedAt
|
||||
}
|
||||
total
|
||||
}
|
||||
resources(page: 1, limit: 3) {
|
||||
elements {
|
||||
id
|
||||
title
|
||||
resourceUrl
|
||||
summary
|
||||
updatedAt
|
||||
type
|
||||
path
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
todoLists {
|
||||
elements {
|
||||
id
|
||||
title
|
||||
todos {
|
||||
elements {
|
||||
id
|
||||
title
|
||||
status
|
||||
dueDate
|
||||
assignedTo {
|
||||
id
|
||||
preferredUsername
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
}
|
||||
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
|
||||
${POST_BASIC_FIELDS}
|
||||
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CREATE_GROUP = gql`
|
||||
mutation CreateGroup(
|
||||
$creatorActorId: ID!
|
||||
$preferredUsername: String!
|
||||
$name: String!
|
||||
$summary: String
|
||||
$avatar: PictureInput
|
||||
$banner: PictureInput
|
||||
) {
|
||||
createGroup(
|
||||
creatorActorId: $creatorActorId
|
||||
preferredUsername: $preferredUsername
|
||||
name: $name
|
||||
summary: $summary
|
||||
banner: $banner
|
||||
avatar: $avatar
|
||||
) {
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
summary
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_GROUP = gql`
|
||||
mutation UpdateGroup(
|
||||
$id: ID!
|
||||
$name: String
|
||||
$summary: String
|
||||
$avatar: PictureInput
|
||||
$banner: PictureInput
|
||||
$visibility: GroupVisibility
|
||||
$physicalAddress: AddressInput
|
||||
) {
|
||||
updateGroup(
|
||||
id: $id
|
||||
name: $name
|
||||
summary: $summary
|
||||
banner: $banner
|
||||
avatar: $avatar
|
||||
visibility: $visibility
|
||||
physicalAddress: $physicalAddress
|
||||
) {
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
summary
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const SUSPEND_PROFILE = gql`
|
||||
mutation SuspendProfile($id: ID!) {
|
||||
suspendProfile(id: $id) {
|
||||
|
@ -86,8 +86,8 @@ export const CREATE_COMMENT_FROM_EVENT = gql`
|
||||
`;
|
||||
|
||||
export const DELETE_COMMENT = gql`
|
||||
mutation DeleteComment($commentId: ID!, $actorId: ID!) {
|
||||
deleteComment(commentId: $commentId, actorId: $actorId) {
|
||||
mutation DeleteComment($commentId: ID!) {
|
||||
deleteComment(commentId: $commentId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@ -99,4 +99,5 @@ export const UPDATE_COMMENT = gql`
|
||||
...CommentFields
|
||||
}
|
||||
}
|
||||
${COMMENT_FIELDS_FRAGMENT}
|
||||
`;
|
||||
|
@ -5,6 +5,7 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
|
||||
id
|
||||
title
|
||||
slug
|
||||
updatedAt
|
||||
lastComment {
|
||||
id
|
||||
text
|
||||
@ -15,6 +16,7 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
|
||||
url
|
||||
}
|
||||
}
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
`;
|
||||
@ -110,6 +112,7 @@ export const GET_DISCUSSION = gql`
|
||||
}
|
||||
insertedAt
|
||||
updatedAt
|
||||
deletedAt
|
||||
}
|
||||
}
|
||||
...DiscussionFields
|
||||
|
215
js/src/graphql/group.ts
Normal file
215
js/src/graphql/group.ts
Normal file
@ -0,0 +1,215 @@
|
||||
import gql from "graphql-tag";
|
||||
import { DISCUSSION_BASIC_FIELDS_FRAGMENT } from "./discussion";
|
||||
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "./resources";
|
||||
import { POST_BASIC_FIELDS } from "./post";
|
||||
|
||||
export const LIST_GROUPS = gql`
|
||||
query {
|
||||
groups {
|
||||
elements {
|
||||
id
|
||||
url
|
||||
name
|
||||
domain
|
||||
summary
|
||||
preferredUsername
|
||||
suspended
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
organizedEvents {
|
||||
elements {
|
||||
uuid
|
||||
title
|
||||
beginsOn
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const FETCH_GROUP = gql`
|
||||
query($name: String!) {
|
||||
group(preferredUsername: $name) {
|
||||
id
|
||||
url
|
||||
name
|
||||
domain
|
||||
summary
|
||||
preferredUsername
|
||||
suspended
|
||||
visibility
|
||||
physicalAddress {
|
||||
description
|
||||
street
|
||||
locality
|
||||
postalCode
|
||||
region
|
||||
country
|
||||
geom
|
||||
type
|
||||
id
|
||||
originId
|
||||
}
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
organizedEvents {
|
||||
elements {
|
||||
id
|
||||
uuid
|
||||
title
|
||||
beginsOn
|
||||
}
|
||||
total
|
||||
}
|
||||
discussions {
|
||||
total
|
||||
elements {
|
||||
...DiscussionBasicFields
|
||||
}
|
||||
}
|
||||
posts {
|
||||
total
|
||||
elements {
|
||||
...PostBasicFields
|
||||
}
|
||||
}
|
||||
members {
|
||||
elements {
|
||||
role
|
||||
actor {
|
||||
id
|
||||
name
|
||||
domain
|
||||
preferredUsername
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
insertedAt
|
||||
}
|
||||
total
|
||||
}
|
||||
resources(page: 1, limit: 3) {
|
||||
elements {
|
||||
id
|
||||
title
|
||||
resourceUrl
|
||||
summary
|
||||
updatedAt
|
||||
type
|
||||
path
|
||||
metadata {
|
||||
...ResourceMetadataBasicFields
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
todoLists {
|
||||
elements {
|
||||
id
|
||||
title
|
||||
todos {
|
||||
elements {
|
||||
id
|
||||
title
|
||||
status
|
||||
dueDate
|
||||
assignedTo {
|
||||
id
|
||||
preferredUsername
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
total
|
||||
}
|
||||
}
|
||||
}
|
||||
${DISCUSSION_BASIC_FIELDS_FRAGMENT}
|
||||
${POST_BASIC_FIELDS}
|
||||
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CREATE_GROUP = gql`
|
||||
mutation CreateGroup(
|
||||
$creatorActorId: ID!
|
||||
$preferredUsername: String!
|
||||
$name: String!
|
||||
$summary: String
|
||||
$avatar: PictureInput
|
||||
$banner: PictureInput
|
||||
) {
|
||||
createGroup(
|
||||
creatorActorId: $creatorActorId
|
||||
preferredUsername: $preferredUsername
|
||||
name: $name
|
||||
summary: $summary
|
||||
banner: $banner
|
||||
avatar: $avatar
|
||||
) {
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
summary
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UPDATE_GROUP = gql`
|
||||
mutation UpdateGroup(
|
||||
$id: ID!
|
||||
$name: String
|
||||
$summary: String
|
||||
$avatar: PictureInput
|
||||
$banner: PictureInput
|
||||
$visibility: GroupVisibility
|
||||
$physicalAddress: AddressInput
|
||||
) {
|
||||
updateGroup(
|
||||
id: $id
|
||||
name: $name
|
||||
summary: $summary
|
||||
banner: $banner
|
||||
avatar: $avatar
|
||||
visibility: $visibility
|
||||
physicalAddress: $physicalAddress
|
||||
) {
|
||||
id
|
||||
preferredUsername
|
||||
name
|
||||
summary
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
banner {
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const LEAVE_GROUP = gql`
|
||||
mutation LeaveGroup($groupId: ID!) {
|
||||
leaveGroup(groupId: $groupId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
@ -1,23 +1,52 @@
|
||||
import gql from "graphql-tag";
|
||||
|
||||
export const MEMBER_FRAGMENT = gql`
|
||||
fragment MemberFragment on Member {
|
||||
id
|
||||
role
|
||||
parent {
|
||||
id
|
||||
preferredUsername
|
||||
domain
|
||||
name
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
actor {
|
||||
id
|
||||
preferredUsername
|
||||
domain
|
||||
name
|
||||
avatar {
|
||||
url
|
||||
}
|
||||
}
|
||||
insertedAt
|
||||
}
|
||||
`;
|
||||
|
||||
export const INVITE_MEMBER = gql`
|
||||
mutation InviteMember($groupId: ID!, $targetActorUsername: String!) {
|
||||
inviteMember(groupId: $groupId, targetActorUsername: $targetActorUsername) {
|
||||
id
|
||||
role
|
||||
parent {
|
||||
id
|
||||
}
|
||||
actor {
|
||||
id
|
||||
}
|
||||
...MemberFragment
|
||||
}
|
||||
}
|
||||
${MEMBER_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const ACCEPT_INVITATION = gql`
|
||||
mutation AcceptInvitation($id: ID!) {
|
||||
acceptInvitation(id: $id) {
|
||||
...MemberFragment
|
||||
}
|
||||
}
|
||||
${MEMBER_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const REJECT_INVITATION = gql`
|
||||
mutation RejectInvitation($id: ID!) {
|
||||
rejectInvitation(id: $id) {
|
||||
id
|
||||
}
|
||||
}
|
||||
@ -33,6 +62,7 @@ export const GROUP_MEMBERS = gql`
|
||||
preferredUsername
|
||||
members(page: $page, limit: $limit, roles: $roles) {
|
||||
elements {
|
||||
id
|
||||
role
|
||||
actor {
|
||||
id
|
||||
@ -50,3 +80,11 @@ export const GROUP_MEMBERS = gql`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const REMOVE_MEMBER = gql`
|
||||
mutation RemoveMember($groupId: ID!, $memberId: ID!) {
|
||||
removeMember(groupId: $groupId, memberId: $memberId) {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
@ -756,5 +756,9 @@
|
||||
"No ongoing todos": "No ongoing todos",
|
||||
"No discussions yet": "No discussions yet",
|
||||
"Add / Remove…": "Add / Remove…",
|
||||
"No public posts": "No public posts"
|
||||
"No public posts": "No public posts",
|
||||
"You have been removed from this group's members.": "You have been removed from this group's members.",
|
||||
"Since you are a new member, private content can take a few minutes to appear.": "Since you are a new member, private content can take a few minutes to appear.",
|
||||
"Leave group": "Leave group",
|
||||
"Remove": "Remove"
|
||||
}
|
||||
|
@ -757,5 +757,9 @@
|
||||
"No ongoing todos": "Pas de todos en cours",
|
||||
"No discussions yet": "Pas encore de discussions",
|
||||
"Add / Remove…": "Ajouter / Supprimer…",
|
||||
"No public posts": "Pas de billets publics"
|
||||
"No public posts": "Pas de billets publics",
|
||||
"You have been removed from this group's members.": "Vous avez été exclu des membres de ce groupe.",
|
||||
"Since you are a new member, private content can take a few minutes to appear.": "Étant donné que vous êtes un·e nouveau·elle membre, le contenu privé peut mettre quelques minutes à arriver.",
|
||||
"Leave group": "Quitter le groupe",
|
||||
"Remove": "Exclure"
|
||||
}
|
||||
|
@ -33,6 +33,8 @@ export interface IMember {
|
||||
parent: IGroup;
|
||||
actor: IActor;
|
||||
invitedBy?: IPerson;
|
||||
insertedAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export class Group extends Actor implements IGroup {
|
||||
|
@ -7,7 +7,7 @@ export interface IComment {
|
||||
url?: string;
|
||||
text: string;
|
||||
local: boolean;
|
||||
actor: IActor;
|
||||
actor: IActor | null;
|
||||
inReplyToComment?: IComment;
|
||||
originComment?: IComment;
|
||||
replies: IComment[];
|
||||
@ -56,7 +56,7 @@ export class CommentModel implements IComment {
|
||||
this.text = hash.text;
|
||||
this.inReplyToComment = hash.inReplyToComment;
|
||||
this.originComment = hash.originComment;
|
||||
this.actor = new Actor(hash.actor);
|
||||
this.actor = hash.actor ? new Actor(hash.actor) : new Actor();
|
||||
this.event = new EventModel(hash.event);
|
||||
this.replies = hash.replies;
|
||||
this.updatedAt = hash.updatedAt;
|
||||
|
@ -19,7 +19,8 @@
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { IGroup, IPerson } from "@/types/actor";
|
||||
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "@/graphql/actor";
|
||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import { FETCH_GROUP } from "@/graphql/group";
|
||||
import { CREATE_DISCUSSION } from "@/graphql/discussion";
|
||||
import RouteName from "../../router/name";
|
||||
|
||||
|
@ -77,6 +77,8 @@
|
||||
v-for="comment in discussion.comments.elements"
|
||||
:key="comment.id"
|
||||
:comment="comment"
|
||||
@update-comment="updateComment"
|
||||
@delete-comment="deleteComment"
|
||||
/>
|
||||
<b-button
|
||||
v-if="discussion.comments.elements.length < discussion.comments.total"
|
||||
@ -87,7 +89,12 @@
|
||||
<b-field :label="$t('Text')">
|
||||
<editor v-model="newComment" />
|
||||
</b-field>
|
||||
<b-button native-type="submit" type="is-primary">{{ $t("Reply") }}</b-button>
|
||||
<b-button
|
||||
native-type="submit"
|
||||
:disabled="['<p></p>', ''].includes(newComment)"
|
||||
type="is-primary"
|
||||
>{{ $t("Reply") }}</b-button
|
||||
>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@ -107,6 +114,7 @@ import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
|
||||
import { GraphQLError } from "graphql";
|
||||
import RouteName from "../../router/name";
|
||||
import { IComment } from "../../types/comment.model";
|
||||
import { DELETE_COMMENT, UPDATE_COMMENT } from "@/graphql/comment";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -191,6 +199,8 @@ export default class discussion extends Vue {
|
||||
usernameWithDomain = usernameWithDomain;
|
||||
|
||||
async reply() {
|
||||
if (this.newComment === "") return;
|
||||
|
||||
await this.$apollo.mutate({
|
||||
mutation: REPLY_TO_DISCUSSION,
|
||||
variables: {
|
||||
@ -223,6 +233,80 @@ export default class discussion extends Vue {
|
||||
this.newComment = "";
|
||||
}
|
||||
|
||||
async updateComment(comment: IComment) {
|
||||
const { data } = await this.$apollo.mutate<{ deleteComment: IComment }>({
|
||||
mutation: UPDATE_COMMENT,
|
||||
variables: {
|
||||
commentId: comment.id,
|
||||
text: comment.text,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (!data || !data.deleteComment) return;
|
||||
const discussionData = store.readQuery<{
|
||||
discussion: IDiscussion;
|
||||
}>({
|
||||
query: GET_DISCUSSION,
|
||||
variables: {
|
||||
slug: this.slug,
|
||||
page: this.page,
|
||||
},
|
||||
});
|
||||
if (!discussionData) return;
|
||||
const { discussion } = discussionData;
|
||||
const index = discussion.comments.elements.findIndex(
|
||||
({ id }) => id === data.deleteComment.id
|
||||
);
|
||||
if (index > -1) {
|
||||
discussion.comments.elements.splice(index, 1);
|
||||
discussion.comments.total -= 1;
|
||||
}
|
||||
store.writeQuery({
|
||||
query: GET_DISCUSSION,
|
||||
variables: { slug: this.slug, page: this.page },
|
||||
data: { discussion },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteComment(comment: IComment) {
|
||||
const { data } = await this.$apollo.mutate<{ deleteComment: IComment }>({
|
||||
mutation: DELETE_COMMENT,
|
||||
variables: {
|
||||
commentId: comment.id,
|
||||
},
|
||||
update: (store, { data }) => {
|
||||
if (!data || !data.deleteComment) return;
|
||||
const discussionData = store.readQuery<{
|
||||
discussion: IDiscussion;
|
||||
}>({
|
||||
query: GET_DISCUSSION,
|
||||
variables: {
|
||||
slug: this.slug,
|
||||
page: this.page,
|
||||
},
|
||||
});
|
||||
if (!discussionData) return;
|
||||
const { discussion } = discussionData;
|
||||
const index = discussion.comments.elements.findIndex(
|
||||
({ id }) => id === data.deleteComment.id
|
||||
);
|
||||
if (index > -1) {
|
||||
const updatedComment = discussion.comments.elements[index];
|
||||
updatedComment.deletedAt = new Date();
|
||||
updatedComment.actor = null;
|
||||
updatedComment.text = "";
|
||||
discussion.comments.elements.splice(index, 1, updatedComment);
|
||||
}
|
||||
store.writeQuery({
|
||||
query: GET_DISCUSSION,
|
||||
variables: { slug: this.slug, page: this.page },
|
||||
data: { discussion },
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async loadMoreComments() {
|
||||
if (!this.hasMoreComments) return;
|
||||
this.page += 1;
|
||||
|
@ -46,7 +46,7 @@
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { FETCH_GROUP } from "@/graphql/actor";
|
||||
import { FETCH_GROUP } from "@/graphql/group";
|
||||
import { IGroup, usernameWithDomain } from "@/types/actor";
|
||||
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
|
||||
import RouteName from "../../router/name";
|
||||
@ -56,6 +56,7 @@ import RouteName from "../../router/name";
|
||||
apollo: {
|
||||
group: {
|
||||
query: FETCH_GROUP,
|
||||
fetchPolicy: "cache-and-network",
|
||||
variables() {
|
||||
return {
|
||||
name: this.preferredUsername,
|
||||
|
@ -33,7 +33,8 @@
|
||||
<script lang="ts">
|
||||
import { Component, Vue } from "vue-property-decorator";
|
||||
import { Group, IPerson } from "@/types/actor";
|
||||
import { CREATE_GROUP, CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
|
||||
import { CREATE_GROUP } from "@/graphql/group";
|
||||
import PictureUpload from "@/components/PictureUpload.vue";
|
||||
import RouteName from "../../router/name";
|
||||
|
||||
|
@ -19,6 +19,17 @@
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<invitations
|
||||
v-if="isCurrentActorAnInvitedGroupMember"
|
||||
:invitations="[groupMember]"
|
||||
@acceptInvitation="acceptInvitation"
|
||||
/>
|
||||
<b-message v-if="isCurrentActorARejectedGroupMember" type="is-danger">
|
||||
{{ $t("You have been removed from this group's members.") }}
|
||||
</b-message>
|
||||
<b-message v-if="isCurrentActorAGroupMember && isCurrentActorARecentMember" type="is-info">
|
||||
{{ $t("Since you are a new member, private content can take a few minutes to appear.") }}
|
||||
</b-message>
|
||||
<header class="block-container presentation">
|
||||
<div class="block-column media">
|
||||
<div class="media-left">
|
||||
@ -35,15 +46,24 @@
|
||||
>
|
||||
<b-skeleton v-else :animated="true" />
|
||||
<br />
|
||||
<router-link
|
||||
v-if="isCurrentActorAGroupAdmin"
|
||||
:to="{
|
||||
name: RouteName.GROUP_PUBLIC_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
class="button is-outlined"
|
||||
>{{ $t("Group settings") }}</router-link
|
||||
>
|
||||
<div class="buttons">
|
||||
<router-link
|
||||
v-if="isCurrentActorAGroupAdmin"
|
||||
:to="{
|
||||
name: RouteName.GROUP_PUBLIC_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
}"
|
||||
class="button is-outlined"
|
||||
>{{ $t("Group settings") }}</router-link
|
||||
>
|
||||
<b-button
|
||||
type="is-danger"
|
||||
v-if="isCurrentActorAGroupMember"
|
||||
outlined
|
||||
@click="leaveGroup"
|
||||
>{{ $t("Leave group") }}</b-button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-column members" v-if="isCurrentActorAGroupMember">
|
||||
@ -56,7 +76,7 @@
|
||||
role: member.role,
|
||||
})
|
||||
"
|
||||
v-for="member in group.members.elements"
|
||||
v-for="member in members"
|
||||
:key="member.actor.id"
|
||||
>
|
||||
<img
|
||||
@ -71,6 +91,7 @@
|
||||
<p>
|
||||
{{ $t("{count} team members", { count: group.members.total }) }}
|
||||
<router-link
|
||||
v-if="isCurrentActorAGroupAdmin"
|
||||
:to="{
|
||||
name: RouteName.GROUP_MEMBERS_SETTINGS,
|
||||
params: { preferredUsername: usernameWithDomain(group) },
|
||||
@ -255,7 +276,8 @@
|
||||
<script lang="ts">
|
||||
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
|
||||
import EventCard from "@/components/Event/EventCard.vue";
|
||||
import { FETCH_GROUP, CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
|
||||
import { FETCH_GROUP, LEAVE_GROUP } from "@/graphql/group";
|
||||
import {
|
||||
IActor,
|
||||
IGroup,
|
||||
@ -263,6 +285,7 @@ import {
|
||||
usernameWithDomain,
|
||||
Group as GroupModel,
|
||||
MemberRole,
|
||||
IMember,
|
||||
} from "@/types/actor";
|
||||
import Subtitle from "@/components/Utils/Subtitle.vue";
|
||||
import CompactTodo from "@/components/Todo/CompactTodo.vue";
|
||||
@ -274,6 +297,8 @@ import FolderItem from "@/components/Resource/FolderItem.vue";
|
||||
import RouteName from "../../router/name";
|
||||
import { Address } from "@/types/address.model";
|
||||
import GroupSection from "../../components/Group/GroupSection.vue";
|
||||
import Invitations from "@/components/Group/Invitations.vue";
|
||||
import addMinutes from "date-fns/addMinutes";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
@ -308,6 +333,7 @@ import GroupSection from "../../components/Group/GroupSection.vue";
|
||||
FolderItem,
|
||||
ResourceItem,
|
||||
GroupSection,
|
||||
Invitations,
|
||||
"map-leaflet": () => import(/* webpackChunkName: "map" */ "../../components/Map.vue"),
|
||||
},
|
||||
metaInfo() {
|
||||
@ -348,6 +374,29 @@ export default class Group extends Vue {
|
||||
}
|
||||
}
|
||||
|
||||
async leaveGroup() {
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: LEAVE_GROUP,
|
||||
variables: {
|
||||
groupId: this.group.id,
|
||||
},
|
||||
});
|
||||
return this.$router.push({ name: RouteName.MY_GROUPS });
|
||||
}
|
||||
|
||||
acceptInvitation() {
|
||||
if (this.groupMember) {
|
||||
const index = this.person.memberships.elements.findIndex(
|
||||
// @ts-ignore
|
||||
({ id }: IMember) => id === this.groupMember.id
|
||||
);
|
||||
const member = this.groupMember;
|
||||
member.role = MemberRole.MEMBER;
|
||||
this.person.memberships.elements.splice(index, 1, member);
|
||||
this.$apollo.queries.group.refetch();
|
||||
}
|
||||
}
|
||||
|
||||
get groupTitle() {
|
||||
if (!this.group) return undefined;
|
||||
return this.group.preferredUsername;
|
||||
@ -358,15 +407,47 @@ export default class Group extends Vue {
|
||||
return this.group.summary;
|
||||
}
|
||||
|
||||
get groupMember(): IMember | undefined {
|
||||
if (!this.person || !this.person.id) return undefined;
|
||||
return this.person.memberships.elements.find(({ parent: { id } }) => id === this.group.id);
|
||||
}
|
||||
|
||||
get groupMemberships() {
|
||||
if (!this.person || !this.person.id) return undefined;
|
||||
return this.person.memberships.elements.map(({ parent: { id } }) => id);
|
||||
return this.person.memberships.elements
|
||||
.filter(
|
||||
(membership: IMember) =>
|
||||
![MemberRole.REJECTED, MemberRole.NOT_APPROVED, MemberRole.INVITED].includes(
|
||||
membership.role
|
||||
)
|
||||
)
|
||||
.map(({ parent: { id } }) => id);
|
||||
}
|
||||
|
||||
get isCurrentActorAGroupMember(): boolean {
|
||||
return this.groupMemberships != undefined && this.groupMemberships.includes(this.group.id);
|
||||
}
|
||||
|
||||
get isCurrentActorARejectedGroupMember(): boolean {
|
||||
return (
|
||||
this.person &&
|
||||
this.person.memberships.elements
|
||||
.filter((membership) => membership.role === MemberRole.REJECTED)
|
||||
.map(({ parent: { id } }) => id)
|
||||
.includes(this.group.id)
|
||||
);
|
||||
}
|
||||
|
||||
get isCurrentActorAnInvitedGroupMember(): boolean {
|
||||
return (
|
||||
this.person &&
|
||||
this.person.memberships.elements
|
||||
.filter((membership) => membership.role === MemberRole.INVITED)
|
||||
.map(({ parent: { id } }) => id)
|
||||
.includes(this.group.id)
|
||||
);
|
||||
}
|
||||
|
||||
get isCurrentActorAGroupAdmin(): boolean {
|
||||
return (
|
||||
this.person &&
|
||||
@ -376,6 +457,24 @@ export default class Group extends Vue {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* New members, if on a different server, can take a while to refresh the group and fetch all private data
|
||||
*/
|
||||
get isCurrentActorARecentMember(): boolean {
|
||||
return (
|
||||
this.groupMember !== undefined &&
|
||||
this.groupMember.role === MemberRole.MEMBER &&
|
||||
addMinutes(new Date(`${this.groupMember.updatedAt}Z`), 10) > new Date()
|
||||