mobilizon/js/src/views/Event/EventView.vue

1556 lines
45 KiB
Vue
Executable File

<template>
<div class="container mx-auto">
<o-loading v-model:active="eventLoading" />
<div class="wrapper mb-3">
<event-banner :picture="event?.picture" />
<div class="intro-wrapper">
<div class="date-calendar-icon-wrapper" v-if="event?.beginsOn">
<date-calendar-icon :date="event.beginsOn.toString()" />
</div>
<section class="intro" dir="auto">
<div class="flex flex-wrap">
<div class="flex-1 min-w-fit">
<h1
class="text-4xl font-bold m-0"
dir="auto"
:lang="event?.language"
>
{{ event?.title }}
</h1>
<div class="organizer">
<div v-if="event?.organizerActor && !event?.attributedTo">
<popover-actor-card
:actor="event.organizerActor"
:inline="true"
>
<i18n-t
keypath="By {username}"
dir="auto"
class="block truncate max-w-xs md:max-w-sm"
>
<template #username>
<span dir="ltr">{{
displayName(event.organizerActor)
}}</span>
</template>
</i18n-t>
</popover-actor-card>
</div>
<span v-else-if="event?.attributedTo">
<popover-actor-card
:actor="event.attributedTo"
:inline="true"
>
<i18n-t
keypath="By {group}"
dir="auto"
class="block truncate max-w-xs md:max-w-sm"
>
<template #group>
<router-link
:to="{
name: RouteName.GROUP,
params: {
preferredUsername: usernameWithDomain(
event.attributedTo
),
},
}"
dir="ltr"
>{{ displayName(event.attributedTo) }}</router-link
>
</template>
</i18n-t>
</popover-actor-card>
</span>
</div>
<div class="inline-flex items-center gap-1">
<p v-if="event?.status !== EventStatus.CONFIRMED">
<tag
variant="warning"
v-if="event?.status === EventStatus.TENTATIVE"
>{{ t("Event to be confirmed") }}</tag
>
<tag
variant="danger"
v-if="event?.status === EventStatus.CANCELLED"
>{{ t("Event cancelled") }}</tag
>
</p>
<p class="flex gap-1 items-center" dir="auto">
<tag v-if="eventCategory" class="category" capitalize>{{
eventCategory
}}</tag>
<router-link
v-for="tag in event?.tags ?? []"
:key="tag.title"
:to="{ name: RouteName.TAG, params: { tag: tag.title } }"
>
<tag>{{ tag.title }}</tag>
</router-link>
</p>
<tag variant="warning" size="medium" v-if="event?.draft"
>{{ t("Draft") }}
</tag>
</div>
</div>
<div class="">
<participation-section
v-if="
event &&
currentActor &&
identities &&
anonymousParticipationConfig
"
:participation="participations[0]"
:event="event"
:anonymousParticipation="anonymousParticipation"
:currentActor="currentActor"
:identities="identities"
:anonymousParticipationConfig="anonymousParticipationConfig"
@join-event="joinEvent"
@join-modal="isJoinModalActive = true"
@join-event-with-confirmation="joinEventWithConfirmation"
@confirm-leave="confirmLeave"
@cancel-anonymous-participation="cancelAnonymousParticipation"
/>
<div class="flex flex-col">
<template v-if="!event?.draft">
<p
v-if="event?.visibility === EventVisibility.PUBLIC"
class="inline-flex gap-1"
>
<Earth />
{{ t("Public event") }}
</p>
<p
v-if="event?.visibility === EventVisibility.UNLISTED"
class="inline-flex gap-1"
>
<Link />
{{ t("Private event") }}
</p>
</template>
<template v-if="!event?.local && organizer?.domain">
<a :href="event?.url">
<tag>{{ organizer?.domain }}</tag>
</a>
</template>
<p class="inline-flex gap-1">
<TicketConfirmationOutline />
<router-link
class="participations-link"
v-if="canManageEvent && event?.draft === false"
:to="{
name: RouteName.PARTICIPATIONS,
params: { eventId: event.uuid },
}"
>
<!-- We retire one because of the event creator who is a
participant -->
<span v-if="maximumAttendeeCapacity">
{{
t(
"{available}/{capacity} available places",
{
available:
maximumAttendeeCapacity -
event.participantStats.participant,
capacity: maximumAttendeeCapacity,
},
maximumAttendeeCapacity -
event.participantStats.participant
)
}}
</span>
<span v-else>
{{
t(
"No one is participating|One person participating|{going} people participating",
{
going: event.participantStats.participant,
},
event.participantStats.participant
)
}}
</span>
</router-link>
<span v-else>
<span v-if="maximumAttendeeCapacity">
{{
t(
"{available}/{capacity} available places",
{
available:
maximumAttendeeCapacity -
(event?.participantStats.participant ?? 0),
capacity: maximumAttendeeCapacity,
},
maximumAttendeeCapacity -
(event?.participantStats.participant ?? 0)
)
}}
</span>
<span v-else>
{{
t(
"No one is participating|One person participating|{going} people participating",
{
going: event?.participantStats.participant,
},
event?.participantStats.participant ?? 0
)
}}
</span>
</span>
<VTooltip v-if="event?.local === false">
<HelpCircleOutline :size="16" />
<template #popper>
{{
t(
"The actual number of participants may differ, as this event is hosted on another instance."
)
}}
</template>
</VTooltip>
</p>
<o-dropdown>
<template #trigger>
<o-button icon-right="dots-horizontal">
{{ t("Actions") }}
</o-button>
</template>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event?.draft"
>
<router-link
class="flex gap-1"
:to="{
name: RouteName.EDIT_EVENT,
params: { eventId: event?.uuid },
}"
>
<Pencil />
{{ t("Edit") }}
</router-link>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
has-link
v-if="canManageEvent || event?.draft"
>
<router-link
class="flex gap-1"
:to="{
name: RouteName.DUPLICATE_EVENT,
params: { eventId: event?.uuid },
}"
>
<ContentDuplicate />
{{ t("Duplicate") }}
</router-link>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
v-if="canManageEvent || event?.draft"
@click="openDeleteEventModal"
@keyup.enter="openDeleteEventModal"
><span class="flex gap-1">
<Delete />
{{ t("Delete") }}
</span>
</o-dropdown-item>
<hr
role="presentation"
class="dropdown-divider"
aria-role="o-dropdown-item"
v-if="canManageEvent || event?.draft"
/>
<o-dropdown-item
aria-role="listitem"
v-if="event?.draft === false"
@click="triggerShare()"
@keyup.enter="triggerShare()"
class="p-1"
>
<span class="flex gap-1">
<Share />
{{ t("Share this event") }}
</span>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
@click="downloadIcsEvent()"
@keyup.enter="downloadIcsEvent()"
v-if="event?.draft === false"
>
<span class="flex gap-1">
<CalendarPlus />
{{ t("Add to my calendar") }}
</span>
</o-dropdown-item>
<o-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
@keyup.enter="isReportModalActive = true"
class="p-1"
>
<span class="flex gap-1">
<Flag />
{{ t("Report") }}
</span>
</o-dropdown-item>
</o-dropdown>
</div>
</div>
</div>
</section>
</div>
<div
class="event-description-wrapper rounded-lg dark:border-violet-title flex flex-wrap flex-col md:flex-row-reverse"
>
<aside class="event-metadata rounded dark:bg-gray-600 shadow-md">
<div class="sticky">
<event-metadata-sidebar
v-if="event"
:event="event"
:user="loggedUser"
@showMapModal="showMap = true"
/>
</div>
</aside>
<div class="event-description-comments">
<section class="event-description">
<h2 class="text-xl">{{ t("About this event") }}</h2>
<p v-if="!event?.description">
{{ t("The event organizer didn't add any description.") }}
</p>
<div v-else>
<div
:lang="event?.language"
dir="auto"
class="description-content"
ref="eventDescriptionElement"
v-html="event.description"
/>
</div>
</section>
<section class="integration-wrappers">
<component
v-for="(metadata, integration) in integrations"
:is="integration"
:key="integration"
:metadata="metadata"
/>
</section>
<section class="comments" ref="commentsObserver">
<a href="#comments">
<h2 class="text-xl" id="comments">{{ t("Comments") }}</h2>
</a>
<comment-tree v-if="event && loadComments" :event="event" />
</section>
</div>
</div>
<section class="" v-if="(event?.relatedEvents ?? []).length > 0">
<h2 class="">
{{ t("These events may interest you") }}
</h2>
<multi-card :events="event?.relatedEvents ?? []" />
</section>
<o-modal
v-model:active="isReportModalActive"
has-modal-card
ref="reportModal"
:close-button-aria-label="t('Close')"
>
<ReportModal
:on-confirm="reportEvent"
:title="t('Report this event')"
:outside-domain="organizerDomain"
/>
</o-modal>
<o-modal
:close-button-aria-label="t('Close')"
v-model:active="isShareModalActive"
has-modal-card
ref="shareModal"
>
<share-event-modal
v-if="event"
:event="event"
:eventCapacityOK="eventCapacityOK"
/>
</o-modal>
<o-modal
v-model:active="isJoinModalActive"
has-modal-card
ref="participationModal"
:close-button-aria-label="t('Close')"
>
<identity-picker v-if="identity" v-model="identity">
<template #footer>
<footer class="flex gap-2">
<o-button
outlined
ref="cancelButton"
@click="isJoinModalActive = false"
@keyup.enter="isJoinModalActive = false"
>
{{ t("Cancel") }}
</o-button>
<o-button
v-if="identity"
variant="primary"
ref="confirmButton"
@click="
event?.joinOptions === EventJoinOptions.RESTRICTED
? joinEventWithConfirmation(identity as IPerson)
: joinEvent(identity as IPerson)
"
@keyup.enter="
event?.joinOptions === EventJoinOptions.RESTRICTED
? joinEventWithConfirmation(identity as IPerson)
: joinEvent(identity as IPerson)
"
>
{{ t("Confirm my particpation") }}
</o-button>
</footer>
</template>
</identity-picker>
</o-modal>
<o-modal
v-model:active="isJoinConfirmationModalActive"
has-modal-card
ref="joinConfirmationModal"
:close-button-aria-label="t('Close')"
>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">
{{ t("Participation confirmation") }}
</p>
</header>
<section class="modal-card-body">
<p>
{{
t(
"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?"
)
}}
</p>
<form
@submit.prevent="
joinEvent(
actorForConfirmation as IPerson,
messageForConfirmation
)
"
>
<o-field :label="t('Message')">
<o-input
type="textarea"
size="medium"
v-model="messageForConfirmation"
minlength="10"
></o-input>
</o-field>
<div class="buttons">
<o-button
native-type="button"
class="button"
ref="cancelButton"
@click="isJoinConfirmationModalActive = false"
@keyup.enter="isJoinConfirmationModalActive = false"
>{{ t("Cancel") }}
</o-button>
<o-button variant="primary" native-type="submit">
{{ t("Confirm my participation") }}
</o-button>
</div>
</form>
</section>
</div>
</o-modal>
<o-modal
v-model:active="showMap"
:close-button-aria-label="t('Close')"
class="map-modal"
v-if="event?.physicalAddress?.geom"
has-modal-card
full-screen
:can-cancel="['escape', 'outside']"
>
<template #default="props">
<event-map
:routingType="routingType ?? RoutingType.OPENSTREETMAP"
:address="event.physicalAddress"
@close="props.close"
/>
</template>
</o-modal>
</div>
</div>
</template>
<script lang="ts" setup>
import {
EventJoinOptions,
EventStatus,
EventVisibility,
MemberRole,
ParticipantRole,
RoutingType,
} from "@/types/enums";
import {
EVENT_PERSON_PARTICIPATION,
// EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
FETCH_EVENT,
JOIN_EVENT,
LEAVE_EVENT,
} from "@/graphql/event";
import { IEvent } from "@/types/event.model";
import {
displayName,
IActor,
IPerson,
usernameWithDomain,
} from "@/types/actor";
import { GRAPHQL_API_ENDPOINT } from "@/api/_entrypoint";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import MultiCard from "@/components/Event/MultiCard.vue";
import ReportModal from "@/components/Report/ReportModal.vue";
import IdentityPicker from "@/views/Account/IdentityPicker.vue";
import ParticipationSection from "@/components/Participation/ParticipationSection.vue";
import RouteName from "@/router/name";
import CommentTree from "@/components/Comment/CommentTree.vue";
import "intersection-observer";
import {
AnonymousParticipationNotFoundError,
getLeaveTokenForParticipation,
isParticipatingInThisEvent,
removeAnonymousParticipation,
} from "@/services/AnonymousParticipationStorage";
import Tag from "@/components/TagElement.vue";
import EventMetadataSidebar from "@/components/Event/EventMetadataSidebar.vue";
import EventBanner from "@/components/Event/EventBanner.vue";
import EventMap from "@/components/Event/EventMap.vue";
import PopoverActorCard from "@/components/Account/PopoverActorCard.vue";
import { IParticipant } from "@/types/participant.model";
import { ApolloCache, FetchResult } from "@apollo/client/core";
import { IEventMetadataDescription } from "@/types/event-metadata";
import { eventMetaDataList } from "@/services/EventMetadata";
import { useDeleteEvent, useFetchEvent } from "@/composition/apollo/event";
import {
computed,
onMounted,
ref,
watch,
defineAsyncComponent,
inject,
} from "vue";
import { useRoute, useRouter } from "vue-router";
import Earth from "vue-material-design-icons/Earth.vue";
import Link from "vue-material-design-icons/Link.vue";
import Flag from "vue-material-design-icons/Flag.vue";
import CalendarPlus from "vue-material-design-icons/CalendarPlus.vue";
import ContentDuplicate from "vue-material-design-icons/ContentDuplicate.vue";
import Delete from "vue-material-design-icons/Delete.vue";
import Pencil from "vue-material-design-icons/Pencil.vue";
import HelpCircleOutline from "vue-material-design-icons/HelpCircleOutline.vue";
import TicketConfirmationOutline from "vue-material-design-icons/TicketConfirmationOutline.vue";
import {
useCurrentActorClient,
useCurrentUserIdentities,
usePersonStatusGroup,
} from "@/composition/apollo/actor";
import { useLoggedUser } from "@/composition/apollo/user";
import { useMutation, useQuery } from "@vue/apollo-composable";
import {
useAnonymousActorId,
useAnonymousParticipationConfig,
useAnonymousReportsConfig,
useEventCategories,
useRoutingType,
} from "@/composition/apollo/config";
import { useCreateReport } from "@/composition/apollo/report";
import Share from "vue-material-design-icons/Share.vue";
import { useI18n } from "vue-i18n";
import { Dialog } from "@/plugins/dialog";
import { Notifier } from "@/plugins/notifier";
import { AbsintheGraphQLErrors } from "@/types/errors.model";
import { useHead } from "@vueuse/head";
import { Snackbar } from "@/plugins/snackbar";
const ShareEventModal = defineAsyncComponent(
() => import("@/components/Event/ShareEventModal.vue")
);
/* eslint-disable @typescript-eslint/no-unused-vars */
const IntegrationTwitch = defineAsyncComponent(
() => import("@/components/Event/Integrations/TwitchIntegration.vue")
);
const IntegrationPeertube = defineAsyncComponent(
() => import("@/components/Event/Integrations/PeerTubeIntegration.vue")
);
const IntegrationYoutube = defineAsyncComponent(
() => import("@/components/Event/Integrations/YouTubeIntegration.vue")
);
const IntegrationJitsiMeet = defineAsyncComponent(
() => import("@/components/Event/Integrations/JitsiMeetIntegration.vue")
);
const IntegrationEtherpad = defineAsyncComponent(
() => import("@/components/Event/Integrations/EtherpadIntegration.vue")
);
/* eslint-enable @typescript-eslint/no-unused-vars */
const props = defineProps<{
uuid: string;
}>();
const { t } = useI18n({ useScope: "global" });
const {
event,
onError: onFetchEventError,
loading: eventLoading,
} = useFetchEvent(props.uuid);
const eventId = computed(() => event.value?.id);
const { currentActor } = useCurrentActorClient();
const currentActorId = computed(() => currentActor.value?.id);
const { loggedUser } = useLoggedUser();
const {
result: participationsResult,
// subscribeToMore: subscribeToMoreParticipation,
} = useQuery<{ person: IPerson }>(
EVENT_PERSON_PARTICIPATION,
() => ({
eventId: event.value?.id,
actorId: currentActorId.value,
}),
() => ({
enabled:
currentActorId.value !== undefined &&
currentActorId.value !== null &&
eventId.value !== undefined,
})
);
// subscribeToMoreParticipation(() => ({
// document: EVENT_PERSON_PARTICIPATION_SUBSCRIPTION_CHANGED,
// variables: {
// eventId: eventId,
// actorId: currentActorId,
// },
// }));
const participations = computed(
() => participationsResult.value?.person.participations?.elements ?? []
);
const { person } = usePersonStatusGroup(
usernameWithDomain(event.value?.attributedTo)
);
const { anonymousReportsConfig } = useAnonymousReportsConfig();
const { anonymousActorId } = useAnonymousActorId();
const { eventCategories } = useEventCategories();
const { anonymousParticipationConfig } = useAnonymousParticipationConfig();
const { identities } = useCurrentUserIdentities();
// metaInfo() {
// return {
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// title: this.eventTitle,
// meta: [
// // eslint-disable-next-line @typescript-eslint/ban-ts-comment
// // @ts-ignore
// { name: "description", content: this.eventDescription },
// ],
// };
// },
const identity = ref<IPerson | undefined | null>(null);
const reportModal = ref();
const oldParticipationRole = ref<string | undefined>(undefined);
const isReportModalActive = ref(false);
const isShareModalActive = ref(false);
const isJoinModalActive = ref(false);
const isJoinConfirmationModalActive = ref(false);
const observer = ref<IntersectionObserver | null>(null);
const commentsObserver = ref<Element | null>(null);
const loadComments = ref(false);
const anonymousParticipation = ref<boolean | null>(null);
const actorForConfirmation = ref<IPerson | null>(null);
const messageForConfirmation = ref("");
const eventTitle = computed((): undefined | string => {
return event.value?.title;
});
const eventDescription = computed((): undefined | string => {
return event.value?.description;
});
const route = useRoute();
const router = useRouter();
const eventDescriptionElement = ref<HTMLElement | null>(null);
onMounted(async () => {
identity.value = currentActor.value;
if (route.hash.includes("#comment-")) {
loadComments.value = true;
}
try {
if (window.isSecureContext) {
anonymousParticipation.value = await anonymousParticipationConfirmed();
}
} catch (e) {
if (e instanceof AnonymousParticipationNotFoundError) {
anonymousParticipation.value = null;
} else {
console.error(e);
}
}
observer.value = new IntersectionObserver(
(entries) => {
// eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
if (entry) {
loadComments.value = entry.isIntersecting || loadComments.value;
}
}
},
{
rootMargin: "-50px 0px -50px",
}
);
if (commentsObserver.value) {
observer.value.observe(commentsObserver.value);
}
watch(eventDescription, () => {
if (!eventDescription.value) return;
if (!eventDescriptionElement.value) return;
eventDescriptionElement.value.addEventListener("click", ($event) => {
// TODO: Find the right type for target
let { target }: { target: any } = $event;
while (target && target.tagName !== "A") target = target.parentNode;
// handle only links that occur inside the component and do not reference external resources
if (target && target.matches(".hashtag") && target.href) {
// some sanity checks taken from vue-router:
// https://github.com/vuejs/vue-router/blob/dev/src/components/link.js#L106
const { altKey, ctrlKey, metaKey, shiftKey, button, defaultPrevented } =
$event;
// don't handle with control keys
if (metaKey || altKey || ctrlKey || shiftKey) return;
// don't handle when preventDefault called
if (defaultPrevented) return;
// don't handle right clicks
if (button !== undefined && button !== 0) return;
// don't handle if `target="_blank"`
if (target && target.getAttribute) {
const linkTarget = target.getAttribute("target");
if (/\b_blank\b/i.test(linkTarget)) return;
}
// don't handle same page links/anchors
const url = new URL(target.href);
const to = url.pathname;
if (window.location.pathname !== to && $event.preventDefault) {
$event.preventDefault();
router.push(to);
}
}
});
});
// this.$on("event-deleted", () => {
// return router.push({ name: RouteName.HOME });
// });
});
const deleteEventMessage = computed(() => {
const participantsLength = event.value?.participantStats.participant;
const prefix = participantsLength
? t(
"There are {participants} participants.",
{
participants: event.value.participantStats.participant,
},
event.value.participantStats.participant
)
: "";
return `${prefix}
${t(
"Are you sure you want to delete this event? This action cannot be reverted."
)}
<br><br>
${t('To confirm, type your event title "{eventTitle}"', {
eventTitle: event.value?.title,
})}`;
});
const notifier = inject<Notifier>("notifier");
const dialog = inject<Dialog>("dialog");
const {
mutate: deleteEvent,
onDone: onDeleteEventDone,
onError: onDeleteEventError,
} = useDeleteEvent();
const escapeRegExp = (string: string) => {
return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string
};
const openDeleteEventModal = () => {
dialog?.prompt({
title: t("Delete event"),
message: deleteEventMessage.value,
confirmText: t("Delete event"),
cancelText: t("Cancel"),
variant: "danger",
hasIcon: true,
hasInput: true,
inputAttrs: {
placeholder: event.value?.title,
pattern: escapeRegExp(event.value?.title ?? ""),
},
onConfirm: (result: string) => {
console.log("calling delete event", result);
if (result.trim() === event.value?.title) {
event.value?.id ? deleteEvent({ eventId: event.value?.id }) : null;
}
},
});
};
onDeleteEventDone(() => {
router.push({ name: RouteName.MY_EVENTS });
});
onDeleteEventError((error) => {
console.error(error);
});
const {
mutate: createReportMutation,
onDone: onCreateReportDone,
onError: onCreateReportError,
} = useCreateReport();
onCreateReportDone(() => {
notifier?.success(t("Event {eventTitle} reported", { eventTitle }));
});
onCreateReportError((error) => {
console.error(error);
});
const reportEvent = async (
content: string,
forward: boolean
): Promise<void> => {
isReportModalActive.value = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
reportModal.value.close();
if (!organizer.value) return;
createReportMutation({
eventId: event.value?.id ?? "",
reportedId: organizer.value?.id ?? "",
content,
forward,
});
};
const joinEventWithConfirmation = (actor: IPerson): void => {
isJoinConfirmationModalActive.value = true;
actorForConfirmation.value = actor;
};
const {
mutate: joinEventMutation,
onDone: onJoinEventMutationDone,
onError: onJoinEventMutationError,
} = useMutation<{
joinEvent: IParticipant;
}>(JOIN_EVENT, () => ({
update: (
store: ApolloCache<{
joinEvent: IParticipant;
}>,
{ data }: FetchResult
) => {
if (data == null) return;
const participationCachedData = store.readQuery<{ person: IPerson }>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: event.value?.id, actorId: identity.value?.id },
});
if (participationCachedData?.person == undefined) {
console.error(
"Cannot update participation cache, because of null value."
);
return;
}
store.writeQuery({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: event.value?.id, actorId: identity.value?.id },
data: {
person: {
...participationCachedData?.person,
participations: {
elements: [data.joinEvent],
total: 1,
},
},
},
});
const cachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: event.value?.uuid },
});
if (cachedData == null) return;
const { event: cachedEvent } = cachedData;
if (cachedEvent === null) {
console.error(
"Cannot update event participant cache, because of null value."
);
return;
}
const participantStats = { ...cachedEvent.participantStats };
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
participantStats.notApproved += 1;
} else {
participantStats.going += 1;
participantStats.participant += 1;
}
store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: props.uuid },
data: {
event: {
...cachedEvent,
participantStats,
},
},
});
},
}));
const joinEvent = async (
identityForJoin: IPerson,
message: string | null = null
): Promise<void> => {
isJoinConfirmationModalActive.value = false;
isJoinModalActive.value = false;
joinEventMutation({
eventId: event.value?.id,
actorId: identityForJoin?.id,
message,
});
};
onJoinEventMutationDone(({ data }) => {
if (data) {
if (data.joinEvent.role === ParticipantRole.NOT_APPROVED) {
participationRequestedMessage();
} else {
participationConfirmedMessage();
}
}
});
onJoinEventMutationError((error) => {
console.error(error);
});
const confirmLeave = (): void => {
dialog?.confirm({
title: t('Leaving event "{title}"', {
title: event.value?.title,
}),
message: t(
'Are you sure you want to cancel your participation at event "{title}"?',
{
title: event.value?.title,
}
),
confirmText: t("Leave event"),
cancelText: t("Cancel"),
variant: "danger",
hasIcon: true,
onConfirm: () => {
if (event.value && currentActor.value?.id) {
console.log("calling leave event");
leaveEvent(event.value, currentActor.value.id);
}
},
});
};
watch(participations, () => {
if (participations.value.length > 0) {
if (
oldParticipationRole.value &&
participations.value[0].role !== ParticipantRole.NOT_APPROVED &&
oldParticipationRole.value !== participations.value[0].role
) {
switch (participations.value[0].role) {
case ParticipantRole.PARTICIPANT:
participationConfirmedMessage();
break;
case ParticipantRole.REJECTED:
participationRejectedMessage();
break;
default:
participationChangedMessage();
break;
}
}
oldParticipationRole.value = participations.value[0].role;
}
});
const participationConfirmedMessage = () => {
notifier?.success(t("Your participation has been confirmed"));
};
const participationRequestedMessage = () => {
notifier?.success(t("Your participation has been requested"));
};
const participationRejectedMessage = () => {
notifier?.error(t("Your participation has been rejected"));
};
const participationChangedMessage = () => {
notifier?.info(t("Your participation status has been changed"));
};
const downloadIcsEvent = async (): Promise<void> => {
const data = await (
await fetch(`${GRAPHQL_API_ENDPOINT}/events/${props.uuid}/export/ics`)
).text();
const blob = new Blob([data], { type: "text/calendar" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = `${event.value?.title}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const triggerShare = (): void => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-start
if (navigator.share) {
navigator
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
.share({
title: event.value?.title,
url: event.value?.url,
})
.then(() => console.debug("Successful share"))
.catch((error: any) => console.debug("Error sharing", error));
} else {
isShareModalActive.value = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
};
const handleErrors = (errors: AbsintheGraphQLErrors): void => {
if (
errors.some((error) => error.status_code === 404) ||
errors.some(({ message }) => message.includes("has invalid value $uuid"))
) {
router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
};
onFetchEventError(({ graphQLErrors }) =>
handleErrors(graphQLErrors as AbsintheGraphQLErrors)
);
// const actorIsParticipant = computed((): boolean => {
// if (actorIsOrganizer.value) return true;
// return (
// participations.value.length > 0 &&
// participations.value[0].role === ParticipantRole.PARTICIPANT
// );
// });
const actorIsOrganizer = computed((): boolean => {
return (
participations.value.length > 0 &&
participations.value[0].role === ParticipantRole.CREATOR
);
});
const hasGroupPrivileges = computed((): boolean => {
return (
person.value?.memberships !== undefined &&
person.value?.memberships?.total > 0 &&
[MemberRole.MODERATOR, MemberRole.ADMINISTRATOR].includes(
person.value?.memberships?.elements[0].role
)
);
});
const canManageEvent = computed((): boolean => {
return actorIsOrganizer.value || hasGroupPrivileges.value;
});
// const endDate = computed((): string | undefined => {
// return event.value?.endsOn && event.value.endsOn > event.value.beginsOn
// ? event.value.endsOn
// : event.value?.beginsOn;
// });
const maximumAttendeeCapacity = computed((): number | undefined => {
return event.value?.options?.maximumAttendeeCapacity;
});
const eventCapacityOK = computed((): boolean => {
if (event.value?.draft) return true;
if (!maximumAttendeeCapacity.value) return true;
return (
event.value?.options?.maximumAttendeeCapacity !== undefined &&
event.value.participantStats.participant !== undefined &&
event.value?.options?.maximumAttendeeCapacity >
event.value.participantStats.participant
);
});
// const numberOfPlacesStillAvailable = computed((): number | undefined => {
// if (event.value?.draft) return maximumAttendeeCapacity.value;
// return (
// (maximumAttendeeCapacity.value ?? 0) -
// (event.value?.participantStats.participant ?? 0)
// );
// });
const anonymousParticipationConfirmed = async (): Promise<boolean> => {
return isParticipatingInThisEvent(props.uuid);
};
const cancelAnonymousParticipation = async (): Promise<void> => {
if (!event.value || !anonymousActorId.value) return;
const token = (await getLeaveTokenForParticipation(props.uuid)) as string;
leaveEvent(event.value, anonymousActorId.value, token);
await removeAnonymousParticipation(props.uuid);
anonymousParticipation.value = null;
};
const ableToReport = computed((): boolean => {
return (
currentActor.value?.id != null ||
anonymousReportsConfig.value?.allowed === true
);
});
const organizer = computed((): IActor | null => {
if (event.value?.attributedTo?.id) {
return event.value.attributedTo;
}
if (event.value?.organizerActor) {
return event.value.organizerActor;
}
return null;
});
const organizerDomain = computed((): string | undefined => {
return organizer.value?.domain ?? undefined;
});
const metadataToComponent: Record<string, string> = {
"mz:live:twitch:url": "IntegrationTwitch",
"mz:live:peertube:url": "IntegrationPeertube",
"mz:live:youtube:url": "IntegrationYoutube",
"mz:visio:jitsi_meet": "IntegrationJitsiMeet",
"mz:notes:etherpad:url": "IntegrationEtherpad",
};
const integrations = computed((): Record<string, IEventMetadataDescription> => {
return (event.value?.metadata ?? [])
.map((val) => {
const def = eventMetaDataList.find((dat) => dat.key === val.key);
return {
...def,
...val,
};
})
.reduce((acc: Record<string, IEventMetadataDescription>, metadata) => {
const component = metadataToComponent[metadata.key];
if (component !== undefined) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
acc[component] = metadata;
}
return acc;
}, {});
});
const showMap = ref(false);
const { routingType } = useRoutingType();
const eventCategory = computed((): string | undefined => {
if (event.value?.category === "MEETING") {
return undefined;
}
return (eventCategories.value ?? []).find((eventCategoryToFind) => {
return eventCategoryToFind.id === event.value?.category;
})?.label as string;
});
const {
mutate: leaveEventMutation,
onDone: onLeaveEventMutationDone,
onError: onLeaveEventMutationError,
} = useMutation<{ leaveEvent: { actor: { id: string } } }>(LEAVE_EVENT, () => ({
update: (
store: ApolloCache<{
leaveEvent: IParticipant;
}>,
{ data }: FetchResult,
{ context, variables }
) => {
if (data == null) return;
let participation;
const token = context?.token;
const actorId = variables?.actorId;
const localEventId = variables?.eventId;
const eventUUID = context?.eventUUID;
const isAnonymousParticipationConfirmed =
context?.isAnonymousParticipationConfirmed;
if (!token) {
const participationCachedData = store.readQuery<{
person: IPerson;
}>({
query: EVENT_PERSON_PARTICIPATION,
variables: { eventId: localEventId, actorId },
});
if (participationCachedData == null) return;
const { person: cachedPerson } = participationCachedData;
[participation] = cachedPerson?.participations?.elements ?? [undefined];
store.modify({
id: `Person:${actorId}`,
fields: {
participations() {
return {
elements: [],
total: 0,
};
},
},
});
}
const eventCachedData = store.readQuery<{ event: IEvent }>({
query: FETCH_EVENT,
variables: { uuid: eventUUID },
});
if (eventCachedData == null) return;
const { event: eventCached } = eventCachedData;
if (eventCached === null) {
console.error("Cannot update event cache, because of null value.");
return;
}
const participantStats = { ...eventCached.participantStats };
if (participation && participation?.role === ParticipantRole.NOT_APPROVED) {
participantStats.notApproved -= 1;
} else if (isAnonymousParticipationConfirmed === false) {
participantStats.notConfirmed -= 1;
} else {
participantStats.going -= 1;
participantStats.participant -= 1;
}
store.writeQuery({
query: FETCH_EVENT,
variables: { uuid: eventUUID },
data: {
event: {
...eventCached,
participantStats,
},
},
});
},
}));
const leaveEvent = (
eventToLeave: IEvent,
actorId: string,
token: string | null = null,
isAnonymousParticipationConfirmed: boolean | null = null
): void => {
leaveEventMutation(
{
eventId: eventToLeave.id,
actorId,
token,
},
{
context: {
token,
isAnonymousParticipationConfirmed,
eventUUID: eventToLeave.uuid,
},
}
);
};
onLeaveEventMutationDone(({ data }) => {
if (data) {
notifier?.success(t("You have cancelled your participation"));
}
});
const snackbar = inject<Snackbar>("snackbar");
onLeaveEventMutationError((error) => {
snackbar?.open({
message: error.message,
variant: "danger",
position: "bottom",
});
console.error(error);
});
useHead({
title: computed(() => eventTitle.value ?? ""),
meta: [{ name: "description", content: eventDescription.value }],
});
</script>
<style lang="scss" scoped>
@use "@/styles/_mixins" as *;
.section {
padding: 1rem 2rem 4rem;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s;
}
.fade-enter,
.fade-leave-to {
opacity: 0;
}
div.sidebar {
display: flex;
flex-wrap: wrap;
flex-direction: column;
position: relative;
&::before {
content: "";
background: #b3b3b2;
position: absolute;
bottom: 30px;
top: 30px;
left: 0;
height: calc(100% - 60px);
width: 1px;
}
div.organizer {
display: inline-flex;
padding-top: 10px;
a {
color: #4a4a4a;
span {
line-height: 2.7rem;
@include padding-right(6px);
}
}
}
}
.intro {
// background: white;
.is-3-tablet {
width: initial;
}
p.tags {
a {
text-decoration: none;
}
span {
&.tag {
margin: 0 2px;
}
}
}
}
.event-description-wrapper {
padding: 0;
aside.event-metadata {
min-width: 20rem;
flex: 1;
.sticky {
// position: sticky;
// background: white;
top: 50px;
padding: 1rem;
}
}
div.event-description-comments {
min-width: 20rem;
padding: 1rem;
flex: 2;
// background: white;
}
.description-content {
:deep(h1) {
font-size: 2rem;
}
:deep(h2) {
font-size: 1.5rem;
}
:deep(h3) {
font-size: 1.25rem;
}
:deep(ul) {
list-style-type: disc;
}
:deep(li) {
margin: 10px auto 10px 2rem;
}
:deep(blockquote) {
border-left: 0.2em solid #333;
display: block;
@include padding-left(1rem);
}
:deep(p) {
margin: 10px auto;
a {
display: inline-block;
padding: 0.3rem;
color: #111;
&:empty {
display: none;
}
}
}
}
}
.comments {
padding-top: 3rem;
a h3#comments {
margin-bottom: 10px;
}
}
.more-events {
// background: white;
padding: 1rem 1rem 4rem;
& > .title {
font-size: 1.5rem;
}
}
// .dropdown .dropdown-trigger span {
// cursor: pointer;
// }
// a.dropdown-item,
// .dropdown .dropdown-menu .has-link a,
// button.dropdown-item {
// white-space: nowrap;
// width: 100%;
// @include padding-right(1rem);
// text-align: right;
// }
a.participations-link {
text-decoration: none;
}
.no-border {
border: 0;
cursor: auto;
}
.wrapper,
.intro-wrapper {
display: flex;
flex-direction: column;
}
.intro-wrapper {
position: relative;
padding: 0 16px 16px;
// background: #fff;
.date-calendar-icon-wrapper {
margin-top: 16px;
height: 0;
display: flex;
align-items: flex-end;
align-self: flex-start;
margin-bottom: 7px;
@include margin-left(0);
}
}
.title {
margin: 0;
font-size: 2rem;
}
</style>