Migrate to Vue 3 and Vite

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2022-07-12 10:55:28 +02:00
parent 8f4099ee33
commit ee20e03cc2
464 changed files with 31515 additions and 32758 deletions

View File

@@ -1,31 +1,35 @@
<template>
<section class="section container">
<h1 class="title">{{ $t("Create a new group") }}</h1>
<section class="container mx-auto">
<h1>{{ $t("Create a new group") }}</h1>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
<o-notification
variant="danger"
v-for="(value, index) in errors"
:key="index"
>
{{ value }}
</b-message>
</o-notification>
<form @submit.prevent="createGroup">
<b-field :label="$t('Group display name')" label-for="group-display-name">
<b-input
<o-field :label="$t('Group display name')" label-for="group-display-name">
<o-input
aria-required="true"
required
v-model="group.name"
id="group-display-name"
/>
</b-field>
</o-field>
<div class="field">
<label class="label" for="group-preferred-username">{{
$t("Federated Group Name")
}}</label>
<div class="field-body">
<b-field
<o-field
:message="preferredUsernameErrors[0]"
:type="preferredUsernameErrors[1]"
>
<b-input
<o-input
ref="preferredUsernameInput"
aria-required="true"
required
@@ -45,26 +49,28 @@
<p class="control">
<span class="button is-static">@{{ host }}</span>
</p>
</b-field>
</o-field>
</div>
<p
v-html="
$t(
'This is like your federated username (<code>{username}</code>) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique.',
{ username: usernameWithDomain(currentActor, true) }
)
"
/>
<i18n-t
v-if="currentActor"
keypath="This is like your federated username ({username}) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique."
>
<template #username>
<code>
{{ usernameWithDomain(currentActor, true) }}
</code>
</template>
</i18n-t>
</div>
<b-field
<o-field
:label="$t('Description')"
label-for="group-summary"
:message="summaryErrors[0]"
:type="summaryErrors[1]"
>
<b-input v-model="group.summary" type="textarea" id="group-summary" />
</b-field>
<o-input v-model="group.summary" type="textarea" id="group-summary" />
</o-field>
<div>
<b>{{ $t("Avatar") }}</b>
@@ -91,198 +97,163 @@
</section>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import { Group, IPerson, usernameWithDomain } from "@/types/actor";
import { CURRENT_ACTOR_CLIENT, PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { CREATE_GROUP } from "@/graphql/group";
import { mixins } from "vue-class-component";
import IdentityEditionMixin from "@/mixins/identityEdition";
import { MemberRole } from "@/types/enums";
<script lang="ts" setup>
import { Group, usernameWithDomain, displayName } from "@/types/actor";
import RouteName from "../../router/name";
import { convertToUsername } from "../../utils/username";
import PictureUpload from "../../components/PictureUpload.vue";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { ErrorResponse } from "@/types/errors.model";
import { ServerParseError } from "@apollo/client/link/http";
import { ApolloCache, FetchResult, InMemoryCache } from "@apollo/client/core";
import { useCurrentActorClient } from "@/composition/apollo/actor";
import { useUploadLimits } from "@/composition/apollo/config";
import { computed, inject, reactive, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { useI18n } from "vue-i18n";
import { useCreateGroup } from "@/composition/apollo/group";
import {
useAvatarMaxSize,
useBannerMaxSize,
useHost,
} from "@/composition/config";
import { Notifier } from "@/plugins/notifier";
@Component({
components: {
PictureUpload,
},
apollo: {
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
config: CONFIG,
},
metaInfo() {
return {
title: this.$t("Create a new group") as string,
};
},
})
export default class CreateGroup extends mixins(IdentityEditionMixin) {
currentActor!: IPerson;
const { currentActor } = useCurrentActorClient();
const { uploadLimits } = useUploadLimits();
group = new Group();
const { t } = useI18n({ useScope: "global" });
config!: IConfig;
useHead({
title: computed(() => t("Create a new group")),
});
avatarFile: File | null = null;
const group = ref(new Group());
bannerFile: File | null = null;
const avatarFile = ref<File | null>(null);
const bannerFile = ref<File | null>(null);
errors: string[] = [];
const errors = ref<string[]>([]);
fieldErrors: Record<string, string | undefined> = {
preferred_username: undefined,
summary: undefined,
const fieldErrors = reactive<Record<string, string | undefined>>({
preferred_username: undefined,
summary: undefined,
});
const router = useRouter();
const host = useHost();
const avatarMaxSize = useAvatarMaxSize();
const bannerMaxSize = useBannerMaxSize();
const notifier = inject<Notifier>("notifier");
const createGroup = async (): Promise<void> => {
errors.value = [];
fieldErrors.preferred_username = undefined;
fieldErrors.summary = undefined;
const variables = buildVariables();
const { onDone, onError } = useCreateGroup(variables);
onDone(() => {
notifier?.success(
t("Group {displayName} created", {
displayName: displayName(group),
})
);
router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(group.value) },
});
});
onError((err) => handleError(err as unknown as ErrorResponse));
};
watch(group, (newGroup) => {
group.value.preferredUsername = convertToUsername(newGroup.name);
});
const buildVariables = () => {
let avatarObj = {};
let bannerObj = {};
const groupBasic = {
preferredUsername: group.value.preferredUsername,
name: group.value.name,
summary: group.value.summary,
};
usernameWithDomain = usernameWithDomain;
async createGroup(): Promise<void> {
try {
this.errors = [];
this.fieldErrors = { preferred_username: undefined, summary: undefined };
await this.$apollo.mutate({
mutation: CREATE_GROUP,
variables: this.buildVariables(),
update: (store: ApolloCache<InMemoryCache>, { data }: FetchResult) => {
const query = {
query: PERSON_MEMBERSHIPS,
variables: {
id: this.currentActor.id,
},
};
const membershipData = store.readQuery<{ person: IPerson }>(query);
if (!membershipData) return;
const { person } = membershipData;
person.memberships.elements.push({
parent: data?.createGroup,
role: MemberRole.ADMINISTRATOR,
actor: this.currentActor,
insertedAt: new Date().toString(),
updatedAt: new Date().toString(),
});
store.writeQuery({ ...query, data: { person } });
if (avatarFile.value) {
avatarObj = {
avatar: {
media: {
name: avatarFile.value?.name,
alt: `${group.value.preferredUsername}'s avatar`,
file: avatarFile.value,
},
});
await this.$router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(this.group) },
});
this.$notifier.success(
this.$t("Group {displayName} created", {
displayName: this.group.displayName(),
}) as string
);
} catch (err: any) {
this.handleError(err);
}
}
// eslint-disable-next-line class-methods-use-this
get host(): string {
return window.location.hostname;
}
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
get bannerMaxSize(): number | undefined {
return this?.config?.uploadLimits?.banner;
}
@Watch("group.name")
updateUsername(groupName: string): void {
this.group.preferredUsername = convertToUsername(groupName);
}
private buildVariables() {
let avatarObj = {};
let bannerObj = {};
if (this.avatarFile) {
avatarObj = {
avatar: {
media: {
name: this.avatarFile.name,
alt: `${this.group.preferredUsername}'s avatar`,
file: this.avatarFile,
},
},
};
}
if (this.bannerFile) {
bannerObj = {
banner: {
media: {
name: this.bannerFile.name,
alt: `${this.group.preferredUsername}'s banner`,
file: this.bannerFile,
},
},
};
}
return {
...this.group,
...avatarObj,
...bannerObj,
},
};
}
private handleError(err: ErrorResponse) {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (bannerFile.value) {
bannerObj = {
banner: {
media: {
name: bannerFile.value?.name,
alt: `${group.value.preferredUsername}'s banner`,
file: bannerFile.value,
},
},
};
}
if (error?.response?.status === 413) {
this.errors.push(
this.$t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
return {
...groupBasic,
...avatarObj,
...bannerObj,
};
};
const handleError = (err: ErrorResponse) => {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
errors.value.push(
t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
err.graphQLErrors?.forEach((error) => {
if (error.field) {
if (Array.isArray(error.message)) {
this.fieldErrors[error.field] = error.message[0];
} else {
this.fieldErrors[error.field] = error.message;
}
}
err.graphQLErrors?.forEach((error) => {
if (error.field) {
if (Array.isArray(error.message)) {
fieldErrors[error.field] = error.message[0];
} else {
this.errors.push(error.message);
fieldErrors[error.field] = error.message;
}
});
}
} else {
errors.value.push(error.message);
}
});
};
get summaryErrors() {
const message = this.fieldErrors.summary
? this.fieldErrors.summary
: undefined;
const type = this.fieldErrors.summary ? "is-danger" : undefined;
return [message, type];
}
const summaryErrors = computed(() => {
const message = fieldErrors.summary ? fieldErrors.summary : undefined;
const type = fieldErrors.summary ? "is-danger" : undefined;
return [message, type];
});
get preferredUsernameErrors() {
const message = this.fieldErrors.preferred_username
? this.fieldErrors.preferred_username
: this.$t(
"Only alphanumeric lowercased characters and underscores are supported."
);
const type = this.fieldErrors.preferred_username ? "is-danger" : undefined;
return [message, type];
}
}
const preferredUsernameErrors = computed(() => {
const message = fieldErrors.preferred_username
? fieldErrors.preferred_username
: t(
"Only alphanumeric lowercased characters and underscores are supported."
);
const type = fieldErrors.preferred_username ? "is-danger" : undefined;
return [message, type];
});
</script>
<style>

File diff suppressed because it is too large Load Diff

View File

@@ -20,22 +20,22 @@
},
]"
/>
<b-loading :active="$apollo.loading" />
<o-loading :active="loading" />
<section
class="container section"
class="container mx-auto section"
v-if="group && isCurrentActorAGroupAdmin && followers"
>
<h1>{{ $t("Group Followers") }} ({{ followers.total }})</h1>
<b-field :label="$t('Status')" horizontal>
<b-switch v-model="pending">{{ $t("Pending") }}</b-switch>
</b-field>
<b-table
<o-field :label="$t('Status')" horizontal>
<o-switch v-model="pending">{{ $t("Pending") }}</o-switch>
</o-field>
<o-table
:data="followers.elements"
ref="queueTable"
:loading="this.$apollo.loading"
:loading="loading"
paginated
backend-pagination
:current-page.sync="page"
v-model:current-page="page"
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
@@ -46,204 +46,193 @@
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="triggerLoadMoreFollowersPageChange"
@page-change="loadMoreFollowers"
@sort="(field, order) => $emit('sort', field, order)"
>
<b-table-column
<o-table-column
field="actor.preferredUsername"
:label="$t('Follower')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<article class="flex gap-1">
<figure v-if="props.row.actor.avatar">
<img
class="is-rounded"
class="rounded"
:src="props.row.actor.avatar.url"
alt=""
width="48"
height="48"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<AccountCircle v-else :size="48" />
<div class="">
<div class="">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><br />
<span class="is-size-7 has-text-grey-dark"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
<span class="">@{{ usernameWithDomain(props.row.actor) }}</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
</o-table-column>
<o-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
<b-table-column field="actions" :label="$t('Actions')" v-slot="props">
</o-table-column>
<o-table-column field="actions" :label="$t('Actions')" v-slot="props">
<div class="buttons">
<b-button
<o-button
v-if="!props.row.approved"
@click="updateFollower(props.row, true)"
icon-left="check"
type="is-success"
>{{ $t("Accept") }}</b-button
variant="success"
>{{ $t("Accept") }}</o-button
>
<b-button
<o-button
@click="updateFollower(props.row, false)"
icon-left="close"
type="is-danger"
>{{ $t("Reject") }}</b-button
variant="danger"
>{{ $t("Reject") }}</o-button
>
</div>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account" inline>
{{ $t("No follower matches the filters") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<b-message v-else-if="!$apollo.loading && group">
<o-notification v-else-if="!loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component";
<script lang="ts" setup>
import { GROUP_FOLLOWERS, UPDATE_FOLLOWER } from "@/graphql/followers";
import RouteName from "../../router/name";
import { displayName, usernameWithDomain } from "../../types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { IFollower } from "@/types/actor/follower.model";
import { Paginate } from "@/types/paginate";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
booleanTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { computed, inject } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { usePersonStatusGroup } from "@/composition/apollo/actor";
import { MemberRole } from "@/types/enums";
import { formatTimeString, formatDateString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
followers: {
query: GROUP_FOLLOWERS,
variables() {
return {
name: this.$route.params.preferredUsername,
followersPage: this.page,
followersLimit: this.FOLLOWERS_PER_PAGE,
approved: this.pending === null ? null : !this.pending,
};
},
update: (data) => data.group.followers,
const props = defineProps<{ preferredUsername: string }>();
const page = useRouteQuery("page", 1, integerTransformer);
const pending = useRouteQuery("pending", false, booleanTransformer);
const FOLLOWERS_PER_PAGE = 10;
const {
result: followersResult,
fetchMore,
loading,
} = useQuery(GROUP_FOLLOWERS, () => ({
name: props.preferredUsername,
followersPage: page.value,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending.value,
}));
const group = computed(() => followersResult.value?.group);
const followers = computed(
() => group.value?.followers ?? { total: 0, elements: [] }
);
const { t } = useI18n({ useScope: "global" });
useHead({ title: computed(() => t("Group Followers")) });
const loadMoreFollowers = async (): Promise<void> => {
await fetchMore({
// New variables
variables: {
name: usernameWithDomain(group.value),
followersPage: page.value,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending.value,
},
},
components: {
EmptyContent,
},
metaInfo() {
return {
title: this.$t("Group Followers") as string,
};
},
})
export default class GroupFollowers extends mixins(GroupMixin) {
loading = true;
});
};
RouteName = RouteName;
const notifier = inject<Notifier>("notifier");
page = parseInt((this.$route.query.page as string) || "1", 10);
pending: boolean | null =
(this.$route.query.pending as string) == "1" || null;
FOLLOWERS_PER_PAGE = 10;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
followers!: Paginate<IFollower>;
mounted(): void {
this.page = parseInt((this.$route.query.page as string) || "1", 10);
}
@Watch("page")
triggerLoadMoreFollowersPageChange(page: string): void {
this.$router.replace({
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
query: { ...this.$route.query, page },
});
}
@Watch("pending")
triggerPendingStatusPageChange(pending: boolean): void {
this.$router.replace({
name: RouteName.GROUP_FOLLOWERS_SETTINGS,
query: { ...this.$route.query, ...{ pending: pending ? "1" : "0" } },
});
}
async loadMoreFollowers(): Promise<void> {
const { FOLLOWERS_PER_PAGE, group, page, pending } = this;
await this.$apollo.queries.followers.fetchMore({
// New variables
variables() {
return {
name: usernameWithDomain(group),
followersPage: page,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending,
};
const { onDone, onError, mutate } = useMutation<{ updateFollower: IFollower }>(
UPDATE_FOLLOWER,
() => ({
refetchQueries: [
{
query: GROUP_FOLLOWERS,
},
});
}
],
})
);
async updateFollower(follower: IFollower, approved: boolean): Promise<void> {
const { FOLLOWERS_PER_PAGE, group, page, pending } = this;
try {
await this.$apollo.mutate<{ rejectFollower: IFollower }>({
mutation: UPDATE_FOLLOWER,
variables: {
id: follower.id,
approved,
},
refetchQueries: [
{
query: GROUP_FOLLOWERS,
variables: {
name: usernameWithDomain(group),
followersPage: page,
followersLimit: FOLLOWERS_PER_PAGE,
approved: !pending,
},
},
],
});
const message = approved
? this.$t("@{username}'s follow request was accepted", {
username: follower.actor.preferredUsername,
})
: this.$t("@{username}'s follow request was rejected", {
username: follower.actor.preferredUsername,
});
this.$notifier.success(message as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
onDone(({ data }) => {
const follower = data?.updateFollower;
const message =
data?.updateFollower.approved === true
? t("@{username}'s follow request was accepted", {
username: follower?.actor.preferredUsername,
})
: t("@{username}'s follow request was rejected", {
username: follower?.actor.preferredUsername,
});
notifier?.success(message);
});
onError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
}
});
const updateFollower = async (
follower: IFollower,
approved: boolean
): Promise<void> => {
mutate({
id: follower.id,
approved,
});
};
const isCurrentActorAGroupAdmin = computed((): boolean => {
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const { person } = usePersonStatusGroup(props.preferredUsername);
</script>

View File

@@ -11,194 +11,190 @@
{
name: RouteName.GROUP_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Settings'),
text: t('Settings'),
},
{
name: RouteName.GROUP_MEMBERS_SETTINGS,
params: { preferredUsername: usernameWithDomain(group) },
text: $t('Members'),
text: t('Members'),
},
]"
/>
<b-loading :active="$apollo.loading" />
<o-loading :active="groupMembersLoading" />
<section
class="container section"
class="container mx-auto section"
v-if="group && isCurrentActorAGroupAdmin"
>
<form @submit.prevent="inviteMember">
<b-field
:label="$t('Invite a new member')"
<o-field
:label="t('Invite a new member')"
custom-class="add-relay"
label-for="new-member-field"
horizontal
>
<b-field
<o-field
grouped
expanded
size="is-large"
size="large"
:type="inviteError ? 'is-danger' : null"
:message="inviteError"
>
<p class="control">
<b-input
<o-input
id="new-member-field"
v-model="newMemberUsername"
:placeholder="$t('Ex: someone@mobilizon.org')"
:placeholder="t(`Ex: someone{'@'}mobilizon.org`)"
/>
</p>
<p class="control">
<b-button type="is-primary" native-type="submit">{{
$t("Invite member")
}}</b-button>
<o-button variant="primary" native-type="submit">{{
t("Invite member")
}}</o-button>
</p>
</b-field>
</b-field>
</o-field>
</o-field>
</form>
<h1>{{ $t("Group Members") }} ({{ group.members.total }})</h1>
<b-field
:label="$t('Status')"
<h1>{{ t("Group Members") }} ({{ group.members.total }})</h1>
<o-field
:label="t('Status')"
horizontal
label-for="group-members-status-filter"
>
<b-select v-model="roles" id="group-members-status-filter">
<o-select v-model="roles" id="group-members-status-filter">
<option value="">
{{ $t("Everything") }}
{{ t("Everything") }}
</option>
<option :value="MemberRole.ADMINISTRATOR">
{{ $t("Administrator") }}
{{ t("Administrator") }}
</option>
<option :value="MemberRole.MODERATOR">
{{ $t("Moderator") }}
{{ t("Moderator") }}
</option>
<option :value="MemberRole.MEMBER">
{{ $t("Member") }}
{{ t("Member") }}
</option>
<option :value="MemberRole.INVITED">
{{ $t("Invited") }}
{{ t("Invited") }}
</option>
<option :value="MemberRole.NOT_APPROVED">
{{ $t("Not approved") }}
{{ t("Not approved") }}
</option>
<option :value="MemberRole.REJECTED">
{{ $t("Rejected") }}
{{ t("Rejected") }}
</option>
</b-select>
</b-field>
<b-table
</o-select>
</o-field>
<o-table
v-if="members"
:data="members.elements"
ref="queueTable"
:loading="this.$apollo.loading"
:loading="groupMembersLoading"
paginated
backend-pagination
:current-page.sync="page"
v-model:current-page="page"
:pagination-simple="true"
:aria-next-label="$t('Next page')"
:aria-previous-label="$t('Previous page')"
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
:aria-next-label="t('Next page')"
:aria-previous-label="t('Previous page')"
:aria-page-label="t('Page')"
:aria-current-label="t('Current page')"
:total="members.total"
:per-page="MEMBERS_PER_PAGE"
backend-sorting
:default-sort-direction="'desc'"
:default-sort="['insertedAt', 'desc']"
@page-change="triggerLoadMoreMemberPageChange"
@page-change="loadMoreMembers"
@sort="(field, order) => $emit('sort', field, order)"
>
<b-table-column
<o-table-column
field="actor.preferredUsername"
:label="$t('Member')"
:label="t('Member')"
v-slot="props"
>
<article class="media">
<figure
class="media-left image is-48x48"
v-if="props.row.actor.avatar"
>
<article class="flex">
<figure v-if="props.row.actor.avatar">
<img
class="is-rounded"
class="rounded"
:src="props.row.actor.avatar.url"
:alt="props.row.actor.avatar.alt || ''"
height="48"
width="48"
/>
</figure>
<b-icon
class="media-left"
v-else
size="is-large"
icon="account-circle"
/>
<div class="media-content">
<div class="content">
<AccountCircle v-else :size="48" />
<div class="">
<div class="">
<span v-if="props.row.actor.name">{{
props.row.actor.name
}}</span
><br />
<span class="is-size-7 has-text-grey-dark"
>@{{ usernameWithDomain(props.row.actor) }}</span
>
<span class="">@{{ usernameWithDomain(props.row.actor) }}</span>
</div>
</div>
</article>
</b-table-column>
<b-table-column field="role" :label="$t('Role')" v-slot="props">
</o-table-column>
<o-table-column field="role" :label="t('Role')" v-slot="props">
<b-tag
type="is-primary"
variant="info"
v-if="props.row.role === MemberRole.ADMINISTRATOR"
>
{{ $t("Administrator") }}
{{ t("Administrator") }}
</b-tag>
<b-tag
type="is-primary"
variant="info"
v-else-if="props.row.role === MemberRole.MODERATOR"
>
{{ $t("Moderator") }}
{{ t("Moderator") }}
</b-tag>
<b-tag v-else-if="props.row.role === MemberRole.MEMBER">
{{ $t("Member") }}
{{ t("Member") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === MemberRole.NOT_APPROVED"
>
{{ $t("Not approved") }}
{{ t("Not approved") }}
</b-tag>
<b-tag
type="is-danger"
variant="danger"
v-else-if="props.row.role === MemberRole.REJECTED"
>
{{ $t("Rejected") }}
{{ t("Rejected") }}
</b-tag>
<b-tag
type="is-warning"
variant="warning"
v-else-if="props.row.role === MemberRole.INVITED"
>
{{ $t("Invited") }}
{{ t("Invited") }}
</b-tag>
</b-table-column>
<b-table-column field="insertedAt" :label="$t('Date')" v-slot="props">
</o-table-column>
<o-table-column field="insertedAt" :label="t('Date')" v-slot="props">
<span class="has-text-centered">
{{ props.row.insertedAt | formatDateString }}<br />{{
props.row.insertedAt | formatTimeString
{{ formatDateString(props.row.insertedAt) }}<br />{{
formatTimeString(props.row.insertedAt)
}}
</span>
</b-table-column>
<b-table-column field="actions" :label="$t('Actions')" v-slot="props">
<div class="buttons" v-if="props.row.actor.id !== currentActor.id">
<b-button
type="is-success"
</o-table-column>
<o-table-column field="actions" :label="t('Actions')" v-slot="props">
<div
class="flex flex-wrap gap-2"
v-if="props.row.actor.id !== currentActor?.id"
>
<o-button
variant="success"
v-if="props.row.role === MemberRole.NOT_APPROVED"
@click="approveMember(props.row)"
@click="approveMember(props.row.id)"
icon-left="check"
>{{ $t("Approve member") }}</b-button
>{{ t("Approve member") }}</o-button
>
<b-button
type="is-danger"
<o-button
variant="danger"
v-if="props.row.role === MemberRole.NOT_APPROVED"
@click="rejectMember(props.row)"
icon-left="exit-to-app"
>{{ $t("Reject member") }}</b-button
>{{ t("Reject member") }}</o-button
>
<b-button
<o-button
v-if="
[MemberRole.MEMBER, MemberRole.MODERATOR].includes(
props.row.role
@@ -206,9 +202,9 @@
"
@click="promoteMember(props.row)"
icon-left="chevron-double-up"
>{{ $t("Promote") }}</b-button
>{{ t("Promote") }}</o-button
>
<b-button
<o-button
v-if="
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR].includes(
props.row.role
@@ -216,293 +212,304 @@
"
@click="demoteMember(props.row)"
icon-left="chevron-double-down"
>{{ $t("Demote") }}</b-button
>{{ t("Demote") }}</o-button
>
<b-button
<o-button
v-if="props.row.role === MemberRole.MEMBER"
@click="removeMember(props.row)"
type="is-danger"
variant="danger"
icon-left="exit-to-app"
>{{ $t("Remove") }}</b-button
>{{ t("Remove") }}</o-button
>
</div>
</b-table-column>
<template slot="empty">
</o-table-column>
<template #empty>
<empty-content icon="account" inline>
{{ $t("No member matches the filters") }}
{{ t("No member matches the filters") }}
</empty-content>
</template>
</b-table>
</o-table>
</section>
<b-message v-else-if="!$apollo.loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
<o-notification v-else-if="!groupMembersLoading && group">
{{ t("You are not an administrator for this group.") }}
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import GroupMixin from "@/mixins/group";
import { mixins } from "vue-class-component";
<script lang="ts" setup>
import { FETCH_GROUP } from "@/graphql/group";
import { MemberRole } from "@/types/enums";
import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name";
import RouteName from "@/router/name";
import {
INVITE_MEMBER,
GROUP_MEMBERS,
REMOVE_MEMBER,
UPDATE_MEMBER,
APPROVE_MEMBER,
} from "../../graphql/member";
import { usernameWithDomain, displayName } from "../../types/actor";
} from "@/graphql/member";
import { usernameWithDomain, displayName, IGroup } from "@/types/actor";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { computed, inject, ref, watch } from "vue";
import {
enumTransformer,
integerTransformer,
useRouteQuery,
} from "vue-use-route-query";
import { useRoute, useRouter } from "vue-router";
import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { formatTimeString, formatDateString } from "@/filters/datetime";
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
import { Notifier } from "@/plugins/notifier";
@Component({
apollo: {
members: {
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Group members")),
});
const props = defineProps<{ preferredUsername: string }>();
const { currentActor } = useCurrentActorClient();
const loading = ref(true);
const newMemberUsername = ref("");
const inviteError = ref("");
const page = useRouteQuery("page", 1, integerTransformer);
const roles = useRouteQuery("roles", undefined, enumTransformer(MemberRole));
const MEMBERS_PER_PAGE = 10;
const notifier = inject<Notifier>("notifier");
const {
result: groupMembersResult,
fetchMore: fetchMoreGroupMembers,
loading: groupMembersLoading,
} = useQuery<{ group: IGroup }>(GROUP_MEMBERS, () => ({
groupName: props.preferredUsername,
page: page.value,
limit: MEMBERS_PER_PAGE,
roles: roles.value,
}));
const group = computed(() => groupMembersResult.value?.group);
const members = computed(
() => group.value?.members ?? { total: 0, elements: [] }
);
const {
mutate: inviteMemberMutation,
onDone: onInviteMemberDone,
onError: onInviteMemberError,
} = useMutation<{ inviteMember: IMember }>(INVITE_MEMBER, () => ({
refetchQueries: [
{
query: GROUP_MEMBERS,
variables() {
return {
groupName: this.$route.params.preferredUsername,
page: this.page,
limit: this.MEMBERS_PER_PAGE,
roles: this.roles,
};
},
update: (data) => data.group.members,
},
},
components: {
EmptyContent,
},
metaInfo() {
return {
title: this.$t("Group Members") as string,
};
},
})
export default class GroupMembers extends mixins(GroupMixin) {
loading = true;
],
}));
newMemberUsername = "";
inviteError = "";
MemberRole = MemberRole;
roles: MemberRole | "" = "";
RouteName = RouteName;
page = parseInt((this.$route.query.page as string) || "1", 10);
MEMBERS_PER_PAGE = 10;
usernameWithDomain = usernameWithDomain;
displayName = displayName;
mounted(): void {
const roleQuery = this.$route.query.role as string;
if (Object.values(MemberRole).includes(roleQuery as MemberRole)) {
this.roles = roleQuery as MemberRole;
}
this.page = parseInt((this.$route.query.page as string) || "1", 10);
onInviteMemberError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
inviteError.value = error.graphQLErrors[0].message;
}
});
async inviteMember(): Promise<void> {
try {
this.inviteError = "";
const { roles, MEMBERS_PER_PAGE, group, page } = this;
const variables = {
groupName: usernameWithDomain(group),
onInviteMemberDone(() => {
notifier?.success(
t("{username} was invited to {group}", {
username: newMemberUsername.value,
group: displayName(group.value),
})
);
newMemberUsername.value = "";
});
const inviteMember = async (): Promise<void> => {
inviteError.value = "";
inviteMemberMutation({
groupId: group.value?.id,
targetActorUsername: newMemberUsername.value,
});
};
const router = useRouter();
const route = useRoute();
const loadMoreMembers = async (): Promise<void> => {
await fetchMoreGroupMembers({
// New variables
variables() {
return {
name: usernameWithDomain(group.value),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
console.log("variables", variables);
await this.$apollo.mutate<{ inviteMember: IMember }>({
mutation: INVITE_MEMBER,
variables: {
groupId: this.group.id,
targetActorUsername: this.newMemberUsername,
},
refetchQueries: [
{
query: GROUP_MEMBERS,
variables,
},
],
});
this.$notifier.success(
this.$t("{username} was invited to {group}", {
username: this.newMemberUsername,
group: displayName(this.group),
}) as string
);
this.newMemberUsername = "";
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.inviteError = error.graphQLErrors[0].message;
}
}
}
},
});
};
@Watch("page")
triggerLoadMoreMemberPageChange(page: string): void {
this.$router.replace({
name: RouteName.GROUP_MEMBERS_SETTINGS,
query: { ...this.$route.query, page },
});
}
const {
mutate: mutateRemoveMember,
onDone: onRemoveMemberDone,
onError: onRemoveMemberError,
} = useMutation(REMOVE_MEMBER, () => ({
refetchQueries: [
{
query: GROUP_MEMBERS,
},
],
}));
@Watch("roles")
triggerLoadMoreMemberRoleChange(roles: string): void {
this.$router.replace({
name: RouteName.GROUP_MEMBERS_SETTINGS,
query: { ...this.$route.query, roles },
});
onRemoveMemberDone(() => {
let message = t("The member was removed from the group {group}", {
group: displayName(group.value),
}) as string;
if (oldMember.role === MemberRole.NOT_APPROVED) {
message = t("The membership request from {profile} was rejected", {
group: displayName(oldMember.actor),
}) as string;
}
notifier?.success(message);
});
async loadMoreMembers(): Promise<void> {
const { roles, MEMBERS_PER_PAGE, group, page } = this;
await this.$apollo.queries.members.fetchMore({
// New variables
variables() {
return {
name: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
},
});
onRemoveMemberError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
async removeMember(oldMember: IMember): Promise<void> {
const { roles, MEMBERS_PER_PAGE, group, page } = this;
const variables = {
groupName: usernameWithDomain(group),
page,
limit: MEMBERS_PER_PAGE,
roles,
};
try {
await this.$apollo.mutate<{ removeMember: IMember }>({
mutation: REMOVE_MEMBER,
variables: {
groupId: this.group.id,
memberId: oldMember.id,
},
refetchQueries: [
{
query: GROUP_MEMBERS,
variables,
},
],
});
let message = this.$t("The member was removed from the group {group}", {
group: displayName(this.group),
}) as string;
const removeMember = (oldMember: IMember) => {
mutateRemoveMember({
groupId: group.value?.id,
memberId: oldMember.id,
});
};
const promoteMember = (member: IMember): void => {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
updateMember(member, MemberRole.ADMINISTRATOR);
}
if (member.role === MemberRole.MEMBER) {
updateMember(member, MemberRole.MODERATOR);
}
};
const demoteMember = (member: IMember): void => {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
updateMember(member, MemberRole.MEMBER);
}
if (member.role === MemberRole.ADMINISTRATOR) {
updateMember(member, MemberRole.MODERATOR);
}
};
const {
mutate: approveMember,
onDone: onApproveMemberDone,
onError: onApproveMemberError,
} = useMutation<{ approveMember: IMember }, { memberId: string }>(
APPROVE_MEMBER
);
onApproveMemberDone(() => {
notifier?.success(t("The member was approved"));
});
onApproveMemberError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
const rejectMember = (member: IMember): void => {
if (!member.id) return;
removeMember(member);
};
const {
mutate: updateMemberMutation,
onDone: onUpdateMutationDone,
onError: onUpdateMutationError,
} = useMutation<
{ id: string; role: MemberRole },
{ memberId: string; role: MemberRole; oldRole: MemberRole }
>(UPDATE_MEMBER, () => ({
refetchQueries: [
{
query: FETCH_GROUP,
variables: { name: props.preferredUsername },
},
],
}));
onUpdateMutationDone(({ data, context }) => {
let successMessage;
console.debug("onUpdateMutationDone", context);
switch (data?.role) {
case MemberRole.MODERATOR:
successMessage = "The member role was updated to moderator";
break;
case MemberRole.ADMINISTRATOR:
successMessage = "The member role was updated to administrator";
break;
case MemberRole.MEMBER:
if (oldMember.role === MemberRole.NOT_APPROVED) {
message = this.$t(
"The membership request from {profile} was rejected",
{
group: displayName(oldMember.actor),
}
) as string;
successMessage = "The member was approved";
} else {
successMessage = "The member role was updated to simple member";
}
this.$notifier.success(message);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
break;
default:
successMessage = "The member role was updated";
}
notifier?.success(t(successMessage));
});
promoteMember(member: IMember): void {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
this.updateMember(member, MemberRole.ADMINISTRATOR);
}
if (member.role === MemberRole.MEMBER) {
this.updateMember(member, MemberRole.MODERATOR);
}
onUpdateMutationError((error) => {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
demoteMember(member: IMember): void {
if (!member.id) return;
if (member.role === MemberRole.MODERATOR) {
this.updateMember(member, MemberRole.MEMBER);
}
if (member.role === MemberRole.ADMINISTRATOR) {
this.updateMember(member, MemberRole.MODERATOR);
}
}
const updateMember = async (
oldMember: IMember,
role: MemberRole
): Promise<void> => {
updateMemberMutation({
memberId: oldMember.id as string,
role,
oldRole: oldMember.role,
});
};
async approveMember(member: IMember): Promise<void> {
try {
await this.$apollo.mutate<{ approveMember: IMember }>({
mutation: APPROVE_MEMBER,
variables: { memberId: member.id },
});
this.$notifier.success(this.$t("The member was approved") as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
const isCurrentActorAGroupAdmin = computed((): boolean => {
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
});
rejectMember(member: IMember): void {
if (!member.id) return;
this.removeMember(member);
}
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
async updateMember(oldMember: IMember, role: MemberRole): Promise<void> {
try {
await this.$apollo.mutate<{ updateMember: IMember }>({
mutation: UPDATE_MEMBER,
variables: {
memberId: oldMember.id,
role,
},
refetchQueries: [
{
query: FETCH_GROUP,
variables: { name: this.$route.params.preferredUsername },
},
],
});
let successMessage;
switch (role) {
case MemberRole.MODERATOR:
successMessage = "The member role was updated to moderator";
break;
case MemberRole.ADMINISTRATOR:
successMessage = "The member role was updated to administrator";
break;
case MemberRole.MEMBER:
if (oldMember.role === MemberRole.NOT_APPROVED) {
successMessage = "The member was approved";
} else {
successMessage = "The member role was updated to simple member";
}
break;
default:
successMessage = "The member role was updated";
}
this.$notifier.success(this.$t(successMessage) as string);
} catch (error: any) {
console.error(error);
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
}
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const { person } = usePersonStatusGroup(props.preferredUsername);
</script>

View File

@@ -20,42 +20,44 @@
},
]"
/>
<b-loading :active="$apollo.loading" />
<o-loading :active="loading" />
<section
class="container section"
class="container mx-auto section"
v-if="group && isCurrentActorAGroupAdmin"
>
<form @submit.prevent="updateGroup">
<b-field :label="$t('Group name')" label-for="group-settings-name">
<b-input v-model="editableGroup.name" id="group-settings-name" />
</b-field>
<b-field :label="$t('Group short description')">
<editor
<o-field :label="$t('Group name')" label-for="group-settings-name">
<o-input v-model="editableGroup.name" id="group-settings-name" />
</o-field>
<o-field :label="$t('Group short description')">
<Editor
mode="basic"
v-model="editableGroup.summary"
:maxSize="500"
:aria-label="$t('Group description body')"
/></b-field>
<b-field :label="$t('Avatar')">
v-if="currentActor"
:currentActor="currentActor"
/></o-field>
<o-field :label="$t('Avatar')">
<picture-upload
:textFallback="$t('Avatar')"
v-model="avatarFile"
:defaultImage="group.avatar"
:maxSize="avatarMaxSize"
/>
</b-field>
</o-field>
<b-field :label="$t('Banner')">
<o-field :label="$t('Banner')">
<picture-upload
:textFallback="$t('Banner')"
v-model="bannerFile"
:defaultImage="group.banner"
:maxSize="bannerMaxSize"
/>
</b-field>
</o-field>
<p class="label">{{ $t("Group visibility") }}</p>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.visibility"
name="groupVisibility"
:native-value="GroupVisibility.PUBLIC"
@@ -66,10 +68,10 @@
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page."
)
}}</small>
</b-radio>
</o-radio>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.visibility"
name="groupVisibility"
:native-value="GroupVisibility.UNLISTED"
@@ -79,31 +81,31 @@
"You'll need to transmit the group URL so people may access the group's profile. The group won't be findable in Mobilizon's search or regular search engines."
)
}}</small>
</b-radio>
<p class="control">
</o-radio>
<p class="pl-6">
<code>{{ group.url }}</code>
<b-tooltip
<o-tooltip
v-if="canShowCopyButton"
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
variant="success"
position="left"
>
<b-button
type="is-primary"
<o-button
variant="primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
/>
</b-tooltip>
</o-tooltip>
</p>
</div>
<p class="label">{{ $t("New members") }}</p>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.OPEN"
@@ -114,10 +116,10 @@
"Anyone wanting to be a member from your group will be able to from your group page."
)
}}</small>
</b-radio>
</o-radio>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.MODERATED"
@@ -127,10 +129,10 @@
"Anyone can request being a member, but an administrator needs to approve the membership."
)
}}</small>
</b-radio>
</o-radio>
</div>
<div class="field">
<b-radio
<o-radio
v-model="editableGroup.openness"
name="groupOpenness"
:native-value="Openness.INVITE_ONLY"
@@ -140,17 +142,17 @@
"The only way for your group to get new members is if an admininistrator invites them."
)
}}</small>
</b-radio>
</o-radio>
</div>
<b-field
<o-field
:label="$t('Followers')"
:message="$t('Followers will receive new public events and posts.')"
>
<b-checkbox v-model="editableGroup.manuallyApprovesFollowers">
<o-checkbox v-model="editableGroup.manuallyApprovesFollowers">
{{ $t("Manually approve new followers") }}
</b-checkbox>
</b-field>
</o-checkbox>
</o-field>
<full-address-auto-complete
:label="$t('Group address')"
@@ -158,229 +160,265 @@
:hideMap="true"
/>
<div class="buttons">
<b-button native-type="submit" type="is-primary">{{
<div class="flex flex-wrap gap-2 my-2">
<o-button native-type="submit" variant="primary">{{
$t("Update group")
}}</b-button>
<b-button @click="confirmDeleteGroup" type="is-danger">{{
}}</o-button>
<o-button @click="confirmDeleteGroup" variant="danger">{{
$t("Delete group")
}}</b-button>
}}</o-button>
</div>
</form>
<b-message type="is-danger" v-for="(value, index) in errors" :key="index">
<o-notification
variant="danger"
v-for="(value, index) in errors"
:key="index"
>
{{ value }}
</b-message>
</o-notification>
</section>
<b-message v-else-if="!$apollo.loading">
<o-notification v-else-if="!loading">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</o-notification>
</div>
</template>
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
<script lang="ts" setup>
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import PictureUpload from "@/components/PictureUpload.vue";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { GroupVisibility, Openness } from "@/types/enums";
import { UPDATE_GROUP } from "../../graphql/group";
import {
Group,
IGroup,
usernameWithDomain,
displayName,
} from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import { GroupVisibility, MemberRole, Openness } from "@/types/enums";
import { Group, IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { Address, IAddress } from "@/types/address.model";
import { ServerParseError } from "@apollo/client/link/http";
import { ErrorResponse } from "@apollo/client/link/error";
import RouteName from "@/router/name";
import { buildFileFromIMedia } from "@/utils/image";
import { useAvatarMaxSize, useBannerMaxSize } from "@/composition/config";
import { useI18n } from "vue-i18n";
import { computed, ref, watch, defineAsyncComponent, inject } from "vue";
import { useGroup, useUpdateGroup } from "@/composition/apollo/group";
import {
useCurrentActorClient,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { DELETE_GROUP } from "@/graphql/group";
import { useMutation } from "@vue/apollo-composable";
import { useRouter } from "vue-router";
import { Dialog } from "@/plugins/dialog";
import { useHead } from "@vueuse/head";
import { Notifier } from "@/plugins/notifier";
@Component({
components: {
FullAddressAutoComplete,
PictureUpload,
editor: () => import("../../components/Editor.vue"),
},
apollo: {
config: CONFIG,
},
metaInfo() {
return {
title: this.$t("Group settings") as string,
};
},
})
export default class GroupSettings extends mixins(GroupMixin) {
RouteName = RouteName;
const Editor = defineAsyncComponent(() => import("@/components/Editor.vue"));
config!: IConfig;
const props = defineProps<{ preferredUsername: string }>();
errors: string[] = [];
const { currentActor } = useCurrentActorClient();
avatarFile: File | null = null;
const { group, loading } = useGroup(props.preferredUsername);
bannerFile: File | null = null;
const { t } = useI18n({ useScope: "global" });
usernameWithDomain = usernameWithDomain;
useHead({
title: computed(() => t("Group settings")),
});
displayName = displayName;
const notifier = inject<Notifier>("notifier");
GroupVisibility = GroupVisibility;
const avatarFile = ref<File | null>(null);
const bannerFile = ref<File | null>(null);
Openness = Openness;
const errors = ref<string[]>([]);
showCopiedTooltip = false;
const showCopiedTooltip = ref(false);
editableGroup: IGroup = new Group();
const editableGroup = ref<IGroup>(new Group());
async updateGroup(): Promise<void> {
try {
const variables = this.buildVariables();
await this.$apollo.mutate<{ updateGroup: IGroup }>({
mutation: UPDATE_GROUP,
variables,
});
this.$notifier.success(this.$t("Group settings saved") as string);
} catch (err: any) {
this.handleError(err);
const updateGroup = async (): Promise<void> => {
const variables = buildVariables();
const { onDone, onError } = useUpdateGroup(variables);
onDone(() => {
notifier?.success(t("Group settings saved") as string);
});
onError((err) => {
handleError(err as unknown as ErrorResponse);
});
};
const copyURL = async (): Promise<void> => {
await window.navigator.clipboard.writeText(group.value?.url ?? "");
showCopiedTooltip.value = true;
setTimeout(() => {
showCopiedTooltip.value = false;
}, 2000);
};
watch(group, async (oldGroup: IGroup, newGroup: IGroup) => {
try {
if (
oldGroup?.avatar !== undefined &&
oldGroup?.avatar !== newGroup?.avatar
) {
avatarFile.value = await buildFileFromIMedia(group.value?.avatar);
}
if (
oldGroup?.banner !== undefined &&
oldGroup?.banner !== newGroup?.banner
) {
bannerFile.value = await buildFileFromIMedia(group.value?.banner);
}
} catch (e) {
// Catch errors while building media
console.error(e);
}
async copyURL(): Promise<void> {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
editableGroup.value = { ...group.value };
});
const buildVariables = () => {
let avatarObj = {};
let bannerObj = {};
const variables = { ...editableGroup.value };
let physicalAddress;
if (variables.physicalAddress) {
physicalAddress = { ...variables.physicalAddress };
} else {
physicalAddress = variables.physicalAddress;
}
@Watch("group")
async watchUpdateGroup(oldGroup: IGroup, newGroup: IGroup): Promise<void> {
try {
if (
oldGroup?.avatar !== undefined &&
oldGroup?.avatar !== newGroup?.avatar
) {
this.avatarFile = await buildFileFromIMedia(this.group.avatar);
}
if (
oldGroup?.banner !== undefined &&
oldGroup?.banner !== newGroup?.banner
) {
this.bannerFile = await buildFileFromIMedia(this.group.banner);
}
} catch (e) {
// Catch errors while building media
console.error(e);
}
this.editableGroup = { ...this.group };
}
private buildVariables() {
let avatarObj = {};
let bannerObj = {};
const variables = { ...this.editableGroup };
let physicalAddress;
if (variables.physicalAddress) {
physicalAddress = { ...variables.physicalAddress };
} else {
physicalAddress = variables.physicalAddress;
}
// eslint-disable-next-line
// @ts-ignore
if (variables.__typename) {
// eslint-disable-next-line
// @ts-ignore
if (variables.__typename) {
// eslint-disable-next-line
// @ts-ignore
delete variables.__typename;
}
delete variables.__typename;
}
// eslint-disable-next-line
// @ts-ignore
if (physicalAddress && physicalAddress.__typename) {
// eslint-disable-next-line
// @ts-ignore
if (physicalAddress && physicalAddress.__typename) {
// eslint-disable-next-line
// @ts-ignore
delete physicalAddress.__typename;
}
delete variables.avatar;
delete variables.banner;
delete physicalAddress.__typename;
}
delete variables.avatar;
delete variables.banner;
if (this.avatarFile) {
avatarObj = {
avatar: {
media: {
name: this.avatarFile.name,
alt: `${this.editableGroup.preferredUsername}'s avatar`,
file: this.avatarFile,
},
if (avatarFile.value) {
avatarObj = {
avatar: {
media: {
name: avatarFile.value?.name,
alt: `${editableGroup.value?.preferredUsername}'s avatar`,
file: avatarFile,
},
};
}
if (this.bannerFile) {
bannerObj = {
banner: {
media: {
name: this.bannerFile.name,
alt: `${this.editableGroup.preferredUsername}'s banner`,
file: this.bannerFile,
},
},
};
}
return {
id: this.group.id,
name: this.editableGroup.name,
summary: this.editableGroup.summary,
visibility: this.editableGroup.visibility,
openness: this.editableGroup.openness,
manuallyApprovesFollowers: this.editableGroup.manuallyApprovesFollowers,
physicalAddress,
...avatarObj,
...bannerObj,
},
};
}
// eslint-disable-next-line class-methods-use-this
get canShowCopyButton(): boolean {
return window.isSecureContext;
if (bannerFile.value) {
bannerObj = {
banner: {
media: {
name: bannerFile.value?.name,
alt: `${editableGroup.value?.preferredUsername}'s banner`,
file: bannerFile,
},
},
};
}
return {
id: group.value?.id,
name: editableGroup.value?.name,
summary: editableGroup.value?.summary,
visibility: editableGroup.value?.visibility,
openness: editableGroup.value?.openness,
manuallyApprovesFollowers: editableGroup.value?.manuallyApprovesFollowers,
physicalAddress,
...avatarObj,
...bannerObj,
};
};
get currentAddress(): IAddress {
return new Address(this.editableGroup.physicalAddress);
}
const canShowCopyButton = computed((): boolean => {
return window.isSecureContext;
});
set currentAddress(address: IAddress) {
this.editableGroup.physicalAddress = address;
}
const currentAddress = computed({
get(): IAddress {
return new Address(editableGroup.value?.physicalAddress);
},
set(address: IAddress) {
editableGroup.value.physicalAddress = address;
},
});
get avatarMaxSize(): number | undefined {
return this?.config?.uploadLimits?.avatar;
}
const avatarMaxSize = useAvatarMaxSize();
const bannerMaxSize = useBannerMaxSize();
get bannerMaxSize(): number | undefined {
return this?.config?.uploadLimits?.banner;
}
const handleError = (err: ErrorResponse) => {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
private handleError(err: ErrorResponse) {
if (err?.networkError?.name === "ServerParseError") {
const error = err?.networkError as ServerParseError;
if (error?.response?.status === 413) {
this.errors.push(
this.$t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
if (error?.response?.status === 413) {
errors.value.push(
t(
"Unable to create the group. One of the pictures may be too heavy."
) as string
);
}
this.errors.push(
...(err.graphQLErrors || []).map(
({ message }: { message: string }) => message
)
);
}
}
errors.value.push(
...(err.graphQLErrors || []).map(
({ message }: { message: string }) => message
)
);
};
const isCurrentActorAGroupAdmin = computed((): boolean => {
return hasCurrentActorThisRole(MemberRole.ADMINISTRATOR);
});
const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
const roles = Array.isArray(givenRole) ? givenRole : [givenRole];
return (
personMemberships.value?.total > 0 &&
roles.includes(personMemberships.value?.elements[0].role)
);
};
const personMemberships = computed(
() => person.value?.memberships ?? { total: 0, elements: [] }
);
const { person } = usePersonStatusGroup(props.preferredUsername);
const dialog = inject<Dialog>("dialog");
const confirmDeleteGroup = (): void => {
console.debug("confirm delete group", dialog);
dialog?.confirm({
title: t("Delete group"),
message: t(
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>."
),
confirmText: t("Delete group"),
cancelText: t("Cancel"),
type: "danger",
hasIcon: true,
onConfirm: () =>
deleteGroupMutation({
groupId: group.value?.id,
}),
});
};
const { mutate: deleteGroupMutation, onDone: deleteGroupDone } = useMutation<{
deleteGroup: IGroup;
}>(DELETE_GROUP);
const router = useRouter();
deleteGroupDone(() => {
router.push({ name: RouteName.MY_GROUPS });
});
</script>

View File

@@ -1,5 +1,5 @@
<template>
<section class="section container">
<section class="container mx-auto">
<h1 class="title">{{ $t("My groups") }}</h1>
<p>
{{
@@ -8,14 +8,15 @@
)
}}
</p>
<div class="buttons" v-if="!hideCreateGroupButton">
<router-link
class="button is-primary"
<div class="flex my-3" v-if="!hideCreateGroupButton">
<o-button
tag="router-link"
variant="primary"
:to="{ name: RouteName.CREATE_GROUP }"
>{{ $t("Create group") }}</router-link
>{{ $t("Create group") }}</o-button
>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<o-loading v-model:active="loading"></o-loading>
<invitations
:invitations="invitations"
@accept-invitation="acceptInvitation"
@@ -27,10 +28,11 @@
v-for="member in memberships"
:key="member.id"
:member="member"
@leave="leaveGroup(member.parent)"
@leave="leaveGroup({ groupId: member.parent.id })"
/>
<b-pagination
<o-pagination
:total="membershipsPages.total"
v-show="membershipsPages.total > limit"
v-model="page"
:per-page="limit"
:aria-next-label="$t('Next page')"
@@ -38,30 +40,36 @@
:aria-page-label="$t('Page')"
:aria-current-label="$t('Current page')"
>
</b-pagination>
</o-pagination>
</section>
<section
class="has-text-centered not-found"
v-if="memberships.length === 0 && !$apollo.loading"
class="text-center not-found"
v-if="memberships.length === 0 && !loading"
>
<div class="columns is-vertical is-centered">
<div class="column is-three-quarters">
<div class="">
<div class="">
<div class="img-container" :class="{ webp: supportsWebPFormat }" />
<div class="content has-text-centered">
<div class="text-center">
<p>
{{ $t("You are not part of any group.") }}
<i18n path="Do you wish to {create_group} or {explore_groups}?">
<router-link
:to="{ name: RouteName.CREATE_GROUP }"
slot="create_group"
>{{ $t("create a group") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_groups"
>{{ $t("explore the groups") }}</router-link
>
</i18n>
<i18n-t
keypath="Do you wish to {create_group} or {explore_groups}?"
>
<template #create_group>
<o-button
tag="router-link"
:to="{ name: RouteName.CREATE_GROUP }"
>{{ $t("create a group") }}</o-button
>
</template>
<template #explore_groups>
<o-button
tag="router-link"
:to="{ name: RouteName.SEARCH }"
>{{ $t("explore the groups") }}</o-button
>
</template>
</i18n-t>
</p>
</div>
</div>
@@ -70,137 +78,117 @@
</section>
</template>
<script lang="ts">
import { Component, Vue } from "vue-property-decorator";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
<script lang="ts" setup>
import { LOGGED_USER_MEMBERSHIPS } from "@/graphql/actor";
import { LEAVE_GROUP } from "@/graphql/group";
import GroupMemberCard from "@/components/Group/GroupMemberCard.vue";
import Invitations from "@/components/Group/Invitations.vue";
import { Paginate } from "@/types/paginate";
import { IGroup, usernameWithDomain } from "@/types/actor";
import { Route } from "vue-router";
import { IMember } from "@/types/actor/member.model";
import { MemberRole } from "@/types/enums";
import { supportsWebPFormat } from "@/utils/support";
import RouteName from "../../router/name";
import { useRestrictions } from "@/composition/apollo/config";
import { useMutation, useQuery } from "@vue/apollo-composable";
import { IUser } from "@/types/current-user.model";
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, inject } from "vue";
import { useHead } from "@vueuse/head";
import { useI18n } from "vue-i18n";
import { useRouter } from "vue-router";
import { Notifier } from "@/plugins/notifier";
@Component({
components: {
GroupMemberCard,
Invitations,
},
apollo: {
config: {
query: CONFIG,
},
membershipsPages: {
query: LOGGED_USER_MEMBERSHIPS,
fetchPolicy: "cache-and-network",
variables() {
return {
page: this.page,
limit: this.limit,
};
},
update: (data) => data.loggedUser.memberships,
},
},
metaInfo() {
return {
title: this.$t("My groups") as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class MyGroups extends Vue {
membershipsPages!: Paginate<IMember>;
const page = useRouteQuery("page", 1, integerTransformer);
const limit = 10;
RouteName = RouteName;
const { result: membershipsResult, loading } = useQuery<{
loggedUser: Pick<IUser, "memberships">;
}>(LOGGED_USER_MEMBERSHIPS, () => ({
page: page.value,
limit,
}));
config!: IConfig;
page = 1;
limit = 10;
supportsWebPFormat = supportsWebPFormat;
acceptInvitation(member: IMember): Promise<Route> {
return this.$router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
}
rejectInvitation({ id: memberId }: { id: string }): void {
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;
const membershipsPages = computed(
() =>
membershipsResult.value?.loggedUser?.memberships ?? {
total: 0,
elements: [],
}
}
);
async leaveGroup(group: IGroup): Promise<void> {
try {
const { page, limit } = this;
await this.$apollo.mutate({
mutation: LEAVE_GROUP,
const { t } = useI18n({ useScope: "global" });
useHead({
title: t("My groups"),
});
const notifier = inject<Notifier>("notifier");
const router = useRouter();
const acceptInvitation = (member: IMember) => {
return router.push({
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
});
};
const rejectInvitation = ({ id: memberId }: { id: string }) => {
const index = membershipsPages.value.elements.findIndex(
(membership) =>
membership.role === MemberRole.INVITED && membership.id === memberId
);
if (index > -1) {
membershipsPages.value.elements.splice(index, 1);
membershipsPages.value.total -= 1;
}
};
const { mutate: leaveGroup, onError: onLeaveGroupError } = useMutation(
LEAVE_GROUP,
() => ({
refetchQueries: [
{
query: LOGGED_USER_MEMBERSHIPS,
variables: {
groupId: group.id,
page,
limit,
},
refetchQueries: [
{
query: LOGGED_USER_MEMBERSHIPS,
variables: {
page,
limit,
},
},
],
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
},
],
})
);
get invitations(): IMember[] {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
onLeaveGroupError((error) => {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
notifier?.error(error.graphQLErrors[0].message);
}
});
get memberships(): IMember[] {
if (!this.membershipsPages) return [];
return this.membershipsPages.elements.filter(
(member: IMember) =>
![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
);
}
const invitations = computed((): IMember[] => {
if (!membershipsPages.value) return [];
return membershipsPages.value.elements.filter(
(member: IMember) => member.role === MemberRole.INVITED
);
});
get hideCreateGroupButton(): boolean {
return !!this.config?.restrictions?.onlyAdminCanCreateGroups;
}
}
const memberships = computed((): IMember[] => {
if (!membershipsPages.value) return [];
return membershipsPages.value.elements.filter(
(member: IMember) =>
![MemberRole.INVITED, MemberRole.REJECTED].includes(member.role)
);
});
const { restrictions } = useRestrictions();
const hideCreateGroupButton = computed((): boolean => {
return restrictions.value?.onlyAdminCanCreateGroups === true;
});
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
main > .container {
background: $white;
& > h1 {
margin: 10px auto 5px;
}
}
.participation {
margin: 1rem auto;
}

View File

@@ -1,53 +1,47 @@
<template>
<aside class="section container">
<h1 class="title">{{ $t("Settings") }}</h1>
<div class="columns">
<aside class="column is-one-quarter-desktop">
<div class="container mx-auto">
<h1 class="">{{ t("Settings") }}</h1>
<div class="flex flex-wrap gap-2">
<aside class="max-w-xs flex-1">
<ul>
<SettingMenuSection
:title="$t('Settings')"
:title="t('Settings')"
:to="{ name: RouteName.GROUP_SETTINGS }"
>
<SettingMenuItem
:title="this.$t('Public')"
:title="t('Public')"
:to="{ name: RouteName.GROUP_PUBLIC_SETTINGS }"
/>
<SettingMenuItem
:title="this.$t('Members')"
:title="t('Members')"
:to="{ name: RouteName.GROUP_MEMBERS_SETTINGS }"
/>
<SettingMenuItem
:title="this.$t('Followers')"
:title="t('Followers')"
:to="{ name: RouteName.GROUP_FOLLOWERS_SETTINGS }"
/>
</SettingMenuSection>
</ul>
</aside>
<div class="column">
<div class="flex-1">
<router-view />
</div>
</div>
</aside>
</div>
</template>
<script lang="ts">
import { Component } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import RouteName from "../../router/name";
import SettingMenuSection from "../../components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "../../components/Settings/SettingMenuItem.vue";
<script lang="ts" setup>
import { computed } from "vue";
import { useI18n } from "vue-i18n";
import { useHead } from "@vueuse/head";
import RouteName from "@/router/name";
import SettingMenuSection from "@/components/Settings/SettingMenuSection.vue";
import SettingMenuItem from "@/components/Settings/SettingMenuItem.vue";
@Component({
components: { SettingMenuSection, SettingMenuItem },
metaInfo() {
return {
title: this.$t("Group settings") as string,
};
},
})
export default class Settings extends mixins(GroupMixin) {
RouteName = RouteName;
}
const { t } = useI18n({ useScope: "global" });
useHead({
title: computed(() => t("Group settings")),
});
</script>
<style lang="scss" scoped>

View File

@@ -1,5 +1,5 @@
<template>
<div class="container section">
<div class="container mx-auto section">
<breadcrumbs-nav
v-if="group"
:links="[
@@ -17,87 +17,69 @@
/>
<section class="timeline">
<b-field>
<b-radio-button v-model="activityType" :native-value="undefined">
<b-icon icon="timeline-text"></b-icon>
{{ $t("All activities") }}</b-radio-button
<o-field>
<o-radio v-model="activityType" :native-value="undefined">
<TimelineText />
{{ $t("All activities") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.MEMBER"
<o-radio v-model="activityType" :native-value="ActivityType.MEMBER">
<o-icon icon="account-multiple-plus"></o-icon>
{{ $t("Members") }}</o-radio
>
<b-icon icon="account-multiple-plus"></b-icon>
{{ $t("Members") }}</b-radio-button
<o-radio v-model="activityType" :native-value="ActivityType.GROUP">
<o-icon icon="cog"></o-icon>
{{ $t("Settings") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.GROUP"
<o-radio v-model="activityType" :native-value="ActivityType.EVENT">
<o-icon icon="calendar"></o-icon>
{{ $t("Events") }}</o-radio
>
<b-icon icon="cog"></b-icon>
{{ $t("Settings") }}</b-radio-button
<o-radio v-model="activityType" :native-value="ActivityType.POST">
<o-icon icon="bullhorn"></o-icon>
{{ $t("Posts") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.EVENT"
<o-radio v-model="activityType" :native-value="ActivityType.DISCUSSION">
<o-icon icon="chat"></o-icon>
{{ $t("Discussions") }}</o-radio
>
<b-icon icon="calendar"></b-icon>
{{ $t("Events") }}</b-radio-button
<o-radio v-model="activityType" :native-value="ActivityType.RESOURCE">
<o-icon icon="link"></o-icon>
{{ $t("Resources") }}</o-radio
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.POST"
</o-field>
<o-field>
<o-radio v-model="activityAuthor" :native-value="undefined">
<TimelineText />
{{ $t("All activities") }}</o-radio
>
<b-icon icon="bullhorn"></b-icon>
{{ $t("Posts") }}</b-radio-button
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.DISCUSSION"
>
<b-icon icon="chat"></b-icon>
{{ $t("Discussions") }}</b-radio-button
>
<b-radio-button
v-model="activityType"
:native-value="ActivityType.RESOURCE"
>
<b-icon icon="link"></b-icon>
{{ $t("Resources") }}</b-radio-button
>
</b-field>
<b-field>
<b-radio-button v-model="activityAuthor" :native-value="undefined">
<b-icon icon="timeline-text"></b-icon>
{{ $t("All activities") }}</b-radio-button
>
<b-radio-button
<o-radio
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.SELF"
>
<b-icon icon="account"></b-icon>
{{ $t("From yourself") }}</b-radio-button
<o-icon icon="account"></o-icon>
{{ $t("From yourself") }}</o-radio
>
<b-radio-button
<o-radio
v-model="activityAuthor"
:native-value="ActivityAuthorFilter.BY"
>
<b-icon icon="account-multiple"></b-icon>
{{ $t("By others") }}</b-radio-button
<o-icon icon="account-multiple"></o-icon>
{{ $t("By others") }}</o-radio
>
</b-field>
</o-field>
<transition-group name="timeline-list" tag="div">
<div
class="day"
v-for="[date, activityItems] in Object.entries(activities)"
:key="date"
>
<b-skeleton
<o-skeleton
v-if="date.search(/skeleton/) !== -1"
width="300px"
height="48px"
/>
<h2 class="is-size-3 has-text-weight-bold" v-else-if="isToday(date)">
<span v-tooltip="$options.filters.formatDateString(date)">
<span v-tooltip="formatDateString(date)">
{{ $t("Today") }}
</span>
</h2>
@@ -105,12 +87,12 @@
class="is-size-3 has-text-weight-bold"
v-else-if="isYesterday(date)"
>
<span v-tooltip="$options.filters.formatDateString(date)">{{
<span v-tooltip="formatDateString(date)">{{
$t("Yesterday")
}}</span>
</h2>
<h2 v-else class="is-size-3 has-text-weight-bold">
{{ date | formatDateString }}
{{ formatDateString(date) }}
</h2>
<ul>
<li v-for="activityItem in activityItems" :key="activityItem.id">
@@ -126,7 +108,7 @@
<empty-content
icon="timeline-text"
v-if="
!$apollo.loading &&
!loading &&
activity.elements.length > 0 &&
activity.elements.length >= activity.total
"
@@ -134,7 +116,7 @@
{{ $t("No more activity to display.") }}
</empty-content>
<empty-content
v-if="!$apollo.loading && activity.total === 0"
v-if="!loading && activity.total === 0"
icon="timeline-text"
>
{{
@@ -144,24 +126,30 @@
}}
</empty-content>
<observer @intersect="loadMore" />
<b-button
<o-button
v-if="activity.elements.length < activity.total"
@click="loadMore"
>{{ $t("Load more activities") }}</b-button
>{{ $t("Load more activities") }}</o-button
>
</section>
</div>
</template>
<script lang="ts">
<script lang="ts" setup>
import { GROUP_TIMELINE } from "@/graphql/group";
import { IGroup, usernameWithDomain, displayName } from "@/types/actor";
import { ActivityType } from "@/types/enums";
import { Paginate } from "@/types/paginate";
import { Component, Prop, Vue } from "vue-property-decorator";
import { IActivity } from "../../types/activity.model";
import Observer from "../../components/Utils/Observer.vue";
import SkeletonActivityItem from "../../components/Activity/SkeletonActivityItem.vue";
import RouteName from "../../router/name";
import TimelineText from "vue-material-design-icons/TimelineText.vue";
import { useQuery } from "@vue/apollo-composable";
import { useHead } from "@vueuse/head";
import { enumTransformer, useRouteQuery } from "vue-use-route-query";
import { computed, defineAsyncComponent, ref } from "vue";
import { useI18n } from "vue-i18n";
import { formatDateString } from "@/filters/datetime";
const PAGINATION_LIMIT = 25;
const SKELETON_DAY_ITEMS = 2;
@@ -173,223 +161,182 @@ enum ActivityAuthorFilter {
BY = "BY",
}
export type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
type ActivityFilter = ActivityType | ActivityAuthorFilter | null;
@Component({
apollo: {
group: {
query: GROUP_TIMELINE,
fetchPolicy: "cache-and-network",
variables() {
return {
preferredUsername: this.preferredUsername,
page: 1,
limit: PAGINATION_LIMIT,
type: this.activityType,
author: this.activityAuthor,
};
},
},
},
components: {
Observer,
SkeletonActivityItem,
"event-activity-item": () =>
import("../../components/Activity/EventActivityItem.vue"),
"post-activity-item": () =>
import("../../components/Activity/PostActivityItem.vue"),
"member-activity-item": () =>
import("../../components/Activity/MemberActivityItem.vue"),
"resource-activity-item": () =>
import("../../components/Activity/ResourceActivityItem.vue"),
"discussion-activity-item": () =>
import("../../components/Activity/DiscussionActivityItem.vue"),
"group-activity-item": () =>
import("../../components/Activity/GroupActivityItem.vue"),
"empty-content": () => import("../../components/Utils/EmptyContent.vue"),
},
metaInfo() {
return {
title: this.$t("{group} activity timeline", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
group: this.group?.name,
}) as string,
titleTemplate: "%s | Mobilizon",
};
},
})
export default class Timeline extends Vue {
@Prop({ required: true, type: String }) preferredUsername!: string;
const props = defineProps<{ preferredUsername: string }>();
group!: IGroup;
const { t } = useI18n({ useScope: "global" });
RouteName = RouteName;
const EventActivityItem = defineAsyncComponent(
() => import("../../components/Activity/EventActivityItem.vue")
);
const PostActivityItem = defineAsyncComponent(
() => import("../../components/Activity/PostActivityItem.vue")
);
const MemberActivityItem = defineAsyncComponent(
() => import("../../components/Activity/MemberActivityItem.vue")
);
const ResourceActivityItem = defineAsyncComponent(
() => import("../../components/Activity/ResourceActivityItem.vue")
);
const DiscussionActivityItem = defineAsyncComponent(
() => import("../../components/Activity/DiscussionActivityItem.vue")
);
const GroupActivityItem = defineAsyncComponent(
() => import("../../components/Activity/GroupActivityItem.vue")
);
const EmptyContent = defineAsyncComponent(
() => import("../../components/Utils/EmptyContent.vue")
);
usernameWithDomain = usernameWithDomain;
const activityType = useRouteQuery(
"activityType",
undefined,
enumTransformer(ActivityType)
);
const activityAuthor = useRouteQuery(
"activityAuthor",
undefined,
enumTransformer(ActivityAuthorFilter)
);
displayName = displayName;
const page = ref(1);
ActivityType = ActivityType;
const {
result: groupTimelineResult,
fetchMore: fetchMoreActivities,
loading,
} = useQuery<{ group: IGroup }>(GROUP_TIMELINE, () => ({
preferredUsername: props.preferredUsername,
page: page.value,
limit: PAGINATION_LIMIT,
type: activityType.value,
author: activityAuthor.value,
}));
ActivityAuthorFilter = ActivityAuthorFilter;
const group = computed(() => groupTimelineResult.value?.group);
get activityType(): ActivityType | undefined {
if (this.$route?.query?.type) {
return this.$route?.query?.type as ActivityType;
}
return undefined;
useHead({
title: computed(() =>
t("{group} activity timeline", { group: group.value?.name })
),
});
const activity = computed((): Paginate<IActivitySkeleton> => {
if (group.value) {
return group.value.activity;
}
return {
total: 0,
elements: skeletons.value.map((skeleton) => ({
skeleton,
})),
};
});
set activityType(type: ActivityType | undefined) {
this.$router.push({
name: RouteName.TIMELINE,
params: {
preferredUsername: this.preferredUsername,
},
query: { ...this.$route.query, type },
});
const limit = PAGINATION_LIMIT;
const component = (type: ActivityType): any | undefined => {
switch (type) {
case ActivityType.EVENT:
return EventActivityItem;
case ActivityType.POST:
return PostActivityItem;
case ActivityType.MEMBER:
return MemberActivityItem;
case ActivityType.RESOURCE:
return ResourceActivityItem;
case ActivityType.DISCUSSION:
return DiscussionActivityItem;
case ActivityType.GROUP:
return GroupActivityItem;
}
};
get activityAuthor(): ActivityAuthorFilter | undefined {
if (this.$route?.query?.author) {
return this.$route?.query?.author as ActivityAuthorFilter;
}
return undefined;
}
set activityAuthor(author: ActivityAuthorFilter | undefined) {
this.$router.push({
name: RouteName.TIMELINE,
params: {
preferredUsername: this.preferredUsername,
},
query: { ...this.$route.query, author },
});
}
get activity(): Paginate<IActivitySkeleton> {
if (this.group) {
return this.group.activity;
}
return {
total: 0,
elements: this.skeletons.map((skeleton) => ({
skeleton,
})),
};
}
page = 1;
limit = PAGINATION_LIMIT;
component(type: ActivityType): string | undefined {
switch (type) {
case ActivityType.EVENT:
return "event-activity-item";
case ActivityType.POST:
return "post-activity-item";
case ActivityType.MEMBER:
return "member-activity-item";
case ActivityType.RESOURCE:
return "resource-activity-item";
case ActivityType.DISCUSSION:
return "discussion-activity-item";
case ActivityType.GROUP:
return "group-activity-item";
}
}
get skeletons(): string[] {
return [...Array(SKELETON_DAY_ITEMS)]
.map((_, i) => {
return [...Array(SKELETON_ITEMS_PER_DAY)].map((_a, j) => {
return `${i}-${j}`;
});
})
.flat();
}
async loadMore(): Promise<void> {
if (this.page * PAGINATION_LIMIT >= this.activity.total) {
return;
}
this.page++;
try {
await this.$apollo.queries.group.fetchMore({
variables: {
page: this.page,
limit: PAGINATION_LIMIT,
},
const skeletons = computed((): string[] => {
return [...Array(SKELETON_DAY_ITEMS)]
.map((_, i) => {
return [...Array(SKELETON_ITEMS_PER_DAY)].map((_a, j) => {
return `${i}-${j}`;
});
} catch (e) {
console.error(e);
}
}
})
.flat();
});
get activities(): Record<string, IActivitySkeleton[]> {
return this.activity.elements.reduce(
(acc: Record<string, IActivitySkeleton[]>, elem) => {
let key;
if (this.isIActivity(elem)) {
const insertedAt = new Date(elem.insertedAt);
insertedAt.setHours(0);
insertedAt.setMinutes(0);
insertedAt.setSeconds(0);
insertedAt.setMilliseconds(0);
key = insertedAt.toISOString();
} else {
key = `skeleton-${elem.skeleton.split("-")[0]}`;
}
const existing = acc[key];
if (existing) {
acc[key] = [...existing, ...[elem]];
} else {
acc[key] = [elem];
}
return acc;
const loadMore = async (): Promise<void> => {
if (page.value * PAGINATION_LIMIT >= activity.value.total) {
return;
}
page.value++;
try {
await fetchMoreActivities({
variables: {
page: page.value,
limit: PAGINATION_LIMIT,
},
{}
);
});
} catch (e) {
console.error(e);
}
};
isIActivity(object: IActivitySkeleton): object is IActivity {
return !("skeleton" in object);
}
const activities = computed((): Record<string, IActivitySkeleton[]> => {
return activity.value.elements.reduce(
(acc: Record<string, IActivitySkeleton[]>, elem) => {
let key;
if (isIActivity(elem)) {
const insertedAt = new Date(elem.insertedAt);
insertedAt.setHours(0);
insertedAt.setMinutes(0);
insertedAt.setSeconds(0);
insertedAt.setMilliseconds(0);
key = insertedAt.toISOString();
} else {
key = `skeleton-${elem.skeleton.split("-")[0]}`;
}
const existing = acc[key];
if (existing) {
acc[key] = [...existing, ...[elem]];
} else {
acc[key] = [elem];
}
return acc;
},
{}
);
});
getRandomInt(min: number, max: number): number {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min) + min);
}
const isIActivity = (object: IActivitySkeleton): object is IActivity => {
return !("skeleton" in object);
};
const getRandomInt = (min: number, max: number): number => {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min) + min);
};
isToday(dateString: string): boolean {
const now = new Date();
const date = new Date(dateString);
return (
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate()
);
}
const isToday = (dateString: string): boolean => {
const now = new Date();
const date = new Date(dateString);
return (
now.getFullYear() === date.getFullYear() &&
now.getMonth() === date.getMonth() &&
now.getDate() === date.getDate()
);
};
isYesterday(dateString: string): boolean {
const date = new Date(dateString);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
yesterday.getFullYear() === date.getFullYear() &&
yesterday.getMonth() === date.getMonth() &&
yesterday.getDate() === date.getDate()
);
}
}
const isYesterday = (dateString: string): boolean => {
const date = new Date(dateString);
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
return (
yesterday.getFullYear() === date.getFullYear() &&
yesterday.getMonth() === date.getMonth() &&
yesterday.getDate() === date.getDate()
);
};
</script>
<style lang="scss" scoped>
.container.section {
background: $white;
}
.timeline {
ul {
// padding: 0.5rem 0;