From b0e8a32d2aab9c816dc3750391e57b9f0d8d7d9a Mon Sep 17 00:00:00 2001
From: Thomas Citharel <tcit@tcit.fr>
Date: Wed, 2 Sep 2020 17:42:17 +0200
Subject: [PATCH] Improvements to group page

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
---
 js/src/components/Account/ActorCard.vue       |  2 +-
 js/src/components/Editor.vue                  | 39 ++++++++----
 js/src/components/Event/EventListViewCard.vue |  6 +-
 .../components/Event/EventMinimalistCard.vue  | 41 ++++++++++++-
 js/src/graphql/group.ts                       | 36 ++++++++++-
 js/src/views/Event/GroupEvents.vue            | 33 +++++++---
 js/src/views/Group/Group.vue                  | 49 +++++++++------
 js/src/views/Group/GroupSettings.vue          | 16 ++---
 lib/federation/activity_pub/types/actors.ex   | 29 ++++++---
 lib/graphql/resolvers/group.ex                | 37 ++++++++++--
 lib/graphql/schema/actors/group.ex            |  4 ++
 lib/mobilizon/actors/actor.ex                 |  4 +-
 lib/mobilizon/events/events.ex                | 60 ++++++++++++++++++-
 lib/service/export/feed.ex                    |  2 +-
 lib/service/export/icalendar.ex               |  2 +-
 lib/web/controllers/feed_controller.ex        |  2 +-
 lib/web/views/json_ld/object_view.ex          | 18 +++---
 test/support/factory.ex                       |  3 +-
 test/web/controllers/feed_controller_test.exs |  2 +-
 19 files changed, 298 insertions(+), 87 deletions(-)

