Add admin interface to manage instances subscriptions

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2019-12-03 11:29:51 +01:00
parent 0a96d70348
commit 334d66bf5d
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
141 changed files with 4198 additions and 1923 deletions

1
.gitignore vendored
View File

@ -19,6 +19,7 @@ erl_crash.dump
.env.test
/.env
.env.2
.env.1
/setup_db.psql

View File

@ -22,7 +22,7 @@ config :mobilizon, :instance,
repository: Mix.Project.config()[:source_url],
allow_relay: true,
# Federation is to be activated with Mobilizon 1.0.0-beta.2
federating: false,
federating: true,
remote_limit: 100_000,
upload_limit: 10_000_000,
avatar_upload_limit: 2_000_000,
@ -63,7 +63,7 @@ config :mobilizon, MobilizonWeb.Upload,
config :mobilizon, MobilizonWeb.Uploaders.Local, uploads: "uploads"
config :mobilizon, :media_proxy,
enabled: false,
enabled: true,
proxy_opts: [
redirect_on_failure: false,
max_body_length: 25 * 1_048_576,
@ -107,7 +107,9 @@ config :auto_linker,
# TODO: Set to :no_scheme when it works properly
validate_tld: true,
class: false,
strip_prefix: false
strip_prefix: false,
new_window: true,
rel: "noopener noreferrer ugc"
]
config :phoenix, :format_encoders, json: Jason, "activity-json": Jason
@ -120,6 +122,8 @@ config :ex_cldr,
config :http_signatures,
adapter: Mobilizon.Service.HTTPSignatures.Signature
config :mobilizon, :activitypub, sign_object_fetches: true
config :mobilizon, Mobilizon.Service.Geospatial.Nominatim,
endpoint:
System.get_env("GEOSPATIAL_NOMINATIM_ENDPOINT") || "https://nominatim.openstreetmap.org",
@ -155,7 +159,7 @@ config :mobilizon, :maps,
config :mobilizon, Oban,
repo: Mobilizon.Storage.Repo,
prune: {:maxlen, 10_000},
queues: [default: 10, search: 20]
queues: [default: 10, search: 20, background: 5]
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.

View File

