diff --git a/resources/js/components/commit-composer-dialog.tsx b/resources/js/components/commit-composer-dialog.tsx index 893333e8a..572f7c317 100644 --- a/resources/js/components/commit-composer-dialog.tsx +++ b/resources/js/components/commit-composer-dialog.tsx @@ -6,6 +6,7 @@ import { DialogTitle, } from '@/components/ui/dialog'; import { useSharedState } from '@/hooks/use-shared-state'; +import type { IssueKind } from '@/lib/issue-commands'; const bodyId = 'commit-dialog-body'; @@ -43,12 +44,17 @@ export function useCommitDialogClosedCount(): number { return closedCount; } +/** + * Opens as a feature request or bug report when given an issue, as if `/feature` or `/bug` had been typed. + */ export function CommitComposerDialog({ open, onOpenChange, + issue = null, }: { open: boolean; onOpenChange: (open: boolean) => void; + issue?: IssueKind | null; }) { const [, setClosedCount] = useSharedState(closedCountKey, 0); @@ -103,6 +109,7 @@ export function CommitComposerDialog({ diff --git a/resources/js/components/commit-composer.tsx b/resources/js/components/commit-composer.tsx index 4668c4e9c..a8898760d 100644 --- a/resources/js/components/commit-composer.tsx +++ b/resources/js/components/commit-composer.tsx @@ -55,6 +55,7 @@ import { maxRawBodyLength, minBodyLength, } from '@/lib/commit'; +import type { IssueKind } from '@/lib/issue-commands'; import { hasModKey, useKeyLabels } from '@/lib/keyboard'; import { characterCount, @@ -559,10 +560,12 @@ function CommitComposerBody({ export function CommitComposer({ id = 'body', isInDialog = false, + issue: initialIssue = null, onCommitted, }: { id?: string; isInDialog?: boolean; + issue?: IssueKind | null; onCommitted?: () => void; }) { const schedule = useCommitSchedule(); @@ -597,6 +600,7 @@ export function CommitComposer({ !hasImages && poll === null && !isScheduling, + initialKind: initialIssue, onCreated: () => { drop(); focusBody(id); diff --git a/resources/js/components/go-live-dialog.tsx b/resources/js/components/go-live-dialog.tsx new file mode 100644 index 000000000..3ae3e68d5 --- /dev/null +++ b/resources/js/components/go-live-dialog.tsx @@ -0,0 +1,143 @@ +import { Form, router } from '@inertiajs/react'; +import { Radio } from 'lucide-react'; +import { useState } from 'react'; +import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController'; +import InputError from '@/components/input-error'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from '@/components/ui/dialog'; +import { Label } from '@/components/ui/label'; + +/** + * Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live. + */ +const maxHours = 12; + +const defaultHours = 2; + +export function endStream(callbacks?: { + onStart?: () => void; + onFinish?: () => void; +}): void { + router.visit(UserLiveController.destroy(), { + preserveScroll: true, + ...callbacks, + onSuccess: () => router.flushAll(), + }); +} + +export function GoLiveDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [hours, setHours] = useState(defaultHours); + const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`; + const filled = ((hours - 1) / (maxHours - 1)) * 100; + + return ( + + + Go live + + Share your YouTube or Twitch stream and your avatar gets a + live marker that sends people to it, for as long as you + choose. + + +
{ + onOpenChange(false); + setHours(defaultHours); + router.flushAll(); + }} + resetOnSuccess + > + {({ processing, errors }) => ( +
+
+ + + +
+ +
+
+ + + {hoursLabel} + +
+ + setHours( + event.currentTarget.valueAsNumber, + ) + } + aria-valuetext={hoursLabel} + style={{ + background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`, + }} + className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none" + data-test="live-hours-slider" + /> +
+ 1h + {maxHours}h +
+ +
+ + + + + + + +
+ )} +
+
+
+ ); +} diff --git a/resources/js/components/issue-command.tsx b/resources/js/components/issue-command.tsx index 94f8ad1f5..c6604dc18 100644 --- a/resources/js/components/issue-command.tsx +++ b/resources/js/components/issue-command.tsx @@ -34,14 +34,18 @@ export function useIssueCommand({ body, updateBody, enabled, + initialKind = null, onCreated, }: { body: string; updateBody: (body: string) => void; enabled: boolean; + initialKind?: IssueKind | null; onCreated: () => void; }) { - const [kind, setKind] = useState(null); + const [kind, setKind] = useState( + enabled ? initialKind : null, + ); const [selected, setSelected] = useState(0); const [dismissed, setDismissed] = useState(false); const [focused, setFocused] = useState(false); diff --git a/resources/js/components/profile-actions-menu.tsx b/resources/js/components/profile-actions-menu.tsx index ddd86ecf3..3493706bb 100644 --- a/resources/js/components/profile-actions-menu.tsx +++ b/resources/js/components/profile-actions-menu.tsx @@ -1,50 +1,22 @@ import { Form, router } from '@inertiajs/react'; import { Ellipsis, Radio, RadioOff } from 'lucide-react'; import { useState } from 'react'; -import UserLiveController from '@/actions/App/Http/Controllers/UserLiveController'; -import InputError from '@/components/input-error'; +import { endStream, GoLiveDialog } from '@/components/go-live-dialog'; import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogClose, - DialogContent, - DialogDescription, - DialogFooter, - DialogTitle, -} from '@/components/ui/dialog'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Label } from '@/components/ui/label'; import type { User } from '@/types'; -/** - * Mirrors `User::MAX_LIVE_HOURS`, the longest a stream can be shown as live. - */ -const maxHours = 12; - -const defaultHours = 2; - /** * The quick actions on your own profile, where other people's profiles have the mute and block menu. */ export function ProfileActionsMenu({ user }: { user: Pick }) { const [isGoingLive, setIsGoingLive] = useState(false); const [isEnding, setIsEnding] = useState(false); - const [hours, setHours] = useState(defaultHours); - const hoursLabel = hours === 1 ? '1 hour' : `${hours} hours`; - const filled = ((hours - 1) / (maxHours - 1)) * 100; - - const endStream = () => - router.visit(UserLiveController.destroy(), { - preserveScroll: true, - onStart: () => setIsEnding(true), - onFinish: () => setIsEnding(false), - onSuccess: () => router.flushAll(), - }); return ( <> @@ -64,7 +36,12 @@ export function ProfileActionsMenu({ user }: { user: Pick }) { {user.live ? ( + endStream({ + onStart: () => setIsEnding(true), + onFinish: () => setIsEnding(false), + }) + } data-test="end-stream" > @@ -82,104 +59,7 @@ export function ProfileActionsMenu({ user }: { user: Pick }) { - - - Go live - - Share your YouTube or Twitch stream and your avatar gets - a live marker that sends people to it, for as long as - you choose. - - -
{ - setIsGoingLive(false); - setHours(defaultHours); - router.flushAll(); - }} - resetOnSuccess - > - {({ processing, errors }) => ( -
-
- - - -
- -
-
- - - {hoursLabel} - -
- - setHours( - event.currentTarget - .valueAsNumber, - ) - } - aria-valuetext={hoursLabel} - style={{ - background: `linear-gradient(to right, var(--primary) ${filled}%, var(--muted) ${filled}%)`, - }} - className="h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-moz-range-thumb]:size-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-moz-range-thumb]:shadow-sm [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:transition-transform active:[&::-webkit-slider-thumb]:scale-110 motion-reduce:[&::-webkit-slider-thumb]:transition-none" - data-test="live-hours-slider" - /> -
- 1h - {maxHours}h -
- -
- - - - - - - -
- )} -
-
-
+ ); } diff --git a/resources/js/components/search-dialog.tsx b/resources/js/components/search-dialog.tsx index 8bda499b9..4f8ef10ab 100644 --- a/resources/js/components/search-dialog.tsx +++ b/resources/js/components/search-dialog.tsx @@ -1,6 +1,17 @@ import { Link, router, usePage } from '@inertiajs/react'; import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { ArrowRight, History, Search, Sparkles } from 'lucide-react'; +import type { LucideIcon } from 'lucide-react'; +import { + ArrowRight, + Bug, + History, + Lightbulb, + Radio, + Search, + Sparkles, + Square, + SquarePen, +} from 'lucide-react'; import type { KeyboardEvent, ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; import CommitController from '@/actions/App/Http/Controllers/CommitController'; @@ -22,6 +33,8 @@ import { TooManySearches, } from '@/lib/commit-search'; import { useKeyLabels } from '@/lib/keyboard'; +import type { SearchAction } from '@/lib/search-actions'; +import { searchActions } from '@/lib/search-actions'; import { awaitsDeveloperName, searchesCommits, @@ -43,6 +56,7 @@ const commitLimit = 5; type Found = { term: string; items: T[]; throttled?: boolean }; type Option = + | { kind: 'action'; id: string; action: SearchAction } | { kind: 'developer'; id: string; user: User; reason?: string | null } | { kind: 'commit'; id: string; commit: Commit } | { kind: 'all'; id: string } @@ -57,18 +71,23 @@ const suggestionOptions: Option[] = suggestedQueries.map((term, index) => ({ /** * Searches developers and commits. Picking a developer for a new conversation narrows it to developers, says why - * anyone can't be messaged, and hands the pick back instead of opening their profile. + * anyone can't be messaged, and hands the pick back instead of opening their profile. Given `onAction`, it also offers + * to write a commit, open an issue or go live when the search names one, above what it found. */ export function SearchDialog({ open, onOpenChange, onPickDeveloper, excludeId, + onAction, + isLive = false, }: { open: boolean; onOpenChange: (open: boolean) => void; onPickDeveloper?: (user: User) => void; excludeId?: string; + onAction?: (action: SearchAction) => void; + isLive?: boolean; }) { const userId = usePage().props.auth.user?.id ?? null; const recentSearches = useRecentSearches(userId); @@ -103,6 +122,7 @@ export function SearchDialog({ intent === 'topic' ? visibleDevelopers.slice(0, 2) : visibleDevelopers, visibleCommits, commitTerm, + onAction && !isPicking ? searchActions(term, { isLive }) : [], ); const isLoading = developers.isLoading || commits.isLoading; const hasSearched = @@ -156,6 +176,12 @@ export function SearchDialog({ close(); + if (option.kind === 'action') { + onAction?.(option.action); + + return; + } + if (option.kind === 'developer' && onPickDeveloper) { onPickDeveloper(option.user); @@ -320,7 +346,7 @@ export function SearchDialog({ isActive={index === activeIndex} onHover={() => setActiveIndex(index)} onOpen={close} - onPick={type} + onPick={() => openOption(option)} onPickDeveloper={ onPickDeveloper && ((user) => { @@ -473,7 +499,13 @@ function optionsFor( developers: FoundDeveloper[], commits: Commit[], commitTerm: string, + actions: SearchAction[], ): Option[] { + const actionOptions: Option[] = actions.map((action) => ({ + kind: 'action', + id: `action-${action}`, + action, + })); const developerOptions: Option[] = developers.map(({ user, reason }) => ({ kind: 'developer', id: `developer-${user.id}`, @@ -489,13 +521,19 @@ function optionsFor( commitTerm === '' ? [] : [{ kind: 'all', id: 'all' }]; if (intent === 'topic') { - return [...commitOptions, ...seeAll, ...developerOptions]; + return [ + ...actionOptions, + ...commitOptions, + ...seeAll, + ...developerOptions, + ]; } - return [...developerOptions, ...commitOptions, ...seeAll]; + return [...actionOptions, ...developerOptions, ...commitOptions, ...seeAll]; } const headings: Record = { + action: 'Actions', developer: 'Developers', commit: 'Most relevant commits', all: null, @@ -524,7 +562,7 @@ function headingBefore(options: Option[], index: number): ReactNode { } function hrefFor( - option: Exclude, + option: Exclude, term: string, ) { if (option.kind === 'developer') { @@ -557,7 +595,7 @@ function ResultOption({ isActive: boolean; onHover: () => void; onOpen: () => void; - onPick: (term: string) => void; + onPick: () => void; onPickDeveloper?: (user: User) => void; }) { const className = cn( @@ -594,7 +632,8 @@ function ResultOption({ role="option" aria-selected={isActive} > - {option.kind === 'suggestion' || + {option.kind === 'action' || + option.kind === 'suggestion' || option.kind === 'recent' || (option.kind === 'developer' && onPickDeveloper) ? (