From 3fd991d53b23790e640fbabc59e5f201ef0773b7 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 29 Oct 2026 11:51:07 +0000 Subject: [PATCH] feat: let org maintainers choose who can ping Maintainers can now keep pings in an org to maintainers, or turn them off, from the members dialog. The web and API ping endpoints refuse pings the setting doesn't allow, the composer's ping button is disabled with the reason, and each change is logged in the thread. The knowledge base says so too. --- KNOWLEDGE_BASE.md | 6 +- app/Actions/UpdateOrgPingsFrom.php | 36 ++++++++ app/Enums/OrgEvent.php | 4 +- app/Enums/PingsFrom.php | 24 +++++ .../Api/OrgPingsFromController.php | 25 +++++ .../Controllers/OrgPingsFromController.php | 22 +++++ app/Http/Requests/CreateApiOrgPingRequest.php | 12 ++- app/Http/Requests/CreateOrgPingRequest.php | 12 ++- .../Requests/UpdateApiOrgPingsFromRequest.php | 47 ++++++++++ .../Requests/UpdateOrgPingsFromRequest.php | 47 ++++++++++ app/Http/Resources/OrgThreadResource.php | 17 ++-- app/Models/Conversation.php | 3 + app/Queries/ConversationThreadQuery.php | 3 + ..._add_pings_from_to_conversations_table.php | 17 ++++ .../components/chat/conversation-thread.tsx | 72 +++++++++------ resources/js/components/chat/org.tsx | 92 ++++++++++++++++++- resources/js/lib/chat.ts | 43 +++++++++ resources/js/types/conversation.ts | 9 +- routes/api.php | 6 +- routes/web.php | 2 + .../Controllers/Api/OrgPingControllerTest.php | 21 +++++ .../Api/OrgPingsFromControllerTest.php | 62 +++++++++++++ .../Controllers/OrgPingControllerTest.php | 25 +++++ .../OrgPingsFromControllerTest.php | 88 ++++++++++++++++++ tests/Unit/RemoteNotifications.test.tsx | 2 +- tests/Unit/RemotePreferences.test.tsx | 26 +++++- 26 files changed, 674 insertions(+), 49 deletions(-) create mode 100644 app/Actions/UpdateOrgPingsFrom.php create mode 100644 app/Enums/PingsFrom.php create mode 100644 app/Http/Controllers/Api/OrgPingsFromController.php create mode 100644 app/Http/Controllers/OrgPingsFromController.php create mode 100644 app/Http/Requests/UpdateApiOrgPingsFromRequest.php create mode 100644 app/Http/Requests/UpdateOrgPingsFromRequest.php create mode 100644 database/migrations/2026_10_27_120000_add_pings_from_to_conversations_table.php create mode 100644 tests/Feature/Controllers/Api/OrgPingsFromControllerTest.php create mode 100644 tests/Feature/Controllers/OrgPingsFromControllerTest.php 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 + + +
+ )}
    diff --git a/resources/js/lib/chat.ts b/resources/js/lib/chat.ts index f9fc9a3fd..01e2e1d06 100644 --- a/resources/js/lib/chat.ts +++ b/resources/js/lib/chat.ts @@ -20,6 +20,7 @@ import OrgMessageController from '@/actions/App/Http/Controllers/OrgMessageContr import OrgMuteController from '@/actions/App/Http/Controllers/OrgMuteController'; import OrgPinController from '@/actions/App/Http/Controllers/OrgPinController'; import OrgPingController from '@/actions/App/Http/Controllers/OrgPingController'; +import OrgPingsFromController from '@/actions/App/Http/Controllers/OrgPingsFromController'; import OrgReadController from '@/actions/App/Http/Controllers/OrgReadController'; import OrgTypingController from '@/actions/App/Http/Controllers/OrgTypingController'; import RemoteCandidateController from '@/actions/App/Http/Controllers/RemoteCandidateController'; @@ -56,6 +57,7 @@ import type { OrgRef, OrgRole, OrgThread, + PingsFrom, PushAccess, Thread, User, @@ -104,6 +106,7 @@ export type ThreadState = { members: OrgMember[]; typing: string[]; role: OrgRole | null; + pings_from: PingsFrom; /** * The org's pinned messages, latest pinned first, which everyone in the org sees whatever history they were given. * Whether the user pinned the conversation itself in their inbox is pinned_at. @@ -214,6 +217,10 @@ let isAlertingTab = false; let isFetchingInbox = false; let muteVersion = 0; let pinVersion = 0; +/** + * When the user last chose who can ping each org, so a poll that left before then can't put the old choice back. + */ +const pingsFromChosenAt = new Map(); let inboxRevision = 0; const invalidatedInboxTabs = new Set(); @@ -808,6 +815,7 @@ function emptyThread(target: ChatTarget): ThreadState { members: [], typing: [], role: null, + pings_from: 'everyone', pinned: [], pinnedMessagesVersion: null, is_archived: false, @@ -830,6 +838,7 @@ function fromServer(thread: Thread | OrgThread): Partial { members: thread.members, typing: thread.typing, role: thread.role, + pings_from: thread.pings_from, is_archived: thread.is_archived, pinned_at: thread.pinned_at, muted_until_at: thread.muted_until_at, @@ -1242,6 +1251,9 @@ async function pollThread(target: ChatTarget): Promise { [key]: { ...current, ...fromServer(fresh), + ...((pingsFromChosenAt.get(key) ?? 0) > polledAt + ? { pings_from: current.pings_from } + : {}), target: 'org' in fresh ? { kind: 'org', org: fresh.org } @@ -2206,6 +2218,37 @@ export async function addOrgMembers( await refreshOrg(org); } +/** + * Chooses who can ping the org. The thread takes the choice straight away, keeps it over polls that left before it, + * and puts it back when the server refuses. + */ +export async function setOrgPingsFrom( + org: OrgRef, + from: PingsFrom, +): Promise { + const key = targetKey({ kind: 'org', org }); + const previous = state.threads[key]?.pings_from ?? 'everyone'; + + pingsFromChosenAt.set(key, performance.now()); + updateThread(key, () => ({ pings_from: from })); + + try { + await request( + OrgPingsFromController.update.url({ org: org.id }), + 'PUT', + { + pings_from: from, + }, + ); + } catch (error) { + updateThread(key, () => ({ pings_from: previous })); + + throw error; + } + + await refreshOrg(org); +} + export async function setOrgMemberRole( org: OrgRef, member: User, diff --git a/resources/js/types/conversation.ts b/resources/js/types/conversation.ts index 5ee575ff3..d8245c5f6 100644 --- a/resources/js/types/conversation.ts +++ b/resources/js/types/conversation.ts @@ -69,7 +69,7 @@ export type ConversationKind = 'direct' | 'org'; export type OrgRole = 'maintainer' | 'contributor'; /** - * What happened in a remote, logged between its messages: an org's membership changes, and pings. + * What happened in a remote, logged between its messages: an org's membership changes, who can ping it, and pings. */ export type OrgEvent = | 'created' @@ -80,8 +80,14 @@ export type OrgEvent = | 'promoted' | 'demoted' | 'inherited' + | 'pings_changed' | 'ping'; +/** + * Who can ping an org, as its maintainers chose. + */ +export type PingsFrom = 'everyone' | 'maintainers' | 'nobody'; + export type OrgRef = { id: string; name: string | null }; export type OrgMember = { @@ -137,6 +143,7 @@ export type OrgThread = { pinned_at: string | null; muted_until_at: string | null; role: OrgRole | null; + pings_from: PingsFrom; access: PushAccess; /** * The org's pinned messages, latest pinned first, unlike pinned_at, which is when the user pinned the org in their diff --git a/routes/api.php b/routes/api.php index 59c88722b..f4cfc2b42 100644 --- a/routes/api.php +++ b/routes/api.php @@ -46,6 +46,7 @@ use App\Http\Controllers\Api\OrgMuteController; use App\Http\Controllers\Api\OrgPinController; use App\Http\Controllers\Api\OrgPingController; +use App\Http\Controllers\Api\OrgPingsFromController; use App\Http\Controllers\Api\OrgReadController; use App\Http\Controllers\Api\OrgTypingController; use App\Http\Controllers\Api\RelatedCommitController; @@ -387,12 +388,15 @@ Route::post('remotes/orgs', [OrgController::class, 'store'])->middleware('throttle:messages')->name('api-org.store'); }); - // Remotes: renaming an org and managing its members, as the web's org settings do... + // Remotes: renaming an org, choosing who can ping it, and managing its members, as the web's org settings do... Route::middleware('throttle:conversations')->group(function (): void { Route::put('remotes/orgs/{org}', [OrgController::class, 'update']) ->whereUuid('org') ->middleware(PreventPostingInTimeout::class) ->name('api-org.update'); + Route::put('remotes/orgs/{org}/pings-from', [OrgPingsFromController::class, 'update']) + ->whereUuid('org') + ->name('api-org-pings-from.update'); Route::post('remotes/orgs/{org}/members', [OrgMemberController::class, 'store']) ->whereUuid('org') ->middleware([PreventPostingInTimeout::class, 'throttle:messages']) diff --git a/routes/web.php b/routes/web.php index e5bb2d572..bde9c83e5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -83,6 +83,7 @@ use App\Http\Controllers\OrgMuteController; use App\Http\Controllers\OrgPinController; use App\Http\Controllers\OrgPingController; +use App\Http\Controllers\OrgPingsFromController; use App\Http\Controllers\OrgReadController; use App\Http\Controllers\OrgTypingController; use App\Http\Controllers\PrivacyController; @@ -411,6 +412,7 @@ Route::get('remotes/candidates', [RemoteCandidateController::class, 'index'])->name('remote-candidate.index'); Route::post('remotes/orgs', [OrgController::class, 'store'])->middleware([PreventPostingInTimeout::class, 'throttle:messages'])->name('org.store'); Route::put('remotes/orgs/{org}', [OrgController::class, 'update'])->whereUuid('org')->middleware(PreventPostingInTimeout::class)->name('org.update'); + Route::put('remotes/orgs/{org}/pings-from', [OrgPingsFromController::class, 'update'])->whereUuid('org')->name('org-pings-from.update'); Route::get('remotes/orgs/{org}/messages', [OrgMessageController::class, 'index'])->whereUuid('org')->name('org-message.index'); Route::put('remotes/orgs/{org}/read', [OrgReadController::class, 'update'])->whereUuid('org')->name('org-read.update'); Route::put('remotes/orgs/{org}/typing', [OrgTypingController::class, 'update'])->whereUuid('org')->name('org-typing.update'); diff --git a/tests/Feature/Controllers/Api/OrgPingControllerTest.php b/tests/Feature/Controllers/Api/OrgPingControllerTest.php index 300ae6335..6e54d3000 100644 --- a/tests/Feature/Controllers/Api/OrgPingControllerTest.php +++ b/tests/Feature/Controllers/Api/OrgPingControllerTest.php @@ -33,6 +33,27 @@ $this->assertDatabaseCount('messages', 2); })->with([false, true]); +it('keeps to who the org lets ping, as the web does', function (): void { + [$member] = followersOf($this->user, 1); + $org = createOrg($this->user, [$member]); + $memberKeyPair = apiKeyPairFor($member); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $org), ['pings_from' => 'maintainers'])->assertNoContent(); + + signedApiRequest($memberKeyPair, 'POST', route('api-org-ping.store', $org)) + ->assertForbidden() + ->assertJsonPath('message', 'Only maintainers can ping this org.'); + signedApiRequest($this->keyPair, 'POST', route('api-org-ping.store', $org))->assertCreated(); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertNoContent(); + + signedApiRequest($this->keyPair, 'POST', route('api-org-ping.store', $org)) + ->assertForbidden() + ->assertJsonPath('message', 'Pings are off in this org.'); + + expect(Message::query()->where('event', OrgEvent::Ping)->count())->toBe(1); +}); + it('hides an org from a nonmember trying to ping it', function (): void { $maintainer = User::factory()->withRemotes()->create(); [$member] = followersOf($maintainer, 1); diff --git a/tests/Feature/Controllers/Api/OrgPingsFromControllerTest.php b/tests/Feature/Controllers/Api/OrgPingsFromControllerTest.php new file mode 100644 index 000000000..54a14a47f --- /dev/null +++ b/tests/Feature/Controllers/Api/OrgPingsFromControllerTest.php @@ -0,0 +1,62 @@ +user = User::factory()->create(); + $this->keyPair = apiKeyPairFor($this->user); +}); + +it('lets maintainers choose who can ping the org, as the web does', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $org), ['pings_from' => 'maintainers'])->assertNoContent(); + + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe(PingsFrom::Maintainers) + ->and(Message::query()->latest('id')->firstOrFail()->event)->toBe(OrgEvent::PingsChanged); + + signedApiRequest($this->keyPair, 'GET', route('api-org-message.index', $org))->assertOk()->assertJsonPath('data.pings_from', 'maintainers'); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $org), ['pings_from' => 'contributors']) + ->assertUnprocessable() + ->assertOnlyJsonValidationErrors('pings_from'); +}); + +it('keeps contributors and outsiders from choosing who can ping', function (): void { + $mark = User::factory()->create(); + $this->user->following()->attach($mark); + $org = createOrg($mark, [$this->user]); + $elsewhere = createOrg($mark, followersOf($mark, 1)); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $org), ['pings_from' => 'nobody']) + ->assertForbidden() + ->assertJsonPath('message', 'Only maintainers can choose who can ping the org.'); + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $elsewhere), ['pings_from' => 'nobody'])->assertNotFound(); + + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe(PingsFrom::Everyone); +}); + +it('hides the org from a maintainer with Remotes disabled', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + $this->user->update(['remotes_enabled' => false]); + + signedApiRequest($this->keyPair, 'PUT', route('api-org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertNotFound(); + + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe(PingsFrom::Everyone); +}); + +it('rejects requests that are not signed', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + auth()->logout(); + + $this->putJson(route('api-org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertUnauthorized(); +}); diff --git a/tests/Feature/Controllers/OrgPingControllerTest.php b/tests/Feature/Controllers/OrgPingControllerTest.php index 81585eecf..278acfbbf 100644 --- a/tests/Feature/Controllers/OrgPingControllerTest.php +++ b/tests/Feature/Controllers/OrgPingControllerTest.php @@ -35,6 +35,31 @@ $this->assertDatabaseCount('messages', 2); })->with([false, true]); +it('lets only maintainers ping when the org keeps pings to them', function (): void { + [$member] = followersOf($this->user, 1); + $org = createOrg($this->user, [$member]); + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => 'maintainers'])->assertNoContent(); + + $this->actingAs($member)->postJson(route('org-ping.store', $org)) + ->assertForbidden() + ->assertJsonPath('message', 'Only maintainers can ping this org.'); + $this->actingAs($this->user)->postJson(route('org-ping.store', $org))->assertCreated(); + + expect(Message::query()->where('event', OrgEvent::Ping)->pluck('user_id')->all())->toBe([$this->user->id]); +}); + +it('lets nobody ping when the org turned pings off', function (bool $contributor): void { + [$member] = followersOf($this->user, 1); + $org = createOrg($this->user, [$member]); + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertNoContent(); + + $this->actingAs($contributor ? $member : $this->user)->postJson(route('org-ping.store', $org)) + ->assertForbidden() + ->assertJsonPath('message', 'Pings are off in this org.'); + + expect(Message::query()->where('event', OrgEvent::Ping)->exists())->toBeFalse(); +})->with([false, true]); + it('hides an org from a nonmember trying to ping it', function (): void { $maintainer = User::factory()->withRemotes()->create(); [$member] = followersOf($maintainer, 1); diff --git a/tests/Feature/Controllers/OrgPingsFromControllerTest.php b/tests/Feature/Controllers/OrgPingsFromControllerTest.php new file mode 100644 index 000000000..2b1419410 --- /dev/null +++ b/tests/Feature/Controllers/OrgPingsFromControllerTest.php @@ -0,0 +1,88 @@ +user = User::factory()->withRemotes()->create(); +}); + +it('lets maintainers choose who can ping the org, and logs it', function (PingsFrom $from): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => $from->value])->assertNoContent(); + + $event = Message::query()->latest('id')->firstOrFail(); + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe($from) + ->and($event->event)->toBe(OrgEvent::PingsChanged) + ->and($event->body)->toBe($from->value) + ->and($event->user_id)->toBe($this->user->id); + + $this->actingAs($nuno)->getJson(route('org-message.index', $org))->assertOk()->assertJsonPath('pings_from', $from->value); +})->with([PingsFrom::Maintainers, PingsFrom::Nobody]); + +it('logs nothing when the org already has that choice', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => 'everyone'])->assertNoContent(); + + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe(PingsFrom::Everyone); + $this->assertDatabaseCount('messages', 1); +}); + +it('keeps contributors and outsiders from choosing who can ping', function (): void { + $mark = User::factory()->withRemotes()->create(); + $this->user->following()->attach($mark); + $org = createOrg($mark, [$this->user]); + $elsewhere = createOrg($mark, followersOf($mark, 1)); + + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => 'nobody']) + ->assertForbidden() + ->assertJsonPath('message', 'Only maintainers can choose who can ping the org.'); + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $elsewhere), ['pings_from' => 'nobody'])->assertNotFound(); + + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe(PingsFrom::Everyone); +}); + +it('hides the org from a maintainer with Remotes disabled', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + $this->user->update(['remotes_enabled' => false]); + + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertNotFound(); +}); + +it('lets a maintainer in a timeout still turn pings off, as it posts nothing of theirs', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + UserTimeout::factory()->for($this->user)->create(); + + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertNoContent(); + + expect(Conversation::query()->findOrFail($org)->pings_from)->toBe(PingsFrom::Nobody); +}); + +it('requires a known choice', function (mixed $from): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + + $this->actingAs($this->user)->putJson(route('org-pings-from.update', $org), ['pings_from' => $from]) + ->assertUnprocessable() + ->assertOnlyJsonValidationErrors('pings_from'); +})->with([null, 'contributors']); + +it('returns 401 for a guest', function (): void { + [$nuno] = followersOf($this->user, 1); + $org = createOrg($this->user, [$nuno]); + auth()->logout(); + + $this->putJson(route('org-pings-from.update', $org), ['pings_from' => 'nobody'])->assertUnauthorized(); +}); diff --git a/tests/Unit/RemoteNotifications.test.tsx b/tests/Unit/RemoteNotifications.test.tsx index 2a9506538..6c8f38e65 100644 --- a/tests/Unit/RemoteNotifications.test.tsx +++ b/tests/Unit/RemoteNotifications.test.tsx @@ -809,7 +809,7 @@ test('an unfocused window does not mark incoming messages read before it can ale messages: [conversation(50).last_message!], has_older: false, reactions_version: 0, reaction_changes: [], read_through_id: 0, members: [], typing: [], is_archived: false, pinned_at: null, muted_until_at: null, - role: 'contributor', access: 'open', pinned: [], + role: 'contributor', pings_from: 'everyone', access: 'open', pinned: [], }); markThreadRead(target); diff --git a/tests/Unit/RemotePreferences.test.tsx b/tests/Unit/RemotePreferences.test.tsx index de72d1b76..d4d2237ca 100644 --- a/tests/Unit/RemotePreferences.test.tsx +++ b/tests/Unit/RemotePreferences.test.tsx @@ -16,7 +16,7 @@ vi.doMock('react', () => ({ }, })); -const { chatSnapshot, loadTab, searchConversations, seedInbox, seedThread, setConversationMute, summaryKey, targetOf, watchRemoteAlerts, watchThread } = await import('../../resources/js/lib/chat'); +const { chatSnapshot, loadTab, searchConversations, seedInbox, seedThread, setConversationMute, setOrgPingsFrom, summaryKey, targetKey, targetOf, watchRemoteAlerts, watchThread } = await import('../../resources/js/lib/chat'); const { useRemoteAlerts } = await import('../../resources/js/hooks/use-chat'); const { ConversationList } = await import('../../resources/js/components/chat/conversation-list'); const { ConversationActions } = await import('../../resources/js/components/chat/conversation-actions'); @@ -37,7 +37,7 @@ const orgSummary: ConversationSummary = { const thread: OrgThread & { org: typeof org } = { org, messages: [], has_older: false, reactions_version: 0, reaction_changes: [], read_through_id: 0, members: [], typing: [], is_archived: true, pinned_at: null, muted_until_at: null, - role: 'contributor', access: 'open', pinned: [], + role: 'contributor', pings_from: 'everyone', access: 'open', pinned: [], }; function elements(node: ReactNode): ReactElement>[] { @@ -165,6 +165,28 @@ test('fresh thread mute changes beat a stashed summary and older in-flight inbox assert.equal(muteItem(props).label, 'Unmute'); }); +test('a thread poll that left before the user chose who can ping does not put the old choice back', async () => { + seedThread(target, thread); + const polls: ((response: Response) => void)[] = []; + vi.stubGlobal('fetch', async (url: string, options?: RequestInit) => { + if (options?.method === 'PUT') return new Response(null, { status: 204 }); + if (url.includes('/inbox')) return Response.json({ conversations: [orgSummary], has_more: false, unread: 0, requests: 0 }); + return new Promise((resolve) => polls.push(resolve)); + }); + cleanups.push(watchThread(target)); + await vi.advanceTimersByTimeAsync(0); + + const choosing = setOrgPingsFrom(org, 'nobody'); + assert.equal(chatSnapshot().threads[targetKey(target)].pings_from, 'nobody'); + polls[0](Response.json({ ...thread, pings_from: 'everyone' })); + await vi.advanceTimersByTimeAsync(0); + assert.equal(chatSnapshot().threads[targetKey(target)].pings_from, 'nobody'); + + polls[1](Response.json({ ...thread, pings_from: 'nobody' })); + await choosing; + assert.equal(chatSnapshot().threads[targetKey(target)].pings_from, 'nobody'); +}); + test.each(['default', 'denied', 'unsupported', 'granted'] as const)('tab-title notifications keep polling independently of banner permission: %s', async (permission) => { if (permission === 'unsupported') Reflect.deleteProperty(window, 'Notification'); else vi.stubGlobal('Notification', { permission });