Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,11 +343,11 @@ Sources: [Remotes setting](resources/js/components/remotes-setting.tsx), [contac

An **Org** is a private group chat within Remotes, not a synchronized GitHub organization. Create one with other eligible developers; a group can contain up to **100 people including you**. Members need Remotes enabled, must follow the person adding them (unless that person is an admin), and must have no block with anyone in the org. A group's optional name can be up to **60 characters**.

The creator starts as a **Maintainer**, and invited people start as **Contributors**. Maintainers can rename the group, add or remove members, and change roles. Contributors can read and send messages. When adding members, a maintainer can share existing history or only new messages. Members can leave or stash the group for themselves. At least one maintainer remains while the group has members: the last maintainer cannot be demoted, and if they leave, the longest-standing remaining member takes over. An org that everyone has left is deleted.
The creator starts as a **Maintainer**, and invited people start as **Contributors**. Maintainers can rename the group, add or remove members, change roles, and pin up to **25 messages**. Contributors can read and send messages. When adding members, a maintainer can share existing history or only new messages. Members can leave or stash the group for themselves. At least one maintainer remains while the group has members: the last maintainer cannot be demoted, and if they leave, the longest-standing remaining member takes over. An org that everyone has left is deleted.

Group messages support the same text, images, voice notes, replies, link cards, reactions, and typing indicators as direct messages. A member's read position shows only when both you and that member have pull receipts on. Any new message pops the org back for members who stashed it. Membership and history access determine what a person can read.
Group messages support the same text, images, voice notes, replies, link cards, reactions, and typing indicators as direct messages. A member's read position shows only when both you and that member have pull receipts on. Any new message pops the org back for members who stashed it. Membership and history access determine what a person can read, except pinned messages: on the web, every member sees those in the pinned bar at the top of the chat, including people added without the history, and can reply and react to them.

Sources: [group creation](app/Http/Requests/CreateOrgRequest.php), [invitation eligibility](app/Queries/RemoteCandidatesQuery.php), [history sharing](app/Actions/CreateOrgMembers.php), [role changes](app/Http/Requests/UpdateOrgMemberRequest.php), [leaving a group](app/Actions/DeleteOrgMember.php).
Sources: [group creation](app/Http/Requests/CreateOrgRequest.php), [invitation eligibility](app/Queries/RemoteCandidatesQuery.php), [history sharing](app/Actions/CreateOrgMembers.php), [pinning](app/Http/Requests/UpdateMessagePinRequest.php), [role changes](app/Http/Requests/UpdateOrgMemberRequest.php), [leaving a group](app/Actions/DeleteOrgMember.php).

### Muting and blocking

Expand Down Expand Up @@ -507,7 +507,7 @@ Posts awaiting automated checks stay pending if those checks fail; a failed chec

Before you first commit, reply, quote, or schedule a commit, the web composer shows the commit rules, and keeps showing them on every attempt until you send with "Got it, commit": no engagement bait, no low-effort posts, no AI-generated posts, no spam or self-promotion, and no harassment, hate, or private information, followed by a link to the terms. Your first submission must come from the web so you can accept these rules; terminal, GitHub Action, and app posting become available afterwards.

During a **timeout**, you can still read and react, but cannot commit, reply, quote, schedule, send remotes, or start, rename, or add people to orgs from any surface; each attempt says how long is left and why. Scheduled commits wait until the timeout ends. Like bans, timeouts belong to the GitHub account: deleting the /dev/null account and signing in again does not end a timeout or erase its history.
During a **timeout**, you can still read and react, but cannot commit, reply, quote, schedule, send remotes, or start, rename, add people to, or pin messages in orgs from any surface; each attempt says how long is left and why. Scheduled commits wait until the timeout ends. Like bans, timeouts belong to the GitHub account: deleting the /dev/null account and signing in again does not end a timeout or erase its history.

A **ban** sends you to a page explaining why, where you can only sign out or delete your account; the app refuses everything but deleting the account, and the terminal refuses posting. A ban belongs to the GitHub account, so deleting the /dev/null account and signing in again does not lift it. Banning someone cancels their scheduled commits, so lifting the ban later does not publish them all at once.

Expand Down
34 changes: 34 additions & 0 deletions app/Actions/DeleteMessagePin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Support\Facades\DB;

final readonly class DeleteMessagePin
{
public function __construct(private UpdateMessageReactionsVersion $updateMessageReactionsVersion)
{
//
}

/**
* The conversation is locked before the message is updated, as reactions lock it, so an unpin and a reaction on
* the same message can't deadlock.
*/
public function handle(Message $message): void
{
DB::transaction(function () use ($message): void {
Conversation::query()->whereKey($message->conversation_id)->lockForUpdate()->first();

$unpinned = Message::query()->whereKey($message->id)->whereNotNull('pinned_at')->toBase()->update(['pinned_at' => null]);

if ($unpinned > 0) {
$this->updateMessageReactionsVersion->handle($message);
}
});
}
}
35 changes: 35 additions & 0 deletions app/Actions/UpdateMessagePin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Support\Facades\DB;

final readonly class UpdateMessagePin
{
public function __construct(private UpdateMessageReactionsVersion $updateMessageReactionsVersion)
{
//
}

/**
* Pins the message for everyone in its org, keeping when it was first pinned however often it is pinned again. The
* conversation is locked before the message is updated, as reactions lock it, so a pin and a reaction on the same
* message can't deadlock.
*/
public function handle(Message $message): void
{
DB::transaction(function () use ($message): void {
Conversation::query()->whereKey($message->conversation_id)->lockForUpdate()->first();

$pinned = Message::query()->whereKey($message->id)->whereNull('pinned_at')->toBase()->update(['pinned_at' => now()]);

if ($pinned > 0) {
$this->updateMessageReactionsVersion->handle($message);
}
});
}
}
5 changes: 3 additions & 2 deletions app/Actions/UpdateMessageReactionsVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@
{
/**
* Polls ask for the reactions changed since a version, so each change takes the conversation's next version and
* marks its message with it. Bumping the conversation locks its row until the change commits, keeping versions in
* the order they become visible.
* marks its message with it. Pinning and unpinning move the version too, so polls hand an org's pinned messages
* over again. Bumping the conversation locks its row until the change commits, keeping versions in the order they
* become visible.
*/
public function handle(Message $message): void
{
Expand Down
38 changes: 38 additions & 0 deletions app/Http/Controllers/MessagePinController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\DeleteMessagePin;
use App\Actions\UpdateMessagePin;
use App\Http\Requests\DeleteMessagePinRequest;
use App\Http\Requests\UpdateMessagePinRequest;
use App\Models\Message;
use App\Queries\PinnedMessagesQuery;
use Illuminate\Http\JsonResponse;

/**
* Pinning and unpinning answer with the org's pinned messages and its reactions version. The version is read before
* the messages, as the thread reads it, so a change landing in between is handed over again on the next poll.
*/
final readonly class MessagePinController
{
public function update(UpdateMessagePinRequest $request, Message $message, UpdateMessagePin $action, PinnedMessagesQuery $pinnedMessages): JsonResponse
{
$action->handle($message);

$org = $message->conversation()->firstOrFail();

return response()->json(['pinned' => $pinnedMessages->get($org), 'reactions_version' => $org->reactions_version]);
}

public function destroy(DeleteMessagePinRequest $request, Message $message, DeleteMessagePin $action, PinnedMessagesQuery $pinnedMessages): JsonResponse
{
$action->handle($message);

$org = $message->conversation()->firstOrFail();

return response()->json(['pinned' => $pinnedMessages->get($org), 'reactions_version' => $org->reactions_version]);
}
}
41 changes: 41 additions & 0 deletions app/Http/Requests/DeleteMessagePinRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Message;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class DeleteMessagePinRequest extends FormRequest
{
/**
* Maintainers unpin an org's messages for everyone in it.
*/
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$message = $this->route('message');
assert($message instanceof Message);

$org = $message->conversation;

return match (true) {
! $user->remotes_enabled || ! $message->isVisibleTo($user) || ! $org->isOrg() => Response::denyAsNotFound(),
! $org->isMaintainedBy($user) => Response::deny('Only maintainers can unpin messages.'),
default => Response::allow(),
};
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
44 changes: 44 additions & 0 deletions app/Http/Requests/UpdateMessagePinRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;

final class UpdateMessagePinRequest extends FormRequest
{
/**
* Maintainers pin an org's messages for everyone in it, up to its limit, and only within the history they can see.
* Events can't be pinned.
*/
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$message = $this->route('message');
assert($message instanceof Message);

$org = $message->conversation;

return match (true) {
! $user->remotes_enabled || $message->event !== null || ! $message->isVisibleTo($user) || ! $org->isOrg() => Response::denyAsNotFound(),
! $org->isMaintainedBy($user) => Response::deny('Only maintainers can pin messages.'),
$message->pinned_at === null && $org->messages()->whereNotNull('pinned_at')->count() >= Conversation::MAX_PINS => Response::deny('An org can pin up to '.Conversation::MAX_PINS.' messages. Unpin one first.'),
default => Response::allow(),
};
}

/**
* @return array{}
*/
public function rules(): array
{
return [];
}
}
7 changes: 5 additions & 2 deletions app/Http/Resources/OrgThreadResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@

/**
* An org as the web's thread loads and polls it: its name, a page of messages from where the user's history starts,
* the reactions changed since the version a poll last saw, its members with their roles and, where both share pulls,
* how far each has pulled, who is typing, and the user's own role.
* the reactions changed since the version a poll last saw, the messages it pinned unless they are unchanged since that
* version, its members with their roles and, where both share pulls, how far each has pulled, who is typing, and the
* user's own role.
*
* @phpstan-import-type OrgThread from ConversationThreadQuery
*
Expand All @@ -22,6 +23,7 @@
* has_older: OrgThread['has_older'],
* reactions_version: OrgThread['reactions_version'],
* reaction_changes: OrgThread['reaction_changes'],
* pinned: OrgThread['pinned'],
* read_through_id: OrgThread['read_through_id'],
* members: OrgThread['members'],
* typing: OrgThread['typing'],
Expand All @@ -44,6 +46,7 @@ public function toArray(Request $request): array
...$thread,
'org' => ['id' => $thread['org']->id, 'name' => $thread['org']->name],
'messages' => MessageResource::collection($thread['messages']),
'pinned' => $thread['pinned'] === null ? null : MessageResource::collection($thread['pinned']),
'members' => $thread['members']->map(fn (array $member): array => [
'role' => $member['role'],
'read_through_id' => $member['read_through_id'],
Expand Down
5 changes: 5 additions & 0 deletions app/Models/Conversation.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@ final class Conversation extends Model
*/
public const int MAX_MEMBERS = 100;

/**
* An org pins up to twenty-five messages at a time.
*/
public const int MAX_PINS = 25;

/**
* Two people share exactly one conversation, whichever of them started it.
*/
Expand Down
10 changes: 7 additions & 3 deletions app/Models/Message.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use Carbon\CarbonInterface;
use Database\Factories\MessageFactory;
use Illuminate\Database\Eloquent\Attributes\Visible;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
Expand All @@ -31,6 +32,7 @@
* @property-read OrgEvent|null $event
* @property-read string|null $subject_id
* @property-read int $reactions_version
* @property-read CarbonInterface|null $pinned_at
* @property-read User $user
* @property-read User|null $subject
* @property-read Message|null $replyTo
Expand All @@ -42,7 +44,7 @@
* @property-read CarbonInterface $created_at
* @property-read CarbonInterface $updated_at
*/
#[Visible(['id', 'user_id', 'client_id', 'body', 'reply_to_id', 'mentions', 'event', 'subject_id', 'created_at', 'user', 'subject', 'linkPreview', 'images', 'voice', 'reactions'])]
#[Visible(['id', 'user_id', 'client_id', 'body', 'reply_to_id', 'mentions', 'event', 'subject_id', 'pinned_at', 'created_at', 'user', 'subject', 'linkPreview', 'images', 'voice', 'reactions'])]
final class Message extends Model
{
/** @use HasFactory<MessageFactory> */
Expand Down Expand Up @@ -73,6 +75,7 @@ public function casts(): array
'event' => OrgEvent::class,
'subject_id' => 'string',
'reactions_version' => 'integer',
'pinned_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
];
Expand Down Expand Up @@ -147,14 +150,15 @@ public function reactions(): HasMany
}

/**
* Only the people in the conversation see a message, and in an org only from where their history starts.
* Only the people in the conversation see a message, and in an org only from where their history starts, unless
* the org pinned it for everyone.
*/
public function isVisibleTo(User $user): bool
{
return ConversationParticipant::query()
->where('conversation_id', $this->conversation_id)
->where('user_id', $user->id)
->where('visible_from_id', '<', $this->id)
->when($this->pinned_at === null, fn (Builder $query): Builder => $query->where('visible_from_id', '<', $this->id))
->exists();
}

Expand Down
10 changes: 10 additions & 0 deletions app/Queries/ConversationThreadQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
* has_older: bool,
* reactions_version: int,
* reaction_changes: Collection<int, ReactionChange>,
* pinned: Collection<int, Message>|null,
* read_through_id: int,
* members: Collection<int, array{user: User, role: OrgRole|null, read_through_id: int, suggests_mentions: bool, is_followed: bool}>,
* typing: array<int, string>,
Expand All @@ -49,6 +50,11 @@
{
public const int PAGE = 50;

public function __construct(private PinnedMessagesQuery $pinnedMessages)
{
//
}

/**
* A one-to-one conversation. Reads only show as pulled when both people share them, and only once the partner
* has merged the conversation.
Expand Down Expand Up @@ -84,6 +90,9 @@ public function get(User $user, User $partner, ?int $after = null, ?int $before

/**
* An org the user is in, from where their history starts. Each member's reads show only when both share pulls.
* The messages the org pinned come whatever history the user was given, and polls get them again only once the
* reactions version moved past the one they sent, as pinning and unpinning move it. They are null when unchanged,
* and on older pages, where the thread keeps the ones it has.
*
* @return OrgThread
*/
Expand Down Expand Up @@ -112,6 +121,7 @@ public function org(User $user, Conversation $org, ?int $after = null, ?int $bef
'has_older' => $hasOlder,
'reactions_version' => $org->reactions_version,
'reaction_changes' => $this->reactionChanges($org, $mine->visible_from_id, $reactionsAfter),
'pinned' => $before === null && ($reactionsAfter === null || $org->reactions_version > $reactionsAfter) ? $this->pinnedMessages->get($org) : null,
'read_through_id' => $mine->read_through_id,
'members' => $participants->map(fn (ConversationParticipant $participant): array => [
'user' => $participant->user,
Expand Down
31 changes: 31 additions & 0 deletions app/Queries/PinnedMessagesQuery.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace App\Queries;

use App\Models\Conversation;
use App\Models\Message;
use Illuminate\Support\Collection;

final readonly class PinnedMessagesQuery
{
/**
* The messages an org pinned, latest pinned first, for everyone in the org whatever history they were given. Each
* comes with its author, images, voice note and reactions, but never the message it replies to, not even its id,
* as that may be from before the viewer's history and isn't pinned itself.
*
* @return Collection<int, Message>
*/
public function get(Conversation $org): Collection
{
return $org->messages()
->whereNotNull('pinned_at')
->with(['images', 'voice', 'reactions', 'user'])
->orderByDesc('pinned_at')
->orderByDesc('id')
->get()
->makeHidden('reply_to_id')
->toBase();
}
}
Loading