forked from potsda.mn/mobilizon
WIP
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
parent
0613f7f736
commit
b5672cee7e
|
@ -27,7 +27,7 @@
|
|||
"@absinthe/socket": "^0.2.1",
|
||||
"@absinthe/socket-apollo-link": "^0.2.1",
|
||||
"@apollo/client": "^3.3.16",
|
||||
"@oruga-ui/oruga-next": "^0.6.0",
|
||||
"@oruga-ui/oruga-next": "^0.7.0",
|
||||
"@sentry/tracing": "^7.1",
|
||||
"@sentry/vue": "^7.1",
|
||||
"@tiptap/core": "^2.0.0-beta.41",
|
||||
|
@ -114,7 +114,7 @@
|
|||
"@vitest/coverage-v8": "^0.34.1",
|
||||
"@vitest/ui": "^0.34.1",
|
||||
"@vue/eslint-config-prettier": "^8.0.0",
|
||||
"@vue/eslint-config-typescript": "^11.0.0",
|
||||
"@vue/eslint-config-typescript": "^12.0.0",
|
||||
"@vue/test-utils": "^2.0.2",
|
||||
"eslint": "^8.21.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
|
@ -131,7 +131,7 @@
|
|||
"prettier-eslint": "^15.0.1",
|
||||
"rollup-plugin-visualizer": "^5.7.1",
|
||||
"sass": "^1.34.1",
|
||||
"typescript": "~5.1.3",
|
||||
"typescript": "~5.2.2",
|
||||
"vite": "^4.0.4",
|
||||
"vite-plugin-pwa": "^0.16.4",
|
||||
"vitest": "^0.34.1",
|
||||
|
|
|
@ -138,6 +138,7 @@ interval.value = window.setInterval(async () => {
|
|||
}, 60000) as unknown as number;
|
||||
|
||||
onBeforeMount(async () => {
|
||||
console.debug("Before mount App");
|
||||
if (initializeCurrentUser()) {
|
||||
try {
|
||||
await initializeCurrentActor();
|
||||
|
@ -150,6 +151,8 @@ onBeforeMount(async () => {
|
|||
userAlreadyActivated: "true",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -202,20 +205,24 @@ onUnmounted(() => {
|
|||
const { mutate: updateCurrentUser } = useMutation(UPDATE_CURRENT_USER_CLIENT);
|
||||
|
||||
const initializeCurrentUser = () => {
|
||||
console.debug("Initializing current user");
|
||||
const userId = localStorage.getItem(AUTH_USER_ID);
|
||||
const userEmail = localStorage.getItem(AUTH_USER_EMAIL);
|
||||
const accessToken = localStorage.getItem(AUTH_ACCESS_TOKEN);
|
||||
const role = localStorage.getItem(AUTH_USER_ROLE);
|
||||
|
||||
if (userId && userEmail && accessToken && role) {
|
||||
updateCurrentUser({
|
||||
const userData = {
|
||||
id: userId,
|
||||
email: userEmail,
|
||||
isLoggedIn: true,
|
||||
role,
|
||||
});
|
||||
};
|
||||
updateCurrentUser(userData);
|
||||
console.debug("Initialized current user", userData);
|
||||
return true;
|
||||
}
|
||||
console.debug("Failed to initialize current user");
|
||||
return false;
|
||||
};
|
||||
|
||||
|
|
|
@ -45,6 +45,11 @@ export const typePolicies: TypePolicies = {
|
|||
comments: paginatedLimitPagination<IComment>(),
|
||||
},
|
||||
},
|
||||
Conversation: {
|
||||
fields: {
|
||||
comments: paginatedLimitPagination<IComment>(),
|
||||
},
|
||||
},
|
||||
Group: {
|
||||
fields: {
|
||||
organizedEvents: paginatedLimitPagination([
|
||||
|
|
77
js/src/components/Account/ActorAutoComplete.vue
Normal file
77
js/src/components/Account/ActorAutoComplete.vue
Normal file
|
@ -0,0 +1,77 @@
|
|||
<template>
|
||||
<o-inputitems
|
||||
:modelValue="modelValue"
|
||||
@update:modelValue="(val: IActor[]) => $emit('update:modelValue', val)"
|
||||
:data="availableActors"
|
||||
:allow-autocomplete="true"
|
||||
:allow-new="false"
|
||||
:open-on-focus="false"
|
||||
field="displayName"
|
||||
placeholder="Add a recipient"
|
||||
@typing="getActors"
|
||||
>
|
||||
<template #default="props">
|
||||
<ActorInline :actor="props.option" />
|
||||
</template>
|
||||
</o-inputitems>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SEARCH_PERSON_AND_GROUPS } from "@/graphql/search";
|
||||
import { IActor, IGroup, IPerson, displayName } from "@/types/actor";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
import { useLazyQuery } from "@vue/apollo-composable";
|
||||
import { ref } from "vue";
|
||||
import ActorInline from "./ActorInline.vue";
|
||||
|
||||
defineProps<{
|
||||
modelValue: IActor[];
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"update:modelValue": [value: IActor[]];
|
||||
}>();
|
||||
|
||||
const {
|
||||
load: loadSearchPersonsAndGroupsQuery,
|
||||
refetch: refetchSearchPersonsAndGroupsQuery,
|
||||
} = useLazyQuery<
|
||||
{ searchPersons: Paginate<IPerson>; searchGroups: Paginate<IGroup> },
|
||||
{ searchText: string }
|
||||
>(SEARCH_PERSON_AND_GROUPS);
|
||||
|
||||
const availableActors = ref<IActor[]>([]);
|
||||
|
||||
const getActors = async (text: string) => {
|
||||
availableActors.value = await fetchActors(text);
|
||||
};
|
||||
|
||||
const fetchActors = async (text: string): Promise<IActor[]> => {
|
||||
if (text === "") return [];
|
||||
try {
|
||||
const res =
|
||||
(await loadSearchPersonsAndGroupsQuery(SEARCH_PERSON_AND_GROUPS, {
|
||||
searchText: text,
|
||||
})) ||
|
||||
(
|
||||
await refetchSearchPersonsAndGroupsQuery({
|
||||
searchText: text,
|
||||
})
|
||||
)?.data;
|
||||
if (!res) return [];
|
||||
return [
|
||||
...res.searchPersons.elements.map((person) => ({
|
||||
...person,
|
||||
displayName: displayName(person),
|
||||
})),
|
||||
...res.searchGroups.elements.map((group) => ({
|
||||
...group,
|
||||
displayName: displayName(group),
|
||||
})),
|
||||
];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -39,6 +39,9 @@
|
|||
v-html="actor.summary"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex pr-2">
|
||||
<Email />
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div
|
||||
class="p-4 bg-white rounded-lg shadow-md sm:p-8 flex items-center space-x-4"
|
||||
|
@ -81,6 +84,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Email from "vue-material-design-icons/Email.vue";
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
|
|
|
@ -25,7 +25,7 @@ import { IActor } from "@/types/actor";
|
|||
import { ActorType } from "@/types/enums";
|
||||
|
||||
const avatarUrl = ref<string>(
|
||||
"https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg"
|
||||
"https://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.jpg"
|
||||
);
|
||||
|
||||
const stateLocal = reactive<IActor>({
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
<template>
|
||||
<div
|
||||
class="inline-flex items-start bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
|
||||
class="inline-flex items-start gap-2 bg-white dark:bg-violet-1 dark:text-white p-2 rounded-md"
|
||||
>
|
||||
<div class="flex-none mr-2">
|
||||
<div class="flex-none">
|
||||
<figure v-if="actor.avatar">
|
||||
<img
|
||||
class="rounded-xl"
|
||||
|
@ -24,11 +24,15 @@
|
|||
@{{ usernameWithDomain(actor) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex pr-2 self-center">
|
||||
<Email />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { displayName, IActor, usernameWithDomain } from "../../types/actor";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Email from "vue-material-design-icons/Email.vue";
|
||||
|
||||
defineProps<{
|
||||
actor: IActor;
|
||||
|
|
|
@ -49,7 +49,7 @@ const group = {
|
|||
domain: "mobilizon.fr",
|
||||
avatar: {
|
||||
...baseActorAvatar,
|
||||
url: "https://framapiaf.s3.framasoft.org/framapiaf/accounts/avatars/000/000/399/original/aa56a445efb72803.jpg",
|
||||
url: "https://stockage.framapiaf.org/framapiaf/accounts/avatars/000/000/399/original/52b08a3e80b43d40.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
160
js/src/components/Conversations/ConversationListItem.vue
Normal file
160
js/src/components/Conversations/ConversationListItem.vue
Normal file
|
@ -0,0 +1,160 @@
|
|||
<template>
|
||||
<router-link
|
||||
class="flex gap-2 w-full items-center px-2 py-4 border-b-stone-200 border-b bg-white dark:bg-transparent"
|
||||
dir="auto"
|
||||
:to="{
|
||||
name: RouteName.CONVERSATION,
|
||||
params: { id: conversation.conversationParticipantId },
|
||||
}"
|
||||
>
|
||||
<div class="relative">
|
||||
<figure
|
||||
class="w-12 h-12"
|
||||
v-if="
|
||||
conversation.lastComment?.actor &&
|
||||
conversation.lastComment.actor.avatar
|
||||
"
|
||||
>
|
||||
<img
|
||||
class="rounded-full"
|
||||
:src="conversation.lastComment.actor.avatar.url"
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
/>
|
||||
</figure>
|
||||
<account-circle :size="48" v-else />
|
||||
<div class="flex absolute -bottom-2 left-6">
|
||||
<template
|
||||
v-for="extraParticipant in nonLastCommenterParticipants.slice(0, 2)"
|
||||
:key="extraParticipant.id"
|
||||
>
|
||||
<figure class="w-6 h-6 -mr-3">
|
||||
<img
|
||||
v-if="extraParticipant && extraParticipant.avatar"
|
||||
class="rounded-full h-6"
|
||||
:src="extraParticipant.avatar.url"
|
||||
alt=""
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
<account-circle :size="24" v-else />
|
||||
</figure>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overflow-hidden flex-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<i18n-t
|
||||
keypath="With {participants}"
|
||||
tag="p"
|
||||
class="truncate flex-1"
|
||||
v-if="formattedListOfParticipants"
|
||||
>
|
||||
<template #participants>
|
||||
<span v-html="formattedListOfParticipants" />
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p v-else>{{ t("With unknown participants") }}</p>
|
||||
<div class="inline-flex items-center px-1.5">
|
||||
<span
|
||||
v-if="conversation.unread"
|
||||
class="bg-primary rounded-full inline-block h-2.5 w-2.5 mx-2"
|
||||
>
|
||||
</span>
|
||||
<time
|
||||
class="whitespace-nowrap"
|
||||
:datetime="actualDate.toString()"
|
||||
:title="formatDateTimeString(actualDate)"
|
||||
>
|
||||
{{ distanceToNow }}</time
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="line-clamp-2 my-1"
|
||||
dir="auto"
|
||||
v-if="!conversation.lastComment?.deletedAt"
|
||||
>
|
||||
{{ htmlTextEllipsis }}
|
||||
</div>
|
||||
<div v-else class="">
|
||||
{{ t("[This comment has been deleted]") }}
|
||||
</div>
|
||||
</div>
|
||||
</router-link>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { IConversation } from "../../types/conversation";
|
||||
import RouteName from "../../router/name";
|
||||
import { computed, inject } from "vue";
|
||||
import { formatDateTimeString } from "../../filters/datetime";
|
||||
import type { Locale } from "date-fns";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { formatList } from "@/utils/i18n";
|
||||
import { displayName } from "@/types/actor";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
|
||||
const props = defineProps<{
|
||||
conversation: IConversation;
|
||||
}>();
|
||||
|
||||
const conversation = computed(() => props.conversation);
|
||||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const distanceToNow = computed(() => {
|
||||
return (
|
||||
formatDistanceToNowStrict(new Date(actualDate.value), {
|
||||
locale: dateFnsLocale,
|
||||
}) ?? t("Right now")
|
||||
);
|
||||
});
|
||||
|
||||
const htmlTextEllipsis = computed((): string => {
|
||||
const element = document.createElement("div");
|
||||
if (conversation.value.lastComment && conversation.value.lastComment.text) {
|
||||
element.innerHTML = conversation.value.lastComment.text
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<p>/gi, " ");
|
||||
}
|
||||
return element.innerText;
|
||||
});
|
||||
|
||||
const actualDate = computed((): string => {
|
||||
if (
|
||||
conversation.value.updatedAt === conversation.value.insertedAt &&
|
||||
conversation.value.lastComment?.publishedAt
|
||||
) {
|
||||
return conversation.value.lastComment.publishedAt;
|
||||
}
|
||||
return conversation.value.updatedAt;
|
||||
});
|
||||
|
||||
const formattedListOfParticipants = computed(() => {
|
||||
return formatList(
|
||||
otherParticipants.value.map(
|
||||
(participant) => `<b>${displayName(participant)}</b>`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const otherParticipants = computed(
|
||||
() =>
|
||||
conversation.value?.participants.filter(
|
||||
(participant) => participant.id !== currentActor.value?.id
|
||||
) ?? []
|
||||
);
|
||||
|
||||
const nonLastCommenterParticipants = computed(() =>
|
||||
otherParticipants.value.filter(
|
||||
(participant) =>
|
||||
participant.id !== conversation.value.lastComment?.actor?.id
|
||||
)
|
||||
);
|
||||
</script>
|
69
js/src/components/Conversations/EventConversations.vue
Normal file
69
js/src/components/Conversations/EventConversations.vue
Normal file
|
@ -0,0 +1,69 @@
|
|||
<template>
|
||||
<div class="container mx-auto section">
|
||||
<breadcrumbs-nav :links="[]" />
|
||||
<section>
|
||||
<h1>{{ t("Conversations") }}</h1>
|
||||
<!-- <o-button
|
||||
tag="router-link"
|
||||
:to="{
|
||||
name: RouteName.CREATE_CONVERSATION,
|
||||
params: { uuid: event.uuid },
|
||||
}"
|
||||
>{{ t("New private message") }}</o-button
|
||||
> -->
|
||||
<div v-if="conversations.elements.length > 0">
|
||||
<conversation-list-item
|
||||
:conversation="conversation"
|
||||
v-for="conversation in conversations.elements"
|
||||
:key="conversation.id"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="conversations.total > CONVERSATIONS_PER_PAGE"
|
||||
class="conversation-pagination"
|
||||
:total="conversations.total"
|
||||
v-model:current="page"
|
||||
:per-page="CONVERSATIONS_PER_PAGE"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<empty-content v-else icon="chat">
|
||||
{{ t("There's no conversations yet") }}
|
||||
</empty-content>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import ConversationListItem from "../../components/Conversations/ConversationListItem.vue";
|
||||
// import RouteName from "../../router/name";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRouteQuery, integerTransformer } from "vue-use-route-query";
|
||||
import { computed } from "vue";
|
||||
import { IEvent } from "../../types/event.model";
|
||||
import { EVENT_CONVERSATIONS } from "../../graphql/event";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const CONVERSATIONS_PER_PAGE = 10;
|
||||
|
||||
const props = defineProps<{ event: IEvent }>();
|
||||
const event = computed(() => props.event);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const { result: conversationsResult } = useQuery<{
|
||||
event: Pick<IEvent, "conversations">;
|
||||
}>(EVENT_CONVERSATIONS, () => ({
|
||||
uuid: event.value.uuid,
|
||||
page: page.value,
|
||||
}));
|
||||
|
||||
const conversations = computed(
|
||||
() =>
|
||||
conversationsResult.value?.event.conversations || { elements: [], total: 0 }
|
||||
);
|
||||
</script>
|
137
js/src/components/Conversations/NewConversation.vue
Normal file
137
js/src/components/Conversations/NewConversation.vue
Normal file
|
@ -0,0 +1,137 @@
|
|||
<template>
|
||||
<form @submit="sendForm" class="flex flex-col">
|
||||
<ActorAutoComplete v-model="actorMentions" />
|
||||
<Editor
|
||||
v-model="text"
|
||||
mode="basic"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
<footer class="flex gap-2 py-3 mx-2 justify-end">
|
||||
<o-button :disabled="!canSend" nativeType="submit">{{
|
||||
t("Send")
|
||||
}}</o-button>
|
||||
</footer>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { IActor, IPerson, usernameWithDomain } from "@/types/actor";
|
||||
import { computed, defineAsyncComponent, provide, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import ActorAutoComplete from "../../components/Account/ActorAutoComplete.vue";
|
||||
import {
|
||||
DefaultApolloClient,
|
||||
provideApolloClient,
|
||||
useMutation,
|
||||
} from "@vue/apollo-composable";
|
||||
import { apolloClient } from "@/vue-apollo";
|
||||
import { PROFILE_CONVERSATIONS } from "@/graphql/event";
|
||||
import { POST_PRIVATE_MESSAGE_MUTATION } from "@/graphql/conversations";
|
||||
import { IConversation } from "@/types/conversation";
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { useRouter } from "vue-router";
|
||||
import RouteName from "@/router/name";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
mentions?: IActor[];
|
||||
}>(),
|
||||
{ mentions: () => [] }
|
||||
);
|
||||
|
||||
provide(DefaultApolloClient, apolloClient);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const emit = defineEmits(["close"]);
|
||||
|
||||
const actorMentions = ref(props.mentions);
|
||||
|
||||
const textMentions = computed(() =>
|
||||
(props.mentions ?? []).map((actor) => usernameWithDomain(actor)).join(" ")
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const text = ref(textMentions.value);
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("../../components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const { currentActor } = provideApolloClient(apolloClient)(() => {
|
||||
return useCurrentActorClient();
|
||||
});
|
||||
|
||||
const canSend = computed(() => {
|
||||
return actorMentions.value.length > 0 || /@.+/.test(text.value);
|
||||
});
|
||||
|
||||
const { mutate: postPrivateMessageMutate } = provideApolloClient(apolloClient)(
|
||||
() =>
|
||||
useMutation<
|
||||
{
|
||||
postPrivateMessage: IConversation;
|
||||
},
|
||||
{
|
||||
text: string;
|
||||
actorId: string;
|
||||
language?: string;
|
||||
mentions?: string[];
|
||||
attributedToId?: string;
|
||||
}
|
||||
>(POST_PRIVATE_MESSAGE_MUTATION, {
|
||||
update(cache, result) {
|
||||
if (!result.data?.postPrivateMessage) return;
|
||||
const cachedData = cache.readQuery<{
|
||||
loggedPerson: Pick<IPerson, "conversations" | "id">;
|
||||
}>({
|
||||
query: PROFILE_CONVERSATIONS,
|
||||
variables: {
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
if (!cachedData) return;
|
||||
cache.writeQuery({
|
||||
query: PROFILE_CONVERSATIONS,
|
||||
variables: {
|
||||
page: 1,
|
||||
},
|
||||
data: {
|
||||
loggedPerson: {
|
||||
...cachedData?.loggedPerson,
|
||||
conversations: {
|
||||
...cachedData.loggedPerson.conversations,
|
||||
total: (cachedData.loggedPerson.conversations?.total ?? 0) + 1,
|
||||
elements: [
|
||||
...(cachedData.loggedPerson.conversations?.elements ?? []),
|
||||
result.data.postPrivateMessage,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const sendForm = async (e: Event) => {
|
||||
e.preventDefault();
|
||||
console.debug("Sending new private message");
|
||||
if (!currentActor.value?.id) return;
|
||||
const result = await postPrivateMessageMutate({
|
||||
actorId: currentActor.value.id,
|
||||
text: text.value,
|
||||
mentions: actorMentions.value.map((actor) => usernameWithDomain(actor)),
|
||||
});
|
||||
if (!result?.data?.postPrivateMessage.conversationParticipantId) return;
|
||||
router.push({
|
||||
name: RouteName.CONVERSATION,
|
||||
params: { id: result?.data?.postPrivateMessage.conversationParticipantId },
|
||||
});
|
||||
emit("close");
|
||||
};
|
||||
</script>
|
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<article class="flex gap-2 bg-white dark:bg-transparent">
|
||||
<article
|
||||
class="flex gap-2 bg-white dark:bg-transparent border rounded-md p-2 mt-2"
|
||||
>
|
||||
<div class="">
|
||||
<figure class="" v-if="comment.actor && comment.actor.avatar">
|
||||
<img
|
||||
|
@ -29,12 +31,12 @@
|
|||
v-if="
|
||||
comment.actor &&
|
||||
!comment.deletedAt &&
|
||||
comment.actor.id === currentActor?.id
|
||||
(comment.actor.id === currentActor.id || canReport)
|
||||
"
|
||||
>
|
||||
<o-dropdown aria-role="list" position="bottom-left">
|
||||
<template #trigger>
|
||||
<o-icon role="button" icon="dots-horizontal" />
|
||||
<DotsHorizontal class="cursor-pointer" />
|
||||
</template>
|
||||
|
||||
<o-dropdown-item
|
||||
|
@ -53,10 +55,14 @@
|
|||
<o-icon icon="delete"></o-icon>
|
||||
{{ t("Delete") }}
|
||||
</o-dropdown-item>
|
||||
<!-- <o-dropdown-item aria-role="listitem" @click="isReportModalActive = true">
|
||||
<o-dropdown-item
|
||||
v-if="canReport"
|
||||
aria-role="listitem"
|
||||
@click="isReportModalActive = true"
|
||||
>
|
||||
<o-icon icon="flag" />
|
||||
{{ t("Report") }}
|
||||
</o-dropdown-item> -->
|
||||
</o-dropdown-item>
|
||||
</o-dropdown>
|
||||
</span>
|
||||
<div class="self-center">
|
||||
|
@ -124,6 +130,20 @@
|
|||
</form>
|
||||
</div>
|
||||
</article>
|
||||
<o-modal
|
||||
v-model:active="isReportModalActive"
|
||||
has-modal-card
|
||||
ref="reportModal"
|
||||
:close-button-aria-label="t('Close')"
|
||||
:autoFocus="false"
|
||||
:trapFocus="false"
|
||||
>
|
||||
<ReportModal
|
||||
:on-confirm="reportComment"
|
||||
:title="t('Report this comment')"
|
||||
:outside-domain="comment.actor?.domain"
|
||||
/>
|
||||
</o-modal>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
|
@ -132,17 +152,26 @@ import { IPerson, usernameWithDomain } from "../../types/actor";
|
|||
import { computed, defineAsyncComponent, inject, ref } from "vue";
|
||||
import { formatDateTimeString } from "@/filters/datetime";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import DotsHorizontal from "vue-material-design-icons/DotsHorizontal.vue";
|
||||
import type { Locale } from "date-fns";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useCreateReport } from "@/composition/apollo/report";
|
||||
import { Snackbar } from "@/plugins/snackbar";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
import ReportModal from "@/components/Report/ReportModal.vue";
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("@/components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: IComment;
|
||||
currentActor: IPerson;
|
||||
}>();
|
||||
canReport: boolean;
|
||||
}>(),
|
||||
{ canReport: false }
|
||||
);
|
||||
|
||||
const emit = defineEmits(["update:modelValue", "deleteComment"]);
|
||||
|
||||
|
@ -156,7 +185,7 @@ const updatedComment = ref("");
|
|||
|
||||
const dateFnsLocale = inject<Locale>("dateFnsLocale");
|
||||
|
||||
// isReportModalActive: boolean = false;
|
||||
const isReportModalActive = ref(false);
|
||||
|
||||
const toggleEditMode = (): void => {
|
||||
updatedComment.value = comment.value.text;
|
||||
|
@ -170,6 +199,51 @@ const updateComment = (): void => {
|
|||
});
|
||||
toggleEditMode();
|
||||
};
|
||||
|
||||
const {
|
||||
mutate: createReportMutation,
|
||||
onError: onCreateReportError,
|
||||
onDone: oneCreateReportDone,
|
||||
} = useCreateReport();
|
||||
|
||||
const reportComment = async (
|
||||
content: string,
|
||||
forward: boolean
|
||||
): Promise<void> => {
|
||||
if (!props.modelValue.actor) return;
|
||||
createReportMutation({
|
||||
reportedId: props.modelValue.actor?.id ?? "",
|
||||
commentsIds: [props.modelValue.id ?? ""],
|
||||
content,
|
||||
forward,
|
||||
});
|
||||
};
|
||||
|
||||
const snackbar = inject<Snackbar>("snackbar");
|
||||
const { oruga } = useProgrammatic();
|
||||
|
||||
onCreateReportError((e) => {
|
||||
isReportModalActive.value = false;
|
||||
if (e.message) {
|
||||
snackbar?.open({
|
||||
message: e.message,
|
||||
variant: "danger",
|
||||
position: "bottom",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
oneCreateReportDone(() => {
|
||||
isReportModalActive.value = false;
|
||||
oruga.notification.open({
|
||||
message: t("Comment from {'@'}{username} reported", {
|
||||
username: props.modelValue.actor?.preferredUsername,
|
||||
}),
|
||||
variant: "success",
|
||||
position: "bottom-right",
|
||||
duration: 5000,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
@use "@/styles/_mixins" as *;
|
||||
|
|
|
@ -2,28 +2,29 @@ import { SEARCH_PERSONS } from "@/graphql/search";
|
|||
import { VueRenderer } from "@tiptap/vue-3";
|
||||
import tippy from "tippy.js";
|
||||
import MentionList from "./MentionList.vue";
|
||||
import { apolloClient, waitApolloQuery } from "@/vue-apollo";
|
||||
import { apolloClient } from "@/vue-apollo";
|
||||
import { IPerson } from "@/types/actor";
|
||||
import pDebounce from "p-debounce";
|
||||
import { MentionOptions } from "@tiptap/extension-mention";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
|
||||
import { provideApolloClient, useLazyQuery } from "@vue/apollo-composable";
|
||||
import { Paginate } from "@/types/paginate";
|
||||
|
||||
const fetchItems = async (query: string): Promise<IPerson[]> => {
|
||||
try {
|
||||
if (query === "") return [];
|
||||
const res = await waitApolloQuery(
|
||||
provideApolloClient(apolloClient)(() => {
|
||||
return useQuery<
|
||||
const res = await provideApolloClient(apolloClient)(async () => {
|
||||
const { load: loadSearchPersonsQuery } = useLazyQuery<
|
||||
{ searchPersons: Paginate<IPerson> },
|
||||
{ searchText: string }
|
||||
>(SEARCH_PERSONS, () => ({
|
||||
>(SEARCH_PERSONS);
|
||||
|
||||
return await loadSearchPersonsQuery(SEARCH_PERSONS, {
|
||||
searchText: query,
|
||||
}));
|
||||
})
|
||||
);
|
||||
return res.data.searchPersons.elements;
|
||||
});
|
||||
});
|
||||
if (!res) return [];
|
||||
return res.searchPersons.elements;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
|
|
|
@ -318,18 +318,10 @@ const debounceDelay = computed(() =>
|
|||
geocodingAutocomplete.value === true ? 200 : 2000
|
||||
);
|
||||
|
||||
const { onResult: onAddressSearchResult, load: searchAddress } = useLazyQuery<{
|
||||
const { load: searchAddress } = useLazyQuery<{
|
||||
searchAddress: IAddress[];
|
||||
}>(ADDRESS);
|
||||
|
||||
onAddressSearchResult((result) => {
|
||||
if (result.loading) return;
|
||||
const { data } = result;
|
||||
console.debug("onAddressSearchResult", data.searchAddress);
|
||||
addressData.value = data.searchAddress;
|
||||
isFetching.value = false;
|
||||
});
|
||||
|
||||
const asyncData = async (query: string): Promise<void> => {
|
||||
console.debug("Finding addresses");
|
||||
if (!query.length) {
|
||||
|
@ -345,11 +337,21 @@ const asyncData = async (query: string): Promise<void> => {
|
|||
|
||||
isFetching.value = true;
|
||||
|
||||
searchAddress(undefined, {
|
||||
try {
|
||||
const result = await searchAddress(undefined, {
|
||||
query,
|
||||
locale: locale,
|
||||
type: props.resultType,
|
||||
});
|
||||
|
||||
if (!result) return;
|
||||
console.debug("onAddressSearchResult", result.searchAddress);
|
||||
addressData.value = result.searchAddress;
|
||||
isFetching.value = false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedAddressText = computed(() => {
|
||||
|
@ -393,24 +395,9 @@ const locateMe = async (): Promise<void> => {
|
|||
gettingLocation.value = false;
|
||||
};
|
||||
|
||||
const { onResult: onReverseGeocodeResult, load: loadReverseGeocode } =
|
||||
useReverseGeocode();
|
||||
const { load: loadReverseGeocode } = useReverseGeocode();
|
||||
|
||||
onReverseGeocodeResult((result) => {
|
||||
if (result.loading !== false) return;
|
||||
const { data } = result;
|
||||
addressData.value = data.reverseGeocode;
|
||||
|
||||
if (addressData.value.length > 0) {
|
||||
const foundAddress = addressData.value[0];
|
||||
Object.assign(selected, foundAddress);
|
||||
console.debug("reverse geocode succeded, setting new address");
|
||||
queryTextWithDefault.value = addressFullName(foundAddress);
|
||||
emit("update:modelValue", selected);
|
||||
}
|
||||
});
|
||||
|
||||
const reverseGeoCode = (e: LatLng, zoom: number) => {
|
||||
const reverseGeoCode = async (e: LatLng, zoom: number) => {
|
||||
console.debug("reverse geocode");
|
||||
|
||||
// If the details is opened, just update coords, don't reverse geocode
|
||||
|
@ -423,12 +410,26 @@ const reverseGeoCode = (e: LatLng, zoom: number) => {
|
|||
// If the position has been updated through autocomplete selection, no need to geocode it!
|
||||
if (!e || checkCurrentPosition(e)) return;
|
||||
|
||||
loadReverseGeocode(undefined, {
|
||||
try {
|
||||
const result = await loadReverseGeocode(undefined, {
|
||||
latitude: e.lat,
|
||||
longitude: e.lng,
|
||||
zoom,
|
||||
locale: locale as unknown as string,
|
||||
});
|
||||
if (!result) return;
|
||||
addressData.value = result.reverseGeocode;
|
||||
|
||||
if (addressData.value.length > 0) {
|
||||
const foundAddress = addressData.value[0];
|
||||
Object.assign(selected, foundAddress);
|
||||
console.debug("reverse geocode succeded, setting new address");
|
||||
queryTextWithDefault.value = addressFullName(foundAddress);
|
||||
emit("update:modelValue", selected);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load reverse geocode", err);
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
|
|
|
@ -40,10 +40,10 @@ const basicGroup: IGroup = {
|
|||
const groupWithMedia: IGroup = {
|
||||
...basicGroup,
|
||||
banner: {
|
||||
url: "https://mobilizon.fr/media/7b340fe641e7ad711ebb6f8821b5ce824992db08701e37ebb901c175436aaafc.jpg?name=framasoft%27s%20banner.jpg",
|
||||
url: "https://mobilizon.fr/media/a8227a16cc80b3d20ff5ee549a29c1b20a0ca1547f8861129aae9f00c3c69d12.jpg?name=framasoft%27s%20banner.jpg",
|
||||
},
|
||||
avatar: {
|
||||
url: "https://mobilizon.fr/media/ff5b2d425fb73e17fcbb56a1a032359ee0b21453c11af59e103e783817a32fdf.png?name=framasoft%27s%20avatar.png",
|
||||
url: "https://mobilizon.fr/media/890f5396ef80081a6b1b18a5db969746cf8bb340e8a4e657d665e41f6646c539.jpg?name=framasoft%27s%20avatar.jpg",
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -122,8 +122,8 @@ const events = computed(
|
|||
() => eventsResult.value?.searchEvents ?? { elements: [], total: 0 }
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
load();
|
||||
onMounted(async () => {
|
||||
await load();
|
||||
});
|
||||
|
||||
const loading = computed(() => props.doingGeoloc || loadingEvents.value);
|
||||
|
|
|
@ -13,7 +13,24 @@
|
|||
>
|
||||
<MobilizonLogo class="w-40" />
|
||||
</router-link>
|
||||
<div class="flex items-center md:order-2 ml-auto" v-if="currentActor?.id">
|
||||
<div
|
||||
class="flex items-center md:order-2 ml-auto gap-2"
|
||||
v-if="currentActor?.id"
|
||||
>
|
||||
<router-link
|
||||
:to="{ name: RouteName.CONVERSATION_LIST }"
|
||||
class="flex sm:mr-3 text-sm md:mr-0 relative"
|
||||
id="conversations-menu-button"
|
||||
aria-expanded="false"
|
||||
>
|
||||
<span class="sr-only">{{ t("Open conversations") }}</span>
|
||||
<Inbox :size="32" />
|
||||
<span
|
||||
v-show="unreadConversationsCount > 0"
|
||||
class="absolute bottom-0.5 -left-2 bg-primary rounded-full inline-block h-3 w-3 mx-2"
|
||||
>
|
||||
</span>
|
||||
</router-link>
|
||||
<o-dropdown position="bottom-left">
|
||||
<template #trigger>
|
||||
<button
|
||||
|
@ -202,22 +219,28 @@
|
|||
import MobilizonLogo from "@/components/MobilizonLogo.vue";
|
||||
import { ICurrentUserRole } from "@/types/enums";
|
||||
import { logout } from "../utils/auth";
|
||||
import { displayName } from "../types/actor";
|
||||
import { IPerson, displayName } from "../types/actor";
|
||||
import RouteName from "../router/name";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import AccountCircle from "vue-material-design-icons/AccountCircle.vue";
|
||||
import Inbox from "vue-material-design-icons/Inbox.vue";
|
||||
import { useCurrentUserClient } from "@/composition/apollo/user";
|
||||
import {
|
||||
useCurrentActorClient,
|
||||
useCurrentUserIdentities,
|
||||
} from "@/composition/apollo/actor";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { useLazyQuery, useMutation } from "@vue/apollo-composable";
|
||||
import { UPDATE_DEFAULT_ACTOR } from "@/graphql/actor";
|
||||
import { changeIdentity } from "@/utils/identity";
|
||||
import { useRegistrationConfig } from "@/composition/apollo/config";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
import {
|
||||
UNREAD_ACTOR_CONVERSATIONS,
|
||||
UNREAD_ACTOR_CONVERSATIONS_SUBSCRIPTION,
|
||||
} from "@/graphql/user";
|
||||
import { ICurrentUser } from "@/types/current-user.model";
|
||||
|
||||
const { currentUser } = useCurrentUserClient();
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
@ -239,6 +262,61 @@ const canRegister = computed(() => {
|
|||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const unreadConversationsCount = computed(
|
||||
() =>
|
||||
unreadActorConversationsResult.value?.loggedUser.defaultActor
|
||||
?.unreadConversationsCount ?? 0
|
||||
);
|
||||
|
||||
const {
|
||||
result: unreadActorConversationsResult,
|
||||
load: loadUnreadConversations,
|
||||
subscribeToMore,
|
||||
} = useLazyQuery<{
|
||||
loggedUser: Pick<ICurrentUser, "id" | "defaultActor">;
|
||||
}>(UNREAD_ACTOR_CONVERSATIONS);
|
||||
|
||||
watch(currentActor, async (currentActorValue, previousActorValue) => {
|
||||
if (
|
||||
currentActorValue?.id &&
|
||||
currentActorValue.preferredUsername !==
|
||||
previousActorValue?.preferredUsername
|
||||
) {
|
||||
await loadUnreadConversations();
|
||||
|
||||
subscribeToMore<
|
||||
{ personId: string },
|
||||
{ personUnreadConversationsCount: number }
|
||||
>({
|
||||
document: UNREAD_ACTOR_CONVERSATIONS_SUBSCRIPTION,
|
||||
variables: {
|
||||
personId: currentActor.value?.id as string,
|
||||
},
|
||||
updateQuery: (previousResult, { subscriptionData }) => {
|
||||
console.debug(
|
||||
"Updating actor unread conversations count query after subscribe to more update",
|
||||
subscriptionData?.data?.personUnreadConversationsCount
|
||||
);
|
||||
return {
|
||||
...previousResult,
|
||||
loggedUser: {
|
||||
id: previousResult.loggedUser.id,
|
||||
defaultActor: {
|
||||
...previousResult.loggedUser.defaultActor,
|
||||
unreadConversationsCount:
|
||||
subscriptionData?.data?.personUnreadConversationsCount ??
|
||||
previousResult.loggedUser.defaultActor
|
||||
?.unreadConversationsCount,
|
||||
} as IPerson, // no idea why,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {});
|
||||
|
||||
watch(identities, () => {
|
||||
// If we don't have any identities, the user has validated their account,
|
||||
// is logging for the first time but didn't create an identity somehow
|
||||
|
|
109
js/src/components/Participation/NewPrivateMessage.vue
Normal file
109
js/src/components/Participation/NewPrivateMessage.vue
Normal file
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<form @submit="sendForm">
|
||||
<Editor
|
||||
v-model="text"
|
||||
mode="basic"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
<o-button class="mt-3" nativeType="submit">{{ t("Send") }}</o-button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { useCurrentActorClient } from "@/composition/apollo/actor";
|
||||
import { SEND_EVENT_PRIVATE_MESSAGE_MUTATION } from "@/graphql/conversations";
|
||||
import { EVENT_CONVERSATIONS } from "@/graphql/event";
|
||||
import { IConversation } from "@/types/conversation";
|
||||
import { ParticipantRole } from "@/types/enums";
|
||||
import { IEvent } from "@/types/event.model";
|
||||
import { useMutation } from "@vue/apollo-composable";
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const props = defineProps<{
|
||||
event: IEvent;
|
||||
}>();
|
||||
|
||||
const event = computed(() => props.event);
|
||||
|
||||
const text = ref("");
|
||||
const {
|
||||
mutate: eventPrivateMessageMutate,
|
||||
onDone: onEventPrivateMessageMutated,
|
||||
} = useMutation<
|
||||
{
|
||||
sendEventPrivateMessage: IConversation;
|
||||
},
|
||||
{
|
||||
text: string;
|
||||
actorId: string;
|
||||
eventId: string;
|
||||
roles?: string;
|
||||
inReplyToActorId?: ParticipantRole[];
|
||||
language?: string;
|
||||
}
|
||||
>(SEND_EVENT_PRIVATE_MESSAGE_MUTATION, {
|
||||
update(cache, result) {
|
||||
if (!result.data?.sendEventPrivateMessage) return;
|
||||
const cachedData = cache.readQuery<{
|
||||
event: Pick<IEvent, "conversations" | "id" | "uuid">;
|
||||
}>({
|
||||
query: EVENT_CONVERSATIONS,
|
||||
variables: {
|
||||
uuid: event.value.uuid,
|
||||
page: 1,
|
||||
},
|
||||
});
|
||||
if (!cachedData) return;
|
||||
cache.writeQuery({
|
||||
query: EVENT_CONVERSATIONS,
|
||||
variables: {
|
||||
uuid: event.value.uuid,
|
||||
page: 1,
|
||||
},
|
||||
data: {
|
||||
event: {
|
||||
...cachedData?.event,
|
||||
conversations: {
|
||||
...cachedData.event.conversations,
|
||||
total: cachedData.event.conversations.total + 1,
|
||||
elements: [
|
||||
...cachedData.event.conversations.elements,
|
||||
result.data.sendEventPrivateMessage,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const sendForm = (e: Event) => {
|
||||
e.preventDefault();
|
||||
console.debug("Sending new private message");
|
||||
if (!currentActor.value?.id || !event.value.id) return;
|
||||
eventPrivateMessageMutate({
|
||||
text: text.value,
|
||||
actorId:
|
||||
event.value?.attributedTo?.id ??
|
||||
event.value.organizerActor?.id ??
|
||||
currentActor.value?.id,
|
||||
eventId: event.value.id,
|
||||
});
|
||||
};
|
||||
|
||||
onEventPrivateMessageMutated(() => {
|
||||
text.value = "";
|
||||
});
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("../../components/TextEditor.vue")
|
||||
);
|
||||
</script>
|
|
@ -5,7 +5,9 @@
|
|||
</header>
|
||||
|
||||
<section>
|
||||
<div class="flex gap-1 flex-row mb-3">
|
||||
<div
|
||||
class="flex gap-1 flex-row mb-3 bg-mbz-yellow p-3 rounded items-center"
|
||||
>
|
||||
<o-icon
|
||||
icon="alert"
|
||||
variant="warning"
|
||||
|
|
|
@ -273,7 +273,7 @@ import Placeholder from "@tiptap/extension-placeholder";
|
|||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string;
|
||||
mode?: string;
|
||||
mode?: "description" | "comment" | "basic";
|
||||
maxSize?: number;
|
||||
ariaLabel?: string;
|
||||
currentActor: IPerson;
|
||||
|
@ -305,12 +305,6 @@ const isBasicMode = computed((): boolean => {
|
|||
return props.mode === "basic";
|
||||
});
|
||||
|
||||
// const insertMention = (obj: { range: any; attrs: any }) => {
|
||||
// console.debug("initialize Mention");
|
||||
// };
|
||||
|
||||
// const observer = ref<MutationObserver | null>(null);
|
||||
|
||||
const transformPastedHTML = (html: string): string => {
|
||||
// When using comment mode, limit to acceptable tags
|
||||
if (isCommentMode.value) {
|
||||
|
|
46
js/src/composition/apollo/members.ts
Normal file
46
js/src/composition/apollo/members.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { GROUP_MEMBERS } from "@/graphql/member";
|
||||
import { IGroup } from "@/types/actor";
|
||||
import { MemberRole } from "@/types/enums";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { computed } from "vue";
|
||||
import type { Ref } from "vue";
|
||||
|
||||
type useGroupMembersOptions = {
|
||||
membersPage?: number;
|
||||
membersLimit?: number;
|
||||
roles?: MemberRole[];
|
||||
enabled?: Ref<boolean>;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
export function useGroupMembers(
|
||||
groupName: Ref<string>,
|
||||
options: useGroupMembersOptions = {}
|
||||
) {
|
||||
console.debug("useGroupMembers", options);
|
||||
const { result, error, loading, onResult, onError, refetch, fetchMore } =
|
||||
useQuery<
|
||||
{
|
||||
group: IGroup;
|
||||
},
|
||||
{
|
||||
name: string;
|
||||
membersPage?: number;
|
||||
membersLimit?: number;
|
||||
}
|
||||
>(
|
||||
GROUP_MEMBERS,
|
||||
() => ({
|
||||
groupName: groupName.value,
|
||||
page: options.membersPage,
|
||||
limit: options.membersLimit,
|
||||
name: options.name,
|
||||
}),
|
||||
() => ({
|
||||
enabled: !!groupName.value && options.enabled?.value,
|
||||
fetchPolicy: "cache-and-network",
|
||||
})
|
||||
);
|
||||
const members = computed(() => result.value?.group?.members);
|
||||
return { members, error, loading, onResult, onError, refetch, fetchMore };
|
||||
}
|
|
@ -1,18 +1,22 @@
|
|||
import { FILTER_TAGS } from "@/graphql/tags";
|
||||
import { ITag } from "@/types/tag.model";
|
||||
import { apolloClient, waitApolloQuery } from "@/vue-apollo";
|
||||
import { provideApolloClient, useQuery } from "@vue/apollo-composable";
|
||||
import { apolloClient } from "@/vue-apollo";
|
||||
import { provideApolloClient, useLazyQuery } from "@vue/apollo-composable";
|
||||
|
||||
export async function fetchTags(text: string): Promise<ITag[]> {
|
||||
try {
|
||||
const res = await waitApolloQuery(
|
||||
provideApolloClient(apolloClient)(() =>
|
||||
useQuery<{ tags: ITag[] }, { filter: string }>(FILTER_TAGS, {
|
||||
const { load: loadFetchTagsQuery } = useLazyQuery<
|
||||
{ tags: ITag[] },
|
||||
{ filter: string }
|
||||
>(FILTER_TAGS);
|
||||
|
||||
const res = await provideApolloClient(apolloClient)(() =>
|
||||
loadFetchTagsQuery(FILTER_TAGS, {
|
||||
filter: text,
|
||||
})
|
||||
)
|
||||
);
|
||||
return res.data.tags;
|
||||
if (!res) return [];
|
||||
return res.tags;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return [];
|
||||
|
|
|
@ -17,6 +17,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
|
|||
insertedAt
|
||||
updatedAt
|
||||
deletedAt
|
||||
publishedAt
|
||||
isAnnouncement
|
||||
language
|
||||
}
|
||||
|
|
166
js/src/graphql/conversations.ts
Normal file
166
js/src/graphql/conversations.ts
Normal file
|
@ -0,0 +1,166 @@
|
|||
import gql from "graphql-tag";
|
||||
import { ACTOR_FRAGMENT } from "./actor";
|
||||
import { COMMENT_FIELDS_FRAGMENT } from "./comment";
|
||||
|
||||
export const CONVERSATION_QUERY_FRAGMENT = gql`
|
||||
fragment ConversationQuery on Conversation {
|
||||
id
|
||||
conversationParticipantId
|
||||
actor {
|
||||
...ActorFragment
|
||||
}
|
||||
lastComment {
|
||||
...CommentFields
|
||||
}
|
||||
participants {
|
||||
...ActorFragment
|
||||
}
|
||||
event {
|
||||
id
|
||||
uuid
|
||||
title
|
||||
picture {
|
||||
id
|
||||
url
|
||||
name
|
||||
metadata {
|
||||
width
|
||||
height
|
||||
blurhash
|
||||
}
|
||||
}
|
||||
}
|
||||
unread
|
||||
insertedAt
|
||||
updatedAt
|
||||
}
|
||||
${ACTOR_FRAGMENT}
|
||||
${COMMENT_FIELDS_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CONVERSATIONS_QUERY_FRAGMENT = gql`
|
||||
fragment ConversationsQuery on PaginatedConversationList {
|
||||
total
|
||||
elements {
|
||||
...ConversationQuery
|
||||
}
|
||||
}
|
||||
${CONVERSATION_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const SEND_EVENT_PRIVATE_MESSAGE_MUTATION = gql`
|
||||
mutation SendEventPrivateMessageMutation(
|
||||
$text: String!
|
||||
$actorId: ID!
|
||||
$eventId: ID!
|
||||
$roles: [ParticipantRoleEnum]
|
||||
$attributedToId: ID
|
||||
$language: String
|
||||
) {
|
||||
sendEventPrivateMessage(
|
||||
text: $text
|
||||
actorId: $actorId
|
||||
eventId: $eventId
|
||||
roles: $roles
|
||||
attributedToId: $attributedToId
|
||||
language: $language
|
||||
) {
|
||||
...ConversationQuery
|
||||
}
|
||||
}
|
||||
${CONVERSATION_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const GET_CONVERSATION = gql`
|
||||
query GetConversation($id: ID!, $page: Int, $limit: Int) {
|
||||
conversation(id: $id) {
|
||||
...ConversationQuery
|
||||
comments(page: $page, limit: $limit) @connection(key: "comments") {
|
||||
total
|
||||
elements {
|
||||
id
|
||||
text
|
||||
actor {
|
||||
...ActorFragment
|
||||
}
|
||||
insertedAt
|
||||
updatedAt
|
||||
deletedAt
|
||||
publishedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${CONVERSATION_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const POST_PRIVATE_MESSAGE_MUTATION = gql`
|
||||
mutation PostPrivateMessageMutation(
|
||||
$text: String!
|
||||
$actorId: ID!
|
||||
$language: String
|
||||
$mentions: [String]
|
||||
) {
|
||||
postPrivateMessage(
|
||||
text: $text
|
||||
actorId: $actorId
|
||||
language: $language
|
||||
mentions: $mentions
|
||||
) {
|
||||
...ConversationQuery
|
||||
}
|
||||
}
|
||||
${CONVERSATION_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const REPLY_TO_PRIVATE_MESSAGE_MUTATION = gql`
|
||||
mutation ReplyToPrivateMessageMutation(
|
||||
$text: String!
|
||||
$actorId: ID!
|
||||
$attributedToId: ID
|
||||
$language: String
|
||||
$conversationId: ID!
|
||||
$mentions: [String]
|
||||
) {
|
||||
postPrivateMessage(
|
||||
text: $text
|
||||
actorId: $actorId
|
||||
attributedToId: $attributedToId
|
||||
language: $language
|
||||
conversationId: $conversationId
|
||||
mentions: $mentions
|
||||
) {
|
||||
...ConversationQuery
|
||||
}
|
||||
}
|
||||
${CONVERSATION_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CONVERSATION_COMMENT_CHANGED = gql`
|
||||
subscription ConversationCommentChanged($id: ID!) {
|
||||
conversationCommentChanged(id: $id) {
|
||||
id
|
||||
lastComment {
|
||||
id
|
||||
text
|
||||
updatedAt
|
||||
insertedAt
|
||||
deletedAt
|
||||
publishedAt
|
||||
actor {
|
||||
...ActorFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${ACTOR_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const MARK_CONVERSATION_AS_READ = gql`
|
||||
mutation MarkConversationAsRead($id: ID!, $read: Boolean!) {
|
||||
updateConversation(conversationId: $id, read: $read) {
|
||||
...ConversationQuery
|
||||
}
|
||||
}
|
||||
${CONVERSATION_QUERY_FRAGMENT}
|
||||
`;
|
|
@ -7,6 +7,7 @@ import {
|
|||
PARTICIPANT_QUERY_FRAGMENT,
|
||||
} from "./participant";
|
||||
import { TAG_FRAGMENT } from "./tags";
|
||||
import { CONVERSATIONS_QUERY_FRAGMENT } from "./conversations";
|
||||
|
||||
const FULL_EVENT_FRAGMENT = gql`
|
||||
fragment FullEvent on Event {
|
||||
|
@ -375,9 +376,16 @@ export const PARTICIPANTS = gql`
|
|||
rejected
|
||||
participant
|
||||
}
|
||||
organizerActor {
|
||||
...ActorFragment
|
||||
}
|
||||
attributedTo {
|
||||
...ActorFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
${PARTICIPANTS_QUERY_FRAGMENT}
|
||||
${ACTOR_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const EVENT_PERSON_PARTICIPATION = gql`
|
||||
|
@ -494,3 +502,41 @@ export const EXPORT_EVENT_PARTICIPATIONS = gql`
|
|||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const EVENT_CONVERSATIONS = gql`
|
||||
query EventConversations($uuid: UUID!, $page: Int, $limit: Int) {
|
||||
event(uuid: $uuid) {
|
||||
id
|
||||
uuid
|
||||
title
|
||||
conversations(page: $page, limit: $limit) {
|
||||
...ConversationsQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
${CONVERSATIONS_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const USER_CONVERSATIONS = gql`
|
||||
query UserConversations($page: Int, $limit: Int) {
|
||||
loggedUser {
|
||||
id
|
||||
conversations(page: $page, limit: $limit) {
|
||||
...ConversationsQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
${CONVERSATIONS_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const PROFILE_CONVERSATIONS = gql`
|
||||
query ProfileConversations($page: Int, $limit: Int) {
|
||||
loggedPerson {
|
||||
id
|
||||
conversations(page: $page, limit: $limit) {
|
||||
...ConversationsQuery
|
||||
}
|
||||
}
|
||||
}
|
||||
${CONVERSATIONS_QUERY_FRAGMENT}
|
||||
`;
|
||||
|
|
|
@ -85,6 +85,12 @@ const REPORT_FRAGMENT = gql`
|
|||
uuid
|
||||
title
|
||||
}
|
||||
conversation {
|
||||
id
|
||||
participants {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
notes {
|
||||
id
|
||||
|
|
|
@ -247,6 +247,34 @@ export const SEARCH_PERSONS = gql`
|
|||
${ACTOR_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const SEARCH_PERSON_AND_GROUPS = gql`
|
||||
query SearchPersonsAndGroups($searchText: String!, $page: Int, $limit: Int) {
|
||||
searchPersons(term: $searchText, page: $page, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
...ActorFragment
|
||||
}
|
||||
}
|
||||
searchGroups(term: $searchText, page: $page, limit: $limit) {
|
||||
total
|
||||
elements {
|
||||
...ActorFragment
|
||||
banner {
|
||||
id
|
||||
url
|
||||
}
|
||||
membersCount
|
||||
followersCount
|
||||
physicalAddress {
|
||||
...AdressFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${ADDRESS_FRAGMENT}
|
||||
${ACTOR_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const INTERACT = gql`
|
||||
query Interact($uri: String!) {
|
||||
interact(uri: $uri) {
|
||||
|
|
|
@ -312,3 +312,21 @@ export const FEED_TOKENS_LOGGED_USER = gql`
|
|||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UNREAD_ACTOR_CONVERSATIONS = gql`
|
||||
query LoggedUserUnreadConversations {
|
||||
loggedUser {
|
||||
id
|
||||
defaultActor {
|
||||
id
|
||||
unreadConversationsCount
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const UNREAD_ACTOR_CONVERSATIONS_SUBSCRIPTION = gql`
|
||||
subscription OnUreadActorConversationsChanged($personId: ID!) {
|
||||
personUnreadConversationsCount(personId: $personId)
|
||||
}
|
||||
`;
|
||||
|
|
|
@ -1610,5 +1610,20 @@
|
|||
"External registration": "External registration",
|
||||
"I want to manage the registration with an external provider": "I want to manage the registration with an external provider",
|
||||
"External provider URL": "External provider URL",
|
||||
"Members will also access private sections like discussions, resources and restricted posts.": "Members will also access private sections like discussions, resources and restricted posts."
|
||||
"Members will also access private sections like discussions, resources and restricted posts.": "Members will also access private sections like discussions, resources and restricted posts.",
|
||||
"With unknown participants": "With unknown participants",
|
||||
"With {participants}": "With {participants}",
|
||||
"Conversations": "Conversations",
|
||||
"New private message": "New private message",
|
||||
"There's no conversations yet": "There's no conversations yet",
|
||||
"Open conversations": "Open conversations",
|
||||
"List of conversations": "List of conversations",
|
||||
"Conversation with {participants}": "Conversation with {participants}",
|
||||
"Delete this conversation": "Delete this conversation",
|
||||
"Are you sure you want to delete this entire conversation?": "Are you sure you want to delete this entire conversation?",
|
||||
"This is a announcement from the organizers of event {event}. You can't reply to it, but you can send a private message to event organizers.": "This is a announcement from the organizers of event {event}. You can't reply to it, but you can send a private message to event organizers.",
|
||||
"You have access to this conversation as a member of the {group} group": "You have access to this conversation as a member of the {group} group",
|
||||
"Comment from an event announcement": "Comment from an event announcement",
|
||||
"Comment from a private conversation": "Comment from a private conversation",
|
||||
"I've been mentionned in a conversation": "I've been mentionned in a conversation"
|
||||
}
|
|
@ -31,7 +31,7 @@
|
|||
"A post has been updated": "Un billet a été mis à jour",
|
||||
"A practical tool": "Un outil pratique",
|
||||
"A resource has been created or updated": "Une resource a été créée ou mise à jour",
|
||||
"A short tagline for your instance homepage. Defaults to \"Gather ⋅ Organize ⋅ Mobilize\"": "Un court slogan pour la page d'accueil de votre instance. La valeur par défaut est « Rassembler ⋅ Organiser ⋅ Mobiliser »",
|
||||
"A short tagline for your instance homepage. Defaults to \"Gather · Organize · Mobilize\"": "Un court slogan pour la page d'accueil de votre instance. La valeur par défaut est « Rassembler · Organiser · Mobiliser »",
|
||||
"A twitter account handle to follow for event updates": "Un compte sur Twitter à suivre pour les mises à jour de l'événement",
|
||||
"A user-friendly, emancipatory and ethical tool for gathering, organising, and mobilising.": "Un outil convivial, émancipateur et éthique pour se rassembler, s'organiser et se mobiliser.",
|
||||
"A validation email was sent to {email}": "Un email de validation a été envoyé à {email}",
|
||||
|
@ -103,7 +103,7 @@
|
|||
"An URL to an external ticketing platform": "Une URL vers une plateforme de billetterie externe",
|
||||
"An anonymous profile joined the event {event}.": "Un profil anonyme a rejoint l'événement {event}.",
|
||||
"An error has occured while refreshing the page.": "Une erreur est survenue lors du rafraîchissement de la page.",
|
||||
"An error has occured. Sorry about that. You may try to reload the page.": "Une erreur est survenue. Nous en sommes désolé⋅es. Vous pouvez essayer de rafraîchir la page.",
|
||||
"An error has occured. Sorry about that. You may try to reload the page.": "Une erreur est survenue. Nous en sommes désolé·es. Vous pouvez essayer de rafraîchir la page.",
|
||||
"An ethical alternative": "Une alternative éthique",
|
||||
"An event I'm going to has been updated": "Un événement auquel je participe a été mis à jour",
|
||||
"An event I'm going to has posted an announcement": "Un événement auquel je participe a posté une annonce",
|
||||
|
@ -118,7 +118,7 @@
|
|||
"And {number} comments": "Et {number} commentaires",
|
||||
"Announcements": "Annonces",
|
||||
"Announcements and mentions notifications are always sent straight away.": "Les notifications d'annonces et de mentions sont toujours envoyées directement.",
|
||||
"Anonymous participant": "Participant⋅e anonyme",
|
||||
"Anonymous participant": "Participant·e anonyme",
|
||||
"Anonymous participants will be asked to confirm their participation through e-mail.": "Les participants anonymes devront confirmer leur participation par email.",
|
||||
"Anonymous participations": "Participations anonymes",
|
||||
"Any category": "N'importe quelle catégorie",
|
||||
|
@ -126,7 +126,7 @@
|
|||
"Any distance": "N'importe quelle distance",
|
||||
"Any type": "N'importe quel type",
|
||||
"Anyone can join freely": "N'importe qui peut rejoindre",
|
||||
"Anyone can request being a member, but an administrator needs to approve the membership.": "N'importe qui peut demander à être membre, mais un⋅e administrateur⋅ice devra approuver leur adhésion.",
|
||||
"Anyone can request being a member, but an administrator needs to approve the membership.": "N'importe qui peut demander à être membre, mais un·e administrateur·ice devra approuver leur adhésion.",
|
||||
"Anyone wanting to be a member from your group will be able to from your group page.": "N'importe qui voulant devenir membre pourra le faire depuis votre page de groupe.",
|
||||
"Application": "Application",
|
||||
"Application authorized": "Application autorisée",
|
||||
|
@ -135,26 +135,26 @@
|
|||
"Apply filters": "Appliquer les filtres",
|
||||
"Approve member": "Approuver le ou la membre",
|
||||
"Apps": "Applications",
|
||||
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Êtes-vous vraiment certain⋅e de vouloir supprimer votre compte ? Vous allez tout perdre. Identités, paramètres, événements créés, messages et participations disparaîtront pour toujours.",
|
||||
"Are you really sure you want to delete your whole account? You'll lose everything. Identities, settings, events created, messages and participations will be gone forever.": "Êtes-vous vraiment certain·e de vouloir supprimer votre compte ? Vous allez tout perdre. Identités, paramètres, événements créés, messages et participations disparaîtront pour toujours.",
|
||||
"Are you sure you want to <b>completely delete</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>complètement supprimer</b> ce groupe ? Tous les membres - y compris ceux·elles sur d'autres instances - seront notifié·e·s et supprimé·e·s du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
|
||||
"Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>.": "Êtes-vous certain⋅e de vouloir <b>supprimer</b> ce commentaire ? <b>Cette action ne peut pas être annulée.</b>",
|
||||
"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.",
|
||||
"Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their 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 discussion avec le créateur de l'événement et lui demander de modifier son événement à la place.",
|
||||
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion 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 discussion avec le créateur de l'événement ou bien modifier son événement à la place.",
|
||||
"Are you sure you want to <b>delete</b> this comment? <b>This action cannot be undone</b>.": "Êtes-vous certain·e de vouloir <b>supprimer</b> ce commentaire ? <b>Cette action ne peut pas être annulée.</b>",
|
||||
"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.",
|
||||
"Are you sure you want to <b>delete</b> this event? <b>This action cannot be undone</b>. You may want to engage the discussion with the event creator and ask them to edit their 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 discussion avec le créateur de l'événement et lui demander de modifier son événement à la place.",
|
||||
"Are you sure you want to <b>delete</b> this event? This action cannot be undone. You may want to engage the discussion 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 discussion avec le créateur de l'événement ou bien modifier son événement à la place.",
|
||||
"Are you sure you want to <b>suspend</b> this group? All members - including remote ones - will be notified and removed from the group, and <b>all of the group data (events, posts, discussions, todos…) will be irretrievably destroyed</b>.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Tous les membres - y compris ceux·elles sur d'autres instances - seront notifié·e·s et supprimé·e·s du groupe, et <b>toutes les données associées au groupe (événements, billets, discussions, todos…) seront irrémédiablement détruites</b>.",
|
||||
"Are you sure you want to <b>suspend</b> this group? As this group originates from instance {instance}, this will only remove local members and delete the local data, as well as rejecting all the future data.": "Êtes-vous certain·e de vouloir <b>suspendre</b> ce groupe ? Comme ce groupe provient de l'instance {instance}, cela supprimera seulement les membres locaux et supprimera les données locales, et rejettera également toutes les données futures.",
|
||||
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la création de l'événement ? Vous allez perdre toutes vos modifications.",
|
||||
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain⋅e de vouloir annuler la modification de l'événement ? Vous allez perdre toutes vos modifications.",
|
||||
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain⋅e de vouloir annuler votre participation à l'événement « {title} » ?",
|
||||
"Are you sure you want to delete this entire discussion?": "Êtes-vous certain⋅e de vouloir supprimer l'entièreté de cette discussion ?",
|
||||
"Are you sure you want to delete this event? This action cannot be reverted.": "Êtes-vous certain⋅e de vouloir supprimer cet événement ? Cette action ne peut être annulée.",
|
||||
"Are you sure you want to cancel the event creation? You'll lose all modifications.": "Êtes-vous certain·e de vouloir annuler la création de l'événement ? Vous allez perdre toutes vos modifications.",
|
||||
"Are you sure you want to cancel the event edition? You'll lose all modifications.": "Êtes-vous certain·e de vouloir annuler la modification de l'événement ? Vous allez perdre toutes vos modifications.",
|
||||
"Are you sure you want to cancel your participation at event \"{title}\"?": "Êtes-vous certain·e de vouloir annuler votre participation à l'événement « {title} » ?",
|
||||
"Are you sure you want to delete this entire discussion?": "Êtes-vous certain·e de vouloir supprimer l'entièreté de cette discussion ?",
|
||||
"Are you sure you want to delete this event? This action cannot be reverted.": "Êtes-vous certain·e de vouloir supprimer cet événement ? Cette action ne peut être annulée.",
|
||||
"Are you sure you want to delete this post? This action cannot be reverted.": "Voulez-vous vraiment supprimer ce billet ? Cette action ne peut pas être annulée.",
|
||||
"Are you sure you want to leave the group {groupName}? You'll loose access to this group's private content. This action cannot be undone.": "Êtes-vous sûr⋅e de vouloir quitter le groupe {groupName} ? Vous perdrez accès au contenu privé de ce groupe. Cette action ne peut pas être annulée.",
|
||||
"Are you sure you want to leave the group {groupName}? You'll loose access to this group's private content. This action cannot be undone.": "Êtes-vous sûr·e de vouloir quitter le groupe {groupName} ? Vous perdrez accès au contenu privé de ce groupe. Cette action ne peut pas être annulée.",
|
||||
"As the event organizer has chosen to manually validate participation requests, your participation will be really confirmed only once you receive an email stating it's being accepted.": "L'organisateur de l'événement ayant choisi de valider manuellement les demandes de participation, votre participation ne sera réellement confirmée que lorsque vous recevrez un courriel indiquant qu'elle est acceptée.",
|
||||
"Ask your instance admin to {enable_feature}.": "Demandez à l'administrateur⋅ice de votre instance d'{enable_feature}.",
|
||||
"Ask your instance admin to {enable_feature}.": "Demandez à l'administrateur·ice de votre instance d'{enable_feature}.",
|
||||
"Assigned to": "Assigné à",
|
||||
"Atom feed for events and posts": "Flux Atom pour les événements et les billets",
|
||||
"Attending": "Participant⋅e",
|
||||
"Attending": "Participant·e",
|
||||
"Authorize": "Autoriser",
|
||||
"Authorize application": "Autoriser l'application",
|
||||
"Authorized on {authorization_date}": "Autorisée le {authorization_date}",
|
||||
|
@ -165,7 +165,7 @@
|
|||
"Back to previous page": "Retour à la page précédente",
|
||||
"Back to profile list": "Retour à la liste des profiles",
|
||||
"Back to top": "Retour en haut",
|
||||
"Back to user list": "Retour à la liste des utilisateur⋅ices",
|
||||
"Back to user list": "Retour à la liste des utilisateur·ices",
|
||||
"Banner": "Bannière",
|
||||
"Become part of the community and start organizing events": "Faites partie de la communauté et commencez à organiser des événements",
|
||||
"Before you can login, you need to click on the link inside it to validate your account.": "Avant que vous puissiez vous enregistrer, vous devez cliquer sur le lien à l'intérieur pour valider votre compte.",
|
||||
|
@ -207,7 +207,7 @@
|
|||
"Change role": "Changer le role",
|
||||
"Change the filters.": "Changez les filtres.",
|
||||
"Change timezone": "Changer de fuseau horaire",
|
||||
"Change user email": "Modifier l'email de l'utilisateur⋅ice",
|
||||
"Change user email": "Modifier l'email de l'utilisateur·ice",
|
||||
"Change user role": "Changer le role de l'utilisateur",
|
||||
"Check your device to continue. You may now close this window.": "Vérifiez votre appareil pour continuer. Vous pouvez maintenant fermer cette fenêtre.",
|
||||
"Check your inbox (and your junk mail folder).": "Vérifiez votre boîte de réception (et votre dossier des indésirables).",
|
||||
|
@ -223,7 +223,7 @@
|
|||
"Click for more information": "Cliquez pour plus d'informations",
|
||||
"Click to upload": "Cliquez pour téléverser",
|
||||
"Close": "Fermer",
|
||||
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateur⋅rice·s)",
|
||||
"Close comments for all (except for admins)": "Fermer les commentaires à tout le monde (excepté les administrateur·rice·s)",
|
||||
"Close map": "Fermer la carte",
|
||||
"Closed": "Fermé",
|
||||
"Comment body": "Corps du commentaire",
|
||||
|
@ -238,7 +238,7 @@
|
|||
"Confirm my participation": "Confirmer ma participation",
|
||||
"Confirm my particpation": "Confirmer ma participation",
|
||||
"Confirm participation": "Confirmer la participation",
|
||||
"Confirm user": "Confirmer l'utilisateur⋅ice",
|
||||
"Confirm user": "Confirmer l'utilisateur·ice",
|
||||
"Confirmed": "Confirmé·e",
|
||||
"Confirmed at": "Confirmé·e à",
|
||||
"Confirmed: Will happen": "Confirmé : aura lieu",
|
||||
|
@ -343,7 +343,7 @@
|
|||
"Distance": "Distance",
|
||||
"Do not receive any mail": "Ne pas recevoir d'e-mail",
|
||||
"Do you really want to suspend the account « {emailAccount} » ?": "Voulez-vous vraiment suspendre le compte « {emailAccount} » ?",
|
||||
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Voulez-vous vraiment suspendre ce compte ? Tous les profils de cet⋅te utilisateur⋅ice seront supprimés.",
|
||||
"Do you really want to suspend this account? All of the user's profiles will be deleted.": "Voulez-vous vraiment suspendre ce compte ? Tous les profils de cet·te utilisateur·ice seront supprimés.",
|
||||
"Do you really want to suspend this profile? All of the profiles content will be deleted.": "Voulez-vous vraiment suspendre ce profil ? Tout le contenu du profil sera supprimé.",
|
||||
"Do you wish to {create_event} or {explore_events}?": "Voulez-vous {create_event} ou {explore_events} ?",
|
||||
"Do you wish to {create_group} or {explore_groups}?": "Voulez-vous {create_group} ou {explore_groups} ?",
|
||||
|
@ -356,7 +356,7 @@
|
|||
"Edit": "Modifier",
|
||||
"Edit post": "Éditer le billet",
|
||||
"Edit profile {profile}": "Éditer le profil {profile}",
|
||||
"Edit user email": "Éditer l'email de l'utilisateur⋅ice",
|
||||
"Edit user email": "Éditer l'email de l'utilisateur·ice",
|
||||
"Edited {ago}": "Édité il y a {ago}",
|
||||
"Edited {relative_time} ago": "Édité il y a {relative_time}",
|
||||
"Eg: Stockholm, Dance, Chess…": "Par exemple : Lyon, Danse, Bridge…",
|
||||
|
@ -444,15 +444,15 @@
|
|||
"Follow a new instance": "Suivre une nouvelle instance",
|
||||
"Follow instance": "Suivre l'instance",
|
||||
"Follow request pending approval": "Demande de suivi en attente d'approbation",
|
||||
"Follow requests will be approved by a group moderator": "Les demandes de suivi seront approuvées par un⋅e modérateur⋅ice du groupe",
|
||||
"Follow requests will be approved by a group moderator": "Les demandes de suivi seront approuvées par un·e modérateur·ice du groupe",
|
||||
"Follow status": "Statut du suivi",
|
||||
"Followed": "Suivies",
|
||||
"Followed, pending response": "Suivie, en attente de la réponse",
|
||||
"Follower": "Abonné⋅es",
|
||||
"Followers": "Abonné⋅es",
|
||||
"Followers will receive new public events and posts.": "Les abonnée⋅s recevront les nouveaux événements et billets publics.",
|
||||
"Follower": "Abonné·es",
|
||||
"Followers": "Abonné·es",
|
||||
"Followers will receive new public events and posts.": "Les abonnée·s recevront les nouveaux événements et billets publics.",
|
||||
"Following": "Suivantes",
|
||||
"Following the group will allow you to be informed of the {group_upcoming_public_events}, whereas joining the group means you will {access_to_group_private_content_as_well}, including group discussions, group resources and members-only posts.": "Suivre le groupe vous permettra d'être informé⋅e des {group_upcoming_public_events}, alors que rejoindre le groupe signfie que vous {access_to_group_private_content_as_well}, y compris les discussion de groupe, les resources du groupe et les billets réservés au groupe.",
|
||||
"Following the group will allow you to be informed of the {group_upcoming_public_events}, whereas joining the group means you will {access_to_group_private_content_as_well}, including group discussions, group resources and members-only posts.": "Suivre le groupe vous permettra d'être informé·e des {group_upcoming_public_events}, alors que rejoindre le groupe signfie que vous {access_to_group_private_content_as_well}, y compris les discussion de groupe, les resources du groupe et les billets réservés au groupe.",
|
||||
"Followings": "Abonnements",
|
||||
"Follows us": "Nous suit",
|
||||
"Follows us, pending approval": "Nous suit, en attente de validation",
|
||||
|
@ -467,13 +467,13 @@
|
|||
"From the {startDate} to the {endDate}": "Du {startDate} au {endDate}",
|
||||
"From yourself": "De vous",
|
||||
"Fully accessible with a wheelchair": "Entièrement accessible avec un fauteuil roulant",
|
||||
"Gather ⋅ Organize ⋅ Mobilize": "Rassembler ⋅ Organiser ⋅ Mobiliser",
|
||||
"Gather · Organize · Mobilize": "Rassembler · Organiser · Mobiliser",
|
||||
"General": "Général",
|
||||
"General information": "Informations générales",
|
||||
"General settings": "Paramètres généraux",
|
||||
"Geolocate me": "Me géolocaliser",
|
||||
"Geolocation was not determined in time.": "La localisation n'a pas été déterminée à temps.",
|
||||
"Get informed of the upcoming public events": "Soyez informé⋅e des événements publics à venir",
|
||||
"Get informed of the upcoming public events": "Soyez informé·e des événements publics à venir",
|
||||
"Getting location": "Récupération de la position",
|
||||
"Getting there": "S'y rendre",
|
||||
"Glossary": "Glossaire",
|
||||
|
@ -482,7 +482,7 @@
|
|||
"Go!": "Go !",
|
||||
"Google Meet": "Google Meet",
|
||||
"Group": "Groupe",
|
||||
"Group Followers": "Abonné⋅es au groupe",
|
||||
"Group Followers": "Abonné·es au groupe",
|
||||
"Group Members": "Membres du groupe",
|
||||
"Group URL": "URL du groupe",
|
||||
"Group activity": "Activité des groupes",
|
||||
|
@ -520,8 +520,8 @@
|
|||
"I participate": "Je participe",
|
||||
"I want to allow people to participate without an account.": "Je veux permettre aux gens de participer sans avoir un compte.",
|
||||
"I want to approve every participation request": "Je veux approuver chaque demande de participation",
|
||||
"I've been mentionned in a comment under an event": "J'ai été mentionné⋅e dans un commentaire sous un événement",
|
||||
"I've been mentionned in a group discussion": "J'ai été mentionné⋅e dans une discussion d'un groupe",
|
||||
"I've been mentionned in a comment under an event": "J'ai été mentionné·e dans un commentaire sous un événement",
|
||||
"I've been mentionned in a group discussion": "J'ai été mentionné·e dans une discussion d'un groupe",
|
||||
"I've clicked on X, then on Y": "J'ai cliqué sur X, puis sur Y",
|
||||
"ICS feed for events": "Flux ICS pour les événements",
|
||||
"ICS/WebCal Feed": "Flux ICS/WebCal",
|
||||
|
@ -535,7 +535,7 @@
|
|||
"If this identity is the only administrator of some groups, you need to delete them before being able to delete this identity.": "Si cette identité est la seule administratrice de certains groupes, vous devez les supprimer avant de pouvoir supprimer cette identité.",
|
||||
"If you are being asked for your federated indentity, it's composed of your username and your instance. For instance, the federated identity for your first profile is:": "Si l'on vous demande votre identité fédérée, elle est composée de votre nom d'utilisateur·ice et de votre instance. Par exemple, l'identité fédérée de votre premier profil est :",
|
||||
"If you have opted for manual validation of participants, Mobilizon will send you an email to inform you of new participations to be processed. You can choose the frequency of these notifications below.": "Si vous avez opté pour la validation manuelle des participantes, Mobilizon vous enverra un email pour vous informer des nouvelles participations à traiter. Vous pouvez choisir la fréquence de ces notifications ci-dessous.",
|
||||
"If you want, you may send a message to the event organizer here.": "Si vous le désirez, vous pouvez laisser un message pour l'organisateur⋅ice de l'événement ci-dessous.",
|
||||
"If you want, you may send a message to the event organizer here.": "Si vous le désirez, vous pouvez laisser un message pour l'organisateur·ice de l'événement ci-dessous.",
|
||||
"Ignore": "Ignorer",
|
||||
"Illustration picture for “{category}” by {author} on {source} ({license})": "Image d'illustration pour “{category}” par {author} sur {source} ({license})",
|
||||
"In person": "En personne",
|
||||
|
@ -640,7 +640,7 @@
|
|||
"Member": "Membre",
|
||||
"Members": "Membres",
|
||||
"Members-only post": "Billet reservé aux membres",
|
||||
"Membership requests will be approved by a group moderator": "Les demandes d'adhésion seront approuvées par un⋅e modérateur⋅ice du groupe",
|
||||
"Membership requests will be approved by a group moderator": "Les demandes d'adhésion seront approuvées par un·e modérateur·ice du groupe",
|
||||
"Memberships": "Adhésions",
|
||||
"Mentions": "Mentions",
|
||||
"Message": "Message",
|
||||
|
@ -704,7 +704,7 @@
|
|||
"No event found at this address": "Aucun événement trouvé à cette addresse",
|
||||
"No events found": "Aucun événement trouvé",
|
||||
"No events found for {search}": "Aucun événement trouvé pour {search}",
|
||||
"No follower matches the filters": "Aucun⋅e abonné⋅e ne correspond aux filtres",
|
||||
"No follower matches the filters": "Aucun·e abonné·e ne correspond aux filtres",
|
||||
"No group found": "Aucun groupe trouvé",
|
||||
"No group matches the filters": "Aucun groupe ne correspond aux filtres",
|
||||
"No group member found": "Aucun membre du groupe trouvé",
|
||||
|
@ -719,7 +719,7 @@
|
|||
"No instances match this filter. Try resetting filter fields?": "Aucune instance ne correspond à ce filtre. Essayer de remettre à zéro les champs des filtres ?",
|
||||
"No languages found": "Aucune langue trouvée",
|
||||
"No member matches the filters": "Aucun·e membre ne correspond aux filtres",
|
||||
"No members found": "Aucun⋅e membre trouvé⋅e",
|
||||
"No members found": "Aucun·e membre trouvé·e",
|
||||
"No memberships found": "Aucune adhésion trouvée",
|
||||
"No message": "Pas de message",
|
||||
"No moderation logs yet": "Pas encore de journaux de modération",
|
||||
|
@ -729,8 +729,8 @@
|
|||
"No organized events found": "Aucun événement organisé trouvé",
|
||||
"No organized events listed": "Aucun événement organisé listé",
|
||||
"No participant matches the filters": "Aucun·e participant·e ne correspond aux filtres",
|
||||
"No participant to approve|Approve participant|Approve {number} participants": "Aucun⋅e participant⋅e à valider|Valider le ou la participant⋅e|Valider {number} participant⋅es",
|
||||
"No participant to reject|Reject participant|Reject {number} participants": "Aucun⋅e participant⋅e à refuser|Refuser le ou la participant⋅e|Refuser {number} participant⋅es",
|
||||
"No participant to approve|Approve participant|Approve {number} participants": "Aucun·e participant·e à valider|Valider le ou la participant·e|Valider {number} participant·es",
|
||||
"No participant to reject|Reject participant|Reject {number} participants": "Aucun·e participant·e à refuser|Refuser le ou la participant·e|Refuser {number} participant·es",
|
||||
"No participations listed": "Aucune participation listée",
|
||||
"No posts found": "Aucun billet trouvé",
|
||||
"No posts yet": "Pas encore de billets",
|
||||
|
@ -745,8 +745,8 @@
|
|||
"No results found": "Aucun résultat trouvé",
|
||||
"No results found for {search}": "Aucun résultat trouvé pour {search}",
|
||||
"No rules defined yet.": "Pas de règles définies pour le moment.",
|
||||
"No user matches the filter": "Aucun⋅e utilisateur⋅ice ne correspond au filtre",
|
||||
"No user matches the filters": "Aucun⋅e utilisateur⋅ice ne correspond aux filtres",
|
||||
"No user matches the filter": "Aucun·e utilisateur·ice ne correspond au filtre",
|
||||
"No user matches the filters": "Aucun·e utilisateur·ice ne correspond aux filtres",
|
||||
"None": "Aucun",
|
||||
"Not accessible with a wheelchair": "Non accessible avec un fauteuil roulant",
|
||||
"Not approved": "Non approuvé·e·s",
|
||||
|
@ -757,7 +757,7 @@
|
|||
"Notification settings": "Paramètres des notifications",
|
||||
"Notifications": "Notifications",
|
||||
"Notifications for manually approved participations to an event": "Notifications pour l'approbation manuelle des participations à un événement",
|
||||
"Notify participants": "Notifier les participant⋅es",
|
||||
"Notify participants": "Notifier les participant·es",
|
||||
"Notify the user of the change": "Notifier l'utilisateur du changement",
|
||||
"Now, create your first profile:": "Maintenant, créez votre premier profil :",
|
||||
"Number of members": "Nombre de membres",
|
||||
|
@ -780,13 +780,13 @@
|
|||
"Only accessible through link (private)": "Uniquement accessible par lien (privé)",
|
||||
"Only accessible to members of the group": "Accessible uniquement aux membres du groupe",
|
||||
"Only alphanumeric lowercased characters and underscores are supported.": "Seuls les caractères alphanumériques minuscules et les tirets bas sont acceptés.",
|
||||
"Only group members can access discussions": "Seul⋅es les membres du groupes peuvent accéder aux discussions",
|
||||
"Only group moderators can create, edit and delete events.": "Seule⋅s les modérateur⋅ices de groupe peuvent créer, éditer et supprimer des événements.",
|
||||
"Only group members can access discussions": "Seul·es les membres du groupes peuvent accéder aux discussions",
|
||||
"Only group moderators can create, edit and delete events.": "Seule·s les modérateur·ices de groupe peuvent créer, éditer et supprimer des événements.",
|
||||
"Only group moderators can create, edit and delete posts.": "Seul·e·s les modérateur·rice·s du groupe peuvent créer, éditer et supprimer des billets.",
|
||||
"Only registered users may fetch remote events from their URL.": "Seul⋅es les utilisateur⋅ices enregistré⋅es peuvent récupérer des événements depuis leur URL.",
|
||||
"Only registered users may fetch remote events from their URL.": "Seul·es les utilisateur·ices enregistré·es peuvent récupérer des événements depuis leur URL.",
|
||||
"Open": "Ouvert",
|
||||
"Open a topic on our forum": "Ouvrir un sujet sur notre forum",
|
||||
"Open an issue on our bug tracker (advanced users)": "Ouvrir un ticket sur notre système de suivi des bugs (utilisateur⋅ices avancé⋅es)",
|
||||
"Open an issue on our bug tracker (advanced users)": "Ouvrir un ticket sur notre système de suivi des bugs (utilisateur·ices avancé·es)",
|
||||
"Open main menu": "Ouvrir le menu principal",
|
||||
"Open user menu": "Ouvrir le menu utilisateur",
|
||||
"Opened reports": "Signalements ouverts",
|
||||
|
@ -796,24 +796,24 @@
|
|||
"Organized by": "Organisé par",
|
||||
"Organized by {name}": "Organisé par {name}",
|
||||
"Organized events": "Événements organisés",
|
||||
"Organizer": "Organisateur⋅ice",
|
||||
"Organizer": "Organisateur·ice",
|
||||
"Organizer notifications": "Notifications pour organisateur·rice",
|
||||
"Organizers": "Organisateur⋅ices",
|
||||
"Organizers": "Organisateur·ices",
|
||||
"Other": "Autre",
|
||||
"Other actions": "Autres actions",
|
||||
"Other notification options:": "Autres options de notification :",
|
||||
"Other software may also support this.": "D'autres logiciels peuvent également supporter cette fonctionnalité.",
|
||||
"Other users with the same IP address": "Autres utilisateur⋅ices avec la même adresse IP",
|
||||
"Other users with the same email domain": "Autres utilisateur⋅ices avec le même domaine de courriel",
|
||||
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateur⋅rice·s du groupe.",
|
||||
"Other users with the same IP address": "Autres utilisateur·ices avec la même adresse IP",
|
||||
"Other users with the same email domain": "Autres utilisateur·ices avec le même domaine de courriel",
|
||||
"Otherwise this identity will just be removed from the group administrators.": "Sinon cette identité sera juste supprimée des administrateur·rice·s du groupe.",
|
||||
"Owncast": "Owncast",
|
||||
"Page": "Page",
|
||||
"Page limited to my group (asks for auth)": "Accès limité à mon groupe (demande authentification)",
|
||||
"Page not found": "Page non trouvée",
|
||||
"Parent folder": "Dossier parent",
|
||||
"Partially accessible with a wheelchair": "Partiellement accessible avec un fauteuil roulant",
|
||||
"Participant": "Participant⋅e",
|
||||
"Participants": "Participant⋅e⋅s",
|
||||
"Participant": "Participant·e",
|
||||
"Participants": "Participant·e·s",
|
||||
"Participants to {eventTitle}": "Participant·es à {eventTitle}",
|
||||
"Participate": "Participer",
|
||||
"Participate using your email address": "Participer en utilisant votre adresse email",
|
||||
|
@ -839,7 +839,7 @@
|
|||
"Pick an instance": "Choisir une instance",
|
||||
"Please add as many details as possible to help identify the problem.": "Merci d'ajouter un maximum de détails afin d'aider à identifier le problème.",
|
||||
"Please check your spam folder if you didn't receive the email.": "Merci de vérifier votre dossier des indésirables si vous n'avez pas reçu l'email.",
|
||||
"Please contact this instance's Mobilizon admin if you think this is a mistake.": "Veuillez contacter l'administrateur⋅rice de cette instance Mobilizon si vous pensez qu’il s’agit d’une erreur.",
|
||||
"Please contact this instance's Mobilizon admin if you think this is a mistake.": "Veuillez contacter l'administrateur·rice de cette instance Mobilizon si vous pensez qu’il s’agit d’une erreur.",
|
||||
"Please do not use it in any real way.": "Merci de ne pas en faire une utilisation réelle.",
|
||||
"Please enter your password to confirm this action.": "Merci d'entrer votre mot de passe pour confirmer cette action.",
|
||||
"Please make sure the address is correct and that the page hasn't been moved.": "Assurez‐vous que l’adresse est correcte et que la page n’a pas été déplacée.",
|
||||
|
@ -1055,7 +1055,7 @@
|
|||
"The URL where the event live can be watched again after it has ended": "L'URL où le direct de l'événement peut être visionné à nouveau une fois terminé",
|
||||
"The Zoom video teleconference URL": "L'URL de visio-conférence Zoom",
|
||||
"The account's email address was changed. Check your emails to verify it.": "L'adresse email du compte a été modifiée. Vérifiez vos emails pour confirmer le changement.",
|
||||
"The actual number of participants may differ, as this event is hosted on another instance.": "Le nombre réel de participant⋅e⋅s peut être différent, car cet événement provient d'une autre instance.",
|
||||
"The actual number of participants may differ, as this event is hosted on another instance.": "Le nombre réel de participant·e·s peut être différent, car cet événement provient d'une autre instance.",
|
||||
"The calc will be created on {service}": "Le calc sera créé sur {service}",
|
||||
"The content came from another server. Transfer an anonymous copy of the report?": "Le contenu provient d'une autre instance. Transférer une copie anonyme du signalement ?",
|
||||
"The device code is incorrect or no longer valid.": "Le code de l'appareil est incorrect ou n'est plus valide.",
|
||||
|
@ -1069,9 +1069,9 @@
|
|||
"The event is fully online": "L'événement est entièrement en ligne",
|
||||
"The event live video contains subtitles": "Le direct vidéo de l'événement contient des sous-titres",
|
||||
"The event live video does not contain subtitles": "Le direct vidéo de l'événement ne contient pas de sous-titres",
|
||||
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "L'organisateur⋅ice de l'événement a choisi de valider manuellement les demandes de participation. Voulez-vous ajouter un petit message pour expliquer pourquoi vous souhaitez participer à cet événement ?",
|
||||
"The event organizer didn't add any description.": "L'organisateur⋅ice de l'événement n'a pas ajouté de description.",
|
||||
"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "L'organisateur⋅ice de l'événement valide les participations manuellement. Comme vous avez choisi de participer sans compte, merci d'expliquer pourquoi vous voulez participer à cet événement.",
|
||||
"The event organiser has chosen to validate manually participations. Do you want to add a little note to explain why you want to participate to this event?": "L'organisateur·ice de l'événement a choisi de valider manuellement les demandes de participation. Voulez-vous ajouter un petit message pour expliquer pourquoi vous souhaitez participer à cet événement ?",
|
||||
"The event organizer didn't add any description.": "L'organisateur·ice de l'événement n'a pas ajouté de description.",
|
||||
"The event organizer manually approves participations. Since you've chosen to participate without an account, please explain why you want to participate to this event.": "L'organisateur·ice de l'événement valide les participations manuellement. Comme vous avez choisi de participer sans compte, merci d'expliquer pourquoi vous voulez participer à cet événement.",
|
||||
"The event title will be ellipsed.": "Le titre de l'événement sera ellipsé.",
|
||||
"The event will show as attributed to this group.": "L'événement sera affiché comme étant attribué à ce groupe.",
|
||||
"The event will show as attributed to this profile.": "L'événement sera affiché comme attribué à ce profil.",
|
||||
|
@ -1082,7 +1082,7 @@
|
|||
"The events you created are not shown here.": "Les événements que vous avez créé ne s'affichent pas ici.",
|
||||
"The following user's profiles will be deleted, with all their data:": "Les profils suivants de l'utilisateur·ice seront supprimés, avec toutes leurs données :",
|
||||
"The geolocation prompt was denied.": "La demande de localisation a été refusée.",
|
||||
"The group can now be joined by anyone, but new members need to be approved by an administrator.": "Le groupe peut maintenant être rejoint par n'importe qui, mais les nouvelles et nouveaux membres doivent être approuvées par un⋅e modérateur⋅ice.",
|
||||
"The group can now be joined by anyone, but new members need to be approved by an administrator.": "Le groupe peut maintenant être rejoint par n'importe qui, mais les nouvelles et nouveaux membres doivent être approuvées par un·e modérateur·ice.",
|
||||
"The group can now be joined by anyone.": "Le groupe peut maintenant être rejoint par n'importe qui.",
|
||||
"The group can now only be joined with an invite.": "Le groupe peut maintenant être rejoint uniquement sur invitation.",
|
||||
"The group will be publicly listed in search results and may be suggested in the explore section. Only public informations will be shown on it's page.": "Le groupe sera listé publiquement dans les résultats de recherche et pourra être suggéré sur la page « Explorer ». Seules les informations publiques seront affichées sur sa page.",
|
||||
|
@ -1104,15 +1104,15 @@
|
|||
"The post {post} was updated by {profile}.": "Le billet {post} a été mis à jour par {profile}.",
|
||||
"The provided application was not found.": "L'application fournie n'a pas été trouvée.",
|
||||
"The report contents (eventual comments and event) and the reported profile details will be transmitted to Akismet.": "Les contenus du signalement (les éventuels commentaires et événement) et les détails du profil signalé seront transmis à Akismet.",
|
||||
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "Le signalement sera envoyé aux modérateur⋅ices de votre instance. Vous pouvez expliquer pourquoi vous signalez ce contenu ci-dessous.",
|
||||
"The report will be sent to the moderators of your instance. You can explain why you report this content below.": "Le signalement sera envoyé aux modérateur·ices de votre instance. Vous pouvez expliquer pourquoi vous signalez ce contenu ci-dessous.",
|
||||
"The selected picture is too heavy. You need to select a file smaller than {size}.": "L'image sélectionnée est trop lourde. Vous devez sélectionner un fichier de moins de {size}.",
|
||||
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback.": "Les détails techniques de l'erreur peuvent aider les développeur⋅ices à résoudre le problème plus facilement. Merci de les inclure dans vos retours.",
|
||||
"The user has been disabled": "L'utilisateur⋅ice a été désactivé",
|
||||
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback.": "Les détails techniques de l'erreur peuvent aider les développeur·ices à résoudre le problème plus facilement. Merci de les inclure dans vos retours.",
|
||||
"The user has been disabled": "L'utilisateur·ice a été désactivé",
|
||||
"The videoconference will be created on {service}": "La visio-conférence sera créée sur {service}",
|
||||
"The {default_privacy_policy} will be used. They will be translated in the user's language.": "La {default_privacy_policy} sera utilisée. Elle sera traduite dans la langue de l'utilisateur·rice.",
|
||||
"The {default_terms} will be used. They will be translated in the user's language.": "Les {default_terms} seront utilisées. Elles seront traduites dans la langue de l'utilisateur⋅rice.",
|
||||
"The {default_terms} will be used. They will be translated in the user's language.": "Les {default_terms} seront utilisées. Elles seront traduites dans la langue de l'utilisateur·rice.",
|
||||
"Theme": "Thème",
|
||||
"There are {participants} participants.": "Il n'y a qu'un⋅e participant⋅e. | Il y a {participants} participant⋅es.",
|
||||
"There are {participants} participants.": "Il n'y a qu'un·e participant·e. | Il y a {participants} participant·es.",
|
||||
"There is no activity yet. Start doing some things to see activity appear here.": "Il n'y a pas encore d'activité. Commencez par effectuer des actions pour voir des éléments s'afficher ici.",
|
||||
"There will be no way to recover your data.": "Il n'y aura aucun moyen de récupérer vos données.",
|
||||
"There will be no way to restore the profile's data!": "Il n'y aura aucun moyen de restorer les données du profil !",
|
||||
|
@ -1120,9 +1120,9 @@
|
|||
"There's no discussions yet": "Il n'y a pas encore de discussions",
|
||||
"These apps can access your account through the API. If you see here apps that you don't recognize, that don't work as expected or that you don't use anymore, you can revoke their access.": "Ces applications peuvent accéder à votre compte via l'API. Si vous voyez ici des applications que vous ne reconnaissez pas, qui ne fonctionnent pas comme prévu ou que vous n'utilisez plus, vous pouvez révoquer leur accès.",
|
||||
"These events may interest you": "Ces événements peuvent vous intéresser",
|
||||
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.",
|
||||
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "Ces flux contiennent des informations sur les événements pour lesquels ce profil spécifique est un⋅e participant⋅e ou un⋅e créateur⋅ice. Vous devriez les garder privés. Vous pouvez trouver des flux pour l'ensemble de vos profils dans vos paramètres de notification.",
|
||||
"This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation.": "Cette instance Mobilizon et l'organisateur⋅ice de l'événement autorise les participations anonymes, mais requiert une validation à travers une confirmation par email.",
|
||||
"These feeds contain event data for the events for which any of your profiles is a participant or creator. You should keep these private. You can find feeds for specific profiles on each profile edition page.": "Ces flux contiennent des informations sur les événements pour lesquels n'importe lequel de vos profils est un·e participant·e ou un·e créateur·ice. Vous devriez les garder privés. Vous pouvez trouver des flux spécifiques à chaque profil sur la page d'édition des profils.",
|
||||
"These feeds contain event data for the events for which this specific profile is a participant or creator. You should keep these private. You can find feeds for all of your profiles into your notification settings.": "Ces flux contiennent des informations sur les événements pour lesquels ce profil spécifique est un·e participant·e ou un·e créateur·ice. Vous devriez les garder privés. Vous pouvez trouver des flux pour l'ensemble de vos profils dans vos paramètres de notification.",
|
||||
"This Mobilizon instance and this event organizer allows anonymous participations, but requires validation through email confirmation.": "Cette instance Mobilizon et l'organisateur·ice de l'événement autorise les participations anonymes, mais requiert une validation à travers une confirmation par email.",
|
||||
"This URL doesn't seem to be valid": "Cette URL ne semble pas être valide",
|
||||
"This URL is not supported": "Cette URL n'est pas supportée",
|
||||
"This application asks for the following permissions:": "Cette application demande les autorisations suivantes :",
|
||||
|
@ -1191,14 +1191,14 @@
|
|||
"This is like your federated username (<code>{username}</code>) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique.": "C'est comme votre adresse fédérée (<code>{username}</code>) pour les groupes. Cela permettra au groupe d'être trouvable sur la fédération, et est garanti d'être unique.",
|
||||
"This is like your federated username ({username}) for groups. It will allow the group to be found on the federation, and is guaranteed to be unique.": "C'est comme votre adresse fédérée ({username}) pour les groupes. Cela permettra au groupe d'être trouvable sur la fédération, et est garanti d'être unique.",
|
||||
"This month": "Ce mois-ci",
|
||||
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.": "Ce billet est accessible uniquement aux membres. Vous y avez accès à des fins de modération car vous êtes modérateur⋅ice de l'instance.",
|
||||
"This post is accessible only for members. You have access to it for moderation purposes only because you are an instance moderator.": "Ce billet est accessible uniquement aux membres. Vous y avez accès à des fins de modération car vous êtes modérateur·ice de l'instance.",
|
||||
"This post is accessible only through it's link. Be careful where you post this link.": "Ce billet est accessible uniquement à travers son lien. Faites attention où vous le diffusez.",
|
||||
"This profile is from another instance, the informations shown here may be incomplete.": "Ce profil provient d'une autre instance, les informations montrées ici peuvent être incomplètes.",
|
||||
"This profile is located on this instance, so you need to {access_the_corresponding_account} to suspend it.": "Ce profil se situe sur cette instance, vous devez donc {access_the_corresponding_account} afin de le suspendre.",
|
||||
"This profile was not found": "Ce profil n'a pas été trouvé",
|
||||
"This setting will be used to display the website and send you emails in the correct language.": "Ce paramètre sera utilisé pour l'affichage du site et pour vous envoyer des courriels dans la bonne langue.",
|
||||
"This user doesn't have any profiles": "Cet utilisateur⋅ice n'a aucun profil",
|
||||
"This user was not found": "Cet utilisateur⋅ice n'a pas été trouvé⋅e",
|
||||
"This user doesn't have any profiles": "Cet utilisateur·ice n'a aucun profil",
|
||||
"This user was not found": "Cet utilisateur·ice n'a pas été trouvé·e",
|
||||
"This website isn't moderated and the data that you enter will be automatically destroyed every day at 00:01 (Paris timezone).": "Ce site n’est pas modéré et les données que vous y rentrerez seront automatiquement détruites tous les jours à 00:01 (heure de Paris).",
|
||||
"This week": "Cette semaine",
|
||||
"This weekend": "Ce week-end",
|
||||
|
@ -1243,12 +1243,12 @@
|
|||
"Underline": "Souligné",
|
||||
"Undo": "Annuler",
|
||||
"Unfollow": "Ne plus suivre",
|
||||
"Unfortunately, your participation request was rejected by the organizers.": "Malheureusement, votre demande de participation a été refusée par les organisateur⋅ices.",
|
||||
"Unfortunately, your participation request was rejected by the organizers.": "Malheureusement, votre demande de participation a été refusée par les organisateur·ices.",
|
||||
"Unknown": "Inconnu",
|
||||
"Unknown actor": "Acteur inconnu",
|
||||
"Unknown error.": "Erreur inconnue.",
|
||||
"Unknown value for the openness setting.": "Valeur inconnue pour le paramètre d'ouverture.",
|
||||
"Unlogged participation": "Participation non connecté⋅e",
|
||||
"Unlogged participation": "Participation non connecté·e",
|
||||
"Unsaved changes": "Modifications non enregistrées",
|
||||
"Unsubscribe to browser push notifications": "Se désinscrire des notifications push du navigateur",
|
||||
"Unsuspend": "Annuler la suspension",
|
||||
|
@ -1275,10 +1275,10 @@
|
|||
"Uploaded media total size": "Taille totale des médias téléversés",
|
||||
"Use my location": "Utiliser ma position",
|
||||
"User": "Utilisateur·rice",
|
||||
"User settings": "Paramètres utilisateur⋅ices",
|
||||
"User settings": "Paramètres utilisateur·ices",
|
||||
"User suspended and report resolved": "Utilisateur suspendu et signalement résolu",
|
||||
"Username": "Identifiant",
|
||||
"Users": "Utilisateur⋅rice⋅s",
|
||||
"Users": "Utilisateur·rice·s",
|
||||
"Validating account": "Validation du compte",
|
||||
"Validating email": "Validation de l'email",
|
||||
"Video Conference": "Visio-conférence",
|
||||
|
@ -1377,7 +1377,7 @@
|
|||
"You deleted the post {post}.": "Vous avez supprimé le billet {post}.",
|
||||
"You deleted the resource {resource}.": "Vous avez supprimé la ressource {resource}.",
|
||||
"You demoted the member {member} to an unknown role.": "Vous avez rétrogradé le membre {member} à un role inconnu.",
|
||||
"You demoted {member} to moderator.": "Vous avez rétrogradé {member} en tant que modérateur⋅ice.",
|
||||
"You demoted {member} to moderator.": "Vous avez rétrogradé {member} en tant que modérateur·ice.",
|
||||
"You demoted {member} to simple member.": "Vous avez rétrogradé {member} en tant que simple membre.",
|
||||
"You didn't create or join any event yet.": "Vous n'avez pas encore créé ou rejoint d'événement.",
|
||||
"You don't follow any instances yet.": "Vous ne suivez aucune instance pour le moment.",
|
||||
|
@ -1386,7 +1386,7 @@
|
|||
"You have attended {count} events in the past.": "Vous n'avez participé à aucun événement par le passé.|Vous avez participé à un événement par le passé.|Vous avez participé à {count} événements par le passé.",
|
||||
"You have been invited by {invitedBy} to the following group:": "Vous avez été invité par {invitedBy} à rejoindre le groupe suivant :",
|
||||
"You have been logged-out": "Vous avez été déconnecté·e",
|
||||
"You have been removed from this group's members.": "Vous avez été exclu⋅e des membres de ce groupe.",
|
||||
"You have been removed from this group's members.": "Vous avez été exclu·e des membres de ce groupe.",
|
||||
"You have cancelled your participation": "Vous avez annulé votre participation",
|
||||
"You have one event in {days} days.": "Vous n'avez pas d'événements dans {days} jours | Vous avez un événement dans {days} jours. | Vous avez {count} événements dans {days} jours",
|
||||
"You have one event today.": "Vous n'avez pas d'événement aujourd'hui | Vous avez un événement aujourd'hui. | Vous avez {count} événements aujourd'hui",
|
||||
|
@ -1398,7 +1398,7 @@
|
|||
"You may clear all participation information for this device with the buttons below.": "Vous pouvez effacer toutes les informations de participation pour cet appareil avec les boutons ci-dessous.",
|
||||
"You may now close this page or {return_to_the_homepage}.": "Vous pouvez maintenant fermer cette page ou {return_to_the_homepage}.",
|
||||
"You may now close this window, or {return_to_event}.": "Vous pouvez maintenant fermer cette fenêtre, ou bien {return_to_event}.",
|
||||
"You may show some members as contacts.": "Vous pouvez afficher certain⋅es membres en tant que contacts.",
|
||||
"You may show some members as contacts.": "Vous pouvez afficher certain·es membres en tant que contacts.",
|
||||
"You moved the folder {resource} into {new_path}.": "Vous avez déplacé le dossier {resource} dans {new_path}.",
|
||||
"You moved the folder {resource} to the root folder.": "Vous avez déplacé le dossier {resource} dans le dossier racine.",
|
||||
"You moved the resource {resource} into {new_path}.": "Vous avez déplacé la ressource {resource} dans {new_path}.",
|
||||
|
@ -1407,8 +1407,8 @@
|
|||
"You need to provide the following code to your application. It will only be valid for a few minutes.": "Vous devez fournir le code suivant à votre application. Il sera seulement valide pendant quelques minutes.",
|
||||
"You posted a comment on the event {event}.": "Vous avez posté un commentaire sur l'événement {event}.",
|
||||
"You promoted the member {member} to an unknown role.": "Vous avez promu le ou la membre {member} à un role inconnu.",
|
||||
"You promoted {member} to administrator.": "Vous avez promu {member} en tant qu'adminstrateur⋅ice.",
|
||||
"You promoted {member} to moderator.": "Vous avez promu {member} en tant que modérateur⋅ice.",
|
||||
"You promoted {member} to administrator.": "Vous avez promu {member} en tant qu'adminstrateur·ice.",
|
||||
"You promoted {member} to moderator.": "Vous avez promu {member} en tant que modérateur·ice.",
|
||||
"You rejected {member}'s membership request.": "Vous avez rejeté la demande d'adhésion de {member}.",
|
||||
"You renamed the discussion from {old_discussion} to {discussion}.": "Vous avez renommé la discussion {old_discussion} en {discussion}.",
|
||||
"You renamed the folder from {old_resource_title} to {resource}.": "Vous avez renommé le dossier {old_resource_title} en {resource}.",
|
||||
|
@ -1420,14 +1420,14 @@
|
|||
"You updated the group {group}.": "Vous avez mis à jour le groupe {group}.",
|
||||
"You updated the member {member}.": "Vous avez mis à jour le ou la membre {member}.",
|
||||
"You updated the post {post}.": "Vous avez mis à jour le billet {post}.",
|
||||
"You were demoted to an unknown role by {profile}.": "Vous avez été rétrogradé⋅e à un role inconnu par {profile}.",
|
||||
"You were demoted to moderator by {profile}.": "Vous avez été rétrogradé⋅e modérateur⋅ice par {profile}.",
|
||||
"You were demoted to simple member by {profile}.": "Vous avez été rétrogradé⋅e simple membre par {profile}.",
|
||||
"You were promoted to administrator by {profile}.": "Vous avez été promu⋅e administrateur⋅ice par {profile}.",
|
||||
"You were promoted to an unknown role by {profile}.": "Vous avez été promu⋅e à un role inconnu par {profile}.",
|
||||
"You were promoted to moderator by {profile}.": "Vous avez été promu⋅e modérateur⋅ice par {profile}.",
|
||||
"You were demoted to an unknown role by {profile}.": "Vous avez été rétrogradé·e à un role inconnu par {profile}.",
|
||||
"You were demoted to moderator by {profile}.": "Vous avez été rétrogradé·e modérateur·ice par {profile}.",
|
||||
"You were demoted to simple member by {profile}.": "Vous avez été rétrogradé·e simple membre par {profile}.",
|
||||
"You were promoted to administrator by {profile}.": "Vous avez été promu·e administrateur·ice par {profile}.",
|
||||
"You were promoted to an unknown role by {profile}.": "Vous avez été promu·e à un role inconnu par {profile}.",
|
||||
"You were promoted to moderator by {profile}.": "Vous avez été promu·e modérateur·ice par {profile}.",
|
||||
"You will be able to add an avatar and set other options in your account settings.": "Vous pourrez ajouter un avatar et définir d'autres options dans les paramètres de votre compte.",
|
||||
"You will be redirected to the original instance": "Vous allez être redirigé⋅e vers l'instance d'origine",
|
||||
"You will be redirected to the original instance": "Vous allez être redirigé·e vers l'instance d'origine",
|
||||
"You will find here all the events you have created or of which you are a participant, as well as events organized by groups you follow or are a member of.": "Vous trouverez ici tous les événements que vous avez créé ou dont vous êtes un·e participant·e, ainsi que les événements organisés par les groupes que vous suivez ou dont vous êtes membre.",
|
||||
"You will receive notifications about this group's public activity depending on %{notification_settings}.": "Vous recevrez des notifications à propos de l'activité publique de ce groupe en fonction de %{notification_settings}.",
|
||||
"You wish to participate to the following event": "Vous souhaitez participer à l'événement suivant",
|
||||
|
@ -1472,7 +1472,7 @@
|
|||
"Zoom": "Zoom",
|
||||
"Zoom in": "Zoomer",
|
||||
"Zoom out": "Dézoomer",
|
||||
"[This comment has been deleted by it's author]": "[Ce commentaire a été supprimé par son auteur⋅rice]",
|
||||
"[This comment has been deleted by it's author]": "[Ce commentaire a été supprimé par son auteur·rice]",
|
||||
"[This comment has been deleted]": "[Ce commentaire a été supprimé]",
|
||||
"[deleted]": "[supprimé]",
|
||||
"a non-existent report": "un signalement non-existant",
|
||||
|
@ -1517,9 +1517,9 @@
|
|||
"{available}/{capacity} available places": "Pas de places restantes|{available}/{capacity} places restantes|{available}/{capacity} places restantes",
|
||||
"{count} events": "{count} événements",
|
||||
"{count} km": "{count} km",
|
||||
"{count} members": "Aucun membre|Un⋅e membre|{count} membres",
|
||||
"{count} members or followers": "Aucun⋅e membre ou abonné⋅e|Un⋅e membre ou abonné⋅e|{count} membres ou abonné⋅es",
|
||||
"{count} participants": "Aucun⋅e participant⋅e | Un⋅e participant⋅e | {count} participant⋅e⋅s",
|
||||
"{count} members": "Aucun membre|Un·e membre|{count} membres",
|
||||
"{count} members or followers": "Aucun·e membre ou abonné·e|Un·e membre ou abonné·e|{count} membres ou abonné·es",
|
||||
"{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",
|
||||
"{eventsCount} events found": "Aucun événement trouvé|Un événement trouvé|{eventsCount} événements trouvés",
|
||||
"{folder} - Resources": "{folder} - Ressources",
|
||||
|
@ -1536,7 +1536,7 @@
|
|||
"{member} joined the group.": "{member} a rejoint le groupe.",
|
||||
"{member} rejected the invitation to join the group.": "{member} a refusé l'invitation à se joindre au groupe.",
|
||||
"{member} requested to join the group.": "{member} a demandé à rejoindre le groupe.",
|
||||
"{member} was invited by {profile}.": "{member} a été invité⋅e par {profile}.",
|
||||
"{member} was invited by {profile}.": "{member} a été invité·e par {profile}.",
|
||||
"{moderator} added a note on {report}": "{moderator} a ajouté une note sur {report}",
|
||||
"{moderator} closed {report}": "{moderator} a fermé {report}",
|
||||
"{moderator} deleted an event named \"{title}\"": "{moderator} a supprimé un événement nommé \"{title}\"",
|
||||
|
@ -1554,7 +1554,7 @@
|
|||
"{numberOfCategories} selected": "{numberOfCategories} sélectionnées",
|
||||
"{numberOfLanguages} selected": "{numberOfLanguages} sélectionnées",
|
||||
"{number} kilometers": "{number} kilomètres",
|
||||
"{number} members": "Aucun⋅e membre|Un⋅e membre|{number} membres",
|
||||
"{number} members": "Aucun·e membre|Un·e membre|{number} membres",
|
||||
"{number} memberships": "{number} adhésions",
|
||||
"{number} organized events": "Aucun événement organisé|Un événement organisé|{number} événements organisés",
|
||||
"{number} participations": "Aucune participation|Une participation|{number} participations",
|
||||
|
@ -1574,7 +1574,7 @@
|
|||
"{profile} deleted the folder {resource}.": "{profile} a supprimé le dossier {resource}.",
|
||||
"{profile} deleted the resource {resource}.": "{profile} a supprimé la ressource {resource}.",
|
||||
"{profile} demoted {member} to an unknown role.": "{profile} a rétrogradé {member} à un role inconnu.",
|
||||
"{profile} demoted {member} to moderator.": "{profile} a rétrogradé {member} en tant que modérateur⋅ice.",
|
||||
"{profile} demoted {member} to moderator.": "{profile} a rétrogradé {member} en tant que modérateur·ice.",
|
||||
"{profile} demoted {member} to simple member.": "{profile} a rétrogradé {member} en tant que simple membre.",
|
||||
"{profile} excluded member {member}.": "{profile} a exclu le ou la membre {member}.",
|
||||
"{profile} joined the the event {event}.": "{profile} a rejoint l'événement {event}.",
|
||||
|
@ -1583,9 +1583,9 @@
|
|||
"{profile} moved the resource {resource} into {new_path}.": "{profile} a déplacé la ressource {resource} dans {new_path}.",
|
||||
"{profile} moved the resource {resource} to the root folder.": "{profile} a déplacé la ressource {resource} dans le dossier racine.",
|
||||
"{profile} posted a comment on the event {event}.": "{profile} a posté un commentaire sur l'événement {event}.",
|
||||
"{profile} promoted {member} to administrator.": "{profile} a promu {member} en tant qu'administrateur⋅ice.",
|
||||
"{profile} promoted {member} to administrator.": "{profile} a promu {member} en tant qu'administrateur·ice.",
|
||||
"{profile} promoted {member} to an unknown role.": "{profile} a promu {member} à un role inconnu.",
|
||||
"{profile} promoted {member} to moderator.": "{profile} a promu {member} en tant que modérateur⋅ice.",
|
||||
"{profile} promoted {member} to moderator.": "{profile} a promu {member} en tant que modérateur·ice.",
|
||||
"{profile} quit the group.": "{profile} a quitté le groupe.",
|
||||
"{profile} rejected {member}'s membership request.": "{profile} a rejeté la demande d'adhésion de {member}.",
|
||||
"{profile} renamed the discussion from {old_discussion} to {discussion}.": "{profile} a renommé la discussion {old_discussion} en {discussion}.",
|
||||
|
@ -1601,10 +1601,25 @@
|
|||
"{username} was invited to {group}": "{username} a été invité à {group}",
|
||||
"{user}'s follow request was accepted": "La demande de suivi de {user} a été acceptée",
|
||||
"{user}'s follow request was rejected": "La demande de suivi de {user} a été rejetée",
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur⋅ices OpenStreetMap",
|
||||
"© The OpenStreetMap Contributors": "© Les Contributeur·ices OpenStreetMap",
|
||||
"Go to booking": "Aller à la réservation",
|
||||
"External registration": "Inscription externe",
|
||||
"I want to manage the registration with an external provider": "Je souhaite gérer l'enregistrement auprès d'un fournisseur externe",
|
||||
"External provider URL": "URL du fournisseur externe",
|
||||
"Members will also access private sections like discussions, resources and restricted posts.": "Les membres auront également accès aux section privées comme les discussions, les ressources et les billets restreints."
|
||||
"Members will also access private sections like discussions, resources and restricted posts.": "Les membres auront également accès aux section privées comme les discussions, les ressources et les billets restreints.",
|
||||
"With unknown participants": "Avec des participant·es inconnu·es",
|
||||
"With {participants}": "Avec {participants}",
|
||||
"Conversations": "Conversations",
|
||||
"New private message": "Nouveau message privé",
|
||||
"There's no conversations yet": "Il n'y a pas encore de conversations",
|
||||
"Open conversations": "Ouvrir les conversations",
|
||||
"List of conversations": "Liste des conversations",
|
||||
"Conversation with {participants}": "Conversation avec {participants}",
|
||||
"Delete this conversation": "Supprimer cette conversation",
|
||||
"Are you sure you want to delete this entire conversation?": "Êtes-vous sûr·e de vouloir supprimer l'entièreté de cette conversation ?",
|
||||
"This is a announcement from the organizers of event {event}. You can't reply to it, but you can send a private message to event organizers.": "Ceci est une annonce des organisateur·ices de cet événement {event}. Vous ne pouvez pas y répondre, mais vous pouvez envoyer un nouveau message aux organisateur·ices de l'événement.",
|
||||
"You have access to this conversation as a member of the {group} group": "Vous avez accès à cette conversation en tant que membre du groupe {group}",
|
||||
"Comment from an event announcement": "Commentaire d'une annonce d'événement",
|
||||
"Comment from a private conversation": "Commentaire d'une conversation privée",
|
||||
"I've been mentionned in a conversation": "J'ai été mentionnée dans une conversation"
|
||||
}
|
||||
|
|
33
js/src/router/conversation.ts
Normal file
33
js/src/router/conversation.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { RouteRecordRaw } from "vue-router";
|
||||
import { i18n } from "@/utils/i18n";
|
||||
|
||||
const t = i18n.global.t;
|
||||
|
||||
export enum ConversationRouteName {
|
||||
CONVERSATION_LIST = "DISCUSSION_LIST",
|
||||
CONVERSATION = "CONVERSATION",
|
||||
}
|
||||
|
||||
export const conversationRoutes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: "/conversations",
|
||||
name: ConversationRouteName.CONVERSATION_LIST,
|
||||
component: (): Promise<any> =>
|
||||
import("@/views/Conversations/ConversationListView.vue"),
|
||||
props: true,
|
||||
meta: {
|
||||
requiredAuth: true,
|
||||
announcer: {
|
||||
message: (): string => t("List of conversations") as string,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/conversations/:id/:comment_id?",
|
||||
name: ConversationRouteName.CONVERSATION,
|
||||
component: (): Promise<any> =>
|
||||
import("@/views/Conversations/ConversationView.vue"),
|
||||
props: true,
|
||||
meta: { requiredAuth: true, announcer: { skip: true } },
|
||||
},
|
||||
];
|
|
@ -8,6 +8,7 @@ import { authGuardIfNeeded } from "./guards/auth-guard";
|
|||
import { settingsRoutes } from "./settings";
|
||||
import { groupsRoutes } from "./groups";
|
||||
import { discussionRoutes } from "./discussion";
|
||||
import { conversationRoutes } from "./conversation";
|
||||
import { userRoutes } from "./user";
|
||||
import RouteName from "./name";
|
||||
import { AVAILABLE_LANGUAGES, i18n } from "@/utils/i18n";
|
||||
|
@ -36,6 +37,7 @@ export const routes = [
|
|||
...actorRoutes,
|
||||
...groupsRoutes,
|
||||
...discussionRoutes,
|
||||
...conversationRoutes,
|
||||
...errorRoutes,
|
||||
{
|
||||
path: "/search",
|
||||
|
|
|
@ -4,6 +4,7 @@ import { ErrorRouteName } from "./error";
|
|||
import { SettingsRouteName } from "./settings";
|
||||
import { GroupsRouteName } from "./groups";
|
||||
import { DiscussionRouteName } from "./discussion";
|
||||
import { ConversationRouteName } from "./conversation";
|
||||
import { UserRouteName } from "./user";
|
||||
|
||||
enum GlobalRouteName {
|
||||
|
@ -31,5 +32,6 @@ export default {
|
|||
...SettingsRouteName,
|
||||
...GroupsRouteName,
|
||||
...DiscussionRouteName,
|
||||
...ConversationRouteName,
|
||||
...ErrorRouteName,
|
||||
};
|
||||
|
|
|
@ -7,6 +7,7 @@ import type { IParticipant } from "../participant.model";
|
|||
import type { IMember } from "./member.model";
|
||||
import type { IFeedToken } from "../feedtoken.model";
|
||||
import { IFollower } from "./follower.model";
|
||||
import { IConversation } from "../conversation";
|
||||
|
||||
export interface IPerson extends IActor {
|
||||
feedTokens: IFeedToken[];
|
||||
|
@ -16,6 +17,8 @@ export interface IPerson extends IActor {
|
|||
follows?: Paginate<IFollower>;
|
||||
user?: ICurrentUser;
|
||||
organizedEvents?: Paginate<IEvent>;
|
||||
conversations?: Paginate<IConversation>;
|
||||
unreadConversationsCount?: number;
|
||||
}
|
||||
|
||||
export class Person extends Actor implements IPerson {
|
||||
|
@ -28,6 +31,7 @@ export class Person extends Actor implements IPerson {
|
|||
memberships!: Paginate<IMember>;
|
||||
|
||||
organizedEvents!: Paginate<IEvent>;
|
||||
conversations!: Paginate<IConversation>;
|
||||
|
||||
user!: ICurrentUser;
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { IPerson, Person } from "@/types/actor";
|
||||
import type { IEvent } from "@/types/event.model";
|
||||
import { EventModel } from "@/types/event.model";
|
||||
import { IConversation } from "./conversation";
|
||||
|
||||
export interface IComment {
|
||||
id?: string;
|
||||
|
@ -20,6 +21,7 @@ export interface IComment {
|
|||
publishedAt?: string;
|
||||
isAnnouncement: boolean;
|
||||
language?: string;
|
||||
conversation?: IConversation;
|
||||
}
|
||||
|
||||
export class CommentModel implements IComment {
|
||||
|
|
17
js/src/types/conversation.ts
Normal file
17
js/src/types/conversation.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import type { IActor } from "@/types/actor";
|
||||
import type { IComment } from "@/types/comment.model";
|
||||
import type { Paginate } from "@/types/paginate";
|
||||
import { IEvent } from "./event.model";
|
||||
|
||||
export interface IConversation {
|
||||
conversationParticipantId?: string;
|
||||
id?: string;
|
||||
actor?: IActor;
|
||||
lastComment?: IComment;
|
||||
comments: Paginate<IComment>;
|
||||
participants: IActor[];
|
||||
updatedAt: string;
|
||||
insertedAt: string;
|
||||
unread: boolean;
|
||||
event?: IEvent;
|
||||
}
|
|
@ -8,6 +8,7 @@ import { PictureInformation } from "./picture";
|
|||
import { IMember } from "./actor/member.model";
|
||||
import { IFeedToken } from "./feedtoken.model";
|
||||
import { IApplicationToken } from "./application.model";
|
||||
import { IConversation } from "./conversation";
|
||||
|
||||
export interface ICurrentUser {
|
||||
id: string;
|
||||
|
@ -69,4 +70,5 @@ export interface IUser extends ICurrentUser {
|
|||
memberships: Paginate<IMember>;
|
||||
feedTokens: IFeedToken[];
|
||||
authAuthorizedApplications: IApplicationToken[];
|
||||
conversations: Paginate<IConversation>;
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { EventOptions } from "./event-options.model";
|
|||
import type { IEventOptions } from "./event-options.model";
|
||||
import { EventJoinOptions, EventStatus, EventVisibility } from "./enums";
|
||||
import { IEventMetadata, IEventMetadataDescription } from "./event-metadata";
|
||||
import { IConversation } from "./conversation";
|
||||
|
||||
export interface IEventCardOptions {
|
||||
hideDate?: boolean;
|
||||
|
@ -85,6 +86,7 @@ export interface IEvent {
|
|||
|
||||
relatedEvents: IEvent[];
|
||||
comments: IComment[];
|
||||
conversations: Paginate<IConversation>;
|
||||
|
||||
onlineAddress?: string;
|
||||
phoneAddress?: string;
|
||||
|
@ -161,6 +163,8 @@ export class EventModel implements IEvent {
|
|||
|
||||
comments: IComment[] = [];
|
||||
|
||||
conversations!: Paginate<IConversation>;
|
||||
|
||||
attributedTo?: IGroup = new Group();
|
||||
|
||||
organizerActor?: IActor = new Actor();
|
||||
|
|
|
@ -49,17 +49,17 @@ export function deleteUserData(): void {
|
|||
});
|
||||
}
|
||||
|
||||
export async function logout(performServerLogout = true): Promise<void> {
|
||||
const { mutate: logoutMutation } = provideApolloClient(apolloClient)(() =>
|
||||
const { mutate: logoutMutation } = provideApolloClient(apolloClient)(() =>
|
||||
useMutation(LOGOUT)
|
||||
);
|
||||
const { mutate: cleanUserClient } = provideApolloClient(apolloClient)(() =>
|
||||
);
|
||||
const { mutate: cleanUserClient } = provideApolloClient(apolloClient)(() =>
|
||||
useMutation(UPDATE_CURRENT_USER_CLIENT)
|
||||
);
|
||||
const { mutate: cleanActorClient } = provideApolloClient(apolloClient)(() =>
|
||||
);
|
||||
const { mutate: cleanActorClient } = provideApolloClient(apolloClient)(() =>
|
||||
useMutation(UPDATE_CURRENT_ACTOR_CLIENT)
|
||||
);
|
||||
);
|
||||
|
||||
export async function logout(performServerLogout = true): Promise<void> {
|
||||
if (performServerLogout) {
|
||||
logoutMutation({
|
||||
refreshToken: localStorage.getItem(AUTH_REFRESH_TOKEN),
|
||||
|
|
|
@ -16,21 +16,31 @@ function saveActorData(obj: IPerson): void {
|
|||
localStorage.setItem(AUTH_USER_ACTOR_ID, `${obj.id}`);
|
||||
}
|
||||
|
||||
const {
|
||||
mutate: updateCurrentActorClient,
|
||||
onDone: onUpdateCurrentActorClientDone,
|
||||
} = provideApolloClient(apolloClient)(() =>
|
||||
useMutation(UPDATE_CURRENT_ACTOR_CLIENT)
|
||||
);
|
||||
|
||||
export async function changeIdentity(identity: IPerson): Promise<void> {
|
||||
if (!identity.id) return;
|
||||
const { mutate: updateCurrentActorClient } = provideApolloClient(
|
||||
apolloClient
|
||||
)(() => useMutation(UPDATE_CURRENT_ACTOR_CLIENT));
|
||||
console.debug("Changing identity", identity);
|
||||
|
||||
updateCurrentActorClient(identity);
|
||||
if (identity.id) {
|
||||
console.debug("Saving actor data");
|
||||
saveActorData(identity);
|
||||
}
|
||||
|
||||
onUpdateCurrentActorClientDone(() => {
|
||||
console.debug("Updating current actor client");
|
||||
});
|
||||
}
|
||||
|
||||
const { onResult: setIdentities, load: loadIdentities } = provideApolloClient(
|
||||
apolloClient
|
||||
)(() => useLazyQuery<{ loggedUser: Pick<ICurrentUser, "actors"> }>(IDENTITIES));
|
||||
const { load: loadIdentities } = provideApolloClient(apolloClient)(() =>
|
||||
useLazyQuery<{ loggedUser: Pick<ICurrentUser, "actors"> }>(IDENTITIES)
|
||||
);
|
||||
|
||||
/**
|
||||
* We fetch from localStorage the latest actor ID used,
|
||||
|
@ -39,11 +49,14 @@ const { onResult: setIdentities, load: loadIdentities } = provideApolloClient(
|
|||
*/
|
||||
export async function initializeCurrentActor(): Promise<void> {
|
||||
const actorId = localStorage.getItem(AUTH_USER_ACTOR_ID);
|
||||
console.debug("Initializing current actor", actorId);
|
||||
|
||||
loadIdentities();
|
||||
try {
|
||||
const result = await loadIdentities();
|
||||
if (!result) return;
|
||||
|
||||
setIdentities(async ({ data }) => {
|
||||
const identities = computed(() => data?.loggedUser?.actors);
|
||||
console.debug("got identities", result);
|
||||
const identities = computed(() => result.loggedUser?.actors);
|
||||
console.debug(
|
||||
"initializing current actor based on identities",
|
||||
identities.value
|
||||
|
@ -61,5 +74,7 @@ export async function initializeCurrentActor(): Promise<void> {
|
|||
if (activeIdentity) {
|
||||
await changeIdentity(activeIdentity);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to initialize current Actor", e);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -169,7 +169,7 @@ const { mutate: acceptInstance, onError: onAcceptInstanceError } = useMutation(
|
|||
() => ({
|
||||
update(cache: ApolloCache<any>) {
|
||||
cache.writeFragment({
|
||||
id: cache.identify(instance as unknown as Reference),
|
||||
id: cache.identify(instance.value as unknown as Reference),
|
||||
fragment: gql`
|
||||
fragment InstanceFollowerStatus on Instance {
|
||||
followerStatus
|
||||
|
|
94
js/src/views/Conversations/ConversationListView.vue
Normal file
94
js/src/views/Conversations/ConversationListView.vue
Normal file
|
@ -0,0 +1,94 @@
|
|||
<template>
|
||||
<div class="container mx-auto" v-if="conversations">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.CONVERSATION_LIST,
|
||||
text: t('Conversations'),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<section>
|
||||
<h1>{{ t("Conversations") }}</h1>
|
||||
<o-button @click="openNewMessageModal">{{
|
||||
t("New private message")
|
||||
}}</o-button>
|
||||
<div v-if="conversations.elements.length > 0" class="my-2">
|
||||
<conversation-list-item
|
||||
:conversation="conversation"
|
||||
v-for="conversation in conversations.elements"
|
||||
:key="conversation.id"
|
||||
/>
|
||||
<o-pagination
|
||||
v-show="conversations.total > CONVERSATIONS_PER_PAGE"
|
||||
class="conversation-pagination"
|
||||
:total="conversations.total"
|
||||
v-model:current="page"
|
||||
:per-page="CONVERSATIONS_PER_PAGE"
|
||||
:aria-next-label="t('Next page')"
|
||||
:aria-previous-label="t('Previous page')"
|
||||
:aria-page-label="t('Page')"
|
||||
:aria-current-label="t('Current page')"
|
||||
>
|
||||
</o-pagination>
|
||||
</div>
|
||||
<empty-content v-else icon="chat">
|
||||
{{ t("There's no conversations yet") }}
|
||||
</empty-content>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import RouteName from "../../router/name";
|
||||
import { useQuery } from "@vue/apollo-composable";
|
||||
import { computed, defineAsyncComponent, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { integerTransformer, useRouteQuery } from "vue-use-route-query";
|
||||
import { PROFILE_CONVERSATIONS } from "@/graphql/event";
|
||||
import ConversationListItem from "../../components/Conversations/ConversationListItem.vue";
|
||||
import EmptyContent from "../../components/Utils/EmptyContent.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { IPerson } from "@/types/actor";
|
||||
import { useProgrammatic } from "@oruga-ui/oruga-next";
|
||||
|
||||
const page = useRouteQuery("page", 1, integerTransformer);
|
||||
const CONVERSATIONS_PER_PAGE = 10;
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
useHead({
|
||||
title: computed(() => t("List of conversations")),
|
||||
});
|
||||
|
||||
const error = ref(false);
|
||||
|
||||
const { result: conversationsResult } = useQuery<{
|
||||
loggedPerson: Pick<IPerson, "conversations">;
|
||||
}>(PROFILE_CONVERSATIONS, () => ({
|
||||
page: page.value,
|
||||
}));
|
||||
|
||||
const conversations = computed(
|
||||
() =>
|
||||
conversationsResult.value?.loggedPerson.conversations || {
|
||||
elements: [],
|
||||
total: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const { oruga } = useProgrammatic();
|
||||
|
||||
const NewConversation = defineAsyncComponent(
|
||||
() => import("@/components/Conversations/NewConversation.vue")
|
||||
);
|
||||
|
||||
const openNewMessageModal = () => {
|
||||
oruga.modal.open({
|
||||
component: NewConversation,
|
||||
trapFocus: true,
|
||||
});
|
||||
};
|
||||
</script>
|
527
js/src/views/Conversations/ConversationView.vue
Normal file
527
js/src/views/Conversations/ConversationView.vue
Normal file
|
@ -0,0 +1,527 @@
|
|||
<template>
|
||||
<div class="container mx-auto" v-if="conversation">
|
||||
<breadcrumbs-nav
|
||||
:links="[
|
||||
{
|
||||
name: RouteName.CONVERSATION_LIST,
|
||||
text: t('Conversations'),
|
||||
},
|
||||
{
|
||||
name: RouteName.CONVERSATION,
|
||||
params: { id: conversation.id },
|
||||
text: title,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
<div
|
||||
v-if="conversation.event"
|
||||
class="bg-mbz-yellow p-6 mb-6 rounded flex gap-2 items-center"
|
||||
>
|
||||
<Calendar :size="36" />
|
||||
<i18n-t
|
||||
tag="p"
|
||||
keypath="This is a announcement from the organizers of event {event}"
|
||||
>
|
||||
<template #event>
|
||||
<b>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: conversation.event.uuid },
|
||||
}"
|
||||
>{{ conversation.event.title }}</router-link
|
||||
>
|
||||
</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentActor && currentActor.id !== conversation.actor?.id"
|
||||
class="bg-mbz-info p-6 rounded flex gap-2 items-center my-3"
|
||||
>
|
||||
<i18n-t
|
||||
keypath="You have access to this conversation as a member of the {group} group"
|
||||
tag="p"
|
||||
>
|
||||
<template #group>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.GROUP,
|
||||
params: {
|
||||
preferredUsername: usernameWithDomain(conversation.actor),
|
||||
},
|
||||
}"
|
||||
><b>{{ displayName(conversation.actor) }}</b></router-link
|
||||
>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
<o-notification v-if="error" variant="danger">
|
||||
{{ error }}
|
||||
</o-notification>
|
||||
<section v-if="currentActor">
|
||||
<discussion-comment
|
||||
v-for="comment in conversation.comments.elements"
|
||||
:key="comment.id"
|
||||
:model-value="comment"
|
||||
:current-actor="currentActor"
|
||||
:can-report="true"
|
||||
@update:modelValue="
|
||||
(comment: IComment) =>
|
||||
updateComment({
|
||||
commentId: comment.id as string,
|
||||
text: comment.text,
|
||||
})
|
||||
"
|
||||
@delete-comment="
|
||||
(comment: IComment) =>
|
||||
deleteComment({
|
||||
commentId: comment.id as string,
|
||||
})
|
||||
"
|
||||
/>
|
||||
<o-button
|
||||
v-if="
|
||||
conversation.comments.elements.length < conversation.comments.total
|
||||
"
|
||||
@click="loadMoreComments"
|
||||
>{{ t("Fetch more") }}</o-button
|
||||
>
|
||||
<form @submit.prevent="reply" v-if="!error && !conversation.event">
|
||||
<o-field :label="t('Text')">
|
||||
<Editor
|
||||
v-model="newComment"
|
||||
:aria-label="t('Message body')"
|
||||
v-if="currentActor"
|
||||
:currentActor="currentActor"
|
||||
:placeholder="t('Write a new message')"
|
||||
/>
|
||||
</o-field>
|
||||
<o-button
|
||||
class="my-2"
|
||||
native-type="submit"
|
||||
:disabled="['<p></p>', ''].includes(newComment)"
|
||||
variant="primary"
|
||||
>{{ t("Reply") }}</o-button
|
||||
>
|
||||
</form>
|
||||
<div
|
||||
v-else-if="conversation.event"
|
||||
class="bg-mbz-yellow p-6 rounded flex gap-2 items-center mt-6"
|
||||
>
|
||||
<Calendar :size="36" />
|
||||
<i18n-t
|
||||
tag="p"
|
||||
keypath="This is a announcement from the organizers of event {event}. You can't reply to it, but you can send a private message to event organizers."
|
||||
>
|
||||
<template #event>
|
||||
<b>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: conversation.event.uuid },
|
||||
}"
|
||||
>{{ conversation.event.title }}</router-link
|
||||
>
|
||||
</b>
|
||||
</template>
|
||||
</i18n-t>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
CONVERSATION_COMMENT_CHANGED,
|
||||
GET_CONVERSATION,
|
||||
MARK_CONVERSATION_AS_READ,
|
||||
REPLY_TO_PRIVATE_MESSAGE_MUTATION,
|
||||
} from "../../graphql/conversations";
|
||||
import DiscussionComment from "../../components/Discussion/DiscussionComment.vue";
|
||||
import { DELETE_COMMENT, UPDATE_COMMENT } from "../../graphql/comment";
|
||||
import RouteName from "../../router/name";
|
||||
import { IComment } from "../../types/comment.model";
|
||||
import {
|
||||
ApolloCache,
|
||||
FetchResult,
|
||||
InMemoryCache,
|
||||
gql,
|
||||
} from "@apollo/client/core";
|
||||
import { useMutation, useQuery } from "@vue/apollo-composable";
|
||||
import {
|
||||
defineAsyncComponent,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
} from "vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useCurrentActorClient } from "../../composition/apollo/actor";
|
||||
import { AbsintheGraphQLError } from "../../types/errors.model";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { IConversation } from "@/types/conversation";
|
||||
import { usernameWithDomain, displayName } from "@/types/actor";
|
||||
import { formatList } from "@/utils/i18n";
|
||||
import throttle from "lodash/throttle";
|
||||
import Calendar from "vue-material-design-icons/Calendar.vue";
|
||||
import { ActorType } from "@/types/enums";
|
||||
|
||||
const props = defineProps<{ id: string }>();
|
||||
|
||||
const conversationId = computed(() => props.id);
|
||||
|
||||
const page = ref(1);
|
||||
const COMMENTS_PER_PAGE = 10;
|
||||
|
||||
const { currentActor } = useCurrentActorClient();
|
||||
|
||||
const {
|
||||
result: conversationResult,
|
||||
onResult: onConversationResult,
|
||||
onError: onConversationError,
|
||||
subscribeToMore,
|
||||
fetchMore,
|
||||
} = useQuery<{ conversation: IConversation }>(
|
||||
GET_CONVERSATION,
|
||||
() => ({
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
}),
|
||||
() => ({
|
||||
enabled: conversationId.value !== undefined,
|
||||
})
|
||||
);
|
||||
|
||||
subscribeToMore({
|
||||
document: CONVERSATION_COMMENT_CHANGED,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
updateQuery(
|
||||
previousResult: any,
|
||||
{ subscriptionData }: { subscriptionData: any }
|
||||
) {
|
||||
const previousConversation = previousResult.conversation;
|
||||
const lastComment =
|
||||
subscriptionData.data.conversationCommentChanged.lastComment;
|
||||
hasMoreComments.value = !previousConversation.comments.elements.some(
|
||||
(comment: IComment) => comment.id === lastComment.id
|
||||
);
|
||||
if (hasMoreComments.value) {
|
||||
return {
|
||||
conversation: {
|
||||
...previousConversation,
|
||||
lastComment: lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...previousConversation.comments.elements.filter(
|
||||
({ id }: { id: string }) => id !== lastComment.id
|
||||
),
|
||||
lastComment,
|
||||
],
|
||||
total: previousConversation.comments.total + 1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return previousConversation;
|
||||
},
|
||||
});
|
||||
|
||||
const conversation = computed(() => conversationResult.value?.conversation);
|
||||
const otherParticipants = computed(
|
||||
() =>
|
||||
conversation.value?.participants.filter(
|
||||
(participant) => participant.id !== currentActor.value?.id
|
||||
) ?? []
|
||||
);
|
||||
|
||||
const Editor = defineAsyncComponent(
|
||||
() => import("../../components/TextEditor.vue")
|
||||
);
|
||||
|
||||
const { t } = useI18n({ useScope: "global" });
|
||||
|
||||
const title = computed(() =>
|
||||
t("Conversation with {participants}", {
|
||||
participants: formatList(
|
||||
otherParticipants.value.map((participant) => displayName(participant))
|
||||
),
|
||||
})
|
||||
);
|
||||
|
||||
useHead({
|
||||
title: title.value,
|
||||
});
|
||||
|
||||
const newComment = ref("");
|
||||
// const newTitle = ref("");
|
||||
// const editTitleMode = ref(false);
|
||||
const hasMoreComments = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const { mutate: replyToConversationMutation } = useMutation<
|
||||
{
|
||||
postPrivateMessage: IConversation;
|
||||
},
|
||||
{
|
||||
text: string;
|
||||
actorId: string;
|
||||
language?: string;
|
||||
conversationId: string;
|
||||
mentions?: string[];
|
||||
attributedToId?: string;
|
||||
}
|
||||
>(REPLY_TO_PRIVATE_MESSAGE_MUTATION, () => ({
|
||||
update: (store: ApolloCache<InMemoryCache>, { data }) => {
|
||||
console.debug("update after reply to", [conversationId.value, page.value]);
|
||||
const conversationData = store.readQuery<{
|
||||
conversation: IConversation;
|
||||
}>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
});
|
||||
console.debug("update after reply to", conversationData);
|
||||
if (!conversationData) return;
|
||||
const { conversation: conversationCached } = conversationData;
|
||||
|
||||
console.debug("got cache", conversationCached);
|
||||
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
},
|
||||
data: {
|
||||
conversation: {
|
||||
...conversationCached,
|
||||
lastComment: data?.postPrivateMessage.lastComment,
|
||||
comments: {
|
||||
elements: [
|
||||
...conversationCached.comments.elements,
|
||||
data?.postPrivateMessage.lastComment,
|
||||
],
|
||||
total: conversationCached.comments.total + 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const reply = () => {
|
||||
if (
|
||||
newComment.value === "" ||
|
||||
!conversation.value?.id ||
|
||||
!currentActor.value?.id
|
||||
)
|
||||
return;
|
||||
|
||||
replyToConversationMutation({
|
||||
conversationId: conversation.value?.id,
|
||||
text: newComment.value,
|
||||
actorId: currentActor.value?.id,
|
||||
mentions: otherParticipants.value.map((participant) =>
|
||||
usernameWithDomain(participant)
|
||||
),
|
||||
attributedToId:
|
||||
conversation.value?.actor?.type === ActorType.GROUP
|
||||
? conversation.value?.actor.id
|
||||
: undefined,
|
||||
});
|
||||
|
||||
newComment.value = "";
|
||||
};
|
||||
|
||||
const { mutate: updateComment } = useMutation<
|
||||
{ updateComment: IComment },
|
||||
{ commentId: string; text: string }
|
||||
>(UPDATE_COMMENT, () => ({
|
||||
update: (
|
||||
store: ApolloCache<{ deleteComment: IComment }>,
|
||||
{ data }: FetchResult
|
||||
) => {
|
||||
if (!data || !data.deleteComment) return;
|
||||
const discussionData = store.readQuery<{
|
||||
conversation: IConversation;
|
||||
}>({
|
||||
query: GET_CONVERSATION,
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
},
|
||||
});
|
||||
if (!discussionData) return;
|
||||
const { conversation: discussionCached } = discussionData;
|
||||
const index = discussionCached.comments.elements.findIndex(
|
||||
({ id }) => id === data.deleteComment.id
|
||||
);
|
||||
if (index > -1) {
|
||||
discussionCached.comments.elements.splice(index, 1);
|
||||
discussionCached.comments.total -= 1;
|
||||
}
|
||||
store.writeQuery({
|
||||
query: GET_CONVERSATION,
|
||||
variables: { id: conversationId.value, page: page.value },
|
||||
data: { conversation: discussionCached },
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const { mutate: deleteComment } = useMutation<
|
||||
{ deleteComment: { id: string } },
|
||||
{ commentId: string }
|
||||
>(DELETE_COMMENT, () => ({
|
||||
update: (store: ApolloCache<{ deleteComment: IComment }>, { data }) => {
|
||||
const id = data?.deleteComment?.id;
|
||||
if (!id) return;
|
||||
store.writeFragment({
|
||||
id: `Comment:${id}`,
|
||||
fragment: gql`
|
||||
fragment CommentDeleted on Comment {
|
||||
deletedAt
|
||||
actor {
|
||||
id
|
||||
}
|
||||
text
|
||||
}
|
||||
`,
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
text: "",
|
||||
actor: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
const loadMoreComments = async (): Promise<void> => {
|
||||
if (!hasMoreComments.value) return;
|
||||
console.debug("Loading more comments");
|
||||
page.value++;
|
||||
try {
|
||||
await fetchMore({
|
||||
// New variables
|
||||
variables: () => ({
|
||||
id: conversationId.value,
|
||||
page: page.value,
|
||||
limit: COMMENTS_PER_PAGE,
|
||||
}),
|
||||
});
|
||||
hasMoreComments.value = !conversation.value?.comments.elements
|
||||
.map(({ id }) => id)
|
||||
.includes(conversation.value?.lastComment?.id);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
// const dialog = inject<Dialog>("dialog");
|
||||
|
||||
// const openDeleteDiscussionConfirmation = (): void => {
|
||||
// dialog?.confirm({
|
||||
// variant: "danger",
|
||||
// title: t("Delete this conversation"),
|
||||
// message: t("Are you sure you want to delete this entire conversation?"),
|
||||
// confirmText: t("Delete conversation"),
|
||||
// cancelText: t("Cancel"),
|
||||
// onConfirm: () =>
|
||||
// deleteConversation({
|
||||
// discussionId: conversation.value?.id,
|
||||
// }),
|
||||
// });
|
||||
// };
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// const { mutate: deleteConversation, onDone: deleteConversationDone } =
|
||||
// useMutation(DELETE_DISCUSSION);
|
||||
|
||||
// deleteConversationDone(() => {
|
||||
// if (conversation.value?.actor) {
|
||||
// router.push({
|
||||
// name: RouteName.DISCUSSION_LIST,
|
||||
// params: {
|
||||
// preferredUsername: usernameWithDomain(conversation.value.actor),
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
onConversationError((discussionError) =>
|
||||
handleErrors(discussionError.graphQLErrors as AbsintheGraphQLError[])
|
||||
);
|
||||
|
||||
onConversationResult(({ data }) => {
|
||||
if (
|
||||
page.value === 1 &&
|
||||
data?.conversation?.comments?.total &&
|
||||
data?.conversation?.comments?.total < COMMENTS_PER_PAGE
|
||||
) {
|
||||
markConversationAsRead();
|
||||
}
|
||||
});
|
||||
|
||||
const handleErrors = async (errors: AbsintheGraphQLError[]): Promise<void> => {
|
||||
if (errors[0].code === "not_found") {
|
||||
await router.push({ name: RouteName.PAGE_NOT_FOUND });
|
||||
}
|
||||
if (errors[0].code === "unauthorized") {
|
||||
error.value = errors[0].message;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("scroll", handleScroll);
|
||||
});
|
||||
|
||||
const { mutate: markConversationAsRead } = useMutation<
|
||||
{
|
||||
updateConversation: IConversation;
|
||||
},
|
||||
{
|
||||
id: string;
|
||||
read: boolean;
|
||||
}
|
||||
>(MARK_CONVERSATION_AS_READ, {
|
||||
variables: {
|
||||
id: conversationId.value,
|
||||
read: true,
|
||||
},
|
||||
});
|
||||
|
||||
const loadMoreCommentsThrottled = throttle(async () => {
|
||||
console.log("Throttled");
|
||||
await loadMoreComments();
|
||||
if (!hasMoreComments.value && conversation.value?.unread) {
|
||||
console.debug("marking as read");
|
||||
markConversationAsRead();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
const handleScroll = (): void => {
|
||||
const scrollTop =
|
||||
(document.documentElement && document.documentElement.scrollTop) ||
|
||||
document.body.scrollTop;
|
||||
const scrollHeight =
|
||||
(document.documentElement && document.documentElement.scrollHeight) ||
|
||||
document.body.scrollHeight;
|
||||
const clientHeight =
|
||||
document.documentElement.clientHeight || window.innerHeight;
|
||||
const scrolledToBottom =
|
||||
Math.ceil(scrollTop + clientHeight + 800) >= scrollHeight;
|
||||
if (scrolledToBottom) {
|
||||
console.debug("Scrolled to bottom");
|
||||
loadMoreCommentsThrottled();
|
||||
}
|
||||
};
|
||||
</script>
|
|
@ -250,6 +250,8 @@
|
|||
</div>
|
||||
</template>
|
||||
</o-table>
|
||||
<EventConversations :event="event" class="my-6" />
|
||||
<NewPrivateMessage :event="event" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
@ -283,6 +285,8 @@ import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
|||
import { Notifier } from "@/plugins/notifier";
|
||||
import Tag from "@/components/TagElement.vue";
|
||||
import { useHead } from "@vueuse/head";
|
||||
import EventConversations from "../../components/Conversations/EventConversations.vue";
|
||||
import NewPrivateMessage from "../../components/Participation/NewPrivateMessage.vue";
|
||||
|
||||
const PARTICIPANTS_PER_PAGE = 10;
|
||||
const MESSAGE_ELLIPSIS_LENGTH = 130;
|
||||
|
|
|
@ -44,9 +44,9 @@
|
|||
<div class="flex flex-wrap justify-center flex-col md:flex-row">
|
||||
<div
|
||||
class="flex flex-col items-center flex-1 m-0"
|
||||
v-if="isCurrentActorAGroupMember && !previewPublic"
|
||||
v-if="isCurrentActorAGroupMember && !previewPublic && members"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<div class="flex">
|
||||
<figure
|
||||
:title="
|
||||
t(`{'@'}{username} ({role})`, {
|
||||
|
@ -54,11 +54,12 @@
|
|||
role: member.role,
|
||||
})
|
||||
"
|
||||
v-for="member in members"
|
||||
v-for="member in members.elements"
|
||||
:key="member.actor.id"
|
||||
class="-mr-3"
|
||||
>
|
||||
<img
|
||||
class="rounded-full"
|
||||
class="rounded-full h-8"
|
||||
:src="member.actor.avatar.url"
|
||||
v-if="member.actor.avatar"
|
||||
alt=""
|
||||
|
@ -698,6 +699,7 @@ import Events from "@/components/Group/Sections/EventsSection.vue";
|
|||
import { Dialog } from "@/plugins/dialog";
|
||||
import { Notifier } from "@/plugins/notifier";
|
||||
import { useGroupResourcesList } from "@/composition/apollo/resources";
|
||||
import { useGroupMembers } from "@/composition/apollo/members";
|
||||
|
||||
const props = defineProps<{
|
||||
preferredUsername: string;
|
||||
|
@ -1050,18 +1052,18 @@ const isCurrentActorOnADifferentDomainThanGroup = computed((): boolean => {
|
|||
return group.value?.domain !== null;
|
||||
});
|
||||
|
||||
const members = computed((): IMember[] => {
|
||||
return (
|
||||
(group.value?.members?.elements ?? []).filter(
|
||||
(member: IMember) =>
|
||||
![
|
||||
MemberRole.INVITED,
|
||||
MemberRole.REJECTED,
|
||||
MemberRole.NOT_APPROVED,
|
||||
].includes(member.role)
|
||||
) ?? []
|
||||
);
|
||||
});
|
||||
// const members = computed((): IMember[] => {
|
||||
// return (
|
||||
// (group.value?.members?.elements ?? []).filter(
|
||||
// (member: IMember) =>
|
||||
// ![
|
||||
// MemberRole.INVITED,
|
||||
// MemberRole.REJECTED,
|
||||
// MemberRole.NOT_APPROVED,
|
||||
// ].includes(member.role)
|
||||
// ) ?? []
|
||||
// );
|
||||
// });
|
||||
|
||||
const physicalAddress = computed((): Address | null => {
|
||||
if (!group.value?.physicalAddress) return null;
|
||||
|
@ -1179,6 +1181,10 @@ const hasCurrentActorThisRole = (givenRole: string | string[]): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
const { members } = useGroupMembers(preferredUsername, {
|
||||
enabled: computed(() => isCurrentActorAGroupMember.value),
|
||||
});
|
||||
|
||||
watch(isCurrentActorAGroupMember, () => {
|
||||
refetchGroup();
|
||||
});
|
||||
|
|
|
@ -257,6 +257,45 @@
|
|||
<h2 class="mb-1">{{ t("Reported content") }}</h2>
|
||||
<ul v-for="comment in report.comments" :key="comment.id">
|
||||
<li>
|
||||
<template v-if="comment.conversation && comment.event">
|
||||
<i18n-t keypath="Comment from an event announcement" tag="p">
|
||||
<template #eventTitle>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: comment.event?.uuid },
|
||||
}"
|
||||
>
|
||||
<b>{{ comment.event?.title }}</b>
|
||||
</router-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<DiscussionComment
|
||||
:modelValue="comment"
|
||||
:current-actor="currentActor as IPerson"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="comment.conversation">
|
||||
<i18n-t keypath="Comment from a private conversation" tag="p">
|
||||
<template #eventTitle>
|
||||
<router-link
|
||||
:to="{
|
||||
name: RouteName.EVENT,
|
||||
params: { uuid: comment.event?.uuid },
|
||||
}"
|
||||
>
|
||||
<b>{{ comment.event?.title }}</b>
|
||||
</router-link>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<DiscussionComment
|
||||
:modelValue="comment"
|
||||
:current-actor="currentActor as IPerson"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<i18n-t keypath="Comment under event {eventTitle}" tag="p">
|
||||
<template #eventTitle>
|
||||
<router-link
|
||||
|
@ -276,6 +315,7 @@
|
|||
:current-actor="currentActor as IPerson"
|
||||
:readOnly="true"
|
||||
/>
|
||||
</template>
|
||||
<o-button
|
||||
v-if="!comment.deletedAt"
|
||||
variant="danger"
|
||||
|
@ -389,10 +429,10 @@ import { useFeatures } from "@/composition/apollo/config";
|
|||
import { IEvent } from "@/types/event.model";
|
||||
import EmptyContent from "@/components/Utils/EmptyContent.vue";
|
||||
import EventComment from "@/components/Comment/EventComment.vue";
|
||||
import DiscussionComment from "@/components/Discussion/DiscussionComment.vue";
|
||||
import { SUSPEND_PROFILE } from "@/graphql/actor";
|
||||
import { GET_USER, SUSPEND_USER } from "@/graphql/user";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { waitApolloQuery } from "@/vue-apollo";
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
|
@ -721,7 +761,10 @@ const { mutate: doSuspendUser, onDone: onSuspendUserDone } = useMutation<
|
|||
{ userId: string }
|
||||
>(SUSPEND_USER);
|
||||
|
||||
const userLazyQuery = useLazyQuery<{ user: IUser }, { id: string }>(GET_USER);
|
||||
const { load: loadUserLazyQuery } = useLazyQuery<
|
||||
{ user: IUser },
|
||||
{ id: string }
|
||||
>(GET_USER);
|
||||
|
||||
const suspendProfile = async (actorId: string): Promise<void> => {
|
||||
dialog?.confirm({
|
||||
|
@ -761,15 +804,13 @@ const cachedReportedUser = ref<IUser | undefined>();
|
|||
const suspendUser = async (user: IUser): Promise<void> => {
|
||||
try {
|
||||
if (!cachedReportedUser.value) {
|
||||
userLazyQuery.load(GET_USER, { id: user.id });
|
||||
|
||||
const userLazyQueryResult = await waitApolloQuery<
|
||||
{ user: IUser },
|
||||
{ id: string }
|
||||
>(userLazyQuery);
|
||||
console.debug("data", userLazyQueryResult);
|
||||
|
||||
cachedReportedUser.value = userLazyQueryResult.data.user;
|
||||
try {
|
||||
const result = await loadUserLazyQuery(GET_USER, { id: user.id });
|
||||
if (!result) return;
|
||||
cachedReportedUser.value = result.user;
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
dialog?.confirm({
|
||||
|
|
|
@ -73,7 +73,7 @@
|
|||
<tr v-for="subType in notificationType.subtypes" :key="subType.id">
|
||||
<td v-for="(method, key) in notificationMethods" :key="key">
|
||||
<o-checkbox
|
||||
:modelValue="notificationValues[subType.id][key].enabled"
|
||||
:modelValue="notificationValues?.[subType.id]?.[key]?.enabled"
|
||||
@update:modelValue="
|
||||
(e: boolean) =>
|
||||
updateNotificationValue({
|
||||
|
@ -82,7 +82,7 @@
|
|||
enabled: e,
|
||||
})
|
||||
"
|
||||
:disabled="notificationValues[subType.id][key].disabled"
|
||||
:disabled="notificationValues?.[subType.id]?.[key]?.disabled"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
|
@ -104,7 +104,7 @@
|
|||
>
|
||||
<o-select
|
||||
v-model="groupNotifications"
|
||||
@input="updateSetting({ groupNotifications })"
|
||||
@update:modelValue="updateSetting({ groupNotifications })"
|
||||
id="groupNotifications"
|
||||
>
|
||||
<option
|
||||
|
@ -450,6 +450,10 @@ const defaultNotificationValues = {
|
|||
email: { enabled: true, disabled: false },
|
||||
push: { enabled: true, disabled: false },
|
||||
},
|
||||
conversation_mention: {
|
||||
email: { enabled: true, disabled: false },
|
||||
push: { enabled: true, disabled: false },
|
||||
},
|
||||
discussion_mention: {
|
||||
email: { enabled: true, disabled: false },
|
||||
push: { enabled: false, disabled: false },
|
||||
|
@ -464,6 +468,10 @@ const notificationTypes: NotificationType[] = [
|
|||
{
|
||||
label: t("Mentions") as string,
|
||||
subtypes: [
|
||||
{
|
||||
id: "conversation_mention",
|
||||
label: t("I've been mentionned in a conversation") as string,
|
||||
},
|
||||
{
|
||||
id: "event_comment_mention",
|
||||
label: t("I've been mentionned in a comment under an event") as string,
|
||||
|
|
|
@ -249,6 +249,8 @@ onCurrentUserMutationDone(async () => {
|
|||
userAlreadyActivated: "true",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -37,23 +37,24 @@ const {
|
|||
{ id: string; email: string; isLoggedIn: boolean; role: ICurrentUserRole }
|
||||
>(UPDATE_CURRENT_USER_CLIENT);
|
||||
|
||||
const { onResult: onLoggedUserResult, load: loadUser } = useLazyQuery<{
|
||||
const { load: loadUser } = useLazyQuery<{
|
||||
loggedUser: IUser;
|
||||
}>(LOGGED_USER);
|
||||
|
||||
onUpdateCurrentUserClientDone(async () => {
|
||||
loadUser();
|
||||
});
|
||||
|
||||
onLoggedUserResult(async (result) => {
|
||||
if (result.loading) return;
|
||||
const loggedUser = result.data.loggedUser;
|
||||
try {
|
||||
const result = await loadUser();
|
||||
if (!result) return;
|
||||
const loggedUser = result.loggedUser;
|
||||
if (loggedUser.defaultActor) {
|
||||
await changeIdentity(loggedUser.defaultActor);
|
||||
await router.push({ name: RouteName.HOME });
|
||||
} else {
|
||||
// No need to push to REGISTER_PROFILE, the navbar will do it for us
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
import {
|
||||
ApolloClient,
|
||||
ApolloQueryResult,
|
||||
NormalizedCacheObject,
|
||||
OperationVariables,
|
||||
} from "@apollo/client/core";
|
||||
import { ApolloClient, NormalizedCacheObject } from "@apollo/client/core";
|
||||
import buildCurrentUserResolver from "@/apollo/user";
|
||||
import { cache } from "./apollo/memory";
|
||||
import { fullLink } from "./apollo/link";
|
||||
import { UseQueryReturn } from "@vue/apollo-composable";
|
||||
|
||||
export const apolloClient = new ApolloClient<NormalizedCacheObject>({
|
||||
cache,
|
||||
|
@ -15,24 +9,3 @@ export const apolloClient = new ApolloClient<NormalizedCacheObject>({
|
|||
connectToDevTools: true,
|
||||
resolvers: buildCurrentUserResolver(cache),
|
||||
});
|
||||
|
||||
export function waitApolloQuery<
|
||||
TResult = any,
|
||||
TVariables extends OperationVariables = OperationVariables,
|
||||
>({
|
||||
onResult,
|
||||
onError,
|
||||
}: UseQueryReturn<TResult, TVariables>): Promise<ApolloQueryResult<TResult>> {
|
||||
return new Promise((res, rej) => {
|
||||
const { off: offResult } = onResult((result) => {
|
||||
if (result.loading === false) {
|
||||
offResult();
|
||||
res(result);
|
||||
}
|
||||
});
|
||||
const { off: offError } = onError((error) => {
|
||||
offError();
|
||||
rej(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
1881
js/yarn.lock
1881
js/yarn.lock
File diff suppressed because it is too large
Load diff
|
@ -14,7 +14,15 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
|
|||
]
|
||||
|
||||
@type create_entities ::
|
||||
:event | :comment | :discussion | :actor | :todo_list | :todo | :resource | :post
|
||||
:event
|
||||
| :comment
|
||||
| :discussion
|
||||
| :conversation
|
||||
| :actor
|
||||
| :todo_list
|
||||
| :todo
|
||||
| :resource
|
||||
| :post
|
||||
|
||||
@doc """
|
||||
Create an activity of type `Create`
|
||||
|
@ -50,18 +58,27 @@ defmodule Mobilizon.Federation.ActivityPub.Actions.Create do
|
|||
end
|
||||
end
|
||||
|
||||
@map_types %{
|
||||
:event => Types.Events,
|
||||
:comment => Types.Comments,
|
||||
:discussion => Types.Discussions,
|
||||
:conversation => Types.Conversations,
|
||||
:actor => Types.Actors,
|
||||
:todo_list => Types.TodoLists,
|
||||
:todo => Types.Todos,
|
||||
:resource => Types.Resources,
|
||||
:post => Types.Posts
|
||||
}
|
||||
|
||||
@spec do_create(create_entities(), map(), map()) ::
|
||||
{:ok, Entity.t(), Activity.t()} | {:error, Ecto.Changeset.t() | atom()}
|
||||
defp do_create(type, args, additional) do
|
||||
case type do
|
||||
:event -> Types.Events.create(args, additional)
|
||||
:comment -> Types.Comments.create(args, additional)
|
||||
:discussion -> Types.Discussions.create(args, additional)
|
||||
:actor -> Types.Actors.create(args, additional)
|
||||
:todo_list -> Types.TodoLists.create(args, additional)
|
||||
:todo -> Types.Todos.create(args, additional)
|
||||
:resource -> Types.Resources.create(args, additional)
|
||||
:post -> Types.Posts.create(args, additional)
|
||||
mod = Map.get(@map_types, type)
|
||||
|
||||
if is_nil(mod) do
|
||||
{:error, :type_not_supported}
|
||||
else
|
||||
mod.create(args, additional)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
|
|||
|
||||
alias Mobilizon.{Actors, Discussions, Events, Share}
|
||||
alias Mobilizon.Actors.{Actor, Member}
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.{Comment, Discussion}
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.Federation.ActivityPub.Types.Entity
|
||||
|
@ -38,6 +39,10 @@ defmodule Mobilizon.Federation.ActivityPub.Audience do
|
|||
%{"to" => maybe_add_group_members([], actor), "cc" => []}
|
||||
end
|
||||
|
||||
def get_audience(%Conversation{participants: participants}) do
|
||||
%{"to" => Enum.map(participants, & &1.url), "cc" => []}
|
||||
end
|
||||
|
||||
# Deleted comments are just like tombstones
|
||||
def get_audience(%Comment{deleted_at: deleted_at}) when not is_nil(deleted_at) do
|
||||
%{"to" => [@ap_public], "cc" => []}
|
||||
|
|
|
@ -177,7 +177,7 @@ defmodule Mobilizon.Federation.ActivityPub.Fetcher do
|
|||
{:error, :content_not_json}
|
||||
|
||||
{:ok, %Tesla.Env{} = res} ->
|
||||
Logger.debug("Resource returned bad HTTP code #{inspect(res)}")
|
||||
Logger.debug("Resource returned bad HTTP code (#{res.status}) #{inspect(res)}")
|
||||
{:error, :http_error}
|
||||
|
||||
{:error, err} ->
|
||||
|
|
|
@ -68,11 +68,12 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
|
|||
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
|
||||
Logger.info("Handle incoming to create notes")
|
||||
|
||||
case Discussions.get_comment_from_url_with_preload(object["id"]) do
|
||||
{:error, :comment_not_found} ->
|
||||
case Converter.Comment.as_to_model_data(object) do
|
||||
%{visibility: visibility, event_id: event_id}
|
||||
when visibility != :public and event_id != nil ->
|
||||
Logger.info("Tried to reply to an event with a private comment - ignore")
|
||||
:error
|
||||
%{visibility: visibility} = object_data
|
||||
when visibility === :private ->
|
||||
Actions.Create.create(:conversation, object_data, false)
|
||||
|
||||
object_data when is_map(object_data) ->
|
||||
case Discussions.get_comment_from_url_with_preload(object_data.url) do
|
||||
|
@ -80,11 +81,12 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
|
|||
object_data
|
||||
|> transform_object_data_for_discussion()
|
||||
|> save_comment_or_discussion()
|
||||
end
|
||||
end
|
||||
|
||||
{:ok, %Comment{} = comment} ->
|
||||
# Object already exists
|
||||
{:ok, nil, comment}
|
||||
end
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
|
|
207
lib/federation/activity_pub/types/conversation.ex
Normal file
207
lib/federation/activity_pub/types/conversation.ex
Normal file
|
@ -0,0 +1,207 @@
|
|||
defmodule Mobilizon.Federation.ActivityPub.Types.Conversations do
|
||||
@moduledoc false
|
||||
|
||||
# alias Mobilizon.Conversations.ConversationParticipant
|
||||
alias Mobilizon.{Actors, Conversations, Discussions}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
|
||||
alias Mobilizon.Federation.ActivityPub.{Audience, Permission}
|
||||
alias Mobilizon.Federation.ActivityPub.Types.Entity
|
||||
alias Mobilizon.Federation.ActivityStream
|
||||
alias Mobilizon.Federation.ActivityStream.Converter.Utils, as: ConverterUtils
|
||||
alias Mobilizon.Federation.ActivityStream.Convertible
|
||||
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
|
||||
alias Mobilizon.Service.Activity.Conversation, as: ConversationActivity
|
||||
alias Mobilizon.Web.Endpoint
|
||||
import Mobilizon.Federation.ActivityPub.Utils, only: [make_create_data: 2, make_update_data: 2]
|
||||
require Logger
|
||||
|
||||
@behaviour Entity
|
||||
|
||||
@impl Entity
|
||||
@spec create(map(), map()) ::
|
||||
{:ok, Conversation.t(), ActivityStream.t()}
|
||||
| {:error, :conversation_not_found | :last_comment_not_found | Ecto.Changeset.t()}
|
||||
def create(%{conversation_id: conversation_id} = args, additional)
|
||||
when not is_nil(conversation_id) do
|
||||
Logger.debug("Creating a reply to a conversation #{inspect(args, pretty: true)}")
|
||||
args = prepare_args(args)
|
||||
Logger.debug("Creating a reply to a conversation #{inspect(args, pretty: true)}")
|
||||
|
||||
case Conversations.get_conversation(conversation_id) do
|
||||
%Conversation{} = conversation ->
|
||||
case Conversations.reply_to_conversation(conversation, args) do
|
||||
{:ok, %Conversation{last_comment_id: last_comment_id} = conversation} ->
|
||||
ConversationActivity.insert_activity(conversation, subject: "conversation_replied")
|
||||
maybe_publish_graphql_subscription(conversation)
|
||||
|
||||
case Discussions.get_comment_with_preload(last_comment_id) do
|
||||
%Comment{} = last_comment ->
|
||||
comment_as_data = Convertible.model_to_as(last_comment)
|
||||
audience = Audience.get_audience(conversation)
|
||||
create_data = make_create_data(comment_as_data, Map.merge(audience, additional))
|
||||
{:ok, conversation, create_data}
|
||||
|
||||
nil ->
|
||||
{:error, :last_comment_not_found}
|
||||
end
|
||||
|
||||
{:error, _, %Ecto.Changeset{} = err, _} ->
|
||||
{:error, err}
|
||||
end
|
||||
|
||||
nil ->
|
||||
{:error, :discussion_not_found}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Entity
|
||||
def create(args, additional) do
|
||||
with args when is_map(args) <- prepare_args(args) do
|
||||
case Conversations.create_conversation(args) do
|
||||
{:ok, %Conversation{} = conversation} ->
|
||||
ConversationActivity.insert_activity(conversation, subject: "conversation_created")
|
||||
conversation_as_data = Convertible.model_to_as(conversation)
|
||||
audience = Audience.get_audience(conversation)
|
||||
create_data = make_create_data(conversation_as_data, Map.merge(audience, additional))
|
||||
{:ok, conversation, create_data}
|
||||
|
||||
{:error, _, %Ecto.Changeset{} = err, _} ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@impl Entity
|
||||
@spec update(Conversation.t(), map(), map()) ::
|
||||
{:ok, Conversation.t(), ActivityStream.t()} | {:error, Ecto.Changeset.t()}
|
||||
def update(%Conversation{} = old_conversation, args, additional) do
|
||||
case Conversations.update_conversation(old_conversation, args) do
|
||||
{:ok, %Conversation{} = new_conversation} ->
|
||||
# ConversationActivity.insert_activity(new_conversation,
|
||||
# subject: "conversation_renamed",
|
||||
# old_conversation: old_conversation
|
||||
# )
|
||||
|
||||
conversation_as_data = Convertible.model_to_as(new_conversation)
|
||||
audience = Audience.get_audience(new_conversation)
|
||||
update_data = make_update_data(conversation_as_data, Map.merge(audience, additional))
|
||||
{:ok, new_conversation, update_data}
|
||||
|
||||
{:error, %Ecto.Changeset{} = err} ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
@impl Entity
|
||||
@spec delete(Conversation.t(), Actor.t(), boolean, map()) ::
|
||||
{:error, Ecto.Changeset.t()} | {:ok, ActivityStream.t(), Actor.t(), Conversation.t()}
|
||||
def delete(
|
||||
%Conversation{} = _conversation,
|
||||
%Actor{} = _actor,
|
||||
_local,
|
||||
_additionnal
|
||||
) do
|
||||
{:error, :not_applicable}
|
||||
end
|
||||
|
||||
# @spec actor(Conversation.t()) :: Actor.t() | nil
|
||||
# def actor(%ConversationParticipant{actor_id: actor_id}), do: Actors.get_actor(actor_id)
|
||||
|
||||
# @spec group_actor(Conversation.t()) :: Actor.t() | nil
|
||||
# def group_actor(%Conversation{actor_id: actor_id}), do: Actors.get_actor(actor_id)
|
||||
|
||||
@spec permissions(Conversation.t()) :: Permission.t()
|
||||
def permissions(%Conversation{}) do
|
||||
%Permission{access: :member, create: :member, update: :moderator, delete: :moderator}
|
||||
end
|
||||
|
||||
@spec maybe_publish_graphql_subscription(Conversation.t()) :: :ok
|
||||
defp maybe_publish_graphql_subscription(%Conversation{} = conversation) do
|
||||
Absinthe.Subscription.publish(Endpoint, conversation,
|
||||
conversation_comment_changed: conversation.id
|
||||
)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
||||
@spec prepare_args(map) :: map | {:error, :empty_participants}
|
||||
defp prepare_args(args) do
|
||||
{text, mentions, _tags} =
|
||||
APIUtils.make_content_html(
|
||||
args |> Map.get(:text, "") |> String.trim(),
|
||||
# Can't put additional tags on a comment
|
||||
[],
|
||||
"text/html"
|
||||
)
|
||||
|
||||
mentions =
|
||||
(args |> Map.get(:mentions, []) |> prepare_mentions()) ++
|
||||
ConverterUtils.fetch_mentions(mentions)
|
||||
|
||||
if Enum.empty?(mentions) do
|
||||
{:error, :empty_participants}
|
||||
else
|
||||
event = Map.get(args, :event, get_event(Map.get(args, :event_id)))
|
||||
|
||||
participants =
|
||||
(mentions ++
|
||||
[
|
||||
%{actor_id: args.actor_id},
|
||||
%{
|
||||
actor_id:
|
||||
if(is_nil(event),
|
||||
do: nil,
|
||||
else: event.attributed_to_id || event.organizer_actor_id
|
||||
)
|
||||
}
|
||||
])
|
||||
|> Enum.reduce(
|
||||
[],
|
||||
fn %{actor_id: actor_id}, acc ->
|
||||
case Actors.get_actor(actor_id) do
|
||||
nil -> acc
|
||||
actor -> acc ++ [actor]
|
||||
end
|
||||
end
|
||||
)
|
||||
|> Enum.uniq_by(& &1.id)
|
||||
|
||||
args
|
||||
|> Map.put(:text, text)
|
||||
|> Map.put(:mentions, mentions)
|
||||
|> Map.put(:participants, participants)
|
||||
end
|
||||
end
|
||||
|
||||
@spec prepare_mentions(list(String.t())) :: list(%{actor_id: String.t()})
|
||||
defp prepare_mentions(mentions) do
|
||||
Enum.reduce(mentions, [], &prepare_mention/2)
|
||||
end
|
||||
|
||||
@spec prepare_mention(String.t() | map(), list()) :: list(%{actor_id: String.t()})
|
||||
defp prepare_mention(%{actor_id: _} = mention, mentions) do
|
||||
mentions ++ [mention]
|
||||
end
|
||||
|
||||
defp prepare_mention(mention, mentions) do
|
||||
case ActivityPubActor.find_or_make_actor_from_nickname(mention) do
|
||||
{:ok, %Actor{id: actor_id}} ->
|
||||
mentions ++ [%{actor_id: actor_id}]
|
||||
|
||||
{:error, _} ->
|
||||
mentions
|
||||
end
|
||||
end
|
||||
|
||||
defp get_event(nil), do: nil
|
||||
|
||||
defp get_event(event_id) do
|
||||
case Mobilizon.Events.get_event(event_id) do
|
||||
{:ok, event} -> event
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
end
|
|
@ -22,6 +22,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
|
|||
|
||||
@actor_types ["Group", "Person", "Application"]
|
||||
@all_actor_types @actor_types ++ ["Organization", "Service"]
|
||||
@ap_public_audience "https://www.w3.org/ns/activitystreams#Public"
|
||||
|
||||
# Wraps an object into an activity
|
||||
@spec create_activity(map(), boolean()) :: {:ok, Activity.t()}
|
||||
|
@ -491,8 +492,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
|
|||
if public do
|
||||
Logger.debug("Making announce data for a public object")
|
||||
|
||||
{[actor.followers_url, object_actor_url],
|
||||
["https://www.w3.org/ns/activitystreams#Public"]}
|
||||
{[actor.followers_url, object_actor_url], [@ap_public_audience]}
|
||||
else
|
||||
Logger.debug("Making announce data for a private object")
|
||||
|
||||
|
@ -539,7 +539,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
|
|||
"actor" => url,
|
||||
"object" => activity,
|
||||
"to" => [actor.followers_url, actor.url],
|
||||
"cc" => ["https://www.w3.org/ns/activitystreams#Public"]
|
||||
"cc" => [@ap_public_audience]
|
||||
}
|
||||
|
||||
if activity_id, do: Map.put(data, "id", activity_id), else: data
|
||||
|
|
|
@ -47,9 +47,6 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|
|||
|
||||
case maybe_fetch_actor_and_attributed_to_id(object) do
|
||||
{:ok, %Actor{id: actor_id, domain: actor_domain}, attributed_to} ->
|
||||
Logger.debug("Inserting full comment")
|
||||
Logger.debug(inspect(object))
|
||||
|
||||
data = %{
|
||||
text: object["content"],
|
||||
url: object["id"],
|
||||
|
@ -70,14 +67,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|
|||
is_announcement: Map.get(object, "isAnnouncement", false)
|
||||
}
|
||||
|
||||
Logger.debug("Converted object before fetching parents")
|
||||
Logger.debug(inspect(data))
|
||||
|
||||
data = maybe_fetch_parent_object(object, data)
|
||||
|
||||
Logger.debug("Converted object after fetching parents")
|
||||
Logger.debug(inspect(data))
|
||||
data
|
||||
maybe_fetch_parent_object(object, data)
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
|
@ -147,17 +137,20 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|
|||
end
|
||||
|
||||
@spec determine_to(CommentModel.t()) :: [String.t()]
|
||||
defp determine_to(%CommentModel{} = comment) do
|
||||
cond do
|
||||
not is_nil(comment.attributed_to) ->
|
||||
[comment.attributed_to.url]
|
||||
|
||||
comment.visibility == :public ->
|
||||
["https://www.w3.org/ns/activitystreams#Public"]
|
||||
|
||||
true ->
|
||||
[comment.actor.followers_url]
|
||||
defp determine_to(%CommentModel{visibility: :private, mentions: mentions} = _comment) do
|
||||
Enum.map(mentions, fn mention -> mention.actor.url end)
|
||||
end
|
||||
|
||||
defp determine_to(%CommentModel{visibility: :public} = comment) do
|
||||
if is_nil(comment.attributed_to) do
|
||||
["https://www.w3.org/ns/activitystreams#Public"]
|
||||
else
|
||||
[comment.attributed_to.url]
|
||||
end
|
||||
end
|
||||
|
||||
defp determine_to(%CommentModel{} = comment) do
|
||||
[comment.actor.followers_url]
|
||||
end
|
||||
|
||||
defp maybe_fetch_parent_object(object, data) do
|
||||
|
@ -170,9 +163,12 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|
|||
|
||||
case ActivityPub.fetch_object_from_url(object["inReplyTo"]) do
|
||||
# Reply to an event (Event)
|
||||
{:ok, %Event{id: id}} ->
|
||||
{:ok, %Event{id: id} = event} ->
|
||||
Logger.debug("Parent object is an event")
|
||||
data |> Map.put(:event_id, id)
|
||||
|
||||
data
|
||||
|> Map.put(:event_id, id)
|
||||
|> Map.put(:event, event)
|
||||
|
||||
# Reply to a comment (Comment)
|
||||
{:ok, %CommentModel{id: id} = comment} ->
|
||||
|
@ -182,6 +178,7 @@ defmodule Mobilizon.Federation.ActivityStream.Converter.Comment do
|
|||
|> Map.put(:in_reply_to_comment_id, id)
|
||||
|> Map.put(:origin_comment_id, comment |> CommentModel.get_thread_id())
|
||||
|> Map.put(:event_id, comment.event_id)
|
||||
|> Map.put(:conversation_id, comment.conversation_id)
|
||||
|
||||
# Reply to a discucssion (Discussion)
|
||||
{:ok,
|
||||
|
|
68
lib/federation/activity_stream/converter/conversation.ex
Normal file
68
lib/federation/activity_stream/converter/conversation.ex
Normal file
|
@ -0,0 +1,68 @@
|
|||
defmodule Mobilizon.Federation.ActivityStream.Converter.Conversation do
|
||||
@moduledoc """
|
||||
Comment converter.
|
||||
|
||||
This module allows to convert conversations from ActivityStream format to our own
|
||||
internal one, and back.
|
||||
"""
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
|
||||
alias Mobilizon.Federation.ActivityStream.{Converter, Convertible}
|
||||
alias Mobilizon.Federation.ActivityStream.Converter.Conversation, as: ConversationConverter
|
||||
alias Mobilizon.Storage.Repo
|
||||
import Mobilizon.Service.Guards, only: [is_valid_string: 1]
|
||||
|
||||
require Logger
|
||||
|
||||
@behaviour Converter
|
||||
|
||||
defimpl Convertible, for: Conversation do
|
||||
defdelegate model_to_as(comment), to: ConversationConverter
|
||||
end
|
||||
|
||||
@doc """
|
||||
Make an AS comment object from an existing `conversation` structure.
|
||||
"""
|
||||
@impl Converter
|
||||
@spec model_to_as(Conversation.t()) :: map
|
||||
def model_to_as(%Conversation{} = conversation) do
|
||||
conversation = Repo.preload(conversation, [:participants, last_comment: [:actor]])
|
||||
|
||||
%{
|
||||
"type" => "Note",
|
||||
"to" => Enum.map(conversation.participants, & &1.url),
|
||||
"cc" => [],
|
||||
"content" => conversation.last_comment.text,
|
||||
"mediaType" => "text/html",
|
||||
"actor" => conversation.last_comment.actor.url,
|
||||
"id" => conversation.last_comment.url,
|
||||
"publishedAt" => conversation.inserted_at
|
||||
}
|
||||
end
|
||||
|
||||
@impl Converter
|
||||
@spec as_to_model_data(map) :: map() | {:error, atom()}
|
||||
def as_to_model_data(%{"type" => "Note", "name" => name} = object) when is_valid_string(name) do
|
||||
with %{actor_id: actor_id, creator_id: creator_id} <- extract_actors(object) do
|
||||
%{actor_id: actor_id, creator_id: creator_id, title: name, url: object["id"]}
|
||||
end
|
||||
end
|
||||
|
||||
@spec extract_actors(map()) ::
|
||||
%{actor_id: String.t(), creator_id: String.t()} | {:error, atom()}
|
||||
defp extract_actors(%{"actor" => creator_url, "attributedTo" => actor_url} = _object)
|
||||
when is_valid_string(creator_url) and is_valid_string(actor_url) do
|
||||
with {:ok, %Actor{id: creator_id, suspended: false}} <-
|
||||
ActivityPubActor.get_or_fetch_actor_by_url(creator_url),
|
||||
{:ok, %Actor{id: actor_id, suspended: false}} <-
|
||||
ActivityPubActor.get_or_fetch_actor_by_url(actor_url) do
|
||||
%{actor_id: actor_id, creator_id: creator_id}
|
||||
else
|
||||
{:error, error} -> {:error, error}
|
||||
{:ok, %Actor{url: ^creator_url}} -> {:error, :creator_suspended}
|
||||
{:ok, %Actor{url: ^actor_url}} -> {:error, :actor_suspended}
|
||||
end
|
||||
end
|
||||
end
|
|
@ -242,12 +242,15 @@ defmodule Mobilizon.Federation.WebFinger do
|
|||
@spec domain_from_federated_actor(String.t()) :: {:ok, String.t()} | {:error, :host_not_found}
|
||||
defp domain_from_federated_actor(actor) do
|
||||
case String.split(actor, "@") do
|
||||
[_name, ""] ->
|
||||
{:error, :host_not_found}
|
||||
|
||||
[_name, domain] ->
|
||||
{:ok, domain}
|
||||
|
||||
_e ->
|
||||
host = URI.parse(actor).host
|
||||
if is_nil(host), do: {:error, :host_not_found}, else: {:ok, host}
|
||||
if is_nil(host) or host == "", do: {:error, :host_not_found}, else: {:ok, host}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -4,7 +4,8 @@ defmodule Mobilizon.GraphQL.API.Comments do
|
|||
"""
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.{Comment, Discussion}
|
||||
alias Mobilizon.Federation.ActivityPub.{Actions, Activity}
|
||||
alias Mobilizon.GraphQL.API.Utils
|
||||
|
||||
|
@ -53,6 +54,22 @@ defmodule Mobilizon.GraphQL.API.Comments do
|
|||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a conversation (or reply to a conversation)
|
||||
"""
|
||||
@spec create_conversation(map()) ::
|
||||
{:ok, Activity.t(), Conversation.t()}
|
||||
| {:error, :entity_tombstoned | atom | Ecto.Changeset.t()}
|
||||
def create_conversation(args) do
|
||||
args = extract_pictures_from_comment_body(args)
|
||||
|
||||
Actions.Create.create(
|
||||
:conversation,
|
||||
args,
|
||||
true
|
||||
)
|
||||
end
|
||||
|
||||
@spec extract_pictures_from_comment_body(map()) :: map()
|
||||
defp extract_pictures_from_comment_body(%{text: text, actor_id: actor_id} = args) do
|
||||
pictures = Utils.extract_pictures_from_body(text, actor_id)
|
||||
|
|
|
@ -4,8 +4,8 @@ defmodule Mobilizon.GraphQL.API.Events do
|
|||
"""
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
|
||||
alias Mobilizon.Federation.ActivityPub.{Actions, Activity, Utils}
|
||||
alias Mobilizon.GraphQL.API.Utils, as: APIUtils
|
||||
|
||||
|
@ -36,6 +36,12 @@ defmodule Mobilizon.GraphQL.API.Events do
|
|||
Actions.Delete.delete(event, actor, true)
|
||||
end
|
||||
|
||||
@spec send_private_message_to_participants(map()) ::
|
||||
{:ok, Activity.t(), Comment.t()} | {:error, atom() | Ecto.Changeset.t()}
|
||||
def send_private_message_to_participants(args) do
|
||||
Actions.Create.create(:comment, args, true)
|
||||
end
|
||||
|
||||
@spec prepare_args(map) :: map
|
||||
defp prepare_args(args) do
|
||||
organizer_actor = Map.get(args, :organizer_actor)
|
||||
|
|
|
@ -116,13 +116,9 @@ defmodule Mobilizon.GraphQL.API.Search do
|
|||
@spec process_from_username(String.t()) :: Page.t(Actor.t())
|
||||
defp process_from_username(search) do
|
||||
case ActivityPubActor.find_or_make_actor_from_nickname(search) do
|
||||
{:ok, %Actor{type: :Group} = actor} ->
|
||||
{:ok, %Actor{} = actor} ->
|
||||
%Page{total: 1, elements: [actor]}
|
||||
|
||||
# Don't return anything else than groups
|
||||
{:ok, %Actor{}} ->
|
||||
%Page{total: 0, elements: []}
|
||||
|
||||
{:error, _err} ->
|
||||
Logger.debug(fn -> "Unable to find or make actor '#{search}'" end)
|
||||
|
||||
|
|
|
@ -16,11 +16,13 @@ defmodule Mobilizon.GraphQL.Authorization do
|
|||
@impl true
|
||||
def has_user_access?(%User{}, _scope, _rule), do: true
|
||||
|
||||
@impl true
|
||||
def has_user_access?(%ApplicationToken{scope: scope} = _current_app_token, _struct, rule)
|
||||
when rule != :forbid_app_access do
|
||||
AppScope.has_app_access?(scope, rule)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def has_user_access?(_current_user, _scoped_struct, _rule), do: false
|
||||
|
||||
@impl true
|
||||
|
|
269
lib/graphql/resolvers/conversation.ex
Normal file
269
lib/graphql/resolvers/conversation.ex
Normal file
|
@ -0,0 +1,269 @@
|
|||
defmodule Mobilizon.GraphQL.Resolvers.Conversation do
|
||||
@moduledoc """
|
||||
Handles the group-related GraphQL calls.
|
||||
"""
|
||||
|
||||
alias Mobilizon.{Actors, Conversations}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.{Conversation, ConversationParticipant, ConversationView}
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.GraphQL.API.Comments
|
||||
alias Mobilizon.Storage.Page
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Web.Endpoint
|
||||
# alias Mobilizon.Users.User
|
||||
import Mobilizon.Web.Gettext, only: [dgettext: 2]
|
||||
|
||||
@spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) ::
|
||||
{:ok, Page.t(ConversationView.t())} | {:error, :unauthenticated}
|
||||
def find_conversations_for_event(
|
||||
%Event{id: event_id, attributed_to_id: attributed_to_id},
|
||||
%{page: page, limit: limit},
|
||||
%{
|
||||
context: %{
|
||||
current_actor: %Actor{id: actor_id}
|
||||
}
|
||||
}
|
||||
)
|
||||
when not is_nil(attributed_to_id) do
|
||||
if Actors.is_member?(actor_id, attributed_to_id) do
|
||||
{:ok,
|
||||
event_id
|
||||
|> Conversations.find_conversations_for_event(actor_id, page, limit)
|
||||
|> conversation_participant_to_view()}
|
||||
else
|
||||
{:ok, %Page{total: 0, elements: []}}
|
||||
end
|
||||
end
|
||||
|
||||
@spec find_conversations_for_event(Event.t(), map(), Absinthe.Resolution.t()) ::
|
||||
{:ok, Page.t(ConversationView.t())} | {:error, :unauthenticated}
|
||||
def find_conversations_for_event(
|
||||
%Event{id: event_id, organizer_actor_id: organizer_actor_id},
|
||||
%{page: page, limit: limit},
|
||||
%{
|
||||
context: %{
|
||||
current_actor: %Actor{id: actor_id}
|
||||
}
|
||||
}
|
||||
) do
|
||||
if organizer_actor_id == actor_id do
|
||||
{:ok,
|
||||
event_id
|
||||
|> Conversations.find_conversations_for_event(actor_id, page, limit)
|
||||
|> conversation_participant_to_view()}
|
||||
else
|
||||
{:ok, %Page{total: 0, elements: []}}
|
||||
end
|
||||
end
|
||||
|
||||
def list_conversations(%Actor{id: actor_id}, %{page: page, limit: limit}, %{
|
||||
context: %{
|
||||
current_actor: %Actor{id: _current_actor_id}
|
||||
}
|
||||
}) do
|
||||
{:ok,
|
||||
actor_id
|
||||
|> Conversations.list_conversation_participants_for_actor(page, limit)
|
||||
|> conversation_participant_to_view()}
|
||||
end
|
||||
|
||||
def list_conversations(%User{id: user_id}, %{page: page, limit: limit}, %{
|
||||
context: %{
|
||||
current_actor: %Actor{id: _current_actor_id}
|
||||
}
|
||||
}) do
|
||||
{:ok,
|
||||
user_id
|
||||
|> Conversations.list_conversation_participants_for_user(page, limit)
|
||||
|> conversation_participant_to_view()}
|
||||
end
|
||||
|
||||
def unread_conversations_count(%Actor{id: actor_id}, _args, %{
|
||||
context: %{
|
||||
current_user: %User{} = user
|
||||
}
|
||||
}) do
|
||||
case User.owns_actor(user, actor_id) do
|
||||
{:is_owned, %Actor{}} ->
|
||||
{:ok, Conversations.count_unread_conversation_participants_for_person(actor_id)}
|
||||
|
||||
_ ->
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
end
|
||||
|
||||
def get_conversation(_parent, %{id: conversation_participant_id}, %{
|
||||
context: %{
|
||||
current_actor: %Actor{id: performing_actor_id}
|
||||
}
|
||||
}) do
|
||||
case Conversations.get_conversation_participant(conversation_participant_id) do
|
||||
nil ->
|
||||
{:error, :not_found}
|
||||
|
||||
%ConversationParticipant{actor_id: actor_id} = conversation_participant ->
|
||||
if actor_id == performing_actor_id or Actors.is_member?(performing_actor_id, actor_id) do
|
||||
{:ok, conversation_participant_to_view(conversation_participant)}
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def get_comments_for_conversation(
|
||||
%ConversationView{origin_comment_id: origin_comment_id, actor_id: conversation_actor_id},
|
||||
%{page: page, limit: limit},
|
||||
%{
|
||||
context: %{
|
||||
current_actor: %Actor{id: performing_actor_id}
|
||||
}
|
||||
}
|
||||
) do
|
||||
if conversation_actor_id == performing_actor_id or
|
||||
Actors.is_member?(performing_actor_id, conversation_actor_id) do
|
||||
{:ok,
|
||||
Mobilizon.Discussions.get_comments_in_reply_to_comment_id(origin_comment_id, page, limit)}
|
||||
else
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation(
|
||||
_parent,
|
||||
%{actor_id: actor_id} = args,
|
||||
%{
|
||||
context: %{
|
||||
current_actor: %Actor{} = current_actor
|
||||
}
|
||||
}
|
||||
) do
|
||||
if authorized_to_reply?(
|
||||
Map.get(args, :conversation_id),
|
||||
Map.get(args, :attributed_to_id),
|
||||
current_actor.id
|
||||
) do
|
||||
case Comments.create_conversation(args) do
|
||||
{:ok, _activity, %Conversation{} = conversation} ->
|
||||
Absinthe.Subscription.publish(
|
||||
Endpoint,
|
||||
Conversations.count_unread_conversation_participants_for_person(current_actor.id),
|
||||
person_unread_conversations_count: current_actor.id
|
||||
)
|
||||
|
||||
conversation_participant_actor =
|
||||
args |> Map.get(:attributed_to_id, actor_id) |> Actors.get_actor()
|
||||
|
||||
{:ok, conversation_to_view(conversation, conversation_participant_actor)}
|
||||
|
||||
{:error, :empty_participants} ->
|
||||
{:error, dgettext("errors", "Conversation needs to mention at least one participant")}
|
||||
end
|
||||
else
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
end
|
||||
|
||||
def update_conversation(_parent, %{conversation_id: conversation_participant_id, read: read}, %{
|
||||
context: %{
|
||||
current_actor: %Actor{id: current_actor_id}
|
||||
}
|
||||
}) do
|
||||
with {:no_participant,
|
||||
%ConversationParticipant{actor_id: actor_id} = conversation_participant} <-
|
||||
{:no_participant,
|
||||
Conversations.get_conversation_participant(conversation_participant_id)},
|
||||
{:valid_actor, true} <-
|
||||
{:valid_actor,
|
||||
actor_id == current_actor_id or
|
||||
Actors.is_member?(current_actor_id, actor_id)},
|
||||
{:ok, %ConversationParticipant{} = conversation_participant} <-
|
||||
Conversations.update_conversation_participant(conversation_participant, %{
|
||||
unread: !read
|
||||
}) do
|
||||
Absinthe.Subscription.publish(
|
||||
Endpoint,
|
||||
Conversations.count_unread_conversation_participants_for_person(actor_id),
|
||||
person_unread_conversations_count: actor_id
|
||||
)
|
||||
|
||||
{:ok, conversation_participant_to_view(conversation_participant)}
|
||||
else
|
||||
{:no_participant, _} ->
|
||||
{:error, :not_found}
|
||||
|
||||
{:valid_actor, _} ->
|
||||
{:error, :unauthorized}
|
||||
end
|
||||
end
|
||||
|
||||
def delete_conversation(_, _, _), do: :ok
|
||||
|
||||
defp conversation_participant_to_view(%Page{elements: elements} = page) do
|
||||
%Page{page | elements: Enum.map(elements, &conversation_participant_to_view/1)}
|
||||
end
|
||||
|
||||
defp conversation_participant_to_view(%ConversationParticipant{} = conversation_participant) do
|
||||
value =
|
||||
conversation_participant
|
||||
|> Map.from_struct()
|
||||
|> Map.merge(Map.from_struct(conversation_participant.conversation))
|
||||
|> Map.delete(:conversation)
|
||||
|> Map.put(
|
||||
:participants,
|
||||
Enum.map(
|
||||
conversation_participant.conversation.participants,
|
||||
&conversation_participant_to_actor/1
|
||||
)
|
||||
)
|
||||
|> Map.put(:conversation_participant_id, conversation_participant.id)
|
||||
|
||||
struct(ConversationView, value)
|
||||
end
|
||||
|
||||
defp conversation_to_view(
|
||||
%Conversation{id: conversation_id} = conversation,
|
||||
%Actor{id: actor_id} = actor,
|
||||
unread \\ true
|
||||
) do
|
||||
value =
|
||||
conversation
|
||||
|> Map.from_struct()
|
||||
|> Map.put(:actor, actor)
|
||||
|> Map.put(:unread, unread)
|
||||
|> Map.put(
|
||||
:conversation_participant_id,
|
||||
Conversations.get_participant_by_conversation_and_actor(conversation_id, actor_id).id
|
||||
)
|
||||
|
||||
struct(ConversationView, value)
|
||||
end
|
||||
|
||||
defp conversation_participant_to_actor(%Actor{} = actor), do: actor
|
||||
|
||||
defp conversation_participant_to_actor(%ConversationParticipant{} = conversation_participant),
|
||||
do: conversation_participant.actor
|
||||
|
||||
@spec authorized_to_reply?(String.t() | nil, String.t() | nil, String.t()) :: boolean()
|
||||
# Not a reply
|
||||
defp authorized_to_reply?(conversation_id, _attributed_to_id, _current_actor_id)
|
||||
when is_nil(conversation_id),
|
||||
do: true
|
||||
|
||||
# We are authorized to reply if we are one of the participants, or if we a a member of a participant group
|
||||
defp authorized_to_reply?(conversation_id, attributed_to_id, current_actor_id) do
|
||||
case Conversations.get_conversation(conversation_id) do
|
||||
nil ->
|
||||
false
|
||||
|
||||
%Conversation{participants: participants} ->
|
||||
participant_ids = Enum.map(participants, fn participant -> to_string(participant.id) end)
|
||||
|
||||
current_actor_id in participant_ids or
|
||||
Enum.any?(participant_ids, fn participant_id ->
|
||||
Actors.is_member?(current_actor_id, participant_id) and
|
||||
attributed_to_id == participant_id
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,9 +2,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
|||
@moduledoc """
|
||||
Handles the participation-related GraphQL calls.
|
||||
"""
|
||||
# alias Mobilizon.Conversations.ConversationParticipant
|
||||
alias Mobilizon.{Actors, Config, Crypto, Events}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.{Conversation, ConversationView}
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.GraphQL.API.Comments
|
||||
alias Mobilizon.GraphQL.API.Participations
|
||||
alias Mobilizon.Service.Export.Participants.{CSV, ODS, PDF}
|
||||
alias Mobilizon.Users.User
|
||||
|
@ -346,6 +349,60 @@ defmodule Mobilizon.GraphQL.Resolvers.Participant do
|
|||
|
||||
def export_event_participants(_, _, _), do: {:error, :unauthorized}
|
||||
|
||||
def send_private_messages_to_participants(
|
||||
_parent,
|
||||
%{roles: roles, event_id: event_id, actor_id: actor_id} =
|
||||
args,
|
||||
%{
|
||||
context: %{
|
||||
current_user: %User{locale: _locale},
|
||||
current_actor: %Actor{id: current_actor_id}
|
||||
}
|
||||
}
|
||||
) do
|
||||
participant_actors =
|
||||
event_id
|
||||
|> Events.list_all_participants_for_event(roles)
|
||||
|> Enum.map(& &1.actor)
|
||||
|
||||
mentions =
|
||||
participant_actors
|
||||
|> Enum.map(& &1.id)
|
||||
|> Enum.uniq()
|
||||
|> Enum.map(&%{actor_id: &1, event_id: event_id})
|
||||
|
||||
args =
|
||||
Map.merge(args, %{
|
||||
mentions: mentions,
|
||||
visibility: :private
|
||||
})
|
||||
|
||||
with {:member, true} <-
|
||||
{:member,
|
||||
current_actor_id == actor_id or Actors.is_member?(current_actor_id, actor_id)},
|
||||
{:ok, _activity, %Conversation{} = conversation} <- Comments.create_conversation(args) do
|
||||
{:ok, conversation_to_view(conversation, Actors.get_actor(actor_id))}
|
||||
else
|
||||
{:member, false} ->
|
||||
{:error, :unauthorized}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
def send_private_messages_to_participants(_parent, _args, _resolution),
|
||||
do: {:error, :unauthorized}
|
||||
|
||||
defp conversation_to_view(%Conversation{} = conversation, %Actor{} = actor) do
|
||||
value =
|
||||
conversation
|
||||
|> Map.from_struct()
|
||||
|> Map.put(:actor, actor)
|
||||
|
||||
struct(ConversationView, value)
|
||||
end
|
||||
|
||||
@spec valid_email?(String.t() | nil) :: boolean
|
||||
defp valid_email?(email) when is_nil(email), do: false
|
||||
|
||||
|
|
|
@ -55,6 +55,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_types(Schema.Users.ActivitySetting)
|
||||
import_types(Schema.FollowedGroupActivityType)
|
||||
import_types(Schema.AuthApplicationType)
|
||||
import_types(Schema.ConversationType)
|
||||
|
||||
@desc "A struct containing the id of the deleted object"
|
||||
object :deleted_object do
|
||||
|
@ -165,6 +166,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_fields(:todo_list_queries)
|
||||
import_fields(:todo_queries)
|
||||
import_fields(:discussion_queries)
|
||||
import_fields(:conversation_queries)
|
||||
import_fields(:resource_queries)
|
||||
import_fields(:post_queries)
|
||||
import_fields(:statistics_queries)
|
||||
|
@ -189,6 +191,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
import_fields(:todo_list_mutations)
|
||||
import_fields(:todo_mutations)
|
||||
import_fields(:discussion_mutations)
|
||||
import_fields(:conversation_mutations)
|
||||
import_fields(:resource_mutations)
|
||||
import_fields(:post_mutations)
|
||||
import_fields(:actor_mutations)
|
||||
|
@ -204,6 +207,7 @@ defmodule Mobilizon.GraphQL.Schema do
|
|||
subscription do
|
||||
import_fields(:person_subscriptions)
|
||||
import_fields(:discussion_subscriptions)
|
||||
import_fields(:conversation_subscriptions)
|
||||
end
|
||||
|
||||
@spec middleware(list(module()), any(), map()) :: list(module())
|
||||
|
|
|
@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
|||
import Absinthe.Resolution.Helpers, only: [dataloader: 2]
|
||||
|
||||
alias Mobilizon.Events
|
||||
alias Mobilizon.GraphQL.Resolvers.{Media, Person}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Conversation, Media, Person}
|
||||
alias Mobilizon.GraphQL.Schema
|
||||
|
||||
import_types(Schema.Events.FeedTokenType)
|
||||
|
@ -136,6 +136,25 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
|||
arg(:limit, :integer, default_value: 10, description: "The limit of follows per page")
|
||||
resolve(&Person.person_follows/3)
|
||||
end
|
||||
|
||||
@desc "The list of conversations this person has"
|
||||
field(:conversations, :paginated_conversation_list,
|
||||
meta: [private: true, rule: :"read:profile:conversations"]
|
||||
) do
|
||||
arg(:page, :integer,
|
||||
default_value: 1,
|
||||
description: "The page in the conversations list"
|
||||
)
|
||||
|
||||
arg(:limit, :integer, default_value: 10, description: "The limit of conversations per page")
|
||||
resolve(&Conversation.list_conversations/3)
|
||||
end
|
||||
|
||||
field(:unread_conversations_count, :integer,
|
||||
meta: [private: true, rule: :"read:profile:conversations"]
|
||||
) do
|
||||
resolve(&Conversation.unread_conversations_count/3)
|
||||
end
|
||||
end
|
||||
|
||||
@desc """
|
||||
|
@ -353,5 +372,16 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
|
|||
{:ok, topic: [args.group, args.person_id]}
|
||||
end)
|
||||
end
|
||||
|
||||
@desc "Notify when a person unread conversations count changed"
|
||||
field(:person_unread_conversations_count, :integer,
|
||||
meta: [private: true, rule: :"read:profile:conversations"]
|
||||
) do
|
||||
arg(:person_id, non_null(:id), description: "The person's ID")
|
||||
|
||||
config(fn args, _ ->
|
||||
{:ok, topic: [args.person_id]}
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
132
lib/graphql/schema/conversation.ex
Normal file
132
lib/graphql/schema/conversation.ex
Normal file
|
@ -0,0 +1,132 @@
|
|||
defmodule Mobilizon.GraphQL.Schema.ConversationType do
|
||||
@moduledoc """
|
||||
Schema representation for conversation
|
||||
"""
|
||||
use Absinthe.Schema.Notation
|
||||
|
||||
# import Absinthe.Resolution.Helpers, only: [dataloader: 1]
|
||||
|
||||
# alias Mobilizon.Actors
|
||||
alias Mobilizon.GraphQL.Resolvers.Conversation
|
||||
|
||||
@desc "A conversation"
|
||||
object :conversation do
|
||||
meta(:authorize, :user)
|
||||
interfaces([:activity_object])
|
||||
field(:id, :id, description: "Internal ID for this conversation")
|
||||
|
||||
field(:conversation_participant_id, :id,
|
||||
description: "Internal ID for the conversation participant"
|
||||
)
|
||||
|
||||
field(:last_comment, :comment, description: "The last comment of the conversation")
|
||||
|
||||
field :comments, :paginated_comment_list do
|
||||
arg(:page, :integer, default_value: 1)
|
||||
arg(:limit, :integer, default_value: 10)
|
||||
resolve(&Conversation.get_comments_for_conversation/3)
|
||||
description("The comments for the conversation")
|
||||
end
|
||||
|
||||
field(:participants, list_of(:person),
|
||||
# resolve: dataloader(Actors),
|
||||
description: "The list of participants to the conversation"
|
||||
)
|
||||
|
||||
field(:event, :event, description: "The event this conversation is associated to")
|
||||
|
||||
field(:actor, :person,
|
||||
# resolve: dataloader(Actors),
|
||||
description: "The actor concerned by the conversation"
|
||||
)
|
||||
|
||||
field(:unread, :boolean, description: "Whether this conversation is unread")
|
||||
|
||||
field(:inserted_at, :datetime, description: "When was this conversation's created")
|
||||
field(:updated_at, :datetime, description: "When was this conversation's updated")
|
||||
end
|
||||
|
||||
@desc "A paginated list of conversations"
|
||||
object :paginated_conversation_list do
|
||||
meta(:authorize, :user)
|
||||
field(:elements, list_of(:conversation), description: "A list of conversations")
|
||||
field(:total, :integer, description: "The total number of conversations in the list")
|
||||
end
|
||||
|
||||
object :conversation_queries do
|
||||
@desc "Get a conversation"
|
||||
field :conversation, type: :conversation do
|
||||
arg(:id, :id, description: "The conversation's ID")
|
||||
|
||||
middleware(Rajska.QueryAuthorization,
|
||||
permit: :user,
|
||||
scope: Mobilizon.Conversations.Conversation,
|
||||
rule: :"read:conversations",
|
||||
args: %{id: :id}
|
||||
)
|
||||
|
||||
resolve(&Conversation.get_conversation/3)
|
||||
end
|
||||
end
|
||||
|
||||
object :conversation_mutations do
|
||||
@desc "Post a private message"
|
||||
field :post_private_message, type: :conversation do
|
||||
arg(:text, non_null(:string), description: "The conversation's first comment body")
|
||||
arg(:actor_id, non_null(:id), description: "The profile ID to create the conversation as")
|
||||
arg(:attributed_to_id, :id, description: "The group ID to attribute the conversation to")
|
||||
arg(:conversation_id, :id, description: "The conversation ID to reply to")
|
||||
arg(:language, :string, description: "The comment language", default_value: "und")
|
||||
arg(:mentions, list_of(:string), description: "A list of federated usernames to mention")
|
||||
|
||||
middleware(Rajska.QueryAuthorization,
|
||||
permit: :user,
|
||||
scope: Mobilizon.Conversations.ConversationParticipant,
|
||||
rule: :"write:conversation:create",
|
||||
args: %{actor_id: :actor_id}
|
||||
)
|
||||
|
||||
resolve(&Conversation.create_conversation/3)
|
||||
end
|
||||
|
||||
@desc "Update a conversation"
|
||||
field :update_conversation, type: :conversation do
|
||||
arg(:conversation_id, non_null(:id), description: "The conversation's ID")
|
||||
arg(:read, non_null(:boolean), description: "Whether the conversation is read or not")
|
||||
|
||||
middleware(Rajska.QueryAuthorization,
|
||||
permit: :user,
|
||||
scope: Mobilizon.Conversations.Conversation,
|
||||
rule: :"write:conversation:update",
|
||||
args: %{id: :conversation_id}
|
||||
)
|
||||
|
||||
resolve(&Conversation.update_conversation/3)
|
||||
end
|
||||
|
||||
@desc "Delete a conversation"
|
||||
field :delete_conversation, type: :conversation do
|
||||
arg(:conversation_id, non_null(:id), description: "The conversation's ID")
|
||||
|
||||
middleware(Rajska.QueryAuthorization,
|
||||
permit: :user,
|
||||
scope: Mobilizon.Conversations.Conversation,
|
||||
rule: :"write:conversation:delete",
|
||||
args: %{id: :conversation_id}
|
||||
)
|
||||
|
||||
resolve(&Conversation.delete_conversation/3)
|
||||
end
|
||||
end
|
||||
|
||||
object :conversation_subscriptions do
|
||||
@desc "Notify when a conversation changed"
|
||||
field :conversation_comment_changed, :conversation do
|
||||
arg(:id, non_null(:id), description: "The conversation's ID")
|
||||
|
||||
config(fn args, _ ->
|
||||
{:ok, topic: args.id}
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -56,6 +56,8 @@ defmodule Mobilizon.GraphQL.Schema.Discussions.CommentType do
|
|||
description: "Whether this comment needs to be announced to participants"
|
||||
)
|
||||
|
||||
field(:conversation, :conversation, description: "The conversation this comment is part of")
|
||||
|
||||
field(:language, :string, description: "The comment language")
|
||||
end
|
||||
|
||||
|
|
|
@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
|
|||
import Absinthe.Resolution.Helpers, only: [dataloader: 1, dataloader: 2]
|
||||
|
||||
alias Mobilizon.{Actors, Addresses, Discussions}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Event, Media, Tag}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Conversation, Event, Media, Tag}
|
||||
alias Mobilizon.GraphQL.Schema
|
||||
|
||||
import_types(Schema.AddressType)
|
||||
|
@ -113,6 +113,18 @@ defmodule Mobilizon.GraphQL.Schema.EventType do
|
|||
field(:options, :event_options, description: "The event options")
|
||||
field(:metadata, list_of(:event_metadata), description: "A key-value list of metadata")
|
||||
field(:language, :string, description: "The event language")
|
||||
|
||||
field(:conversations, :paginated_conversation_list,
|
||||
description: "The list of conversations started on this event"
|
||||
) do
|
||||
arg(:page, :integer,
|
||||
default_value: 1,
|
||||
description: "The page in the paginated conversation list"
|
||||
)
|
||||
|
||||
arg(:limit, :integer, default_value: 10, description: "The limit of conversations per page")
|
||||
resolve(&Conversation.find_conversations_for_event/3)
|
||||
end
|
||||
end
|
||||
|
||||
@desc "The list of visibility options for an event"
|
||||
|
|
|
@ -159,5 +159,34 @@ defmodule Mobilizon.GraphQL.Schema.Events.ParticipantType do
|
|||
|
||||
resolve(&Participant.export_event_participants/3)
|
||||
end
|
||||
|
||||
@desc "Send private messages to participants"
|
||||
field :send_event_private_message, :conversation do
|
||||
arg(:event_id, non_null(:id),
|
||||
description: "The ID from the event for which to export participants"
|
||||
)
|
||||
|
||||
arg(:roles, list_of(:participant_role_enum),
|
||||
default_value: [],
|
||||
description: "The participant roles to include"
|
||||
)
|
||||
|
||||
arg(:text, non_null(:string), description: "The private message body")
|
||||
|
||||
arg(:actor_id, non_null(:id),
|
||||
description: "The profile ID to create the private message as"
|
||||
)
|
||||
|
||||
arg(:language, :string, description: "The private message language", default_value: "und")
|
||||
|
||||
middleware(Rajska.QueryAuthorization,
|
||||
permit: :user,
|
||||
scope: Mobilizon.Events.Event,
|
||||
rule: :"write:event:participants:private_message",
|
||||
args: %{id: :event_id}
|
||||
)
|
||||
|
||||
resolve(&Participant.send_private_messages_to_participants/3)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
|
|||
|
||||
alias Mobilizon.Events
|
||||
alias Mobilizon.GraphQL.Resolvers.Application, as: ApplicationResolver
|
||||
alias Mobilizon.GraphQL.Resolvers.{Media, User}
|
||||
alias Mobilizon.GraphQL.Resolvers.{Conversation, Media, User}
|
||||
alias Mobilizon.GraphQL.Resolvers.Users.ActivitySettings
|
||||
alias Mobilizon.GraphQL.Schema
|
||||
|
||||
|
@ -191,6 +191,19 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
|
|||
) do
|
||||
resolve(&ApplicationResolver.get_user_applications/3)
|
||||
end
|
||||
|
||||
@desc "The list of conversations this person has"
|
||||
field(:conversations, :paginated_conversation_list,
|
||||
meta: [private: true, rule: :"read:profile:conversations"]
|
||||
) do
|
||||
arg(:page, :integer,
|
||||
default_value: 1,
|
||||
description: "The page in the conversations list"
|
||||
)
|
||||
|
||||
arg(:limit, :integer, default_value: 10, description: "The limit of conversations per page")
|
||||
resolve(&Conversation.list_conversations/3)
|
||||
end
|
||||
end
|
||||
|
||||
@desc "The list of roles an user can have"
|
||||
|
|
|
@ -17,10 +17,24 @@ defmodule Mobilizon.Activities do
|
|||
very_high: 50
|
||||
)
|
||||
|
||||
@activity_types ["event", "post", "discussion", "resource", "group", "member", "comment"]
|
||||
@activity_types [
|
||||
"event",
|
||||
"post",
|
||||
"conversation",
|
||||
"discussion",
|
||||
"resource",
|
||||
"group",
|
||||
"member",
|
||||
"comment"
|
||||
]
|
||||
@event_activity_subjects ["event_created", "event_updated", "event_deleted", "comment_posted"]
|
||||
@participant_activity_subjects ["event_new_participation"]
|
||||
@post_activity_subjects ["post_created", "post_updated", "post_deleted"]
|
||||
@conversation_activity_subjects [
|
||||
"conversation_created",
|
||||
"conversation_replied",
|
||||
"conversation_event_announcement"
|
||||
]
|
||||
@discussion_activity_subjects [
|
||||
"discussion_created",
|
||||
"discussion_replied",
|
||||
|
@ -49,6 +63,7 @@ defmodule Mobilizon.Activities do
|
|||
@settings_activity_subjects ["group_created", "group_updated"]
|
||||
|
||||
@subjects @event_activity_subjects ++
|
||||
@conversation_activity_subjects ++
|
||||
@participant_activity_subjects ++
|
||||
@post_activity_subjects ++
|
||||
@discussion_activity_subjects ++
|
||||
|
@ -61,6 +76,7 @@ defmodule Mobilizon.Activities do
|
|||
"actor",
|
||||
"post",
|
||||
"discussion",
|
||||
"conversation",
|
||||
"resource",
|
||||
"member",
|
||||
"group",
|
||||
|
|
|
@ -10,6 +10,7 @@ defmodule Mobilizon.Actors.Actor do
|
|||
alias Mobilizon.{Actors, Addresses, Config, Crypto, Mention, Share}
|
||||
alias Mobilizon.Actors.{ActorOpenness, ActorType, ActorVisibility, Follower, Member}
|
||||
alias Mobilizon.Addresses.Address
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.{Event, FeedToken, Participant}
|
||||
alias Mobilizon.Medias.File
|
||||
|
@ -196,6 +197,11 @@ defmodule Mobilizon.Actors.Actor do
|
|||
has_many(:owner_shares, Share, foreign_key: :owner_actor_id)
|
||||
many_to_many(:memberships, __MODULE__, join_through: Member)
|
||||
|
||||
many_to_many(:conversations, Conversation,
|
||||
join_through: "conversation_participants",
|
||||
join_keys: [conversation_id: :id, participant_id: :id]
|
||||
)
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
|
|
57
lib/mobilizon/conversations/conversation.ex
Normal file
57
lib/mobilizon/conversations/conversation.ex
Normal file
|
@ -0,0 +1,57 @@
|
|||
defmodule Mobilizon.Conversations.Conversation do
|
||||
@moduledoc """
|
||||
Represents a conversation
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Ecto.Changeset
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.ConversationParticipant
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
id: String.t(),
|
||||
origin_comment: Comment.t(),
|
||||
last_comment: Comment.t(),
|
||||
participants: list(Actor.t())
|
||||
}
|
||||
|
||||
@required_attrs [:origin_comment_id, :last_comment_id]
|
||||
@optional_attrs [:event_id]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
schema "conversations" do
|
||||
belongs_to(:origin_comment, Comment)
|
||||
belongs_to(:last_comment, Comment)
|
||||
belongs_to(:event, Event)
|
||||
has_many(:comments, Comment)
|
||||
|
||||
many_to_many(:participants, Actor,
|
||||
join_through: ConversationParticipant,
|
||||
join_keys: [conversation_id: :id, actor_id: :id],
|
||||
on_replace: :delete
|
||||
)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def changeset(%__MODULE__{} = conversation, attrs) do
|
||||
conversation
|
||||
|> cast(attrs, @attrs)
|
||||
|> maybe_set_participants(attrs)
|
||||
|> validate_required(@required_attrs)
|
||||
end
|
||||
|
||||
defp maybe_set_participants(%Changeset{} = changeset, %{participants: participants})
|
||||
when length(participants) > 0 do
|
||||
put_assoc(changeset, :participants, participants)
|
||||
end
|
||||
|
||||
defp maybe_set_participants(%Changeset{} = changeset, _), do: changeset
|
||||
end
|
40
lib/mobilizon/conversations/conversation_participant.ex
Normal file
40
lib/mobilizon/conversations/conversation_participant.ex
Normal file
|
@ -0,0 +1,40 @@
|
|||
defmodule Mobilizon.Conversations.ConversationParticipant do
|
||||
@moduledoc """
|
||||
Represents a conversation participant
|
||||
"""
|
||||
|
||||
use Ecto.Schema
|
||||
|
||||
import Ecto.Changeset
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
|
||||
@type t :: %__MODULE__{
|
||||
conversation: Conversation.t(),
|
||||
actor: Actor.t(),
|
||||
unread: boolean()
|
||||
}
|
||||
|
||||
@required_attrs [:actor_id, :conversation_id]
|
||||
@optional_attrs [:unread]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
schema "conversation_participants" do
|
||||
belongs_to(:conversation, Conversation)
|
||||
belongs_to(:actor, Actor)
|
||||
field(:unread, :boolean, default: true)
|
||||
|
||||
timestamps(type: :utc_datetime)
|
||||
end
|
||||
|
||||
@doc false
|
||||
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def changeset(%__MODULE__{} = conversation, attrs) do
|
||||
conversation
|
||||
|> cast(attrs, @attrs)
|
||||
|> validate_required(@required_attrs)
|
||||
|> foreign_key_constraint(:conversation_id)
|
||||
|> foreign_key_constraint(:actor_id)
|
||||
end
|
||||
end
|
22
lib/mobilizon/conversations/conversation_view.ex
Normal file
22
lib/mobilizon/conversations/conversation_view.ex
Normal file
|
@ -0,0 +1,22 @@
|
|||
defmodule Mobilizon.Conversations.ConversationView do
|
||||
@moduledoc """
|
||||
Represents a conversation view for GraphQL API
|
||||
"""
|
||||
|
||||
defstruct [
|
||||
:id,
|
||||
:conversation_participant_id,
|
||||
:origin_comment,
|
||||
:origin_comment_id,
|
||||
:last_comment,
|
||||
:last_comment_id,
|
||||
:event,
|
||||
:event_id,
|
||||
:actor,
|
||||
:actor_id,
|
||||
:unread,
|
||||
:inserted_at,
|
||||
:updated_at,
|
||||
:participants
|
||||
]
|
||||
end
|
344
lib/mobilizon/conversations/conversations.ex
Normal file
344
lib/mobilizon/conversations/conversations.ex
Normal file
|
@ -0,0 +1,344 @@
|
|||
defmodule Mobilizon.Conversations do
|
||||
@moduledoc """
|
||||
The conversations context
|
||||
"""
|
||||
|
||||
import Ecto.Query
|
||||
|
||||
alias Ecto.Changeset
|
||||
alias Ecto.Multi
|
||||
alias Mobilizon.Actors.{Actor, Member}
|
||||
alias Mobilizon.Conversations.{Conversation, ConversationParticipant}
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Storage.{Page, Repo}
|
||||
|
||||
@conversation_preloads [
|
||||
:origin_comment,
|
||||
:last_comment,
|
||||
:event,
|
||||
:participants
|
||||
]
|
||||
|
||||
@comment_preloads [
|
||||
:actor,
|
||||
:event,
|
||||
:attributed_to,
|
||||
:in_reply_to_comment,
|
||||
:origin_comment,
|
||||
:replies,
|
||||
:tags,
|
||||
:mentions,
|
||||
:media
|
||||
]
|
||||
|
||||
@doc """
|
||||
Get a conversation by it's ID
|
||||
"""
|
||||
@spec get_conversation(String.t() | integer()) :: Conversation.t() | nil
|
||||
def get_conversation(conversation_id) do
|
||||
Conversation
|
||||
|> Repo.get(conversation_id)
|
||||
|> Repo.preload(@conversation_preloads)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a conversation by it's ID
|
||||
"""
|
||||
@spec get_conversation_participant(String.t() | integer()) :: Conversation.t() | nil
|
||||
def get_conversation_participant(conversation_participant_id) do
|
||||
preload_conversation_participant_details()
|
||||
|> where([cp], cp.id == ^conversation_participant_id)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
def get_participant_by_conversation_and_actor(conversation_id, actor_id) do
|
||||
preload_conversation_participant_details()
|
||||
|> where([cp], cp.conversation_id == ^conversation_id and cp.actor_id == ^actor_id)
|
||||
|> Repo.one()
|
||||
end
|
||||
|
||||
defp preload_conversation_participant_details do
|
||||
ConversationParticipant
|
||||
|> join(:inner, [cp], c in Conversation, on: cp.conversation_id == c.id)
|
||||
|> join(:left, [_cp, c], e in Event, on: c.event_id == e.id)
|
||||
|> join(:inner, [cp], a in Actor, on: cp.actor_id == a.id)
|
||||
|> join(:inner, [_cp, c], lc in Comment, on: c.last_comment_id == lc.id)
|
||||
|> join(:inner, [_cp, c], oc in Comment, on: c.origin_comment_id == oc.id)
|
||||
|> join(:inner, [_cp, c], p in ConversationParticipant, on: c.id == p.conversation_id)
|
||||
|> join(:inner, [_cp, _c, _e, _a, _lc, _oc, p], ap in Actor, on: p.actor_id == ap.id)
|
||||
|> preload([_cp, c, e, a, lc, oc, p, ap],
|
||||
actor: a,
|
||||
conversation:
|
||||
{c, event: e, last_comment: lc, origin_comment: oc, participants: {p, actor: ap}}
|
||||
)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get a paginated list of conversations for an actor
|
||||
"""
|
||||
@spec find_conversations_for_actor(Actor.t(), integer | nil, integer | nil) ::
|
||||
Page.t(Conversation.t())
|
||||
def find_conversations_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
|
||||
Conversation
|
||||
|> where([c], c.actor_id == ^actor_id)
|
||||
|> preload(^@conversation_preloads)
|
||||
|> order_by(desc: :updated_at)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@spec find_conversations_for_event(
|
||||
String.t() | integer,
|
||||
String.t() | integer,
|
||||
integer | nil,
|
||||
integer | nil
|
||||
) :: Page.t(ConversationParticipant.t())
|
||||
def find_conversations_for_event(event_id, actor_id, page \\ nil, limit \\ nil) do
|
||||
ConversationParticipant
|
||||
|> join(:inner, [cp], c in Conversation, on: cp.conversation_id == c.id)
|
||||
|> join(:left, [_cp, c], e in Event, on: c.event_id == e.id)
|
||||
|> join(:inner, [cp], a in Actor, on: cp.actor_id == a.id)
|
||||
|> join(:inner, [_cp, c], lc in Comment, on: c.last_comment_id == lc.id)
|
||||
|> join(:inner, [_cp, c], oc in Comment, on: c.origin_comment_id == oc.id)
|
||||
|> join(:inner, [_cp, c], p in ConversationParticipant, on: c.id == p.conversation_id)
|
||||
|> join(:inner, [_cp, _c, _e, _a, _lc, _oc, p], ap in Actor, on: p.actor_id == ap.id)
|
||||
|> where([_cp, c], c.event_id == ^event_id)
|
||||
|> where([cp], cp.actor_id == ^actor_id)
|
||||
|> preload([_cp, c, e, a, lc, oc, p, ap],
|
||||
actor: a,
|
||||
conversation:
|
||||
{c, event: e, last_comment: lc, origin_comment: oc, participants: {p, actor: ap}}
|
||||
)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@spec list_conversation_participants_for_actor(
|
||||
integer | String.t(),
|
||||
integer | nil,
|
||||
integer | nil
|
||||
) ::
|
||||
Page.t(ConversationParticipant.t())
|
||||
def list_conversation_participants_for_actor(actor_id, page \\ nil, limit \\ nil) do
|
||||
subquery =
|
||||
ConversationParticipant
|
||||
|> distinct([cp], cp.conversation_id)
|
||||
|> join(:left, [cp], m in Member, on: cp.actor_id == m.parent_id)
|
||||
|> where([cp], cp.actor_id == ^actor_id)
|
||||
|> or_where(
|
||||
[_cp, m],
|
||||
m.actor_id == ^actor_id and m.role in [:creator, :administrator, :moderator]
|
||||
)
|
||||
|
||||
subquery
|
||||
|> subquery()
|
||||
|> order_by([cp], desc: cp.unread, desc: cp.updated_at)
|
||||
|> preload([:actor, conversation: [:last_comment, :participants]])
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@spec list_conversation_participants_for_user(
|
||||
integer | String.t(),
|
||||
integer | nil,
|
||||
integer | nil
|
||||
) ::
|
||||
Page.t(ConversationParticipant.t())
|
||||
def list_conversation_participants_for_user(user_id, page \\ nil, limit \\ nil) do
|
||||
ConversationParticipant
|
||||
|> join(:inner, [cp], a in Actor, on: cp.actor_id == a.id)
|
||||
|> where([_cp, a], a.user_id == ^user_id)
|
||||
|> preload([:actor, conversation: [:last_comment, :participants]])
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@spec list_conversation_participants_for_conversation(integer | String.t()) ::
|
||||
list(ConversationParticipant.t())
|
||||
def list_conversation_participants_for_conversation(conversation_id) do
|
||||
ConversationParticipant
|
||||
|> where([cp], cp.conversation_id == ^conversation_id)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@spec count_unread_conversation_participants_for_person(integer | String.t()) ::
|
||||
non_neg_integer()
|
||||
def count_unread_conversation_participants_for_person(actor_id) do
|
||||
ConversationParticipant
|
||||
|> where([cp], cp.actor_id == ^actor_id and cp.unread == true)
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates a conversation.
|
||||
"""
|
||||
@spec create_conversation(map()) ::
|
||||
{:ok, Conversation.t()} | {:error, atom(), Changeset.t(), map()}
|
||||
def create_conversation(attrs) do
|
||||
with {:ok, %{comment: %Comment{} = _comment, conversation: %Conversation{} = conversation}} <-
|
||||
Multi.new()
|
||||
|> Multi.insert(
|
||||
:comment,
|
||||
Comment.changeset(
|
||||
%Comment{},
|
||||
Map.merge(attrs, %{
|
||||
actor_id: attrs.actor_id,
|
||||
attributed_to_id: attrs.actor_id,
|
||||
visibility: :private
|
||||
})
|
||||
)
|
||||
)
|
||||
|> Multi.insert(:conversation, fn %{
|
||||
comment: %Comment{
|
||||
id: comment_id,
|
||||
origin_comment_id: origin_comment_id
|
||||
}
|
||||
} ->
|
||||
Conversation.changeset(
|
||||
%Conversation{},
|
||||
Map.merge(attrs, %{
|
||||
last_comment_id: comment_id,
|
||||
origin_comment_id: origin_comment_id || comment_id,
|
||||
participants: attrs.participants
|
||||
})
|
||||
)
|
||||
end)
|
||||
|> Multi.update(:update_comment, fn %{
|
||||
comment: %Comment{} = comment,
|
||||
conversation: %Conversation{id: conversation_id}
|
||||
} ->
|
||||
Comment.changeset(
|
||||
comment,
|
||||
%{conversation_id: conversation_id}
|
||||
)
|
||||
end)
|
||||
|> Multi.update_all(
|
||||
:conversation_participants,
|
||||
fn %{
|
||||
conversation: %Conversation{
|
||||
id: conversation_id
|
||||
}
|
||||
} ->
|
||||
ConversationParticipant
|
||||
|> where(
|
||||
[cp],
|
||||
cp.conversation_id == ^conversation_id and cp.actor_id == ^attrs.actor_id
|
||||
)
|
||||
|> update([cp], set: [unread: false, updated_at: ^NaiveDateTime.utc_now()])
|
||||
end,
|
||||
[]
|
||||
)
|
||||
|> Repo.transaction(),
|
||||
%Conversation{} = conversation <- Repo.preload(conversation, @conversation_preloads) do
|
||||
{:ok, conversation}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Create a response to a conversation
|
||||
"""
|
||||
@spec reply_to_conversation(Conversation.t(), map()) ::
|
||||
{:ok, Conversation.t()} | {:error, atom(), Ecto.Changeset.t(), map()}
|
||||
def reply_to_conversation(%Conversation{id: conversation_id} = conversation, attrs \\ %{}) do
|
||||
attrs =
|
||||
Map.merge(attrs, %{
|
||||
conversation_id: conversation_id,
|
||||
actor_id: Map.get(attrs, :creator_id, Map.get(attrs, :actor_id)),
|
||||
origin_comment_id: conversation.origin_comment_id,
|
||||
in_reply_to_comment_id: conversation.last_comment_id,
|
||||
visibility: :private
|
||||
})
|
||||
|
||||
changeset =
|
||||
Comment.changeset(
|
||||
%Comment{},
|
||||
attrs
|
||||
)
|
||||
|
||||
with {:ok, %{comment: %Comment{} = comment, conversation: %Conversation{} = conversation}} <-
|
||||
Multi.new()
|
||||
|> Multi.insert(
|
||||
:comment,
|
||||
changeset
|
||||
)
|
||||
|> Multi.update(:conversation, fn %{comment: %Comment{id: comment_id}} ->
|
||||
Conversation.changeset(
|
||||
conversation,
|
||||
%{last_comment_id: comment_id}
|
||||
)
|
||||
end)
|
||||
|> Multi.update_all(
|
||||
:conversation_participants,
|
||||
fn %{
|
||||
conversation: %Conversation{
|
||||
id: conversation_id
|
||||
}
|
||||
} ->
|
||||
ConversationParticipant
|
||||
|> where(
|
||||
[cp],
|
||||
cp.conversation_id == ^conversation_id and cp.actor_id != ^attrs.actor_id
|
||||
)
|
||||
|> update([cp], set: [unread: true, updated_at: ^NaiveDateTime.utc_now()])
|
||||
end,
|
||||
[]
|
||||
)
|
||||
|> Multi.update_all(
|
||||
:conversation_participants_author,
|
||||
fn %{
|
||||
conversation: %Conversation{
|
||||
id: conversation_id
|
||||
}
|
||||
} ->
|
||||
ConversationParticipant
|
||||
|> where(
|
||||
[cp],
|
||||
cp.conversation_id == ^conversation_id and cp.actor_id == ^attrs.actor_id
|
||||
)
|
||||
|> update([cp], set: [unread: false, updated_at: ^NaiveDateTime.utc_now()])
|
||||
end,
|
||||
[]
|
||||
)
|
||||
|> Repo.transaction(),
|
||||
# Conversation is not updated
|
||||
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
|
||||
{:ok, %Conversation{conversation | last_comment: comment}}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Update a conversation.
|
||||
"""
|
||||
@spec update_conversation(Conversation.t(), map()) ::
|
||||
{:ok, Conversation.t()} | {:error, Changeset.t()}
|
||||
def update_conversation(%Conversation{} = conversation, attrs \\ %{}) do
|
||||
conversation
|
||||
|> Conversation.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Delete a conversation.
|
||||
"""
|
||||
@spec delete_conversation(Conversation.t()) ::
|
||||
{:ok, %{comments: {integer() | nil, any()}}} | {:error, :comments, Changeset.t(), map()}
|
||||
def delete_conversation(%Conversation{id: conversation_id}) do
|
||||
Multi.new()
|
||||
|> Multi.delete_all(:comments, fn _ ->
|
||||
where(Comment, [c], c.conversation_id == ^conversation_id)
|
||||
end)
|
||||
# |> Multi.delete(:conversation, conversation)
|
||||
|> Repo.transaction()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Update a conversation participant. Only their read status for now
|
||||
"""
|
||||
@spec update_conversation_participant(ConversationParticipant.t(), map()) ::
|
||||
{:ok, ConversationParticipant.t()} | {:error, Changeset.t()}
|
||||
def update_conversation_participant(
|
||||
%ConversationParticipant{} = conversation_participant,
|
||||
attrs \\ %{}
|
||||
) do
|
||||
conversation_participant
|
||||
|> ConversationParticipant.changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
|
@ -9,6 +9,7 @@ defmodule Mobilizon.Discussions.Comment do
|
|||
import Mobilizon.Storage.Ecto, only: [maybe_add_published_at: 1]
|
||||
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.{Comment, CommentVisibility, Discussion}
|
||||
alias Mobilizon.Events.{Event, Tag}
|
||||
alias Mobilizon.Medias.Media
|
||||
|
@ -49,7 +50,9 @@ defmodule Mobilizon.Discussions.Comment do
|
|||
:local,
|
||||
:is_announcement,
|
||||
:discussion_id,
|
||||
:language
|
||||
:conversation_id,
|
||||
:language,
|
||||
:visibility
|
||||
]
|
||||
@attrs @required_attrs ++ @optional_attrs
|
||||
|
||||
|
@ -71,6 +74,7 @@ defmodule Mobilizon.Discussions.Comment do
|
|||
belongs_to(:in_reply_to_comment, Comment, foreign_key: :in_reply_to_comment_id)
|
||||
belongs_to(:origin_comment, Comment, foreign_key: :origin_comment_id)
|
||||
belongs_to(:discussion, Discussion, type: :binary_id)
|
||||
belongs_to(:conversation, Conversation)
|
||||
has_many(:replies, Comment, foreign_key: :origin_comment_id)
|
||||
many_to_many(:tags, Tag, join_through: "comments_tags", on_replace: :delete)
|
||||
has_many(:mentions, Mention)
|
||||
|
@ -80,7 +84,7 @@ defmodule Mobilizon.Discussions.Comment do
|
|||
end
|
||||
|
||||
@doc """
|
||||
Returns the id of the first comment in the discussion.
|
||||
Returns the id of the first comment in the discussion or conversation.
|
||||
"""
|
||||
@spec get_thread_id(t) :: integer
|
||||
def get_thread_id(%__MODULE__{id: id, origin_comment_id: origin_comment_id}) do
|
||||
|
@ -181,7 +185,7 @@ defmodule Mobilizon.Discussions.Comment do
|
|||
Tag.changeset(%Tag{}, tag)
|
||||
end
|
||||
|
||||
defp process_mention(tag) do
|
||||
Mention.changeset(%Mention{}, tag)
|
||||
defp process_mention(mention) do
|
||||
Mention.changeset(%Mention{}, mention)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -42,9 +42,9 @@ defmodule Mobilizon.Discussions do
|
|||
:origin_comment,
|
||||
:replies,
|
||||
:tags,
|
||||
:mentions,
|
||||
:discussion,
|
||||
:media
|
||||
:media,
|
||||
mentions: [:actor]
|
||||
]
|
||||
|
||||
@discussion_preloads [
|
||||
|
@ -76,6 +76,7 @@ defmodule Mobilizon.Discussions do
|
|||
Comment
|
||||
|> join(:left, [c], r in Comment, on: r.origin_comment_id == c.id)
|
||||
|> where([c, _], is_nil(c.in_reply_to_comment_id))
|
||||
|> where([c], c.visibility in ^@public_visibility)
|
||||
# TODO: This was added because we don't want to count deleted comments in total_replies.
|
||||
# However, it also excludes all top-level comments with deleted replies from being selected
|
||||
# |> where([_, r], is_nil(r.deleted_at))
|
||||
|
@ -197,9 +198,13 @@ defmodule Mobilizon.Discussions do
|
|||
"""
|
||||
@spec update_comment(Comment.t(), map) :: {:ok, Comment.t()} | {:error, Changeset.t()}
|
||||
def update_comment(%Comment{} = comment, attrs) do
|
||||
with {:ok, %Comment{} = comment} <-
|
||||
comment
|
||||
|> Comment.update_changeset(attrs)
|
||||
|> Repo.update()
|
||||
|> Repo.update(),
|
||||
%Comment{} = comment <- Repo.preload(comment, @comment_preloads) do
|
||||
{:ok, comment}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
@ -272,6 +277,19 @@ defmodule Mobilizon.Discussions do
|
|||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Get all the comments contained into a discussion
|
||||
"""
|
||||
@spec get_comments_in_reply_to_comment_id(integer, integer | nil, integer | nil) ::
|
||||
Page.t(Comment.t())
|
||||
def get_comments_in_reply_to_comment_id(origin_comment_id, page \\ nil, limit \\ nil) do
|
||||
Comment
|
||||
|> where([c], c.id == ^origin_comment_id)
|
||||
|> or_where([c], c.origin_comment_id == ^origin_comment_id)
|
||||
|> order_by(asc: :inserted_at)
|
||||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Counts local comments under events
|
||||
"""
|
||||
|
|
|
@ -13,6 +13,7 @@ defmodule Mobilizon.Events.Event do
|
|||
alias Mobilizon.{Addresses, Events, Medias, Mention}
|
||||
alias Mobilizon.Addresses.Address
|
||||
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.Comment
|
||||
|
||||
alias Mobilizon.Events.{
|
||||
|
@ -126,6 +127,7 @@ defmodule Mobilizon.Events.Event do
|
|||
has_many(:sessions, Session)
|
||||
has_many(:mentions, Mention)
|
||||
has_many(:comments, Comment)
|
||||
has_many(:conversations, Conversation)
|
||||
many_to_many(:contacts, Actor, join_through: "event_contacts", on_replace: :delete)
|
||||
many_to_many(:tags, Tag, join_through: "events_tags", on_replace: :delete)
|
||||
many_to_many(:participants, Actor, join_through: Participant)
|
||||
|
|
|
@ -871,6 +871,21 @@ defmodule Mobilizon.Events do
|
|||
|> Page.build_page(page, limit)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns the whole list of participants for an event.
|
||||
Default behaviour is to not return :not_approved or :not_confirmed participants
|
||||
"""
|
||||
@spec list_all_participants_for_event(String.t(), list(atom())) :: list(Participant.t())
|
||||
def list_all_participants_for_event(
|
||||
id,
|
||||
roles \\ []
|
||||
) do
|
||||
id
|
||||
|> participants_for_event_query(roles)
|
||||
|> preload([:actor, :event])
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@spec list_actors_participants_for_event(String.t()) :: [Actor.t()]
|
||||
def list_actors_participants_for_event(id) do
|
||||
id
|
||||
|
|
|
@ -32,8 +32,8 @@ defmodule Mobilizon.Mention do
|
|||
|
||||
@doc false
|
||||
@spec changeset(t | Ecto.Schema.t(), map) :: Ecto.Changeset.t()
|
||||
def changeset(event, attrs) do
|
||||
event
|
||||
def changeset(mention, attrs) do
|
||||
mention
|
||||
|> cast(attrs, @attrs)
|
||||
# TODO: Enforce having either event_id or comment_id
|
||||
|> validate_required(@required_attrs)
|
||||
|
|
|
@ -21,7 +21,14 @@ defmodule Mobilizon.Reports do
|
|||
def get_report(id) do
|
||||
Report
|
||||
|> Repo.get(id)
|
||||
|> Repo.preload([:reported, :reporter, :manager, :events, :comments, :notes])
|
||||
|> Repo.preload([
|
||||
:reported,
|
||||
:reporter,
|
||||
:manager,
|
||||
:events,
|
||||
:notes,
|
||||
comments: [conversation: [:participants]]
|
||||
])
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
90
lib/service/activity/conversation.ex
Normal file
90
lib/service/activity/conversation.ex
Normal file
|
@ -0,0 +1,90 @@
|
|||
defmodule Mobilizon.Service.Activity.Conversation do
|
||||
@moduledoc """
|
||||
Insert a conversation activity
|
||||
"""
|
||||
alias Mobilizon.Conversations
|
||||
alias Mobilizon.Conversations.{Conversation, ConversationParticipant}
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Service.Activity
|
||||
alias Mobilizon.Service.Workers.LegacyNotifierBuilder
|
||||
|
||||
@behaviour Activity
|
||||
|
||||
@impl Activity
|
||||
def insert_activity(conversation, options \\ [])
|
||||
|
||||
def insert_activity(
|
||||
%Conversation{} = conversation,
|
||||
options
|
||||
) do
|
||||
subject = Keyword.fetch!(options, :subject)
|
||||
|
||||
send_participant_notifications(subject, conversation, conversation.last_comment, options)
|
||||
end
|
||||
|
||||
def insert_activity(_, _), do: {:ok, nil}
|
||||
|
||||
@impl Activity
|
||||
def get_object(conversation_id) do
|
||||
Conversations.get_conversation(conversation_id)
|
||||
end
|
||||
|
||||
# An actor is mentionned
|
||||
@spec send_participant_notifications(String.t(), Discussion.t(), Comment.t(), Keyword.t()) ::
|
||||
{:ok, Oban.Job.t()} | {:ok, :skipped}
|
||||
defp send_participant_notifications(
|
||||
subject,
|
||||
%Conversation{
|
||||
id: conversation_id
|
||||
} = conversation,
|
||||
%Comment{actor_id: actor_id},
|
||||
_options
|
||||
)
|
||||
when subject in [
|
||||
"conversation_created",
|
||||
"conversation_replied",
|
||||
"conversation_event_announcement"
|
||||
] do
|
||||
# We need to send each notification individually as the conversation URL varies for each participant
|
||||
|
||||
conversation_id
|
||||
|> Conversations.list_conversation_participants_for_conversation()
|
||||
|> Enum.each(fn %ConversationParticipant{id: conversation_participant_id} =
|
||||
conversation_participant ->
|
||||
LegacyNotifierBuilder.enqueue(
|
||||
:legacy_notify,
|
||||
%{
|
||||
"subject" => subject,
|
||||
"subject_params" =>
|
||||
Map.merge(
|
||||
%{
|
||||
conversation_id: conversation_id,
|
||||
conversation_participant_id: conversation_participant_id
|
||||
},
|
||||
event_subject_params(conversation)
|
||||
),
|
||||
"type" => :conversation,
|
||||
"object_type" => :conversation,
|
||||
"author_id" => actor_id,
|
||||
"object_id" => to_string(conversation_id),
|
||||
"participant" => Map.take(conversation_participant, [:id, :actor_id])
|
||||
}
|
||||
)
|
||||
end)
|
||||
|
||||
{:ok, :enqueued}
|
||||
end
|
||||
|
||||
defp send_participant_notifications(_, _, _, _), do: {:ok, :skipped}
|
||||
|
||||
defp event_subject_params(%Conversation{
|
||||
event: %Event{id: conversation_event_id, title: conversation_event_title}
|
||||
}),
|
||||
do: %{
|
||||
conversation_event_id: conversation_event_id,
|
||||
conversation_event_title: conversation_event_title
|
||||
}
|
||||
|
||||
defp event_subject_params(_), do: %{}
|
||||
end
|
73
lib/service/activity/renderer/conversation.ex
Normal file
73
lib/service/activity/renderer/conversation.ex
Normal file
|
@ -0,0 +1,73 @@
|
|||
defmodule Mobilizon.Service.Activity.Renderer.Conversation do
|
||||
@moduledoc """
|
||||
Render a conversation activity
|
||||
"""
|
||||
alias Mobilizon.Activities.Activity
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Service.Activity.Renderer
|
||||
alias Mobilizon.Web.Endpoint
|
||||
alias Mobilizon.Web.Router.Helpers, as: Routes
|
||||
import Mobilizon.Web.Gettext, only: [dgettext: 3]
|
||||
|
||||
@behaviour Renderer
|
||||
|
||||
@impl Renderer
|
||||
def render(%Activity{} = activity, options) do
|
||||
locale = Keyword.get(options, :locale, "en")
|
||||
Gettext.put_locale(locale)
|
||||
profile = profile(activity)
|
||||
|
||||
case activity.subject do
|
||||
:conversation_created ->
|
||||
%{
|
||||
body:
|
||||
dgettext(
|
||||
"activity",
|
||||
"%{profile} sent you a message",
|
||||
%{
|
||||
profile: profile
|
||||
}
|
||||
),
|
||||
url: conversation_url(activity)
|
||||
}
|
||||
|
||||
:conversation_replied ->
|
||||
%{
|
||||
body:
|
||||
dgettext(
|
||||
"activity",
|
||||
"%{profile} replied to your message",
|
||||
%{
|
||||
profile: profile
|
||||
}
|
||||
),
|
||||
url: conversation_url(activity)
|
||||
}
|
||||
|
||||
:conversation_event_announcement ->
|
||||
%{
|
||||
body:
|
||||
dgettext(
|
||||
"activity",
|
||||
"%{profile} sent a private message about event %{event}",
|
||||
%{
|
||||
profile: profile,
|
||||
event: event_title(activity)
|
||||
}
|
||||
),
|
||||
url: conversation_url(activity)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp conversation_url(activity) do
|
||||
Routes.page_url(
|
||||
Endpoint,
|
||||
:conversation,
|
||||
activity.subject_params["conversation_id"]
|
||||
)
|
||||
end
|
||||
|
||||
defp profile(activity), do: Actor.display_name_and_username(activity.author)
|
||||
defp event_title(activity), do: activity.subject_params["conversation_event_title"]
|
||||
end
|
|
@ -51,17 +51,25 @@ defmodule Mobilizon.Service.Activity.Renderer do
|
|||
res
|
||||
end
|
||||
|
||||
@types_map %{
|
||||
discussion: Discussion,
|
||||
conversation: Conversation,
|
||||
event: Event,
|
||||
group: Group,
|
||||
member: Member,
|
||||
post: Post,
|
||||
resource: Resource,
|
||||
comment: Comment
|
||||
}
|
||||
|
||||
@spec do_render(Activity.t(), Keyword.t()) :: common_render()
|
||||
defp do_render(%Activity{type: type} = activity, options) do
|
||||
case type do
|
||||
:discussion -> Discussion.render(activity, options)
|
||||
:event -> Event.render(activity, options)
|
||||
:group -> Group.render(activity, options)
|
||||
:member -> Member.render(activity, options)
|
||||
:post -> Post.render(activity, options)
|
||||
:resource -> Resource.render(activity, options)
|
||||
:comment -> Comment.render(activity, options)
|
||||
_ -> nil
|
||||
case Map.get(@types_map, type) do
|
||||
nil ->
|
||||
nil
|
||||
|
||||
mod ->
|
||||
mod.render(activity, options)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -70,6 +70,9 @@ defmodule Mobilizon.Service.Notifier.Email do
|
|||
@always_direct_subjects [
|
||||
:participation_event_comment,
|
||||
:event_comment_mention,
|
||||
:conversation_mention,
|
||||
:conversation_created,
|
||||
:conversation_replied,
|
||||
:discussion_mention,
|
||||
:event_new_comment
|
||||
]
|
||||
|
@ -175,6 +178,9 @@ defmodule Mobilizon.Service.Notifier.Email do
|
|||
"member_updated" => false,
|
||||
"user_email_password_updated" => true,
|
||||
"event_comment_mention" => true,
|
||||
"conversation_mention" => true,
|
||||
"conversation_created" => true,
|
||||
"conversation_replied" => true,
|
||||
"discussion_mention" => true,
|
||||
"event_new_comment" => true
|
||||
}
|
||||
|
|
|
@ -33,6 +33,10 @@ defmodule Mobilizon.Service.Notifier.Filter do
|
|||
defp map_activity_to_activity_setting(%Activity{subject: :event_comment_mention}),
|
||||
do: "event_comment_mention"
|
||||
|
||||
defp map_activity_to_activity_setting(%Activity{subject: subject})
|
||||
when subject in [:conversation_mention, :conversation_created, :conversation_replied],
|
||||
do: to_string(subject)
|
||||
|
||||
defp map_activity_to_activity_setting(%Activity{subject: :discussion_mention}),
|
||||
do: "discussion_mention"
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ defmodule Mobilizon.Service.Notifier.Push do
|
|||
"member_updated" => false,
|
||||
"user_email_password_updated" => false,
|
||||
"event_comment_mention" => true,
|
||||
"conversation_mention" => true,
|
||||
"discussion_mention" => false,
|
||||
"event_new_comment" => false
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
|
|||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Events.{Event, Participant}
|
||||
alias Mobilizon.Service.Notifier
|
||||
require Logger
|
||||
|
||||
use Mobilizon.Service.Workers.Helper, queue: "activity"
|
||||
|
||||
|
@ -15,6 +16,7 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
|
|||
def perform(%Job{args: args}) do
|
||||
{"legacy_notify", args} = Map.pop(args, "op")
|
||||
activity = build_activity(args)
|
||||
Logger.debug("Handling activity #{activity.subject} to notify in LegacyNotifierBuilder")
|
||||
|
||||
if args["subject"] == "participation_event_comment" do
|
||||
notify_anonymous_participants(get_in(args, ["subject_params", "event_id"]), activity)
|
||||
|
@ -22,7 +24,7 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
|
|||
|
||||
args
|
||||
|> users_to_notify(author_id: args["author_id"], group_id: Map.get(args, "group_id"))
|
||||
|> Enum.each(&Notifier.notify(&1, activity, single_activity: true))
|
||||
|> Enum.each(¬ify_user(&1, activity))
|
||||
end
|
||||
|
||||
defp build_activity(args) do
|
||||
|
@ -48,6 +50,15 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
|
|||
users_from_actor_ids(mentionned_actor_ids, Keyword.fetch!(options, :author_id))
|
||||
end
|
||||
|
||||
@spec users_to_notify(map(), Keyword.t()) :: list(Users.t())
|
||||
defp users_to_notify(
|
||||
%{"subject" => subject, "participant" => %{"actor_id" => actor_id}},
|
||||
options
|
||||
)
|
||||
when subject in ["conversation_created", "conversation_replied"] do
|
||||
users_from_actor_ids([actor_id], Keyword.fetch!(options, :author_id))
|
||||
end
|
||||
|
||||
defp users_to_notify(
|
||||
%{"subject" => "discussion_mention", "mentions" => mentionned_actor_ids},
|
||||
options
|
||||
|
@ -114,4 +125,9 @@ defmodule Mobilizon.Service.Workers.LegacyNotifierBuilder do
|
|||
)
|
||||
end)
|
||||
end
|
||||
|
||||
defp notify_user(user, activity) do
|
||||
Logger.debug("Notifying #{user.email} for activity #{activity.subject}")
|
||||
Notifier.notify(user, activity, single_activity: true)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -44,7 +44,7 @@ defmodule Mobilizon.Web.Auth.Context do
|
|||
|
||||
context = if is_nil(user_agent), do: context, else: Map.put(context, :user_agent, user_agent)
|
||||
|
||||
put_private(conn, :absinthe, %{context: context})
|
||||
Absinthe.Plug.put_options(conn, context: context)
|
||||
end
|
||||
|
||||
defp set_user_context({conn, context}, %User{id: user_id, email: user_email} = user) do
|
||||
|
|
20
lib/web/cache/activity_pub.ex
vendored
20
lib/web/cache/activity_pub.ex
vendored
|
@ -3,9 +3,10 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
|
|||
ActivityPub related cache.
|
||||
"""
|
||||
|
||||
alias Mobilizon.{Actors, Discussions, Events, Posts, Resources, Todos, Tombstone}
|
||||
alias Mobilizon.{Actors, Conversations, Discussions, Events, Posts, Resources, Todos, Tombstone}
|
||||
alias Mobilizon.Actors.Actor, as: ActorModel
|
||||
alias Mobilizon.Actors.Member
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.{Comment, Discussion}
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Federation.ActivityPub.{Actor, Relay}
|
||||
|
@ -184,6 +185,23 @@ defmodule Mobilizon.Web.Cache.ActivityPub do
|
|||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a conversation participant by it's ID, with all associations loaded.
|
||||
"""
|
||||
@spec get_conversation_by_id_with_preload(String.t()) ::
|
||||
{:commit, Todo.t()} | {:ignore, nil}
|
||||
def get_conversation_by_id_with_preload(id) do
|
||||
Cachex.fetch(@cache, "conversation_participant_" <> id, fn "conversation_participant_" <> id ->
|
||||
case Conversations.get_conversation_participant(id) do
|
||||
%Conversation{} = conversation ->
|
||||
{:commit, conversation}
|
||||
|
||||
nil ->
|
||||
{:ignore, nil}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets a member by its UUID, with all associations loaded.
|
||||
"""
|
||||
|
|
5
lib/web/cache/cache.ex
vendored
5
lib/web/cache/cache.ex
vendored
|
@ -4,6 +4,7 @@ defmodule Mobilizon.Web.Cache do
|
|||
"""
|
||||
|
||||
alias Mobilizon.Actors.{Actor, Member}
|
||||
alias Mobilizon.Conversations.Conversation
|
||||
alias Mobilizon.Discussions.{Comment, Discussion}
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Posts.Post
|
||||
|
@ -27,6 +28,10 @@ defmodule Mobilizon.Web.Cache do
|
|||
defdelegate get_todo_list_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
@spec get_todo_by_uuid_with_preload(binary) :: {:commit, Todo.t()} | {:ignore, nil}
|
||||
defdelegate get_todo_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
|
||||
@spec get_conversation_by_id_with_preload(binary) ::
|
||||
{:commit, Conversation.t()} | {:ignore, nil}
|
||||
defdelegate get_conversation_by_id_with_preload(uuid), to: ActivityPub
|
||||
@spec get_member_by_uuid_with_preload(binary) :: {:commit, Member.t()} | {:ignore, nil}
|
||||
defdelegate get_member_by_uuid_with_preload(uuid), to: ActivityPub
|
||||
@spec get_post_by_slug_with_preload(binary) :: {:commit, Post.t()} | {:ignore, nil}
|
||||
|
|
|
@ -13,9 +13,7 @@ defmodule Mobilizon.Web.GraphQLSocket do
|
|||
with {:ok, authed_socket} <-
|
||||
Guardian.Phoenix.Socket.authenticate(socket, Mobilizon.Web.Auth.Guardian, token),
|
||||
resource <- Guardian.Phoenix.Socket.current_resource(authed_socket) do
|
||||
set_context(authed_socket, resource)
|
||||
|
||||
{:ok, authed_socket}
|
||||
{:ok, set_context(authed_socket, resource)}
|
||||
else
|
||||
{:error, _} ->
|
||||
:error
|
||||
|
@ -24,8 +22,17 @@ defmodule Mobilizon.Web.GraphQLSocket do
|
|||
|
||||
def connect(_args, _socket), do: :error
|
||||
|
||||
@spec id(any) :: nil
|
||||
def id(_socket), do: nil
|
||||
@spec id(Phoenix.Socket.t()) :: String.t() | nil
|
||||
def id(%Phoenix.Socket{assigns: assigns}) do
|
||||
context = Keyword.get(assigns.absinthe.opts, :context)
|
||||
current_user = Map.get(context, :current_user)
|
||||
|
||||
if current_user do
|
||||
"user_socket:#{current_user.id}"
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@spec set_context(Phoenix.Socket.t(), User.t() | ApplicationToken.t()) :: Phoenix.Socket.t()
|
||||
defp set_context(socket, %User{} = user) do
|
||||
|
|
|
@ -85,6 +85,12 @@ defmodule Mobilizon.Web.PageController do
|
|||
render_or_error(conn, &checks?/3, status, :todo, todo)
|
||||
end
|
||||
|
||||
@spec conversation(Plug.Conn.t(), map()) :: Plug.Conn.t() | {:error, :not_found}
|
||||
def conversation(conn, %{"id" => slug}) do
|
||||
{status, conversation} = Cache.get_conversation_by_id_with_preload(slug)
|
||||
render_or_error(conn, &checks?/3, status, :conversation, conversation)
|
||||
end
|
||||
|
||||
@typep collections :: :resources | :posts | :discussions | :events | :todos
|
||||
|
||||
@spec resources(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||
|
|
|
@ -132,6 +132,7 @@ defmodule Mobilizon.Web.Router do
|
|||
get("/@:name/discussions", PageController, :discussions)
|
||||
get("/@:name/events", PageController, :events)
|
||||
get("/p/:slug", PageController, :post)
|
||||
get("/conversations/:id", PageController, :conversation)
|
||||
get("/@:name/c/:slug", PageController, :discussion)
|
||||
end
|
||||
|
||||
|
@ -176,6 +177,7 @@ defmodule Mobilizon.Web.Router do
|
|||
|
||||
forward("/", Absinthe.Plug.GraphiQL,
|
||||
schema: Mobilizon.GraphQL.Schema,
|
||||
socket: Mobilizon.Web.GraphQLSocket,
|
||||
interface: :playground
|
||||
)
|
||||
end
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
<%= case @activity.subject do %>
|
||||
<% :conversation_created -> %>
|
||||
<%= dgettext("activity", "%{profile} mentionned you in a %{conversation}.", %{
|
||||
profile: "<b>#{escaped_display_name_and_username(@activity.author)}</b>",
|
||||
conversation:
|
||||
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
|
||||
:conversation,
|
||||
@activity.subject_params["conversation_participant_id"]) |> URI.decode()}\">conversation</a>"
|
||||
})
|
||||
|> raw %>
|
||||
<% :conversation_replied -> %>
|
||||
<%= dgettext("activity", "%{profile} replied you in a %{conversation}.", %{
|
||||
profile: "<b>#{escaped_display_name_and_username(@activity.author)}</b>",
|
||||
conversation:
|
||||
"<a href=\"#{Routes.page_url(Mobilizon.Web.Endpoint,
|
||||
:conversation,
|
||||
@activity.subject_params["conversation_participant_id"]) |> URI.decode()}\">conversation</a>"
|
||||
})
|
||||
|> raw %>
|
||||
<% end %>
|
|
@ -0,0 +1,11 @@
|
|||
<%= case @activity.subject do %><% :conversation_created -> %><%= dgettext("activity", "%{profile} mentionned you in a conversation.",
|
||||
%{
|
||||
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
|
||||
}
|
||||
) %>
|
||||
<%= Routes.page_url(Mobilizon.Web.Endpoint, :conversation, @activity.subject_params["conversation_participant_id"]) |> URI.decode() %><% :conversation_replied -> %><%= dgettext("activity", "%{profile} replied you in a conversation.",
|
||||
%{
|
||||
profile: Mobilizon.Actors.Actor.display_name_and_username(@activity.author),
|
||||
}
|
||||
) %>
|
||||
<%= Routes.page_url(Mobilizon.Web.Endpoint, :conversation, @activity.subject_params["conversation_participant_id"]) |> URI.decode() %><% end %>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue