Merge branch 'follow-groups' into 'master'

Group follows

Closes #512

See merge request framasoft/mobilizon!1092
This commit is contained in:
Thomas Citharel 2021-11-03 09:41:49 +00:00
commit 3dc1707e01
118 changed files with 4904 additions and 3485 deletions

View File

@ -4,6 +4,9 @@ FROM node:16-alpine as assets
RUN apk add --no-cache python3 build-base libwebp-tools bash imagemagick ncurses
WORKDIR /build
COPY js .
ENV CYPRESS_INSTALL_BINARY 0
RUN yarn install \
&& yarn run build

View File

@ -5,79 +5,41 @@
"kind": "INTERFACE",
"name": "ActionLogObject",
"possibleTypes": [
{
"name": "Comment"
},
{
"name": "Event"
},
{
"name": "Person"
},
{
"name": "Report"
},
{
"name": "ReportNote"
},
{
"name": "User"
}
{ "name": "Comment" },
{ "name": "Event" },
{ "name": "Group" },
{ "name": "Person" },
{ "name": "Report" },
{ "name": "ReportNote" },
{ "name": "User" }
]
},
{
"kind": "INTERFACE",
"name": "ActivityObject",
"possibleTypes": [
{
"name": "Comment"
},
{
"name": "Discussion"
},
{
"name": "Event"
},
{
"name": "Group"
},
{
"name": "Member"
},
{
"name": "Post"
},
{
"name": "Resource"
}
{ "name": "Comment" },
{ "name": "Discussion" },
{ "name": "Event" },
{ "name": "Group" },
{ "name": "Member" },
{ "name": "Post" },
{ "name": "Resource" }
]
},
{
"kind": "INTERFACE",
"name": "Actor",
"possibleTypes": [
{
"name": "Person"
},
{
"name": "Group"
},
{
"name": "Application"
}
{ "name": "Application" },
{ "name": "Group" },
{ "name": "Person" }
]
},
{
"kind": "INTERFACE",
"name": "Interactable",
"possibleTypes": [
{
"name": "Event"
},
{
"name": "Group"
}
]
"possibleTypes": [{ "name": "Event" }, { "name": "Group" }]
}
]
}

View File

@ -76,18 +76,19 @@
"@types/prosemirror-view": "^1.11.4",
"@typescript-eslint/eslint-plugin": "^4.18.0",
"@typescript-eslint/parser": "^4.18.0",
"@vue/cli-plugin-babel": "~5.0.0-beta.5",
"@vue/cli-plugin-e2e-cypress": "~5.0.0-beta.5",
"@vue/cli-plugin-eslint": "~5.0.0-beta.5",
"@vue/cli-plugin-pwa": "~5.0.0-beta.5",
"@vue/cli-plugin-router": "~5.0.0-beta.5",
"@vue/cli-plugin-typescript": "~5.0.0-beta.5",
"@vue/cli-plugin-unit-jest": "~5.0.0-beta.5",
"@vue/cli-service": "~5.0.0-beta.5",
"@vue/cli-plugin-babel": "~5.0.0-beta.7",
"@vue/cli-plugin-e2e-cypress": "~5.0.0-beta.7",
"@vue/cli-plugin-eslint": "~5.0.0-beta.7",
"@vue/cli-plugin-pwa": "~5.0.0-beta.7",
"@vue/cli-plugin-router": "~5.0.0-beta.7",
"@vue/cli-plugin-typescript": "~5.0.0-beta.7",
"@vue/cli-plugin-unit-jest": "~5.0.0-beta.7",
"@vue/cli-service": "~5.0.0-beta.7",
"@vue/eslint-config-prettier": "^6.0.0",
"@vue/eslint-config-typescript": "^7.0.0",
"@vue/test-utils": "^1.1.0",
"@vue/vue2-jest": "^27.0.0-alpha.2",
"@vue/vue3-jest": "^27.0.0-alpha.1",
"cypress": "^8.3.0",
"eslint": "^7.20.0",
"eslint-plugin-cypress": "^2.10.3",

View File

@ -157,3 +157,10 @@ p {
background-color: whitesmoke;
color: #0a0a0a;
}
/**
* Bulma/Buefy fixes
*/
.icon {
vertical-align: middle;
}

View File