diff --git a/js/src/components/Account/ActorCard.vue b/js/src/components/Account/ActorCard.vue
index b5fbe5ee9..4f910e534 100644
--- a/js/src/components/Account/ActorCard.vue
+++ b/js/src/components/Account/ActorCard.vue
@@ -13,7 +13,7 @@
           {{ 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>
diff --git a/js/src/components/Editor.vue b/js/src/components/Editor.vue
index d6a077998..68982a291 100644
--- a/js/src/components/Editor.vue
+++ b/js/src/components/Editor.vue
@@ -40,6 +40,7 @@
           </button>
 
           <button
+            v-if="!isBasicMode"
             class="menubar__button"
             :class="{ 'is-active': isActive.heading({ level: 1 }) }"
             @click="commands.heading({ level: 1 })"
@@ -49,6 +50,7 @@
           </button>
 
           <button
+            v-if="!isBasicMode"
             class="menubar__button"
             :class="{ 'is-active': isActive.heading({ level: 2 }) }"
             @click="commands.heading({ level: 2 })"
@@ -58,6 +60,7 @@
           </button>
 
           <button
+            v-if="!isBasicMode"
             class="menubar__button"
             :class="{ 'is-active': isActive.heading({ level: 3 }) }"
             @click="commands.heading({ level: 3 })"
@@ -75,12 +78,18 @@
             <b-icon icon="link" />
           </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" />
           </button>
 
           <button
             class="menubar__button"
+            v-if="!isBasicMode"
             :class="{ 'is-active': isActive.bullet_list() }"
             @click="commands.bullet_list"
             type="button"
@@ -89,6 +98,7 @@
           </button>
 
           <button
+            v-if="!isBasicMode"
             class="menubar__button"
             :class="{ 'is-active': isActive.ordered_list() }"
             @click="commands.ordered_list"
@@ -98,6 +108,7 @@
           </button>
 
           <button
+            v-if="!isBasicMode"
             class="menubar__button"
             :class="{ 'is-active': isActive.blockquote() }"
             @click="commands.blockquote"
@@ -106,11 +117,11 @@
             <b-icon icon="format-quote-close" />
           </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" />
           </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" />
           </button>
         </div>
@@ -229,26 +240,30 @@ export default class EditorComponent extends Vue {
 
   filteredActors: IActor[] = [];
 
-  suggestionRange!: object | null;
+  suggestionRange!: Record<string, unknown> | null;
 
   navigatedActorIndex = 0;
 
   popup!: Instance[] | null;
 
-  get isDescriptionMode() {
-    return this.mode === "description";
+  get isDescriptionMode(): boolean {
+    return this.mode === "description" || this.isBasicMode;
   }
 
-  get isCommentMode() {
+  get isCommentMode(): boolean {
     return this.mode === "comment";
   }
 
-  get hasResults() {
-    return this.filteredActors.length;
+  get hasResults(): boolean {
+    return this.filteredActors.length > 0;
   }
 
-  get showSuggestions() {
-    return this.query || this.hasResults;
+  get showSuggestions(): boolean {
+    return (this.query || this.hasResults) as boolean;
+  }
+
+  get isBasicMode(): boolean {
+    return this.mode === "basic";
   }
 
   // eslint-disable-next-line
@@ -258,7 +273,7 @@ export default class EditorComponent extends Vue {
 
   observer!: MutationObserver | null;
 
-  mounted() {
+  mounted(): void {
     this.editor = new Editor({
       extensions: [
         new Blockquote(),
diff --git a/js/src/components/Event/EventListViewCard.vue b/js/src/components/Event/EventListViewCard.vue
index 8e68e2f20..aed279d58 100644
--- a/js/src/components/Event/EventListViewCard.vue
+++ b/js/src/components/Event/EventListViewCard.vue
@@ -16,7 +16,7 @@
           </span>
           <span>
             <span>
-              {{ $t("Organized by {name}", { name: event.organizerActor.displayName() }) }}
+              {{ $t("Organized by {name}", { name: usernameWithDomain(event.organizerActor) }) }}
             </span>
           </span>
         </div>
@@ -53,7 +53,7 @@
 import { ParticipantRole, EventVisibility, IEventCardOptions, IEvent } from "@/types/event.model";
 import { Component, Prop } from "vue-property-decorator";
 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 ActorMixin from "@/mixins/actor";
 import { CURRENT_ACTOR_CLIENT } from "@/graphql/actor";
@@ -96,6 +96,8 @@ export default class EventListViewCard extends mixins(ActorMixin, EventMixin) {
   EventVisibility = EventVisibility;
 
   RouteName = RouteName;
+
+  usernameWithDomain = usernameWithDomain;
 }
 </script>
 
diff --git a/js/src/components/Event/EventMinimalistCard.vue b/js/src/components/Event/EventMinimalistCard.vue
index ae3a312b2..7ede0d852 100644
--- a/js/src/components/Event/EventMinimalistCard.vue
+++ b/js/src/components/Event/EventMinimalistCard.vue
@@ -9,7 +9,46 @@
       <p v-if="event.physicalAddress" class="has-text-grey">
         {{ event.physicalAddress.description }}
       </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>
   </router-link>
 </template>
diff --git a/js/src/graphql/group.ts b/js/src/graphql/group.ts
index 1dddfe9dd..a87f30127 100644
--- a/js/src/graphql/group.ts
+++ b/js/src/graphql/group.ts
@@ -78,12 +78,30 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
     banner {
       url
     }
-    organizedEvents {
+    organizedEvents(
+      afterDatetime: $afterDateTime
+      beforeDatetime: $beforeDateTime
+      page: $organisedEventsPage
+      limit: $organisedEventslimit
+    ) {
       elements {
         id
         uuid
         title
         beginsOn
+        options {
+          maximumAttendeeCapacity
+        }
+        participantStats {
+          participant
+          notApproved
+        }
+        organizerActor {
+          id
+          preferredUsername
+          name
+          domain
+        }
       }
       total
     }
@@ -154,7 +172,13 @@ export const GROUP_FIELDS_FRAGMENTS = gql`
 `;
 
 export const FETCH_GROUP = gql`
-  query($name: String!) {
+  query(
+    $name: String!
+    $afterDateTime: DateTime
+    $beforeDateTime: DateTime
+    $organisedEventsPage: Int
+    $organisedEventslimit: Int
+  ) {
     group(preferredUsername: $name) {
       ...GroupFullFields
     }
@@ -166,7 +190,13 @@ export const FETCH_GROUP = gql`
 `;
 
 export const GET_GROUP = gql`
-  query($id: ID!) {
+  query(
+    $id: ID!
+    $afterDateTime: DateTime
+    $beforeDateTime: DateTime
+    $organisedEventsPage: Int
+    $organisedEventslimit: Int
+  ) {
     getGroup(id: $id) {
       ...GroupFullFields
     }
diff --git a/js/src/views/Event/GroupEvents.vue b/js/src/views/Event/GroupEvents.vue
index 0d63a32a7..e7edd6e23 100644
--- a/js/src/views/Event/GroupEvents.vue
+++ b/js/src/views/Event/GroupEvents.vue
@@ -34,20 +34,25 @@
         }}
       </p>
       <b-loading :active.sync="$apollo.loading"></b-loading>
-      <section v-if="group && group.organizedEvents.total > 0">
+      <section v-if="group">
         <subtitle>
-          {{ $t("Past events") }}
+          {{ showPassedEvents ? $t("Past events") : $t("Upcoming events") }}
         </subtitle>
+        <b-switch v-model="showPassedEvents">{{ $t("Past events") }}</b-switch>
         <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>
+        <b-message
+          v-if="group.organizedEvents.elements.length === 0 && $apollo.loading === false"
+          type="is-danger"
+        >
+          {{ $t("No events found") }}
+        </b-message>
       </section>
-      <b-message
-        v-if="group.organizedEvents.elements.length === 0 && $apollo.loading === false"
-        type="is-danger"
-      >
-        {{ $t("No events found") }}
-      </b-message>
     </section>
   </div>
 </template>
@@ -55,6 +60,8 @@
 import { Component, Vue } from "vue-property-decorator";
 import { FETCH_GROUP } from "@/graphql/group";
 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";
 
 @Component({
@@ -64,10 +71,16 @@ import { IGroup, usernameWithDomain } from "../../types/actor";
       variables() {
         return {
           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 {
   group!: IGroup;
@@ -75,5 +88,7 @@ export default class GroupEvents extends Vue {
   usernameWithDomain = usernameWithDomain;
 
   RouteName = RouteName;
+
+  showPassedEvents = false;
 }
 </script>
diff --git a/js/src/views/Group/Group.vue b/js/src/views/Group/Group.vue
index db93fa014..70e3a739b 100644
--- a/js/src/views/Group/Group.vue
+++ b/js/src/views/Group/Group.vue
@@ -302,6 +302,10 @@
       {{ $t("No group found") }}
     </b-message>
     <div v-else class="public-container">
+      <section>
+        <subtitle>{{ $t("About") }}</subtitle>
+        <div v-html="group.summary" />
+      </section>
       <section>
         <subtitle>{{ $t("Upcoming events") }}</subtitle>
         <div class="organized-events-wrapper" v-if="group && group.organizedEvents.total > 0">
@@ -318,16 +322,12 @@
       </section>
       <section>
         <subtitle>{{ $t("Latest posts") }}</subtitle>
-        <div v-if="group && group.posts.total > 0">
-          <router-link
-            v-for="post in group.posts.elements"
-            :key="post.id"
-            :to="{ name: RouteName.POST, params: { slug: post.slug } }"
-          >
-            {{ post.title }}
-          </router-link>
+        <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>
+        <div v-else-if="group" class="content has-text-grey has-text-centered">
+          <p>{{ $t("No posts yet") }}</p>
         </div>
-        <span v-else-if="group">{{ $t("No public posts") }}</span>
         <b-skeleton animated v-else></b-skeleton>
       </section>
       <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 Invitations from "@/components/Group/Invitations.vue";
 import addMinutes from "date-fns/addMinutes";
+import { Route } from "vue-router";
 import GroupSection from "../../components/Group/GroupSection.vue";
 import RouteName from "../../router/name";
 
@@ -413,11 +414,13 @@ import RouteName from "../../router/name";
   metaInfo() {
     return {
       // if no subcomponents specify a metaInfo.title, this title will be used
+      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
       // @ts-ignore
       title: this.groupTitle,
       // all titles will be injected into this template
       titleTemplate: "%s | Mobilizon",
       meta: [
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
         // @ts-ignore
         { name: "description", content: this.groupSummary },
       ],
@@ -442,14 +445,14 @@ export default class Group extends Vue {
   showMap = false;
 
   @Watch("currentActor")
-  watchCurrentActor(currentActor: IActor, oldActor: IActor) {
+  watchCurrentActor(currentActor: IActor, oldActor: IActor): void {
     if (currentActor.id && oldActor && currentActor.id !== oldActor.id) {
       this.$apollo.queries.group.refetch();
     }
   }
 
-  async leaveGroup() {
-    const { data } = await this.$apollo.mutate({
+  async leaveGroup(): Promise<Route> {
+    await this.$apollo.mutate({
       mutation: LEAVE_GROUP,
       variables: {
         groupId: this.group.id,
@@ -458,9 +461,10 @@ export default class Group extends Vue {
     return this.$router.push({ name: RouteName.MY_GROUPS });
   }
 
-  acceptInvitation() {
+  acceptInvitation(): void {
     if (this.groupMember) {
       const index = this.person.memberships.elements.findIndex(
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
         // @ts-ignore
         ({ 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;
     return this.group.preferredUsername;
   }
 
-  get groupSummary() {
+  get groupSummary(): undefined | string {
     if (!this.group) return undefined;
     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);
   }
 
-  get groupMemberships() {
-    if (!this.person || !this.person.id) return undefined;
+  get groupMemberships(): (string | undefined)[] {
+    if (!this.person || !this.person.id) return [];
     return this.person.memberships.elements
       .filter(
         (membership: IMember) =>
@@ -499,7 +503,7 @@ export default class Group extends Vue {
   }
 
   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 {
@@ -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 {
     return (
@@ -673,5 +678,11 @@ div.container {
       }
     }
   }
+
+  .public-container {
+    section {
+      margin-top: 2rem;
+    }
+  }
 }
 </style>
diff --git a/js/src/views/Group/GroupSettings.vue b/js/src/views/Group/GroupSettings.vue
index 084557e43..0ad4ea423 100644
--- a/js/src/views/Group/GroupSettings.vue
+++ b/js/src/views/Group/GroupSettings.vue
@@ -37,7 +37,7 @@
           <b-input v-model="group.name" />
         </b-field>
         <b-field :label="$t('Group short description')">
-          <b-input type="textarea" v-model="group.summary"
+          <editor mode="basic" v-model="group.summary"
         /></b-field>
         <p class="label">{{ $t("Group visibility") }}</p>
         <div class="field">
@@ -105,12 +105,12 @@
 <script lang="ts">
 import { Component, Vue } from "vue-property-decorator";
 import FullAddressAutoComplete from "@/components/Event/FullAddressAutoComplete.vue";
+import { Route } from "vue-router";
 import RouteName from "../../router/name";
 import { FETCH_GROUP, UPDATE_GROUP, DELETE_GROUP } from "../../graphql/group";
 import { IGroup, usernameWithDomain } from "../../types/actor";
 import { Address, IAddress } from "../../types/address.model";
-import { IMember, Group } from "../../types/actor/group.model";
-import { Paginate } from "../../types/paginate";
+import { Group } from "../../types/actor/group.model";
 
 @Component({
   apollo: {
@@ -129,6 +129,7 @@ import { Paginate } from "../../types/paginate";
   },
   components: {
     FullAddressAutoComplete,
+    editor: () => import("../../components/Editor.vue"),
   },
 })
 export default class GroupSettings extends Vue {
@@ -149,7 +150,7 @@ export default class GroupSettings extends Vue {
 
   showCopiedTooltip = false;
 
-  async updateGroup() {
+  async updateGroup(): Promise<void> {
     const variables = { ...this.group };
     // eslint-disable-next-line
     // @ts-ignore
@@ -165,7 +166,7 @@ export default class GroupSettings extends Vue {
     });
   }
 
-  confirmDeleteGroup() {
+  confirmDeleteGroup(): void {
     this.$buefy.dialog.confirm({
       title: this.$t("Delete group") as string,
       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 }>({
       mutation: DELETE_GROUP,
       variables: {
@@ -189,7 +190,7 @@ export default class GroupSettings extends Vue {
     return this.$router.push({ name: RouteName.MY_GROUPS });
   }
 
-  async copyURL() {
+  async copyURL(): Promise<void> {
     await window.navigator.clipboard.writeText(this.group.url);
     this.showCopiedTooltip = true;
     setTimeout(() => {
@@ -197,6 +198,7 @@ export default class GroupSettings extends Vue {
     }, 2000);
   }
 
+  // eslint-disable-next-line class-methods-use-this
   get canShowCopyButton(): boolean {
     return window.isSecureContext;
   }
diff --git a/lib/federation/activity_pub/types/actors.ex b/lib/federation/activity_pub/types/actors.ex
index 824bb3b3d..77dfc8871 100644
--- a/lib/federation/activity_pub/types/actors.ex
+++ b/lib/federation/activity_pub/types/actors.ex
@@ -89,12 +89,27 @@ defmodule Mobilizon.Federation.ActivityPub.Types.Actors do
   def group_actor(%Actor{} = actor), do: actor
 
   defp prepare_args_for_actor(args) do
-    with preferred_username <-
-           args |> Map.get(:preferred_username) |> HTML.strip_tags() |> String.trim(),
-         summary <- args |> Map.get(:summary, "") |> String.trim(),
-         {summary, _mentions, _tags} <-
-           summary |> String.trim() |> APIUtils.make_content_html([], "text/html") do
-      %{args | preferred_username: preferred_username, summary: summary}
-    end
+    args
+    |> maybe_sanitize_username()
+    |> maybe_sanitize_summary()
   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
diff --git a/lib/graphql/resolvers/group.ex b/lib/graphql/resolvers/group.ex
index 6bbf276f6..3b33b404a 100644
--- a/lib/graphql/resolvers/group.ex
+++ b/lib/graphql/resolvers/group.ex
@@ -9,7 +9,6 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
   alias Mobilizon.Federation.ActivityPub
   alias Mobilizon.GraphQL.API
   alias Mobilizon.GraphQL.Resolvers.Person
-  alias Mobilizon.Storage.Page
   alias Mobilizon.Users.User
 
   require Logger
@@ -271,7 +270,10 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
 
   def find_events_for_group(
         %Actor{id: group_id} = group,
-        _args,
+        %{
+          page: page,
+          limit: limit
+        } = args,
         %{
           context: %{
             current_user: %User{role: user_role} = user
@@ -282,15 +284,38 @@ defmodule Mobilizon.GraphQL.Resolvers.Group do
          {:member, true} <-
            {:member, Actors.is_member?(actor_id, group_id) or is_moderator(user_role)} do
       # 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
       {:member, false} ->
-        {:ok, %Page{total: 0, elements: []}}
+        find_events_for_group(group, args, nil)
     end
   end
 
-  def find_events_for_group(_parent, _args, _resolution) do
-    {:ok, %Page{total: 0, elements: []}}
+  def find_events_for_group(
+        %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
 
   defp restrict_fields_for_non_member_request(%Actor{} = group) do
diff --git a/lib/graphql/schema/actors/group.ex b/lib/graphql/schema/actors/group.ex
index 3a357d46c..395a25e48 100644
--- a/lib/graphql/schema/actors/group.ex
+++ b/lib/graphql/schema/actors/group.ex
@@ -54,6 +54,10 @@ defmodule Mobilizon.GraphQL.Schema.Actors.GroupType do
 
     # This one should have a privacy setting
     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)
       description("A list of the events this actor has organized")
     end
diff --git a/lib/mobilizon/actors/actor.ex b/lib/mobilizon/actors/actor.ex
index d59154fa6..9d49973f8 100644
--- a/lib/mobilizon/actors/actor.ex
+++ b/lib/mobilizon/actors/actor.ex
@@ -179,8 +179,8 @@ defmodule Mobilizon.Actors.Actor do
   @doc """
   Checks whether actor visibility is public.
   """
-  @spec is_public_visibility(t) :: boolean
-  def is_public_visibility(%__MODULE__{visibility: visibility}) do
+  @spec is_public_visibility?(t) :: boolean
+  def is_public_visibility?(%__MODULE__{visibility: visibility}) do
     visibility in [:public, :unlisted]
   end
 
diff --git a/lib/mobilizon/events/events.ex b/lib/mobilizon/events/events.ex
index 7fd6e996c..a492de2d5 100644
--- a/lib/mobilizon/events/events.ex
+++ b/lib/mobilizon/events/events.ex
@@ -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{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
     actor_id
@@ -424,10 +424,25 @@ defmodule Mobilizon.Events do
     |> Page.build_page(page, limit)
   end
 
-  @spec list_organized_events_for_group(Actor.t(), integer | nil, integer | nil) :: Page.t()
-  def list_organized_events_for_group(%Actor{id: group_id}, page \\ nil, limit \\ nil) do
+  @spec list_organized_events_for_group(
+          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
     |> event_for_group_query()
+    |> event_filter_visibility(visibility)
+    |> event_filter_begins_on(after_datetime, before_datetime)
     |> preload_for_event()
     |> Page.build_page(page, limit)
   end
@@ -1643,6 +1658,45 @@ defmodule Mobilizon.Events do
     from(p in query, where: p.role == ^role)
   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),
     do: participation_order_begins_on_desc(query)
 
diff --git a/lib/service/export/feed.ex b/lib/service/export/feed.ex
index ec077d141..92f3a9d3a 100644
--- a/lib/service/export/feed.ex
+++ b/lib/service/export/feed.ex
@@ -46,7 +46,7 @@ defmodule Mobilizon.Service.Export.Feed do
   @spec fetch_actor_event_feed(String.t()) :: String.t()
   defp fetch_actor_event_feed(name) do
     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
       {:ok, build_actor_feed(actor, events)}
     else
diff --git a/lib/service/export/icalendar.ex b/lib/service/export/icalendar.ex
index f4b0d047a..eb6e02f24 100644
--- a/lib/service/export/icalendar.ex
+++ b/lib/service/export/icalendar.ex
@@ -48,7 +48,7 @@ defmodule Mobilizon.Service.Export.ICalendar do
   """
   @spec export_public_actor(Actor.t()) :: String.t()
   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} <-
            Events.list_public_events_for_actor(actor) do
       {:ok, %ICalendar{events: events |> Enum.map(&do_export_event/1)} |> ICalendar.to_ics()}
diff --git a/lib/web/controllers/feed_controller.ex b/lib/web/controllers/feed_controller.ex
index 129563098..56a522da6 100644
--- a/lib/web/controllers/feed_controller.ex
+++ b/lib/web/controllers/feed_controller.ex
@@ -25,7 +25,7 @@ defmodule Mobilizon.Web.FeedController do
         |> put_resp_content_type("text/calendar")
         |> send_resp(200, data)
 
-      _ ->
+      _err ->
         {:error, :not_found}
     end
   end
diff --git a/lib/web/views/json_ld/object_view.ex b/lib/web/views/json_ld/object_view.ex
index 7db4a393f..5c241e609 100644
--- a/lib/web/views/json_ld/object_view.ex
+++ b/lib/web/views/json_ld/object_view.ex
@@ -5,8 +5,8 @@ defmodule Mobilizon.Web.JsonLD.ObjectView do
   alias Mobilizon.Addresses.Address
   alias Mobilizon.Events.Event
   alias Mobilizon.Posts.Post
+  alias Mobilizon.Web.{Endpoint, MediaProxy}
   alias Mobilizon.Web.JsonLD.ObjectView
-  alias Mobilizon.Web.MediaProxy
 
   def render("group.json", %{group: %Actor{} = group}) do
     %{
@@ -37,18 +37,16 @@ defmodule Mobilizon.Web.JsonLD.ObjectView do
         if(event.status == :cancelled,
           do: "https://schema.org/EventCancelled",
           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 =
       if event.begins_on,
         do: Map.put(json_ld, "startDate", DateTime.to_iso8601(event.begins_on)),
diff --git a/test/support/factory.ex b/test/support/factory.ex
index a53112992..0f8d555f8 100644
--- a/test/support/factory.ex
+++ b/test/support/factory.ex
@@ -53,7 +53,8 @@ defmodule Mobilizon.Factory do
       outbox_url: Actor.build_url(preferred_username, :outbox),
       shared_inbox_url: "#{Endpoint.url()}/inbox",
       last_refreshed_at: DateTime.utc_now(),
-      user: build(:user)
+      user: build(:user),
+      visibility: :public
     }
   end
 
diff --git a/test/web/controllers/feed_controller_test.exs b/test/web/controllers/feed_controller_test.exs
index e6343a2ea..7d141b263 100644
--- a/test/web/controllers/feed_controller_test.exs
+++ b/test/web/controllers/feed_controller_test.exs
@@ -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",
          %{conn: conn} do
-      actor = insert(:actor)
+      actor = insert(:actor, visibility: :private)
       tag1 = insert(:tag, title: "RSS", slug: "rss")
       tag2 = insert(:tag, title: "ATOM", slug: "atom")
       insert(:event, organizer_actor: actor, tags: [tag1])