Improve and activate groups

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
master
Thomas Citharel 2 years ago
parent 1ca46a6863
commit 49a5725da3
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
  1. 5
      config/config.exs
  2. 2
      js/src/App.vue
  3. 13
      js/src/apollo/utils.ts
  4. 49
      js/src/components/Admin/Followers.vue
  5. 49
      js/src/components/Admin/Followings.vue
  6. 17
      js/src/components/Comment/Comment.vue
  7. 125
      js/src/components/Comment/CommentTree.vue
  8. 46
      js/src/components/Editor.vue
  9. 33
      js/src/components/Editor/MaxSize.ts
  10. 102
      js/src/components/Event/OrganizerPicker.vue
  11. 199
      js/src/components/Event/OrganizerPickerWrapper.vue
  12. 2
      js/src/components/Footer.vue
  13. 84
      js/src/components/Group/GroupMemberCard.vue
  14. 19
      js/src/components/Group/GroupPicker.vue
  15. 17
      js/src/components/Group/GroupPickerWrapper.vue
  16. 45
      js/src/components/Group/Invitations.vue
  17. 7
      js/src/components/Participation/ConfirmParticipation.vue
  18. 25
      js/src/components/Participation/ParticipationWithoutAccount.vue
  19. 18
      js/src/components/PictureUpload.vue
  20. 30
      js/src/components/Resource/FolderItem.vue
  21. 22
      js/src/components/Settings/NotificationsOnboarding.vue
  22. 23
      js/src/components/Todo/CompactTodo.vue
  23. 25
      js/src/components/Todo/FullTodo.vue
  24. 47
      js/src/graphql/actor.ts
  25. 4
      js/src/graphql/config.ts
  26. 35
      js/src/graphql/event.ts
  27. 1
      js/src/graphql/resources.ts
  28. 2
      js/src/graphql/todos.ts
  29. 55
      js/src/i18n/en_US.json
  30. 16
      js/src/i18n/fr_FR.json
  31. 5
      js/src/mixins/event.ts
  32. 8
      js/src/router/actor.ts
  33. 2
      js/src/router/guards/register-guard.ts
  34. 4
      js/src/types/actor/actor.model.ts
  35. 2
      js/src/types/config.model.ts
  36. 9
      js/src/types/event.model.ts
  37. 2
      js/src/types/login-error-code.model.ts
  38. 17
      js/src/typings/intl.d.ts
  39. 91
      js/src/utils/errors.ts
  40. 8
      js/src/utils/i18n.ts
  41. 4
      js/src/views/About/AboutInstance.vue
  42. 24
      js/src/views/Account/children/EditIdentity.vue
  43. 25
      js/src/views/Admin/AdminGroupProfile.vue
  44. 2
      js/src/views/Discussions/DiscussionsList.vue
  45. 113
      js/src/views/Event/Edit.vue
  46. 26
      js/src/views/Event/Event.vue
  47. 2
      js/src/views/Event/GroupEvents.vue
  48. 28
      js/src/views/Group/Create.vue
  49. 87
      js/src/views/Group/Group.vue
  50. 66
      js/src/views/Group/GroupList.vue
  51. 14
      js/src/views/Group/GroupMembers.vue
  52. 80
      js/src/views/Group/GroupSettings.vue
  53. 40
      js/src/views/Group/MyGroups.vue
  54. 6
      js/src/views/Group/Settings.vue
  55. 15
      js/src/views/Posts/Post.vue
  56. 2
      js/src/views/Resources/ResourceFolder.vue
  57. 41
      js/src/views/Settings/AccountSettings.vue
  58. 2
      js/src/views/Todos/Todo.vue
  59. 2
      js/src/views/Todos/TodoList.vue
  60. 2
      js/src/views/Todos/TodoLists.vue
  61. 44
      js/src/views/User/Login.vue
  62. 4
      js/src/views/User/Register.vue
  63. 20
      js/src/views/User/SendPasswordReset.vue
  64. 24
      js/src/vue-apollo.ts
  65. 7
      lib/federation/activity_pub/types/events.ex
  66. 9
      lib/federation/activity_stream/converter/utils.ex
  67. 4
      lib/graphql/api/groups.ex
  68. 23
      lib/graphql/resolvers/actor.ex
  69. 15
      lib/graphql/resolvers/admin.ex
  70. 15
      lib/graphql/resolvers/comment.ex
  71. 2
      lib/graphql/resolvers/config.ex
  72. 10
      lib/graphql/resolvers/discussion.ex
  73. 30
      lib/graphql/resolvers/event.ex
  74. 15
      lib/graphql/resolvers/feed_token.ex
  75. 62
      lib/graphql/resolvers/group.ex
  76. 20
      lib/graphql/resolvers/member.ex
  77. 63
      lib/graphql/resolvers/participant.ex
  78. 44
      lib/graphql/resolvers/person.ex
  79. 7
      lib/graphql/resolvers/picture.ex
  80. 30
      lib/graphql/resolvers/post.ex
  81. 25
      lib/graphql/resolvers/report.ex
  82. 27
      lib/graphql/resolvers/resource.ex
  83. 41
      lib/graphql/resolvers/todos.ex
  84. 82
      lib/graphql/resolvers/user.ex
  85. 2
      lib/graphql/schema/config.ex
  86. 8
      lib/graphql/schema/event.ex
  87. 3
      lib/mobilizon/actors/actors.ex
  88. 8
      lib/mobilizon/config.ex
  89. 5
      lib/mobilizon/events/event.ex
  90. 5
      lib/mobilizon/events/events.ex
  91. 15
      lib/mobilizon/users/user.ex
  92. 2
      lib/web/endpoint.ex
  93. 67
      lib/web/plugs/set_locale_plug.ex
  94. 2
      lib/web/proxy/reverse_proxy.ex
  95. 731
      priv/gettext/ar/LC_MESSAGES/errors.po
  96. 731
      priv/gettext/be/LC_MESSAGES/errors.po
  97. 731
      priv/gettext/ca/LC_MESSAGES/errors.po
  98. 731
      priv/gettext/cs/LC_MESSAGES/errors.po
  99. 731
      priv/gettext/de/LC_MESSAGES/errors.po
  100. 731
      priv/gettext/en/LC_MESSAGES/errors.po
  101. Some files were not shown because too many files have changed in this diff Show More