@ -0,0 +1,104 @@
# Federation
## ActivityPub
Mobilizon uses [ActivityPub](http://activitypub.rocks/) to federate content between instances. It only supports the server-to-server part of [the ActivityPub spec](https://www.w3.org/TR/activitypub/).
It implements the [HTTP signatures spec](https://tools.ietf.org/html/draft-cavage-http-signatures-12) for authentication of inbox deliveries, but doesn't implement Linked Data Signatures for forwarded payloads, and instead fetches content when needed.
To match usernames to actors, Mobilizon uses [WebFinger](https://tools.ietf.org/html/rfc7033).
## Instance subscriptions
Instances subscribe to each other through an internal actor named `relay@instance.tld` that publishes (through `Announce`) every created content to it's followers. Each content creation share is saved so that updates and deletes are correctly sent to every
## Activities
Supported Activity | Supported Object
------------ | -------------
`Accept` | `Follow`, `Join`
`Announce` | `Object`
`Create` | `Note`, `Event`
`Delete` | `Object`
`Flag` | `Object`
`Follow` | `Object`
`Reject` | `Follow`, `Join`
`Remove` | `Note`, `Event`
`Undo` | `Announce`, `Follow`
`Update` | `Object`
## Extensions
### Event
The vocabulary for Event is based on [the Event object in ActivityStreams](https://www.w3.org/TR/activitystreams-vocabulary/#dfn-event), extended with :
* the [Event Schema](https://schema.org/Event) from Schema.org
* some properties from [iCalendar](https://tools.ietf.org/html/rfc5545), such as `ical:status` (see [this issue](https://framagit.org/framasoft/mobilizon/issues/320))
The following properties are added.
#### repliesModeration
Disabling replies is [an ongoing issue with ActivityPub](https://github.com/w3c/activitypub/issues/319) so we use a temporary property.
See [the corresponding issue](https://framagit.org/framasoft/mobilizon/issues/321).
Accepted values: `allow_all`, `closed`, `moderated` (not used at the moment)
Example:
```json
{
"@context": [
"...",
{
"mz": "https://joinmobilizon.org/ns#",
"repliesModerationOption": {
"@id": "mz:repliesModerationOption",
"@type": "mz:repliesModerationOptionType"
},
"repliesModerationOptionType": {
"@id": "mz:repliesModerationOptionType",
"@type": "rdfs:Class"
}
}
],
"...": "...",
"repliesModerationOption": "allow_all",
"type": "Event",
"url": "http://mobilizon1.com/events/8cf76e9f-c426-4912-9cd6-c7030b969611"
}
```
#### joinMode
Indicator of how new members may be able to join.
See [the corresponding issue](https://framagit.org/framasoft/mobilizon/issues/321).
Accepted values: `free`, `restricted`, `invite` (not used at the moment)
Example:
```json
{
"@context": [
"...",
{
"mz": "https://joinmobilizon.org/ns#",
"joinMode": {
"@id": "mz:joinMode",
"@type": "mz:joinModeType"
},
"joinModeType": {
"@id": "mz:joinModeType",
"@type": "rdfs:Class"
}
}
],
"...": "...",
"joinMode": "restricted",
"type": "Event",
"url": "http://mobilizon1.com/events/8cf76e9f-c426-4912-9cd6-c7030b969611"
}
```

View File

@ -12,15 +12,21 @@
"dev": "vue-cli-service build --watch",
"styleguide": "vue-cli-service styleguidist",
"styleguide:build": "vue-cli-service styleguidist:build",
"vue-i18n-extract": "vue-i18n-extract"
"vue-i18n-extract": "vue-i18n-extract",
"graphql:get-schema": "graphql get-schema",
"i18n-extract": "vue-i18n-extract report -v './src/**/*.?(ts|vue)' -l './src/i18n/en_US.json' -o output.json"
},
"dependencies": {
"@absinthe/socket": "^0.2.1",
"@absinthe/socket-apollo-link": "^0.2.1",
"@mdi/font": "^4.5.95",
"apollo-absinthe-upload-link": "^1.5.0",
"apollo-cache-inmemory": "^1.5.1",
"apollo-client": "^2.5.1",
"apollo-link": "^1.2.11",
"apollo-link-http": "^1.5.16",
"apollo-link-ws": "^1.0.19",
"apollo-utilities": "^1.3.2",
"buefy": "^0.8.2",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.1",
@ -30,6 +36,7 @@
"leaflet.locatecontrol": "^0.68.0",
"lodash": "^4.17.11",
"ngeohash": "^0.6.3",
"phoenix": "^1.4.11",
"register-service-worker": "^1.6.2",
"tippy.js": "4.3.5",
"tiptap": "^1.26.0",

View File

@ -26,7 +26,8 @@
</div>
<div class="media-content">
<span class="title" ref="title">{{ actorDisplayName }}</span><br>
<small class="has-text-grey">@{{ participant.actor.preferredUsername }}</small>
<small class="has-text-grey" v-if="participant.actor.domain">@{{ participant.actor.preferredUsername }}@{{ participant.actor.domain }}</small>
<small class="has-text-grey" v-else>@{{ participant.actor.preferredUsername }}</small>
</div>
</div>
</div>
@ -41,7 +42,7 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IActor, IPerson, Person } from '@/types/actor';
import { Person } from '@/types/actor';
import { IParticipant, ParticipantRole } from '@/types/event.model';
@Component

View File

@ -0,0 +1,141 @@
<template>
<div>
<b-table
v-show="relayFollowers.elements.length > 0"
:data="relayFollowers.elements"
:loading="$apollo.queries.relayFollowers.loading"
ref="table"
:checked-rows.sync="checkedRows"
:is-row-checkable="(row) => row.id !== 3"
detailed
:show-detail-icon="false"
paginated
backend-pagination
:total="relayFollowers.total"
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
<template slot-scope="props">
<b-table-column field="actor.id" label="ID" width="40" numeric>
{{ props.row.actor.id }}
</b-table-column>
<b-table-column field="actor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="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>
<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>
<a @click="toggle(props.row)" v-if="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="actor.updatedAt" :label="$t('Date')" sortable>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article>
<div class="content">
<strong>{{ props.row.actor.domain }}</strong>
<small>@{{ props.row.actor.preferredUsername }}</small>
<small>31m</small>
<br>
<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 { ACCEPT_RELAY, REJECT_RELAY, RELAY_FOLLOWERS } from '@/graphql/admin';
import { Paginate } from '@/types/paginate';
import { IFollower } from '@/types/actor/follower.model';
import RelayMixin from '@/mixins/relay';
@Component({
apollo: {
relayFollowers: {
query: RELAY_FOLLOWERS,
fetchPolicy: 'cache-and-network',
},
},
metaInfo() {
return {
title: this.$t('Followers') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Followers extends Mixins(RelayMixin) {
relayFollowers: Paginate<IFollower> = { elements: [], total: 0 };
async acceptRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async rejectRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.rejectRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async acceptRelay(address: String) {
await this.$apollo.mutate({
mutation: ACCEPT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
}
async rejectRelay(address: String) {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {
return this.checkedRows.some(checkedRow => !checkedRow.approved);
}
}
</script>

View File

@ -0,0 +1,142 @@
<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: test.mobilizon.org')" />
</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
:total="relayFollowings.total"
:per-page="perPage"
@page-change="onPageChange"
checkable
checkbox-position="left">
<template slot-scope="props">
<b-table-column field="targetActor.id" label="ID" width="40" numeric>
{{ props.row.targetActor.id }}
</b-table-column>
<b-table-column field="targetActor.type" :label="$t('Type')" width="80">
<b-icon icon="lan" v-if="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>
<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>
<a @click="toggle(props.row)" v-if="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>
{{ props.row.updatedAt | formatDateTimeString }}
</b-table-column>
</template>
<template slot="detail" slot-scope="props">
<article>
<div class="content">
<strong>{{ props.row.targetActor.domain }}</strong>
<small>@{{ props.row.targetActor.preferredUsername }}</small>
<small>31m</small>
<br>
<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.elements.length === 0">
{{ $t("You don't follow any instances yet.") }}
</b-message>
</div>
</template>
<script lang="ts">
import { Component, Mixins } from 'vue-property-decorator';
import { ADD_RELAY, RELAY_FOLLOWINGS, REMOVE_RELAY } from '@/graphql/admin';
import { IFollower } from '@/types/actor/follower.model';
import { Paginate } from '@/types/paginate';
import RelayMixin from '@/mixins/relay';
@Component({
apollo: {
relayFollowings: {
query: RELAY_FOLLOWINGS,
fetchPolicy: 'cache-and-network',
},
},
metaInfo() {
return {
title: this.$t('Followings') as string,
titleTemplate: '%s | Mobilizon',
};
},
})
export default class Followings extends Mixins(RelayMixin) {
relayFollowings: Paginate<IFollower> = { elements: [], total: 0 };
newRelayAddress: String = '';
async followRelay(e) {
e.preventDefault();
await this.$apollo.mutate({
mutation: ADD_RELAY,
variables: {
address: this.newRelayAddress,
},
// TODO: Handle cache update properly without refreshing
});
await this.$apollo.queries.relayFollowings.refetch();
this.newRelayAddress = '';
}
async removeRelays() {
await this.checkedRows.forEach((row: IFollower) => {
this.removeRelay(`${row.targetActor.preferredUsername}@${row.targetActor.domain}`);
});
}
async removeRelay(address: String) {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = [];
}
}
</script>

View File

@ -11,7 +11,8 @@
<div class="content">
<span class="first-line" v-if="!comment.deletedAt">
<strong>{{ comment.actor.name }}</strong>
<small>@{{ comment.actor.preferredUsername }}</small>
<small v-if="comment.actor.domain">@{{ comment.actor.preferredUsername }}@{{ comment.actor.domain }}</small>
<small v-else>@{{ comment.actor.preferredUsername }}</small>
<a class="comment-link has-text-grey" :href="commentId">
<small>{{ timeago(new Date(comment.updatedAt)) }}</small>
</a>
@ -202,7 +203,7 @@ export default class Comment extends Vue {
timeago(dateTime): String {
if (this.timeAgoInstance != null) {
// @ts-ignore
// @ts-ignore
return this.timeAgoInstance.format(dateTime);
}
return '';
@ -213,7 +214,7 @@ export default class Comment extends Vue {
}
get commentFromOrganizer(): boolean {
return this.event.organizerActor !== undefined && this.comment.actor.id === this.event.organizerActor.id;
return this.event.organizerActor !== undefined && this.comment.actor && this.comment.actor.id === this.event.organizerActor.id;
}
get commentId(): String {
@ -230,6 +231,7 @@ export default class Comment extends Vue {
title: this.$t('Report this comment'),
comment: this.comment,
onConfirm: this.reportComment,
outsideDomain: this.comment.actor.domain,
},
});
}
@ -244,6 +246,7 @@ export default class Comment extends Vue {
reportedId: this.comment.actor.id,
commentsIds: [this.comment.id],
content,
forward,
},
});
this.$buefy.notification.open({

View File

@ -221,7 +221,7 @@ export default class CommentTree extends Vue {
data: { thread: replies },
});
// @ts-ignore
// @ts-ignore
const parentCommentIndex = oldComments.findIndex(oldComment => oldComment.id === comment.originComment.id);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;

View File

@ -409,9 +409,9 @@ export default class EditorComponent extends Vue {
}
replyToComment(comment: IComment) {
console.log('called replyToComment', comment);
const actorModel = new Actor(comment.actor);
if (!this.editor) return;
console.log(this.editor.commands);
this.editor.commands.mention({ id: actorModel.id, label: actorModel.usernameWithDomain().substring(1) });
this.editor.focus();
}

View File

@ -112,7 +112,7 @@ export default class AddressAutoComplete extends Vue {
addressData: IAddress[] = [];
selected: IAddress = new Address();
isFetching: boolean = false;
queryText: string = this.value && (new Address(this.value)).fullName || '';
queryText: string = (this.value && (new Address(this.value)).fullName) || '';
addressModalActive: boolean = false;
private gettingLocation: boolean = false;
private location!: Position;
@ -164,6 +164,7 @@ export default class AddressAutoComplete extends Vue {
@Watch('value')
updateEditing() {
if (!(this.value && this.value.id)) return;
this.selected = this.value;
const address = new Address(this.selected);
this.queryText = `${address.poiInfos.name} ${address.poiInfos.alternativeName}`;

View File

@ -26,11 +26,11 @@ A button to set your participation
<div class="participation-button">
<b-dropdown aria-role="list" position="is-bottom-left" v-if="participation && participation.role === ParticipantRole.PARTICIPANT">
<button class="button is-success" type="button" slot="trigger">
<b-icon icon="check"></b-icon>
<b-icon icon="check" />
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
@ -45,11 +45,11 @@ A button to set your participation
<div v-else-if="participation && participation.role === ParticipantRole.NOT_APPROVED">
<b-dropdown aria-role="list" position="is-bottom-left" class="dropdown-disabled">
<button class="button is-success" type="button" slot="trigger">
<b-icon icon="timer-sand-empty"></b-icon>
<b-icon icon="timer-sand-empty" />
<template>
<span>{{ $t('I participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<!-- <b-dropdown-item :value="false" aria-role="listitem">-->
@ -73,7 +73,7 @@ A button to set your participation
<template>
<span>{{ $t('Participate') }}</span>
</template>
<b-icon icon="menu-down"></b-icon>
<b-icon icon="menu-down" />
</button>
<b-dropdown-item :value="true" aria-role="listitem" @click="joinEvent(currentActor)">
@ -84,12 +84,12 @@ A button to set your participation
</figure>
</div>
<div class="media-content">
<span>{{ $t('with {identity}', {identity: currentActor.preferredUsername }) }}</span>
<span>{{ $t('as {identity}', {identity: currentActor.preferredUsername }) }}</span>
</div>
</div>
</b-dropdown-item>
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal">
<b-dropdown-item :value="false" aria-role="listitem" @click="joinModal" v-if="identities.length > 1">
{{ $t('with another identity…')}}
</b-dropdown-item>
</b-dropdown>
@ -99,14 +99,32 @@ A button to set your participation
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IParticipant, ParticipantRole } from '@/types/event.model';
import { IPerson } from '@/types/actor';
import { IPerson, Person } from '@/types/actor';
import { IDENTITIES } from '@/graphql/actor';
import { CURRENT_USER_CLIENT } from '@/graphql/user';
import { ICurrentUser } from '@/types/current-user.model';
@Component
@Component({
apollo: {
currentUser: {
query: CURRENT_USER_CLIENT,
},
identities: {
query: IDENTITIES,
update: ({ identities }) => identities ? identities.map(identity => new Person(identity)) : [],
skip() {
return this.currentUser.isLoggedIn === false;
},
},
},
})
export default class ParticipationButton extends Vue {
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true }) currentActor!: IPerson;
ParticipantRole = ParticipantRole;
currentUser!: ICurrentUser;
identities: IPerson[] = [];
joinEvent(actor: IPerson) {
this.$emit('joinEvent', actor);

View File

@ -16,7 +16,7 @@ import { Component, Prop, Vue } from 'vue-property-decorator';
@Component({
beforeDestroy() {
// @ts-ignore
// @ts-ignore
this.parentContainer.removeLayer(this);
},
})

View File

@ -20,7 +20,14 @@
</div>
<div class="content columns">
<div class="column is-one-quarter-desktop">Reported by <img v-if="report.reporter.avatar" class="image" :src="report.reporter.avatar.url" /> @{{ report.reporter.preferredUsername }}</div>
<div class="column is-one-quarter-desktop">
<span v-if="report.reporter.type === ActorType.APPLICATION">
{{ $t('Reported by someone on {domain}', { domain: report.reporter.domain}) }}
</span>
<span v-else>
{{ $t('Reported by {reporter}', { reporter: report.reporter.preferredUsername}) }}
</span>
</div>
<div class="column" v-if="report.content">{{ report.content }}</div>
</div>
</div>
@ -29,10 +36,13 @@
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator';
import { IReport } from '@/types/report.model';
import { ActorType } from '@/types/actor';
@Component
export default class ReportCard extends Vue {
@Prop({ required: true }) report!: IReport;
ActorType = ActorType;
}
</script>
<style lang="scss">

View File

@ -44,11 +44,8 @@
/>
</div>
<p v-if="outsideDomain">
{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}
</p>
<div class="control" v-if="outsideDomain">
<p>{{ $t('The content came from another server. Transfer an anonymous copy of the report?') }}</p>
<b-switch v-model="forward">{{ $t('Transfer to {outsideDomain}', { outsideDomain }) }}</b-switch>
</div>
</div>

View File

@ -19,3 +19,87 @@ export const DASHBOARD = gql`
}
}
`;
export const RELAY_FRAGMENT = gql`
fragment relayFragment on Follower {
actor {
id,
preferredUsername,
name,
domain,
type,
summary
},
targetActor {
id,
preferredUsername,
name,
domain,
type,
summary
},
approved,
insertedAt,
updatedAt
}
`;
export const RELAY_FOLLOWERS = gql`
query relayFollowers($page: Int, $limit: Int) {
relayFollowers(page: $page, limit: $limit) {
elements {
...relayFragment
},
total
}
}
${RELAY_FRAGMENT}
`;
export const RELAY_FOLLOWINGS = gql`
query relayFollowings($page: Int, $limit: Int) {
relayFollowings(page: $page, limit: $limit) {
elements {
...relayFragment
},
total
}
}
${RELAY_FRAGMENT}
`;
export const ADD_RELAY = gql`
mutation addRelay($address: String!) {
addRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const REMOVE_RELAY = gql`
mutation removeRelay($address: String!) {
removeRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const ACCEPT_RELAY = gql`
mutation acceptRelay($address: String!) {
acceptRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;
export const REJECT_RELAY = gql`
mutation rejectRelay($address: String!) {
rejectRelay(address: $address) {
...relayFragment
}
}
${RELAY_FRAGMENT}
`;

View File

@ -13,6 +13,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
url
},
id,
domain,
preferredUsername,
name
},

View File

@ -10,7 +10,8 @@ const participantQuery = `
url
},
name,
id
id,
domain
},
event {
id
@ -441,3 +442,21 @@ export const EVENT_PERSON_PARTICIPATION = gql`
}
}
`;
export const EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED = gql`
subscription ($actorId: ID!, $eventId: ID!) {
eventPersonParticipationChanged(personId: $actorId) {
id,
participations(eventId: $eventId) {
id,
role,
actor {
id
},
event {
id
}
}
}
}
`;

View File

@ -18,7 +18,9 @@ export const REPORTS = gql`
name,
avatar {
url
}
},
domain,
type
},
event {
id,
@ -52,7 +54,9 @@ const REPORT_FRAGMENT = gql`
name,
avatar {
url
}
},
domain,
type
},
event {
id,
@ -111,9 +115,10 @@ export const CREATE_REPORT = gql`
$reporterId: ID!,
$reportedId: ID!,
$content: String,
$commentsIds: [ID]
$commentsIds: [ID],
$forward: Boolean
) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds) {
createReport(eventId: $eventId, reporterId: $reporterId, reportedId: $reportedId, content: $content, commentsIds: $commentsIds, forward: $forward) {
id
}
}

View File

@ -322,7 +322,6 @@
"resend confirmation email": "Bestätigungsmail erneut senden",
"respect of the fundamental freedoms": "Respekt für die fundamentalen Freiheiten",
"with another identity…": "mit einer anderen Identität.…",
"with {identity}": "mit {identity}",
"{approved} / {total} seats": "{approved} / {total} Plätze",
"{count} participants": "Noch keine Teilnehmer | Ein Teilnehmer | {count} Teilnehmer",
"{count} requests waiting": "{count} Anfragen ausstehend",

View File

@ -333,11 +333,59 @@
"resend confirmation email": "resend confirmation email",
"respect of the fundamental freedoms": "respect of the fundamental freedoms",
"with another identity…": "with another identity…",
"with {identity}": "with {identity}",
"as {identity}": "as {identity}",
"{approved} / {total} seats": "{approved} / {total} seats",
"{count} participants": "No participants yet | One participant | {count} participants",
"{count} requests waiting": "{count} requests waiting",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks",
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors"
"© The OpenStreetMap Contributors": "© The OpenStreetMap Contributors",
"Reply": "Reply",
"Accepted": "Accepted",
"Pending": "Pending",
"No instance to remove|Remove instance|Remove {number} instances": "No instances to remove|Remove instance|Remove {number} instances",
"Dashboard": "Dashboard",
"Reports": "Reports",
"Mark as resolved": "Mark as resolved",
"Reopen": "Reopen",
"Close": "Close",
"Reported identity": "Reported identity",
"Reported by": "Reported by",
"Reported": "Reported",
"Updated": "Updated",
"Open": "Open",
"Closed": "Closed",
"Resolved": "Resolved",
"Unknown": "Unknown",
"No comment": "No comment",
"Notes": "Notes",
"New note": "New note",
"Add a note": "Add a note",
"Deleting event": "Deleting event",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.",
"Delete Event": "Delete Event",
"Type": "Type",
"Domain": "Domain",
"Date": "Date",
"No instance to approve|Approve instance|Approve {number} instances": "No instance to approve|Approve instance|Approve {number} instances",
"No instance to reject|Reject instance|Reject {number} instances": "No instance to reject|Reject instance|Reject {number} instances",
"No instance follows your instance yet.": "No instance follows your instance yet.",
"Followers": "Followers",
"Add an instance": "Add an instance",
"Ex: test.mobilizon.org": "Ex: test.mobilizon.org",
"You don't follow any instances yet.": "You don't follow any instances yet.",
"Followings": "Followings",
"Instances": "Instances",
"Reported by {reporter}": "Reported by {reporter}",
"No open reports yet": "No open reports yet",
"No resolved reports yet": "No resolved reports yet",
"No closed reports yet": "No closed reports yet",
"Reported by someone on {domain}": "Reported by someone on {domain}",
"Your participation has been rejected": "Your participation has been rejected",
"Your participation status has been changed": "Your participation status has been changed",
"Unknown actor": "Unknown actor",
"Deleting comment": "Deleting comment",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Are you sure you want to <b>delete</b> this comment? This action cannot be undone.",
"Delete Comment": "Delete Comment",
"Comment deleted": "Comment deleted"
}

View File

@ -286,7 +286,7 @@
"Update my event": "Éditer mon événement",
"User accounts and every other data is currently deleted every 48 hours, so you may want to register again.": "Les comptes utilisateurs et toutes les autres données sont actuellement supprimées toutes les 48 heures, donc vous voulez peut-être vous inscrire à nouveau.",
"Username": "Pseudo",
"Users": "Utilisateurs",
"Users": "Utilisateur⋅ice⋅s",
"View a reply": "Aucune réponse | Voir une réponse | Voir {totalReplies} réponses",
"View event page": "Voir la page de l'événement",
"View everything": "Voir tout",
@ -337,11 +337,57 @@
"resend confirmation email": "réenvoyer l'email de confirmation",
"respect of the fundamental freedoms": "le respect des libertés fondamentales",
"with another identity…": "avec une autre identité…",
"with {identity}": "avec {identity}",
"as {identity}": "en tant que {identity}",
"{approved} / {total} seats": "{approved} / {total} places",
"{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s",
"{count} requests waiting": "Une demande en attente|{count} demandes en attente",
"{license} guarantees {respect} of the people who will use it. Since {source}, anyone can audit it, which guarantees its transparency.": "{license} garantit {respect} des personnes qui l'utiliseront. Puisque {source}, il est publiquement auditable, ce qui garantit sa transparence.",
"© The Mobilizon Contributors {date} - Made with Elixir, Phoenix, VueJS & with some love and some weeks": "© Les contributeurs de Mobilizon {date} - Fait avec Elixir, Phoenix, VueJS & et de l'amour et des semaines",
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap"
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
"Reply": "Répondre",
"Accepted": "Accepté",
"Pending": "En attente",
"No instance to remove|Remove instance|Remove {number} instances": "Pas d'instances à supprimer|Supprimer une instance|Supprimer {number} instances",
"Mark as resolved": "Marquer comme résolu",
"Reopen": "Réouvrir",
"Close": "Fermé",
"Reported identity": "Identité signalée",
"Reported by": "Signalée par",
"Reported": "Signalée",
"Updated": "Mis à jour",
"Open": "Ouvert",
"Closed": "Fermé",
"Resolved": "Résolu",
"Unknown": "Inconnu",
"No comment": "Pas de commentaire",
"Notes": "Notes",
"New note": "Nouvelle note",
"Add a note": "Ajouter une note",
"Deleting event": "Suppression de l'événement",
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the conversation with the event creator or edit its event instead.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> cet événement ? Cette action n'est pas réversible. Vous voulez peut-être engager la conversation avec le créateur de l'événement ou bien éditer son événement à la place.",
"Delete Event": "Supprimer l'événement",
"Type": "Type",
"Domain": "Domaine",
"Date": "Date",
"No instance to approve|Approve instance|Approve {number} instances": "Aucune instance à approuver|Approuver une instance|Approuver {number} instances",
"No instance to reject|Reject instance|Reject {number} instances": "Aucune instance à rejetter|Rejetter une instance|Rejetter {number} instances",
"No instance follows your instance yet.": "Aucune instance ne suit votre instance pour le moment.",
"Followers": "Abonnés",
"Add an instance": "Ajouter une instance",
"Ex: test.mobilizon.org": "Ex: test.mobilizon.org",
"You don't follow any instances yet.": "Vous ne suivez aucune instance pour le moment.",
"Followings": "Abonnements",
"Instances": "Instances",
"Reported by {reporter}": "Signalé par {reporter}",
"No open reports yet": "Aucun signalement ouvert pour le moment",
"No resolved reports yet": "Aucun signalement résolu pour le moment",
"No closed reports yet": "Aucun signalement fermé pour le moment",
"Reported by someone on {domain}": "Signalé par quelqu'un depuis {domain}",
"Your participation has been rejected": "Votre participation a été rejettée",
"Your participation status has been changed": "Le statut de votre participation a été mis à jour",
"Unknown actor": "Acteur inconnu",
"Deleting comment": "Suppression du commentaire en cours",
"Are you sure you want to <b>delete</b> this comment? This action cannot be undone.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire? Cette action ne peut pas être annulée.",
"Delete Comment": "Supprimer le commentaire",
"Comment deleted": "Commentaire supprimé"
}

View File

@ -321,7 +321,6 @@
"resend confirmation email": "bevestigingsemail opnieuw versturen",
"respect of the fundamental freedoms": "respect voor de fundamentele vrijheden",
"with another identity…": "met een andere identiteit…",
"with {identity}": "met {identity}",
"{approved} / {total} seats": "{approved} / {total} plaatsen",
"{count} participants": "Nog geen deelnemers | Eén deelnemer | {count} deelnemers",
"{count} requests waiting": "{count} aanvragen in afwachting",

View File

@ -368,7 +368,6 @@
"resend confirmation email": "tornar enviar lo messatge de confirmacion",
"respect of the fundamental freedoms": "lo respet de las libertats fondamentalas",
"with another identity…": "amb una autra identitat…",
"with {identity}": "amb {identity}",
"{actor}'s avatar": "Avatar de {actor}",
"{approved} / {total} seats": "{approved} / {total} plaças",
"{count} participants": "Cap de participacion pel moment|Un participant|{count} participants",

View File

@ -324,7 +324,6 @@
"resend confirmation email": "skicka bekräftelsemail igen",
"respect of the fundamental freedoms": "respektera våra grundläggande friheter",
"with another identity…": "med en annan identitet…",
"with {identity}": "med {identity}",
"{approved} / {total} seats": "{approved} / {total} platser",
"{count} participants": "Inga deltagande ännu|En deltagande|{count} deltagande",
"{count} requests waiting": "{count} förfrågningar väntar",

44
js/src/mixins/relay.ts Normal file
View File

@ -0,0 +1,44 @@
import { Component, Vue } from 'vue-property-decorator';
import { Refs } from '@/shims-vue';
import { ActorType, IActor } from '@/types/actor';
import { IFollower } from '@/types/actor/follower.model';
@Component
export default class RelayMixin extends Vue {
$refs!: Refs<{
table: any,
}>;
checkedRows: IFollower[] = [];
page: number = 1;
perPage: number = 2;
toggle(row) {
this.$refs.table.toggleDetails(row);
}
async onPageChange(page: number) {
this.page = page;
await this.$apollo.queries.relayFollowings.fetchMore({
variables: {
page: this.page,
limit: this.perPage,
},
updateQuery: (previousResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return previousResult;
const newFollowings = fetchMoreResult.relayFollowings.elements;
return {
relayFollowings: {
__typename: previousResult.relayFollowings.__typename,
total: previousResult.relayFollowings.total,
elements: [...previousResult.relayFollowings.elements, ...newFollowings],
},
};
},
});
}
isInstance(actor: IActor): boolean {
return actor.type === ActorType.APPLICATION && actor.preferredUsername === 'relay';
}
}

View File

@ -1,10 +1,12 @@
import Vue from 'vue';
import { ColorModifiers } from 'buefy/types/helpers';
declare module 'vue/types/vue' {
interface Vue {
$notifier: {
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
};
}
}
@ -17,21 +19,23 @@ export class Notifier {
}
success(message: string) {
this.vue.prototype.$buefy.notification.open({
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-success',
hasIcon: true,
});
this.notification(message, 'is-success');
}
error(message: string) {
this.notification(message, 'is-danger');
}
info(message: string) {
this.notification(message, 'is-info');
}
private notification(message: string, type: ColorModifiers) {
this.vue.prototype.$buefy.notification.open({
message,
duration: 5000,
position: 'is-bottom-right',
type: 'is-danger',
type,
hasIcon: true,
});
}

View File

@ -1,8 +1,14 @@
import { RouteConfig } from 'vue-router';
import Dashboard from '@/views/Admin/Dashboard.vue';
import Follows from '@/views/Admin/Follows.vue';
import Followings from '@/components/Admin/Followings.vue';
import Followers from '@/components/Admin/Followers.vue';
export enum AdminRouteName {
DASHBOARD = 'Dashboard',
RELAYS = 'Relays',
RELAY_FOLLOWINGS = 'Followings',
RELAY_FOLLOWERS = 'Followers',
}
export const adminRoutes: RouteConfig[] = [
@ -13,4 +19,24 @@ export const adminRoutes: RouteConfig[] = [
props: true,
meta: { requiredAuth: true },
},
{
path: '/admin/relays',
name: AdminRouteName.RELAYS,
redirect: { name: AdminRouteName.RELAY_FOLLOWINGS },
component: Follows,
children: [
{
path: 'followings',
name: AdminRouteName.RELAY_FOLLOWINGS,
component: Followings,
},
{
path: 'followers',
name: AdminRouteName.RELAY_FOLLOWERS,
component: Followers,
},
],
props: true,
meta: { requiredAuth: true },
},
];

View File

@ -1,5 +1,13 @@
import { IPicture } from '@/types/picture.model';
export enum ActorType {
PERSON = 'PERSON',
APPLICATION = 'APPLICATION',
GROUP = 'GROUP',
ORGANISATION = 'ORGANISATION',
SERVICE = 'SERVICE',
}
export interface IActor {
id?: number;
url: string;
@ -10,6 +18,7 @@ export interface IActor {
suspended: boolean;
avatar: IPicture | null;
banner: IPicture | null;
type: ActorType;
}
export class Actor implements IActor {
@ -22,6 +31,7 @@ export class Actor implements IActor {
summary: string = '';
suspended: boolean = false;
url: string = '';
type: ActorType = ActorType.PERSON;
constructor (hash: IActor | {} = {}) {
Object.assign(this, hash);

View File

@ -0,0 +1,8 @@
import { IActor } from '@/types/actor/actor.model';
export interface IFollower {