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:
Thomas Citharel
2020-08-14 11:32:23 +02:00
parent ad13a57afc
commit 156eba0551
94 changed files with 2650 additions and 1862 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,6 +33,8 @@ export interface IMember {
parent: IGroup;
actor: IActor;
invitedBy?: IPerson;
insertedAt: string;
updatedAt: string;
}
export class Group extends Actor implements IGroup {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()
);
}
get members(): IMember[] {
return this.group.members.elements.filter(
(member) =>
![MemberRole.INVITED, MemberRole.REJECTED, MemberRole.NOT_APPROVED].includes(member.role)
);
}
get physicalAddress(): Address | null {
if (!this.group.physicalAddress) return null;
return new Address(this.group.physicalAddress);

View File

@@ -18,7 +18,7 @@
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { LIST_GROUPS } from "@/graphql/actor";
import { LIST_GROUPS } from "@/graphql/group";
import { Group, IGroup } from "@/types/actor";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import RouteName from "../../router/name";

View File

@@ -1,7 +1,7 @@
<template>
<div>
<nav class="breadcrumb" aria-label="breadcrumbs">
<ul>
<ul v-if="group">
<li>
<router-link
:to="{
@@ -134,6 +134,14 @@
}}
</span>
</b-table-column>
<b-table-column field="actions" :label="$t('Actions')">
<b-button
v-if="props.row.role === MemberRole.MEMBER"
@click="removeMember(props.row.id)"
type="is-danger"
>{{ $t("Remove") }}</b-button
>
</b-table-column>
</template>
<template slot="empty">
<section class="section">
@@ -150,7 +158,7 @@
<script lang="ts">
import { Component, Vue, Watch } from "vue-property-decorator";
import RouteName from "../../router/name";
import { INVITE_MEMBER, GROUP_MEMBERS } from "../../graphql/member";
import { INVITE_MEMBER, GROUP_MEMBERS, REMOVE_MEMBER } from "../../graphql/member";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { IMember, MemberRole } from "../../types/actor/group.model";
@@ -206,7 +214,32 @@ export default class GroupMembers extends Vue {
groupId: this.group.id,
targetActorUsername: this.newMemberUsername,
},
update: (store, { data }) => {
if (data == null) return;
const query = {
query: GROUP_MEMBERS,
variables: {
name: this.$route.params.preferredUsername,
page: 1,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
},
};
const memberData: IMember = data.inviteMember;
const groupData = store.readQuery<{ group: IGroup }>(query);
if (!groupData) return;
const { group } = groupData;
const index = group.members.elements.findIndex((m) => m.actor.id === memberData.actor.id);
if (index === -1) {
group.members.elements.push(memberData);
group.members.total += 1;
} else {
group.members.elements.splice(index, 1, memberData);
}
store.writeQuery({ ...query, data: { group } });
},
});
this.newMemberUsername = "";
}
@Watch("page")
@@ -235,5 +268,36 @@ export default class GroupMembers extends Vue {
},
});
}
async removeMember(memberId: string) {
await this.$apollo.mutate<{ removeMember: IMember }>({
mutation: REMOVE_MEMBER,
variables: {
groupId: this.group.id,
memberId,
},
update: (store, { data }) => {
if (data == null) return;
const query = {
query: GROUP_MEMBERS,
variables: {
name: this.$route.params.preferredUsername,
page: 1,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
},
};
const groupData = store.readQuery<{ group: IGroup }>(query);
if (!groupData) return;
const { group } = groupData;
const index = group.members.elements.findIndex((m) => m.id === memberId);
if (index !== -1) {
group.members.elements.splice(index, 1);
group.members.total -= 1;
store.writeQuery({ ...query, data: { group } });
}
},
});
}
}
</script>

View File

@@ -100,7 +100,7 @@
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/actor";
import { FETCH_GROUP, UPDATE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { IMember, Group } from "../../types/actor/group.model";

View File

@@ -3,14 +3,11 @@
<h1 class="title">{{ $t("My groups") }}</h1>
<router-link :to="{ name: RouteName.CREATE_GROUP }">{{ $t("Create group") }}</router-link>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="invitations && invitations.length > 0">
<InvitationCard
v-for="member in invitations"
:key="member.id"
:member="member"
@accept="acceptInvitation"
/>
</section>
<invitations
:invitations="invitations"
@acceptInvitation="acceptInvitation"
@rejectInvitation="rejectInvitation"
/>
<section v-if="memberships && memberships.length > 0">
<GroupMemberCard v-for="member in memberships" :key="member.id" :member="member" />
</section>
@@ -24,16 +21,16 @@
import { Component, Vue } from "vue-property-decorator";
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import InvitationCard from "@/components/Group/InvitationCard.vue";
import Invitations from "@/components/Group/Invitations.vue";
import { Paginate } from "@/types/paginate";
import { IGroup, IMember, MemberRole } from "@/types/actor";
import { IGroup, IMember, MemberRole, usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name";
import { ACCEPT_INVITATION } from "../../graphql/member";
@Component({
components: {
GroupMemberCard,
InvitationCard,
Invitations,
},
apollo: {
membershipsPages: {
@@ -61,6 +58,23 @@ export default class MyEvents extends Vue {
RouteName = RouteName;
acceptInvitation(member: IMember) {
return this.$router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
}
rejectInvitation({ id: memberId }: { id: string }) {
const index = this.membershipsPages.elements.findIndex(
(membership) => membership.role === MemberRole.INVITED && membership.id === memberId
);
if (index > -1) {
this.membershipsPages.elements.splice(index, 1);
this.membershipsPages.total -= 1;
}
}
get invitations() {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
@@ -74,15 +88,6 @@ export default class MyEvents extends Vue {
(member: IMember) => member.role !== MemberRole.INVITED
);
}
async acceptInvitation(id: string) {
await this.$apollo.mutate<{ acceptInvitation: IMember }>({
mutation: ACCEPT_INVITATION,
variables: {
id,
},
});
}
}
</script>

View File

@@ -26,7 +26,7 @@
import { Component, Vue, Watch } from "vue-property-decorator";
import { Route } from "vue-router";
import { IGroup, IPerson } from "@/types/actor";
import { FETCH_GROUP } from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import RouteName from "../../router/name";
import SettingMenuSection from "../../components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "../../components/Settings/SettingMenuItem.vue";

View File

@@ -79,7 +79,8 @@
</template>
<script lang="ts">
import { Component, Vue, Prop } from "vue-property-decorator";
import { CURRENT_ACTOR_CLIENT, FETCH_GROUP } from "../../graphql/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST, UPDATE_POST, DELETE_POST } from "../../graphql/post";

View File

@@ -42,7 +42,8 @@
import { Component, Vue, Prop } from "vue-property-decorator";
import Editor from "@/components/Editor.vue";
import { GraphQLError } from "graphql";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS, FETCH_GROUP } from "../../graphql/actor";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "../../graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import { TAGS } from "../../graphql/tags";
import { CONFIG } from "../../graphql/config";
import { FETCH_POST, CREATE_POST } from "../../graphql/post";

View File

@@ -31,7 +31,7 @@
name: RouteName.RESOURCE_FOLDER,
params: {
path: ResourceMixin.resourcePathArray(resource).slice(0, index + 1),
preferredUsername: resource.actor.preferredUsername,
preferredUsername: usernameWithDomain(resource.actor),
},
}"
>{{ pathFragment }}</router-link

View File

@@ -44,7 +44,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 } from "@/types/actor";
import { ITodoList } from "@/types/todos";
import { CREATE_TODO_LIST } from "@/graphql/todos";