@ -12,18 +12,17 @@
</docs>
<template>
<time
<div
class="datetime-container"
:class="{ small }"
:datetime="dateObj.getUTCSeconds()"
:style="`--small: ${smallStyle}`"
>
<div class="datetime-container-header" />
<div class="datetime-container-content">
<span class="day">{{ day }}</span>
<span class="month">{{ month }}</span>
<time :datetime="dateObj.toISOString()" class="day">{{ day }}</time>
<time :datetime="dateObj.toISOString()" class="month">{{ month }}</time>
</div>
</time>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
@ -54,7 +53,7 @@ export default class DateCalendarIcon extends Vue {
</script>
<style lang="scss" scoped>
time.datetime-container {
div.datetime-container {
border-radius: 8px;
display: flex;
flex-direction: column;
@ -76,7 +75,7 @@ time.datetime-container {
height: calc(30px * var(--small));
}
span {
time {
display: block;
font-weight: 600;
color: $violet-3;

View File

@ -0,0 +1,36 @@
<template>
<div
:title="
isDescriptionDifferentFromLocality
? `${physicalAddress.description}, ${physicalAddress.locality}`
: physicalAddress.description
"
>
<b-icon icon="map-marker" />
<span v-if="isDescriptionDifferentFromLocality">
{{ physicalAddress.description }},
{{ physicalAddress.locality }}
</span>
<span v-else>
{{ physicalAddress.description }}
</span>
</div>
</template>
<script lang="ts">
import { IAddress } from "@/types/address.model";
import { PropType } from "vue";
import { Prop, Vue, Component } from "vue-property-decorator";
@Component
export default class EventAddress extends Vue {
@Prop({ required: true, type: Object as PropType<IAddress> })
physicalAddress!: IAddress;
get isDescriptionDifferentFromLocality(): boolean {
return (
this.physicalAddress?.description !== this.physicalAddress?.locality &&
this.physicalAddress?.description !== undefined
);
}
}
</script>

View File

@ -40,78 +40,60 @@
</div>
<div class="media-content">
<p class="event-title" :title="event.title">{{ event.title }}</p>
<div class="event-organizer">
<figure
class="image is-24x24"
v-if="organizer(event) && organizer(event).avatar"
>
<img
class="is-rounded"
:src="organizer(event).avatar.url"
alt=""
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(event) }}
</span>
</div>
<event-address
v-if="event.physicalAddress"
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
v-if="event.physicalAddress"
:title="
isDescriptionDifferentFromLocality
? `${event.physicalAddress.description}, ${event.physicalAddress.locality}`
: event.physicalAddress.description
"
v-else-if="event.options && event.options.isOnline"
>
<!-- <p>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</p>-->
<span v-if="isDescriptionDifferentFromLocality">
{{ event.physicalAddress.description }},
{{ event.physicalAddress.locality }}
</span>
<span v-else>
{{ event.physicalAddress.description }}
</span>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
</div>
</div>
</div>
</div>
<!-- <div class="date-and-title">-->
<!-- <div class="date-component">-->
<!-- <date-calendar-icon v-if="!mergedOptions.hideDate" :date="event.beginsOn" />-->
<!-- </div>-->
<!-- <div class="title-wrapper">-->
<!-- <h4>{{ event.title }}</h4>-->
<!-- <div class="organizer-place-wrapper has-text-grey">-->
<!-- <span>{{ $t('By @{username}', { username: actor.preferredUsername }) }}</span>-->
<!-- ·-->
<!-- <span v-if="event.physicalAddress">-->
<!-- {{ event.physicalAddress.description }}, {{ event.physicalAddress.locality }}-->
<!-- </span>-->
<!-- </div>-->
<!-- </div>-->
<!-- </div>-->
<!-- <div v-if="!mergedOptions.hideDetails" class="details">-->
<!-- <div v-if="event.participants.length > 0 &&-->
<!-- mergedOptions.loggedPerson &&-->
<!-- event.participants[0].actor.id === mergedOptions.loggedPerson.id">-->
<!-- <b-tag type="is-info"><translate>Organizer</translate></b-tag>-->
<!-- </div>-->
<!-- <div v-else-if="event.participants.length === 1">-->
<!-- <translate-->
<!-- :translate-params="{name: event.participants[0].actor.preferredUsername}"-->
<!-- >{name} organizes this event</translate>-->
<!-- </div>-->
<!-- <div v-else>-->
<!-- <span v-for="participant in event.participants" :key="participant.actor.uuid">-->
<!-- {{ participant.actor.preferredUsername }}-->
<!-- <span v-if="participant.role === ParticipantRole.CREATOR">(organizer)</span>,-->
<!-- &lt;!&ndash; <translate-->
<!-- :translate-params="{name: participant.actor.preferredUsername}"-->
<!-- >&nbsp;{name} is in,</translate>&ndash;&gt;-->
<!-- </span>-->
<!-- </div>-->
</router-link>
</template>
<script lang="ts">
import { IEvent, IEventCardOptions } from "@/types/event.model";
import {
IEvent,
IEventCardOptions,
organizerDisplayName,
organizer,
} from "@/types/event.model";
import { Component, Prop, Vue } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import { Actor, Person } from "@/types/actor";
import { EventStatus, ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name";
import EventAddress from "@/components/Event/EventAddress.vue";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
EventAddress,
},
})
export default class EventCard extends Vue {
@ -125,6 +107,10 @@ export default class EventCard extends Vue {
RouteName = RouteName;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
defaultOptions: IEventCardOptions = {
hideDate: false,
loggedPerson: false,
@ -143,18 +129,12 @@ export default class EventCard extends Vue {
this.event.organizerActor || this.mergedOptions.organizerActor
);
}
get isDescriptionDifferentFromLocality(): boolean {
return (
this.event?.physicalAddress?.description !==
this.event?.physicalAddress?.locality &&
this.event?.physicalAddress?.description !== undefined
);
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_event-card";
a.card {
display: block;
background: $secondary;
@ -236,35 +216,31 @@ a.card {
margin-bottom: 15px;
margin-left: 0rem;
}
& > .media-content {
flex: 1;
width: 100%;
overflow-x: inherit;
}
}
.event-title {
font-size: 1.2rem;
line-height: 1.25rem;
font-size: 18px;
line-height: 24px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.4rem;
padding-bottom: 8px;
font-weight: bold;
}
.event-subtitle {
font-size: 0.85rem;
display: inline-flex;
flex-wrap: wrap;
color: #3c376e;
}
span {
width: 14rem;
display: block;
overflow: hidden;
flex-grow: 1;
text-overflow: ellipsis;
white-space: nowrap;
}
.organizer-name {
font-size: 14px;
}
}
}

View File

@ -14,9 +14,9 @@
/>
</span>
<b-icon v-else-if="icon" :icon="icon" size="is-medium" />
<p :class="{ 'padding-left': icon }">
<div class="content-wrapper" :class="{ 'padding-left': icon }">
<slot></slot>
</p>
</div>
</div>
</div>
</template>
@ -42,7 +42,7 @@ div.eventMetadataBlock {
align-items: center;
margin-bottom: 1.75rem;
p {
.content-wrapper {
overflow: hidden;
&.padding-left {

View File

@ -3,17 +3,55 @@
class="event-minimalist-card-wrapper"
:to="{ name: RouteName.EVENT, params: { uuid: event.uuid } }"
>
<date-calendar-icon
class="calendar-icon"
:date="event.beginsOn"
:small="true"
/>
<div class="title-info-wrapper">
<p class="event-minimalist-title">{{ event.title }}</p>
<p v-if="event.physicalAddress" class="has-text-grey">
{{ event.physicalAddress.description }}
</p>
<p v-else>
<div class="event-preview mr-0 ml-0">
<div>
<div class="date-component">
<date-calendar-icon :date="event.beginsOn" :small="true" />
</div>
<lazy-image-wrapper
:picture="event.picture"
:rounded="true"
style="height: 100%; position: absolute; top: 0; left: 0; width: 100%"
/>
</div>
</div>
<div class="title-info-wrapper has-text-grey-dark">
<h3 class="event-minimalist-title">
<b-tag
class="mr-2"
type="is-warning"
size="is-medium"
v-if="event.draft"
>{{ $t("Draft") }}</b-tag
>
{{ event.title }}
</h3>
<event-address
v-if="event.physicalAddress"
class="event-subtitle"
:physical-address="event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="event.options && event.options.isOnline"
>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
</div>
<div class="event-subtitle event-organizer" v-if="showOrganizer">
<figure
class="image is-24x24"
v-if="organizer(event) && organizer(event).avatar"
>
<img class="is-rounded" :src="organizer(event).avatar.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(event) }}
</span>
</div>
<p class="participant-metadata">
<b-icon icon="account-multiple" />
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$tc(
@ -64,30 +102,65 @@
</template>
<script lang="ts">
import { Component, Prop, Vue } from "vue-property-decorator";
import { IEvent } from "@/types/event.model";
import { IEvent, organizer, organizerDisplayName } from "@/types/event.model";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { ParticipantRole } from "@/types/enums";
import RouteName from "../../router/name";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import EventAddress from "@/components/Event/EventAddress.vue";
@Component({
components: {
DateCalendarIcon,
LazyImageWrapper,
EventAddress,
},
})
export default class EventMinimalistCard extends Vue {
@Prop({ required: true, type: Object }) event!: IEvent;
@Prop({ required: false, type: Boolean, default: false })
showOrganizer!: boolean;
RouteName = RouteName;
ParticipantRole = ParticipantRole;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
}
</script>
<style lang="scss" scoped>
@use "@/styles/_event-card";
@import "~bulma/sass/utilities/mixins.sass";
@import "@/variables.scss";
.event-minimalist-card-wrapper {
display: flex;
width: 100%;
display: grid;
grid-gap: 5px 10px;
grid-template-areas: "preview" "body";
color: initial;
align-items: flex-start;
@include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
}
.event-preview {
& > div {
position: relative;
height: 120px;
width: 100%;
div.date-component {
display: flex;
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
}
}
.calendar-icon {
margin-right: 1rem;
@ -97,11 +170,19 @@ export default class EventMinimalistCard extends Vue {
flex: 2;
.event-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-size: 1.25rem;
font-weight: 700;
padding-bottom: 5px;
font-size: 18px;
line-height: 24px;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: bold;
color: $title-color;
}
::v-deep .icon {
vertical-align: middle;
}
}
}

View File

@ -1,5 +1,5 @@
<template>
<article class="box">
<article class="box mb-5 mt-4">
<div class="identity-header">
<figure class="image is-24x24" v-if="participation.actor.avatar">
<img
@ -10,16 +10,39 @@
width="24"
/>
</figure>
<b-icon v-else icon="account-circle" />
{{ displayNameAndUsername(participation.actor) }}
</div>
<div class="list-card">
<div class="date-component">
<date-calendar-icon
:date="participation.event.beginsOn"
:small="true"
/>
</div>
<div class="content-and-actions">
<div class="event-preview mr-0 ml-0">
<div>
<div class="date-component">
<date-calendar-icon
:date="participation.event.beginsOn"
:small="true"
/>
</div>
<router-link
:to="{
name: RouteName.EVENT,
params: { uuid: participation.event.uuid },
}"
>
<lazy-image-wrapper
:rounded="true"
:picture="participation.event.picture"
style="
height: 100%;
position: absolute;
top: 0;
left: 0;
width: 100%;
"
/>
</router-link>
</div>
</div>
<div class="list-card-content">
<div class="title-wrapper">
<router-link
@ -31,59 +54,61 @@
<h3 class="title">{{ participation.event.title }}</h3>
</router-link>
</div>
<div class="participation-actor">
<span>
<b-icon
icon="earth"
v-if="participation.event.visibility === EventVisibility.PUBLIC"
/>
<b-icon
icon="link"
v-else-if="
participation.event.visibility === EventVisibility.UNLISTED
"
/>
<b-icon
icon="lock"
v-else-if="
participation.event.visibility === EventVisibility.PRIVATE
"
/>
</span>
<span
v-if="
participation.event.physicalAddress &&
participation.event.physicalAddress.locality
"
>{{ participation.event.physicalAddress.locality }} -</span
>
<i18n
tag="span"
path="Organized by {name}"
v-if="organizerActor.id !== currentActor.id"
>
<popover-actor-card
slot="name"
:actor="organizerActor"
:inline="true"
>
{{ organizerActor.displayName() }}
</popover-actor-card>
</i18n>
<span v-else>{{ $t("Organized by you") }}</span>
<event-address
v-if="participation.event.physicalAddress"
class="event-subtitle"
:physical-address="participation.event.physicalAddress"
/>
<div
class="event-subtitle"
v-else-if="
participation.event.options &&
participation.event.options.isOnline
"
>
<b-icon icon="video" />
<span>{{ $t("Online") }}</span>
</div>
<div>
<div class="event-subtitle event-organizer">
<figure
class="image is-24x24"
v-if="
organizer(participation.event) &&
organizer(participation.event).avatar
"
>
<img
class="is-rounded"
:src="organizer(participation.event).avatar.url"
alt=""
/>
</figure>
<b-icon v-else icon="account-circle" />
<span class="organizer-name">
{{ organizerDisplayName(participation.event) }}
</span>
</div>
<div class="event-subtitle event-participants">
<b-icon
:class="{ 'has-text-danger': lastSeatsLeft }"
icon="account-group"
/>
<span
class="participant-stats"
v-if="
![
ParticipantRole.PARTICIPANT,
ParticipantRole.NOT_APPROVED,
].includes(participation.role)
"
v-if="participation.role !== ParticipantRole.NOT_APPROVED"
>
<!-- Less than 10 seats left -->
<span class="has-text-danger" v-if="lastSeatsLeft">
{{
$t("{number} seats left", {
number: seatsLeft,
})
}}
</span>
<span
v-if="participation.event.options.maximumAttendeeCapacity !== 0"
v-else-if="
participation.event.options.maximumAttendeeCapacity !== 0
"
>
{{
$tc(
@ -111,28 +136,27 @@
)
}}
</span>
<span v-if="participation.event.participantStats.notApproved > 0">
<b-button
type="is-text"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
query: { role: ParticipantRole.NOT_APPROVED },
params: { eventId: participation.event.uuid },
})
"
>
{{
$tc(
"{count} requests waiting",
participation.event.participantStats.notApproved,
{
count: participation.event.participantStats.notApproved,
}
)
}}
</b-button>
</span>
<b-button
v-if="participation.event.participantStats.notApproved > 0"
type="is-text"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
query: { role: ParticipantRole.NOT_APPROVED },
params: { eventId: participation.event.uuid },
})
"
>
{{
$tc(
"{count} requests waiting",
participation.event.participantStats.notApproved,
{
count: participation.event.participantStats.notApproved,
}
)
}}
</b-button>
</span>
</div>
</div>
@ -233,7 +257,11 @@ import { mixins } from "vue-class-component";
import { RawLocation, Route } from "vue-router";
import { EventVisibility, ParticipantRole } from "@/types/enums";
import { IParticipant } from "../../types/participant.model";
import { IEventCardOptions } from "../../types/event.model";
import {
IEventCardOptions,
organizer,
organizerDisplayName,
} from "../../types/event.model";
import { displayNameAndUsername, IActor, IPerson } from "../../types/actor";
import ActorMixin from "../../mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "../../graphql/actor";
@ -241,6 +269,9 @@ import EventMixin from "../../mixins/event";
import RouteName from "../../router/name";
import { changeIdentity } from "../../utils/auth";
import PopoverActorCard from "../Account/PopoverActorCard.vue";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import EventAddress from "@/components/Event/EventAddress.vue";
import { PropType } from "vue";
const defaultOptions: IEventCardOptions = {
hideDate: true,
@ -254,6 +285,8 @@ const defaultOptions: IEventCardOptions = {
components: {
DateCalendarIcon,
PopoverActorCard,
LazyImageWrapper,
EventAddress,
},
apollo: {
currentActor: {
@ -261,11 +294,15 @@ const defaultOptions: IEventCardOptions = {
},
},
})
export default class EventListCard extends mixins(ActorMixin, EventMixin) {
export default class EventParticipationCard extends mixins(
ActorMixin,
EventMixin
) {
/**
* The participation associated
*/
@Prop({ required: true }) participation!: IParticipant;
@Prop({ required: true, type: Object as PropType<IParticipant> })
participation!: IParticipant;
/**
* Options are merged with default options
@ -281,6 +318,10 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
displayNameAndUsername = displayNameAndUsername;
organizerDisplayName = organizerDisplayName;
organizer = organizer;
RouteName = RouteName;
get mergedOptions(): IEventCardOptions {
@ -304,13 +345,13 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
participation.actor.id !== this.currentActor.id &&
participation.event.organizerActor
) {
const organizer = participation.event.organizerActor as IPerson;
await changeIdentity(this.$apollo.provider.defaultClient, organizer);
const organizerActor = participation.event.organizerActor as IPerson;
await changeIdentity(this.$apollo.provider.defaultClient, organizerActor);
this.$buefy.notification.open({
message: this.$t(
"Current identity has been changed to {identityName} in order to manage this event.",
{
identityName: organizer.preferredUsername,
identityName: organizerActor.preferredUsername,
}
) as string,
type: "is-info",
@ -330,10 +371,30 @@ export default class EventListCard extends mixins(ActorMixin, EventMixin) {
}
return this.participation.event.organizerActor;
}
get seatsLeft(): number | null {
if (this.participation.event.options.maximumAttendeeCapacity > 0) {
return (
this.participation.event.options.maximumAttendeeCapacity -
this.participation.event.participantStats.participant
);
}
return null;
}
get lastSeatsLeft(): boolean {
if (this.seatsLeft) {
return this.seatsLeft < 10;
}
return false;
}
}
</script>
<style lang="scss" scoped>
@use "@/styles/_event-card";
@import "~bulma/sass/utilities/mixins.sass";
article.box {
div.tag-container {
position: absolute;
@ -359,49 +420,67 @@ article.box {
.list-card {
display: flex;
padding: 0 6px;
padding: 0 6px 0 0;
position: relative;
flex-direction: column;
div.date-component {
align-self: flex-start;
padding: 5px;
position: absolute;
top: 0;
left: 0;
margin-top: 1px;
height: 0;
display: flex;
align-items: flex-end;
margin-bottom: 15px;
margin-left: 0rem;
}
.content-and-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
padding-bottom: 1rem;
display: grid;
grid-gap: 5px 10px;
grid-template-areas: "preview" "body" "actions";
@include tablet {
grid-template-columns: 1fr 3fr;
grid-template-areas: "preview body" "actions actions";
}
@include desktop {
grid-template-columns: 1fr 3fr 1fr;
grid-template-areas: "preview body actions";
}
.event-preview {
grid-area: preview;
& > div {
height: 128px;
width: 100%;
position: relative;
div.date-component {
display: flex;
position: absolute;
bottom: 5px;
left: 5px;
z-index: 1;
}
img {
width: 100%;
object-position: center;
object-fit: cover;
height: 100%;
}
}
}
.actions {
padding-right: 7.5px;
padding: 7px;
cursor: pointer;
align-self: center;
justify-self: center;
grid-area: actions;
}
div.list-card-content {
flex: 1;
padding: 5px;
min-width: 350px;
grid-area: body;
.participation-actor span,
.participant-stats span {
.participant-stats {
display: flex;
align-items: center;
padding: 0 5px;
button {
height: auto;
padding-top: 0;
}
}
div.title-wrapper {
@ -419,11 +498,11 @@ article.box {
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
font-weight: 400;
line-height: 1em;
font-size: 1.4em;
padding-bottom: 5px;
font-size: 18px;
line-height: 24px;
margin: auto 0;
font-weight: bold;
color: $title-color;
}
}
}
@ -434,9 +513,9 @@ article.box {
background: $yellow-2;
display: flex;
padding: 5px;
padding-left: calc(48px + 15px);
figure {
figure,
span.icon {
padding-right: 3px;
}
}

View File

@ -0,0 +1,90 @@
<template>
<div class="events-wrapper">
<div class="month-group" v-for="key of keys" :key="key">
<h2 class="is-size-5 month-name">
{{ monthName(groupEvents(key)[0]) }}
</h2>
<event-minimalist-card
class="py-4"
v-for="event in groupEvents(key)"
:key="event.id"
:event="event"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
</div>
</template>
<script lang="ts">
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventMinimalistCard from "./EventMinimalistCard.vue";
@Component({
components: {
EventMinimalistCard,
},
})
export default class GroupedMultiEventMinimalistCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
get monthlyGroupedEvents(): Map<string, IEvent[]> {
return this.events.reduce((acc: Map<string, IEvent[]>, event: IEvent) => {
const beginsOn = new Date(event.beginsOn);
const month = `${beginsOn.getUTCMonth()}-${beginsOn.getUTCFullYear()}`;
const monthEvents = acc.get(month) || [];
acc.set(month, [...monthEvents, event]);
return acc;
}, new Map());
}
get keys(): string[] {
return Array.from(this.monthlyGroupedEvents.keys()).sort((a, b) =>
b.localeCompare(a)
);
}
groupEvents(key: string): IEvent[] {
return this.monthlyGroupedEvents.get(key) || [];
}
monthName(event: IEvent): string {
const beginsOn = new Date(event.beginsOn);
return new Intl.DateTimeFormat(undefined, {
month: "long",
year: "numeric",
}).format(beginsOn);
}
}
</script>
<style lang="scss" scoped>
.events-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
.month-group {
.month-name {
text-transform: capitalize;
text-transform: capitalize;
display: inline-block;
position: relative;
font-size: 1.3rem;
&::after {
background: $orange-3;
position: absolute;
left: 0;
right: 0;
top: 100%;
content: "";
width: calc(100% + 30px);
height: 3px;
max-width: 150px;
}
}
}
</style>

View File

@ -0,0 +1,46 @@
<template>
<div class="multi-card-event">
<event-card
class="event-card"
v-for="event in events"
:event="event"
:key="event.uuid"
/>
</div>
</template>
<script lang="ts">
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventCard from "./EventCard.vue";
@Component({
components: {
EventCard,
},
})
export default class MultiCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
}
</script>
<style lang="scss" scoped>
.multi-card-event {
display: grid;
grid-auto-rows: 1fr;
grid-column-gap: 30px;
grid-row-gap: 30px;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
// @media (min-width: 400px) {
// grid-template-columns: repeat(2, 1fr);
// }
// @media (min-width: 800px) {
// grid-template-columns: repeat(4, 1fr);
// }
.event-card {
height: 100%;
display: flex;
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,38 @@
<template>
<div class="events-wrapper">
<event-minimalist-card
v-for="event in events"
:key="event.id"
:event="event"
:isCurrentActorMember="isCurrentActorMember"
:showOrganizer="showOrganizer"
/>
</div>
</template>
<script lang="ts">
import { IEvent } from "@/types/event.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import EventMinimalistCard from "./EventMinimalistCard.vue";
@Component({
components: {
EventMinimalistCard,
},
})
export default class MultiEventMinimalistCard extends Vue {
@Prop({ type: Array as PropType<IEvent[]>, required: true })
events!: IEvent[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
@Prop({ required: false, type: Boolean, default: false })
showOrganizer!: boolean;
}
</script>
<style lang="scss" scoped>
.events-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
</style>

View File

@ -151,6 +151,7 @@ A button to set your participation
>
</b-dropdown>
<b-button
rel="nofollow"
tag="router-link"
:to="{
name: RouteName.EVENT_PARTICIPATE_LOGGED_OUT,
@ -164,6 +165,7 @@ A button to set your participation
>
<b-button
tag="router-link"
rel="nofollow"
:to="{
name: RouteName.EVENT_PARTICIPATE_WITH_ACCOUNT,
params: { uuid: event.uuid },

View File

@ -47,6 +47,7 @@
</li>
<li>
<a
rel="external"
hreflang="en"
href="https://framagit.org/framasoft/mobilizon/blob/master/LICENSE"
>
@ -62,13 +63,16 @@
tag="span"
path="Powered by {mobilizon}. © 2018 - {date} The Mobilizon Contributors - Made with the financial support of {contributors}."
>
<a slot="mobilizon" href="https://joinmobilizon.org">{{
<a rel="external" slot="mobilizon" href="https://joinmobilizon.org">{{
$t("Mobilizon")
}}</a>
<span slot="date">{{ new Date().getFullYear() }}</span>
<a href="https://joinmobilizon.org/hall-of-fame" slot="contributors">{{
$t("more than 1360 contributors")
}}</a>
<a
rel="external"
href="https://joinmobilizon.org/hall-of-fame"
slot="contributors"
>{{ $t("more than 1360 contributors") }}</a
>
</i18n>
</div>
</footer>

View File

@ -4,6 +4,7 @@
<figure class="image is-24x24" v-if="member.actor.avatar">
<img class="is-rounded" :src="member.actor.avatar.url" alt="" />
</figure>
<b-icon v-else icon="account-circle" />
{{ displayNameAndUsername(member.actor) }}
</div>
<div class="card-content">
@ -47,7 +48,7 @@
</div>
</div>
<div class="content" v-if="member.parent.summary">
<p>{{ member.parent.summary }}</p>
<p v-html="member.parent.summary" />
</div>
</div>
<div>
@ -110,7 +111,8 @@ export default class GroupMemberCard extends Vue {
display: flex;
padding: 5px;
figure {
figure,
span.icon {
padding-right: 3px;
}
}

View File

@ -16,7 +16,7 @@
:width="width"
:height="height"
class="absolute top-0 left-0 transition-opacity duration-500"
:class="isLoaded ? 'opacity-100' : 'opacity-0'"
:class="{ isLoaded: isLoaded ? 'opacity-100' : 'opacity-0', rounded }"
alt=""
/>
</div>
@ -37,6 +37,7 @@ export default class LazyImage extends Vue {
@Prop({ type: String, required: false, default: null }) blurhash!: string;
@Prop({ type: Number, default: 1 }) width!: number;
@Prop({ type: Number, default: 1 }) height!: number;
@Prop({ type: Boolean, default: false }) rounded!: boolean;
inheritAttrs = false;
isLoaded = false;
@ -63,12 +64,14 @@ export default class LazyImage extends Vue {
onEnter(): void {
// Image is visible (means: has entered the viewport),
// so start loading by setting the src attribute
this.image.src = this.src;
if (this.image) {
this.image.src = this.src;
this.image.onload = () => {
// Image is loaded, so start fading in
this.isLoaded = true;
};
this.image.onload = () => {
// Image is loaded, so start fading in
this.isLoaded = true;
};
}
}
@Watch("src")
@ -113,5 +116,8 @@ img {
height: 100%;
object-fit: cover;
object-position: 50% 50%;
&.rounded {
border-radius: 8px;
}
}
</style>

View File

@ -5,6 +5,7 @@
:width="pictureOrDefault.metadata.width"
:height="pictureOrDefault.metadata.height"
:blurhash="pictureOrDefault.metadata.blurhash"
:rounded="rounded"
/>
</template>
<script lang="ts">
@ -34,6 +35,7 @@ const DEFAULT_PICTURE = {
export default class LazyImageWrapper extends Vue {
@Prop({ required: false, type: Object as PropType<IMedia | null> })
picture!: IMedia | null;
@Prop({ required: false, type: Boolean, default: false }) rounded!: boolean;
get pictureOrDefault(): Partial<IMedia> {
if (this.picture === null) {

View File

@ -58,7 +58,7 @@
tag="a"
href="https://mediation.koena.net/framasoft/mobilizon/"
target="_blank"
rel="noopener"
rel="noopener external"
hreflang="fr"
>
<img
@ -208,12 +208,8 @@ import RouteName from "../router/name";
@Component({
apollo: {
currentUser: {
query: CURRENT_USER_CLIENT,
},
currentActor: {
query: CURRENT_ACTOR_CLIENT,
},
currentUser: CURRENT_USER_CLIENT,
currentActor: CURRENT_ACTOR_CLIENT,
identities: {
query: IDENTITIES,
update: ({ identities }) =>

View File

@ -31,6 +31,7 @@ import { IEvent } from "@/types/event.model";
metaInfo() {
return {
title: this.$t("Participation with account") as string,
meta: [{ name: "robots", content: "noindex" }],
};
},
})

View File

@ -158,6 +158,7 @@ import { ApolloCache, FetchResult } from "@apollo/client/core";
metaInfo() {
return {
title: this.$t("Participation without account") as string,
meta: [{ name: "robots", content: "noindex" }],
};
},
})

View File

@ -133,6 +133,7 @@ import RouteName from "../../router/name";
metaInfo() {
return {
title: this.$t("Unlogged participation") as string,
meta: [{ name: "robots", content: "noindex" }],
};
},
})

View File

@ -0,0 +1,34 @@
<template>
<div class="posts-wrapper">
<post-list-item
v-for="post in posts"
:key="post.id"
:post="post"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
</template>
<script lang="ts">
import { IPost } from "@/types/post.model";
import { PropType } from "vue";
import { Component, Prop, Vue } from "vue-property-decorator";
import PostListItem from "./PostListItem.vue";
@Component({
components: {
PostListItem,
},
})
export default class MultiPostListItem extends Vue {
@Prop({ type: Array as PropType<IPost[]>, required: true }) posts!: IPost[];
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
}
</script>
<style lang="scss" scoped>
.posts-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
</style>

View File

@ -1,134 +0,0 @@
<template>
<router-link
class="post-minimalist-card-wrapper"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
<div class="title-info-wrapper">
<div class="media">
<div class="media-left">
<figure class="image is-96x96" v-if="post.picture">
<img :src="post.picture.url" alt="" />
</figure>
<b-icon v-else size="is-large" icon="post" />
</div>
<div class="media-content">
<p class="post-minimalist-title">{{ post.title }}</p>
<div class="metadata">
<b-tag type="is-warning" size="is-small" v-if="post.draft">{{
$t("Draft")
}}</b-tag>
<small
v-if="
post.visibility === PostVisibility.PUBLIC &&
isCurrentActorMember
"
class="has-text-grey-dark"
>
<b-icon icon="earth" size="is-small" />{{ $t("Public") }}</small
>
<small
v-else-if="post.visibility === PostVisibility.UNLISTED"
class="has-text-grey-dark"
>
<b-icon icon="link" size="is-small" />{{
$t("Accessible through link")
}}</small
>
<small
v-else-if="post.visibility === PostVisibility.PRIVATE"
class="has-text-grey-dark"
>
<b-icon icon="lock" size="is-small" />{{
$t("Accessible only to members", {
group: post.attributedTo.name,
})
}}</small
>
<small class="has-text-grey-dark">{{
$options.filters.formatDateTimeString(
new Date(post.insertedAt),
undefined,
false
)
}}</small>
<small class="has-text-grey-dark" v-if="isCurrentActorMember">{{
$t("Created by {username}", {
username: `@${usernameWithDomain(post.author)}`,
})
}}</small>
</div>
</div>
</div>
</div>
</router-link>
</template>
<script lang="ts">
import { usernameWithDomain } from "@/types/actor";
import { PostVisibility } from "@/types/enums";
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IPost } from "../../types/post.model";
@Component
export default class PostElementItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost;
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
PostVisibility = PostVisibility;
}
</script>
<style lang="scss" scoped>
.post-minimalist-card-wrapper {
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
.title-info-wrapper {
flex: 2;
.post-minimalist-title {
color: #3c376e;
font-family: "Liberation Sans", "Helvetica Neue", Roboto, Helvetica, Arial,
serif;
font-size: 1rem;
font-weight: 700;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.media .media-left {
& > span.icon {
height: 96px;
width: 96px;
}
& > figure.image > img {
object-fit: cover;
height: 100%;
object-position: center;
width: 100%;
}
}
.metadata {
& > span.tag {
margin-right: 5px;
}
& > small:not(:last-child):after {
content: "·";
padding: 0 5px;
}
}
}
}
</style>

View File

@ -3,53 +3,116 @@
class="post-minimalist-card-wrapper"
:to="{ name: RouteName.POST, params: { slug: post.slug } }"
>
<div class="title-info-wrapper">
<lazy-image-wrapper
:picture="post.picture"
:rounded="true"
style="height: 120px"
/>
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">{{ post.title }}</p>
<small class="has-text-grey-dark">{{
formatDistanceToNow(new Date(post.publishAt || post.insertedAt), {
locale: $dateFnsLocale,
addSuffix: true,
})
}}</small>
<p class="post-publication-date">
<b-icon icon="clock" />
<span class="has-text-grey-dark" v-if="isBeforeLastWeek">{{
publishedAt | formatDateTimeString(undefined, false, "short")
}}</span>
<span v-else>{{
formatDistanceToNow(publishedAt, {
locale: $dateFnsLocale,
addSuffix: true,
})
}}</span>
</p>
<b-taglist v-if="post.tags.length > 0" style="display: inline">
<b-icon icon="tag" />
<b-tag v-for="tag in post.tags" :key="tag.slug">{{ tag.title }}</b-tag>
</b-taglist>
<p class="post-publisher has-text-grey-dark" v-if="isCurrentActorMember">
<b-icon icon="account-edit" />
<i18n path="Published by {name}">
<b class="has-text-weight-medium" slot="name">{{
displayName(post.author)
}}</b>
</i18n>
</p>
</div>
</router-link>
</template>
<script lang="ts">
import { formatDistanceToNow } from "date-fns";
import { formatDistanceToNow, subWeeks, isBefore } from "date-fns";
import { Component, Prop, Vue } from "vue-property-decorator";
import RouteName from "../../router/name";
import { IPost } from "../../types/post.model";
import LazyImageWrapper from "@/components/Image/LazyImageWrapper.vue";
import { displayName } from "@/types/actor";
@Component
@Component({
components: {
LazyImageWrapper,
},
})
export default class PostListItem extends Vue {
@Prop({ required: true, type: Object }) post!: IPost;
@Prop({ required: false, type: Boolean, default: false })
isCurrentActorMember!: boolean;
RouteName = RouteName;
formatDistanceToNow = formatDistanceToNow;
displayName = displayName;
get publishedAt(): Date {
return new Date((this.post.publishAt || this.post.insertedAt) as Date);
}
get isBeforeLastWeek(): boolean {
return isBefore(this.publishedAt, subWeeks(new Date(), 1));
}
}
</script>
<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";
.post-minimalist-card-wrapper {
display: grid;
grid-gap: 5px 10px;
grid-template-areas: "preview" "body";
text-decoration: none;
display: flex;
width: 100%;
color: initial;
border-bottom: 1px solid #e9e9e9;
align-items: center;
@include desktop {
grid-template-columns: 200px 3fr;
grid-template-areas: "preview body";
}
.title-info-wrapper {
flex: 2;
.post-minimalist-title {
color: #3c376e;
font-family: Roboto, Helvetica, Arial, serif;
font-size: 16px;
font-weight: 500;
font-size: 18px;
line-height: 24px;
font-weight: 700;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
-webkit-line-clamp: 3;
}
}
::v-deep .icon {
vertical-align: middle;
margin-right: 5px;
}
::v-deep .tags {
display: inline;
span.tag {
max-width: 200px;
& > span {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
}

View File

@ -0,0 +1,219 @@
<template>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ $t("Share this post") }}</p>
</header>
<section class="modal-card-body is-flex" v-if="post">
<div class="container has-text-centered">
<b-notification
type="is-warning"
v-if="post.visibility !== PostVisibility.PUBLIC"
:closable="false"
>
{{
$t(
"This post is accessible only through it's link. Be careful where you post this link."
)
}}
</b-notification>
<b-field :label="$t('Post URL')" label-for="post-url-text">
<b-input
id="post-url-text"
ref="postURLInput"
:value="postURL"
expanded
/>
<p class="control">
<b-tooltip
:label="$t('URL copied to clipboard')"
:active="showCopiedTooltip"
always
type="is-success"
position="is-left"
>
<b-button
type="is-primary"
icon-right="content-paste"
native-type="button"
@click="copyURL"
@keyup.enter="copyURL"
:title="$t('Copy URL to clipboard')"
/>
</b-tooltip>
</p>
</b-field>
<div>
<a
:href="twitterShareUrl"
target="_blank"
rel="nofollow noopener"
title="Twitter"
><b-icon icon="twitter" size="is-large" type="is-primary"
/></a>
<a
:href="mastodonShareUrl"
class="mastodon"
target="_blank"
rel="nofollow noopener"
title="Mastodon"
>
<mastodon-logo />
</a>
<a
:href="facebookShareUrl"
target="_blank"
rel="nofollow noopener"
title="Facebook"
><b-icon icon="facebook" size="is-large" type="is-primary"
/></a>
<a
:href="whatsAppShareUrl"
target="_blank"
rel="nofollow noopener"
title="WhatsApp"
><b-icon icon="whatsapp" size="is-large" type="is-primary"
/></a>
<a
:href="telegramShareUrl"
class="telegram"
target="_blank"
rel="nofollow noopener"
title="Telegram"
>
<telegram-logo />
</a>
<a
:href="linkedInShareUrl"
target="_blank"
rel="nofollow noopener"
title="LinkedIn"
><b-icon icon="linkedin" size="is-large" type="is-primary"
/></a>
<a
:href="diasporaShareUrl"
class="diaspora"
target="_blank"
rel="nofollow noopener"
title="Diaspora"
>
<diaspora-logo />
</a>
<a
:href="emailShareUrl"
target="_blank"
rel="nofollow noopener"
title="Email"
><b-icon icon="email" size="is-large" type="is-primary"
/></a>
</div>
</div>
</section>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue, Ref } from "vue-property-decorator";
import { PostVisibility } from "@/types/enums";
import { IPost } from "../../types/post.model";
import DiasporaLogo from "../Share/DiasporaLogo.vue";
import MastodonLogo from "../Share/MastodonLogo.vue";
import TelegramLogo from "../Share/TelegramLogo.vue";
import { PropType } from "vue";
import RouteName from "@/router/name";
@Component({
components: {
DiasporaLogo,
MastodonLogo,
TelegramLogo,
},
})
export default class SharePostModal extends Vue {
@Prop({ type: Object as PropType<IPost>, required: true }) post!: IPost;
@Ref("postURLInput") readonly postURLInput!: any;
PostVisibility = PostVisibility;
RouteName = RouteName;
showCopiedTooltip = false;
get twitterShareUrl(): string {
return `https://twitter.com/intent/tweet?url=${encodeURIComponent(
this.postURL
)}&text=${this.post.title}`;
}
get facebookShareUrl(): string {
return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(
this.postURL
)}`;
}
get linkedInShareUrl(): string {
return `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(
this.postURL
)}&title=${this.post.title}`;
}
get whatsAppShareUrl(): string {
return `https://wa.me/?text=${encodeURIComponent(this.basicTextToEncode)}`;
}
get telegramShareUrl(): string {
return `https://t.me/share/url?url=${encodeURIComponent(
this.postURL
)}&text=${encodeURIComponent(this.post.title)}`;
}
get emailShareUrl(): string {
return `mailto:?to=&body=${this.postURL}&subject=${this.post.title}`;
}
get diasporaShareUrl(): string {
return `https://share.diasporafoundation.org/?title=${encodeURIComponent(
this.post.title
)}&url=${encodeURIComponent(this.postURL)}`;
}
get mastodonShareUrl(): string {
return `https://toot.karamoff.dev/?text=${encodeURIComponent(
this.basicTextToEncode
)}`;
}
get basicTextToEncode(): string {
return `${this.post.title}\r\n${this.postURL}`;
}
get postURL(): string {
if (this.post.id) {
return this.$router.resolve({
name: RouteName.POST,
params: { id: this.post.id },
}).href;
}
return "";
}
copyURL(): void {
this.postURLInput.$refs.input.select();
document.execCommand("copy");
this.showCopiedTooltip = true;
setTimeout(() => {
this.showCopiedTooltip = false;
}, 2000);
}
}
</script>
<style lang="scss" scoped>
.diaspora,
.mastodon,
.telegram {
::v-deep span svg {
width: 2.25rem;
}
}
</style>

View File

@ -61,7 +61,7 @@ export default class FolderItem extends Mixins(ResourceMixin) {
list = [];
groupObject: Record<string, unknown> = {
name: `folder-${this.resource.title}`,
name: `folder-${this.resource?.title}`,
pull: false,
put: ["resources"],
};

View File

@ -32,6 +32,7 @@ export default class EmptyContent extends Vue {
}
&.inline {
margin-top: 5vh;
margin-bottom: 2vh;
}
}
</style>

View File

@ -1,14 +1,25 @@
import gql from "graphql-tag";
export const ACTOR_FRAGMENT = gql`
fragment ActorFragment on Actor {
id
avatar {
id
url
}
type
preferredUsername
name
domain
summary
url
}
`;
export const FETCH_PERSON = gql`
query ($username: String!) {
fetchPerson(preferredUsername: $username) {
id
url
name
domain
summary
preferredUsername
...ActorFragment
suspended
mediaSize
avatar {
@ -34,6 +45,7 @@ export const FETCH_PERSON = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const GET_PERSON = gql`
@ -47,12 +59,7 @@ export const GET_PERSON = gql`
$membershipsLimit: Int
) {
person(id: $actorId) {
id
url
name
domain
summary
preferredUsername
...ActorFragment
suspended
mediaSize
avatar {
@ -98,14 +105,7 @@ export const GET_PERSON = gql`
role
insertedAt
parent {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
}
}
@ -115,6 +115,7 @@ export const GET_PERSON = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const PERSON_FRAGMENT = gql`
@ -126,6 +127,7 @@ export const PERSON_FRAGMENT = gql`
}
type
preferredUsername
domain
name
}
`;
@ -151,17 +153,11 @@ export const LIST_PROFILES = gql`
) {
total
elements {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
}
}
${ACTOR_FRAGMENT}
`;
export const UPDATE_DEFAULT_ACTOR = gql`
@ -179,12 +175,9 @@ export const CURRENT_ACTOR_CLIENT = gql`
query currentActor {
currentActor @client {
id
avatar {
id
url
}
preferredUsername
name
avatar
}
}
`;
@ -205,91 +198,6 @@ export const UPDATE_CURRENT_ACTOR_CLIENT = gql`
}
`;
export const LOGGED_USER_PARTICIPATIONS = gql`
query LoggedUserParticipations(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
id
preferredUsername
name
domain
summary
avatar {
id
url
}
}
attributedTo {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
}
participantStats {
going
notApproved
participant
}
options {
maximumAttendeeCapacity
remainingAttendeeCapacity
}
tags {
id
slug
title
}
}
id
role
actor {
id
preferredUsername
name
domain
summary
avatar {
id
url
}
}
}
}
}
}
`;
export const LOGGED_USER_DRAFTS = gql`
query LoggedUserDrafts($page: Int, $limit: Int) {
loggedUser {
@ -298,6 +206,7 @@ export const LOGGED_USER_DRAFTS = gql`
id
uuid
title
draft
picture {
id
url
@ -306,14 +215,7 @@ export const LOGGED_USER_DRAFTS = gql`
beginsOn
visibility
organizerActor {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
participantStats {
going
@ -326,6 +228,7 @@ export const LOGGED_USER_DRAFTS = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const LOGGED_USER_MEMBERSHIPS = gql`
@ -338,25 +241,10 @@ export const LOGGED_USER_MEMBERSHIPS = gql`
id
role
actor {
id
avatar {
id
url
}
preferredUsername
name
domain
...ActorFragment
}
parent {
id
preferredUsername
domain
name
type
avatar {
id
url
}
...ActorFragment
organizedEvents {
elements {
id
@ -370,34 +258,22 @@ export const LOGGED_USER_MEMBERSHIPS = gql`
}
}
invitedBy {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
}
}
}
}
${ACTOR_FRAGMENT}
`;
export const IDENTITIES = gql`
query Identities {
identities {
id
avatar {
id
url
}
type
preferredUsername
name
...ActorFragment
}
}
${ACTOR_FRAGMENT}
`;
export const PERSON_MEMBERSHIPS = gql`
@ -410,20 +286,10 @@ export const PERSON_MEMBERSHIPS = gql`
id
role
parent {
id
preferredUsername
name
domain
type
avatar {
id
url
}
...ActorFragment
}
invitedBy {
id
preferredUsername
name
...ActorFragment
}
insertedAt
updatedAt
@ -431,9 +297,10 @@ export const PERSON_MEMBERSHIPS = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const PERSON_MEMBERSHIP_GROUP = gql`
export const PERSON_STATUS_GROUP = gql`
query PersonMembershipGroup($id: ID!, $group: String!) {
person(id: $id) {
id
@ -443,19 +310,26 @@ export const PERSON_MEMBERSHIP_GROUP = gql`
id
role
parent {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
invitedBy {
id
preferredUsername
name
...ActorFragment
}
insertedAt
updatedAt
}
}
follows(group: $group) {
total
elements {
id
notify
approved
target_actor {
...ActorFragment
}
actor {
...ActorFragment
}
insertedAt
updatedAt
@ -463,6 +337,7 @@ export const PERSON_MEMBERSHIP_GROUP = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED = gql`
@ -478,19 +353,10 @@ export const GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED = gql`
id
role
parent {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
invitedBy {
id
preferredUsername
name
...ActorFragment
}
insertedAt
updatedAt
@ -498,6 +364,7 @@ export const GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const CREATE_PERSON = gql`
@ -513,16 +380,10 @@ export const CREATE_PERSON = gql`
summary: $summary
avatar: $avatar
) {
id
preferredUsername
name
summary
avatar {
id
url
}
...ActorFragment
}
}
${ACTOR_FRAGMENT}
`;
export const UPDATE_PERSON = gql`
@ -533,16 +394,10 @@ export const UPDATE_PERSON = gql`
$avatar: MediaInput
) {
updatePerson(id: $id, name: $name, summary: $summary, avatar: $avatar) {
id
preferredUsername
name
summary
avatar {
id
url
}
...ActorFragment
}
}
${ACTOR_FRAGMENT}
`;
export const DELETE_PERSON = gql`
@ -570,16 +425,10 @@ export const REGISTER_PERSON = gql`
summary: $summary
email: $email
) {
id
preferredUsername
name
summary
avatar {
id
url
}
...ActorFragment
}
}
${ACTOR_FRAGMENT}
`;
export const SUSPEND_PROFILE = gql`

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const DASHBOARD = gql`
query {
@ -14,14 +15,7 @@ export const DASHBOARD = gql`
}
}
lastGroupCreated {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
numberOfUsers
numberOfEvents
@ -33,31 +27,23 @@ export const DASHBOARD = gql`
numberOfConfirmedParticipationsToLocalEvents
}
}
${ACTOR_FRAGMENT}
`;
export const RELAY_FRAGMENT = gql`
fragment relayFragment on Follower {
id
actor {
id
preferredUsername
name
domain
type
summary
...ActorFragment
}
targetActor {
id
preferredUsername
name
domain
type
summary
...ActorFragment
}
approved
insertedAt
updatedAt
}
${ACTOR_FRAGMENT}
`;
export const RELAY_FOLLOWERS = gql`

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const COMMENT_FIELDS_FRAGMENT_NAME = "CommentFields";
export const COMMENT_FIELDS_FRAGMENT = gql`
@ -10,15 +11,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
visibility
local
actor {
avatar {
id
url
}
id
domain
preferredUsername
name
summary
...ActorFragment
}
totalReplies
insertedAt
@ -26,6 +19,7 @@ export const COMMENT_FIELDS_FRAGMENT = gql`
deletedAt
isAnnouncement
}
${ACTOR_FRAGMENT}
`;
export const COMMENT_RECURSIVE_FRAGMENT = gql`

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
fragment DiscussionBasicFields on Discussion {
@ -11,18 +12,13 @@ export const DISCUSSION_BASIC_FIELDS_FRAGMENT = gql`
id
text
actor {
id
preferredUsername
domain
avatar {
id
url
}
...ActorFragment
}
publishedAt
deletedAt
}
}
${ACTOR_FRAGMENT}
`;
export const DISCUSSION_FIELDS_FOR_REPLY_FRAGMENT = gql`
@ -35,26 +31,17 @@ export const DISCUSSION_FIELDS_FOR_REPLY_FRAGMENT = gql`
text
updatedAt
actor {
id
preferredUsername
domain
avatar {
id
url
}
...ActorFragment
}
}
actor {
id
preferredUsername
domain
...ActorFragment
}
creator {
id
preferredUsername
domain
...ActorFragment
}
}
${ACTOR_FRAGMENT}
`;
export const DISCUSSION_FIELDS_FRAGMENT = gql`
@ -70,28 +57,17 @@ export const DISCUSSION_FIELDS_FRAGMENT = gql`
deletedAt
publishedAt
actor {
id
domain
name
preferredUsername
avatar {
url
}
...ActorFragment
}
}
actor {
id
domain
name
preferredUsername
...ActorFragment
}
creator {
id
domain
name
preferredUsername
...ActorFragment
}
}
${ACTOR_FRAGMENT}
`;
export const CREATE_DISCUSSION = gql`
@ -121,14 +97,7 @@ export const GET_DISCUSSION = gql`
id
text
actor {
id
avatar {
id
url
}
name
domain
preferredUsername
...ActorFragment
}
insertedAt
updatedAt
@ -140,6 +109,7 @@ export const GET_DISCUSSION = gql`
}
}
${DISCUSSION_FIELDS_FRAGMENT}
${ACTOR_FRAGMENT}
`;
export const UPDATE_DISCUSSION = gql`
@ -171,16 +141,10 @@ export const DISCUSSION_COMMENT_CHANGED = gql`
deletedAt
publishedAt
actor {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
}
}
}
${ACTOR_FRAGMENT}
`;

View File

@ -1,71 +1,13 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import {
PARTICIPANTS_QUERY_FRAGMENT,
PARTICIPANT_QUERY_FRAGMENT,
} from "./participant";
import { TAG_FRAGMENT } from "./tags";
const PARTICIPANT_QUERY_FRAGMENT = gql`
fragment ParticipantQuery on Participant {
role
id
actor {
preferredUsername
avatar {
id
url
}
name
id
domain
}
event {
id
uuid
}
metadata {
cancellationToken
message
}
insertedAt
}
`;
const PARTICIPANTS_QUERY_FRAGMENT = gql`
fragment ParticipantsQuery on PaginatedParticipantList {
total
elements {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`;
const EVENT_OPTIONS_FRAGMENT = gql`
fragment EventOptions on EventOptions {
maximumAttendeeCapacity
remainingAttendeeCapacity
showRemainingAttendeeCapacity
anonymousParticipation
showStartTime
showEndTime
timezone
offers {
price
priceCurrency
url
}
participationConditions {
title
content
url
}
attendees
program
commentModeration
showParticipationPrice
hideOrganizerWhenGroupEvent
isOnline
}
`;
const FULL_EVENT_FRAGMENT = gql`
fragment FullEvent on Event {
id
@ -97,40 +39,13 @@ const FULL_EVENT_FRAGMENT = gql`
...AdressFragment
}
organizerActor {
avatar {
id
url
}
preferredUsername
domain
name
url
id
summary
...ActorFragment
}
contacts {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
...ActorFragment
}
attributedTo {
avatar {
id
url
}
preferredUsername
name
summary
domain
url
id
...ActorFragment
}
participantStats {
going
@ -156,18 +71,19 @@ const FULL_EVENT_FRAGMENT = gql`
}
}
physicalAddress {
id
description
...AdressFragment
}
organizerActor {
id
avatar {
id
url
}
preferredUsername
domain
name
...ActorFragment
}
attributedTo {
...ActorFragment
}
options {
...EventOptions
}
tags {
...TagFragment
}
}
options {
@ -183,6 +99,7 @@ const FULL_EVENT_FRAGMENT = gql`
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${ACTOR_FRAGMENT}
`;
export const FETCH_EVENT = gql`
@ -244,36 +161,28 @@ export const FETCH_EVENTS = gql`
# online_address,
# phone_address,
physicalAddress {
id
description
locality
...AdressFragment
}
organizerActor {
id
avatar {
id
url
}
preferredUsername
domain
name
...ActorFragment
}
attributedTo {
avatar {
id
url
}
preferredUsername
name
...ActorFragment
}
category
tags {
...TagFragment
}
options {
...EventOptions
}
}
}
}
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${ACTOR_FRAGMENT}
`;
export const CREATE_EVENT = gql`
@ -511,18 +420,15 @@ export const FETCH_GROUP_EVENTS = gql`
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
$organisedEventsLimit: Int
) {
group(preferredUsername: $name) {
id
preferredUsername
domain
name
...ActorFragment
organizedEvents(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
limit: $organisedEventsLimit
) {
elements {
id
@ -531,29 +437,33 @@ export const FETCH_GROUP_EVENTS = gql`
beginsOn
draft
options {
maximumAttendeeCapacity
...EventOptions
}
participantStats {
participant
notApproved
}
attributedTo {
id
preferredUsername
name
domain
...ActorFragment
}
organizerActor {
...ActorFragment
}
physicalAddress {
...AdressFragment
}
picture {
url
id
preferredUsername
name
domain
}
}
total
}
}
}
${EVENT_OPTIONS_FRAGMENT}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
`;
export const CLOSE_EVENTS = gql`
@ -570,13 +480,28 @@ export const CLOSE_EVENTS = gql`
url
}
tags {
slug
title
...TagFragment
}
options {
...EventOptions
}
physicalAddress {
...AdressFragment
}
attributedTo {
...ActorFragment
}
organizerActor {
...ActorFragment
}
__typename
}
}
}
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${ACTOR_FRAGMENT}
`;
export const EXPORT_EVENT_PARTICIPATIONS = gql`

View File

@ -0,0 +1,29 @@
import gql from "graphql-tag";
export const EVENT_OPTIONS_FRAGMENT = gql`
fragment EventOptions on EventOptions {
maximumAttendeeCapacity
remainingAttendeeCapacity
showRemainingAttendeeCapacity
anonymousParticipation
showStartTime
showEndTime
timezone
offers {
price
priceCurrency
url
}
participationConditions {
title
content
url
}
attendees
program
commentModeration
showParticipationPrice
hideOrganizerWhenGroupEvent
isOnline
}
`;

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const GROUP_FOLLOWERS = gql`
query (
@ -8,10 +9,7 @@ export const GROUP_FOLLOWERS = gql`
$approved: Boolean
) {
group(preferredUsername: $name) {
id
preferredUsername
name
domain
...ActorFragment
followers(
page: $followersPage
limit: $followersLimit
@ -21,14 +19,7 @@ export const GROUP_FOLLOWERS = gql`
elements {
id
actor {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
approved
insertedAt
@ -37,6 +28,7 @@ export const GROUP_FOLLOWERS = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const UPDATE_FOLLOWER = gql`
@ -47,3 +39,28 @@ export const UPDATE_FOLLOWER = gql`
}
}
`;
export const FOLLOW_GROUP = gql`
mutation FollowGroup($groupId: ID!, $notify: Boolean) {
followGroup(groupId: $groupId, notify: $notify) {
id
}
}
`;
export const UNFOLLOW_GROUP = gql`
mutation UnfollowGroup($groupId: ID!) {
unfollowGroup(groupId: $groupId) {
id
}
}
`;
export const UPDATE_GROUP_FOLLOW = gql`
mutation UpdateGroupFollow($followId: ID!, $notify: Boolean) {
updateGroupFollow(followId: $followId, notify: $notify) {
id
notify
}
}
`;

View File

@ -2,6 +2,10 @@ import gql from "graphql-tag";
import { DISCUSSION_BASIC_FIELDS_FRAGMENT } from "./discussion";
import { RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT } from "./resources";
import { POST_BASIC_FIELDS } from "./post";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { TAG_FRAGMENT } from "./tags";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
export const LIST_GROUPS = gql`
query ListGroups(
@ -23,12 +27,7 @@ export const LIST_GROUPS = gql`
limit: $limit
) {
elements {
id
url
name
domain
summary
preferredUsername
...ActorFragment
suspended
avatar {
id
@ -51,16 +50,12 @@ export const LIST_GROUPS = gql`
total
}
}
${ACTOR_FRAGMENT}
`;
export const GROUP_FIELDS_FRAGMENTS = gql`
fragment GroupFullFields on Group {
id
url
name
domain
summary
preferredUsername
...ActorFragment
suspended
visibility
openness
@ -76,6 +71,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
type
id
originId
url
}
avatar {
id
@ -101,7 +97,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
limit: $organisedEventsLimit
) {
elements {
id
@ -117,16 +113,23 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
notApproved
}
attributedTo {
id
preferredUsername
name
domain
...ActorFragment
}
organizerActor {
...ActorFragment
}
picture {
id
preferredUsername
name
domain
url
}
physicalAddress {
...AdressFragment
}
options {
...EventOptions
}
tags {
...TagFragment
}
}
total
@ -148,14 +151,7 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
id
role
actor {
id
name
domain
preferredUsername
avatar {
id
url
}
...ActorFragment
}
insertedAt
}
@ -197,6 +193,10 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
total
}
}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT}
`;
export const FETCH_GROUP = gql`
@ -205,7 +205,7 @@ export const FETCH_GROUP = gql`
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
$organisedEventsLimit: Int
$postsPage: Int
$postsLimit: Int
$membersPage: Int
@ -229,7 +229,7 @@ export const GET_GROUP = gql`
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
$organisedEventsLimit: Int
$postsPage: Int
$postsLimit: Int
$membersPage: Int
@ -263,21 +263,14 @@ export const CREATE_GROUP = gql`
banner: $banner
avatar: $avatar
) {
id
preferredUsername
name
domain
summary
avatar {
id
url
}
...ActorFragment
banner {
id
url
}
}
}
${ACTOR_FRAGMENT}
`;
export const UPDATE_GROUP = gql`
@ -303,23 +296,17 @@ export const UPDATE_GROUP = gql`
physicalAddress: $physicalAddress
manuallyApprovesFollowers: $manuallyApprovesFollowers
) {
id
preferredUsername
name
summary
...ActorFragment
visibility
openness
manuallyApprovesFollowers
avatar {
id
url
}
banner {
id
url
}
}
}
${ACTOR_FRAGMENT}
`;
export const DELETE_GROUP = gql`
@ -355,10 +342,7 @@ export const GROUP_TIMELINE = gql`
$limit: Int
) {
group(preferredUsername: $preferredUsername) {
id
preferredUsername
domain
name
...ActorFragment
activity(type: $type, author: $author, page: $page, limit: $limit) {
total
elements {
@ -371,18 +355,10 @@ export const GROUP_TIMELINE = gql`
}
type
author {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
group {
id
preferredUsername
...ActorFragment
}
object {
... on Event {
@ -396,14 +372,7 @@ export const GROUP_TIMELINE = gql`
... on Member {
id
actor {
id
name
preferredUsername
domain
avatar {
id
url
}
...ActorFragment
}
}
... on Resource {
@ -421,11 +390,7 @@ export const GROUP_TIMELINE = gql`
id
}
... on Group {
id
preferredUsername
domain
name
summary
...ActorFragment
visibility
openness
physicalAddress {
@ -433,9 +398,7 @@ export const GROUP_TIMELINE = gql`
}
banner {
id
}
avatar {
id
url
}
}
}
@ -443,4 +406,5 @@ export const GROUP_TIMELINE = gql`
}
}
}
${ACTOR_FRAGMENT}
`;

107
js/src/graphql/home.ts Normal file
View File

@ -0,0 +1,107 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import { TAG_FRAGMENT } from "./tags";
import { USER_SETTINGS_FRAGMENT } from "./user";
export const HOME_USER_QUERIES = gql`
query HomeUserQueries(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
locale
settings {
...UserSettingFragment
}
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
participantStats {
going
notApproved
participant
}
options {
...EventOptions
}
tags {
...TagFragment
}
}
id
role
actor {
...ActorFragment
}
}
}
followedGroupEvents {
total
elements {
profile {
id
}
group {
...ActorFragment
}
event {
id
uuid
title
beginsOn
picture {
url
}
attributedTo {
...ActorFragment
}
organizerActor {
...ActorFragment
}
options {
...EventOptions
}
physicalAddress {
...AdressFragment
}
tags {
...TagFragment
}
}
}
}
}
}
${USER_SETTINGS_FRAGMENT}
${ADDRESS_FRAGMENT}
${TAG_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${ACTOR_FRAGMENT}
`;

View File

@ -1,31 +1,19 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const MEMBER_FRAGMENT = gql`
fragment MemberFragment on Member {
id
role
parent {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
actor {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
insertedAt
}
${ACTOR_FRAGMENT}
`;
export const INVITE_MEMBER = gql`
@ -57,24 +45,13 @@ export const REJECT_INVITATION = gql`
export const GROUP_MEMBERS = gql`
query ($name: String!, $roles: String, $page: Int, $limit: Int) {
group(preferredUsername: $name) {
id
url
name
domain
preferredUsername
...ActorFragment
members(page: $page, limit: $limit, roles: $roles) {
elements {
id
role
actor {
id
name
domain
preferredUsername
avatar {
id
url
}
...ActorFragment
}
insertedAt
}
@ -82,6 +59,7 @@ export const GROUP_MEMBERS = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const UPDATE_MEMBER = gql`

View File

@ -0,0 +1,210 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { ADDRESS_FRAGMENT } from "./address";
import { EVENT_OPTIONS_FRAGMENT } from "./event_options";
import { TAG_FRAGMENT } from "./tags";
export const LOGGED_USER_PARTICIPATIONS = gql`
query LoggedUserParticipations(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
url
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
participantStats {
going
notApproved
participant
}
options {
...EventOptions
}
tags {
id
slug
title
}
physicalAddress {
...AdressFragment
}
}
id
role
actor {
...ActorFragment
}
}
}
}
}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
`;
export const LOGGED_USER_UPCOMING_EVENTS = gql`
query LoggedUserUpcomingEvents(
$afterDateTime: DateTime
$beforeDateTime: DateTime
$page: Int
$limit: Int
) {
loggedUser {
id
participations(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $page
limit: $limit
) {
total
elements {
event {
id
uuid
url
title
picture {
id
url
alt
}
beginsOn
visibility
organizerActor {
...ActorFragment
}
attributedTo {
...ActorFragment
}
participantStats {
going
notApproved
rejected
participant
}
options {
...EventOptions
}
tags {
id
slug
title
}
physicalAddress {
...AdressFragment
}
}
id
role
actor {
...ActorFragment
}
}
}
followedGroupEvents(afterDatetime: $afterDateTime) {
total
elements {
profile {
id
}
group {
...ActorFragment
}
event {
id
uuid
title
beginsOn
picture {
url
}
attributedTo {
...ActorFragment
}
organizerActor {
...ActorFragment
}
options {
...EventOptions
}
physicalAddress {
...AdressFragment
}
tags {
...TagFragment
}
participantStats {
going
notApproved
rejected
participant
}
}
}
}
}
}
${ACTOR_FRAGMENT}
${ADDRESS_FRAGMENT}
${EVENT_OPTIONS_FRAGMENT}
${TAG_FRAGMENT}
`;
export const PARTICIPANT_QUERY_FRAGMENT = gql`
fragment ParticipantQuery on Participant {
role
id
actor {
...ActorFragment
}
event {
id
uuid
}
metadata {
cancellationToken
message
}
insertedAt
}
${ACTOR_FRAGMENT}
`;
export const PARTICIPANTS_QUERY_FRAGMENT = gql`
fragment ParticipantsQuery on PaginatedParticipantList {
total
elements {
...ParticipantQuery
}
}
${PARTICIPANT_QUERY_FRAGMENT}
`;

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
import { TAG_FRAGMENT } from "./tags";
export const POST_FRAGMENT = gql`
@ -10,24 +11,10 @@ export const POST_FRAGMENT = gql`
body
draft
author {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
attributedTo {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
insertedAt
updatedAt
@ -49,6 +36,7 @@ export const POST_FRAGMENT = gql`
}
}
${TAG_FRAGMENT}
${ACTOR_FRAGMENT}
`;
export const POST_BASIC_FIELDS = gql`
@ -58,24 +46,10 @@ export const POST_BASIC_FIELDS = gql`
slug
url
author {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
attributedTo {
id
preferredUsername
name
domain
avatar {
id
url
}
...ActorFragment
}
insertedAt
updatedAt
@ -87,16 +61,18 @@ export const POST_BASIC_FIELDS = gql`
url
name
}
tags {
...TagFragment
}
}
${ACTOR_FRAGMENT}
${TAG_FRAGMENT}
`;
export const FETCH_GROUP_POSTS = gql`
query GroupPosts($preferredUsername: String!, $page: Int, $limit: Int) {
group(preferredUsername: $preferredUsername) {
id
preferredUsername
domain
name
...ActorFragment
posts(page: $page, limit: $limit) {
total
elements {

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const REPORTS = gql`
query Reports($status: ReportStatus, $page: Int, $limit: Int) {
@ -7,25 +8,10 @@ export const REPORTS = gql`
elements {
id
reported {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
reporter {
id
preferredUsername
name
avatar {
id
url
}
domain
type
...ActorFragment
}
event {
id
@ -41,31 +27,17 @@ export const REPORTS = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
const REPORT_FRAGMENT = gql`
fragment ReportFragment on Report {
id
reported {
id
preferredUsername
name
avatar {
id
url
}
domain
...ActorFragment
}
reporter {
id
preferredUsername
name
avatar {
id
url
}
domain
type
...ActorFragment
}
event {
id
@ -81,27 +53,14 @@ const REPORT_FRAGMENT = gql`
id
text
actor {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
}
notes {
id
content
moderator {
id
preferredUsername
name
avatar {
id
url
}
...ActorFragment
}
insertedAt
}
@ -110,6 +69,7 @@ const REPORT_FRAGMENT = gql`
status
content
}
${ACTOR_FRAGMENT}
`;
export const REPORT = gql`
@ -167,13 +127,7 @@ export const LOGS = gql`
id
action
actor {
id
preferredUsername
domain
avatar {
id
url
}
...ActorFragment
}
object {
... on Report {
@ -197,23 +151,14 @@ export const LOGS = gql`
uuid
}
actor {
id
preferredUsername
domain
name
...ActorFragment
}
}
... on Person {
id
preferredUsername
domain
name
...ActorFragment
}
... on Group {
id
preferredUsername
domain
name
...ActorFragment
}
... on User {
id
@ -226,4 +171,5 @@ export const LOGS = gql`
total
}
}
${ACTOR_FRAGMENT}
`;

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT = gql`
fragment ResourceMetadataBasicFields on ResourceMetadata {
@ -38,10 +39,7 @@ export const GET_RESOURCE = gql`
type
}
actor {
id
preferredUsername
name
domain
...ActorFragment
}
children(page: $page, limit: $limit) {
total
@ -68,6 +66,7 @@ export const GET_RESOURCE = gql`
}
}
}
${ACTOR_FRAGMENT}
${RESOURCE_METADATA_BASIC_FIELDS_FRAGMENT}
`;

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const SEARCH_EVENTS = gql`
query SearchEvents(
@ -58,18 +59,11 @@ export const SEARCH_GROUPS = gql`
) {
total
elements {
id
avatar {
id
url
}
domain
preferredUsername
name
__typename
...ActorFragment
}
}
}
${ACTOR_FRAGMENT}
`;
export const SEARCH_PERSONS = gql`
@ -77,18 +71,11 @@ export const SEARCH_PERSONS = gql`
searchPersons(term: $searchText, page: $page, limit: $limit) {
total
elements {
id
avatar {
id
url
}
domain
preferredUsername
name
__typename
...ActorFragment
}
}
}
${ACTOR_FRAGMENT}
`;
export const INTERACT = gql`
@ -110,16 +97,9 @@ export const INTERACT = gql`
__typename
}
... on Group {
id
avatar {
id
url
}
domain
preferredUsername
name
__typename
...ActorFragment
}
}
}
${ACTOR_FRAGMENT}
`;

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const GET_TODO = gql`
query GetTodo($id: ID!) {
@ -9,26 +10,17 @@ export const GET_TODO = gql`
dueDate
todoList {
actor {
id
preferredUsername
domain
name
...ActorFragment
}
title
id
}
assignedTo {
id
preferredUsername
domain
name
avatar {
id
url
}
...ActorFragment
}
}
}
${ACTOR_FRAGMENT}
`;
export const FETCH_TODO_LIST = gql`
@ -43,21 +35,17 @@ export const FETCH_TODO_LIST = gql`
title
status
assignedTo {
id
preferredUsername
domain
...ActorFragment
}
dueDate
}
}
actor {
id
preferredUsername
domain
name
...ActorFragment
}
}
}
${ACTOR_FRAGMENT}
`;
export const CREATE_TODO_LIST = gql`

View File

@ -1,4 +1,5 @@
import gql from "graphql-tag";
import { ACTOR_FRAGMENT } from "./actor";
export const CREATE_USER = gql`
mutation CreateUser($email: String!, $password: String!, $locale: String) {
@ -18,17 +19,12 @@ export const VALIDATE_USER = gql`
id
email
defaultActor {
id
preferredUsername
name
avatar {
id
url
}
...ActorFragment
}
}
}
}
${ACTOR_FRAGMENT}
`;
export const LOGGED_USER = gql`
@ -37,17 +33,12 @@ export const LOGGED_USER = gql`
id
email
defaultActor {
id
preferredUsername
name
avatar {
id
url
}
...ActorFragment
}
provider
}
}
${ACTOR_FRAGMENT}
`;
export const CHANGE_PASSWORD = gql`
@ -228,14 +219,7 @@ export const LIST_USERS = gql`
confirmedAt
disabled
actors {
id
preferredUsername
avatar {
id
url
}
name
summary
...ActorFragment
}
settings {
timezone
@ -243,6 +227,7 @@ export const LIST_USERS = gql`
}
}
}
${ACTOR_FRAGMENT}
`;
export const GET_USER = gql`
@ -263,13 +248,7 @@ export const GET_USER = gql`
id
}
actors {
id
preferredUsername
name
avatar {
id
url
}
...ActorFragment
}
participations {
total
@ -277,6 +256,7 @@ export const GET_USER = gql`
role
}
}
${ACTOR_FRAGMENT}
`;
export const UPDATE_USER_LOCALE = gql`

View File

@ -1021,7 +1021,6 @@
"A member has been updated": "A member has been updated",
"User settings": "User settings",
"You changed your email or password": "You changed your email or password",
"Organized by you": "Organized by you",
"Move resource to the root folder": "Move resource to the root folder",
"Share this group": "Share this group",
"This group is accessible only through it's link. Be careful where you post this link.": "This group is accessible only through it's link. Be careful where you post this link.",
@ -1202,6 +1201,30 @@
"{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
"Back to top": "Back to top",
"Powered by Mobilizon": "Powered by Mobilizon",
"Instance follows": "Instance follows",
"The event is fully online": "The event is fully online"
"The event is fully online": "The event is fully online",
"Follow": "Follow",
"Cancel follow request": "Cancel follow request",
"Unfollow": "Unfollow",
"your notification settings": "your notification settings",
"You will receive notifications about this group's public activity depending on %{notification_settings}.": "You will receive notifications about this group's public activity depending on %{notification_settings}.",
"Online": "Online",
"That you follow or of which you are a member": "That you follow or of which you are a member",
"{number} seats left": "{number} seats left",
"Published by {name}": "Published by {name}",
"Share this post": "Share this post",
"This post is accessible only through it's link. Be careful where you post this link.": "This post is accessible only through it's link. Be careful where you post this link.",
"Post URL": "Post URL",
"Are you sure you want to delete this post? This action cannot be reverted.": "Are you sure you want to delete this post? This action cannot be reverted.",
"Attending": "Attending",
"From my groups": "From my groups",
"You don't have any upcoming events. Maybe try another filter?": "You don't have any upcoming events. Maybe try another filter?",
"Leave group": "Leave group",
"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.": "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.",
"Upcoming events from your groups": "Upcoming events from your groups",
"Accessible only by link": "Accessible only by link",
"Report this post": "Report this post",
"Post {eventTitle} reported": "Post {eventTitle} reported",
"You have attended {count} events in the past.": "You have not attended any events in the past.|You have attended one event in the past.|You have attended {count} events in the past.",
"Showing events starting on": "Showing events starting on",
"Showing events before": "Showing events before"
}

View File

@ -488,7 +488,6 @@
"Instance administrator": "Administrador de instancia",
"Instance configuration": "Configuración de instancia",
"Instance feeds": "Flujos de instancias",
"Instance follows": "La instancia sigue",
"Instance languages": "Idiomas de instancia",
"Instance rules": "Reglas de instancia",
"Instance settings": "Configuraciones de instancia",

View File

@ -1267,8 +1267,6 @@
"Time in your timezone ({timezone})": "Heure dans votre fuseau horaire ({timezone})",
"Export": "Export",
"Times in your timezone ({timezone})": "Heures dans votre fuseau horaire ({timezone})",
"has loaded": "a chargé",
"Skip to main": "",
"Navigated to {pageTitle}": "Navigué vers {pageTitle}",
"Comment body": "Corps du commentaire",
"Confirm participation": "Confirmer la participation",
@ -1308,6 +1306,29 @@
"{timezoneLongName} ({timezoneShortName})": "{timezoneLongName} ({timezoneShortName})",
"Back to top": "Retour en haut",
"Powered by Mobilizon": "Propulsé par Mobilizon",
"Instance follows": "Abonnements de l'instance",
"The event is fully online": "L'événement est entièrement en ligne"
"The event is fully online": "L'événement est entièrement en ligne",
"Follow": "Suivre",
"Cancel follow request": "Annuler la demande de suivi",
"Unfollow": "Ne plus suivre",
"your notification settings": "vos paramètres de notification",
"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}.",
"Online": "En ligne",
"That you follow or of which you are a member": "Que vous suivez ou dont vous êtes membre",
"{number} seats left": "{number} places restantes",
"Published by {name}": "Publié par {name}",
"Share this post": "Partager ce billet",
"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.",
"Post URL": "URL du billet",
"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.",
"Attending": "Participant⋅e",
"From my groups": "De mes groupes",
"You don't have any upcoming events. Maybe try another filter?": "Vous n'avez pas d'événements à venir. Essayez peut-être un autre filtre ?",
"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 certain⋅e de vouloir quitter le groupe {groupName}? Vous perdrez accès au contenu privé de ce groupe. Cette action ne peut être annulée.",
"Upcoming events from your groups": "Événements de vos groupes à venir",
"Accessible only by link": "Accessible uniquement par lien",
"Report this post": "Signaler ce billet",
"Post {eventTitle} reported": "Billet {eventTitle} signalé",
"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é.",
"Showing events starting on": "Afficher les événements à partir de",
"Showing events before": "Afficher les événements avant"
}

View File

@ -1,13 +1,20 @@
import {
CURRENT_ACTOR_CLIENT,
GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
PERSON_MEMBERSHIP_GROUP,
PERSON_STATUS_GROUP,
} from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import { DELETE_GROUP, FETCH_GROUP } from "@/graphql/group";
import RouteName from "@/router/name";
import { IActor, IGroup, IPerson, usernameWithDomain } from "@/types/actor";
import {
IActor,
IFollower,
IGroup,
IPerson,
usernameWithDomain,
} from "@/types/actor";
import { MemberRole } from "@/types/enums";
import { Component, Vue } from "vue-property-decorator";
import { Route } from "vue-router";
const now = new Date();
@ -31,7 +38,7 @@ const now = new Date();
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
@ -100,6 +107,27 @@ export default class GroupMixin extends Vue {
);
}
get isCurrentActorFollowing(): boolean {
return this.currentActorFollow?.approved === true;
}
get isCurrentActorPendingFollow(): boolean {
return this.currentActorFollow?.approved === false;
}
get isCurrentActorFollowingNotify(): boolean {
return (
this.isCurrentActorFollowing && this.currentActorFollow?.notify === true
);
}
get currentActorFollow(): IFollower | null {
if (this.person?.follows?.total > 0) {
return this.person?.follows?.elements[0];
}
return null;
}
handleErrors(errors: any[]): void {
if (
errors.some((error) => error.status_code === 404) ||
@ -108,4 +136,28 @@ export default class GroupMixin extends Vue {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
confirmDeleteGroup(): void {
this.$buefy.dialog.confirm({
title: this.$t("Delete group") as string,
message: this.$t(
"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>."
) as string,
confirmText: this.$t("Delete group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.deleteGroup(),
});
}
async deleteGroup(): Promise<Route> {
await this.$apollo.mutate<{ deleteGroup: IGroup }>({
mutation: DELETE_GROUP,
variables: {
groupId: this.group.id,
},
});
return this.$router.push({ name: RouteName.MY_GROUPS });
}
}

64
js/src/mixins/post.ts Normal file
View File

@ -0,0 +1,64 @@
import { DELETE_POST, FETCH_POST } from "@/graphql/post";
import { usernameWithDomain } from "@/types/actor";
import { IPost } from "@/types/post.model";
import { Component, Vue } from "vue-property-decorator";
import RouteName from "../router/name";
@Component({
apollo: {
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
},
})
export default class PostMixin extends Vue {
post!: IPost;
RouteName = RouteName;
protected async openDeletePostModal(): Promise<void> {
this.$buefy.dialog.confirm({
type: "is-danger",
title: this.$t("Delete post") as string,
message: this.$t(
"Are you sure you want to delete this post? This action cannot be reverted."
) as string,
onConfirm: () => this.deletePost(),
});
}
async deletePost(): Promise<void> {
const { data } = await this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: this.post.id,
},
});
if (data && this.post.attributedTo) {
this.$router.push({
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(this.post.attributedTo),
},
});
}
}
handleErrors(errors: any[]): void {
if (errors.some((error) => error.status_code === 404)) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
}

View File

@ -52,6 +52,7 @@ export const eventRoutes: RouteConfig[] = [
path: "/events/me",
name: EventRouteName.MY_EVENTS,
component: myEvents,
props: true,
meta: {
requiredAuth: true,
announcer: { message: (): string => i18n.t("My events") as string },

View File

@ -18,6 +18,7 @@ export enum GroupsRouteName {
POSTS = "POSTS",
GROUP_EVENTS = "GROUP_EVENTS",
GROUP_JOIN = "GROUP_JOIN",
GROUP_FOLLOW = "GROUP_FOLLOW",
TIMELINE = "TIMELINE",
}
@ -149,6 +150,14 @@ export const groupsRoutes: RouteConfig[] = [
name: GroupsRouteName.GROUP_JOIN,
meta: { requiredAuth: false, announcer: { skip: true } },
},
{
path: "/@:preferredUsername/follow",
component: (): Promise<ImportedComponent> =>
import("@/components/Group/JoinGroupWithAccount.vue"),
props: true,
name: GroupsRouteName.GROUP_FOLLOW,
meta: { requiredAuth: false, announcer: { skip: true } },
},
{
path: "/@:preferredUsername/timeline",
name: GroupsRouteName.TIMELINE,

View File

@ -0,0 +1,18 @@
.event-organizer {
display: flex;
align-items: center;
.organizer-name {
padding-left: 5px;
font-weight: 600;
}
}
.event-subtitle {
display: flex;
align-items: center;
& > span:not(.icon) {
padding-left: 5px;
}
}

View File

@ -5,4 +5,5 @@ export interface IFollower {
actor: IActor;
targetActor: IActor;
approved: boolean;
notify?: boolean;
}

View File

@ -1,3 +1,4 @@
export * from "./actor.model";
export * from "./group.model";
export * from "./person.model";
export * from "./follower.model";

View File

@ -6,12 +6,14 @@ import type { Paginate } from "../paginate";
import type { IParticipant } from "../participant.model";
import type { IMember } from "./member.model";
import type { IFeedToken } from "../feedtoken.model";
import { IFollower } from "./follower.model";
export interface IPerson extends IActor {
feedTokens: IFeedToken[];
goingToEvents: IEvent[];
participations: Paginate<IParticipant>;
memberships: Paginate<IMember>;
follows: Paginate<IFollower>;
user?: ICurrentUser;
}
@ -31,6 +33,7 @@ export class Person extends Actor implements IPerson {
this.patch(hash);
}
follows!: Paginate<IFollower>;
patch(hash: IPerson | Record<string, unknown>): void {
Object.assign(this, hash);

View File

@ -3,6 +3,7 @@ import type { IPerson } from "@/types/actor/person.model";
import type { Paginate } from "./paginate";
import type { IParticipant } from "./participant.model";
import { ICurrentUserRole, INotificationPendingEnum } from "./enums";
import { IFollowedGroupEvent } from "./followedGroupEvent.model";
export interface ICurrentUser {
id: string;
@ -47,6 +48,7 @@ export interface IUser extends ICurrentUser {
drafts: IEvent[];
settings: IUserSettings;
activitySettings: IActivitySetting[];
followedGroupEvents: Paginate<IFollowedGroupEvent>;
locale: string;
provider?: string;
lastSignInAt: string;

View File

@ -4,7 +4,7 @@ import type { ITag } from "@/types/tag.model";
import type { IMedia } from "@/types/media.model";
import type { IComment } from "@/types/comment.model";
import type { Paginate } from "@/types/paginate";
import { Actor, Group } from "./actor";
import { Actor, displayName, Group } from "./actor";
import type { IActor, IGroup, IPerson } from "./actor";
import type { IParticipant } from "./participant.model";
import { EventOptions } from "./event-options.model";
@ -257,3 +257,21 @@ export function toEditJSON(event: IEditableEvent): IEventEditJSON {
})),
};
}
export function organizer(event: IEvent): IActor | null {
if (event.attributedTo?.id) {
return event.attributedTo;
}
if (event.organizerActor) {
return event.organizerActor;
}
return null;
}
export function organizerDisplayName(event: IEvent): string | null {
const organizerActor = organizer(event);
if (organizerActor) {
return displayName(organizerActor);
}
return null;
}

View File

@ -0,0 +1,10 @@
import { IEvent } from "./event.model";
import { IPerson, IGroup } from "./actor";
import { IUser } from "./current-user.model";
export interface IFollowedGroupEvent {
profile: IPerson;
group: IGroup;
user: IUser;
event: IEvent;
}

View File

@ -53,7 +53,7 @@ $success: #0d8758;
$success-invert: findColorInvert($success);
$info: #36bcd4;
$info-invert: findColorInvert($info);
$danger: #ff2e54;
$danger: #cd2026;
$danger-invert: findColorInvert($danger);
$link: $primary;
$link-invert: $primary-invert;

View File

@ -89,7 +89,7 @@ import RouteName from "../../router/name";
},
metaInfo() {
return {
title: this.$t("Instance follows") as string,
title: this.$t("Federation") as string,
};
},
})

View File

@ -89,7 +89,7 @@ import { MemberRole } from "@/types/enums";
import {
CURRENT_ACTOR_CLIENT,
GROUP_MEMBERSHIP_SUBSCRIPTION_CHANGED,
PERSON_MEMBERSHIP_GROUP,
PERSON_STATUS_GROUP,
} from "@/graphql/actor";
import { IMember } from "@/types/actor/member.model";
import EmptyContent from "@/components/Utils/EmptyContent.vue";
@ -116,7 +116,7 @@ const DISCUSSIONS_PER_PAGE = 10;
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {

View File

@ -606,8 +606,7 @@ import {
CURRENT_ACTOR_CLIENT,
IDENTITIES,
LOGGED_USER_DRAFTS,
LOGGED_USER_PARTICIPATIONS,
PERSON_MEMBERSHIP_GROUP,
PERSON_STATUS_GROUP,
} from "../../graphql/actor";
import {
displayNameAndUsername,
@ -635,6 +634,7 @@ import { IEventOptions } from "@/types/event-options.model";
import { USER_SETTINGS } from "@/graphql/user";
import { IUser } from "@/types/current-user.model";
import { IAddress } from "@/types/address.model";
import { LOGGED_USER_PARTICIPATIONS } from "@/graphql/participant";
const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
@ -669,7 +669,7 @@ const DEFAULT_LIMIT_NUMBER_OF_PLACES = 10;
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {

View File

@ -345,15 +345,7 @@
<h3 class="title has-text-centered">
{{ $t("These events may interest you") }}
</h3>
<div class="columns">
<div
class="column is-one-third-desktop"
v-for="relatedEvent in event.relatedEvents"
:key="relatedEvent.uuid"
>
<EventCard :event="relatedEvent" />
</div>
</div>
<multi-card :events="event.relatedEvents" />
</section>
<b-modal
:active.sync="isReportModalActive"
@ -496,15 +488,12 @@ import {
FETCH_EVENT,
JOIN_EVENT,
} from "../../graphql/event";
import {
CURRENT_ACTOR_CLIENT,
PERSON_MEMBERSHIP_GROUP,
} from "../../graphql/actor";
import { CURRENT_ACTOR_CLIENT, PERSON_STATUS_GROUP } from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import { IActor, IPerson, Person, usernameWithDomain } from "../../types/actor";
import { GRAPHQL_API_ENDPOINT } from "../../api/_entrypoint";
import DateCalendarIcon from "../../components/Event/DateCalendarIcon.vue";
import EventCard from "../../components/Event/EventCard.vue";
import MultiCard from "../../components/Event/MultiCard.vue";
import ReportModal from "../../components/Report/ReportModal.vue";
import { IReport } from "../../types/report.model";
import { CREATE_REPORT } from "../../graphql/report";
@ -539,7 +528,7 @@ import { IUser } from "@/types/current-user.model";
@Component({
components: {
Subtitle,
EventCard,
MultiCard,
BIcon,
DateCalendarIcon,
ReportModal,
@ -579,7 +568,6 @@ import { IUser } from "@/types/current-user.model";
apollo: {
event: {
query: FETCH_EVENT,
fetchPolicy: "cache-and-network",
variables() {
return {
uuid: this.uuid,
@ -593,7 +581,6 @@ import { IUser } from "@/types/current-user.model";
loggedUser: USER_SETTINGS,
participations: {
query: EVENT_PERSON_PARTICIPATION,
fetchPolicy: "cache-and-network",
variables() {
return {
eventId: this.event.id,
@ -623,8 +610,7 @@ import { IUser } from "@/types/current-user.model";
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
fetchPolicy: "cache-and-network",
query: PERSON_STATUS_GROUP,
variables() {
return {
id: this.currentActor.id,

View File

@ -52,14 +52,10 @@
{{ showPassedEvents ? $t("Past events") : $t("Upcoming events") }}
</subtitle>
<b-switch v-model="showPassedEvents">{{ $t("Past events") }}</b-switch>
<transition-group name="list" tag="div" class="event-list">
<EventListViewCard
v-for="event in group.organizedEvents.elements"
:key="event.id"
:event="event"
:options="{ memberofGroup: isCurrentActorMember }"
/>
</transition-group>
<grouped-multi-event-minimalist-card
:events="group.organizedEvents.elements"
:isCurrentActorMember="isCurrentActorMember"
/>
<b-message
v-if="
group.organizedEvents.elements.length === 0 &&
@ -88,7 +84,7 @@ import { Component } from "vue-property-decorator";
import { mixins } from "vue-class-component";
import RouteName from "@/router/name";
import Subtitle from "@/components/Utils/Subtitle.vue";
import EventListViewCard from "@/components/Event/EventListViewCard.vue";
import GroupedMultiEventMinimalistCard from "@/components/Event/GroupedMultiEventMinimalistCard.vue";
import { PERSON_MEMBERSHIPS } from "@/graphql/actor";
import GroupMixin from "@/mixins/group";
import { IMember } from "@/types/actor/member.model";
@ -120,14 +116,14 @@ const EVENTS_PAGE_LIMIT = 10;
beforeDateTime: this.showPassedEvents ? new Date() : null,
afterDateTime: this.showPassedEvents ? null : new Date(),
organisedEventsPage: this.eventsPage,
organisedEventslimit: EVENTS_PAGE_LIMIT,
organisedEventsLimit: EVENTS_PAGE_LIMIT,
};
},
},
},
components: {
Subtitle,
EventListViewCard,
GroupedMultiEventMinimalistCard,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@ -18,110 +18,175 @@
>
</div>
<b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="futureParticipations.length > 0">
<subtitle>
{{ $t("Upcoming") }}
</subtitle>
<transition-group name="list" tag="p">
<div v-for="month in monthlyFutureParticipations" :key="month[0]">
<span class="upcoming-month">{{ month[0] }}</span>
<EventListCard
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
v-if="
hasMoreFutureParticipations && futureParticipations.length === limit
"
@click="loadMoreFutureParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
<div class="wrapper">
<div class="event-filter">
<b-field grouped group-multiline>
<b-field>
<b-switch v-model="showUpcoming">{{
showUpcoming ? $t("Upcoming events") : $t("Past events")
}}</b-switch>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showDrafts">{{ $t("Drafts") }}</b-checkbox>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showAttending">{{
$t("Attending")
}}</b-checkbox>
</b-field>
<b-field v-if="showUpcoming">
<b-checkbox v-model="showMyGroups">{{
$t("From my groups")
}}</b-checkbox>
</b-field>
<p v-if="!showUpcoming">
{{
$tc(
"You have attended {count} events in the past.",
pastParticipations.total,
{
count: pastParticipations.total,
}
)
}}
</p>
<b-field
class="date-filter"
expanded
:label="
showUpcoming
? $t('Showing events starting on')
: $t('Showing events before')
"
>
<b-datepicker v-model="dateFilter" />
<b-button
@click="dateFilter = new Date()"
class="reset-area"
icon-left="close"
:title="$t('Clear date filter field')"
/>
</b-field>
</b-field>
</div>
<div class="my-events">
<section
class="py-4"
v-if="showUpcoming && showDrafts && drafts.length > 0"
>
</div>
</section>
<section v-if="drafts.length > 0">
<subtitle>
{{ $t("Drafts") }}
</subtitle>
<div class="columns is-multiline">
<EventCard
v-for="draft in drafts"
:key="draft.uuid"
:event="draft"
class="is-one-quarter-desktop column"
/>
</div>
</section>
<section v-if="pastParticipations.length > 0">
<subtitle>
{{ $t("Past events") }}
</subtitle>
<transition-group name="list" tag="p">
<div v-for="month in monthlyPastParticipations" :key="month[0]">
<span class="past-month">{{ month[0] }}</span>
<EventListCard
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
<multi-event-minimalist-card :events="drafts" :showOrganizer="true" />
</section>
<section
class="py-4"
v-if="
hasMorePastParticipations && pastParticipations.length === limit
showUpcoming && monthlyFutureEvents && monthlyFutureEvents.size > 0
"
>
<transition-group name="list" tag="p">
<div
class="mb-5"
v-for="month in monthlyFutureEvents"
:key="month[0]"
>
<span class="upcoming-month">{{ month[0] }}</span>
<div v-for="element in month[1]" :key="element.id">
<event-participation-card
v-if="'role' in element"
:participation="element"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
<event-minimalist-card
v-else-if="
!monthParticipationsIds(month[1]).includes(element.id)
"
:event="element"
class="participation"
/>
</div>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
v-if="
hasMoreFutureParticipations &&
futureParticipations &&
futureParticipations.length === limit
"
@click="loadMoreFutureParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
>
</div>
</section>
<section
class="has-text-centered not-found"
v-if="
showUpcoming &&
monthlyFutureEvents &&
monthlyFutureEvents.size === 0 &&
!$apollo.loading
"
@click="loadMorePastParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
>
</div>
</section>
<section
class="has-text-centered not-found"
v-if="
futureParticipations.length === 0 &&
pastParticipations.length === 0 &&
!$apollo.loading
"
>
<div class="columns is-vertical is-centered">
<div class="column is-three-quarters">
<div class="img-container" :class="{ webp: supportsWebPFormat }" />
<div class="content has-text-centered">
<p>
{{ $t("You didn't create or join any event yet.") }}
<i18n path="Do you wish to {create_event} or {explore_events}?">
<router-link
:to="{ name: RouteName.CREATE_EVENT }"
slot="create_event"
>{{ $t("create an event") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_events"
>{{ $t("explore the events") }}</router-link
>
</i18n>
{{
$t(
"You don't have any upcoming events. Maybe try another filter?"
)
}}
</p>
<i18n
path="Do you wish to {create_event} or {explore_events}?"
tag="p"
>
<router-link
:to="{ name: RouteName.CREATE_EVENT }"
slot="create_event"
>{{ $t("create an event") }}</router-link
>
<router-link
:to="{ name: RouteName.SEARCH }"
slot="explore_events"
>{{ $t("explore the events") }}</router-link
>
</i18n>
</div>
</div>
</section>
<section v-if="!showUpcoming && pastParticipations.elements.length > 0">
<transition-group name="list" tag="p">
<div v-for="month in monthlyPastParticipations" :key="month[0]">
<span class="past-month">{{ month[0] }}</span>
<event-participation-card
v-for="participation in month[1]"
:key="participation.id"
:participation="participation"
:options="{ hideDate: false }"
@event-deleted="eventDeleted"
class="participation"
/>
</div>
</transition-group>
<div class="columns is-centered">
<b-button
class="column is-narrow"
v-if="
hasMorePastParticipations &&
pastParticipations.elements.length === limit
"
@click="loadMorePastParticipations"
size="is-large"
type="is-primary"
>{{ $t("Load more") }}</b-button
>
</div>
</section>
</div>
</section>
</div>
</div>
</template>
@ -133,35 +198,47 @@ import { ParticipantRole } from "@/types/enums";
import RouteName from "@/router/name";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../../types/participant.model";
import { LOGGED_USER_DRAFTS } from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import EventParticipationCard from "../../components/Event/EventParticipationCard.vue";
import MultiEventMinimalistCard from "../../components/Event/MultiEventMinimalistCard.vue";
import EventMinimalistCard from "../../components/Event/EventMinimalistCard.vue";
import Subtitle from "../../components/Utils/Subtitle.vue";
import {
LOGGED_USER_PARTICIPATIONS,
LOGGED_USER_DRAFTS,
} from "../../graphql/actor";
import { EventModel, IEvent } from "../../types/event.model";
import EventListCard from "../../components/Event/EventListCard.vue";
import EventCard from "../../components/Event/EventCard.vue";
import Subtitle from "../../components/Utils/Subtitle.vue";
LOGGED_USER_UPCOMING_EVENTS,
} from "@/graphql/participant";
import { Paginate } from "@/types/paginate";
type Eventable = IParticipant | IEvent;
@Component({
components: {
Subtitle,
EventCard,
EventListCard,
MultiEventMinimalistCard,
EventParticipationCard,
EventMinimalistCard,
},
apollo: {
config: CONFIG,
futureParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
userUpcomingEvents: {
query: LOGGED_USER_UPCOMING_EVENTS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
afterDateTime: new Date().toISOString(),
variables() {
return {
page: 1,
limit: 10,
afterDateTime: this.dateFilter,
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
update(data) {
this.futureParticipations = data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
);
this.groupEvents = data.loggedUser.followedGroupEvents.elements.map(
({ event }: { event: IEvent }) => event
);
},
},
drafts: {
query: LOGGED_USER_DRAFTS,
@ -176,15 +253,14 @@ import Subtitle from "../../components/Utils/Subtitle.vue";
pastParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables: {
page: 1,
limit: 10,
beforeDateTime: new Date().toISOString(),
variables() {
return {
page: 1,
limit: 10,
beforeDateTime: this.dateFilter,
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
update: (data) => data.loggedUser.participations,
},
},
metaInfo() {
@ -200,13 +276,89 @@ export default class MyEvents extends Vue {
limit = 10;
get showUpcoming(): boolean {
return ((this.$route.query.showUpcoming as string) || "true") === "true";
}
set showUpcoming(showUpcoming: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showUpcoming: showUpcoming.toString() },
});
}
get showDrafts(): boolean {
return ((this.$route.query.showDrafts as string) || "true") === "true";
}
set showDrafts(showDrafts: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showDrafts: showDrafts.toString() },
});
}
get showAttending(): boolean {
return ((this.$route.query.showAttending as string) || "true") === "true";
}
set showAttending(showAttending: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showAttending: showAttending.toString() },
});
}
get showMyGroups(): boolean {
return ((this.$route.query.showMyGroups as string) || "false") === "true";
}
set showMyGroups(showMyGroups: boolean) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: { ...this.$route.query, showMyGroups: showMyGroups.toString() },
});
}
get dateFilter(): Date {
const query = this.$route.query.dateFilter as string;
if (query && /(\d{4}-\d{2}-\d{2})/.test(query)) {
return new Date(`${query}T00:00:00Z`);
}
return new Date();
}
set dateFilter(date: Date) {
const pad = (number: number) => {
if (number < 10) {
return "0" + number;
}
return number;
};
const stringifiedDate = `${date.getFullYear()}-${pad(
date.getMonth() + 1
)}-${pad(date.getDate())}`;
if (this.$route.query.dateFilter !== stringifiedDate) {
this.$router.push({
name: RouteName.MY_EVENTS,
query: {
...this.$route.query,
dateFilter: stringifiedDate,
},
});
}
}
config!: IConfig;
futureParticipations: IParticipant[] = [];
groupEvents: IEvent[] = [];
hasMoreFutureParticipations = true;
pastParticipations: IParticipant[] = [];
pastParticipations: Paginate<IParticipant> = { elements: [], total: 0 };
hasMorePastParticipations = true;
@ -216,49 +368,68 @@ export default class MyEvents extends Vue {
supportsWebPFormat = supportsWebPFormat;
static monthlyParticipations(
participations: IParticipant[],
static monthlyEvents(
elements: Eventable[],
revertSort = false
): Map<string, Participant[]> {
const res = participations.filter(
({ event, role }) =>
event.beginsOn != null && role !== ParticipantRole.REJECTED
);
if (revertSort) {
res.sort(
(a: IParticipant, b: IParticipant) =>
b.event.beginsOn.getTime() - a.event.beginsOn.getTime()
);
} else {
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
}
return res.reduce(
(acc: Map<string, IParticipant[]>, participation: IParticipant) => {
const month = new Date(participation.event.beginsOn).toLocaleDateString(
undefined,
{
year: "numeric",
month: "long",
}
): Map<string, Eventable[]> {
const res = elements.filter((element: Eventable) => {
if ("role" in element) {
return (
element.event.beginsOn != null &&
element.role !== ParticipantRole.REJECTED
);
const filteredParticipations: IParticipant[] = acc.get(month) || [];
filteredParticipations.push(participation);
acc.set(month, filteredParticipations);
return acc;
},
new Map()
);
}
return element.beginsOn != null;
});
if (revertSort) {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
} else {
res.sort((a: Eventable, b: Eventable) => {
const aTime = "role" in a ? a.event.beginsOn : a.beginsOn;
const bTime = "role" in b ? b.event.beginsOn : b.beginsOn;
return new Date(aTime).getTime() - new Date(bTime).getTime();
});
}
return res.reduce((acc: Map<string, Eventable[]>, element: Eventable) => {
const month = new Date(
"role" in element ? element.event.beginsOn : element.beginsOn
).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
});
const filteredElements: Eventable[] = acc.get(month) || [];
filteredElements.push(element);
acc.set(month, filteredElements);
return acc;
}, new Map());
}
get monthlyFutureParticipations(): Map<string, Participant[]> {
return MyEvents.monthlyParticipations(this.futureParticipations);
get monthlyFutureEvents(): Map<string, Eventable[]> {
let eventable = [] as Eventable[];
if (this.showAttending) {
eventable = [...eventable, ...this.futureParticipations];
}
if (this.showMyGroups) {
eventable = [...eventable, ...this.groupEvents];
}
return MyEvents.monthlyEvents(eventable);
}
get monthlyPastParticipations(): Map<string, Participant[]> {
return MyEvents.monthlyParticipations(this.pastParticipations, true);
get monthlyPastParticipations(): Map<string, Eventable[]> {
return MyEvents.monthlyEvents(this.pastParticipations.elements, true);
}
monthParticipationsIds(elements: Eventable[]): string[] {
let res = elements.filter((element: Eventable) => {
return "role" in element;
}) as IParticipant[];
return res.map(({ event }: { event: IEvent }) => {
return event.id as string;
});
}
loadMoreFutureParticipations(): void {
@ -287,9 +458,12 @@ export default class MyEvents extends Vue {
this.futureParticipations = this.futureParticipations.filter(
(participation) => participation.event.id !== eventid
);
this.pastParticipations = this.pastParticipations.filter(
(participation) => participation.event.id !== eventid
);
this.pastParticipations = {
elements: this.pastParticipations.elements.filter(
(participation) => participation.event.id !== eventid
),
total: this.pastParticipations.total - 1,
};
}
get hideCreateEventButton(): boolean {
@ -300,6 +474,8 @@ export default class MyEvents extends Vue {
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style lang="scss" scoped>
@import "~bulma/sass/utilities/mixins.sass";
main > .container {
background: $white;
@ -335,6 +511,7 @@ section {
}
.not-found {
margin-top: 2rem;
.img-container {
background-image: url("../../../public/img/pics/event_creation-480w.jpg");
@media (min-resolution: 2dppx) {
@ -359,4 +536,41 @@ section {
margin: auto auto 1rem;
}
}
.wrapper {
display: grid;
grid-template-areas: "filter" "events";
align-items: start;
@include desktop {
gap: 2rem;
grid-template-columns: 1fr 3fr;
grid-template-areas: "filter events";
}
.event-filter {
grid-area: filter;
background: lightgray;
border-radius: 5px;
padding: 0.75rem 1.25rem 0.25rem;
@include desktop {
padding: 2rem 1.25rem;
::v-deep .field.is-grouped {
display: block;
}
}
::v-deep .field > .field {
margin: 0 auto 1.25rem !important;
}
.date-filter ::v-deep .field-body {
display: block;
}
}
.my-events {
grid-area: events;
}
}
</style>

View File

@ -149,6 +149,7 @@
currentActor.id
"
@click="joinGroup"
@keyup.enter="joinGroup"
type="is-primary"
:disabled="previewPublic"
>{{ $t("Join group") }}</b-button
@ -164,10 +165,64 @@
type="is-primary"
>{{ $t("Join group") }}</b-button
>
<b-button
v-if="
((!isCurrentActorFollowing && !isCurrentActorAGroupMember) ||
previewPublic) &&
!isCurrentActorPendingFollow &&
currentActor.id
"
@click="followGroup"
@keyup.enter="followGroup"
type="is-primary"
:disabled="isCurrentActorPendingFollow"
>{{ $t("Follow") }}</b-button
>
<b-button
tag="router-link"
:to="{
name: RouteName.GROUP_FOLLOW,
params: { preferredUsername: usernameWithDomain(group) },
}"
v-else-if="
!isCurrentActorPendingFollow &&
!isCurrentActorFollowing &&
previewPublic
"
:disabled="previewPublic"
type="is-primary"
>{{ $t("Follow") }}</b-button
>
<b-button
outlined
v-if="isCurrentActorPendingFollow && currentActor.id"
@click="unFollowGroup"
@keyup.enter="unFollowGroup"
type="is-primary"
>{{ $t("Cancel follow request") }}</b-button
><b-button
v-if="
isCurrentActorFollowing && !previewPublic && currentActor.id
"
type="is-primary"
@click="unFollowGroup"
>{{ $t("Unfollow") }}</b-button
>
<b-button
v-if="isCurrentActorFollowing"
@click="toggleFollowNotify"
@keyup.enter="toggleFollowNotify"
:icon-left="
isCurrentActorFollowingNotify
? 'bell-outline'
: 'bell-off-outline'
"
></b-button>
<b-button
outlined
icon-left="share"
@click="triggerShare()"
@keyup.enter="triggerShare()"
v-if="!isCurrentActorAGroupMember || previewPublic"
>
{{ $t("Share") }}
@ -196,6 +251,7 @@
v-if="!previewPublic && isCurrentActorAGroupMember"
aria-role="menuitem"
@click="triggerShare()"
@keyup.enter="triggerShare()"
>
<span>
<b-icon icon="share" />
@ -230,6 +286,7 @@
v-if="ableToReport"
aria-role="menuitem"
@click="isReportModalActive = true"
@keyup.enter="isReportModalActive = true"
>
<span>
<b-icon icon="flag" />
@ -239,7 +296,8 @@
<b-dropdown-item
aria-role="menuitem"
v-if="isCurrentActorAGroupMember && !previewPublic"
@click="leaveGroup"
@click="openLeaveGroupModal"
@keyup.enter="openLeaveGroupModal"
>
<span>
<b-icon icon="exit-to-app" />
@ -351,8 +409,8 @@
class="organized-events-wrapper"
v-if="group && group.organizedEvents.total > 0"
>
<EventMinimalistCard
v-for="event in group.organizedEvents.elements"
<event-minimalist-card
v-for="event in group.organizedEvents.elements.slice(0, 3)"
:event="event"
:key="event.uuid"
class="organized-event"
@ -386,13 +444,11 @@
}"
>
<template v-slot:default>
<div v-if="group.posts.total > 0" class="posts-wrapper">
<post-list-item
v-for="post in group.posts.elements"
:key="post.id"
:post="post"
/>
</div>
<multi-post-list-item
v-if="group.posts.total > 0"
:posts="group.posts.elements.slice(0, 3)"
:isCurrentActorMember="isCurrentActorAGroupMember"
/>
<empty-content v-else-if="group" icon="bullhorn" :inline="true">
{{ $t("No posts yet") }}
</empty-content>
@ -425,7 +481,7 @@
}}
</event-metadata-block>
<event-metadata-block
v-if="physicalAddress"
v-if="physicalAddress && physicalAddress.url"
:title="$t('Location')"
:icon="
physicalAddress ? physicalAddress.poiInfos.poiIcon.icon : 'earth'
@ -452,6 +508,7 @@
<span
class="map-show-button"
@click="showMap = !showMap"
@keyup.enter="showMap = !showMap"
v-if="physicalAddress.geom"
>{{ $t("Show map") }}</span
>
@ -477,8 +534,8 @@
class="organized-events-wrapper"
v-if="group && organizedEvents.elements.length > 0"
>
<EventMinimalistCard
v-for="event in organizedEvents.elements"
<event-minimalist-card
v-for="event in organizedEvents.elements.slice(0, 3)"
:event="event"
:key="event.uuid"
class="organized-event"
@ -486,6 +543,18 @@
</div>
<empty-content v-else-if="group" icon="calendar" :inline="true">
{{ $t("No public upcoming events") }}
<template #desc v-if="isCurrentActorFollowing">
<i18n
class="has-text-grey-dark"
path="You will receive notifications about this group's public activity depending on %{notification_settings}."
>
<router-link
:to="{ name: RouteName.NOTIFICATIONS }"
slot="notification_settings"
>{{ $t("your notification settings") }}</router-link
>
</i18n>
</template>
</empty-content>
<b-skeleton animated v-else-if="$apollo.loading"></b-skeleton>
<router-link
@ -500,13 +569,21 @@
</section>
<section>
<subtitle>{{ $t("Latest posts") }}</subtitle>
<div v-if="posts.elements.length > 0" class="posts-wrapper">
<post-list-item
v-for="post in posts.elements"
:key="post.id"
:post="post"
/>
</div>
<multi-post-list-item
v-if="
posts.elements.filter(
(post) =>
!post.draft && post.visibility === PostVisibility.PUBLIC
).length > 0
"
:posts="
posts.elements.filter(
(post) =>
!post.draft && post.visibility === PostVisibility.PUBLIC
)
"
/>
<empty-content v-else-if="group" icon="bullhorn" :inline="true">
{{ $t("No posts yet") }}
</empty-content>
@ -568,7 +645,7 @@ import Subtitle from "@/components/Utils/Subtitle.vue";
import CompactTodo from "@/components/Todo/CompactTodo.vue";
import EventMinimalistCard from "@/components/Event/EventMinimalistCard.vue";
import DiscussionListItem from "@/components/Discussion/DiscussionListItem.vue";
import PostListItem from "@/components/Post/PostListItem.vue";
import MultiPostListItem from "@/components/Post/MultiPostListItem.vue";
import ResourceItem from "@/components/Resource/ResourceItem.vue";
import FolderItem from "@/components/Resource/FolderItem.vue";
import { Address } from "@/types/address.model";
@ -586,7 +663,7 @@ import { IMember } from "@/types/actor/member.model";
import RouteName from "../../router/name";
import GroupSection from "../../components/Group/GroupSection.vue";
import ReportModal from "../../components/Report/ReportModal.vue";
import { PERSON_MEMBERSHIP_GROUP } from "@/graphql/actor";
import { PERSON_STATUS_GROUP } from "@/graphql/actor";
import { LEAVE_GROUP } from "@/graphql/group";
import LazyImageWrapper from "../../components/Image/LazyImageWrapper.vue";
import EventMetadataBlock from "../../components/Event/EventMetadataBlock.vue";
@ -594,6 +671,11 @@ import EmptyContent from "../../components/Utils/EmptyContent.vue";
import { Paginate } from "@/types/paginate";
import { IEvent } from "@/types/event.model";
import { IPost } from "@/types/post.model";
import {
FOLLOW_GROUP,
UNFOLLOW_GROUP,
UPDATE_GROUP_FOLLOW,
} from "@/graphql/followers";
@Component({
apollo: {
@ -601,7 +683,7 @@ import { IPost } from "@/types/post.model";
},
components: {
DiscussionListItem,
PostListItem,
MultiPostListItem,
EventMinimalistCard,
CompactTodo,
Subtitle,
@ -645,6 +727,8 @@ export default class Group extends mixins(GroupMixin) {
usernameWithDomain = usernameWithDomain;
PostVisibility = PostVisibility;
Openness = Openness;
showMap = false;
@ -674,7 +758,7 @@ export default class Group extends mixins(GroupMixin) {
},
refetchQueries: [
{
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
variables: {
id: currentActorId,
group,
@ -684,6 +768,20 @@ export default class Group extends mixins(GroupMixin) {
});
}
protected async openLeaveGroupModal(): Promise<void> {
this.$buefy.dialog.confirm({
type: "is-danger",
title: this.$t("Leave group") as string,
message: this.$t(
"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.",
{ groupName: `<b>${displayName(this.group)}</b>` }
) as string,
onConfirm: () => this.leaveGroup(),
confirmText: this.$t("Leave group") as string,
cancelText: this.$t("Cancel") as string,
});
}
async leaveGroup(): Promise<void> {
try {
const [group, currentActorId] = [
@ -697,7 +795,7 @@ export default class Group extends mixins(GroupMixin) {
},
refetchQueries: [
{
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
variables: {
id: currentActorId,
group,
@ -712,6 +810,73 @@ export default class Group extends mixins(GroupMixin) {
}
}
async followGroup(): Promise<void> {
try {
const [group, currentActorId] = [
usernameWithDomain(this.group),
this.currentActor.id,
];
await this.$apollo.mutate({
mutation: FOLLOW_GROUP,
variables: {
groupId: this.group.id,
},
refetchQueries: [
{
query: PERSON_STATUS_GROUP,
variables: {
id: currentActorId,
group,
},
},
],
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
async unFollowGroup(): Promise<void> {
console.debug("unfollow group");
try {
const [group, currentActorId] = [
usernameWithDomain(this.group),
this.currentActor.id,
];
await this.$apollo.mutate({
mutation: UNFOLLOW_GROUP,
variables: {
groupId: this.group.id,
},
refetchQueries: [
{
query: PERSON_STATUS_GROUP,
variables: {
id: currentActorId,
group,
},
},
],
});
} catch (error: any) {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
this.$notifier.error(error.graphQLErrors[0].message);
}
}
}
async toggleFollowNotify(): Promise<void> {
await this.$apollo.mutate({
mutation: UPDATE_GROUP_FOLLOW,
variables: {
followId: this.currentActorFollow?.id,
notify: !this.isCurrentActorFollowingNotify,
},
});
}
acceptInvitation(): void {
if (this.groupMember) {
const index = this.person.memberships.elements.findIndex(
@ -882,8 +1047,8 @@ export default class Group extends mixins(GroupMixin) {
return {
total: this.group.posts.total,
elements: this.group.posts.elements.filter((post: IPost) => {
if (this.previewPublic) {
return !(post.draft || post.visibility == PostVisibility.PRIVATE);
if (this.previewPublic || !this.isCurrentActorAGroupMember) {
return !post.draft && post.visibility == PostVisibility.PUBLIC;
}
return true;
}),
@ -1010,19 +1175,6 @@ div.container {
section {
background: $white;
.posts-wrapper {
padding-bottom: 1rem;
}
.organized-events-wrapper {
display: flex;
flex-wrap: wrap;
.organized-event {
margin: 0.25rem 0;
}
}
&.presentation {
.media-left {
span.icon.is-large {
@ -1155,6 +1307,7 @@ div.container {
min-width: 20rem;
flex: 2;
background: white;
padding: 0 5px;
@include desktop {
padding: 10px;
@ -1171,10 +1324,6 @@ div.container {
section {
margin-top: 0;
.posts-wrapper {
margin-bottom: 1rem;
}
}
}
@ -1184,5 +1333,12 @@ div.container {
padding-right: 1rem;
}
}
.organized-events-wrapper,
.posts-wrapper {
display: grid;
grid-gap: 20px;
grid-template: 1fr;
}
}
</style>

View File

@ -31,6 +31,7 @@
</li>
</ul>
</nav>
<b-loading :active="$apollo.loading" />
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin && followers"
@ -125,7 +126,7 @@
</template>
</b-table>
</section>
<b-message v-else-if="group">
<b-message v-else-if="!$apollo.loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>

View File

@ -31,6 +31,7 @@
</li>
</ul>
</nav>
<b-loading :active="$apollo.loading" />
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin"
@ -230,7 +231,7 @@
</template>
</b-table>
</section>
<b-message v-else-if="group">
<b-message v-else-if="!$apollo.loading && group">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>

View File

@ -32,6 +32,7 @@
</li>
</ul>
</nav>
<b-loading :active="$apollo.loading" />
<section
class="container section"
v-if="group && isCurrentActorAGroupAdmin"
@ -169,7 +170,7 @@
{{ value }}
</b-message>
</section>
<b-message v-else>
<b-message v-else-if="!$apollo.loading">
{{ $t("You are not an administrator for this group.") }}
</b-message>
</div>
@ -178,12 +179,11 @@
<script lang="ts">
import { Component, Watch } from "vue-property-decorator";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import { Route } from "vue-router";
import PictureUpload from "@/components/PictureUpload.vue";
import { mixins } from "vue-class-component";
import GroupMixin from "@/mixins/group";
import { GroupVisibility, Openness } from "@/types/enums";
import { UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { UPDATE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model";
import { CONFIG } from "@/graphql/config";
@ -246,31 +246,6 @@ export default class GroupSettings extends mixins(GroupMixin) {
this.handleError(err);
}
}
confirmDeleteGroup(): void {
this.$buefy.dialog.confirm({
title: this.$t("Delete group") as string,
message: this.$t(
"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>."
) as string,
confirmText: this.$t("Delete group") as string,
cancelText: this.$t("Cancel") as string,
type: "is-danger",
hasIcon: true,
onConfirm: () => this.deleteGroup(),
});
}
async deleteGroup(): Promise<Route> {
await this.$apollo.mutate<{ deleteGroup: IGroup }>({
mutation: DELETE_GROUP,
variables: {
groupId: this.group.id,
},
});
return this.$router.push({ name: RouteName.MY_GROUPS });
}
async copyURL(): Promise<void> {
await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true;

View File

@ -1,5 +1,6 @@
<template>
<div id="homepage">
<b-loading :active.sync="$apollo.loading" />
<section
class="hero"
:class="{ webp: supportsWebPFormat }"
@ -59,19 +60,9 @@
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<b-loading :active.sync="$apollo.loading" />
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>
<EventCard :event="event" />
</div>
</div>
<multi-card :events="events.elements.slice(0, 6)" />
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
@ -181,10 +172,7 @@
</div>
</div>
</div>
<div
class="container section"
v-if="config && loggedUser && loggedUser.settings"
>
<div class="container section" v-if="config && loggedUserSettings">
<section v-if="currentActor.id && (welcomeBack || newRegisteredUser)">
<b-message type="is-info" v-if="welcomeBack">{{
$t("Welcome back {username}!", {
@ -200,7 +188,6 @@
<!-- Your upcoming events -->
<section v-if="canShowMyUpcomingEvents">
<h2 class="title">{{ $t("Your upcoming events") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div v-for="row of goingToEvents" class="upcoming-events" :key="row[0]">
<p
class="date-component-container"
@ -226,7 +213,7 @@
</span>
</p>
<div>
<EventListCard
<event-participation-card
v-for="participation in thisWeek(row)"
@event-deleted="eventDeleted"
:key="participation[1].id"
@ -243,27 +230,36 @@
<hr
role="presentation"
class="home-separator"
v-if="canShowMyUpcomingEvents && canShowLastWeekEvents"
v-if="canShowMyUpcomingEvents && canShowFollowedGroupEvents"
/>
<!-- Last week events -->
<section v-if="canShowLastWeekEvents">
<h2 class="title">{{ $t("Last week") }}</h2>
<b-loading :active.sync="$apollo.loading" />
<div>
<EventListCard
v-for="participation in lastWeekEvents"
:key="participation.id"
:participation="participation"
@event-deleted="eventDeleted"
:options="{ hideDate: false }"
/>
</div>
<!-- Events from your followed groups -->
<section class="followActivity" v-if="canShowFollowedGroupEvents">
<h2 class="title">
{{ $t("Upcoming events from your groups") }}
</h2>
<p>{{ $t("That you follow or of which you are a member") }}</p>
<multi-card :events="filteredFollowedGroupsEvents" />
<span class="view-all">
<router-link
:to="{
name: RouteName.MY_EVENTS,
query: {
showUpcoming: 'true',
showDrafts: 'false',
showAttending: 'false',
showMyGroups: 'true',
},
}"
>{{ $t("View everything") }} >></router-link
>
</span>
</section>
<hr
role="presentation"
class="home-separator"
v-if="canShowLastWeekEvents && canShowCloseEvents"
v-if="canShowFollowedGroupEvents && canShowCloseEvents"
/>
<!-- Events close to you -->
<section class="events-close" v-if="canShowCloseEvents">
<h2 class="title">
@ -273,10 +269,10 @@
{{
$tc(
"Within {number} kilometers of {place}",
loggedUser.settings.location.range,
loggedUserSettings.location.range,
{
number: loggedUser.settings.location.range,
place: loggedUser.settings.location.name,
number: loggedUserSettings.location.range,
place: loggedUserSettings.location.name,
}
)
}}
@ -286,24 +282,13 @@
>
<b-icon class="clickable" icon="pencil" size="is-small" />
</router-link>
<b-loading :active.sync="$apollo.loading" />
</p>
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in closeEvents.elements.slice(0, 3)"
:key="event.uuid"
>
<event-card :event="event" />
</div>
</div>
<multi-card :events="closeEvents.elements.slice(0, 3)" />
</section>
<hr
role="presentation"
class="home-separator"
v-if="
canShowMyUpcomingEvents || canShowLastWeekEvents || canShowCloseEvents
"
v-if="canShowMyUpcomingEvents || canShowCloseEvents"
/>
<section class="events-recent">
<h2 class="title">
@ -313,19 +298,10 @@
<i18n tag="span" path="On {instance} and other federated instances">
<b slot="instance">{{ config.name }}</b>
</i18n>
<b-loading :active.sync="$apollo.loading" />
</p>
<div v-if="this.events.total > 0">
<div class="columns is-multiline">
<div
class="column is-one-third-desktop"
v-for="event in this.events.elements.slice(0, 6)"
:key="event.uuid"
>
<recent-event-card-wrapper :event="event" />
</div>
</div>
<div v-if="events.total > 0">
<multi-card :events="events.elements.slice(0, 8)" />
<span class="view-all">
<router-link :to="{ name: RouteName.SEARCH }"
>{{ $t("View everything") }} >></router-link
@ -334,7 +310,7 @@
</div>
<b-message v-else type="is-danger"
>{{ $t("No events found") }}<br />
<div v-if="goingToEvents.size > 0 || lastWeekEvents.length > 0">
<div v-if="goingToEvents.size > 0">
<b-icon size="is-small" icon="information-outline" />
<small>{{
$t("The events you created are not shown here.")
@ -353,28 +329,29 @@ import { Paginate } from "@/types/paginate";
import { supportsWebPFormat } from "@/utils/support";
import { IParticipant, Participant } from "../types/participant.model";
import { CLOSE_EVENTS, FETCH_EVENTS } from "../graphql/event";
import EventListCard from "../components/Event/EventListCard.vue";
import EventCard from "../components/Event/EventCard.vue";
import RecentEventCardWrapper from "../components/Event/RecentEventCardWrapper.vue";
import {
CURRENT_ACTOR_CLIENT,
LOGGED_USER_PARTICIPATIONS,
} from "../graphql/actor";
import EventParticipationCard from "../components/Event/EventParticipationCard.vue";
import MultiCard from "../components/Event/MultiCard.vue";
import { CURRENT_ACTOR_CLIENT } from "../graphql/actor";
import { IPerson, Person } from "../types/actor";
import { ICurrentUser, IUser } from "../types/current-user.model";
import { CURRENT_USER_CLIENT, USER_SETTINGS } from "../graphql/user";
import {
ICurrentUser,
IUser,
IUserSettings,
} from "../types/current-user.model";
import { CURRENT_USER_CLIENT } from "../graphql/user";
import { HOME_USER_QUERIES } from "../graphql/home";
import RouteName from "../router/name";
import { IEvent } from "../types/event.model";
import DateComponent from "../components/Event/DateCalendarIcon.vue";
import { CONFIG } from "../graphql/config";
import { IConfig } from "../types/config.model";
import { IFollowedGroupEvent } from "../types/followedGroupEvent.model";
import Subtitle from "../components/Utils/Subtitle.vue";
@Component({
apollo: {
events: {
query: FETCH_EVENTS,
fetchPolicy: "no-cache", // Debug me: https://github.com/apollographql/apollo-client/issues/3030
variables: {
orderBy: EventSortField.INSERTED_AT,
direction: SortDirection.DESC,
@ -385,35 +362,7 @@ import Subtitle from "../components/Utils/Subtitle.vue";
update: (data) => new Person(data.currentActor),
},
currentUser: CURRENT_USER_CLIENT,
loggedUser: {
query: USER_SETTINGS,
fetchPolicy: "network-only",
skip() {
return !this.currentUser || this.currentUser.isLoggedIn === false;
},
error() {
return null;
},
},
config: CONFIG,
currentUserParticipations: {
query: LOGGED_USER_PARTICIPATIONS,
fetchPolicy: "cache-and-network",
variables() {
const lastWeek = new Date();
lastWeek.setDate(new Date().getDate() - 7);
return {
afterDateTime: lastWeek.toISOString(),
};
},
update: (data) =>
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
),
skip() {
return this.currentUser?.isLoggedIn === false;
},
},
closeEvents: {
query: CLOSE_EVENTS,
variables() {
@ -431,13 +380,30 @@ import Subtitle from "../components/Utils/Subtitle.vue";
);
},
},
userQueries: {
query: HOME_USER_QUERIES,
update(data) {
console.log("loggedUser", data.loggedUser);
this.loggedUser = data.loggedUser;
this.followedGroupEvents = data.loggedUser.followedGroupEvents;
this.currentUserParticipations =
data.loggedUser.participations.elements.map(
(participation: IParticipant) => new Participant(participation)
);
},
variables: {
afterDateTime: new Date().toISOString(),
},
skip() {
return !this.currentUser?.isLoggedIn;
},
},
},
components: {
Subtitle,
DateComponent,
EventListCard,
EventCard,
RecentEventCardWrapper,
EventParticipationCard,
MultiCard,
"settings-onboard": () => import("./User/SettingsOnboard.vue"),
},
metaInfo() {
@ -461,9 +427,9 @@ export default class Home extends Vue {
country = { name: null };
currentUser!: IUser;
currentUser!: ICurrentUser;
loggedUser!: ICurrentUser;
loggedUser: IUser | null = null;
currentActor!: IPerson;
@ -477,6 +443,11 @@ export default class Home extends Vue {
closeEvents: Paginate<IEvent> = { elements: [], total: 0 };
followedGroupEvents: Paginate<IFollowedGroupEvent> = {
elements: [],
total: 0,
};
// get displayed_name() {
// return this.loggedPerson && this.loggedPerson.name === null
// ? this.loggedPerson.preferredUsername
@ -584,20 +555,6 @@ export default class Home extends Vue {
);
}
get lastWeekEvents(): IParticipant[] {
const res = this.currentUserParticipations.filter(
({ event, role }) =>
event.beginsOn != null &&
this.isBefore(event.beginsOn.toDateString(), 0) &&
role !== ParticipantRole.REJECTED
);
res.sort(
(a: IParticipant, b: IParticipant) =>
a.event.beginsOn.getTime() - b.event.beginsOn.getTime()
);
return res;
}
eventDeleted(eventid: string): void {
this.currentUserParticipations = this.currentUserParticipations.filter(
(participation) => participation.event.id !== eventid
@ -610,7 +567,7 @@ export default class Home extends Vue {
@Watch("loggedUser")
detectEmptyUserSettings(loggedUser: IUser): void {
if (loggedUser && loggedUser.id && loggedUser.settings === null) {
if (loggedUser?.id && loggedUser?.settings === null) {
this.$router.push({
name: RouteName.WELCOME_SCREEN,
params: { step: "1" },
@ -618,16 +575,35 @@ export default class Home extends Vue {
}
}
get loggedUserSettings(): IUserSettings | undefined {
return this.loggedUser?.settings;
}
get canShowMyUpcomingEvents(): boolean {
return this.currentActor.id != undefined && this.goingToEvents.size > 0;
}
get canShowLastWeekEvents(): boolean {
return this.currentActor && this.lastWeekEvents.length > 0;
get canShowCloseEvents(): boolean {
return (
this.loggedUser?.settings?.location != undefined &&
this.closeEvents.total > 0
);
}
get canShowCloseEvents(): boolean {
return this.closeEvents.total > 0;
get canShowFollowedGroupEvents(): boolean {
return this.filteredFollowedGroupsEvents.length > 0;
}
get filteredFollowedGroupsEvents(): IEvent[] {
return this.followedGroupEvents.elements
.map(({ event }: { event: IEvent }) => event)
.filter(
({ id }) =>
!this.thisWeekGoingToEvents
.map(({ event: { id: event_id } }) => event_id)
.includes(id)
)
.slice(0, 3);
}
}
</script>

View File

@ -119,9 +119,11 @@
}}</b-button>
</span>
<span class="navbar-item" v-if="this.isUpdate">
<b-button type="is-danger is-outlined" @click="deletePost">{{
$t("Delete post")
}}</b-button>
<b-button
type="is-danger is-outlined"
@click="openDeletePostModal"
>{{ $t("Delete post") }}</b-button
>
</span>
<!-- If an post has been published we can't make it draft anymore -->
<span class="navbar-item" v-if="post.draft === true">
@ -167,12 +169,7 @@ import {
import GroupMixin from "@/mixins/group";
import { PostVisibility } from "@/types/enums";
import { CONFIG } from "../../graphql/config";
import {
FETCH_POST,
CREATE_POST,
UPDATE_POST,
DELETE_POST,
} from "../../graphql/post";
import { CREATE_POST, UPDATE_POST } from "../../graphql/post";
import { IPost } from "../../types/post.model";
import Editor from "../../components/Editor.vue";
@ -181,8 +178,9 @@ import TagInput from "../../components/Event/TagInput.vue";
import RouteName from "../../router/name";
import Subtitle from "../../components/Utils/Subtitle.vue";
import PictureUpload from "../../components/PictureUpload.vue";
import { PERSON_MEMBERSHIP_GROUP } from "@/graphql/actor";
import { PERSON_STATUS_GROUP } from "@/graphql/actor";
import { FETCH_GROUP } from "@/graphql/group";
import PostMixin from "../../mixins/post";
@Component({
apollo: {
@ -198,20 +196,8 @@ import { FETCH_GROUP } from "@/graphql/group";
return !this.preferredUsername;
},
},
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
@ -242,7 +228,7 @@ import { FETCH_GROUP } from "@/graphql/group";
};
},
})
export default class EditPost extends mixins(GroupMixin) {
export default class EditPost extends mixins(GroupMixin, PostMixin) {
@Prop({ required: false, type: String }) slug: undefined | string;
@Prop({ required: false, type: String }) preferredUsername!: string;
@ -266,7 +252,7 @@ export default class EditPost extends mixins(GroupMixin) {
RouteName = RouteName;
editablePost!: IPost;
editablePost: IPost = this.post;
usernameWithDomain = usernameWithDomain;
@ -338,23 +324,6 @@ export default class EditPost extends mixins(GroupMixin) {
}
}
async deletePost(): Promise<void> {
const { data } = await this.$apollo.mutate({
mutation: DELETE_POST,
variables: {
id: this.post.id,
},
});
if (data && this.post.attributedTo) {
this.$router.push({
name: RouteName.POSTS,
params: {
preferredUsername: usernameWithDomain(this.post.attributedTo),
},
});
}
}
static transformMessage(message: string[] | string): string | undefined {
if (Array.isArray(message) && message.length > 0) {
return message[0];

View File

@ -49,10 +49,8 @@
>
</div>
<div class="post-list">
<post-element-item
v-for="post in group.posts.elements"
:key="post.id"
:post="post"
<multi-post-list-item
:posts="group.posts.elements"
:isCurrentActorMember="isCurrentActorMember"
/>
</div>
@ -88,7 +86,7 @@ import { Paginate } from "../../types/paginate";
import { IPost } from "../../types/post.model";
import { usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import PostElementItem from "../../components/Post/PostElementItem.vue";
import MultiPostListItem from "../../components/Post/MultiPostListItem.vue";
const POSTS_PAGE_LIMIT = 10;
@ -124,7 +122,7 @@ const POSTS_PAGE_LIMIT = 10;
},
},
components: {
PostElementItem,
MultiPostListItem,
},
metaInfo() {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@ -132,7 +130,7 @@ const POSTS_PAGE_LIMIT = 10;
const { group } = this;
return {
title: this.$t("{group} posts", {
group: group.name || usernameWithDomain(group),
group: group?.name || usernameWithDomain(group),
}) as string,
};
},

View File

@ -7,7 +7,16 @@
<div class="heading-section">
<div class="heading-wrapper">
<div class="title-metadata">
<h1 class="title">{{ post.title }}</h1>
<div class="title-wrapper">
<b-tag
class="mr-2"
type="is-warning"
size="is-medium"
v-if="post.draft"
>{{ $t("Draft") }}</b-tag
>
<h1 class="title">{{ post.title }}</h1>
</div>
<p class="metadata">
<router-link
slot="author"
@ -49,7 +58,14 @@
}}
</span>
<span
v-if="post.visibility === PostVisibility.PRIVATE"
v-if="post.visibility === PostVisibility.UNLISTED"
class="has-text-grey-dark"
>
<b-icon icon="link" size="is-small" />
{{ $t("Accessible only by link") }}
</span>
<span
v-else-if="post.visibility === PostVisibility.PRIVATE"
class="has-text-grey-dark"
>
<b-icon icon="lock" size="is-small" />
@ -61,21 +77,72 @@
</span>
</p>
</div>
<p class="buttons" v-if="isCurrentActorMember">
<b-tag type="is-warning" size="is-medium" v-if="post.draft">{{
$t("Draft")
}}</b-tag>
<router-link
<b-dropdown position="is-bottom-left" aria-role="list">
<b-button slot="trigger" role="button" icon-right="dots-horizontal">
{{ $t("Actions") }}
</b-button>
<b-dropdown-item
aria-role="listitem"
has-link
v-if="
currentActor.id === post.author.id ||
isCurrentActorAGroupModerator
"
:to="{ name: RouteName.POST_EDIT, params: { slug: post.slug } }"
tag="button"
class="button is-text"
>{{ $t("Edit") }}</router-link
>
</p>
<router-link
:to="{
name: RouteName.POST_EDIT,
params: { slug: post.slug },
}"
>{{ $t("Edit") }} <b-icon icon="pencil"
/></router-link>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="
currentActor.id === post.author.id ||
isCurrentActorAGroupModerator
"
@click="openDeletePostModal"
@keyup.enter="openDeletePostModal"
>
{{ $t("Delete") }}
<b-icon icon="delete" />
</b-dropdown-item>
<hr
role="presentation"
class="dropdown-divider"
aria-role="menuitem"
v-if="
currentActor.id === post.author.id ||
isCurrentActorAGroupModerator
"
/>
<b-dropdown-item
aria-role="listitem"
v-if="!post.draft"
@click="triggerShare()"
@keyup.enter="triggerShare()"
>
<span>
{{ $t("Share this event") }}
<b-icon icon="share" />
</span>
</b-dropdown-item>
<b-dropdown-item
aria-role="listitem"
v-if="ableToReport"
@click="isReportModalActive = true"
@keyup.enter="isReportModalActive = true"
>
<span>
{{ $t("Report") }}
<b-icon icon="flag" />
</span>
</b-dropdown-item>
</b-dropdown>
</div>
</div>
</header>
@ -108,6 +175,21 @@
<tag>{{ tag.title }}</tag>
</router-link>
</section>
<b-modal
:active.sync="isReportModalActive"
has-modal-card
ref="reportModal"
>
<report-modal
:on-confirm="reportPost"
:title="$t('Report this post')"
:outside-domain="groupDomain"
@close="$refs.reportModal.close()"
/>
</b-modal>
<b-modal :active.sync="isShareModalActive" has-modal-card ref="shareModal">
<share-post-modal :post="post" />
</b-modal>
</article>
</template>
@ -120,10 +202,8 @@ import { IMember } from "@/types/actor/member.model";
import {
CURRENT_ACTOR_CLIENT,
PERSON_MEMBERSHIPS,
PERSON_MEMBERSHIP_GROUP,
PERSON_STATUS_GROUP,
} from "../../graphql/actor";
import { FETCH_POST } from "../../graphql/post";
import { IPost } from "../../types/post.model";
import { usernameWithDomain } from "../../types/actor";
import RouteName from "../../router/name";
import Tag from "../../components/Tag.vue";
@ -132,9 +212,17 @@ import ActorInline from "../../components/Account/ActorInline.vue";
import { formatDistanceToNowStrict } from "date-fns";
import { CURRENT_USER_CLIENT } from "@/graphql/user";
import { ICurrentUser } from "@/types/current-user.model";
import { CONFIG } from "@/graphql/config";
import { IConfig } from "@/types/config.model";
import SharePostModal from "../../components/Post/SharePostModal.vue";
import { IReport } from "@/types/report.model";
import { CREATE_REPORT } from "@/graphql/report";
import ReportModal from "../../components/Report/ReportModal.vue";
import PostMixin from "../../mixins/post";
@Component({
apollo: {
config: CONFIG,
currentUser: CURRENT_USER_CLIENT,
currentActor: CURRENT_ACTOR_CLIENT,
memberships: {
@ -150,23 +238,8 @@ import { ICurrentUser } from "@/types/current-user.model";
return !this.currentActor || !this.currentActor.id;
},
},
post: {
query: FETCH_POST,
fetchPolicy: "cache-and-network",
variables() {
return {
slug: this.slug,
};
},
skip() {
return !this.slug;
},
error({ graphQLErrors }) {
this.handleErrors(graphQLErrors);
},
},
person: {
query: PERSON_MEMBERSHIP_GROUP,
query: PERSON_STATUS_GROUP,
fetchPolicy: "cache-and-network",
variables() {
return {
@ -187,6 +260,8 @@ import { ICurrentUser } from "@/types/current-user.model";
Tag,
LazyImageWrapper,
ActorInline,
SharePostModal,
ReportModal,
},
metaInfo() {
return {
@ -200,13 +275,13 @@ import { ICurrentUser } from "@/types/current-user.model";
};
},
})
export default class Post extends mixins(GroupMixin) {
export default class Post extends mixins(GroupMixin, PostMixin) {
@Prop({ required: true, type: String }) slug!: string;
post!: IPost;
memberships!: IMember[];
config!: IConfig;
RouteName = RouteName;
currentUser!: ICurrentUser;
@ -217,11 +292,9 @@ export default class Post extends mixins(GroupMixin) {
PostVisibility = PostVisibility;
handleErrors(errors: any[]): void {
if (errors.some((error) => error.status_code === 404)) {
this.$router.replace({ name: RouteName.PAGE_NOT_FOUND });
}
}
isShareModalActive = false;
isReportModalActive = false;
get isCurrentActorMember(): boolean {
if (!this.post.attributedTo || !this.memberships) return false;
@ -236,6 +309,62 @@ export default class Post extends mixins(GroupMixin) {
ICurrentUserRole.MODERATOR,
].includes(this.currentUser.role);
}
get ableToReport(): boolean {
return (
this.config &&
(this.currentActor.id != null || this.config.anonymous.reports.allowed)
);
}
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: this.post.title,
url: this.post.url,
})
.then(() => console.log("Successful share"))
.catch((error: any) => console.log("Error sharing", error));
} else {
this.isShareModalActive = true;
// send popup
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-end
}
async reportPost(content: string, forward: boolean): Promise<void> {
this.isReportModalActive = false;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
this.$refs.reportModal.close();
const postTitle = this.post.title;
try {
await this.$apollo.mutate<IReport>({
mutation: CREATE_REPORT,
variables: {
postId: this.post.id,
reportedId: this.post.attributedTo?.id,
content,
forward,
},
});
this.$notifier.success(
this.$t("Post {eventTitle} reported", { postTitle }) as string
);
} catch (error) {
console.error(error);
}
}
get groupDomain(): string | undefined | null {
return this.post.attributedTo?.domain;
}
}
</script>
<style lang="scss" scoped>
@ -261,16 +390,31 @@ article.post {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
.title-metadata {
min-width: 300px;
flex: 20;
.title-wrapper {
display: inline;
.tag {
height: 38px;
vertical-align: text-bottom;
}
& > h1 {
display: inline;
}
}
p.metadata {
margin-top: 16px;
margin-top: 10px;
display: flex;
justify-content: flex-start;
flex-wrap: wrap;
flex-direction: column;
*:not(:first-child) {
padding-left: 5px;
@ -328,5 +472,14 @@ article.post {
}
margin: 0 auto;
a.dropdown-item,
.dropdown .dropdown-menu .has-link a,
button.dropdown-item {
white-space: nowrap;
width: 100%;
padding-right: 1rem;
text-align: right;
}
}
</style>

View File

@ -60,11 +60,11 @@
<hr
role="presentation"
class="dropdown-divider"
v-if="config.resourceProviders.length"
v-if="resourceProviders.length"
/>
<b-dropdown-item
aria-role="listitem"
v-for="resourceProvider in config.resourceProviders"
v-for="resourceProvider in resourceProviders"
:key="resourceProvider.software"
@click="createResourceFromProvider(resourceProvider)"
>
@ -418,6 +418,10 @@ export default class Resources extends Mixins(ResourceMixin) {
return this.filteredPath.slice(-1)[0];
}
get resourceProviders(): IProvider[] {
return this.config?.resourceProviders || [];
}
async createResource(): Promise<void> {
if (!this.resource.actor) return;
this.modalError = "";

View File

@ -158,6 +158,7 @@ import AuthProviders from "../../components/User/AuthProviders.vue";
return {
title: this.$t("Login on Mobilizon!") as string,
titleTemplate: "%s | Mobilizon",
meta: [{ name: "robots", content: "noindex" }],
};
},
})

View File

@ -60,6 +60,7 @@ import RouteName from "../../router/name";
metaInfo() {
return {
title: this.$t("Resend confirmation email") as string,
meta: [{ name: "robots", content: "noindex" }],
};
},
})

View File

@ -19,6 +19,7 @@ exports[`ParticipationWithoutAccount handles being already a participant 1`] = `
</div>
</div>
</section>
<!---->
</article>
</transition-stub>
<transition-stub name="fade">
@ -30,6 +31,7 @@ exports[`ParticipationWithoutAccount handles being already a participant 1`] = `
<div class="media-content">You are already a participant of this event</div>
</div>
</section>
<!---->
</article>
</transition-stub>
<div class="field"><label class="label">Email address</label>
@ -88,6 +90,7 @@ exports[`ParticipationWithoutAccount renders the participation without account v
<div class="media-content">Unable to save your participation in this browser.</div>
</div>
</section>
<!---->
</article>
</transition-stub>
<p class="content"><span>You may now close this window, or <a href="/events/f37910ea-fd5a-4756-9679-00971f3f4106" class="">return to the event's page</a>.</span></p>
@ -116,6 +119,7 @@ exports[`ParticipationWithoutAccount renders the warning if the event participat
<div class="media-content">Unable to save your participation in this browser.</div>
</div>
</section>
<!---->
</article>
</transition-stub>
<p class="content"><span>You may now close this window, or <a href="/events/f37910ea-fd5a-4756-9679-00971f3f4106" class="">return to the event's page</a>.</span></p>

View File

@ -1,106 +0,0 @@
import { config, createLocalVue, mount } from "@vue/test-utils";
import PostElementItem from "@/components/Post/PostElementItem.vue";
import { formatDateTimeString } from "@/filters/datetime";
import Buefy from "buefy";
import VueRouter from "vue-router";
import { routes } from "@/router";
import { PostVisibility } from "@/types/enums";
const localVue = createLocalVue();
localVue.use(Buefy);
localVue.use(VueRouter);
const router = new VueRouter({ routes, mode: "history" });
localVue.filter("formatDateTimeString", formatDateTimeString);
config.mocks.$t = (key: string): string => key;
const postData = {
id: "1",
slug: "my-blog-post-some-uuid",
title: "My Blog Post",
body: "My content",
insertedAt: "2020-12-02T09:01:20.873Z",
visibility: PostVisibility.PUBLIC,
author: {
preferredUsername: "author",
domain: "remote-domain.tld",
name: "Author",
},
attributedTo: {
preferredUsername: "my-awesome-group",
domain: null,
name: "My Awesome Group",
},
};
const generateWrapper = (
customPostData: Record<string, unknown> = {},
isCurrentActorMember = false
) => {
return mount(PostElementItem, {
localVue,
router,
propsData: {
post: { ...postData, ...customPostData },
isCurrentActorMember,
},
});
};
describe("PostElementItem", () => {
it("renders post with basic informations", () => {
const wrapper = generateWrapper();
expect(wrapper.html()).toMatchSnapshot();
expect(
wrapper.find("a.post-minimalist-card-wrapper").attributes("href")
).toBe(`/p/${postData.slug}`);
expect(wrapper.find(".post-minimalist-title").text()).toContain(
postData.title
);
expect(wrapper.find(".metadata").text()).toContain(
formatDateTimeString(postData.insertedAt, undefined, false)
);
expect(wrapper.find(".metadata small").text()).not.toContain("Public");
});
it("shows the author if actor is a group member", () => {
const wrapper = generateWrapper({}, true);
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata").text()).toContain(`Created by {username}`);
});
it("shows the draft tag if post is a draft", () => {
const wrapper = generateWrapper({ draft: true });
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.findComponent({ name: "b-tag" }).exists()).toBe(true);
});
it("tells if the post is public when the actor is a group member", () => {
const wrapper = generateWrapper({}, true);
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata small").text()).toContain("Public");
});
it("tells if the post is accessible only through link", () => {
const wrapper = generateWrapper({ visibility: PostVisibility.UNLISTED });
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata small").text()).toContain(
"Accessible through link"
);
});
it("tells if the post is accessible only to members", () => {
const wrapper = generateWrapper({ visibility: PostVisibility.PRIVATE });
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".metadata small").text()).toContain(
"Accessible only to members"
);
});
});

View File

@ -4,6 +4,8 @@ import Buefy from "buefy";
import VueRouter from "vue-router";
import { routes } from "@/router";
import { enUS } from "date-fns/locale";
import { formatDateTimeString } from "@/filters/datetime";
import { i18n } from "@/utils/i18n";
const localVue = createLocalVue();
localVue.use(Buefy);
@ -20,14 +22,23 @@ const postData = {
title: "My Blog Post",
body: "My content",
insertedAt: "2020-12-02T09:01:20.873Z",
tags: [],
};
const generateWrapper = (customPostData: Record<string, unknown> = {}) => {
const generateWrapper = (
customPostData: Record<string, unknown> = {},
customProps: Record<string, unknown> = {}
) => {
return mount(PostListItem, {
localVue,
router,
i18n,
propsData: {
post: { ...postData, ...customPostData },
...customProps,
},
filters: {
formatDateTimeString,
},
});
};
@ -36,14 +47,40 @@ describe("PostListItem", () => {
it("renders post list item with basic informations", () => {
const wrapper = generateWrapper();
// can't use the snapshot feature because of `ago`
expect(wrapper.html()).toMatchSnapshot();
expect(
wrapper.find("a.post-minimalist-card-wrapper").attributes("href")
).toBe(`/p/${postData.slug}`);
expect(wrapper.find(".post-minimalist-title").text()).toContain(
postData.title
expect(wrapper.find(".post-minimalist-title").text()).toBe(postData.title);
expect(wrapper.find(".post-publication-date").text()).toBe("Dec 2, 2020");
expect(wrapper.find(".post-publisher").exists()).toBeFalsy();
});
it("renders post list item with tags", () => {
const wrapper = generateWrapper({
tags: [{ slug: "a-tag", title: "A tag" }],
});
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".tags").text()).toContain("A tag");
expect(wrapper.find(".post-publisher").exists()).toBeFalsy();
});
it("renders post list item with publisher name", () => {
const wrapper = generateWrapper(
{ author: { name: "An author" } },
{ isCurrentActorMember: true }
);
expect(wrapper.html()).toMatchSnapshot();
expect(wrapper.find(".post-publisher").exists()).toBeTruthy();
expect(wrapper.find(".post-publisher").text()).toContain("An author");
});
});

View File

@ -1,103 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostElementItem renders post with basic informations 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!---->
<!----> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem shows the author if actor is a group member 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey-dark">December 2, 2020</small> <small class="has-text-grey-dark">Created by {username}</small>
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem shows the draft tag if post is a draft 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata"><span class="tag is-warning is-small"><span class="">Draft</span>
<!----></span>
<!----> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem tells if the post is accessible only through link 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-link"></i></span>Accessible through link</small> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem tells if the post is accessible only to members 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-lock"></i></span>Accessible only to members</small> <small class="has-text-grey-dark">December 2, 2020</small>
<!---->
</div>
</div>
</div>
</div>
</a>
`;
exports[`PostElementItem tells if the post is public when the actor is a group member 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<div class="title-info-wrapper">
<div class="media">
<div class="media-left"><span class="icon is-large"><i class="mdi mdi-post mdi-48px"></i></span></div>
<div class="media-content">
<p class="post-minimalist-title">My Blog Post</p>
<div class="metadata">
<!----> <small class="has-text-grey-dark"><span class="icon is-small"><i class="mdi mdi-earth"></i></span>Public</small> <small class="has-text-grey-dark">December 2, 2020</small> <small class="has-text-grey-dark">Created by {username}</small>
</div>
</div>
</div>
</div>
</a>
`;

View File

@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PostListItem renders post list item with basic informations 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<!---->
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">My Blog Post</p>
<p class="post-publication-date"><span class="icon"><i class="mdi mdi-clock mdi-24px"></i></span> <span class="has-text-grey-dark">Dec 2, 2020</span></p>
<!---->
<!---->
</div>
</a>
`;
exports[`PostListItem renders post list item with publisher name 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<!---->
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">My Blog Post</p>
<p class="post-publication-date"><span class="icon"><i class="mdi mdi-clock mdi-24px"></i></span> <span class="has-text-grey-dark">Dec 2, 2020</span></p>
<!---->
<p class="post-publisher has-text-grey-dark"><span class="icon"><i class="mdi mdi-account-edit mdi-24px"></i></span> <span>Published by <b class="has-text-weight-medium">An author</b></span></p>
</div>
</a>
`;
exports[`PostListItem renders post list item with tags 1`] = `
<a href="/p/my-blog-post-some-uuid" class="post-minimalist-card-wrapper">
<!---->
<div class="title-info-wrapper has-text-grey-dark">
<p class="post-minimalist-title">My Blog Post</p>
<p class="post-publication-date"><span class="icon"><i class="mdi mdi-clock mdi-24px"></i></span> <span class="has-text-grey-dark">Dec 2, 2020</span></p>
<div class="tags" style="display: inline;"><span class="icon"><i class="mdi mdi-tag mdi-24px"></i></span> <span class="tag"><!----><span class="">A tag</span>
<!----></span>
</div>
<!---->
</div>
</a>
`;

View File

@ -48,6 +48,7 @@ exports[`Reset page shows error if token is invalid 1`] = `
<div class="media-content">The token you provided is invalid.</div>
</div>
</section>
<!---->
</article>
</transition-stub>
<form>

View File

@ -21,7 +21,9 @@ module.exports = {
css: {
loaderOptions: {
scss: {
additionalData: `@import "@/variables.scss";`,
additionalData: `
@use "@/variables.scss" as *;
`,
sassOptions: {
quietDeps: true,
},

File diff suppressed because it is too large Load Diff

View File

@ -300,7 +300,7 @@ defmodule Mobilizon.Federation.ActivityPub.Utils do
end
def origin_check?(id, %{"type" => type, "id" => actor_id} = _params)
when type in ["Actor", "Person", "Group"],
when type in ["Actor", "Person", "Group", "Application"],
do: id == actor_id
def origin_check?(_id, %{"actor" => nil} = _args), do: false

View File

@ -21,7 +21,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
# We limit the max number of events that can be retrieved
@event_max_limit 100
@number_of_related_events 3
@number_of_related_events 4
@spec organizer_for_event(Event.t(), map(), Absinthe.Resolution.t()) ::
{:ok, Actor.t() | nil} | {:error, String.t()}
@ -213,38 +213,19 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
"""
@spec list_related_events(Event.t(), map(), Absinthe.Resolution.t()) :: {:ok, list(Event.t())}
def list_related_events(
%Event{tags: tags, organizer_actor: organizer_actor, uuid: uuid},
%Event{uuid: uuid} = event,
_args,
_resolution
) do
# We get the organizer's next public event
events =
[Events.get_upcoming_public_event_for_actor(organizer_actor, uuid)]
|> Enum.filter(&is_map/1)
# We find similar events with the same tags
# uniq_by : It's possible event_from_same_actor is inside events_from_tags
events =
events
|> Enum.concat(Events.list_events_by_tags(tags, @number_of_related_events))
|> uniq_events()
# TODO: We should use tag_relations to find more appropriate events
# We've considered all recommended events, so we fetch the latest events
events =
if @number_of_related_events - length(events) > 0 do
events
|> Enum.concat(
Events.list_events(1, @number_of_related_events, :begins_on, :asc, true).elements
)
|> uniq_events()
else
events
end
events =
events
event
|> organizer_next_public_event()
# We find similar events with the same tags
|> similar_events_common_tags(event)
# TODO: We should use tag_relations to find more appropriate events
# We've considered all recommended events, so we fetch the latest events
|> add_latest_events()
# We remove the same event from the results
|> Enum.filter(fn event -> event.uuid != uuid end)
# We return only @number_of_related_events right now
@ -253,6 +234,39 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
{:ok, events}
end
@spec organizer_next_public_event(Event.t()) :: list(Event.t())
defp organizer_next_public_event(%Event{attributed_to: %Actor{} = group, uuid: uuid}) do
[Events.get_upcoming_public_event_for_actor(group, uuid)]
|> Enum.filter(&is_map/1)
end
defp organizer_next_public_event(%Event{organizer_actor: %Actor{} = profile, uuid: uuid}) do
[Events.get_upcoming_public_event_for_actor(profile, uuid)]
|> Enum.filter(&is_map/1)
end
@spec similar_events_common_tags(list(Event.t()), Event.t()) :: list(Event.t())
defp similar_events_common_tags(events, %Event{tags: tags, uuid: uuid}) do
events
|> Enum.concat(Events.list_events_by_tags(tags, @number_of_related_events))
|> Enum.filter(fn event -> event.uuid != uuid end)
# uniq_by : It's possible event_from_same_actor is inside events_from_tags
|> uniq_events()
end
@spec add_latest_events(list(Event.t())) :: list(Event.t())
defp add_latest_events(events) do
if @number_of_related_events - length(events) > 0 do
events
|> Enum.concat(
Events.list_events(1, @number_of_related_events + 1, :begins_on, :asc, true).elements
)
|> uniq_events()
else
events
end
end
@spec uniq_events(list(Event.t())) :: list(Event.t())
defp uniq_events(events), do: Enum.uniq_by(events, fn event -> event.uuid end)

View File

@ -6,7 +6,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
import Mobilizon.Users.Guards
alias Mobilizon.Config
alias Mobilizon.{Actors, Events}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Federation.ActivityPub.Actions
alias Mobilizon.Federation.ActivityPub.Actor, as: ActivityPubActor
alias Mobilizon.GraphQL.API
@ -320,6 +320,82 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:error, dgettext("errors", "You need to be logged-in to leave a group")}
end
@doc """
Follow a group
"""
@spec follow_group(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Follower.t()} | {:error, String.t()}
def follow_group(_parent, %{group_id: group_id, notify: _notify}, %{
context: %{current_actor: %Actor{} = actor}
}) do
case Actors.get_actor(group_id) do
%Actor{type: :Group} = group ->
case Actions.Follow.follow(actor, group) do
{:ok, _activity, %Follower{} = follower} ->
{:ok, follower}
{:error, :already_following} ->
{:error, dgettext("errors", "You are already following this group")}
end
nil ->
{:error, dgettext("errors", "Group not found")}
end
end
def follow_group(_parent, _args, _resolution) do
{:error, dgettext("errors", "You need to be logged-in to follow a group")}
end
@doc """
Update a group follow
"""
@spec update_group_follow(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Member.t()} | {:error, String.t()}
def update_group_follow(_parent, %{follow_id: follow_id, notify: notify}, %{
context: %{current_actor: %Actor{} = actor}
}) do
case Actors.get_follower(follow_id) do
%Follower{} = follower ->
if follower.actor_id == actor.id do
# Update notify
Actors.update_follower(follower, %{notify: notify})
else
{:error, dgettext("errors", "Follow does not match your account")}
end
nil ->
{:error, dgettext("errors", "Follow not found")}
end
end
def update_group_follow(_parent, _args, _resolution) do
{:error, dgettext("errors", "You need to be logged-in to update a group follow")}
end
@doc """
Unfollow a group
"""
@spec unfollow_group(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Follower.t()} | {:error, String.t()}
def unfollow_group(_parent, %{group_id: group_id}, %{
context: %{current_actor: %Actor{} = actor}
}) do
case Actors.get_actor(group_id) do
%Actor{type: :Group} = group ->
with {:ok, _activity, %Follower{} = follower} <- Actions.Follow.unfollow(actor, group) do
{:ok, follower}
end
nil ->
{:error, dgettext("errors", "Group not found")}
end
end
def unfollow_group(_parent, _args, _resolution) do
{:error, dgettext("errors", "You need to be logged-in to unfollow a group")}
end
@spec find_events_for_group(Actor.t(), map(), Absinthe.Resolution.t()) ::
{:ok, Page.t(Event.t())}
def find_events_for_group(

View File

@ -6,7 +6,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Member}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.Participant
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
@ -355,7 +355,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end
@doc """
Returns the list of events this person is going to
Returns this person's group memberships
"""
@spec person_memberships(Actor.t(), map(), map()) :: {:ok, Page.t()} | {:error, String.t()}
def person_memberships(%Actor{id: actor_id} = person, %{group: group}, %{
@ -398,6 +398,49 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
end
end
@doc """
Returns this person's group follows
"""
@spec person_follows(Actor.t(), map(), map()) :: {:ok, Page.t()} | {:error, String.t()}
def person_follows(%Actor{} = person, %{group: group}, %{
context: %{current_user: %User{} = user}
}) do
if user_can_access_person_details?(person, user) do
with {:group, %Actor{} = group} <- {:group, Actors.get_actor_by_name(group, :Group)},
%Follower{} = follow <-
Actors.get_follower_by_followed_and_following(group, person) do
{:ok,
%Page{
total: 1,
elements: [follow]
}}
else
nil ->
{:ok, %Page{total: 0, elements: []}}
{:group, nil} ->
{:error, :group_not_found}
end
else
{:error, dgettext("errors", "Profile is not owned by authenticated user")}
end
end
def person_follows(
%Actor{} = person,
%{page: page, limit: limit},
%{
context: %{current_user: %User{} = user}
}
) do
if user_can_access_person_details?(person, user) do
follows = Actors.list_paginated_follows_for_actor(person, page, limit)
{:ok, follows}
else
{:error, dgettext("errors", "Profile is not owned by authenticated user")}
end
end
@spec user_for_person(Actor.t(), map(), Absinthe.Resolution.t()) ::
{:ok, User.t() | nil} | {:error, String.t() | nil}
def user_for_person(%Actor{type: :Person, user_id: user_id}, _args, %{

View File

@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
import Mobilizon.Users.Guards
alias Mobilizon.{Actors, Admin, Config, Events, Users}
alias Mobilizon.{Actors, Admin, Config, Events, FollowedGroupActivity, Users}
alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.{Actions, Relay}
alias Mobilizon.Service.Auth.Authenticator
@ -311,19 +311,20 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
def change_default_actor(
_parent,
%{preferred_username: username},
%{context: %{current_user: %User{id: user_id} = user}}
%{context: %{current_user: %User{} = user}}
) do
with %Actor{id: actor_id} = actor <- Actors.get_local_actor_by_name(username),
{:user_actor, true} <-
{:user_actor, actor_id in Enum.map(Users.get_actors_for_user(user), & &1.id)},
%User{} = user <- Users.update_user_default_actor(user_id, actor) do
{:ok, user}
else
{:user_actor, _} ->
{:error, :actor_not_from_user}
case Actors.get_local_actor_by_name(username) do
%Actor{id: actor_id} = actor ->
if actor_id in Enum.map(Users.get_actors_for_user(user), & &1.id) do
%User{} = user = Users.update_user_default_actor(user, actor)
{:ok, user}
else
{:error, dgettext("errors", "This profile does not belong to you")}
end
_error ->
{:error, :unable_to_change_default_actor}
nil ->
{:error,
dgettext("errors", "Profile with username %{username} not found", %{username: username})}
end
end
@ -632,6 +633,29 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
}}
end
def user_followed_group_events(%User{id: user_id}, %{page: page, limit: limit} = args, %{
context: %{current_user: %User{id: logged_in_user_id}}
})
when user_id == logged_in_user_id do
activities =
FollowedGroupActivity.user_followed_group_events(
user_id,
Map.get(args, :after_datetime),
page,
limit
)
activities = %Page{
activities
| elements:
Enum.map(activities.elements, fn [event, group, profile] ->
%{group: group, profile: profile, event: event}
end)
}
{:ok, activities}
end
@spec update_user_login_information(User.t(), map()) ::
{:ok, User.t()} | {:error, Ecto.Changeset.t()}
defp update_user_login_information(

View File

@ -49,6 +49,7 @@ defmodule Mobilizon.GraphQL.Schema do
import_types(Schema.StatisticsType)
import_types(Schema.Users.PushSubscription)
import_types(Schema.Users.ActivitySetting)
import_types(Schema.FollowedGroupActivityType)
@desc "A struct containing the id of the deleted object"
object :deleted_object do
@ -157,7 +158,6 @@ defmodule Mobilizon.GraphQL.Schema do
import_fields(:resource_queries)
import_fields(:post_queries)
import_fields(:statistics_queries)
# import_fields(:push_queries)
end
@desc """

View File

@ -17,6 +17,11 @@ defmodule Mobilizon.GraphQL.Schema.Actors.FollowerType do
description: "Whether the follow has been approved by the target actor"
)
field(:notify, :boolean,
description:
"Whether the follower will be notified by the target actor's activity or not (applicable for profile/group follows)"
)
field(:inserted_at, :datetime, description: "When the follow was created")
field(:updated_at, :datetime, description: "When the follow was updated")
end

View File

@ -205,6 +205,12 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
value(:private, description: "Visible only to people with the link - or invited")
end
object :group_follow do
field(:group, :group, description: "The group followed")
field(:profile, :group, description: "The group followed")
field(:notify, :boolean, description: "Whether to notify profile from group activity")
end
object :group_queries do
@desc "Get all groups"
field :groups, :paginated_group_list do
@ -310,5 +316,36 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
resolve(&Group.delete_group/3)
end
@desc "Follow a group"
field :follow_group, :follower do
arg(:group_id, non_null(:id), description: "The group ID")
arg(:notify, :boolean,
description: "Whether to notify profile from group activity",
default_value: true
)
resolve(&Group.follow_group/3)
end
@desc "Update a group follow"
field :update_group_follow, :follower do
arg(:follow_id, non_null(:id), description: "The follow ID")
arg(:notify, :boolean,
description: "Whether to notify profile from group activity",
default_value: true
)
resolve(&Group.update_group_follow/3)
end
@desc "Unfollow a group"
field :unfollow_group, :follower do
arg(:group_id, non_null(:id), description: "The group ID")
resolve(&Group.unfollow_group/3)
end
end
end

View File

@ -102,6 +102,21 @@ defmodule Mobilizon.GraphQL.Schema.Actors.PersonType do
arg(:limit, :integer, default_value: 10, description: "The limit of memberships per page")
resolve(&Person.person_memberships/3)
end
@desc "The list of groups this person follows"
field(:follows, :paginated_follower_list,
description: "The list of groups this person follows"
) do
arg(:group, :string, description: "Filter by group federated username")
arg(:page, :integer,
default_value: 1,
description: "The page in the follows list"
)
arg(:limit, :integer, default_value: 10, description: "The limit of follows per page")
resolve(&Person.person_follows/3)
end
end
@desc """

View File

@ -0,0 +1,20 @@
defmodule Mobilizon.GraphQL.Schema.FollowedGroupActivityType do
@moduledoc """
Schema representation for a follow activity
"""
use Absinthe.Schema.Notation
@desc "A paginated list of follow group events"
object :paginated_followed_group_events do
field(:elements, list_of(:followed_group_event), description: "A list of follow group events")
field(:total, :integer, description: "The total number of follow group events in the list")
end
@desc "A follow group event"
object :followed_group_event do
field(:user, :user)
field(:profile, :person)
field(:group, :group)
field(:event, :event)
end
end

View File

@ -104,6 +104,26 @@ defmodule Mobilizon.GraphQL.Schema.UserType do
resolve(&User.user_drafted_events/3)
end
field(:followed_group_events, :paginated_followed_group_events,
description: "The suggested events from the groups this user follows"
) do
arg(:page, :integer,
default_value: 1,
description: "The page in the follow group events list"
)
arg(:limit, :integer,
default_value: 10,
description: "The limit of follow group events per page"
)
arg(:after_datetime, :datetime,
description: "Filter follow group events by event start datetime"
)
resolve(&User.user_followed_group_events/3)
end
field(:settings, :user_settings, description: "The list of settings for this user") do
resolve(&User.user_settings/3)
end

View File

@ -19,7 +19,6 @@ defmodule Mobilizon do
alias Mobilizon.Federation.ActivityPub
alias Mobilizon.Service.{ErrorPage, ErrorReporting}
alias Mobilizon.Service.Export.{Feed, ICalendar}
alias TzWorld.Backend.DetsWithIndexCache
@name Mix.Project.config()[:name]
@version Mix.Project.config()[:version]
@ -38,9 +37,8 @@ defmodule Mobilizon do
@spec start(:normal | {:takeover, node} | {:failover, node}, term) ::
{:ok, pid} | {:ok, pid, term} | {:error, term}
def start(_type, _args) do
# We update and reload TzWorld at runtime so that the data is not contained in releases
# We update TzWorld at runtime so that the data is not contained in releases
TzWorldUpdate.run(nil)
DetsWithIndexCache.handle_call(:reload_data, nil, nil)
children =
[

View File

@ -1030,6 +1030,14 @@ defmodule Mobilizon.Actors do
|> Repo.delete()
end
@spec list_paginated_follows_for_actor(Actor.t(), integer | nil, integer | nil) ::
Page.t(Follower.t())
def list_paginated_follows_for_actor(%Actor{id: actor_id}, page \\ nil, limit \\ nil) do
actor_id
|> followings_for_actor_query
|> Page.build_page(page, limit)
end
@doc """
Returns the list of external followers for an actor.
"""

View File

@ -17,12 +17,14 @@ defmodule Mobilizon.Actors.Follower do
url: String.t(),
target_actor: Actor.t(),
actor: Actor.t(),
notify: boolean(),
inserted_at: DateTime.t(),
updated_at: DateTime.t()
}
@required_attrs [:url, :approved, :target_actor_id, :actor_id]
@attrs @required_attrs
@optional_attrs [:notify]
@attrs @required_attrs ++ @optional_attrs
@timestamps_opts [type: :utc_datetime]
@ -30,6 +32,7 @@ defmodule Mobilizon.Actors.Follower do
schema "followers" do
field(:approved, :boolean, default: false)
field(:url, :string)
field(:notify, :boolean, default: true)
timestamps()

View File

@ -0,0 +1,56 @@
defmodule Mobilizon.FollowedGroupActivity do
@moduledoc """
Provide recent elements from groups that profiles follow
"""
import Ecto.Query
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.{Event, Participant}
alias Mobilizon.Storage.Page
@spec user_followed_group_events(
integer() | String.t(),
DateTime.t() | nil,
integer() | nil,
integer() | nil
) :: Page.t(Event.t())
def user_followed_group_events(user_id, after_datetime, page \\ nil, limit \\ nil) do
Event
|> distinct([e], e.id)
|> join(:left, [e], p in Participant, on: e.id == p.event_id)
|> join(:inner, [_e, p], pa in Actor, on: p.actor_id == pa.id)
|> join(:inner, [e], g in Actor, on: e.attributed_to_id == g.id)
|> join(:left, [_e, _p, _pa, g], f in Follower, on: g.id == f.target_actor_id)
|> join(:left, [_e, _p, _pa, g], m in Member, on: g.id == m.parent_id)
|> join(:inner, [_e, _p, pa, _g, f, m], a in Actor,
on: a.id == f.actor_id or a.id == m.actor_id
)
|> add_after_datetime_filter(after_datetime)
|> where(
[_e, p, pa, _g, f, m, a],
(f.approved or m.role in ^[:member, :moderator, :administrator, :creator]) and
a.user_id == ^user_id and
pa.user_id != ^user_id
)
|> preload([
:organizer_actor,
:attributed_to,
:tags,
:physical_address,
:picture
])
|> select([e, g, _f, _m, a], [
e,
g,
a
])
|> Page.build_page(page, limit)
end
@spec add_after_datetime_filter(Ecto.Query.t(), DateTime.t() | nil) :: Ecto.Query.t()
defp add_after_datetime_filter(query, nil),
do: where(query, [e], e.begins_on > ^DateTime.utc_now())
defp add_after_datetime_filter(query, %DateTime{} = datetime),
do: where(query, [e], e.begins_on > ^datetime)
end

Some files were not shown because too many files have changed in this diff Show More