Merge branch 'group-improvement' into 'master'

Improvements to group page

See merge request framasoft/mobilizon!561
This commit is contained in:
Thomas Citharel 2020-09-21 11:35:43 +02:00
commit 33b56e66ed
19 changed files with 298 additions and 87 deletions

View File

@ -13,7 +13,7 @@
{{ actor.name || `@${usernameWithDomain(actor)}` }} {{ actor.name || `@${usernameWithDomain(actor)}` }}
</p> </p>
<p class="has-text-grey" v-if="actor.name">@{{ usernameWithDomain(actor) }}</p> <p class="has-text-grey" v-if="actor.name">@{{ usernameWithDomain(actor) }}</p>
<p v-if="full" class="summary" :class="{ limit: limit }">{{ actor.summary }}</p> <div v-if="full" class="summary" :class="{ limit: limit }" v-html="actor.summary" />
</div> </div>
</div> </div>
</div> </div>

View File

@ -40,6 +40,7 @@
</button> </button>
<button <button
v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 1 }) }" :class="{ 'is-active': isActive.heading({ level: 1 }) }"
@click="commands.heading({ level: 1 })" @click="commands.heading({ level: 1 })"
@ -49,6 +50,7 @@
</button> </button>
<button <button
v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 2 }) }" :class="{ 'is-active': isActive.heading({ level: 2 }) }"
@click="commands.heading({ level: 2 })" @click="commands.heading({ level: 2 })"
@ -58,6 +60,7 @@
</button> </button>
<button <button
v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': isActive.heading({ level: 3 }) }" :class="{ 'is-active': isActive.heading({ level: 3 }) }"
@click="commands.heading({ level: 3 })" @click="commands.heading({ level: 3 })"
@ -75,12 +78,18 @@
<b-icon icon="link" /> <b-icon icon="link" />
</button> </button>
<button class="menubar__button" @click="showImagePrompt(commands.image)" type="button"> <button
class="menubar__button"
v-if="!isBasicMode"
@click="showImagePrompt(commands.image)"
type="button"
>
<b-icon icon="image" /> <b-icon icon="image" />
</button> </button>
<button <button
class="menubar__button" class="menubar__button"
v-if="!isBasicMode"
:class="{ 'is-active': isActive.bullet_list() }" :class="{ 'is-active': isActive.bullet_list() }"
@click="commands.bullet_list" @click="commands.bullet_list"
type="button" type="button"
@ -89,6 +98,7 @@
</button> </button>
<button <button
v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': isActive.ordered_list() }" :class="{ 'is-active': isActive.ordered_list() }"
@click="commands.ordered_list" @click="commands.ordered_list"
@ -98,6 +108,7 @@
</button> </button>
<button <button
v-if="!isBasicMode"
class="menubar__button" class="menubar__button"
:class="{ 'is-active': isActive.blockquote() }" :class="{ 'is-active': isActive.blockquote() }"
@click="commands.blockquote" @click="commands.blockquote"
@ -106,11 +117,11 @@
<b-icon icon="format-quote-close" /> <b-icon icon="format-quote-close" />
</button> </button>
<button class="menubar__button" @click="commands.undo" type="button"> <button v-if="!isBasicMode" class="menubar__button" @click="commands.undo" type="button">
<b-icon icon="undo" /> <b-icon icon="undo" />
</button> </button>
<button class="menubar__button" @click="commands.redo" type="button"> <button v-if="!isBasicMode" class="menubar__button" @click="commands.redo" type="button">
<b-icon icon="redo" /> <b-icon icon="redo" />
</button> </button>
</div> </div>
@ -229,26 +240,30 @@ export default class EditorComponent extends Vue {
filteredActors: IActor[] = []; filteredActors: IActor[] = [];
suggestionRange!: object | null; suggestionRange!: Record<string, unknown> | null;
navigatedActorIndex = 0; navigatedActorIndex = 0;
popup!: Instance[] | null; popup!: Instance[] | null;
get isDescriptionMode() { get isDescriptionMode(): boolean {
return this.mode === "description"; return this.mode === "description" || this.isBasicMode;
} }
get isCommentMode() { get isCommentMode(): boolean {
return this.mode === "comment"; return this.mode === "comment";
} }
get hasResults() { get hasResults(): boolean {
return this.filteredActors.length; return this.filteredActors.length > 0;
} }
get showSuggestions() { get showSuggestions(): boolean {
return this.query || this.hasResults; return (this.query || this.hasResults) as boolean;
}
get isBasicMode(): boolean {
return this.mode === "basic";
} }
// eslint-disable-next-line // eslint-disable-next-line
@ -258,7 +273,7 @@ export default class EditorComponent extends Vue {
observer!: MutationObserver | null; observer!: MutationObserver | null;
mounted() { mounted(): void {
this.editor = new Editor({ this.editor = new Editor({
extensions: [ extensions: [
new Blockquote(), new Blockquote(),

View File

@ -16,7 +16,7 @@
</span> </span>
<span> <span>
<span> <span>
{{ $t("Organized by {name}", { name: event.organizerActor.displayName() }) }} {{ $t("Organized by {name}", { name: usernameWithDomain(event.organizerActor) }) }}
</span> </span>
</span> </span>
</div> </div>
@ -53,7 +53,7 @@
import { ParticipantRole, EventVisibility, IEventCardOptions, IEvent } from "@/types/event.model"; import { ParticipantRole, EventVisibility, IEventCardOptions, IEvent } from "@/types/event.model";
import { Component, Prop } from "vue-property-decorator"; import { Component, Prop } from "vue-property-decorator";
import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue"; import DateCalendarIcon from "@/components/Event/DateCalendarIcon.vue";
import { IPerson } from "@/types/actor"; import { IPerson, usernameWithDomain } from "@/types/actor";
import { mixins } from "vue-class-component"; import { mixins } from "vue-class-component";
import ActorMixin from "@/mixins/actor"; import ActorMixin from "@/mixins/actor";
import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor"; import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
@ -96,6 +96,8 @@ export default class EventListViewCard extends mixins(ActorMixin, EventMixin) {
EventVisibility = EventVisibility; EventVisibility = EventVisibility;
RouteName = RouteName; RouteName = RouteName;
usernameWithDomain = usernameWithDomain;
} }
</script> </script>

View File

@ -9,7 +9,46 @@
<p v-if="event.physicalAddress" class="has-text-grey"> <p v-if="event.physicalAddress" class="has-text-grey">
{{ event.physicalAddress.description }} {{ event.physicalAddress.description }}
</p> </p>
<p v-else>3 demandes de participation à traiter</p> <p v-else>
<span v-if="event.options.maximumAttendeeCapacity !== 0">
{{
$tc(
"{available}/{capacity} available places",
event.options.maximumAttendeeCapacity - event.participantStats.participant,
{
available:
event.options.maximumAttendeeCapacity - event.participantStats.participant,
capacity: event.options.maximumAttendeeCapacity,
}
)
}}
</span>
<span v-else>
{{
$tc("{count} participants", event.participantStats.participant, {
count: event.participantStats.participant,
})
}}
</span>
<span v-if="event.participantStats.notApproved > 0">
<b-button
type="is-text"
@click="
gotToWithCheck(participation, {
name: RouteName.PARTICIPATIONS,
query: { role: ParticipantRole.NOT_APPROVED },
params: { eventId: event.uuid },
})
"
>
{{
$tc("{count} requests waiting", event.participantStats.notApproved, {
count: event.participantStats.notApproved,
})
}}
</b-button>
</span>
</p>
</div> </div>
</router-link> </router-link>
</template> </template>

View File

@ -78,12 +78,30 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
banner { banner {
url url
} }
organizedEvents { organizedEvents(
afterDatetime: $afterDateTime
beforeDatetime: $beforeDateTime
page: $organisedEventsPage
limit: $organisedEventslimit
) {
elements { elements {
id id
uuid uuid
title title
beginsOn beginsOn
options {
maximumAttendeeCapacity
}
participantStats {
participant
notApproved
}
organizerActor {
id
preferredUsername
name
domain
}
} }
total total
} }
@ -154,7 +172,13 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
`; `;
export const FETCH_GROUP = gql` export const FETCH_GROUP = gql`
query($name: String!) { query(
$name: String!
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
) {
group(preferredUsername: $name) { group(preferredUsername: $name) {
...GroupFullFields ...GroupFullFields
} }
@ -166,7 +190,13 @@ export const FETCH_GROUP = gql`
`; `;
export const GET_GROUP = gql` export const GET_GROUP = gql`
query($id: ID!) { query(
$id: ID!
$afterDateTime: DateTime
$beforeDateTime: DateTime
$organisedEventsPage: Int
$organisedEventslimit: Int
) {
getGroup(id: $id) { getGroup(id: $id) {
...GroupFullFields ...GroupFullFields
} }

View File

@ -34,20 +34,25 @@
}} }}
</p> </p>
<b-loading :active.sync="$apollo.loading"></b-loading> <b-loading :active.sync="$apollo.loading"></b-loading>
<section v-if="group && group.organizedEvents.total > 0"> <section v-if="group">
<subtitle> <subtitle>
{{ $t("Past events") }} {{ showPassedEvents ? $t("Past events") : $t("Upcoming events") }}
</subtitle> </subtitle>
<b-switch v-model="showPassedEvents">{{ $t("Past events") }}</b-switch>
<transition-group name="list" tag="p"> <transition-group name="list" tag="p">
<EventListViewCard v-for="event in group.organizedEvents.elements" :key="event.id" /> <EventListViewCard
v-for="event in group.organizedEvents.elements"
:key="event.id"
:event="event"
/>
</transition-group> </transition-group>
<b-message
v-if="group.organizedEvents.elements.length === 0 && $apollo.loading === false"
type="is-danger"
>
{{ $t("No events found") }}
</b-message>
</section> </section>
<b-message
v-if="group.organizedEvents.elements.length === 0 && $apollo.loading === false"
type="is-danger"
>
{{ $t("No events found") }}
</b-message>
</section> </section>
</div> </div>
</template> </template>
@ -55,6 +60,8 @@
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import { FETCH_GROUP } from "@/graphql/group"; import { FETCH_GROUP } from "@/graphql/group";
import RouteName from "@/router/name"; import RouteName from "@/router/name";
import Subtitle from "@/components/Utils/Subtitle.vue";
import EventListViewCard from "@/components/Event/EventListViewCard.vue";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { IGroup, usernameWithDomain } from "../../types/actor";
@Component({ @Component({
@ -64,10 +71,16 @@ import { IGroup, usernameWithDomain } from "../../types/actor";
variables() { variables() {
return { return {
name: this.$route.params.preferredUsername, name: this.$route.params.preferredUsername,
beforeDateTime: this.showPassedEvents ? new Date() : null,
afterDateTime: this.showPassedEvents ? null : new Date(),
}; };
}, },
}, },
}, },
components: {
Subtitle,
EventListViewCard,
},
}) })
export default class GroupEvents extends Vue { export default class GroupEvents extends Vue {
group!: IGroup; group!: IGroup;
@ -75,5 +88,7 @@ export default class GroupEvents extends Vue {
usernameWithDomain = usernameWithDomain; usernameWithDomain = usernameWithDomain;
RouteName = RouteName; RouteName = RouteName;
showPassedEvents = false;
} }
</script> </script>

View File

@ -302,6 +302,10 @@
{{ $t("No group found") }} {{ $t("No group found") }}
</b-message> </b-message>
<div v-else class="public-container"> <div v-else class="public-container">
<section>
<subtitle>{{ $t("About") }}</subtitle>
<div v-html="group.summary" />
</section>
<section> <section>
<subtitle>{{ $t("Upcoming events") }}</subtitle> <subtitle>{{ $t("Upcoming events") }}</subtitle>
<div class="organized-events-wrapper" v-if="group && group.organizedEvents.total > 0"> <div class="organized-events-wrapper" v-if="group && group.organizedEvents.total > 0">
@ -318,16 +322,12 @@
</section> </section>
<section> <section>
<subtitle>{{ $t("Latest posts") }}</subtitle> <subtitle>{{ $t("Latest posts") }}</subtitle>
<div v-if="group && group.posts.total > 0"> <div v-if="group.posts.total > 0" class="posts-wrapper">
<router-link <post-list-item v-for="post in group.posts.elements" :key="post.id" :post="post" />
v-for="post in group.posts.elements" </div>
:key="post.id" <div v-else-if="group" class="content has-text-grey has-text-centered">
:to="{ name: RouteName.POST, params: { slug: post.slug } }" <p>{{ $t("No posts yet") }}</p>
>
{{ post.title }}
</router-link>
</div> </div>
<span v-else-if="group">{{ $t("No public posts") }}</span>
<b-skeleton animated v-else></b-skeleton> <b-skeleton animated v-else></b-skeleton>
</section> </section>
<b-modal v-if="physicalAddress && physicalAddress.geom" :active.sync="showMap"> <b-modal v-if="physicalAddress && physicalAddress.geom" :active.sync="showMap">
@ -369,6 +369,7 @@ import FolderItem from "@/components/Resource/FolderItem.vue";
import { Address } from "@/types/address.model"; import { Address } from "@/types/address.model";
import Invitations from "@/components/Group/Invitations.vue"; import Invitations from "@/components/Group/Invitations.vue";
import addMinutes from "date-fns/addMinutes"; import addMinutes from "date-fns/addMinutes";
import { Route } from "vue-router";
import GroupSection from "../../components/Group/GroupSection.vue"; import GroupSection from "../../components/Group/GroupSection.vue";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
@ -413,11 +414,13 @@ import RouteName from "../../router/name";
metaInfo() { metaInfo() {
return { return {
// if no subcomponents specify a metaInfo.title, this title will be used // if no subcomponents specify a metaInfo.title, this title will be used
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
title: this.groupTitle, title: this.groupTitle,
// all titles will be injected into this template // all titles will be injected into this template
titleTemplate: "%s | Mobilizon", titleTemplate: "%s | Mobilizon",
meta: [ meta: [
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
{ name: "description", content: this.groupSummary }, { name: "description", content: this.groupSummary },
], ],
@ -442,14 +445,14 @@ export default class Group extends Vue {
showMap = false; showMap = false;
@Watch("currentActor") @Watch("currentActor")
watchCurrentActor(currentActor: IActor, oldActor: IActor) { watchCurrentActor(currentActor: IActor, oldActor: IActor): void {
if (currentActor.id && oldActor && currentActor.id !== oldActor.id) { if (currentActor.id && oldActor && currentActor.id !== oldActor.id) {
this.$apollo.queries.group.refetch(); this.$apollo.queries.group.refetch();
} }
} }
async leaveGroup() { async leaveGroup(): Promise<Route> {
const { data } = await this.$apollo.mutate({ await this.$apollo.mutate({
mutation: LEAVE_GROUP, mutation: LEAVE_GROUP,
variables: { variables: {
groupId: this.group.id, groupId: this.group.id,
@ -458,9 +461,10 @@ export default class Group extends Vue {
return this.$router.push({ name: RouteName.MY_GROUPS }); return this.$router.push({ name: RouteName.MY_GROUPS });
} }
acceptInvitation() { acceptInvitation(): void {
if (this.groupMember) { if (this.groupMember) {
const index = this.person.memberships.elements.findIndex( const index = this.person.memberships.elements.findIndex(
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore // @ts-ignore
({ id }: IMember) => id === this.groupMember.id ({ id }: IMember) => id === this.groupMember.id
); );
@ -471,12 +475,12 @@ export default class Group extends Vue {
} }
} }
get groupTitle() { get groupTitle(): undefined | string {
if (!this.group) return undefined; if (!this.group) return undefined;
return this.group.preferredUsername; return this.group.preferredUsername;
} }
get groupSummary() { get groupSummary(): undefined | string {
if (!this.group) return undefined; if (!this.group) return undefined;
return this.group.summary; return this.group.summary;
} }
@ -486,8 +490,8 @@ export default class Group extends Vue {
return this.person.memberships.elements.find(({ parent: { id } }) => id === this.group.id); return this.person.memberships.elements.find(({ parent: { id } }) => id === this.group.id);
} }
get groupMemberships() { get groupMemberships(): (string | undefined)[] {
if (!this.person || !this.person.id) return undefined; if (!this.person || !this.person.id) return [];
return this.person.memberships.elements return this.person.memberships.elements
.filter( .filter(
(membership: IMember) => (membership: IMember) =>
@ -499,7 +503,7 @@ export default class Group extends Vue {
} }
get isCurrentActorAGroupMember(): boolean { get isCurrentActorAGroupMember(): boolean {
return this.groupMemberships != undefined && this.groupMemberships.includes(this.group.id); return this.groupMemberships !== undefined && this.groupMemberships.includes(this.group.id);
} }
get isCurrentActorARejectedGroupMember(): boolean { get isCurrentActorARejectedGroupMember(): boolean {
@ -532,7 +536,8 @@ export default class Group extends Vue {
} }
/** /**
* New members, if on a different server, can take a while to refresh the group and fetch all private data * New members, if on a different server,
* can take a while to refresh the group and fetch all private data
*/ */
get isCurrentActorARecentMember(): boolean { get isCurrentActorARecentMember(): boolean {
return ( return (
@ -673,5 +678,11 @@ div.container {
} }
} }
} }
.public-container {
section {
margin-top: 2rem;
}
}
} }
</style> </style>

View File

@ -37,7 +37,7 @@
<b-input v-model="group.name" /> <b-input v-model="group.name" />
</b-field> </b-field>
<b-field :label="$t('Group short description')"> <b-field :label="$t('Group short description')">
<b-input type="textarea" v-model="group.summary" <editor mode="basic" v-model="group.summary"
/></b-field> /></b-field>
<p class="label">{{ $t("Group visibility") }}</p> <p class="label">{{ $t("Group visibility") }}</p>
<div class="field"> <div class="field">
@ -105,12 +105,12 @@
<script lang="ts"> <script lang="ts">
import { Component, Vue } from "vue-property-decorator"; import { Component, Vue } from "vue-property-decorator";
import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue"; import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
import { Route } from "vue-router";
import RouteName from "../../router/name"; import RouteName from "../../router/name";
import { FETCH_GROUP, UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group"; import { FETCH_GROUP, UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
import { IGroup, usernameWithDomain } from "../../types/actor"; import { IGroup, usernameWithDomain } from "../../types/actor";
import { Address, IAddress } from "../../types/address.model"; import { Address, IAddress } from "../../types/address.model";
import { IMember, Group } from "../../types/actor/group.model"; import { Group } from "../../types/actor/group.model";
import { Paginate } from "../../types/paginate";
@Component({ @Component({
apollo: { apollo: {
@ -129,6 +129,7 @@ import { Paginate } from "../../types/paginate";
}, },
components: { components: {
FullAddressAutoComplete, FullAddressAutoComplete,
editor: () => import("../../components/Editor.vue"),
}, },
}) })
export default class GroupSettings extends Vue { export default class GroupSettings extends Vue {
@ -149,7 +150,7 @@ export default class GroupSettings extends Vue {
showCopiedTooltip = false; showCopiedTooltip = false;
async updateGroup() { async updateGroup(): Promise<void> {
const variables = { ...this.group }; const variables = { ...this.group };
// eslint-disable-next-line // eslint-disable-next-line
// @ts-ignore // @ts-ignore
@ -165,7 +166,7 @@ export default class GroupSettings extends Vue {
}); });
} }
confirmDeleteGroup() { confirmDeleteGroup(): void {
this.$buefy.dialog.confirm({ this.$buefy.dialog.confirm({
title: this.$t("Delete group") as string, title: this.$t("Delete group") as string,
message: this.$t( message: this.$t(
@ -179,7 +180,7 @@ export default class GroupSettings extends Vue {
}); });
} }
async deleteGroup() { async deleteGroup(): Promise<Route> {
await this.$apollo.mutate<{ deleteGroup: IGroup }>({ await this.$apollo.mutate<{ deleteGroup: IGroup }>({
mutation: DELETE_GROUP, mutation: DELETE_GROUP,
variables: { variables: {
@ -189,7 +190,7 @@ export default class GroupSettings extends Vue {
return this.$router.push({ name: RouteName.MY_GROUPS }); return this.$router.push({ name: RouteName.MY_GROUPS });
} }
async copyURL() { async copyURL(): Promise<void> {
await window.navigator.clipboard.writeText(this.group.url); await window.navigator.clipboard.writeText(this.group.url);
this.showCopiedTooltip = true; this.showCopiedTooltip = true;
setTimeout(() => { setTimeout(() => {
@ -197,6 +198,7 @@ export default class GroupSettings extends Vue {
}, 2000); }, 2000);
} }
// eslint-disable-next-line class-methods-use-this
get canShowCopyButton(): boolean { get canShowCopyButton(): boolean {
return window.isSecureContext; return window.isSecureContext;
} }

View File

@ -89,12 +89,27 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
def group_actor(%Actor{} = actor), do: actor def group_actor(%Actor{} = actor), do: actor
defp prepare_args_for_actor(args) do defp prepare_args_for_actor(args) do
with preferred_username <- args
args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(), |> maybe_sanitize_username()
summary <- args |> Map.get(:summary, "") |> String.trim(), |> maybe_sanitize_summary()
{summary, _mentions, _tags} <-
summary |> String.trim() |> APIUtils.make_content_html([], "text/html") do
%{args | preferred_username: preferred_username, summary: summary}
end
end end
@spec maybe_sanitize_username(map()) :: map()
defp maybe_sanitize_username(%{preferred_username: preferred_username} = args) do
Map.put(args, :preferred_username, preferred_username |> HTML.strip_tags() |> String.trim())
end
defp maybe_sanitize_username(args), do: args
@spec maybe_sanitize_summary(map()) :: map()
defp maybe_sanitize_summary(%{summary: summary} = args) do
{summary, _mentions, _tags} =
summary
|> String.trim()
|> APIUtils.make_content_html([], "text/html")
Map.put(args, :summary, summary)
end
defp maybe_sanitize_summary(args), do: args
end end

View File

@ -9,7 +9,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
alias Mobilizon.Federation.ActivityPub alias Mobilizon.Federation.ActivityPub
alias Mobilizon.GraphQL.API alias Mobilizon.GraphQL.API
alias Mobilizon.GraphQL.Resolvers.Person alias Mobilizon.GraphQL.Resolvers.Person
alias Mobilizon.Storage.Page
alias Mobilizon.Users.User alias Mobilizon.Users.User
require Logger require Logger
@ -271,7 +270,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
def find_events_for_group( def find_events_for_group(
%Actor{id: group_id} = group, %Actor{id: group_id} = group,
_args, %{
page: page,
limit: limit
} = args,
%{ %{
context: %{ context: %{
current_user: %User{role: user_role} = user current_user: %User{role: user_role} = user
@ -282,15 +284,38 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
{:member, true} <- {:member, true} <-
{:member, Actors.is_member?(actor_id, group_id) or is_moderator(user_role)} do {:member, Actors.is_member?(actor_id, group_id) or is_moderator(user_role)} do
# TODO : Handle public / restricted to group members events # TODO : Handle public / restricted to group members events
{:ok, Events.list_organized_events_for_group(group)} {:ok,
Events.list_organized_events_for_group(
group,
:all,
Map.get(args, :after_datetime),
Map.get(args, :before_datetime),
page,
limit
)}
else else
{:member, false} -> {:member, false} ->
{:ok, %Page{total: 0, elements: []}} find_events_for_group(group, args, nil)
end end
end end
def find_events_for_group(_parent, _args, _resolution) do def find_events_for_group(
{:ok, %Page{total: 0, elements: []}} %Actor{} = group,
%{
page: page,
limit: limit
} = args,
_resolution
) do
{:ok,
Events.list_organized_events_for_group(
group,
:public,
Map.get(args, :after_datetime),
Map.get(args, :before_datetime),
page,
limit
)}
end end
defp restrict_fields_for_non_member_request(%Actor{} = group) do defp restrict_fields_for_non_member_request(%Actor{} = group) do

View File

@ -54,6 +54,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
# This one should have a privacy setting # This one should have a privacy setting
field :organized_events, :paginated_event_list do field :organized_events, :paginated_event_list do
arg(:after_datetime, :datetime, default_value: nil)
arg(:before_datetime, :datetime, default_value: nil)
arg(:page, :integer, default_value: 1)
arg(:limit, :integer, default_value: 10)
resolve(&Group.find_events_for_group/3) resolve(&Group.find_events_for_group/3)
description("A list of the events this actor has organized") description("A list of the events this actor has organized")
end end

View File

@ -179,8 +179,8 @@ defmodule Mobilizon.Actors.Actor do
@doc """ @doc """
Checks whether actor visibility is public. Checks whether actor visibility is public.
""" """
@spec is_public_visibility(t) :: boolean @spec is_public_visibility?(t) :: boolean
def is_public_visibility(%__MODULE__{visibility: visibility}) do def is_public_visibility?(%__MODULE__{visibility: visibility}) do
visibility in [:public, :unlisted] visibility in [:public, :unlisted]
end end

View File

@ -405,7 +405,7 @@ defmodule Mobilizon.Events do
def list_public_events_for_actor(actor, page \\ nil, limit \\ nil) def list_public_events_for_actor(actor, page \\ nil, limit \\ nil)
def list_public_events_for_actor(%Actor{type: :Group} = group, page, limit), def list_public_events_for_actor(%Actor{type: :Group} = group, page, limit),
do: list_organized_events_for_group(group, page, limit) do: list_organized_events_for_group(group, :public, nil, page, limit)
def list_public_events_for_actor(%Actor{id: actor_id}, page, limit) do def list_public_events_for_actor(%Actor{id: actor_id}, page, limit) do
actor_id actor_id
@ -424,10 +424,25 @@ defmodule Mobilizon.Events do
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@spec list_organized_events_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t() @spec list_organized_events_for_group(
def list_organized_events_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do Actor.t(),
DateTime.t() | nil,
DateTime.t() | nil,
integer | nil,
integer | nil
) :: Page.t()
def list_organized_events_for_group(
%Actor{id: group_id},
visibility \\ :public,
after_datetime \\ nil,
before_datetime \\ nil,
page \\ nil,
limit \\ nil
) do
group_id group_id
|> event_for_group_query() |> event_for_group_query()
|> event_filter_visibility(visibility)
|> event_filter_begins_on(after_datetime, before_datetime)
|> preload_for_event() |> preload_for_event()
|> Page.build_page(page, limit) |> Page.build_page(page, limit)
end end
@ -1643,6 +1658,45 @@ defmodule Mobilizon.Events do
from(p in query, where: p.role == ^role) from(p in query, where: p.role == ^role)
end end
defp event_filter_visibility(query, :all), do: query
defp event_filter_visibility(query, :public) do
query
|> where(visibility: ^:public)
end
defp event_filter_begins_on(query, nil, nil),
do: event_order_begins_on_desc(query)
defp event_filter_begins_on(query, %DateTime{} = after_datetime, nil) do
query
|> where([e], e.begins_on > ^after_datetime)
|> event_order_begins_on_asc()
end
defp event_filter_begins_on(query, nil, %DateTime{} = before_datetime) do
query
|> where([e], e.begins_on < ^before_datetime)
|> event_order_begins_on_desc()
end
defp event_filter_begins_on(
query,
%DateTime{} = after_datetime,
%DateTime{} = before_datetime
) do
query
|> where([e], e.begins_on < ^before_datetime)
|> where([e], e.begins_on > ^after_datetime)
|> event_order_begins_on_asc()
end
defp event_order_begins_on_asc(query),
do: order_by(query, [e], asc: e.begins_on)
defp event_order_begins_on_desc(query),
do: order_by(query, [e], desc: e.begins_on)
defp participation_filter_begins_on(query, nil, nil), defp participation_filter_begins_on(query, nil, nil),
do: participation_order_begins_on_desc(query) do: participation_order_begins_on_desc(query)

View File

@ -46,7 +46,7 @@ defmodule Mobilizon.Service.Export.Feed do
@spec fetch_actor_event_feed(String.t()) :: String.t() @spec fetch_actor_event_feed(String.t()) :: String.t()
defp fetch_actor_event_feed(name) do defp fetch_actor_event_feed(name) do
with %Actor{} = actor <- Actors.get_local_actor_by_name(name), with %Actor{} = actor <- Actors.get_local_actor_by_name(name),
{:visibility, true} <- {:visibility, Actor.is_public_visibility(actor)}, {:visibility, true} <- {:visibility, Actor.is_public_visibility?(actor)},
%Page{elements: events} <- Events.list_public_events_for_actor(actor) do %Page{elements: events} <- Events.list_public_events_for_actor(actor) do
{:ok, build_actor_feed(actor, events)} {:ok, build_actor_feed(actor, events)}
else else

View File

@ -48,7 +48,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
""" """
@spec export_public_actor(Actor.t()) :: String.t() @spec export_public_actor(Actor.t()) :: String.t()
def export_public_actor(%Actor{} = actor) do def export_public_actor(%Actor{} = actor) do
with true <- Actor.is_public_visibility(actor), with {:visibility, true} <- {:visibility, Actor.is_public_visibility?(actor)},
%Page{elements: events} <- %Page{elements: events} <-
Events.list_public_events_for_actor(actor) do Events.list_public_events_for_actor(actor) do
{:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()} {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()}

View File

@ -25,7 +25,7 @@ defmodule Mobilizon.Web.FeedController do
|> put_resp_content_type("text/calendar") |> put_resp_content_type("text/calendar")
|> send_resp(200, data) |> send_resp(200, data)
_ -> _err ->
{:error, :not_found} {:error, :not_found}
end end
end end

View File

@ -5,8 +5,8 @@ defmodule Mobilizon.Web.JsonLD.ObjectView do
alias Mobilizon.Addresses.Address alias Mobilizon.Addresses.Address
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Posts.Post alias Mobilizon.Posts.Post
alias Mobilizon.Web.{Endpoint, MediaProxy}
alias Mobilizon.Web.JsonLD.ObjectView alias Mobilizon.Web.JsonLD.ObjectView
alias Mobilizon.Web.MediaProxy
def render("group.json", %{group: %Actor{} = group}) do def render("group.json", %{group: %Actor{} = group}) do
%{ %{
@ -37,18 +37,16 @@ defmodule Mobilizon.Web.JsonLD.ObjectView do
if(event.status == :cancelled, if(event.status == :cancelled,
do: "https://schema.org/EventCancelled", do: "https://schema.org/EventCancelled",
else: "https://schema.org/EventScheduled" else: "https://schema.org/EventScheduled"
),
"image" =>
if(event.picture,
do: [
event.picture.file.url |> MediaProxy.url()
],
else: ["#{Endpoint.url()}/img/mobilizon_default_card.png"]
) )
} }
json_ld =
if event.picture do
Map.put(json_ld, "image", [
event.picture.file.url |> MediaProxy.url()
])
else
json_ld
end
json_ld = json_ld =
if event.begins_on, if event.begins_on,
do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)), do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)),

View File

@ -53,7 +53,8 @@ defmodule Mobilizon.Factory do
outbox_url: Actor.build_url(preferred_username, :outbox), outbox_url: Actor.build_url(preferred_username, :outbox),
shared_inbox_url: "#{Endpoint.url()}/inbox", shared_inbox_url: "#{Endpoint.url()}/inbox",
last_refreshed_at: DateTime.utc_now(), last_refreshed_at: DateTime.utc_now(),
user: build(:user) user: build(:user),
visibility: :public
} }
end end

View File

@ -44,7 +44,7 @@ defmodule Mobilizon.Web.FeedControllerTest do
test "it returns a 404 for the actor's public events Atom feed if the actor is not publicly visible", test "it returns a 404 for the actor's public events Atom feed if the actor is not publicly visible",
%{conn: conn} do %{conn: conn} do
actor = insert(:actor) actor = insert(:actor, visibility: :private)
tag1 = insert(:tag, title: "RSS", slug: "rss") tag1 = insert(:tag, title: "RSS", slug: "rss")
tag2 = insert(:tag, title: "ATOM", slug: "atom") tag2 = insert(:tag, title: "ATOM", slug: "atom")
insert(:event, organizer_actor: actor, tags: [tag1]) insert(:event, organizer_actor: actor, tags: [tag1])