diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index 87d026bbd..82f8e16f6 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -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. @@ -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. @@ -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; diff --git a/app/Actions/UpdateOrgPingsFrom.php b/app/Actions/UpdateOrgPingsFrom.php new file mode 100644 index 000000000..e96369ac7 --- /dev/null +++ b/app/Actions/UpdateOrgPingsFrom.php @@ -0,0 +1,36 @@ +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); + }); + } +} diff --git a/app/Enums/OrgEvent.php b/app/Enums/OrgEvent.php index 0a9c0cffe..ba4913c96 100644 --- a/app/Enums/OrgEvent.php +++ b/app/Enums/OrgEvent.php @@ -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 { @@ -17,5 +18,6 @@ enum OrgEvent: string case Promoted = 'promoted'; case Demoted = 'demoted'; case Inherited = 'inherited'; + case PingsChanged = 'pings_changed'; case Ping = 'ping'; } diff --git a/app/Enums/PingsFrom.php b/app/Enums/PingsFrom.php new file mode 100644 index 000000000..2796b86c3 --- /dev/null +++ b/app/Enums/PingsFrom.php @@ -0,0 +1,24 @@ + true, + self::Maintainers => $role === OrgRole::Maintainer, + self::Nobody => false, + }; + } +} diff --git a/app/Http/Controllers/Api/OrgPingsFromController.php b/app/Http/Controllers/Api/OrgPingsFromController.php new file mode 100644 index 000000000..baaaa50d5 --- /dev/null +++ b/app/Http/Controllers/Api/OrgPingsFromController.php @@ -0,0 +1,25 @@ +handle($user, $org, $request->pingsFrom()); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/OrgPingsFromController.php b/app/Http/Controllers/OrgPingsFromController.php new file mode 100644 index 000000000..ecdc68686 --- /dev/null +++ b/app/Http/Controllers/OrgPingsFromController.php @@ -0,0 +1,22 @@ +handle($user, $org, $request->pingsFrom()); + + return response()->noContent(); + } +} diff --git a/app/Http/Requests/CreateApiOrgPingRequest.php b/app/Http/Requests/CreateApiOrgPingRequest.php index 0a4ba7587..8109a18ee 100644 --- a/app/Http/Requests/CreateApiOrgPingRequest.php +++ b/app/Http/Requests/CreateApiOrgPingRequest.php @@ -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; @@ -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 { @@ -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(), + }; } /** diff --git a/app/Http/Requests/CreateOrgPingRequest.php b/app/Http/Requests/CreateOrgPingRequest.php index 2781af432..5b1299215 100644 --- a/app/Http/Requests/CreateOrgPingRequest.php +++ b/app/Http/Requests/CreateOrgPingRequest.php @@ -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; @@ -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 { @@ -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(), + }; } /** diff --git a/app/Http/Requests/UpdateApiOrgPingsFromRequest.php b/app/Http/Requests/UpdateApiOrgPingsFromRequest.php new file mode 100644 index 000000000..c4f2f81bc --- /dev/null +++ b/app/Http/Requests/UpdateApiOrgPingsFromRequest.php @@ -0,0 +1,47 @@ +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> + */ + 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; + } +} diff --git a/app/Http/Requests/UpdateOrgPingsFromRequest.php b/app/Http/Requests/UpdateOrgPingsFromRequest.php new file mode 100644 index 000000000..bba3da54f --- /dev/null +++ b/app/Http/Requests/UpdateOrgPingsFromRequest.php @@ -0,0 +1,47 @@ +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> + */ + 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; + } +} diff --git a/app/Http/Resources/OrgThreadResource.php b/app/Http/Resources/OrgThreadResource.php index 5b3313fa4..d91e37663 100644 --- a/app/Http/Resources/OrgThreadResource.php +++ b/app/Http/Resources/OrgThreadResource.php @@ -12,8 +12,8 @@ /** * 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, 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. + * version, its members with their roles and, where both share pulls, how far each has pulled, who is typing, the + * user's own role, and who can ping it. * * @phpstan-import-type OrgThread from ConversationThreadQuery * @@ -31,6 +31,7 @@ * pinned_at: OrgThread['pinned_at'], * muted_until_at: OrgThread['muted_until_at'], * role: OrgThread['role'], + * pings_from: OrgThread['pings_from'], * access: OrgThread['access'] * } */ @@ -44,16 +45,18 @@ public function toArray(Request $request): array /** @var ThreadWithOrg $thread */ $thread = $this->resource; + $members = $thread['members']->map(fn (array $member): array => [ + 'role' => $member['role'], + 'read_through_id' => $member['read_through_id'], + 'user' => UserResource::make($member['user']), + ]); + return [ ...$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'], - 'user' => UserResource::make($member['user']), - ]), + 'members' => $members, ]; } } diff --git a/app/Models/Conversation.php b/app/Models/Conversation.php index 597044bdc..2216ec13e 100644 --- a/app/Models/Conversation.php +++ b/app/Models/Conversation.php @@ -6,6 +6,7 @@ use App\Enums\ConversationKind; use App\Enums\OrgRole; +use App\Enums\PingsFrom; use App\Enums\RemoteStatus; use Carbon\CarbonInterface; use Database\Factories\ConversationFactory; @@ -21,6 +22,7 @@ * @property-read ConversationKind $kind * @property-read string|null $pair * @property-read string|null $name + * @property-read PingsFrom $pings_from * @property-read int|null $last_message_id * @property-read int $reactions_version * @property-read CarbonInterface $created_at @@ -107,6 +109,7 @@ public function casts(): array 'kind' => ConversationKind::class, 'pair' => 'string', 'name' => 'string', + 'pings_from' => PingsFrom::class, 'last_message_id' => 'integer', 'reactions_version' => 'integer', 'created_at' => 'datetime', diff --git a/app/Queries/ConversationThreadQuery.php b/app/Queries/ConversationThreadQuery.php index 8517859b5..24b7e7c2e 100644 --- a/app/Queries/ConversationThreadQuery.php +++ b/app/Queries/ConversationThreadQuery.php @@ -6,6 +6,7 @@ use App\Actions\UpdateConversationTyping; use App\Enums\OrgRole; +use App\Enums\PingsFrom; use App\Enums\PushAccess; use App\Enums\RemoteStatus; use App\Models\Conversation; @@ -47,6 +48,7 @@ * pinned_at: string|null, * muted_until_at: string|null, * role: OrgRole|null, + * pings_from: PingsFrom, * access: PushAccess * } */ @@ -144,6 +146,7 @@ public function org(User $user, Conversation $org, ?int $after = null, ?int $bef 'pinned_at' => $mine->pinned_at?->toJSON(), 'muted_until_at' => $mine->muted_until_at?->isFuture() ? $mine->muted_until_at->toJSON() : null, 'role' => $mine->role, + 'pings_from' => $org->pings_from, 'access' => PushAccess::Open, ]; } diff --git a/database/migrations/2026_10_27_120000_add_pings_from_to_conversations_table.php b/database/migrations/2026_10_27_120000_add_pings_from_to_conversations_table.php new file mode 100644 index 000000000..121c3819b --- /dev/null +++ b/database/migrations/2026_10_27_120000_add_pings_from_to_conversations_table.php @@ -0,0 +1,17 @@ +string('pings_from')->default('everyone'); + }); + } +}; diff --git a/resources/js/components/chat/conversation-thread.tsx b/resources/js/components/chat/conversation-thread.tsx index 85b9cf69b..68432ab0a 100644 --- a/resources/js/components/chat/conversation-thread.tsx +++ b/resources/js/components/chat/conversation-thread.tsx @@ -35,7 +35,7 @@ import { useTouchReactions, } from '@/components/chat/message-reactions'; import { MessageStatusGlyph } from '@/components/chat/message-status'; -import { EventLine, typingText } from '@/components/chat/org'; +import { EventLine, pingRefusalOf, typingText } from '@/components/chat/org'; import { PinnedBanner, usePinMessage } from '@/components/chat/pinned-messages'; import { VoiceNote, VoiceNoteComposer } from '@/components/chat/voice-note'; import { @@ -835,6 +835,14 @@ export function ConversationThread({ target={current} draft={draft} members={isOrg ? thread?.members : undefined} + pingRefusal={ + isOrg + ? pingRefusalOf( + thread?.pings_from ?? 'everyone', + thread?.role ?? null, + ) + : null + } replyingTo={replyingTo} replyAuthor={ replyingTo === null @@ -1622,6 +1630,7 @@ function ReplyPreview({ function Composer({ target, members, + pingRefusal, draft, replyingTo, replyAuthor, @@ -1633,6 +1642,10 @@ function Composer({ }: { target: ChatTarget; members: OrgMember[] | undefined; + /** + * Why the org won't take a ping from the user, which keeps the ping button disabled, or null when it will. + */ + pingRefusal: string | null; draft: MessageDraftState; replyingTo: MessageReply | null; replyAuthor: Author | null; @@ -1924,32 +1937,39 @@ function Composer({ - + + + - {pingLabel} + + {pingRefusal ?? pingLabel} +
void; @@ -317,6 +360,14 @@ export function OrgMembersDialog({ } }; + const choosePingsFrom = async (from: PingsFrom) => { + try { + await setOrgPingsFrom(org, from); + } catch (error) { + toast.error(errorOf(error)); + } + }; + const leave = async () => { try { await removeOrgMember(org, user, true); @@ -390,6 +441,41 @@ export function OrgMembersDialog({ )} + {isMaintainer && ( +
+ + Who can ping + + +
+ )}