@ -17,7 +17,7 @@ config :mobilizon, :instance,
description: "Change this to a proper description of your instance",
hostname: "localhost",
registrations_open: false,
registration_email_whitelist: [],
registration_email_allowlist: [],
demo: false,
repository: Mix.Project.config()[:source_url],
allow_relay: true,
@ -29,8 +29,7 @@ config :mobilizon, :instance,
email_from: "noreply@localhost",
email_reply_to: "noreply@localhost"
# Groups are to be activated with Mobilizon 1.0.0
config :mobilizon, :groups, enabled: false
config :mobilizon, :groups, enabled: true
config :mobilizon, :events, creation: true

@ -76,7 +76,7 @@ export default class App extends Vue {
currentUser!: ICurrentUser;
async created() {
async created(): Promise<void> {
if (await this.initializeCurrentUser()) {
await initializeCurrentActor(this.$apollo.provider.defaultClient);
}

@ -1,5 +1,4 @@
import { IntrospectionFragmentMatcher, NormalizedCacheObject } from "apollo-cache-inmemory";
import { IError, errors, defaultError, refreshSuggestion } from "@/utils/errors";
import { AUTH_ACCESS_TOKEN, AUTH_REFRESH_TOKEN } from "@/constants";
import { REFRESH_TOKEN } from "@/graphql/auth";
import { saveTokenData } from "@/utils/auth";
@ -24,18 +23,6 @@ export const fragmentMatcher = new IntrospectionFragmentMatcher({
},
});
export const computeErrorMessage = (message: any) => {
const error: IError = errors.reduce((acc, errorLocal) => {
if (RegExp(errorLocal.match).test(message)) {
return errorLocal;
}
return acc;
}, defaultError);
if (error.value === null) return null;
return error.suggestRefresh === false ? error.value : `${error.value}<br>${refreshSuggestion}`;
};
export async function refreshAccessToken(
apolloClient: ApolloClient<NormalizedCacheObject>
): Promise<boolean> {

@ -101,6 +101,7 @@
</template>
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { ACCEPT_RELAY, REJECT_RELAY, RELAY_FOLLOWERS } from "../../graphql/admin";
import { Paginate } from "../../types/paginate";
import { IFollower } from "../../types/actor/follower.model";
@ -125,38 +126,46 @@ export default class Followers extends Mixins(RelayMixin) {
RelayMixin = RelayMixin;
async acceptRelays() {
async acceptRelays(): Promise<void> {
await this.checkedRows.forEach((row: IFollower) => {
this.acceptRelay(`${row.actor.preferredUsername}@${row.actor.domain}`);
});
}
async rejectRelays() {
async rejectRelays(): Promise<void> {
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 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) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
async rejectRelay(address: string) {
await this.$apollo.mutate({
mutation: REJECT_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowers.refetch();
this.checkedRows = [];
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) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
get checkedRowsHaveAtLeastOneToApprove(): boolean {

@ -99,6 +99,7 @@
</template>
<script lang="ts">
import { Component, Mixins } from "vue-property-decorator";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { ADD_RELAY, RELAY_FOLLOWINGS, REMOVE_RELAY } from "../../graphql/admin";
import { IFollower } from "../../types/actor/follower.model";
import { Paginate } from "../../types/paginate";
@ -125,34 +126,42 @@ export default class Followings extends Mixins(RelayMixin) {
RelayMixin = RelayMixin;
async followRelay(e: Event) {
async followRelay(e: Event): Promise<void> {
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 = "";
try {
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 = "";
} catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
async removeRelays() {
async removeRelays(): Promise<void> {
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 = [];
async removeRelay(address: string): Promise<void> {
try {
await this.$apollo.mutate({
mutation: REMOVE_RELAY,
variables: {
address,
},
});
await this.$apollo.queries.relayFollowings.refetch();
this.checkedRows = [];
} catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
}
</script>

@ -126,6 +126,7 @@
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import EditorComponent from "@/components/Editor.vue";
import TimeAgo from "javascript-time-ago";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { CommentModel, IComment } from "../../types/comment.model";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
import { IPerson, usernameWithDomain } from "../../types/actor";
@ -171,7 +172,7 @@ export default class Comment extends Vue {
usernameWithDomain = usernameWithDomain;
async mounted() {
async mounted(): Promise<void> {
const localeName = this.$i18n.locale;
const locale = await import(`javascript-time-ago/locale/${localeName}`);
TimeAgo.addLocale(locale);
@ -183,7 +184,7 @@ export default class Comment extends Vue {
}
}
async createReplyToComment(comment: IComment) {
async createReplyToComment(comment: IComment): Promise<void> {
if (this.replyTo) {
this.replyTo = false;
this.newComment = new CommentModel();
@ -196,7 +197,7 @@ export default class Comment extends Vue {
this.commentEditor.replyToComment(comment);
}
replyToComment() {
replyToComment(): void {
this.newComment.inReplyToComment = this.comment;
this.newComment.originComment = this.comment.originComment || this.comment;
this.newComment.actor = this.currentActor;
@ -205,7 +206,7 @@ export default class Comment extends Vue {
this.replyTo = false;
}
async fetchReplies() {
async fetchReplies(): Promise<void> {
const parentId = this.comment.id;
const { data } = await this.$apollo.query<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
@ -267,7 +268,7 @@ export default class Comment extends Vue {
return this.commentId;
}
reportModal() {
reportModal(): void {
if (!this.comment.actor) return;
this.$buefy.modal.open({
parent: this,
@ -281,7 +282,7 @@ export default class Comment extends Vue {
});
}
async reportComment(content: string, forward: boolean) {
async reportComment(content: string, forward: boolean): Promise<void> {
try {
if (!this.comment.actor) return;
await this.$apollo.mutate<IReport>({
@ -303,8 +304,8 @@ export default class Comment extends Vue {
position: "is-bottom-right",
duration: 5000,
});
} catch (error) {
console.error(error);
} catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
}

@ -51,6 +51,7 @@
import { Prop, Vue, Component, Watch } from "vue-property-decorator";
import Comment from "@/components/Comment/Comment.vue";
import IdentityPickerWrapper from "@/views/Account/IdentityPickerWrapper.vue";
import { SnackbarProgrammatic as Snackbar } from "buefy";
import { CommentModel, IComment } from "../../types/comment.model";
import {
CREATE_COMMENT_FROM_EVENT,
@ -100,11 +101,11 @@ export default class CommentTree extends Vue {
CommentModeration = CommentModeration;
@Watch("currentActor")
watchCurrentActor(currentActor: IPerson) {
watchCurrentActor(currentActor: IPerson): void {
this.newComment.actor = currentActor;
}
async createCommentForEvent(comment: IComment) {
async createCommentForEvent(comment: IComment): Promise<void> {
try {
if (!comment.actor) return;
await this.$apollo.mutate({
@ -190,74 +191,78 @@ export default class CommentTree extends Vue {
// and reset the new comment field
this.newComment = new CommentModel();
} catch (e) {
console.error(e);
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
async deleteComment(comment: IComment) {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
actorId: this.currentActor.id,
},
update: (store, { data }) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS,
variables: {
eventUUID: this.event.uuid,
},
});
if (!commentsData) return;
const { event } = commentsData;
const { comments: oldComments } = event;
if (comment.originComment) {
// we have deleted a reply to a thread
const localData = store.readQuery<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
async deleteComment(comment: IComment): Promise<void> {
try {
await this.$apollo.mutate({
mutation: DELETE_COMMENT,
variables: {
commentId: comment.id,
actorId: this.currentActor.id,
},
update: (store, { data }) => {
if (data == null) return;
const deletedCommentId = data.deleteComment.id;
const commentsData = store.readQuery<{ event: IEvent }>({
query: COMMENTS_THREADS,
variables: {
threadId: comment.originComment.id,
eventUUID: this.event.uuid,
},
});
if (!localData) return;
const { thread: oldReplyList } = localData;
const replies = oldReplyList.filter((reply) => reply.id !== deletedCommentId);
if (!commentsData) return;
const { event } = commentsData;
const { comments: oldComments } = event;
if (comment.originComment) {
// we have deleted a reply to a thread
const localData = store.readQuery<{ thread: IComment[] }>({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: comment.originComment.id,
},
});
if (!localData) return;
const { thread: oldReplyList } = localData;
const replies = oldReplyList.filter((reply) => reply.id !== deletedCommentId);
store.writeQuery({
query: FETCH_THREAD_REPLIES,
variables: {
threadId: comment.originComment.id,
},
data: { thread: replies },
});
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;
parentComment.totalReplies -= 1;
oldComments.splice(parentCommentIndex, 1, parentComment);
event.comments = oldComments;
} else {
// we have deleted a thread itself
event.comments = oldComments.filter((reply) => reply.id !== deletedCommentId);
}
store.writeQuery({
query: FETCH_THREAD_REPLIES,
query: COMMENTS_THREADS,
variables: {
threadId: comment.originComment.id,
eventUUID: this.event.uuid,
},
data: { thread: replies },
data: { event },
});
const { originComment } = comment;
const parentCommentIndex = oldComments.findIndex(
(oldComment) => oldComment.id === originComment.id
);
const parentComment = oldComments[parentCommentIndex];
parentComment.replies = replies;
parentComment.totalReplies -= 1;
oldComments.splice(parentCommentIndex, 1, parentComment);
event.comments = oldComments;
} else {
// we have deleted a thread itself
event.comments = oldComments.filter((reply) => reply.id !== deletedCommentId);
}
store.writeQuery({
query: COMMENTS_THREADS,
variables: {
eventUUID: this.event.uuid,
},
data: { event },
});
},
});
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
},
});
// this.comments = this.comments.filter(commentItem => commentItem.id !== comment.id);
} catch (e) {
Snackbar.open({ message: e.message, type: "is-danger", position: "is-bottom" });
}
}
get orderedComments(): IComment[] {

@ -2,7 +2,7 @@
<div v-if="editor">
<div
class="editor"
:class="{ mode_description: isDescriptionMode }"
:class="{ short_mode: isShortMode, comment_mode: isCommentMode }"
id="tiptab-editor"
:data-actor-id="currentActor && currentActor.id"
>
@ -211,6 +211,7 @@ import tippy, { Instance, sticky } from "tippy.js";
import { SEARCH_PERSONS } from "../graphql/search";
import { Actor, IActor, IPerson } from "../types/actor";
import Image from "./Editor/Image";
import MaxSize from "./Editor/MaxSize";
import { UPLOAD_PICTURE } from "../graphql/upload";
import { listenFileUpload } from "../utils/upload";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
@ -229,6 +230,8 @@ export default class EditorComponent extends Vue {
@Prop({ required: false, default: "description" }) mode!: string;
@Prop({ required: false, default: 100_000_000 }) maxSize!: number;
currentActor!: IPerson;
editor: Editor | null = null;
@ -254,6 +257,10 @@ export default class EditorComponent extends Vue {
return this.mode === "comment";
}
get isShortMode(): boolean {
return this.isBasicMode;
}
get hasResults(): boolean {
return this.filteredActors.length > 0;
}
@ -386,6 +393,7 @@ export default class EditorComponent extends Vue {
showOnlyWhenEditable: false,
}),
new Image(),
new MaxSize({ maxSize: this.maxSize }),
],
onUpdate: ({ getHTML }: { getHTML: Function }) => {
this.$emit("input", getHTML());
@ -395,7 +403,7 @@ export default class EditorComponent extends Vue {
}
@Watch("value")
onValueChanged(val: string) {
onValueChanged(val: string): void {
if (!this.editor) return;
if (val !== this.editor.getHTML()) {
this.editor.setContent(val);
@ -420,7 +428,7 @@ export default class EditorComponent extends Vue {
return undefined;
}
upHandler() {
upHandler(): void {
this.navigatedActorIndex =
(this.navigatedActorIndex + this.filteredActors.length - 1) % this.filteredActors.length;
}
@ -429,11 +437,11 @@ export default class EditorComponent extends Vue {
* navigate to the next item
* if it's the last item, navigate to the first one
*/
downHandler() {
downHandler(): void {
this.navigatedActorIndex = (this.navigatedActorIndex + 1) % this.filteredActors.length;
}
enterHandler() {
enterHandler(): void {
const actor = this.filteredActors[this.navigatedActorIndex];
if (actor) {
this.selectActor(actor);
@ -445,7 +453,7 @@ export default class EditorComponent extends Vue {
* so it's important to pass also the position of your suggestion text
* @param actor IActor
*/
selectActor(actor: IActor) {
selectActor(actor: IActor): void {
const actorModel = new Actor(actor);
this.insertMention({
range: this.suggestionRange,
@ -460,7 +468,7 @@ export default class EditorComponent extends Vue {
}
/** We use this to programatically insert an actor mention when creating a reply to comment */
replyToComment(comment: IComment) {
replyToComment(comment: IComment): void {
if (!comment.actor) return;
const actorModel = new Actor(comment.actor);
if (!this.editor) return;
@ -476,11 +484,12 @@ export default class EditorComponent extends Vue {
* tiptap provides a virtualNode object for using popper.js (or tippy.js) for popups
* @param node
*/
renderPopup(node: Element) {
renderPopup(node: Element): void {
if (this.popup) {
return;
}
this.popup = tippy("#mobilizon", {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
getReferenceClientRect: node.getBoundingClientRect,
appendTo: () => document.body,
@ -497,8 +506,9 @@ export default class EditorComponent extends Vue {
}) as Instance[];
}
destroyPopup() {
destroyPopup(): void {
if (this.popup) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.popup[0].destroy();
this.popup = null;
@ -512,7 +522,7 @@ export default class EditorComponent extends Vue {
* Show a file prompt, upload picture and insert it into editor
* @param command
*/
async showImagePrompt(command: Function) {
async showImagePrompt(command: Function): Promise<void> {
const image = await listenFileUpload();
const { data } = await this.$apollo.mutate({
mutation: UPLOAD_PICTURE,
@ -527,7 +537,7 @@ export default class EditorComponent extends Vue {
}
}
beforeDestroy() {
beforeDestroy(): void {
if (!this.editor) return;
this.destroyPopup();
this.editor.destroy();
@ -577,9 +587,19 @@ $color-white: #eee;
font-style: italic;
}
&.mode_description {
.editor__content div.ProseMirror {
min-height: 10rem;
}
&.short_mode {
div.ProseMirror {
min-height: 5rem;
}
}
&.comment_mode {
div.ProseMirror {
min-height: 10rem;
min-height: 2rem;
}
}

@ -0,0 +1,33 @@
// @ts-nocheck
import { Extension, Plugin } from "tiptap";
export default class MaxSize extends Extension {
get name() {
return "maxSize";
}
get defaultOptions() {
return {
maxSize: null,
};
}
get plugins() {
return [
new Plugin({
appendTransaction: (transactions, oldState, newState) => {
const max = this.options.maxSize;
const oldLength = oldState.doc.content.size;
const newLength = newState.doc.content.size;
if (newLength > max && newLength > oldLength) {
let newTr = newState.tr;
newTr.insertText("", max + 1, newLength);
return newTr;
}
},
}),
];
}
}

@ -0,0 +1,102 @@
<template>
<div class="list is-hoverable">
<b-radio-button
v-model="currentActor"
:native-value="availableActor"
class="list-item"
v-for="availableActor in actualAvailableActors"
:class="{ 'is-active': availableActor.id === currentActor.id }"
:key="availableActor.id"
>
<div class="media">
<figure class="image is-48x48" v-if="availableActor.avatar">
<img class="media-left is-rounded" :src="availableActor.avatar.url" alt="" />
</figure>
<b-icon class="media-left" v-else size="is-large" icon="account-circle" />
<div class="media-content">
<h3>{{ availableActor.name }}</h3>
<small>{{ `@${availableActor.preferredUsername}` }}</small>
</div>
</div>
</b-radio-button>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IMember, IPerson, MemberRole, IActor, Actor } from "@/types/actor";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Paginate } from "@/types/paginate";
@Component({
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS,
variables() {
return {
id: this.identity.id,
};
},
update: (data) => data.person.memberships,
skip() {
return !this.identity.id;
},
},
},
})
export default class OrganizerPicker extends Vue {
@Prop() value!: IActor;
@Prop() identity!: IPerson;
@Prop({ required: false, default: false }) restrictModeratorLevel!: boolean;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
currentActor: IActor = this.value;
Actor = Actor;
get actualMemberships(): IMember[] {
if (this.restrictModeratorLevel) {
return this.groupMemberships.elements.filter((membership: IMember) =>
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR, MemberRole.CREATOR].includes(
membership.role
)
);
}
return this.groupMemberships.elements;
}
get actualAvailableActors(): IActor[] {
return [this.identity, ...this.actualMemberships.map((member) => member.parent)];
}
@Watch("currentActor")
async fetchMembersForGroup(): Promise<void> {
this.$emit("input", this.currentActor);
}
}
</script>
<style lang="scss" scoped>
/deep/ .list-item {
box-sizing: content-box;
label.b-radio {
padding: 0.85rem 0;
.media {
padding: 0.25rem 0;
align-items: center;
figure.image,
span.icon.media-left {
margin-right: 0.5rem;
}
span.icon.media-left {
margin-left: -0.25rem;
}
}
}
}
</style>

@ -0,0 +1,199 @@
<template>
<div class="organizer-picker">
<!-- If we have a current actor (inline) -->
<div v-if="inline && currentActor.id" class="inline box" @click="isComponentModalActive = true">
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="currentActor.avatar">
<img
class="image is-rounded"
:src="currentActor.avatar.url"
:alt="currentActor.avatar.alt"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content" v-if="currentActor.name">
<p class="is-4">{{ currentActor.name }}</p>
<p class="is-6 has-text-grey">{{ `@${currentActor.preferredUsername}` }}</p>
</div>
<div class="media-content" v-else>
{{ `@${currentActor.preferredUsername}` }}
</div>
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t("Change") }}
</b-button>
</div>
</div>
<!-- If we have a current actor -->
<span v-else-if="currentActor.id" class="block" @click="isComponentModalActive = true">
<img
class="image is-48x48"
v-if="currentActor.avatar"
:src="currentActor.avatar.url"
:alt="currentActor.avatar.alt"
/>
<b-icon v-else size="is-large" icon="account-circle" />
</span>
<!-- If we have no current actor -->
<div v-if="groupMemberships.total === 0 || !currentActor.id" class="box">
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="identity.avatar">
<img class="image is-rounded" :src="identity.avatar.url" :alt="identity.avatar.alt" />
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content" v-if="identity.name">
<p class="is-4">{{ identity.name }}</p>
<p class="is-6 has-text-grey">{{ `@${identity.preferredUsername}` }}</p>
</div>
<div class="media-content" v-else>
{{ `@${identity.preferredUsername}` }}
</div>
<b-button type="is-text" @click="isComponentModalActive = true">
{{ $t("Change") }}
</b-button>
</div>
</div>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Pick a profile or a group") }}</p>
</header>
<section class="modal-card-body">
<div class="columns">
<div class="column">
<organizer-picker
v-model="currentActor"
:identity.sync="identity"
@input="relay"
:restrict-moderator-level="true"
/>
</div>
<div class="column">
<div v-if="actorMembersForCurrentActor.length > 0">
<p>{{ $t("Add a contact") }}</p>
<p class="field" v-for="actor in actorMembersForCurrentActor" :key="actor.id">
<b-checkbox v-model="actualContacts" :native-value="actor.id">
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="actor.avatar">
<img
class="image is-rounded"
:src="actor.avatar.url"
:alt="actor.avatar.alt"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
<div class="media-content" v-if="actor.name">
<p class="is-4">{{ actor.name }}</p>
<p class="is-6 has-text-grey">{{ `@${actor.preferredUsername}` }}</p>
</div>
<div class="media-content" v-else>
{{ `@${actor.preferredUsername}` }}
</div>
</div>
</b-checkbox>
</p>
</div>
<div v-else class="content has-text-grey has-text-centered">
<p>{{ $t("Your profile will be shown as contact.") }}</p>
</div>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-primary" type="button" @click="pickActor">
{{ $t("Pick") }}
</button>
</footer>
</div>
</b-modal>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from "vue-property-decorator";
import { IActor, IGroup, IMember, IPerson } from "../../types/actor";
import OrganizerPicker from "./OrganizerPicker.vue";
import { PERSON_MEMBERSHIPS_WITH_MEMBERS } from "../../graphql/actor";
import { Paginate } from "../../types/paginate";
@Component({
components: { OrganizerPicker },
apollo: {
groupMemberships: {
query: PERSON_MEMBERSHIPS_WITH_MEMBERS,
variables() {
return {
id: this.identity.id,
};
},
update: (data) => data.person.memberships,
skip() {
return !this.identity.id;
},
},
},
})
export default class OrganizerPickerWrapper extends Vue {
@Prop({ type: Object, required: true }) value!: IActor;
@Prop({ default: true, type: Boolean }) inline!: boolean;
@Prop({ type: Object, required: true }) identity!: IPerson;
isComponentModalActive = false;
currentActor: IActor = this.value;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
@Prop({ type: Array, required: false, default: () => [] }) contacts!: IActor[];
actualContacts: (string | undefined)[] = this.contacts.map(({ id }) => id);
@Watch("contacts")
updateActualContacts(contacts: IActor[]): void {
this.actualContacts = contacts.map(({ id }) => id);
}
@Watch("value")
updateCurrentActor(value: IGroup): void {
this.currentActor = value;
}
async relay(group: IGroup): Promise<void> {
this.currentActor = group;
}
pickActor(): void {
this.$emit(
"update:contacts",
this.actorMembersForCurrentActor.filter(({ id }) => this.actualContacts.includes(id))
);
this.$emit("input", this.currentActor);
this.isComponentModalActive = false;
}
get actorMembersForCurrentActor(): IActor[] {
const currentMembership = this.groupMemberships.elements.find(
({ parent: { id } }) => id === this.currentActor.id
);
if (currentMembership) {
return currentMembership.parent.members.elements.map(({ actor }) => actor);
}
return [];
}
}
</script>
<style lang="scss" scoped>
.group-picker {
.block,
.no-group,
.inline {
cursor: pointer;
}
}
</style>

@ -10,7 +10,7 @@
<router-link :to="{ name: RouteName.TERMS }">{{ $t("Terms") }}</router-link>
</li>
<li>
<a href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE">
<a hreflang="en" href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE">
{{ $t("License") }}
</a>
</li>

@ -1,32 +1,52 @@
<template>
<div class="card">
<div class="card-content">
<div class="media">
<div class="media-left">
<figure class="image is-48x48">
<img src="https://bulma.io/images/placeholders/96x96.png" alt="Placeholder image" />
</figure>
<div>
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="member.parent.avatar">
<img class="is-rounded" :src="member.parent.avatar.url" alt="" />
</figure>
<b-icon v-else size="is-large" icon="account-group" />
</div>
<div class="media-content">
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
}"
>
<h3>{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey">
<span v-if="member.parent.domain">{{
`@${member.parent.preferredUsername}@${member.parent.domain}`
}}</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
<b-taglist>
<b-tag type="is-info" v-if="member.role === MemberRole.ADMINISTRATOR">{{
$t("Administrator")
}}</b-tag>
<b-tag type="is-info" v-else-if="member.role === MemberRole.MODERATOR">{{
$t("Moderator")
}}</b-tag>
</b-taglist>
</p>
</router-link>
</div>
</div>
<div class="media-content">
<router-link
:to="{
name: RouteName.GROUP,
params: { preferredUsername: usernameWithDomain(member.parent) },
}"
>
<h3>{{ member.parent.name }}</h3>
<p class="is-6 has-text-grey">
<span v-if="member.parent.domain">{{
`@${member.parent.preferredUsername}@${member.parent.domain}`
}}</span>
<span v-else>{{ `@${member.parent.preferredUsername}` }}</span>
</p>
<b-tag type="is-info">{{ member.role }}</b-tag>
</router-link>
<div class="content" v-if="member.parent.summary">
<p>{{ member.parent.summary }}</p>
</div>
</div>
<div class="content">
<p>{{ member.parent.summary }}</p>
<div>
<b-dropdown aria-role="list" position="is-bottom-left">
<b-icon icon="dots-horizontal" slot="trigger" />
<b-dropdown-item aria-role="listitem" @click="$emit('leave')">
<b-icon icon="exit-to-app" />
{{ $t("Leave") }}
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</div>
@ -34,7 +54,7 @@
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IMember, usernameWithDomain } from "@/types/actor";
import { IMember, MemberRole, usernameWithDomain } from "@/types/actor";
import RouteName from "../../router/name";
@Component
@ -44,5 +64,21 @@ export default class GroupMemberCard extends Vue {
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
MemberRole = MemberRole;
}
</script>
<style lang="scss" scoped>
.card-content {
display: flex;
align-items: center;
& > div:first-child {
flex: 1;
}
& > div:last-child {
cursor: pointer;
}
}
</style>

@ -7,7 +7,7 @@
<div class="list is-hoverable">
<a
class="list-item"
v-for="groupMembership in groupMemberships.elements"
v-for="groupMembership in actualMemberships"
:class="{ 'is-active': groupMembership.parent.id === currentGroup.id }"
@click="changeCurrentGroup(groupMembership.parent)"
:key="groupMembership.id"
@ -36,7 +36,7 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IGroup, IMember, IPerson, Group } from "@/types/actor";
import { IGroup, IMember, IPerson, Group, MemberRole } from "@/types/actor";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import { Paginate } from "@/types/paginate";
@ -61,15 +61,28 @@ export default class GroupPicker extends Vue {
@Prop() identity!: IPerson;
@Prop({ required: false, default: false }) restrictModeratorLevel!: boolean;
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
currentGroup: IGroup = this.value;
Group = Group;
changeCurrentGroup(group: IGroup) {
changeCurrentGroup(group: IGroup): void {
this.currentGroup = group;
this.$emit("input", group);
}
get actualMemberships(): IMember[] {
if (this.restrictModeratorLevel) {
return this.groupMemberships.elements.filter((membership: IMember) =>
[MemberRole.ADMINISTRATOR, MemberRole.MODERATOR, MemberRole.CREATOR].includes(
membership.role
)
);
}
return this.groupMemberships.elements;
}
}
</script>

@ -14,7 +14,11 @@
<div class="media">
<div class="media-left">
<figure class="image is-48x48" v-if="currentGroup.avatar">
<img class="image" :src="currentGroup.avatar.url" :alt="currentGroup.avatar.alt" />
<img
class="image is-rounded"
:src="currentGroup.avatar.url"
:alt="currentGroup.avatar.alt"
/>
</figure>
<b-icon v-else size="is-large" icon="account-circle" />
</div>
@ -46,7 +50,12 @@
</p>
</div>
<b-modal :active.sync="isComponentModalActive" has-modal-card>
<group-picker v-model="currentGroup" :identity.sync="identity" @input="relay" />
<group-picker
v-model="currentGroup"
:identity.sync="identity"
@input="relay"
:restrict-moderator-level="true"
/>
</b-modal>
</div>
</template>
@ -88,11 +97,11 @@ export default class GroupPickerWrapper extends Vue {
groupMemberships: Paginate<IMember> = { elements: [], total: 0 };
@Watch("value")
updateCurrentGroup(value: IGroup) {
updateCurrentGroup(value: IGroup): void {
this.currentGroup = value;
}
relay(group: IGroup) {
relay(group: IGroup): void {
this.currentGroup = group;
this.$emit("input", group);
this.isComponentModalActive = false;

@ -14,6 +14,7 @@ import { ACCEPT_INVITATION, REJECT_INVITATION } from "@/graphql/member";
import { IMember } from "@/types/actor";
import { Component, Prop, Vue } from "vue-property-decorator";
import InvitationCard from "@/components/Group/InvitationCard.vue";
import { SnackbarProgrammatic as Snackbar } from "buefy";
@Component({
components: {
@ -23,27 +24,35 @@ import InvitationCard from "@/components/Group/InvitationCard.vue";
export default class Invitations extends Vue {
@Prop({ required: true, type: Array }) invitations!: IMember;
async acceptInvitation(id:<