Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2023-10-17 16:41:31 +02:00
parent 0613f7f736
commit b5672cee7e
No known key found for this signature in database
GPG Key ID: A061B9DDE0CA0773
108 changed files with 5221 additions and 1318 deletions

View File

@ -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",

View File

@ -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;
};

View File

@ -45,6 +45,11 @@ export const typePolicies: TypePolicies = {
comments: paginatedLimitPagination<IComment>(),
},
},
Conversation: {
fields: {
comments: paginatedLimitPagination<IComment>(),
},
},
Group: {
fields: {
organizedEvents: paginatedLimitPagination([

View 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>

View File

@ -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<{

View File

@ -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>({

View File

@ -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;

View File

@ -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",
},
};

View 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>

View 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>

View 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>

View File

@ -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<{
modelValue: IComment;
currentActor: IPerson;
}>();
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 *;

View File

@ -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<
{ searchPersons: Paginate<IPerson> },
{ searchText: string }
>(SEARCH_PERSONS, () => ({
searchText: query,
}));
})
);
return res.data.searchPersons.elements;
const res = await provideApolloClient(apolloClient)(async () => {
const { load: loadSearchPersonsQuery } = useLazyQuery<
{ searchPersons: Paginate<IPerson> },
{ searchText: string }
>(SEARCH_PERSONS);
return await loadSearchPersonsQuery(SEARCH_PERSONS, {
searchText: query,
});
});
if (!res) return [];
return res.searchPersons.elements;
} catch (e) {
console.error(e);
return [];

View File

@ -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, {
query,
locale: locale,
type: props.resultType,
});
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, {
latitude: e.lat,
longitude: e.lng,
zoom,
locale: locale as unknown as string,
});
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

View File

@ -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",
},
};

View File

@ -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);

View File

@ -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

View 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>

View File

@ -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"

View File

@ -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) {

View 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 };
}

View File

@ -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, {
filter: text,
})
)
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 [];

View File

@ -17,6 +17,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
insertedAt
updatedAt
deletedAt
publishedAt
isAnnouncement
language
}

View 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}
`;

View File

@ -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}
`;

View File

@ -85,6 +85,12 @@ const REPORT_FRAGMENT = gql`
uuid
title
}
conversation {
id
participants {
id
}
}
}
notes {
id

View File

@ -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) {

View File

@ -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)
}
`;

View File

@ -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"
}

View File

@ -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": "Participante 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 certaine 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 certaine 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 certaine 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 certaine 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 certaine 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 certaine 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 certaine 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 certaine de vouloir annuler votre participation à l'événement « {title} » ?",
"Are you sure you want to delete this entire discussion?": "Êtes-vous certaine 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 certaine 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ûre 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'administrateurice 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": "Participante",
"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 utilisateurices",
"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'utilisateurice",
"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 administrateurrice·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'utilisateurice",
"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'utilisateurice",
"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ées 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'organisateurice 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 participantes",
"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": "Seules 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": "Organisateurice",
"Organizer": "Organisateur·ice",
"Organizer notifications": "Notifications pour organisateur·rice",
"Organizers": "Organisateurices",
"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 utilisateurices avec la même adresse IP",
"Other users with the same email domain": "Autres utilisateurices 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 administrateurrice·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": "Participante",
"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'administrateurrice de cette instance Mobilizon si vous pensez quil sagit dune 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 quil sagit dune 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.": "Assurezvous que ladresse est correcte et que la page na 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'organisateurice 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'organisateurice 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'organisateurice 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érateurices 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éveloppeurices à résoudre le problème plus facilement. Merci de les inclure dans vos retours.",
"The user has been disabled": "L'utilisateurice 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'utilisateurrice.",
"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'organisateurice 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érateurice 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 utilisateurice 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 nest 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 organisateurices.",
"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 utilisateurices",
"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érateurice.",
"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é exclue 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 certaines 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'adminstrateurice.",
"You promoted {member} to moderator.": "Vous avez promu {member} en tant que modérateurice.",
"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é promue à 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 auteurrice]",
"[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|Une 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érateurice.",
"{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'administrateurice.",
"{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érateurice.",
"{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 Contributeurices 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"
}

View 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 } },
},
];

View File

@ -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",

View File

