Introduce instances admin page
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
65249b60f2
commit
e717312de7
|
@ -290,6 +290,7 @@ config :mobilizon, Oban,
|
||||||
crontab: [
|
crontab: [
|
||||||
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
|
{"@hourly", Mobilizon.Service.Workers.BuildSiteMap, queue: :background},
|
||||||
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
|
{"17 4 * * *", Mobilizon.Service.Workers.RefreshGroups, queue: :background},
|
||||||
|
{"36 * * * *", Mobilizon.Service.Workers.RefreshInstances, queue: :background},
|
||||||
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
|
{"@hourly", Mobilizon.Service.Workers.CleanOrphanMediaWorker, queue: :background},
|
||||||
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
|
{"@hourly", Mobilizon.Service.Workers.CleanUnconfirmedUsersWorker, queue: :background},
|
||||||
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},
|
{"@hourly", Mobilizon.Service.Workers.ExportCleanerWorker, queue: :background},
|
||||||
|
|
|
@ -70,6 +70,9 @@ export const typePolicies: TypePolicies = {
|
||||||
participantStats: { merge: replaceMergePolicy },
|
participantStats: { merge: replaceMergePolicy },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Instance: {
|
||||||
|
keyFields: ["domain"],
|
||||||
|
},
|
||||||
RootQueryType: {
|
RootQueryType: {
|
||||||
fields: {
|
fields: {
|
||||||
relayFollowers: paginatedLimitPagination<IFollower>(),
|
relayFollowers: paginatedLimitPagination<IFollower>(),
|
||||||
|
|
9
js/src/assets/logo.svg
Normal file
9
js/src/assets/logo.svg
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="60" height="60">
|
||||||
|
<path style="opacity:0;fill:#fea72b;fill-opacity:1;stroke:none;stroke-opacity:0" d="M-5.801-6.164h72.69v72.871h-72.69z" />
|
||||||
|
<g data-name="Calque 2">
|
||||||
|
<g data-name="header">
|
||||||
|
<path d="M26.58 27.06q0 8-4.26 12.3a12.21 12.21 0 0 1-9 3.42 12.21 12.21 0 0 1-9-3.42Q0 35.1 0 27.06q0-8.04 4.26-12.3a12.21 12.21 0 0 1 9-3.42 12.21 12.21 0 0 1 9 3.42q4.32 4.24 4.32 12.3zM13.29 17q-5.67 0-5.67 10.06t5.67 10.08q5.71 0 5.71-10.08T13.29 17z" style="fill:#3a384c;fill-opacity:1" transform="translate(14.627 5.256) scale(1.15671)" />
|
||||||
|
<path d="M9 6.78a7.37 7.37 0 0 1-.6-3 7.37 7.37 0 0 1 .6-3A8.09 8.09 0 0 1 12.83 0a7.05 7.05 0 0 1 3.69.84 7.37 7.37 0 0 1 .6 3 7.37 7.37 0 0 1-.6 3 7.46 7.46 0 0 1-3.87.84A6.49 6.49 0 0 1 9 6.78z" style="fill:#fff" transform="translate(14.627 5.256) scale(1.15671)" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 920 B |
|
@ -1,262 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<b-table
|
|
||||||
v-show="relayFollowers.elements.length > 0"
|
|
||||||
:data="relayFollowers.elements"
|
|
||||||
:loading="$apollo.queries.relayFollowers.loading"
|
|
||||||
ref="table"
|
|
||||||
:checked-rows.sync="checkedRows"
|
|
||||||
detailed
|
|
||||||
:show-detail-icon="false"
|
|
||||||
paginated
|
|
||||||
backend-pagination
|
|
||||||
:current-page.sync="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="relayFollowers.total"
|
|
||||||
:per-page="FOLLOWERS_PER_PAGE"
|
|
||||||
@page-change="onFollowersPageChange"
|
|
||||||
checkable
|
|
||||||
checkbox-position="left"
|
|
||||||
>
|
|
||||||
<b-table-column
|
|
||||||
field="actor.id"
|
|
||||||
label="ID"
|
|
||||||
width="40"
|
|
||||||
numeric
|
|
||||||
v-slot="props"
|
|
||||||
>{{ props.row.actor.id }}</b-table-column
|
|
||||||
>
|
|
||||||
|
|
||||||
<b-table-column
|
|
||||||
field="actor.type"
|
|
||||||
:label="$t('Type')"
|
|
||||||
width="80"
|
|
||||||
v-slot="props"
|
|
||||||
>
|
|
||||||
<b-icon icon="lan" v-if="RelayMixin.isInstance(props.row.actor)" />
|
|
||||||
<b-icon icon="account-circle" v-else />
|
|
||||||
</b-table-column>
|
|
||||||
|
|
||||||
<b-table-column
|
|
||||||
field="approved"
|
|
||||||
:label="$t('Status')"
|
|
||||||
width="100"
|
|
||||||
sortable
|
|
||||||
centered
|
|
||||||
v-slot="props"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
|
|
||||||
>{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
|
|
||||||
>
|
|
||||||
</b-table-column>
|
|
||||||
|
|
||||||
<b-table-column field="actor.domain" :label="$t('Domain')" sortable>
|
|
||||||
<template v-slot:default="props">
|
|
||||||
<a
|
|
||||||
@click="toggle(props.row)"
|
|
||||||
v-if="RelayMixin.isInstance(props.row.actor)"
|
|
||||||
>{{ props.row.actor.domain }}</a
|
|
||||||
>
|
|
||||||
<a @click="toggle(props.row)" v-else>{{
|
|
||||||
`${props.row.actor.preferredUsername}@${props.row.actor.domain}`
|
|
||||||
}}</a>
|
|
||||||
</template>
|
|
||||||
</b-table-column>
|
|
||||||
|
|
||||||
<b-table-column
|
|
||||||
field="targetActor.updatedAt"
|
|
||||||
:label="$t('Date')"
|
|
||||||
sortable
|
|
||||||
v-slot="props"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
|
|
||||||
>{{
|
|
||||||
formatDistanceToNow(new Date(props.row.updatedAt), {
|
|
||||||
locale: $dateFnsLocale,
|
|
||||||
})
|
|
||||||
}}</span
|
|
||||||
></b-table-column
|
|
||||||
>
|
|
||||||
|
|
||||||
<template #detail="props">
|
|
||||||
<article>
|
|
||||||
<div class="content">
|
|
||||||
<strong>{{ props.row.actor.name }}</strong>
|
|
||||||
<small v-if="props.row.actor.preferredUsername !== 'relay'"
|
|
||||||
>@{{ props.row.actor.preferredUsername }}</small
|
|
||||||
>
|
|
||||||
<p v-html="props.row.actor.summary" />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template slot="bottom-left" v-if="checkedRows.length > 0">
|
|
||||||
<div class="buttons">
|
|
||||||
<b-button
|
|
||||||
@click="acceptRelays"
|
|
||||||
type="is-success"
|
|
||||||
v-if="checkedRowsHaveAtLeastOneToApprove"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
$tc(
|
|
||||||
"No instance to approve|Approve instance|Approve {number} instances",
|
|
||||||
checkedRows.length,
|
|
||||||
{ number: checkedRows.length }
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</b-button>
|
|
||||||
<b-button @click="rejectRelays" type="is-danger">
|
|
||||||
{{
|
|
||||||
$tc(
|
|
||||||
"No instance to reject|Reject instance|Reject {number} instances",
|
|
||||||
checkedRows.length,
|
|
||||||
{ number: checkedRows.length }
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</b-button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</b-table>
|
|
||||||
<b-message type="is-danger" v-if="relayFollowers.elements.length === 0">{{
|
|
||||||
$t("No instance follows your instance yet.")
|
|
||||||
}}</b-message>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Mixins } from "vue-property-decorator";
|
|
||||||
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import {
|
|
||||||
ACCEPT_RELAY,
|
|
||||||
REJECT_RELAY,
|
|
||||||
RELAY_FOLLOWERS,
|
|
||||||
} from "../../graphql/admin";
|
|
||||||
import { IFollower } from "../../types/actor/follower.model";
|
|
||||||
import RelayMixin from "../../mixins/relay";
|
|
||||||
import RouteName from "@/router/name";
|
|
||||||
import { Paginate } from "@/types/paginate";
|
|
||||||
|
|
||||||
const FOLLOWERS_PER_PAGE = 10;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
apollo: {
|
|
||||||
relayFollowers: {
|
|
||||||
query: RELAY_FOLLOWERS,
|
|
||||||
variables() {
|
|
||||||
return {
|
|
||||||
page: this.page,
|
|
||||||
limit: FOLLOWERS_PER_PAGE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metaInfo() {
|
|
||||||
return {
|
|
||||||
title: this.$t("Followers") as string,
|
|
||||||
titleTemplate: "%s | Mobilizon",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class Followers extends Mixins(RelayMixin) {
|
|
||||||
RelayMixin = RelayMixin;
|
|
||||||
|
|
||||||
formatDistanceToNow = formatDistanceToNow;
|
|
||||||
|
|
||||||
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
|
|
||||||
|
|
||||||
checkedRows: IFollower[] = [];
|
|
||||||
|
|
||||||
FOLLOWERS_PER_PAGE = FOLLOWERS_PER_PAGE;
|
|
||||||
|
|
||||||
toggle(row: Record<string, unknown>): void {
|
|
||||||
this.table.toggleDetails(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
get page(): number {
|
|
||||||
return parseInt((this.$route.query.page as string) || "1", 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
set page(page: number) {
|
|
||||||
this.pushRouter(RouteName.RELAY_FOLLOWERS, {
|
|
||||||
page: page.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptRelays(): void {
|
|
||||||
this.checkedRows.forEach((row: IFollower) => {
|
|
||||||
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
rejectRelays(): void {
|
|
||||||
this.checkedRows.forEach((row: IFollower) => {
|
|
||||||
this.rejectRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async acceptRelay(address: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.$apollo.mutate({
|
|
||||||
mutation: ACCEPT_RELAY,
|
|
||||||
variables: {
|
|
||||||
address,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.$apollo.queries.relayFollowers.refetch();
|
|
||||||
this.checkedRows = [];
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.message) {
|
|
||||||
Snackbar.open({
|
|
||||||
message: e.message,
|
|
||||||
type: "is-danger",
|
|
||||||
position: "is-bottom",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rejectRelay(address: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.$apollo.mutate({
|
|
||||||
mutation: REJECT_RELAY,
|
|
||||||
variables: {
|
|
||||||
address,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.$apollo.queries.relayFollowers.refetch();
|
|
||||||
this.checkedRows = [];
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.message) {
|
|
||||||
Snackbar.open({
|
|
||||||
message: e.message,
|
|
||||||
type: "is-danger",
|
|
||||||
position: "is-bottom",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get checkedRowsHaveAtLeastOneToApprove(): boolean {
|
|
||||||
return this.checkedRows.some((checkedRow) => !checkedRow.approved);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onFollowersPageChange(page: number): Promise<void> {
|
|
||||||
this.page = page;
|
|
||||||
try {
|
|
||||||
await this.$apollo.queries.relayFollowers.fetchMore({
|
|
||||||
variables: {
|
|
||||||
page: this.page,
|
|
||||||
limit: FOLLOWERS_PER_PAGE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -1,311 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<form @submit="followRelay">
|
|
||||||
<b-field
|
|
||||||
:label="$t('Add an instance')"
|
|
||||||
custom-class="add-relay"
|
|
||||||
horizontal
|
|
||||||
>
|
|
||||||
<b-field grouped expanded size="is-large">
|
|
||||||
<p class="control">
|
|
||||||
<b-input
|
|
||||||
v-model="newRelayAddress"
|
|
||||||
:placeholder="$t('Ex: mobilizon.fr')"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p class="control">
|
|
||||||
<b-button type="is-primary" native-type="submit">{{
|
|
||||||
$t("Add an instance")
|
|
||||||
}}</b-button>
|
|
||||||
</p>
|
|
||||||
</b-field>
|
|
||||||
</b-field>
|
|
||||||
</form>
|
|
||||||
<b-table
|
|
||||||
v-show="relayFollowings.elements.length > 0"
|
|
||||||
:data="relayFollowings.elements"
|
|
||||||
:loading="$apollo.queries.relayFollowings.loading"
|
|
||||||
ref="table"
|
|
||||||
:checked-rows.sync="checkedRows"
|
|
||||||
:is-row-checkable="(row) => row.id !== 3"
|
|
||||||
detailed
|
|
||||||
:show-detail-icon="false"
|
|
||||||
paginated
|
|
||||||
backend-pagination
|
|
||||||
:current-page.sync="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="relayFollowings.total"
|
|
||||||
:per-page="FOLLOWINGS_PER_PAGE"
|
|
||||||
@page-change="onFollowingsPageChange"
|
|
||||||
checkable
|
|
||||||
checkbox-position="left"
|
|
||||||
>
|
|
||||||
<b-table-column
|
|
||||||
field="targetActor.id"
|
|
||||||
label="ID"
|
|
||||||
width="40"
|
|
||||||
numeric
|
|
||||||
v-slot="props"
|
|
||||||
>{{ props.row.targetActor.id }}</b-table-column
|
|
||||||
>
|
|
||||||
|
|
||||||
<b-table-column
|
|
||||||
field="targetActor.type"
|
|
||||||
:label="$t('Type')"
|
|
||||||
width="80"
|
|
||||||
v-slot="props"
|
|
||||||
>
|
|
||||||
<b-icon
|
|
||||||
icon="lan"
|
|
||||||
v-if="RelayMixin.isInstance(props.row.targetActor)"
|
|
||||||
/>
|
|
||||||
<b-icon icon="account-circle" v-else />
|
|
||||||
</b-table-column>
|
|
||||||
|
|
||||||
<b-table-column
|
|
||||||
field="approved"
|
|
||||||
:label="$t('Status')"
|
|
||||||
width="100"
|
|
||||||
sortable
|
|
||||||
centered
|
|
||||||
v-slot="props"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="`tag ${props.row.approved ? 'is-success' : 'is-danger'}`"
|
|
||||||
>{{ props.row.approved ? $t("Accepted") : $t("Pending") }}</span
|
|
||||||
>
|
|
||||||
</b-table-column>
|
|
||||||
|
|
||||||
<b-table-column field="targetActor.domain" :label="$t('Domain')" sortable>
|
|
||||||
<template v-slot:default="props">
|
|
||||||
<a
|
|
||||||
@click="toggle(props.row)"
|
|
||||||
v-if="RelayMixin.isInstance(props.row.targetActor)"
|
|
||||||
>{{ props.row.targetActor.domain }}</a
|
|
||||||
>
|
|
||||||
<a @click="toggle(props.row)" v-else>{{
|
|
||||||
`${props.row.targetActor.preferredUsername}@${props.row.targetActor.domain}`
|
|
||||||
}}</a>
|
|
||||||
</template>
|
|
||||||
</b-table-column>
|
|
||||||
|
|
||||||
<b-table-column
|
|
||||||
field="targetActor.updatedAt"
|
|
||||||
:label="$t('Date')"
|
|
||||||
sortable
|
|
||||||
v-slot="props"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:title="$options.filters.formatDateTimeString(props.row.updatedAt)"
|
|
||||||
>{{
|
|
||||||
formatDistanceToNow(new Date(props.row.updatedAt), {
|
|
||||||
locale: $dateFnsLocale,
|
|
||||||
})
|
|
||||||
}}</span
|
|
||||||
></b-table-column
|
|
||||||
>
|
|
||||||
|
|
||||||
<template #detail="props">
|
|
||||||
<article>
|
|
||||||
<div class="content">
|
|
||||||
<strong>{{ props.row.targetActor.name }}</strong>
|
|
||||||
<small v-if="props.row.actor.preferredUsername !== 'relay'"
|
|
||||||
>@{{ props.row.targetActor.preferredUsername }}</small
|
|
||||||
>
|
|
||||||
<p v-html="props.row.targetActor.summary" />
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template slot="bottom-left" v-if="checkedRows.length > 0">
|
|
||||||
<b-button @click="removeRelays" type="is-danger">
|
|
||||||
{{
|
|
||||||
$tc(
|
|
||||||
"No instance to remove|Remove instance|Remove {number} instances",
|
|
||||||
checkedRows.length,
|
|
||||||
{ number: checkedRows.length }
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</b-button>
|
|
||||||
</template>
|
|
||||||
</b-table>
|
|
||||||
<b-message type="is-danger" v-if="relayFollowings.total === 0">{{
|
|
||||||
$t("You don't follow any instances yet.")
|
|
||||||
}}</b-message>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Mixins } from "vue-property-decorator";
|
|
||||||
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
|
||||||
import { formatDistanceToNow } from "date-fns";
|
|
||||||
import { ADD_RELAY, REMOVE_RELAY } from "../../graphql/admin";
|
|
||||||
import { IFollower } from "../../types/actor/follower.model";
|
|
||||||
import RelayMixin from "../../mixins/relay";
|
|
||||||
import { RELAY_FOLLOWINGS } from "@/graphql/admin";
|
|
||||||
import { Paginate } from "@/types/paginate";
|
|
||||||
import RouteName from "@/router/name";
|
|
||||||
import { ApolloCache, FetchResult, Reference } from "@apollo/client/core";
|
|
||||||
import gql from "graphql-tag";
|
|
||||||
|
|
||||||
const FOLLOWINGS_PER_PAGE = 10;
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
apollo: {
|
|
||||||
relayFollowings: {
|
|
||||||
query: RELAY_FOLLOWINGS,
|
|
||||||
variables() {
|
|
||||||
return {
|
|
||||||
page: this.page,
|
|
||||||
limit: FOLLOWINGS_PER_PAGE,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metaInfo() {
|
|
||||||
return {
|
|
||||||
title: this.$t("Followings") as string,
|
|
||||||
titleTemplate: "%s | Mobilizon",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class Followings extends Mixins(RelayMixin) {
|
|
||||||
newRelayAddress = "";
|
|
||||||
|
|
||||||
RelayMixin = RelayMixin;
|
|
||||||
|
|
||||||
formatDistanceToNow = formatDistanceToNow;
|
|
||||||
|
|
||||||
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
|
|
||||||
|
|
||||||
FOLLOWINGS_PER_PAGE = FOLLOWINGS_PER_PAGE;
|
|
||||||
|
|
||||||
checkedRows: IFollower[] = [];
|
|
||||||
|
|
||||||
get page(): number {
|
|
||||||
return parseInt((this.$route.query.page as string) || "1", 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
set page(page: number) {
|
|
||||||
this.pushRouter(RouteName.RELAY_FOLLOWINGS, {
|
|
||||||
page: page.toString(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async onFollowingsPageChange(page: number): Promise<void> {
|
|
||||||
this.page = page;
|
|
||||||
try {
|
|
||||||
await this.$apollo.queries.relayFollowings.fetchMore({
|
|
||||||
variables: {
|
|
||||||
page: this.page,
|
|
||||||
limit: FOLLOWINGS_PER_PAGE,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async followRelay(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
await this.$apollo.mutate<{ relayFollowings: Paginate<IFollower> }>({
|
|
||||||
mutation: ADD_RELAY,
|
|
||||||
variables: {
|
|
||||||
address: this.newRelayAddress.trim(), // trim to fix copy and paste domain name spaces and tabs
|
|
||||||
},
|
|
||||||
update(
|
|
||||||
cache: ApolloCache<{ relayFollowings: Paginate<IFollower> }>,
|
|
||||||
{ data }: FetchResult
|
|
||||||
) {
|
|
||||||
cache.modify({
|
|
||||||
fields: {
|
|
||||||
relayFollowings(
|
|
||||||
existingFollowings = { elements: [], total: 0 },
|
|
||||||
{ readField }
|
|
||||||
) {
|
|
||||||
const newFollowingRef = cache.writeFragment({
|
|
||||||
id: `${data?.addRelay.__typename}:${data?.addRelay.id}`,
|
|
||||||
data: data?.addRelay,
|
|
||||||
fragment: gql`
|
|
||||||
fragment NewFollowing on Follower {
|
|
||||||
id
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
existingFollowings.elements.some(
|
|
||||||
(ref: Reference) =>
|
|
||||||
readField("id", ref) === data?.addRelay.id
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return existingFollowings;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
total: existingFollowings.total + 1,
|
|
||||||
elements: [newFollowingRef, ...existingFollowings.elements],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
broadcast: false,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
this.newRelayAddress = "";
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.message) {
|
|
||||||
Snackbar.open({
|
|
||||||
message: err.message,
|
|
||||||
type: "is-danger",
|
|
||||||
position: "is-bottom",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeRelays(): void {
|
|
||||||
this.checkedRows.forEach((row: IFollower) => {
|
|
||||||
this.removeRelay(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeRelay(follower: IFollower): Promise<void> {
|
|
||||||
const address = `${follower.targetActor.preferredUsername}@${follower.targetActor.domain}`;
|
|
||||||
try {
|
|
||||||
await this.$apollo.mutate<{ removeRelay: IFollower }>({
|
|
||||||
mutation: REMOVE_RELAY,
|
|
||||||
variables: {
|
|
||||||
address,
|
|
||||||
},
|
|
||||||
update(cache: ApolloCache<{ removeRelay: IFollower }>) {
|
|
||||||
cache.modify({
|
|
||||||
fields: {
|
|
||||||
relayFollowings(existingFollowingRefs, { readField }) {
|
|
||||||
return {
|
|
||||||
total: existingFollowingRefs.total - 1,
|
|
||||||
elements: existingFollowingRefs.elements.filter(
|
|
||||||
(followingRef: Reference) =>
|
|
||||||
follower.id !== readField("id", followingRef)
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.$apollo.queries.relayFollowings.refetch();
|
|
||||||
this.checkedRows = [];
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.message) {
|
|
||||||
Snackbar.open({
|
|
||||||
message: e.message,
|
|
||||||
type: "is-danger",
|
|
||||||
position: "is-bottom",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
|
@ -78,7 +78,7 @@
|
||||||
/>
|
/>
|
||||||
<SettingMenuItem
|
<SettingMenuItem
|
||||||
:title="$t('Federation')"
|
:title="$t('Federation')"
|
||||||
:to="{ name: RouteName.RELAYS }"
|
:to="{ name: RouteName.INSTANCES }"
|
||||||
/>
|
/>
|
||||||
</SettingMenuSection>
|
</SettingMenuSection>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
|
@ -70,6 +70,67 @@ export const RELAY_FOLLOWINGS = gql`
|
||||||
${RELAY_FRAGMENT}
|
${RELAY_FRAGMENT}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export const INSTANCE_FRAGMENT = gql`
|
||||||
|
fragment InstanceFragment on Instance {
|
||||||
|
domain
|
||||||
|
hasRelay
|
||||||
|
followerStatus
|
||||||
|
followedStatus
|
||||||
|
eventCount
|
||||||
|
personCount
|
||||||
|
groupCount
|
||||||
|
followersCount
|
||||||
|
followingsCount
|
||||||
|
reportsCount
|
||||||
|
mediaSize
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INSTANCE = gql`
|
||||||
|
query instance($domain: ID!) {
|
||||||
|
instance(domain: $domain) {
|
||||||
|
...InstanceFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${INSTANCE_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const INSTANCES = gql`
|
||||||
|
query Instances(
|
||||||
|
$page: Int
|
||||||
|
$limit: Int
|
||||||
|
$orderBy: InstancesSortFields
|
||||||
|
$direction: String
|
||||||
|
$filterDomain: String
|
||||||
|
$filterFollowStatus: InstanceFilterFollowStatus
|
||||||
|
$filterSuspendStatus: InstanceFilterSuspendStatus
|
||||||
|
) {
|
||||||
|
instances(
|
||||||
|
page: $page
|
||||||
|
limit: $limit
|
||||||
|
orderBy: $orderBy
|
||||||
|
direction: $direction
|
||||||
|
filterDomain: $filterDomain
|
||||||
|
filterFollowStatus: $filterFollowStatus
|
||||||
|
filterSuspendStatus: $filterSuspendStatus
|
||||||
|
) {
|
||||||
|
total
|
||||||
|
elements {
|
||||||
|
...InstanceFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${INSTANCE_FRAGMENT}
|
||||||
|
`;
|
||||||
|
export const ADD_INSTANCE = gql`
|
||||||
|
mutation addInstance($domain: String!) {
|
||||||
|
addInstance(domain: $domain) {
|
||||||
|
...InstanceFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
${INSTANCE_FRAGMENT}
|
||||||
|
`;
|
||||||
|
|
||||||
export const ADD_RELAY = gql`
|
export const ADD_RELAY = gql`
|
||||||
mutation addRelay($address: String!) {
|
mutation addRelay($address: String!) {
|
||||||
addRelay(address: $address) {
|
addRelay(address: $address) {
|
||||||
|
|
|
@ -1260,5 +1260,24 @@
|
||||||
"This profile was not found": "This profile was not found",
|
"This profile was not found": "This profile was not found",
|
||||||
"Back to profile list": "Back to profile list",
|
"Back to profile list": "Back to profile list",
|
||||||
"This user was not found": "This user was not found",
|
"This user was not found": "This user was not found",
|
||||||
"Back to user list": "Back to user list"
|
"Back to user list": "Back to user list",
|
||||||
|
"Stop following instance": "Stop following instance",
|
||||||
|
"Follow instance": "Follow instance",
|
||||||
|
"Accept follow": "Accept follow",
|
||||||
|
"Reject follow": "Reject follow",
|
||||||
|
"This instance doesn't follow yours.": "This instance doesn't follow yours.",
|
||||||
|
"Only Mobilizon instances can be followed": "",
|
||||||
|
"Follow a new instance": "Follow a new instance",
|
||||||
|
"Follow status": "Follow status",
|
||||||
|
"All": "All",
|
||||||
|
"Following": "Following",
|
||||||
|
"Followed": "Followed",
|
||||||
|
"Followed, pending response": "Followed, pending response",
|
||||||
|
"Follows us": "Follows us",
|
||||||
|
"Follows us, pending approval": "Follows us, pending approval",
|
||||||
|
"No instance found.": "No instance found.",
|
||||||
|
"No instances match this filter. Try resetting filter fields?": "No instances match this filter. Try resetting filter fields?",
|
||||||
|
"You haven't interacted with other instances yet.": "You haven't interacted with other instances yet.",
|
||||||
|
"mobilizon-instance.tld": "mobilizon-instance.tld",
|
||||||
|
"Report status": "Report status"
|
||||||
}
|
}
|
|
@ -1260,5 +1260,24 @@
|
||||||
"{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
|
"{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
|
||||||
"{title} ({count} todos)": "{title} ({count} todos)",
|
"{title} ({count} todos)": "{title} ({count} todos)",
|
||||||
"{username} was invited to {group}": "{username} a été invité à {group}",
|
"{username} was invited to {group}": "{username} a été invité à {group}",
|
||||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
|
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
|
||||||
|
"Stop following instance": "Arrêter de suivre l'instance",
|
||||||
|
"Follow instance": "Suivre l'instance",
|
||||||
|
"Accept follow": "Accepter le suivi",
|
||||||
|
"Reject follow": "Rejetter le suivi",
|
||||||
|
"This instance doesn't follow yours.": "Cette instance ne suit pas la vôtre.",
|
||||||
|
"Only Mobilizon instances can be followed": "Seules les instances Mobilizon peuvent être suivies",
|
||||||
|
"Follow a new instance": "Suivre une nouvelle instance",
|
||||||
|
"Follow status": "Statut du suivi",
|
||||||
|
"All": "Toutes",
|
||||||
|
"Following": "Suivantes",
|
||||||
|
"Followed": "Suivies",
|
||||||
|
"Followed, pending response": "Suivie, en attente de la réponse",
|
||||||
|
"Follows us": "Nous suit",
|
||||||
|
"Follows us, pending approval": "Nous suit, en attente de validation",
|
||||||
|
"No instance found": "Aucune instance trouvée",
|
||||||
|
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
|
||||||
|
"You haven't interacted with other instances yet.": "Vous n'avez interagi avec encore aucune autre instance.",
|
||||||
|
"mobilizon-instance.tld": "instance-mobilizon.tld",
|
||||||
|
"Report status": "Statut du signalement"
|
||||||
}
|
}
|
|
@ -1,38 +0,0 @@
|
||||||
import { IActor } from "@/types/actor";
|
|
||||||
import { ActorType } from "@/types/enums";
|
|
||||||
import { Component, Vue, Ref } from "vue-property-decorator";
|
|
||||||
import VueRouter from "vue-router";
|
|
||||||
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
|
||||||
|
|
||||||
@Component
|
|
||||||
export default class RelayMixin extends Vue {
|
|
||||||
@Ref("table") readonly table!: any;
|
|
||||||
|
|
||||||
toggle(row: Record<string, unknown>): void {
|
|
||||||
this.table.toggleDetails(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async pushRouter(
|
|
||||||
routeName: string,
|
|
||||||
args: Record<string, string>
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.$router.push({
|
|
||||||
name: routeName,
|
|
||||||
query: { ...this.$route.query, ...args },
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
|
|
||||||
throw Error(e.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static isInstance(actor: IActor): boolean {
|
|
||||||
return (
|
|
||||||
actor.type === ActorType.APPLICATION &&
|
|
||||||
(actor.preferredUsername === "relay" ||
|
|
||||||
actor.preferredUsername === actor.domain)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -11,9 +11,8 @@ export enum SettingsRouteName {
|
||||||
ADMIN = "ADMIN",
|
ADMIN = "ADMIN",
|
||||||
ADMIN_DASHBOARD = "ADMIN_DASHBOARD",
|
ADMIN_DASHBOARD = "ADMIN_DASHBOARD",
|
||||||
ADMIN_SETTINGS = "ADMIN_SETTINGS",
|
ADMIN_SETTINGS = "ADMIN_SETTINGS",
|
||||||
RELAYS = "Relays",
|
INSTANCES = "INSTANCES",
|
||||||
RELAY_FOLLOWINGS = "Followings",
|
INSTANCE = "INSTANCE",
|
||||||
RELAY_FOLLOWERS = "Followers",
|
|
||||||
USERS = "USERS",
|
USERS = "USERS",
|
||||||
PROFILES = "PROFILES",
|
PROFILES = "PROFILES",
|
||||||
ADMIN_PROFILE = "ADMIN_PROFILE",
|
ADMIN_PROFILE = "ADMIN_PROFILE",
|
||||||
|
@ -199,44 +198,35 @@ export const settingsRoutes: RouteConfig[] = [
|
||||||
meta: { requiredAuth: true, announcer: { skip: true } },
|
meta: { requiredAuth: true, announcer: { skip: true } },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "admin/relays",
|
path: "admin/instances",
|
||||||
name: SettingsRouteName.RELAYS,
|
name: SettingsRouteName.INSTANCES,
|
||||||
redirect: { name: SettingsRouteName.RELAY_FOLLOWINGS },
|
|
||||||
component: (): Promise<ImportedComponent> =>
|
|
||||||
import(/* webpackChunkName: "Follows" */ "@/views/Admin/Follows.vue"),
|
|
||||||
meta: { requiredAuth: true, announcer: { skip: true } },
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: "followings",
|
|
||||||
name: SettingsRouteName.RELAY_FOLLOWINGS,
|
|
||||||
component: (): Promise<ImportedComponent> =>
|
component: (): Promise<ImportedComponent> =>
|
||||||
import(
|
import(
|
||||||
/* webpackChunkName: "Followings" */ "@/components/Admin/Followings.vue"
|
/* webpackChunkName: "Instances" */ "@/views/Admin/Instances.vue"
|
||||||
),
|
),
|
||||||
meta: {
|
meta: {
|
||||||
requiredAuth: true,
|
requiredAuth: true,
|
||||||
announcer: {
|
announcer: {
|
||||||
message: (): string => i18n.t("Followings") as string,
|
message: (): string => i18n.t("Instances") as string,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
path: "followers",
|
|
||||||
name: SettingsRouteName.RELAY_FOLLOWERS,
|
|
||||||
component: (): Promise<ImportedComponent> =>
|
|
||||||
import(
|
|
||||||
/* webpackChunkName: "Followers" */ "@/components/Admin/Followers.vue"
|
|
||||||
),
|
|
||||||
meta: {
|
|
||||||
requiredAuth: true,
|
|
||||||
announcer: {
|
|
||||||
message: (): string => i18n.t("Followers") as string,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
props: true,
|
props: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "admin/instances/:domain",
|
||||||
|
name: SettingsRouteName.INSTANCE,
|
||||||
|
component: (): Promise<ImportedComponent> =>
|
||||||
|
import(
|
||||||
|
/* webpackChunkName: "Instance" */ "@/views/Admin/Instance.vue"
|
||||||
|
),
|
||||||
|
props: true,
|
||||||
|
meta: {
|
||||||
|
requiredAuth: true,
|
||||||
|
announcer: {
|
||||||
|
message: (): string => i18n.t("Instance") as string,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/moderation",
|
path: "/moderation",
|
||||||
name: SettingsRouteName.MODERATION,
|
name: SettingsRouteName.MODERATION,
|
||||||
|
|
|
@ -276,3 +276,15 @@ export enum EventMetadataCategories {
|
||||||
BOOKING = "BOOKING",
|
BOOKING = "BOOKING",
|
||||||
VIDEO_CONFERENCE = "VIDEO_CONFERENCE",
|
VIDEO_CONFERENCE = "VIDEO_CONFERENCE",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum InstanceFilterFollowStatus {
|
||||||
|
ALL = "ALL",
|
||||||
|
FOLLOWING = "FOLLOWING",
|
||||||
|
FOLLOWED = "FOLLOWED",
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InstanceFollowStatus {
|
||||||
|
APPROVED = "APPROVED",
|
||||||
|
PENDING = "PENDING",
|
||||||
|
NONE = "NONE",
|
||||||
|
}
|
||||||
|
|
14
js/src/types/instance.model.ts
Normal file
14
js/src/types/instance.model.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { InstanceFollowStatus } from "./enums";
|
||||||
|
|
||||||
|
export interface IInstance {
|
||||||
|
domain: string;
|
||||||
|
hasRelay: boolean;
|
||||||
|
followerStatus: InstanceFollowStatus;
|
||||||
|
followedStatus: InstanceFollowStatus;
|
||||||
|
personCount: number;
|
||||||
|
groupCount: number;
|
||||||
|
followersCount: number;
|
||||||
|
followingsCount: number;
|
||||||
|
reportsCount: number;
|
||||||
|
mediaSize: number;
|
||||||
|
}
|
|
@ -384,6 +384,12 @@ export default class AdminProfile extends Vue {
|
||||||
{
|
{
|
||||||
key: this.$t("Domain") as string,
|
key: this.$t("Domain") as string,
|
||||||
value: this.person.domain ? this.person.domain : this.$t("Local"),
|
value: this.person.domain ? this.person.domain : this.$t("Local"),
|
||||||
|
link: this.person.domain
|
||||||
|
? {
|
||||||
|
name: RouteName.INSTANCE,
|
||||||
|
params: { domain: this.person.domain },
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: this.$i18n.t("Uploaded media size"),
|
key: this.$i18n.t("Uploaded media size"),
|
||||||
|
|
|
@ -1,116 +0,0 @@
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<nav class="breadcrumb" aria-label="breadcrumbs">
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<router-link :to="{ name: RouteName.ADMIN }">{{
|
|
||||||
$t("Admin")
|
|
||||||
}}</router-link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<router-link :to="{ name: RouteName.RELAYS }">{{
|
|
||||||
$t("Federation")
|
|
||||||
}}</router-link>
|
|
||||||
</li>
|
|
||||||
<li class="is-active" v-if="$route.name == RouteName.RELAY_FOLLOWINGS">
|
|
||||||
<router-link :to="{ name: RouteName.RELAY_FOLLOWINGS }">{{
|
|
||||||
$t("Followings")
|
|
||||||
}}</router-link>
|
|
||||||
</li>
|
|
||||||
<li class="is-active" v-if="$route.name == RouteName.RELAY_FOLLOWERS">
|
|
||||||
<router-link :to="{ name: RouteName.RELAY_FOLLOWERS }">{{
|
|
||||||
$t("Followers")
|
|
||||||
}}</router-link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
<section>
|
|
||||||
<h1 class="title">{{ $t("Instances") }}</h1>
|
|
||||||
<div class="tabs is-boxed">
|
|
||||||
<ul>
|
|
||||||
<router-link
|
|
||||||
tag="li"
|
|
||||||
active-class="is-active"
|
|
||||||
:to="{ name: RouteName.RELAY_FOLLOWINGS }"
|
|
||||||
>
|
|
||||||
<a>
|
|
||||||
<b-icon icon="inbox-arrow-down"></b-icon>
|
|
||||||
<span>
|
|
||||||
{{ $t("Followings") }}
|
|
||||||
<b-tag rounded>{{ relayFollowings.total }}</b-tag>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</router-link>
|
|
||||||
<router-link
|
|
||||||
tag="li"
|
|
||||||
active-class="is-active"
|
|
||||||
:to="{ name: RouteName.RELAY_FOLLOWERS }"
|
|
||||||
>
|
|
||||||
<a>
|
|
||||||
<b-icon icon="inbox-arrow-up"></b-icon>
|
|
||||||
<span>
|
|
||||||
{{ $t("Followers") }}
|
|
||||||
<b-tag rounded>{{ relayFollowers.total }}</b-tag>
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
</router-link>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<router-view></router-view>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script lang="ts">
|
|
||||||
import { Component, Vue } from "vue-property-decorator";
|
|
||||||
import { RELAY_FOLLOWERS, RELAY_FOLLOWINGS } from "@/graphql/admin";
|
|
||||||
import { Paginate } from "@/types/paginate";
|
|
||||||
import { IFollower } from "@/types/actor/follower.model";
|
|
||||||
import RouteName from "../../router/name";
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
apollo: {
|
|
||||||
relayFollowings: {
|
|
||||||
query: RELAY_FOLLOWINGS,
|
|
||||||
fetchPolicy: "cache-and-network",
|
|
||||||
variables: {
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
relayFollowers: {
|
|
||||||
query: RELAY_FOLLOWERS,
|
|
||||||
fetchPolicy: "cache-and-network",
|
|
||||||
variables: {
|
|
||||||
page: 1,
|
|
||||||
limit: 10,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
metaInfo() {
|
|
||||||
return {
|
|
||||||
title: this.$t("Federation") as string,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
})
|
|
||||||
export default class Follows extends Vue {
|
|
||||||
RouteName = RouteName;
|
|
||||||
|
|
||||||
activeTab = 0;
|
|
||||||
|
|
||||||
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
|
|
||||||
|
|
||||||
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.tab-item {
|
|
||||||
form {
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
text-decoration: none !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
268
js/src/views/Admin/Instance.vue
Normal file
268
js/src/views/Admin/Instance.vue
Normal file
|
@ -0,0 +1,268 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="instance">
|
||||||
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||||
|
$t("Admin")
|
||||||
|
}}</router-link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{ name: RouteName.INSTANCES }">{{
|
||||||
|
$t("Instances")
|
||||||
|
}}</router-link>
|
||||||
|
</li>
|
||||||
|
<li class="is-active">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.INSTANCE,
|
||||||
|
params: { domain: instance.domain },
|
||||||
|
}"
|
||||||
|
>{{ instance.domain }}</router-link
|
||||||
|
>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<h1 class="text-2xl">{{ instance.domain }}</h1>
|
||||||
|
<div class="grid md:grid-cols-4 gap-2 content-center text-center mt-2">
|
||||||
|
<div class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.PROFILES,
|
||||||
|
query: { domain: instance.domain },
|
||||||
|
}"
|
||||||
|
class="dark:text-white hover:dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span class="mb-4 text-xl font-semibold block">{{
|
||||||
|
instance.personCount
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm block">{{ $t("Profiles") }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.ADMIN_GROUPS,
|
||||||
|
query: { domain: instance.domain },
|
||||||
|
}"
|
||||||
|
class="dark:text-white hover:dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span class="mb-4 text-xl font-semibold block">{{
|
||||||
|
instance.groupCount
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm block">{{ $t("Groups") }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800 dark:text-white hover:dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span class="mb-4 text-xl font-semibold block">{{
|
||||||
|
instance.followingsCount
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm block">{{ $t("Followings") }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800 dark:text-white hover:dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span class="mb-4 text-xl font-semibold block">{{
|
||||||
|
instance.followersCount
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm block">{{ $t("Followers") }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800">
|
||||||
|
<router-link to="/" class="dark:text-white hover:dark:text-slate-300">
|
||||||
|
<span class="mb-4 text-xl font-semibold block">{{
|
||||||
|
instance.reportsCount
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm block">{{ $t("Reports") }}</span>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 rounded-xl p-8 dark:bg-gray-800 dark:text-white hover:dark:text-slate-300"
|
||||||
|
>
|
||||||
|
<span class="mb-4 font-semibold block">{{
|
||||||
|
formatBytes(instance.mediaSize)
|
||||||
|
}}</span>
|
||||||
|
<span class="text-sm block">{{ $t("Uploaded media size") }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid md:grid-cols-2 gap-4" v-if="instance.hasRelay">
|
||||||
|
<div class="border bg-white p-6 shadow-md rounded-md">
|
||||||
|
<button
|
||||||
|
@click="removeInstanceFollow"
|
||||||
|
v-if="instance.followedStatus == InstanceFollowStatus.APPROVED"
|
||||||
|
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ $t("Stop following instance") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="removeInstanceFollow"
|
||||||
|
v-else-if="instance.followedStatus == InstanceFollowStatus.PENDING"
|
||||||
|
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ $t("Cancel follow request") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="followInstance"
|
||||||
|
v-else
|
||||||
|
class="bg-primary hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ $t("Follow instance") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="border bg-white p-6 shadow-md rounded-md">
|
||||||
|
<button
|
||||||
|
@click="acceptInstance"
|
||||||
|
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
|
||||||
|
class="bg-green-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ $t("Accept follow") }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="rejectInstance"
|
||||||
|
v-else-if="instance.followerStatus != InstanceFollowStatus.NONE"
|
||||||
|
class="bg-red-700 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 focus:ring-offset-gray-50 text-white hover:text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto"
|
||||||
|
>
|
||||||
|
{{ $t("Reject follow") }}
|
||||||
|
</button>
|
||||||
|
<p v-else>
|
||||||
|
{{ $t("This instance doesn't follow yours.") }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="md:h-48 py-16 text-center opacity-50">
|
||||||
|
{{ $t("Only Mobilizon instances can be followed") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
ACCEPT_RELAY,
|
||||||
|
ADD_INSTANCE,
|
||||||
|
INSTANCE,
|
||||||
|
REJECT_RELAY,
|
||||||
|
REMOVE_RELAY,
|
||||||
|
} from "@/graphql/admin";
|
||||||
|
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||||
|
import { formatBytes } from "@/utils/datetime";
|
||||||
|
import RouteName from "@/router/name";
|
||||||
|
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
||||||
|
import { IInstance } from "@/types/instance.model";
|
||||||
|
import { ApolloCache, gql, Reference } from "@apollo/client/core";
|
||||||
|
import { InstanceFollowStatus } from "@/types/enums";
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
apollo: {
|
||||||
|
instance: {
|
||||||
|
query: INSTANCE,
|
||||||
|
variables() {
|
||||||
|
return {
|
||||||
|
domain: this.domain,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class Instance extends Vue {
|
||||||
|
@Prop({ type: String, required: true }) domain!: string;
|
||||||
|
|
||||||
|
instance!: IInstance;
|
||||||
|
|
||||||
|
InstanceFollowStatus = InstanceFollowStatus;
|
||||||
|
|
||||||
|
formatBytes = formatBytes;
|
||||||
|
|
||||||
|
RouteName = RouteName;
|
||||||
|
|
||||||
|
async acceptInstance(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: ACCEPT_RELAY,
|
||||||
|
variables: {
|
||||||
|
address: `relay@${this.domain}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message) {
|
||||||
|
Snackbar.open({
|
||||||
|
message: e.message,
|
||||||
|
type: "is-danger",
|
||||||
|
position: "is-bottom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectInstance(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: REJECT_RELAY,
|
||||||
|
variables: {
|
||||||
|
address: `relay@${this.domain}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message) {
|
||||||
|
Snackbar.open({
|
||||||
|
message: e.message,
|
||||||
|
type: "is-danger",
|
||||||
|
position: "is-bottom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async followInstance(e: Event): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate<{ addInstance: Instance }>({
|
||||||
|
mutation: ADD_INSTANCE,
|
||||||
|
variables: {
|
||||||
|
domain: this.domain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message) {
|
||||||
|
Snackbar.open({
|
||||||
|
message: err.message,
|
||||||
|
type: "is-danger",
|
||||||
|
position: "is-bottom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeInstanceFollow(): Promise<void> {
|
||||||
|
const { instance } = this;
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate({
|
||||||
|
mutation: REMOVE_RELAY,
|
||||||
|
variables: {
|
||||||
|
address: `relay@${this.domain}`,
|
||||||
|
},
|
||||||
|
update(cache: ApolloCache<any>) {
|
||||||
|
cache.writeFragment({
|
||||||
|
id: cache.identify(instance as unknown as Reference),
|
||||||
|
fragment: gql`
|
||||||
|
fragment InstanceFollowedStatus on Instance {
|
||||||
|
followedStatus
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
data: {
|
||||||
|
followedStatus: InstanceFollowStatus.NONE,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.message) {
|
||||||
|
Snackbar.open({
|
||||||
|
message: e.message,
|
||||||
|
type: "is-danger",
|
||||||
|
position: "is-bottom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
305
js/src/views/Admin/Instances.vue
Normal file
305
js/src/views/Admin/Instances.vue
Normal file
|
@ -0,0 +1,305 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<nav class="breadcrumb" aria-label="breadcrumbs">
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<router-link :to="{ name: RouteName.ADMIN }">{{
|
||||||
|
$t("Admin")
|
||||||
|
}}</router-link>
|
||||||
|
</li>
|
||||||
|
<li class="is-active">
|
||||||
|
<router-link :to="{ name: RouteName.INSTANCES }">{{
|
||||||
|
$t("Instances")
|
||||||
|
}}</router-link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
<section>
|
||||||
|
<h1 class="title">{{ $t("Instances") }}</h1>
|
||||||
|
<form @submit="followInstance" class="my-4">
|
||||||
|
<b-field
|
||||||
|
:label="$t('Follow a new instance')"
|
||||||
|
custom-class="add-relay"
|
||||||
|
horizontal
|
||||||
|
>
|
||||||
|
<b-field grouped expanded size="is-large">
|
||||||
|
<p class="control">
|
||||||
|
<b-input
|
||||||
|
v-model="newRelayAddress"
|
||||||
|
:placeholder="$t('Ex: mobilizon.fr')"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p class="control">
|
||||||
|
<b-button type="is-primary" native-type="submit">{{
|
||||||
|
$t("Add an instance")
|
||||||
|
}}</b-button>
|
||||||
|
</p>
|
||||||
|
</b-field>
|
||||||
|
</b-field>
|
||||||
|
</form>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<b-field :label="$t('Follow status')">
|
||||||
|
<b-radio-button
|
||||||
|
v-model="followStatus"
|
||||||
|
:native-value="InstanceFilterFollowStatus.ALL"
|
||||||
|
>{{ $t("All") }}</b-radio-button
|
||||||
|
>
|
||||||
|
<b-radio-button
|
||||||
|
v-model="followStatus"
|
||||||
|
:native-value="InstanceFilterFollowStatus.FOLLOWING"
|
||||||
|
>{{ $t("Following") }}</b-radio-button
|
||||||
|
>
|
||||||
|
<b-radio-button
|
||||||
|
v-model="followStatus"
|
||||||
|
:native-value="InstanceFilterFollowStatus.FOLLOWED"
|
||||||
|
>{{ $t("Followed") }}</b-radio-button
|
||||||
|
>
|
||||||
|
</b-field>
|
||||||
|
<b-field
|
||||||
|
:label="$t('Domain')"
|
||||||
|
label-for="domain-filter"
|
||||||
|
class="flex-auto"
|
||||||
|
>
|
||||||
|
<b-input
|
||||||
|
id="domain-filter"
|
||||||
|
:placeholder="$t('mobilizon-instance.tld')"
|
||||||
|
:value="filterDomain"
|
||||||
|
@input="debouncedUpdateDomainFilter"
|
||||||
|
/>
|
||||||
|
</b-field>
|
||||||
|
</div>
|
||||||
|
<div v-if="instances && instances.elements.length > 0" class="mt-3">
|
||||||
|
<router-link
|
||||||
|
:to="{
|
||||||
|
name: RouteName.INSTANCE,
|
||||||
|
params: { domain: instance.domain },
|
||||||
|
}"
|
||||||
|
class="flex items-center mb-2 rounded bg-secondary p-4 flex-wrap justify-center gap-x-2 gap-y-3"
|
||||||
|
v-for="instance in instances.elements"
|
||||||
|
:key="instance.domain"
|
||||||
|
>
|
||||||
|
<div class="grow overflow-hidden flex items-center gap-1">
|
||||||
|
<img
|
||||||
|
class="w-12"
|
||||||
|
v-if="instance.hasRelay"
|
||||||
|
src="../../assets/logo.svg"
|
||||||
|
alt=""
|
||||||
|
/>
|
||||||
|
<b-icon
|
||||||
|
class="is-large"
|
||||||
|
v-else
|
||||||
|
custom-size="mdi-36px"
|
||||||
|
icon="cloud-question"
|
||||||
|
/>
|
||||||
|
<div class="">
|
||||||
|
<h4 class="text-lg truncate">{{ instance.domain }}</h4>
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
v-if="instance.followedStatus === InstanceFollowStatus.APPROVED"
|
||||||
|
>
|
||||||
|
<b-icon icon="inbox-arrow-down" />
|
||||||
|
{{ $t("Followed") }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
v-else-if="
|
||||||
|
instance.followedStatus === InstanceFollowStatus.PENDING
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<b-icon icon="inbox-arrow-down" />
|
||||||
|
{{ $t("Followed, pending response") }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
v-if="instance.followerStatus == InstanceFollowStatus.APPROVED"
|
||||||
|
>
|
||||||
|
<b-icon icon="inbox-arrow-up" />
|
||||||
|
{{ $t("Follows us") }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="text-sm"
|
||||||
|
v-if="instance.followerStatus == InstanceFollowStatus.PENDING"
|
||||||
|
>
|
||||||
|
<b-icon icon="inbox-arrow-up" />
|
||||||
|
{{ $t("Follows us, pending approval") }}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-none flex gap-3 ltr:ml-3 rtl:mr-3">
|
||||||
|
<p class="flex flex-col text-center">
|
||||||
|
<span class="text-xl">{{ instance.eventCount }}</span
|
||||||
|
><span class="text-sm">{{ $t("Events") }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="flex flex-col text-center">
|
||||||
|
<span class="text-xl">{{ instance.personCount }}</span
|
||||||
|
><span class="text-sm">{{ $t("Profiles") }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="instances && instances.elements.length == 0">
|
||||||
|
<empty-content icon="lan-disconnect" :inline="true">
|
||||||
|
{{ $t("No instance found.") }}
|
||||||
|
<template #desc>
|
||||||
|
<span v-if="hasFilter">
|
||||||
|
{{
|
||||||
|
$t(
|
||||||
|
"No instances match this filter. Try resetting filter fields?"
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
{{ $t("You haven't interacted with other instances yet.") }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</empty-content>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Vue } from "vue-property-decorator";
|
||||||
|
import { ADD_INSTANCE, INSTANCES } from "@/graphql/admin";
|
||||||
|
import { Paginate } from "@/types/paginate";
|
||||||
|
import { IFollower } from "@/types/actor/follower.model";
|
||||||
|
import RouteName from "../../router/name";
|
||||||
|
import { IInstance } from "@/types/instance.model";
|
||||||
|
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||||
|
import VueRouter from "vue-router";
|
||||||
|
import { debounce } from "lodash";
|
||||||
|
import {
|
||||||
|
InstanceFilterFollowStatus,
|
||||||
|
InstanceFollowStatus,
|
||||||
|
} from "@/types/enums";
|
||||||
|
import { SnackbarProgrammatic as Snackbar } from "buefy";
|
||||||
|
const { isNavigationFailure, NavigationFailureType } = VueRouter;
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
apollo: {
|
||||||
|
instances: {
|
||||||
|
query: INSTANCES,
|
||||||
|
fetchPolicy: "cache-and-network",
|
||||||
|
variables() {
|
||||||
|
return {
|
||||||
|
page: this.instancePage,
|
||||||
|
limit: 10,
|
||||||
|
filterDomain: this.filterDomain,
|
||||||
|
filterFollowStatus: this.followStatus,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
metaInfo() {
|
||||||
|
return {
|
||||||
|
title: this.$t("Federation") as string,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
EmptyContent,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class Follows extends Vue {
|
||||||
|
RouteName = RouteName;
|
||||||
|
|
||||||
|
newRelayAddress = "";
|
||||||
|
|
||||||
|
instances!: Paginate<IInstance>;
|
||||||
|
|
||||||
|
instancePage = 1;
|
||||||
|
|
||||||
|
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
|
||||||
|
|
||||||
|
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
|
||||||
|
|
||||||
|
InstanceFilterFollowStatus = InstanceFilterFollowStatus;
|
||||||
|
|
||||||
|
InstanceFollowStatus = InstanceFollowStatus;
|
||||||
|
|
||||||
|
data(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
debouncedUpdateDomainFilter: debounce(this.updateDomainFilter, 500),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDomainFilter(domain: string) {
|
||||||
|
this.filterDomain = domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
get filterDomain(): string {
|
||||||
|
return (this.$route.query.domain as string) || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
set filterDomain(domain: string) {
|
||||||
|
this.pushRouter({ domain });
|
||||||
|
}
|
||||||
|
|
||||||
|
get followStatus(): InstanceFilterFollowStatus {
|
||||||
|
return (
|
||||||
|
(this.$route.query.followStatus as InstanceFilterFollowStatus) ||
|
||||||
|
InstanceFilterFollowStatus.ALL
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
set followStatus(followStatus: InstanceFilterFollowStatus) {
|
||||||
|
this.pushRouter({ followStatus });
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasFilter(): boolean {
|
||||||
|
return (
|
||||||
|
this.followStatus !== InstanceFilterFollowStatus.ALL ||
|
||||||
|
this.filterDomain !== ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async followInstance(e: Event): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
const domain = this.newRelayAddress.trim(); // trim to fix copy and paste domain name spaces and tabs
|
||||||
|
try {
|
||||||
|
await this.$apollo.mutate<{ relayFollowings: Paginate<IFollower> }>({
|
||||||
|
mutation: ADD_INSTANCE,
|
||||||
|
variables: {
|
||||||
|
domain,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.newRelayAddress = "";
|
||||||
|
this.$router.push({
|
||||||
|
name: RouteName.INSTANCE,
|
||||||
|
params: { domain },
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.message) {
|
||||||
|
Snackbar.open({
|
||||||
|
message: err.message,
|
||||||
|
type: "is-danger",
|
||||||
|
position: "is-bottom",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async pushRouter(args: Record<string, string>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.$router.push({
|
||||||
|
name: RouteName.INSTANCES,
|
||||||
|
query: { ...this.$route.query, ...args },
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (isNavigationFailure(e, NavigationFailureType.redirected)) {
|
||||||
|
throw Error(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.tab-item {
|
||||||
|
form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||||
|
|
||||||
import Mobilizon.Users.Guards
|
import Mobilizon.Users.Guards
|
||||||
|
|
||||||
alias Mobilizon.{Actors, Admin, Config, Events}
|
alias Mobilizon.{Actors, Admin, Config, Events, Instances}
|
||||||
alias Mobilizon.Actors.{Actor, Follower}
|
alias Mobilizon.Actors.{Actor, Follower}
|
||||||
alias Mobilizon.Admin.{ActionLog, Setting}
|
alias Mobilizon.Admin.{ActionLog, Setting}
|
||||||
alias Mobilizon.Cldr.Language
|
alias Mobilizon.Cldr.Language
|
||||||
|
@ -329,6 +329,79 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||||
{:error, :unauthenticated}
|
{:error, :unauthenticated}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def get_instances(
|
||||||
|
_parent,
|
||||||
|
args,
|
||||||
|
%{
|
||||||
|
context: %{current_user: %User{role: role}}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
when is_admin(role) do
|
||||||
|
{:ok,
|
||||||
|
Instances.instances(
|
||||||
|
args
|
||||||
|
|> Keyword.new()
|
||||||
|
|> Keyword.take([
|
||||||
|
:page,
|
||||||
|
:limit,
|
||||||
|
:order_by,
|
||||||
|
:direction,
|
||||||
|
:filter_domain,
|
||||||
|
:filter_follow_status,
|
||||||
|
:filter_suspend_status
|
||||||
|
])
|
||||||
|
)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_instances(_parent, _args, %{context: %{current_user: %User{}}}) do
|
||||||
|
{:error, :unauthorized}
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_instances(_parent, _args, _resolution) do
|
||||||
|
{:error, :unauthenticated}
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_instance(_parent, %{domain: domain}, %{
|
||||||
|
context: %{current_user: %User{role: role}}
|
||||||
|
})
|
||||||
|
when is_admin(role) do
|
||||||
|
has_relay = Actors.has_relay?(domain)
|
||||||
|
remote_relay = Actors.get_actor_by_name("relay@#{domain}")
|
||||||
|
local_relay = Relay.get_actor()
|
||||||
|
|
||||||
|
result = %{
|
||||||
|
has_relay: has_relay,
|
||||||
|
follower_status: follow_status(remote_relay, local_relay),
|
||||||
|
followed_status: follow_status(local_relay, remote_relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
{:ok, Map.merge(Instances.instance(domain), result)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_instance(_parent, _args, %{context: %{current_user: %User{}}}) do
|
||||||
|
{:error, :unauthorized}
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_instance(_parent, _args, _resolution) do
|
||||||
|
{:error, :unauthenticated}
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_instance(
|
||||||
|
parent,
|
||||||
|
%{domain: domain} = args,
|
||||||
|
%{context: %{current_user: %User{role: role}}} = resolution
|
||||||
|
)
|
||||||
|
when is_admin(role) do
|
||||||
|
case Relay.follow(domain) do
|
||||||
|
{:ok, _activity, _follow} ->
|
||||||
|
Instances.refresh()
|
||||||
|
get_instance(parent, args, resolution)
|
||||||
|
|
||||||
|
{:error, err} ->
|
||||||
|
{:error, err}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
@spec create_relay(any(), map(), Absinthe.Resolution.t()) ::
|
@spec create_relay(any(), map(), Absinthe.Resolution.t()) ::
|
||||||
{:ok, Follower.t()} | {:error, any()}
|
{:ok, Follower.t()} | {:error, any()}
|
||||||
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
|
def create_relay(_parent, %{address: address}, %{context: %{current_user: %User{role: role}}})
|
||||||
|
@ -425,4 +498,15 @@ defmodule Mobilizon.GraphQL.Resolvers.Admin do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec follow_status(Actor.t() | nil, Actor.t() | nil) :: :approved | :pending | :none
|
||||||
|
defp follow_status(follower, followed) when follower != nil and followed != nil do
|
||||||
|
case Actors.check_follow(follower, followed) do
|
||||||
|
%Follower{approved: true} -> :approved
|
||||||
|
%Follower{approved: false} -> :pending
|
||||||
|
_ -> :none
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp follow_status(_, _), do: :none
|
||||||
end
|
end
|
||||||
|
|
|
@ -153,6 +153,80 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
||||||
value(:custom, as: "CUSTOM", description: "Custom privacy policy text")
|
value(:custom, as: "CUSTOM", description: "Custom privacy policy text")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
enum :instance_follow_status do
|
||||||
|
value(:approved, description: "The instance follow was approved")
|
||||||
|
value(:pending, description: "The instance follow is still pending")
|
||||||
|
value(:none, description: "There's no instance follow etablished")
|
||||||
|
end
|
||||||
|
|
||||||
|
enum :instances_sort_fields do
|
||||||
|
value(:event_count)
|
||||||
|
value(:person_count)
|
||||||
|
value(:group_count)
|
||||||
|
value(:followers_count)
|
||||||
|
value(:followings_count)
|
||||||
|
value(:reports_count)
|
||||||
|
value(:media_size)
|
||||||
|
end
|
||||||
|
|
||||||
|
enum :instance_filter_follow_status do
|
||||||
|
value(:all)
|
||||||
|
value(:following)
|
||||||
|
value(:followed)
|
||||||
|
end
|
||||||
|
|
||||||
|
enum :instance_filter_suspend_status do
|
||||||
|
value(:all)
|
||||||
|
value(:suspended)
|
||||||
|
end
|
||||||
|
|
||||||
|
@desc """
|
||||||
|
An instance representation
|
||||||
|
"""
|
||||||
|
object :instance do
|
||||||
|
field(:domain, :id, description: "The domain name of the instance")
|
||||||
|
field(:has_relay, :boolean, description: "Whether this instance has a Mobilizon relay actor")
|
||||||
|
field(:follower_status, :instance_follow_status, description: "Do we follow this instance")
|
||||||
|
field(:followed_status, :instance_follow_status, description: "Does this instance follow us?")
|
||||||
|
|
||||||
|
field(:event_count, :integer, description: "The number of events on this instance we know of")
|
||||||
|
|
||||||
|
field(:person_count, :integer,
|
||||||
|
description: "The number of profiles on this instance we know of"
|
||||||
|
)
|
||||||
|
|
||||||
|
field(:group_count, :integer, description: "The number of grouo on this instance we know of")
|
||||||
|
|
||||||
|
field(:followers_count, :integer,
|
||||||
|
description: "The number of their profiles who follow our groups"
|
||||||
|
)
|
||||||
|
|
||||||
|
field(:followings_count, :integer,
|
||||||
|
description: "The number of our profiles who follow their groups"
|
||||||
|
)
|
||||||
|
|
||||||
|
field(:reports_count, :integer,
|
||||||
|
description: "The number of reports made against profiles from this instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
field(:media_size, :integer,
|
||||||
|
description: "The size of all the media files sent by actors from this instance"
|
||||||
|
)
|
||||||
|
|
||||||
|
field(:has_relay, :boolean,
|
||||||
|
description:
|
||||||
|
"Whether this instance has a relay, meaning that it's a Mobilizon instance that we can follow"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@desc """
|
||||||
|
A paginated list of instances
|
||||||
|
"""
|
||||||
|
object :paginated_instance_list do
|
||||||
|
field(:elements, list_of(:instance), description: "A list of instances")
|
||||||
|
field(:total, :integer, description: "The total number of instances in the list")
|
||||||
|
end
|
||||||
|
|
||||||
object :admin_queries do
|
object :admin_queries do
|
||||||
@desc "Get the list of action logs"
|
@desc "Get the list of action logs"
|
||||||
field :action_logs, type: :paginated_action_log_list do
|
field :action_logs, type: :paginated_action_log_list do
|
||||||
|
@ -226,9 +300,59 @@ defmodule Mobilizon.GraphQL.Schema.AdminType do
|
||||||
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
|
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
|
||||||
resolve(&Admin.list_relay_followings/3)
|
resolve(&Admin.list_relay_followings/3)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@desc """
|
||||||
|
List instances
|
||||||
|
"""
|
||||||
|
field :instances, :paginated_instance_list do
|
||||||
|
arg(:page, :integer,
|
||||||
|
default_value: 1,
|
||||||
|
description: "The page in the paginated relay followings list"
|
||||||
|
)
|
||||||
|
|
||||||
|
arg(:limit, :integer,
|
||||||
|
default_value: 10,
|
||||||
|
description: "The limit of relay followings per page"
|
||||||
|
)
|
||||||
|
|
||||||
|
arg(:order_by, :instances_sort_fields,
|
||||||
|
default_value: :event_count,
|
||||||
|
description: "The field to order by the list"
|
||||||
|
)
|
||||||
|
|
||||||
|
arg(:filter_domain, :string, default_value: nil, description: "Filter by domain")
|
||||||
|
|
||||||
|
arg(:filter_follow_status, :instance_filter_follow_status,
|
||||||
|
default_value: :all,
|
||||||
|
description: "Whether or not to filter instances by the follow status"
|
||||||
|
)
|
||||||
|
|
||||||
|
arg(:filter_suspend_status, :instance_filter_suspend_status,
|
||||||
|
default_value: :all,
|
||||||
|
description: "Whether or not to filter instances by the suspended status"
|
||||||
|
)
|
||||||
|
|
||||||
|
arg(:direction, :string, default_value: :desc, description: "The sorting direction")
|
||||||
|
resolve(&Admin.get_instances/3)
|
||||||
|
end
|
||||||
|
|
||||||
|
@desc """
|
||||||
|
Get an instance's details
|
||||||
|
"""
|
||||||
|
field :instance, :instance do
|
||||||
|
arg(:domain, non_null(:id), description: "The instance domain")
|
||||||
|
resolve(&Admin.get_instance/3)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
object :admin_mutations do
|
object :admin_mutations do
|
||||||
|
@desc "Add an instance subscription"
|
||||||
|
field :add_instance, type: :instance do
|
||||||
|
arg(:domain, non_null(:string), description: "The instance domain to add")
|
||||||
|
|
||||||
|
resolve(&Admin.create_instance/3)
|
||||||
|
end
|
||||||
|
|
||||||
@desc "Add a relay subscription"
|
@desc "Add a relay subscription"
|
||||||
field :add_relay, type: :follower do
|
field :add_relay, type: :follower do
|
||||||
arg(:address, non_null(:string), description: "The relay hostname to add")
|
arg(:address, non_null(:string), description: "The relay hostname to add")
|
||||||
|
|
|
@ -1256,6 +1256,16 @@ defmodule Mobilizon.Actors do
|
||||||
:ok
|
:ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@spec has_relay?(String.t()) :: boolean()
|
||||||
|
def has_relay?(domain) do
|
||||||
|
Actor
|
||||||
|
|> where(
|
||||||
|
[a],
|
||||||
|
a.preferred_username == "relay" and a.domain == ^domain and a.type == :Application
|
||||||
|
)
|
||||||
|
|> Repo.exists?()
|
||||||
|
end
|
||||||
|
|
||||||
@spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
@spec delete_files_if_media_changed(Ecto.Changeset.t()) :: Ecto.Changeset.t()
|
||||||
defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do
|
defp delete_files_if_media_changed(%Ecto.Changeset{changes: changes, data: data} = changeset) do
|
||||||
Enum.each([:avatar, :banner], fn key ->
|
Enum.each([:avatar, :banner], fn key ->
|
||||||
|
|
19
lib/mobilizon/instances/instance.ex
Normal file
19
lib/mobilizon/instances/instance.ex
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
defmodule Mobilizon.Instances.Instance do
|
||||||
|
@moduledoc """
|
||||||
|
An instance representation
|
||||||
|
|
||||||
|
Using a MATERIALIZED VIEW underneath
|
||||||
|
"""
|
||||||
|
use Ecto.Schema
|
||||||
|
|
||||||
|
@primary_key {:domain, :string, []}
|
||||||
|
schema "instances" do
|
||||||
|
field(:event_count, :integer)
|
||||||
|
field(:person_count, :integer)
|
||||||
|
field(:group_count, :integer)
|
||||||
|
field(:followers_count, :integer)
|
||||||
|
field(:followings_count, :integer)
|
||||||
|
field(:reports_count, :integer)
|
||||||
|
field(:media_size, :integer)
|
||||||
|
end
|
||||||
|
end
|
115
lib/mobilizon/instances/instances.ex
Normal file
115
lib/mobilizon/instances/instances.ex
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
defmodule Mobilizon.Instances do
|
||||||
|
@moduledoc """
|
||||||
|
The instances context
|
||||||
|
"""
|
||||||
|
alias Ecto.Adapters.SQL
|
||||||
|
alias Mobilizon.Actors.{Actor, Follower}
|
||||||
|
alias Mobilizon.Instances.Instance
|
||||||
|
alias Mobilizon.Storage.{Page, Repo}
|
||||||
|
import Ecto.Query
|
||||||
|
|
||||||
|
@is_null_fragment "CASE WHEN ? IS NULL THEN FALSE ELSE TRUE END"
|
||||||
|
|
||||||
|
@spec instances(Keyword.t()) :: Page.t(Instance.t())
|
||||||
|
def instances(options) do
|
||||||
|
page = Keyword.get(options, :page)
|
||||||
|
limit = Keyword.get(options, :limit)
|
||||||
|
order_by = Keyword.get(options, :order_by)
|
||||||
|
direction = Keyword.get(options, :direction)
|
||||||
|
filter_domain = Keyword.get(options, :filter_domain)
|
||||||
|
# suspend_status = Keyword.get(options, :filter_suspend_status)
|
||||||
|
follow_status = Keyword.get(options, :filter_follow_status)
|
||||||
|
|
||||||
|
order_by_options = Keyword.new([{direction, order_by}])
|
||||||
|
|
||||||
|
subquery =
|
||||||
|
Actor
|
||||||
|
|> where(
|
||||||
|
[a],
|
||||||
|
a.preferred_username == "relay" and a.type == :Application and not is_nil(a.domain)
|
||||||
|
)
|
||||||
|
|> join(:left, [a], f1 in Follower, on: f1.target_actor_id == a.id)
|
||||||
|
|> join(:left, [a], f2 in Follower, on: f2.actor_id == a.id)
|
||||||
|
|> select([a, f1, f2], %{
|
||||||
|
domain: a.domain,
|
||||||
|
has_relay: fragment(@is_null_fragment, a.id),
|
||||||
|
following: fragment(@is_null_fragment, f2.id),
|
||||||
|
following_approved: f2.approved,
|
||||||
|
follower: fragment(@is_null_fragment, f1.id),
|
||||||
|
follower_approved: f1.approved
|
||||||
|
})
|
||||||
|
|
||||||
|
query =
|
||||||
|
Instance
|
||||||
|
|> join(:left, [i], s in subquery(subquery), on: i.domain == s.domain)
|
||||||
|
|> select([i, s], {i, s})
|
||||||
|
|> order_by(^order_by_options)
|
||||||
|
|
||||||
|
query =
|
||||||
|
if is_nil(filter_domain) or filter_domain == "" do
|
||||||
|
query
|
||||||
|
else
|
||||||
|
where(query, [i], like(i.domain, ^"%#{filter_domain}%"))
|
||||||
|
end
|
||||||
|
|
||||||
|
query =
|
||||||
|
case follow_status do
|
||||||
|
:following -> where(query, [i, s], s.following == true)
|
||||||
|
:followed -> where(query, [i, s], s.follower == true)
|
||||||
|
:all -> query
|
||||||
|
end
|
||||||
|
|
||||||
|
%Page{elements: elements} = paged_instances = Page.build_page(query, page, limit, :domain)
|
||||||
|
|
||||||
|
%Page{
|
||||||
|
paged_instances
|
||||||
|
| elements: Enum.map(elements, &convert_instance_meta/1)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec instance(String.t()) :: Instance.t()
|
||||||
|
def instance(domain) do
|
||||||
|
Instance
|
||||||
|
|> where(domain: ^domain)
|
||||||
|
|> Repo.one()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec all_domains :: list(Instance.t())
|
||||||
|
def all_domains do
|
||||||
|
Instance
|
||||||
|
|> distinct(true)
|
||||||
|
|> select([:domain])
|
||||||
|
|> Repo.all()
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh :: %{
|
||||||
|
:rows => nil | [[term()] | binary()],
|
||||||
|
:num_rows => non_neg_integer(),
|
||||||
|
optional(atom()) => any()
|
||||||
|
}
|
||||||
|
def refresh do
|
||||||
|
SQL.query!(Repo, "REFRESH MATERIALIZED VIEW instances")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp convert_instance_meta(
|
||||||
|
{instance,
|
||||||
|
%{
|
||||||
|
domain: _domain,
|
||||||
|
follower: follower,
|
||||||
|
follower_approved: follower_approved,
|
||||||
|
following: following,
|
||||||
|
following_approved: following_approved,
|
||||||
|
has_relay: has_relay
|
||||||
|
}}
|
||||||
|
) do
|
||||||
|
instance
|
||||||
|
|> Map.put(:follower_status, follow_status(following, following_approved))
|
||||||
|
|> Map.put(:followed_status, follow_status(follower, follower_approved))
|
||||||
|
|> Map.put(:has_relay, has_relay)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp follow_status(true, true), do: :approved
|
||||||
|
defp follow_status(true, false), do: :pending
|
||||||
|
defp follow_status(false, _), do: :none
|
||||||
|
defp follow_status(nil, _), do: :none
|
||||||
|
end
|
31
lib/service/workers/refresh_instances.ex
Normal file
31
lib/service/workers/refresh_instances.ex
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
defmodule Mobilizon.Service.Workers.RefreshInstances do
|
||||||
|
@moduledoc """
|
||||||
|
Worker to refresh the instances materialized view and the relay actors
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Oban.Worker, unique: [period: :infinity, keys: [:event_uuid, :action]]
|
||||||
|
|
||||||
|
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
|
||||||
|
alias Mobilizon.Instances
|
||||||
|
alias Mobilizon.Instances.Instance
|
||||||
|
alias Oban.Job
|
||||||
|
|
||||||
|
@impl Oban.Worker
|
||||||
|
@spec perform(Oban.Job.t()) :: :ok
|
||||||
|
def perform(%Job{}) do
|
||||||
|
Instances.refresh()
|
||||||
|
|
||||||
|
Instances.all_domains()
|
||||||
|
|> Enum.each(&refresh_instance_actor/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
@spec refresh_instance_actor(Instance.t()) ::
|
||||||
|
{:ok, Mobilizon.Actors.Actor.t()}
|
||||||
|
| {:error,
|
||||||
|
Mobilizon.Federation.ActivityPub.Actor.make_actor_errors()
|
||||||
|
| Mobilizon.Federation.WebFinger.finger_errors()}
|
||||||
|
|
||||||
|
defp refresh_instance_actor(%Instance{domain: domain}) do
|
||||||
|
ActivityPubActor.find_or_make_actor_from_nickname("relay@#{domain}")
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,64 @@
|
||||||
|
defmodule Mobilizon.Storage.Repo.Migrations.AddInstanceMaterializedView do
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
execute("""
|
||||||
|
CREATE MATERIALIZED VIEW instances AS
|
||||||
|
SELECT
|
||||||
|
a.domain,
|
||||||
|
COUNT(DISTINCT(p.id)) AS person_count,
|
||||||
|
COUNT(DISTINCT(g.id)) AS group_count,
|
||||||
|
COUNT(DISTINCT(e.id)) AS event_count,
|
||||||
|
COUNT(f1.id) AS followers_count,
|
||||||
|
COUNT(f2.id) AS followings_count,
|
||||||
|
COUNT(r.id) AS reports_count,
|
||||||
|
SUM(COALESCE((m.file->>'size')::int, 0)) AS media_size
|
||||||
|
FROM actors a
|
||||||
|
LEFT JOIN actors p ON a.id = p.id AND p.type = 'Person'
|
||||||
|
LEFT JOIN actors g ON a.id = g.id AND g.type = 'Group'
|
||||||
|
LEFT JOIN events e ON a.id = e.organizer_actor_id
|
||||||
|
LEFT JOIN followers f1 ON a.id = f1.actor_id
|
||||||
|
LEFT JOIN followers f2 ON a.id = f2.target_actor_id
|
||||||
|
LEFT JOIN reports r ON r.reported_id = a.id
|
||||||
|
LEFT JOIN medias m ON m.actor_id = a.id
|
||||||
|
WHERE a.domain IS NOT NULL
|
||||||
|
GROUP BY a.domain;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION refresh_instances()
|
||||||
|
RETURNS trigger AS $$
|
||||||
|
BEGIN
|
||||||
|
REFRESH MATERIALIZED VIEW instances;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
DROP TRIGGER IF EXISTS refresh_instances_trigger ON actors;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
CREATE TRIGGER refresh_instances_trigger
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE
|
||||||
|
ON actors
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE PROCEDURE refresh_instances();
|
||||||
|
""")
|
||||||
|
|
||||||
|
create_if_not_exists(unique_index("instances", [:domain]))
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
drop_if_exists(unique_index("instances", [:domain]))
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
DROP FUNCTION IF EXISTS refresh_instances() CASCADE;
|
||||||
|
""")
|
||||||
|
|
||||||
|
execute("""
|
||||||
|
DROP MATERIALIZED VIEW IF EXISTS instances;
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
end
|
226
schema.graphql
226
schema.graphql
|
@ -211,6 +211,9 @@ type Config {
|
||||||
"The instance's features"
|
"The instance's features"
|
||||||
features: Features
|
features: Features
|
||||||
|
|
||||||
|
"The instance's restrictions"
|
||||||
|
restrictions: Restrictions
|
||||||
|
|
||||||
"The instance's version"
|
"The instance's version"
|
||||||
version: String
|
version: String
|
||||||
|
|
||||||
|
@ -240,6 +243,9 @@ type Config {
|
||||||
|
|
||||||
"Web Push settings for the instance"
|
"Web Push settings for the instance"
|
||||||
webPush: WebPush
|
webPush: WebPush
|
||||||
|
|
||||||
|
"The instance list of export formats"
|
||||||
|
exportFormats: ExportFormats
|
||||||
}
|
}
|
||||||
|
|
||||||
"A tag"
|
"A tag"
|
||||||
|
@ -306,7 +312,13 @@ type TodoList {
|
||||||
actor: Actor
|
actor: Actor
|
||||||
|
|
||||||
"The todo-list's todos"
|
"The todo-list's todos"
|
||||||
todos: PaginatedTodoList
|
todos(
|
||||||
|
"The page in the paginated todos list"
|
||||||
|
page: Int
|
||||||
|
|
||||||
|
"The limit of todos per page"
|
||||||
|
limit: Int
|
||||||
|
): PaginatedTodoList
|
||||||
}
|
}
|
||||||
|
|
||||||
"Represents a participant to an event"
|
"Represents a participant to an event"
|
||||||
|
@ -455,7 +467,7 @@ type Comment implements ActivityObject & ActionLogObject {
|
||||||
isAnnouncement: Boolean!
|
isAnnouncement: Boolean!
|
||||||
|
|
||||||
"The comment language"
|
"The comment language"
|
||||||
language: String!
|
language: String
|
||||||
}
|
}
|
||||||
|
|
||||||
"An attached media or a link to a media"
|
"An attached media or a link to a media"
|
||||||
|
@ -690,6 +702,9 @@ enum ExportFormatEnum {
|
||||||
|
|
||||||
"PDF format"
|
"PDF format"
|
||||||
PDF
|
PDF
|
||||||
|
|
||||||
|
"ODS format"
|
||||||
|
ODS
|
||||||
}
|
}
|
||||||
|
|
||||||
"The list of visibility options for a comment"
|
"The list of visibility options for a comment"
|
||||||
|
@ -770,6 +785,14 @@ interface Interactable {
|
||||||
url: String
|
url: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum EventType {
|
||||||
|
"The event will happen in person. It can also be livestreamed, but has a physical address"
|
||||||
|
IN_PERSON
|
||||||
|
|
||||||
|
"The event will only happen online. It has no physical address"
|
||||||
|
ONLINE
|
||||||
|
}
|
||||||
|
|
||||||
enum EventMetadataType {
|
enum EventMetadataType {
|
||||||
"A string"
|
"A string"
|
||||||
STRING
|
STRING
|
||||||
|
@ -794,6 +817,14 @@ type DeletedObject {
|
||||||
id: ID
|
id: ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"A follow group event"
|
||||||
|
type FollowedGroupEvent {
|
||||||
|
user: User
|
||||||
|
profile: Person
|
||||||
|
group: Group
|
||||||
|
event: Event
|
||||||
|
}
|
||||||
|
|
||||||
"A paginated list of comments"
|
"A paginated list of comments"
|
||||||
type PaginatedCommentList {
|
type PaginatedCommentList {
|
||||||
"A list of comments"
|
"A list of comments"
|
||||||
|
@ -851,7 +882,7 @@ type Post implements ActivityObject {
|
||||||
updatedAt: DateTime
|
updatedAt: DateTime
|
||||||
|
|
||||||
"The post language"
|
"The post language"
|
||||||
language: String!
|
language: String
|
||||||
|
|
||||||
"The post's tags"
|
"The post's tags"
|
||||||
tags: [Tag]
|
tags: [Tag]
|
||||||
|
@ -941,6 +972,12 @@ type Statistics {
|
||||||
numberOfInstanceFollowings: Int
|
numberOfInstanceFollowings: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"Export formats configuration"
|
||||||
|
type ExportFormats {
|
||||||
|
"The list of formats the event participants can be exported to"
|
||||||
|
eventParticipants: [String]
|
||||||
|
}
|
||||||
|
|
||||||
"Search persons result"
|
"Search persons result"
|
||||||
type Persons {
|
type Persons {
|
||||||
"Total elements"
|
"Total elements"
|
||||||
|
@ -1128,6 +1165,18 @@ type Person implements ActionLogObject & Actor {
|
||||||
"The limit of memberships per page"
|
"The limit of memberships per page"
|
||||||
limit: Int
|
limit: Int
|
||||||
): PaginatedMemberList
|
): PaginatedMemberList
|
||||||
|
|
||||||
|
"The list of groups this person follows"
|
||||||
|
follows(
|
||||||
|
"Filter by group federated username"
|
||||||
|
group: String
|
||||||
|
|
||||||
|
"The page in the follows list"
|
||||||
|
page: Int
|
||||||
|
|
||||||
|
"The limit of follows per page"
|
||||||
|
limit: Int
|
||||||
|
): PaginatedFollowerList
|
||||||
}
|
}
|
||||||
|
|
||||||
"Root Mutation"
|
"Root Mutation"
|
||||||
|
@ -1396,6 +1445,30 @@ type RootMutationType {
|
||||||
groupId: ID!
|
groupId: ID!
|
||||||
): DeletedObject
|
): DeletedObject
|
||||||
|
|
||||||
|
"Follow a group"
|
||||||
|
followGroup(
|
||||||
|
"The group ID"
|
||||||
|
groupId: ID!
|
||||||
|
|
||||||
|
"Whether to notify profile from group activity"
|
||||||
|
notify: Boolean
|
||||||
|
): Follower
|
||||||
|
|
||||||
|
"Update a group follow"
|
||||||
|
updateGroupFollow(
|
||||||
|
"The follow ID"
|
||||||
|
followId: ID!
|
||||||
|
|
||||||
|
"Whether to notify profile from group activity"
|
||||||
|
notify: Boolean
|
||||||
|
): Follower
|
||||||
|
|
||||||
|
"Unfollow a group"
|
||||||
|
unfollowGroup(
|
||||||
|
"The group ID"
|
||||||
|
groupId: ID!
|
||||||
|
): Follower
|
||||||
|
|
||||||
"Create an event"
|
"Create an event"
|
||||||
createEvent(
|
createEvent(
|
||||||
"The event's title"
|
"The event's title"
|
||||||
|
@ -1589,6 +1662,9 @@ type RootMutationType {
|
||||||
|
|
||||||
"The anonymous participant's locale"
|
"The anonymous participant's locale"
|
||||||
locale: String
|
locale: String
|
||||||
|
|
||||||
|
"The anonymous participant's timezone"
|
||||||
|
timezone: String
|
||||||
): Participant
|
): Participant
|
||||||
|
|
||||||
"Leave an event"
|
"Leave an event"
|
||||||
|
@ -1663,6 +1739,18 @@ type RootMutationType {
|
||||||
id: ID!
|
id: ID!
|
||||||
): Member
|
): Member
|
||||||
|
|
||||||
|
"Approve a membership request"
|
||||||
|
approveMember(
|
||||||
|
"The member ID"
|
||||||
|
memberId: ID!
|
||||||
|
): Member
|
||||||
|
|
||||||
|
"Reject a membership request"
|
||||||
|
rejectMember(
|
||||||
|
"The member ID"
|
||||||
|
memberId: ID!
|
||||||
|
): Member
|
||||||
|
|
||||||
"Update a member's role"
|
"Update a member's role"
|
||||||
updateMember(
|
updateMember(
|
||||||
"The member ID"
|
"The member ID"
|
||||||
|
@ -1674,11 +1762,11 @@ type RootMutationType {
|
||||||
|
|
||||||
"Remove a member from a group"
|
"Remove a member from a group"
|
||||||
removeMember(
|
removeMember(
|
||||||
"The group ID"
|
|
||||||
groupId: ID!
|
|
||||||
|
|
||||||
"The member ID"
|
"The member ID"
|
||||||
memberId: ID!
|
memberId: ID!
|
||||||
|
|
||||||
|
"Whether the member should be excluded from the group"
|
||||||
|
exclude: Boolean
|
||||||
): Member
|
): Member
|
||||||
|
|
||||||
"Create a Feed Token"
|
"Create a Feed Token"
|
||||||
|
@ -2108,6 +2196,12 @@ type RootQueryType {
|
||||||
"A geohash for coordinates"
|
"A geohash for coordinates"
|
||||||
location: String
|
location: String
|
||||||
|
|
||||||
|
"Whether to include the groups the current actor is member or follower"
|
||||||
|
excludeMyGroups: Boolean
|
||||||
|
|
||||||
|
"The minimum visibility the group must have"
|
||||||
|
minimumVisibility: GroupVisibility
|
||||||
|
|
||||||
"Radius around the location to search in"
|
"Radius around the location to search in"
|
||||||
radius: Float
|
radius: Float
|
||||||
|
|
||||||
|
@ -2128,6 +2222,9 @@ type RootQueryType {
|
||||||
"A geohash for coordinates"
|
"A geohash for coordinates"
|
||||||
location: String
|
location: String
|
||||||
|
|
||||||
|
"Whether the event is online or in person"
|
||||||
|
type: EventType
|
||||||
|
|
||||||
"Radius around the location to search in"
|
"Radius around the location to search in"
|
||||||
radius: Float
|
radius: Float
|
||||||
|
|
||||||
|
@ -2389,6 +2486,12 @@ type RootQueryType {
|
||||||
direction: String
|
direction: String
|
||||||
): PaginatedFollowerList
|
): PaginatedFollowerList
|
||||||
|
|
||||||
|
"Get an instance's details"
|
||||||
|
instance(
|
||||||
|
"The instance domain"
|
||||||
|
domain: ID!
|
||||||
|
): Instance
|
||||||
|
|
||||||
"Get a todo list"
|
"Get a todo list"
|
||||||
todoList(
|
todoList(
|
||||||
"The todo-list ID"
|
"The todo-list ID"
|
||||||
|
@ -2461,6 +2564,39 @@ string.
|
||||||
"""
|
"""
|
||||||
scalar NaiveDateTime
|
scalar NaiveDateTime
|
||||||
|
|
||||||
|
"An instance representation"
|
||||||
|
type Instance {
|
||||||
|
"The domain name of the instance"
|
||||||
|
domain: ID
|
||||||
|
|
||||||
|
"Whether this instance has a Mobilizon relay actor"
|
||||||
|
hasRelay: Boolean
|
||||||
|
|
||||||
|
"Do we follow this instance"
|
||||||
|
followerStatus: InstanceFollowStatus
|
||||||
|
|
||||||
|
"Does this instance follow us?"
|
||||||
|
followedStatus: InstanceFollowStatus
|
||||||
|
|
||||||
|
"The number of profiles on this instance we know of"
|
||||||
|
personCount: Int
|
||||||
|
|
||||||
|
"The number of grouo on this instance we know of"
|
||||||
|
groupCount: Int
|
||||||
|
|
||||||
|
"The number of their profiles who follow our groups"
|
||||||
|
followersCount: Int
|
||||||
|
|
||||||
|
"The number of our profiles who follow their groups"
|
||||||
|
followingsCount: Int
|
||||||
|
|
||||||
|
"The number of reports made against profiles from this instance"
|
||||||
|
reportsCount: Int
|
||||||
|
|
||||||
|
"The size of all the media files sent by actors from this instance"
|
||||||
|
mediaSize: Int
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
The `DateTime` scalar type represents a date and time in the UTC
|
The `DateTime` scalar type represents a date and time in the UTC
|
||||||
timezone. The DateTime appears in a JSON response as an ISO8601 formatted
|
timezone. The DateTime appears in a JSON response as an ISO8601 formatted
|
||||||
|
@ -2573,8 +2709,14 @@ input EventOptionsInput {
|
||||||
"Show event end time"
|
"Show event end time"
|
||||||
showEndTime: Boolean
|
showEndTime: Boolean
|
||||||
|
|
||||||
|
"The event's timezone"
|
||||||
|
timezone: String
|
||||||
|
|
||||||
"Whether to show or hide the person organizer when event is organized by a group"
|
"Whether to show or hide the person organizer when event is organized by a group"
|
||||||
hideOrganizerWhenGroupEvent: Boolean
|
hideOrganizerWhenGroupEvent: Boolean
|
||||||
|
|
||||||
|
"Whether the event is fully online"
|
||||||
|
isOnline: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
"A report object"
|
"A report object"
|
||||||
|
@ -2765,7 +2907,7 @@ type Event implements ActivityObject & Interactable & ActionLogObject {
|
||||||
metadata: [EventMetadata]
|
metadata: [EventMetadata]
|
||||||
|
|
||||||
"The event language"
|
"The event language"
|
||||||
language: String!
|
language: String
|
||||||
}
|
}
|
||||||
|
|
||||||
"An event offer"
|
"An event offer"
|
||||||
|
@ -2835,6 +2977,18 @@ input AddressInput {
|
||||||
|
|
||||||
"The address's original ID from the provider"
|
"The address's original ID from the provider"
|
||||||
originId: String
|
originId: String
|
||||||
|
|
||||||
|
"The (estimated) timezone of the location"
|
||||||
|
timezone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
"The instance's restrictions"
|
||||||
|
type Restrictions {
|
||||||
|
"Whether groups creation is allowed only for admin, not for all users"
|
||||||
|
onlyAdminCanCreateGroups: Boolean
|
||||||
|
|
||||||
|
"Whether events creation is allowed only for groups, not for persons"
|
||||||
|
onlyGroupsCanCreateEvents: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
"Instance anonymous configuration"
|
"Instance anonymous configuration"
|
||||||
|
@ -2916,8 +3070,14 @@ type EventOptions {
|
||||||
"Show event end time"
|
"Show event end time"
|
||||||
showEndTime: Boolean
|
showEndTime: Boolean
|
||||||
|
|
||||||
|
"The event's timezone"
|
||||||
|
timezone: String
|
||||||
|
|
||||||
"Whether to show or hide the person organizer when event is organized by a group"
|
"Whether to show or hide the person organizer when event is organized by a group"
|
||||||
hideOrganizerWhenGroupEvent: Boolean
|
hideOrganizerWhenGroupEvent: Boolean
|
||||||
|
|
||||||
|
"Whether the event is fully online"
|
||||||
|
isOnline: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
"A resource provider details"
|
"A resource provider details"
|
||||||
|
@ -2958,6 +3118,9 @@ type Follower {
|
||||||
"Whether the follow has been approved by the target actor"
|
"Whether the follow has been approved by the target actor"
|
||||||
approved: Boolean
|
approved: Boolean
|
||||||
|
|
||||||
|
"Whether the follower will be notified by the target actor's activity or not (applicable for profile\/group follows)"
|
||||||
|
notify: Boolean
|
||||||
|
|
||||||
"When the follow was created"
|
"When the follow was created"
|
||||||
insertedAt: DateTime
|
insertedAt: DateTime
|
||||||
|
|
||||||
|
@ -3322,6 +3485,15 @@ type PaginatedGroupList {
|
||||||
total: Int
|
total: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"A paginated list of follow group events"
|
||||||
|
type PaginatedFollowedGroupEvents {
|
||||||
|
"A list of follow group events"
|
||||||
|
elements: [FollowedGroupEvent]
|
||||||
|
|
||||||
|
"The total number of follow group events in the list"
|
||||||
|
total: Int
|
||||||
|
}
|
||||||
|
|
||||||
"Instance map tiles configuration"
|
"Instance map tiles configuration"
|
||||||
type Tiles {
|
type Tiles {
|
||||||
"The instance's tiles endpoint"
|
"The instance's tiles endpoint"
|
||||||
|
@ -3377,6 +3549,20 @@ type Address {
|
||||||
|
|
||||||
"The address's original ID from the provider"
|
"The address's original ID from the provider"
|
||||||
originId: String
|
originId: String
|
||||||
|
|
||||||
|
"The (estimated) timezone of the location"
|
||||||
|
timezone: String
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InstanceFollowStatus {
|
||||||
|
"The instance follow was approved"
|
||||||
|
APPROVED
|
||||||
|
|
||||||
|
"The instance follow is still pending"
|
||||||
|
PENDING
|
||||||
|
|
||||||
|
"There's no instance follow etablished"
|
||||||
|
NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
"The instance's terms configuration"
|
"The instance's terms configuration"
|
||||||
|
@ -3599,6 +3785,9 @@ type User implements ActionLogObject {
|
||||||
|
|
||||||
"The list of memberships for this user"
|
"The list of memberships for this user"
|
||||||
memberships(
|
memberships(
|
||||||
|
"A name to filter members by"
|
||||||
|
name: String
|
||||||
|
|
||||||
"The page in the paginated memberships list"
|
"The page in the paginated memberships list"
|
||||||
page: Int
|
page: Int
|
||||||
|
|
||||||
|
@ -3615,6 +3804,18 @@ type User implements ActionLogObject {
|
||||||
limit: Int
|
limit: Int
|
||||||
): [Event]
|
): [Event]
|
||||||
|
|
||||||
|
"The suggested events from the groups this user follows"
|
||||||
|
followedGroupEvents(
|
||||||
|
"The page in the follow group events list"
|
||||||
|
page: Int
|
||||||
|
|
||||||
|
"The limit of follow group events per page"
|
||||||
|
limit: Int
|
||||||
|
|
||||||
|
"Filter follow group events by event start datetime"
|
||||||
|
afterDatetime: DateTime
|
||||||
|
): PaginatedFollowedGroupEvents
|
||||||
|
|
||||||
"The list of settings for this user"
|
"The list of settings for this user"
|
||||||
settings: UserSettings
|
settings: UserSettings
|
||||||
|
|
||||||
|
@ -3731,6 +3932,9 @@ type Group implements ActionLogObject & ActivityObject & Interactable & Actor {
|
||||||
|
|
||||||
"A paginated list of group members"
|
"A paginated list of group members"
|
||||||
members(
|
members(
|
||||||
|
"A name to filter members by"
|
||||||
|
name: String
|
||||||
|
|
||||||
"The page in the paginated member list"
|
"The page in the paginated member list"
|
||||||
page: Int
|
page: Int
|
||||||
|
|
||||||
|
@ -3760,7 +3964,13 @@ type Group implements ActionLogObject & ActivityObject & Interactable & Actor {
|
||||||
): PaginatedPostList
|
): PaginatedPostList
|
||||||
|
|
||||||
"A paginated list of the todo lists this group has"
|
"A paginated list of the todo lists this group has"
|
||||||
todoLists: PaginatedTodoListList
|
todoLists(
|
||||||
|
"The page in the paginated todo-lists list"
|
||||||
|
page: Int
|
||||||
|
|
||||||
|
"The limit of todo-lists per page"
|
||||||
|
limit: Int
|
||||||
|
): PaginatedTodoListList
|
||||||
|
|
||||||
"A paginated list of the followers this group has"
|
"A paginated list of the followers this group has"
|
||||||
followers(
|
followers(
|
||||||
|
|
Loading…
Reference in a new issue