## :warning: Do not merge :warning:
This PR is **not** intended to be merge…d outside of purely development environments until it's finished. While it has reached a pretty stable state, groups are a complicated topic, and the database model, API and protocol design **may change** before the PR gets merged, with no clear migration path between versions of this PR.
:warning: **Merging this on your instance before this is reviewed will likely result in incompatibilities and migration issues.**
## Basic expectations around groups
Nothing is set in stone yet, but what we are going for is something along those lines:
- groups have a set of members and administrators/moderators
- only group members can post in a group
- posts and replies within a group all have the same audience, set by the group (group members, and potentially publicly-viewable as well): no public/unlisted/followers-only/direct
- posts are viewed in a per-group timeline and not on the home timeline
- new members may or may not need to be approved (group-specific settings)
- moderators can kick/ban group members and delete individual posts
- ideally, users should be able to report group posts not only to server moderators but to group moderators as well, and have a choice in that (e.g. be able to chose not to report to group moderators in case they are wanting to report the group's behavior as a whole to their server's moderators)
## Interface
### Video overview
https://user-images.githubusercontent.com/384364/193271051-436428e9-7229-49fa-b82e-fe5f03e81f65.mp4
### Viewing and posting

### Pending and rejected posts
While a priori moderation isn't planned on Mastodon's side on the short term, interacting with remote groups means you can't synchronously know whether your posts are accepted. They may have a priori moderation, or there might be federation hiccups. For these reasons, your posts may be pending or eventually rejected
Pending | Rejected
:-------------------------:|:-------------------------:
 | 
### Group administration dropdown

### Group members list (with group administration dropdown)

### Server administration interface