@ -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,
};

View File

@ -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;

View File

@ -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 {

View 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;
}

View File

@ -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>;
}

View File

@ -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();

View File

@ -49,17 +49,17 @@ export function deleteUserData(): void {
});
}
export async function logout(performServerLogout = true): Promise<void> {
const { mutate: logoutMutation } = provideApolloClient(apolloClient)(() =>
useMutation(LOGOUT)
);
const { mutate: cleanUserClient } = provideApolloClient(apolloClient)(() =>
useMutation(UPDATE_CURRENT_USER_CLIENT)
);
const { mutate: cleanActorClient } = provideApolloClient(apolloClient)(() =>
useMutation(UPDATE_CURRENT_ACTOR_CLIENT)
);
const { mutate: logoutMutation } = provideApolloClient(apolloClient)(() =>
useMutation(LOGOUT)
);
const { mutate: cleanUserClient } = provideApolloClient(apolloClient)(() =>
useMutation(UPDATE_CURRENT_USER_CLIENT)
);
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),

View File

@ -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);
}
}

View File

@ -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

View 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>

View 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>

View File

@ -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;

View File

@ -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();
});

View File

@ -257,25 +257,65 @@
<h2 class="mb-1">{{ t("Reported content") }}</h2>
<ul v-for="comment in report.comments" :key="comment.id">
<li>
<i18n-t keypath="Comment under event {eventTitle}" 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>
<EventComment
:root-comment="true"
:comment="comment"
:event="comment.event as IEvent"
:current-actor="currentActor as IPerson"
:readOnly="true"
/>
<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
:to="{
name: RouteName.EVENT,
params: { uuid: comment.event?.uuid },
}"
>
<b>{{ comment.event?.title }}</b>
</router-link>
</template>
</i18n-t>
<EventComment
:root-comment="true"
:comment="comment"
:event="comment.event as IEvent"
: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({

View File

@ -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,

View File

@ -249,6 +249,8 @@ onCurrentUserMutationDone(async () => {
userAlreadyActivated: "true",
},
});
} else {
throw err;
}
}
});

View File

@ -37,22 +37,23 @@ 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;
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
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);
}
});

View File

@ -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);
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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" => []}

View File

@ -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} ->

View File

@ -68,24 +68,26 @@ defmodule Mobilizon.Federation.ActivityPub.Transmogrifier do
def handle_incoming(%{"type" => "Create", "object" => %{"type" => "Note"} = object}) do
Logger.info("Handle incoming to create notes")
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
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} = 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
{:error, :comment_not_found} ->
object_data
|> transform_object_data_for_discussion()
|> save_comment_or_discussion()
{:ok, %Comment{} = comment} ->
# Object already exists
{:ok, nil, comment}
object_data when is_map(object_data) ->
case Discussions.get_comment_from_url_with_preload(object_data.url) do
{:error, :comment_not_found} ->
object_data
|> transform_object_data_for_discussion()
|> save_comment_or_discussion()
end
end
{:ok, %Comment{} = comment} ->
# Object already exists
{:ok, nil, comment}
{:error, err} ->
{:error, err}
end

View 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

View File

@ -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

View File

@ -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,19 +137,22 @@ 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]
defp determine_to(%CommentModel{visibility: :private, mentions: mentions} = _comment) do
Enum.map(mentions, fn mention -> mention.actor.url end)
end
comment.visibility == :public ->
["https://www.w3.org/ns/activitystreams#Public"]
true ->
[comment.actor.followers_url]
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
# We fetch the parent object
Logger.debug("We're fetching the parent object")
@ -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,

View 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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View 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

View File

@ -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

View File

@ -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())

View File

@ -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

View 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

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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",

View File

@ -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

View 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

View 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

View 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

View 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

View File

@ -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

View File

@ -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
comment
|> Comment.update_changeset(attrs)
|> Repo.update()
with {:ok, %Comment{} = comment} <-
comment
|> Comment.update_changeset(attrs)
|> 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
"""

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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 """

View 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

View 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

View File

@ -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

View File

@ -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
}

View File

@ -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"

View File

@ -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
}

View File

@ -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(&notify_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

View File

@ -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

View File

@ -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.
"""

View File

@ -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}

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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 %>

View File

@ -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