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

### Remotes: direct messages

Open a commit's **Share** menu to copy its link or choose **Send in Remotes** on an approved commit. Pick a developer and confirm sending its link. The confirmation shows the commit before sending; cancelling sends nothing. Sharing follows the usual Remotes contact and request rules and opens the recipient's conversation, where delivery failures can be retried.

**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.
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Controllers/RemoteCandidateController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
public function index(ListRemoteCandidatesRequest $request, #[CurrentUser] User $user, RemoteCandidatesQuery $candidates): JsonResponse
{
return response()->json([
'candidates' => $candidates->get($user, $request->string('q')->value(), $request->org()),
'candidates' => $candidates->get($user, $request->string('q')->value(), $request->org(), forSharing: $request->isSharing()),
])->header('Cache-Control', 'private, no-store');
}
}
7 changes: 6 additions & 1 deletion app/Http/Requests/ListRemoteCandidatesRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,15 @@ public function rules(): array
return [
'q' => ['nullable', 'string', 'max:100'],
'org' => ['nullable', 'uuid'],
'for' => ['nullable', 'in:remote,org'],
'for' => ['nullable', 'in:remote,org,share'],
];
}

public function isSharing(): bool
{
return $this->input('for') === 'share';
}

/**
* The org people are being picked for: an existing one, a new one not created yet, or none when picking someone
* to message.
Expand Down
8 changes: 5 additions & 3 deletions app/Queries/RemoteCandidatesQuery.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,10 @@ public function __construct(private UserSearchQuery $search, private PushAccessQ
*
* @return Collection<int, array{user: User, reason: string|null}>
*/
public function get(User $user, string $term, ?Conversation $org = null): Collection
public function get(User $user, string $term, ?Conversation $org = null, bool $forSharing = false): Collection
{
$candidates = $this->search->get($term, $user)->reject(fn (User $candidate): bool => $candidate->is($user))->values();
$reasons = $this->reasons($user, $candidates, $org);
$reasons = $this->reasons($user, $candidates, $org, forSharing: $forSharing);

return $candidates->map(fn (User $candidate): array => [
'user' => $candidate,
Expand All @@ -44,12 +44,14 @@ public function get(User $user, string $term, ?Conversation $org = null): Collec
* @param Collection<int, User>|null $joining
* @return array<string, string|null>
*/
public function reasons(User $user, Collection $candidates, ?Conversation $org = null, ?Collection $joining = null): array
public function reasons(User $user, Collection $candidates, ?Conversation $org = null, ?Collection $joining = null, bool $forSharing = false): array
{
if (! $org instanceof Conversation) {
return $candidates->mapWithKeys(fn (User $candidate): array => [$candidate->id => match ($this->pushAccess->get($user, $candidate)) {
PushAccess::Closed => 'has remotes off',
PushAccess::Restricted => "doesn't follow you",
PushAccess::Request => $forSharing ? 'merge their request first' : null,
PushAccess::Waiting => $forSharing ? 'your request is waiting to be merged' : null,
default => null,
}])->all();
}
Expand Down
246 changes: 208 additions & 38 deletions resources/js/components/commit-secondary-actions.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import { Ellipsis, Flag, LinkIcon } from 'lucide-react';
import { useState } from 'react';
import { router, usePage } from '@inertiajs/react';
import { LinkIcon, Send, Share2 } from 'lucide-react';
import { useRef, useState } from 'react';
import type { Ref } from 'react';
import CommitController from '@/actions/App/Http/Controllers/CommitController';
import ConversationController from '@/actions/App/Http/Controllers/ConversationController';
import { CommitReportButton } from '@/components/commit-report-button';
import { useCopyLink } from '@/components/copy-link-button';
import { QuotedCommit } from '@/components/quoted-commit';
import { SearchDialog } from '@/components/search-dialog';
import { Button } from '@/components/ui/button';
import {
CommitReportButton,
CommitReportDialog,
useCanReport,
} from '@/components/commit-report-button';
import { CopyLinkButton, useCopyLink } from '@/components/copy-link-button';
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { sendMessage } from '@/lib/chat';
import { cn } from '@/lib/utils';
import type { Commit } from '@/types';
import type { Commit, User } from '@/types';

export function CommitSecondaryActions({
commit,
Expand All @@ -24,62 +42,214 @@ export function CommitSecondaryActions({
keyShortcut?: string;
isReply?: boolean;
}) {
const { auth } = usePage().props;
const shareTriggerRef = useRef<HTMLButtonElement>(null);
const [isPicking, setIsPicking] = useState(false);
const [isPickerMounted, setIsPickerMounted] = useState(false);
const [recipient, setRecipient] = useState<User | null>(null);
const [isConfirming, setIsConfirming] = useState(false);
const canSend =
auth.user !== null &&
auth.remotes !== null &&
commit.status === 'approved';

const pickRecipient = () => {
setRecipient(null);
setIsPickerMounted(true);
setIsPicking(true);
};

const send = () => {
if (!canSend || recipient === null || !isConfirming) {
return;
}

const target = { kind: 'direct' as const, partner: recipient };
const permalink = CommitController.show({
user: commit.user,
commit,
}).url;

sendMessage(target, {
body: new URL(permalink, window.location.origin).href,
replyTo: null,
images: [],
descriptions: [],
linkPreviewUrl: undefined,
});
setIsConfirming(false);
router.visit(ConversationController.show(recipient));
};

return (
<div className="ml-auto flex items-center gap-0.5">
<div
className={cn(
'hidden items-center gap-0.5 transition-opacity md:flex md:opacity-0 md:focus-within:opacity-100 [@media(hover:none)]:opacity-100',
'flex items-center gap-0.5 transition-opacity md:opacity-0 md:focus-within:opacity-100 [@media(hover:none)]:opacity-100',
isReply
? 'md:reply-hover:opacity-100'
: 'md:commit-hover:opacity-100 md:commit-focus-within:opacity-100',
)}
>
<CopyLinkButton commit={commit} keyShortcut={keyShortcut} />
<CommitShareMenu
commit={commit}
keyShortcut={keyShortcut}
canSend={canSend}
triggerRef={shareTriggerRef}
onSend={pickRecipient}
/>
<CommitReportButton commit={commit} />
</div>
<CommitActionsMenu commit={commit} />
{canSend && isPickerMounted && (
<SearchDialog
open={isPicking}
onOpenChange={setIsPicking}
onPickDeveloper={setRecipient}
excludeId={auth.user?.id}
pickerTitle="Send commit in Remotes"
pickerPlaceholder="Find a developer"
pickerScope="share"
onCloseAutoFocus={(event) => {
event.preventDefault();
setIsPickerMounted(false);

if (recipient !== null) {
setIsConfirming(true);
} else {
shareTriggerRef.current?.focus();
}
}}
/>
)}
{canSend && recipient !== null && (
<Dialog open={isConfirming} onOpenChange={setIsConfirming}>
<DialogContent
className="grid-rows-[auto_minmax(0,1fr)_auto] overflow-hidden bg-card"
onCloseAutoFocus={(event) => {
event.preventDefault();
shareTriggerRef.current?.focus();
}}
>
<DialogHeader className="text-left">
<DialogTitle className="min-w-0 pr-6 wrap-anywhere">
Send commit to @{recipient.handle}?
</DialogTitle>
<DialogDescription>
Send a link to this commit in Remotes.
</DialogDescription>
</DialogHeader>
<div className="min-h-0 overflow-y-auto">
<QuotedCommit
commit={commit}
isPreview
label="Sharing"
/>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsConfirming(false)}
>
Cancel
</Button>
<Button
onClick={send}
data-test="confirm-share-commit"
>
Send
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
}

function CommitActionsMenu({ commit }: { commit: Commit }) {
const [isReporting, setIsReporting] = useState(false);
function CommitShareMenu({
commit,
keyShortcut,
canSend,
triggerRef,
onSend,
}: {
commit: Commit;
keyShortcut?: string;
canSend: boolean;
triggerRef: Ref<HTMLButtonElement>;
onSend: () => void;
}) {
const { copyLink } = useCopyLink(commit);
const canReport = useCanReport(commit);
const pendingSend = useRef(false);

return (
<div className="md:hidden">
<>
{keyShortcut && (
<button
type="button"
tabIndex={-1}
aria-label="Copy link"
aria-keyshortcuts={keyShortcut}
className="hidden"
onClick={copyLink}
/>
)}
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="More actions"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none data-[state=open]:bg-accent data-[state=open]:text-foreground"
data-test="commit-actions-menu"
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
ref={triggerRef}
type="button"
aria-label="Share commit"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none data-[state=open]:bg-accent data-[state=open]:text-foreground"
data-test="share-commit-button"
>
<Share2 aria-hidden className="size-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent>Share</TooltipContent>
</Tooltip>
<DropdownMenuContent
align="end"
className="w-48"
onCloseAutoFocus={(event) => {
if (!pendingSend.current) {
return;
}

event.preventDefault();
pendingSend.current = false;
// Open the picker after the menu has finished closing.
onSend();
}}
>
<DropdownMenuItem
onSelect={copyLink}
data-test="copy-commit-link"
>
<Ellipsis aria-hidden className="size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onSelect={copyLink}>
<LinkIcon />
Copy link
{keyShortcut && (
<DropdownMenuShortcut>
{keyShortcut}
</DropdownMenuShortcut>
)}
</DropdownMenuItem>
{canReport && (
<DropdownMenuItem onSelect={() => setIsReporting(true)}>
<Flag />
Report commit
{canSend && (
<DropdownMenuItem
onSelect={() => {
pendingSend.current = true;
}}
data-test="share-in-remotes"
>
<Send />
Send in Remotes
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
{canReport && (
<CommitReportDialog
commit={commit}
open={isReporting}
onOpenChange={setIsReporting}
/>
)}
</div>
</>
);
}
4 changes: 3 additions & 1 deletion resources/js/components/quoted-commit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import type { DeletedCommit, QuotedCommit as QuotedCommitData } from '@/types';
export function QuotedCommit({
commit,
isPreview = false,
label = 'Quoting',
}: {
commit: QuotedCommitData | DeletedCommit | null;
isPreview?: boolean;
label?: string;
}) {
const now = useNow();
if (commit === null || isDeleted(commit)) {
Expand All @@ -45,7 +47,7 @@ export function QuotedCommit({
const content = (
<>
<span className="flex min-w-0 items-center gap-1.5 text-sm">
<span className="sr-only">Quoting</span>
<span className="sr-only">{label}</span>
<UserAvatar user={commit.user} className="size-5" />
<span className="truncate font-semibold">
{commit.user.name}
Expand Down
Loading
Loading