## REST API
:warning: The described API is subject to change and the description is documented for discussion.
### New OAuth scopes
- `read:groups`
- `write:groups`
- `admin:read:groups`
- `admin:write:groups`
### New entities
#### `Group`:
- `id`: string, local identifier for the group
- `display_name`: string, name of the group
- `created_at`: string (ISO 8601 Datetime), date at which the group was created
- `note`: string, description of the group
- `uri`: string, global (ActivityPub) identifier for the group
- `url`: string, user-friendly URL for the group
- `avatar`: string, URL to a possibly animated image to be used as icon for the group
- `avatar_static`: string, URL to an image to be used as icon for the group
- `header`: string, URL to a possibly animated image to be used as a cover/header image for the group
- `header_static`: string, URL to an image to be used as a cover/header image for the group
- `domain`: string, domain hosting the group. `null` if the group is hosted locally
- `locked`: boolean, `false` if the group is expected to automatically accept membership requests
- `statuses_visibility`: boolean, `'public'` is the only value so far and means all posts are expected to be public
- `membership_required`: boolean, only `true` so far, meaning that it is expected that only members can post
#### `GroupMembership`:
- `id`: string, internal identifier for the membership
- `account`: `Account` entity, which user this membership is about
- `role`: string, role of the user within the group. Valid values are `admin`, `moderator` and `users`
### Existing entities with new or changed attributes
### `Status`:
- new optional `group` attribute: `Group` in which the status exists. When this attribute is set, `visibility` must be `"group"`
- new value for the `visibility` attribute: `"group"`. When `visibility` is `"group"`, the `Status` has a `group` attribute
- new optional `approval_status` attribute with the following possible values: `"pending"`, `"approved"`, `"rejected"`, `"revoked"`. Its absence is equivalent to `"approved"` and does not require any special handling. It is currently only used for group posts but may be used in other cases in the future (e.g. reply control policies). `"pending"` means it has not been approved yet, while `"rejected"` means it has been rejected in a priori moderation / automatic filtering / access rules, and `"revoked"` means it has been denied after being first being approved for a time
### New endpoints
- `POST /api/v1/groups` (requires `write` or `write:groups` and also requires proper user permissions): create a group with the given attributes (`display_name`, `note`, `avatar` and `header`). Sets the user who made the request as group administrator
- `GET /api/v1/groups` (requires `read` or `read:groups`): returns an array of `Group` entities the current user is a member of
- `GET /api/v1/groups/:id` (requires `read` or `read:groups`): returns the `Group` entity describing a given group
- `POST /api/v1/groups/:id/join` (requires `write` or `write:groups`): joins (or request to join) a given group
- `POST /api/v1/groups/:id/leave` (requires `write` or `write:groups`): leaves a given group
- `GET /api/v1/groups/:id/memberships` (requires `read` or `read:groups`). Has an optional `role` attribute that can be used to filter by role (valid roles are `"admin"`, `"moderator"`, `"user"`).
#### Group administration
All of these require the current account to be an admin of the group:
- `PUT /api/v1/groups/:group_id` (requires `write` or `write:groups`): update group attributes (`display_name`, `note`, `avatar` and `header`)
- `DELETE /api/v1/groups/:group_id` (requires `write` or `write:groups`): irreversibly deletes the group
#### Group moderation
All of these require the current account to be an admin/moderator of the group:
- `GET /api/v1/groups/:group_id/membership_requests` (requires `read` or `read:groups`): returns an array of `Account` entities representing pending requests to join a group
- `POST /api/v1/groups/:group_id/membership_requests/:account_id/authorize` (requires `write` or `write:groups`): accept a pending request to become a group member
- `POST /api/v1/groups/:group_id/membership_requests/:account_id/reject` (requires `write` or `write:groups`): reject a pending request to become a group member
- `DELETE /api/v1/groups/:group_id/statuses/:id` (requires `write` or `write:groups`): delete a group post (actually marks it as `revoked` if it is a local post)
- `POST /api/v1/groups/:group_id/kick?account_ids[]=…` (requires `write` or `write:groups`): kick one or more group members
- `GET /api/v1/groups/:group_id/blocks` (requires `read` or `read:groups`): list accounts blocked from interacting with the group
- `POST /api/v1/groups/:group_id/blocks?account_ids[]=…` (requires `write` or `write:groups`): block one or more users. If they were in the group, they are also kicked of it
- `DELETE /api/v1/groups/:group_id/blocks?account_ids[]=…` (requires `write` or `write:groups`): lift one or more blocks
- `POST /api/v1/groups/:group_id/promote?role=new_role&account_ids[]=…` (require `write` or `write:groups`): promote one or more accounts to role `new_role`. An error is returned if any of those accounts has a higher role than `new_role` already, or if the role is higher than the issuing user's. Valid roles are `admin`, and `moderator` and `user`.
- `POST /api/v1/groups/:group_id/demote?role=new_role&account_ids[]=…` (require `write` or `write:groups`): demote one or more accounts to role `new_role`. Returns an error unless every of the target account has a strictly lower role than the user (you cannot demote someone with the same role as you), or if any target account already has a role lower than `new_role`. Valid roles are `admin`, `moderator` and `user`.
#### Instance moderation
- `GET /api/v1/admin/groups` (requires `admin:read:groups` and permissions to manage users): list groups known to the instance. Mimics the interface of `/api/v1/admin/accounts`
Optional query parameters:
- `origin` (string): `"remote"` to list only remote groups, or `"local"` to list only local groups
- `status` (string): `"active"` to list only groups that have not been suspended, or `"suspended"` to list only suspended groups
- `by_domain` (string): search for groups originating from a specific domain
- `display_name` (string): filter groups on display name
- `order` (string): `"active"` to sort by date of most recent post, `"recent"` to sort by group creation date
- `by_member` (string): search groups in which a specific account (given by account id) is a member
- `GET /api/v1/admin/groups/:group_id`: return basic group information
- `POST /api/v1/admin/groups/:group_id/suspend` (requires `admin:write:groups` and permissions to manage users): suspends a group
- `POST /api/v1/admin/groups/:group_id/unsuspend` (requires `admin:write:groups` and permissions to manage users): lift a suspension
- `DELETE /api/v1/admin/groups/:group_id` (requires `admin:write:groups` and permissions to delete user data): deletes an already-suspended group
### Changes to existing endpoints
- `POST /api/v1/statuses`: accepts the new value `"group"` for the `visibility` parameter, and accepts a new `group_id` parameter. When `visibility` is set to `"group"`, a valid `group_id` must be provided, and when a `group_id` is provided, `visibility` must be set to `"group"`. A reply cannot have a different `group_id` than the post it is in reply to.
## ActivityPub (federation)
### Context and survey of existing work
Other fediverse projects have or are working on similar functionality, though there is no definite standard yet, and there are still many open questions. From what I have gathered, the main implementations seem to be:
- [Smithereen](https://github.com/grishka/Smithereen/blob/master/FEDERATION.md#groups), using [FEP-400e](https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-400e.md) to submit posts to the group's `sm:wall` collection
- Pixelfed, supposedly implementing Smithereen's protocol
- [Streams](https://codeberg.org/streams/streams/src/branch/dev/FEDERATION.md)-related projects ([Friendica](https://wiki.friendi.ca/docs/forums), Hubzilla, …) which seems to aim for maximizing compatibility with every ActivityPub implementation, including those which do not have explicit support for groups
- [Lemmy](https://join-lemmy.org/docs/en/federation/overview.html), which seems to mostly reuse the `Follow` and `Announce` flow
At the same time, other projects such as Mobilizon and Peertube use `Group` actors for different purposes.
### Implementation in this PR
:warning: This is subject to change.
Remote users interacting with local groups and local users interacting with remote groups are supported. For the time being, local groups can only have local moderators, and local users can only moderate local groups.
At this time, Mastodon does not perform JSON-LD compaction and expects implementations to use the shorthand for the described attributes, but produces a valid context properly qualifying them.
- groups actors are of type `PublicGroup`, and are expected to provide the following attributes:
- `wall`: an identified public `OrderedCollection` of all approved group posts
- `members`: a `Collection` or `OrderedCollection` of group members
- `name`: a human-readable name for the group
- `publicKey`: public key used for signing requests
- `attributedTo` (optional): array of non-group actors with group moderation powers
- `summary` (optional): a description of the group
- `icon` (optional): icon for the group, akin to profile avatars
- `image` (optional): header illustration for the group, akin to profile headers
- `manuallyApprovesMembers` (optional): true if the group is expected to not automatically approve new members (similar to `manuallyApprovesFollowers`)
- `suspended` (optional): indicates that the group has been temporarily suspended and should not be interacted with
- joining a group works by sending the group actor a `Join` activity and expecting an `Accept` in return
- leaving a group works by sending the group actor a `Leave` activity
- group posts have only the group members in their explicit audience through the `to` attribute
- posts are sent using [FEP-400e](https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-400e.md) by sending a `Create` activity exclusively to the group's inbox, with the embedded object targeting the group's `wall` collection. Such an activity can be denied in /a priori/ moderation or access control via a `Reject` activity
- posts from remote groups are received through an `Add` activity from the group actor, adding the post to its `wall` collection. The post is fetched and MUST include the `wall` collection as its `target`. If the added post is a local post, it will be marked as approved.
- users `Delete` their group posts, and the group actor distributes a `Remove` activity to reflect it. In addition, if the `Delete` activity is signed, the group actor may forward it
- posts can be removed by group moderators, in which case the group actor will send a `Remove` activity
- a group can `Update` itself or `Delete` itself like `Person` actors
### Examples
Group actor:
```
{
"@context": [
"https://www.w3.org/ns/activitystreams",
"https://w3id.org/security/v1",
{
"toot": "http://joinmastodon.org/ns#",
"discoverable": "toot:discoverable",
"suspended": "toot:suspended",
"sm": "http://smithereen.software/ns#",
"wall": {
"@id": "sm:wall",
"@type": "@id"
},
"members": {
"@id": "sm:members",
"@type": "@id"
},
"PublicGroup": "toot:PublicGroup",
"manuallyApprovesMembers": "toot:manuallyApprovesMembers"
}
],
"id": "https://mastodon.social/groups/1",
"type": "PublicGroup",
"inbox": "https://mastodon.social/groups/1/inbox",
"outbox": "https://mastodon.social/groups/1/outbox",
"name": "Mastodon development",
"url": "https://mastodon.social/groups/1",
"published": "2022-09-19T00:00:00Z",
"wall": "https://mastodon.social/groups/1/wall",
"members": "https://mastodon.social/groups/1/members",
"summary": "<p>A group to discuss Mastodon development and make screenshots of the work-in-progress group feature.</p>",
"manuallyApprovesMembers": true,
"attributedTo": [
"https://mastodon.social/users/Gargron",
],
"publicKey": {
"id": "https://mastodon.social/groups/1#main-key",
"owner": "https://mastodon.social/groups/1",
"publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwwIG7x+jVanVg+C1Cb8w\nfYMrAWaK6fIu8amptKI4pChe7YmRWOzhulgTEBjSYSuwvO70kiEKkUzxUARlPRxC\nOehlw2+gsv2PlGfGu3VViq9+jZO41El3s8qOFYnZ+yfHX/SZlHGsujQLnoLP3rPZ\nc5GkaDJ3SHucTzUkFuf+znA7lESFMmK4oM7JtoSFrGlD0u8GObKKXjhenPxt8kxN\n9zKpwA/u4mi91vsEjIctoBbYo90tY5vxKJlTYQq7Btz6Xe6uKibWRXhMES9Col/7\nzG+PQ/RvdiKL7Q5T8R4H9t4bk+RmNFqFPQhmKkq1NX3kw7/uZPay+z4qCBGYud44\nRwIDAQAB\n-----END PUBLIC KEY-----\n"
},
"tag": [],
"endpoints": {
"sharedInbox": "https://mastodon.social/inbox"
},
"icon": {
"type": "Image",
"mediaType": "image/png",
"url": "https://mastodon.social/system/groups/avatars/109/027/262/422/883/263/original/f6f5deced1bec83a.png"
},
"image": {
"type": "Image",
"mediaType": "image/png",
"url": "https://mastodon.social/system/groups/headers/109/027/262/422/883/263/original/e5eb3fa2014b9320.png"
}
}
```
Join activity:
```
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://mastodon.dev/c07d86b4-55bd-413c-a45b-71778cdeca65",
"type": "Join",
"actor": "https://mastodon.dev/users/claire",
"object": "https://mastodon.social/groups/1"
}
```
Create activity:
```
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"toot": "http://joinmastodon.org/ns#",
}
],
"id": "https://mastodon.dev/users/claire/statuses/109086682464796744/activity",
"type": "Create",
"actor": "https://mastodon.dev/users/claire",
"published": "2022-09-30T09:37:57Z",
"to": [
"https://mastodon.social/groups/1/members"
],
"cc": [],
"object": {
"id": "https://mastodon.dev/users/claire/statuses/109086682464796744",
"type": "Note",
"published": "2022-09-30T09:37:57Z",
"url": "https://mastodon.dev/@claire/109086682464796744",
"attributedTo": "https://mastodon.dev/users/claire",
"to": [
"https://mastodon.social/groups/1/members"
],
"cc": [],
"content": "<p>hello</p>",
"target": {
"type": "OrderedCollection",
"id": "https://mastodon.social/groups/1/wall",
"attributedTo": "https://mastodon.social/groups/1"
}
}
}
```
Add activity:
```
{
"@context": "https://www.w3.org/ns/activitystreams",
"type": "Add",
"actor": "https://mastodon.social/groups/1",
"object": "https://mastodon.dev/users/claire/statuses/109086682464796744",
"target": "https://mastodon.social/groups/1/wall"
}
```
## TODO
This is a rough outline of the planned development, but as stated above, “finished” steps will likely be revisited as UX or protocol considerations emerge when working on other steps.
- [x] models and database schema for local groups, members, group posts
- [x] API for posting in groups and seeing group posts
- [x] user interface for posting in groups and seeing group posts
- [x] API for basic group moderation (kicking/banning someone from a group, deleting individual group posts, approving or rejecting group members)
- [x] basic user interface for the aforementioned moderation actions
- [x] API for seeing group information, group members and moderators, joining and leaving groups
- [x] user interface for seeing group information, joining and leaving groups
- [x] API for creating groups
- [ ] user interface for creating groups (partially done, it exists but does not offer setting description, avatar or header image)
- [x] API for deleting groups
- [x] user interface for deleting groups
- [x] ActivityPub support for joining, leaving, and posting within a group **(done up to possible upcoming protocol changes)**
- [x] ActivityPub support for cross-instance moderation (group moderators on a different server than the group actor) **(done up to possible upcoming protocol changes, also needs more testing)**
- [ ] models and database schema for reports within groups
- [ ] REST API changes for reporting posts to group moderators
- [ ] user interface changes for reporting posts to group moderators
- [ ] ActivityPub support for reporting posts to group moderators
___
This project was funded through the [NGI0 Discovery](https://nlnet.nl/discovery) Fund, a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 825322.