Skip to content
Open
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
6 changes: 3 additions & 3 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ Every email has an **Unsubscribe** link at the bottom, and mail apps such as Gma

**Remotes** is /dev/null's private messaging inbox at `/remotes`. Open it from navigation or use a developer's **Remote** control. Remotes can be disabled in Settings → Profile; disabling keeps existing conversations but stops new messaging. By default, you can contact someone who follows you, provided both accounts have Remotes enabled and neither has blocked the other. The follow is only needed for the first message: once a conversation exists, both people can keep writing. Administrators have a contact-policy exception. Tom has Remotes turned off, so he cannot be messaged.

Messages support up to **2,000 characters**, replies to messages, link cards, and up to three images with the same file limits and descriptions as commit attachments. An image makes message text optional. **Voice notes** of up to a minute can be recorded from the microphone button in the composer; a voice note is sent on its own (it can still reply to a message), and you can listen to it before pushing it or discard it. Voice notes are compressed with Opus in your browser, about 180 KB a minute (browsers that can't record Opus send plain 16 kHz mono audio instead). The server rebuilds each recording from its audio alone, so no metadata or tags are kept, and stores it encrypted with the app key like message bodies, only ever served to the people in the conversation, and erased with your account. On a computer, hovering the speaker next to a voice note turns its waveform into a volume ramp to drag; the volume you set applies to every voice note, from anyone, on that device, and is saved in your browser, never on the server. Phones and tablets use their volume buttons instead. The speed button after it plays a voice note at 1×, 1.5× or 2×, with the clock showing how long it takes at that speed; the speed applies only to that voice note and resets when the page reloads. Messages cannot be edited or deleted, and mentions in them do not notify anyone. React to a message with any emoji, up to 12 different ones per person per message: three quick reactions sit next to each message, a picker searches the rest, and a double-tap or double-click toggles ❤️. On touch screens, press and hold a reaction beneath a message to see who left it; a short tap still adds or removes your own reaction. Typing indicators show recent activity. **Ping** with the button next to the microphone, a tribute to MSN Messenger: the remote shakes and plays MSN's sound for you and everyone in it (unless their sound is off or the remote is muted), the history logs it, and it alerts like a message, browser and app notifications included. Ping one person in a direct remote that has already started, or everyone in an org at once, up to three pings a minute. Read receipts, called **pull receipts**, are on by default and shared only when both relevant people enable them. Stashing a conversation archives it for you without deleting it; it pops back when anyone writes in it. On desktop, **Dock** keeps the inbox at the bottom of every page. **Back to present** returns to the newest message after following an old reply or scrolling until at least 25 newer messages are below the conversation viewport. This works in direct remotes, orgs, and the dock. Select a person’s name in the chat header or above a group message to open their profile. New messages arrive by polling, not push: every few seconds while a conversation is open. Message bodies, images, and voice notes are encrypted at rest with keys derived from the app key, not end to end. Block someone from inside a conversation with its menu.
Messages support up to **2,000 characters**, replies to messages, link cards, and up to three images with the same file limits and descriptions as commit attachments. An image makes message text optional. **Voice notes** of up to a minute can be recorded from the microphone button in the composer; a voice note is sent on its own (it can still reply to a message), and you can listen to it before pushing it or discard it. Voice notes are compressed with Opus in your browser, about 180 KB a minute (browsers that can't record Opus send plain 16 kHz mono audio instead). The server rebuilds each recording from its audio alone, so no metadata or tags are kept, and stores it encrypted with the app key like message bodies, only ever served to the people in the conversation, and erased with your account. On a computer, hovering the speaker next to a voice note turns its waveform into a volume ramp to drag; the volume you set applies to every voice note, from anyone, on that device, and is saved in your browser, never on the server. Phones and tablets use their volume buttons instead. The speed button after it plays a voice note at 1×, 1.5× or 2×, with the clock showing how long it takes at that speed; the speed applies only to that voice note and resets when the page reloads. Messages cannot be edited or deleted, and mentions in them do not notify anyone. React to a message with any emoji, up to 12 different ones per person per message: three quick reactions sit next to each message, a picker searches the rest, and a double-tap or double-click toggles ❤️. On touch screens, press and hold a reaction beneath a message to see who left it; a short tap still adds or removes your own reaction. Typing indicators show recent activity. **Ping** with the button next to the microphone, a tribute to MSN Messenger: the remote shakes and plays MSN's sound for you and everyone in it (unless their sound is off or the remote is muted), the history logs it, and it alerts like a message, browser and app notifications included. Ping one person in a direct remote that has already started, or everyone in an org at once, up to three pings a minute. An org's maintainers can let only maintainers ping it, or turn its pings off: the ping button is then disabled and says why, and the history logs each change. Read receipts, called **pull receipts**, are on by default and shared only when both relevant people enable them. Stashing a conversation archives it for you without deleting it; it pops back when anyone writes in it. On desktop, **Dock** keeps the inbox at the bottom of every page. **Back to present** returns to the newest message after following an old reply or scrolling until at least 25 newer messages are below the conversation viewport. This works in direct remotes, orgs, and the dock. Select a person’s name in the chat header or above a group message to open their profile. New messages arrive by polling, not push: every few seconds while a conversation is open. Message bodies, images, and voice notes are encrypted at rest with keys derived from the app key, not end to end. Block someone from inside a conversation with its menu.

When you are at the bottom of a chat, opening a reply moves the messages up to keep the latest one visible above the composer. Canceling restores that space, and sending keeps your new message at the bottom without scrolling the thread. Scrolling through older messages keeps your reading position when opening or canceling a reply; sending from there smoothly brings your new message into view. Reduced-motion preferences are respected.

Expand All @@ -395,7 +395,7 @@ Direct remotes disappear from the inbox when the other person deletes their acco

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, 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.
The creator starts as a **Maintainer**, and invited people start as **Contributors**. Maintainers can rename the group, choose who can ping it (everyone, maintainers only, or nobody), 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, except pinned messages: on the web, every member sees those in the pinned messages bar at the top of the chat, including people added without the history, and can reply and react to them.

Expand Down Expand Up @@ -504,7 +504,7 @@ A signed request sees what the web shows you, such as your own pending commits.
- star or unstar;
- create polls on commits that are neither replies nor quotes, and vote on polls;
- change Remotes settings, read and send messages with images, link cards, and voice notes in conversations and orgs, mark them pulled, react to messages and list your reactions, pin and unpin org messages as a maintainer, find people, and start orgs;
- rename orgs, add people to them, change their roles, remove them, and leave an org, with the same maintainer rules as the web;
- rename orgs, choose who can ping them, add people to them, change their roles, remove them, and leave an org, with the same maintainer rules as the web;
- stash or reopen conversations and orgs, pin and mute them, merge or drop message requests, and send typing indicators;
- switch Pastéis mode and give pastéis;
- report commits;
Expand Down
36 changes: 36 additions & 0 deletions app/Actions/UpdateOrgPingsFrom.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace App\Actions;

use App\Enums\OrgEvent;
use App\Enums\PingsFrom;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Support\Facades\DB;

final readonly class UpdateOrgPingsFrom
{
public function __construct(private CreateConversationEvent $createConversationEvent)
{
//
}

/**
* Logs the change in the org's history, so those who can no longer ping it can see why. Choosing what is already
* set logs nothing.
*/
public function handle(User $actor, Conversation $org, PingsFrom $from): void
{
if ($org->pings_from === $from) {
return;
}

DB::transaction(function () use ($actor, $org, $from): void {
$org->update(['pings_from' => $from]);

$this->createConversationEvent->handle($actor, $org, OrgEvent::PingsChanged, body: $from->value);
});
}
}
4 changes: 3 additions & 1 deletion app/Enums/OrgEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
namespace App\Enums;

/**
* What a remote records in its history, shown as log lines between messages: an org's membership changes, and pings.
* What a remote records in its history, shown as log lines between messages: an org's membership changes, who can ping
* it, and pings.
*/
enum OrgEvent: string
{
Expand All @@ -17,5 +18,6 @@ enum OrgEvent: string
case Promoted = 'promoted';
case Demoted = 'demoted';
case Inherited = 'inherited';
case PingsChanged = 'pings_changed';
case Ping = 'ping';
}
24 changes: 24 additions & 0 deletions app/Enums/PingsFrom.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace App\Enums;

/**
* Who can ping an org. Its maintainers choose, and everyone in it can ping until they do.
*/
enum PingsFrom: string
{
case Everyone = 'everyone';
case Maintainers = 'maintainers';
case Nobody = 'nobody';

public function allows(OrgRole $role): bool
{
return match ($this) {
self::Everyone => true,
self::Maintainers => $role === OrgRole::Maintainer,
self::Nobody => false,
};
}
}
25 changes: 25 additions & 0 deletions app/Http/Controllers/Api/OrgPingsFromController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Actions\UpdateOrgPingsFrom;
use App\Http\Requests\UpdateApiOrgPingsFromRequest;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final readonly class OrgPingsFromController
{
/**
* Chooses who can ping the org, as the web's org settings do.
*/
public function update(UpdateApiOrgPingsFromRequest $request, #[CurrentUser] User $user, Conversation $org, UpdateOrgPingsFrom $action): Response
{
$action->handle($user, $org, $request->pingsFrom());

return response()->noContent();
}
}
22 changes: 22 additions & 0 deletions app/Http/Controllers/OrgPingsFromController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateOrgPingsFrom;
use App\Http\Requests\UpdateOrgPingsFromRequest;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Container\Attributes\CurrentUser;
use Illuminate\Http\Response;

final readonly class OrgPingsFromController
{
public function update(UpdateOrgPingsFromRequest $request, #[CurrentUser] User $user, Conversation $org, UpdateOrgPingsFrom $action): Response
{
$action->handle($user, $org, $request->pingsFrom());

return response()->noContent();
}
}
12 changes: 10 additions & 2 deletions app/Http/Requests/CreateApiOrgPingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Http\Requests;

use App\Enums\OrgRole;
use App\Enums\PingsFrom;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
Expand All @@ -13,7 +14,8 @@
final class CreateApiOrgPingRequest extends FormRequest
{
/**
* Anyone in the org can ping it, as anyone in it can push to it.
* Anyone in the org can ping it, as anyone in it can push to it, unless its maintainers kept pings to themselves
* or turned them off.
*/
public function authorize(): Response
{
Expand All @@ -23,7 +25,13 @@ public function authorize(): Response
$org = $this->route('org');
assert($org instanceof Conversation);

return $user->remotes_enabled && $org->roleOf($user) instanceof OrgRole ? Response::allow() : Response::denyAsNotFound();
$role = $org->roleOf($user);

return match (true) {
! $user->remotes_enabled || ! $role instanceof OrgRole => Response::denyAsNotFound(),
! $org->pings_from->allows($role) => Response::deny($org->pings_from === PingsFrom::Nobody ? 'Pings are off in this org.' : 'Only maintainers can ping this org.'),
default => Response::allow(),
};
}

/**
Expand Down
12 changes: 10 additions & 2 deletions app/Http/Requests/CreateOrgPingRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Http\Requests;

use App\Enums\OrgRole;
use App\Enums\PingsFrom;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
Expand All @@ -13,7 +14,8 @@
final class CreateOrgPingRequest extends FormRequest
{
/**
* Anyone in the org can ping it, as anyone in it can push to it.
* Anyone in the org can ping it, as anyone in it can push to it, unless its maintainers kept pings to themselves
* or turned them off.
*/
public function authorize(): Response
{
Expand All @@ -23,7 +25,13 @@ public function authorize(): Response
$org = $this->route('org');
assert($org instanceof Conversation);

return $user->remotes_enabled && $org->roleOf($user) instanceof OrgRole ? Response::allow() : Response::denyAsNotFound();
$role = $org->roleOf($user);

return match (true) {
! $user->remotes_enabled || ! $role instanceof OrgRole => Response::denyAsNotFound(),
! $org->pings_from->allows($role) => Response::deny($org->pings_from === PingsFrom::Nobody ? 'Pings are off in this org.' : 'Only maintainers can ping this org.'),
default => Response::allow(),
};
}

/**
Expand Down
47 changes: 47 additions & 0 deletions app/Http/Requests/UpdateApiOrgPingsFromRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\OrgRole;
use App\Enums\PingsFrom;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class UpdateApiOrgPingsFromRequest extends FormRequest
{
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$org = $this->route('org');
assert($org instanceof Conversation);

return match (true) {
! $user->remotes_enabled || ! $org->roleOf($user) instanceof OrgRole => Response::denyAsNotFound(),
! $org->isMaintainedBy($user) => Response::deny('Only maintainers can choose who can ping the org.'),
default => Response::allow(),
};
}

/**
* @return array<string, list<string|Enum>>
*/
public function rules(): array
{
return [
'pings_from' => ['required', Rule::enum(PingsFrom::class)],
];
}

public function pingsFrom(): PingsFrom
{
return $this->enum('pings_from', PingsFrom::class) ?? PingsFrom::Everyone;
}
}
47 changes: 47 additions & 0 deletions app/Http/Requests/UpdateOrgPingsFromRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Enums\OrgRole;
use App\Enums\PingsFrom;
use App\Models\Conversation;
use App\Models\User;
use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Enum;

final class UpdateOrgPingsFromRequest extends FormRequest
{
public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);

$org = $this->route('org');
assert($org instanceof Conversation);

return match (true) {
! $user->remotes_enabled || ! $org->roleOf($user) instanceof OrgRole => Response::denyAsNotFound(),
! $org->isMaintainedBy($user) => Response::deny('Only maintainers can choose who can ping the org.'),
default => Response::allow(),
};
}

/**
* @return array<string, list<string|Enum>>
*/
public function rules(): array
{
return [
'pings_from' => ['required', Rule::enum(PingsFrom::class)],
];
}

public function pingsFrom(): PingsFrom
{
return $this->enum('pings_from', PingsFrom::class) ?? PingsFrom::Everyone;
}
}
Loading