Phone app: a Work tab, and work items replace a repository's issues
Work comes right after Home, for every member: the list with its filters in a sheet, a work item with its repositories, what landed and what is missing, suggested targets and comments, and a new one with targets. Code moves to the top of More. g1t://<workspace>/-/work links open them, and old <owner>/<repo>/issues/<n> links resolve to the work item or pull request they name. #N reads by the same rules as the site, and the app asks for work:read and work:write.
- G1T-Session:
- ses_01m560bz6eeqc8v1hrrha03pap
22 files+1555−2190/22 viewed
| 1 | 1 | # g1t for Android and iOS | |
| 2 | 2 | ||
| 3 | 3 | The phone app: [Expo](https://expo.dev) (SDK 57, React Native 0.86) with | |
| 4 | − | Expo Router, in TypeScript. Chat and Code, signed in with g1t's | |
| 4 | + | Expo Router, in TypeScript. Work, Chat and Code, signed in with g1t's | |
| 5 | 5 | own OAuth. Android is the first target. What it does for people is in the | |
| 6 | 6 | guide, [The phone app](../docs/src/content/docs/guides/mobile.md). | |
| 7 | 7 | ||
| ⋯ | |||
| 17 | 17 | sign-in.tsx Sign in with g1t, an access token, or another server | |
| 18 | 18 | oauth/callback.tsx where the site sends sign-in back (g1t://oauth/callback) | |
| 19 | 19 | (app)/ signed in: the tabs | |
| 20 | + | work/ the workspace's work items: the list, an item, new | |
| 20 | 21 | chat/ the list, a conversation, a thread, new, browse | |
| 21 | − | code/ repositories, pull requests, issues, diffs | |
| 22 | − | more/ notifications, workspaces, sign out | |
| 22 | + | code/ repositories, pull requests, diffs (opened from More) | |
| 23 | + | more/ Code, notifications, workspaces, sign out | |
| 23 | 24 | home/, notifications/ coming: they open the site | |
| 24 | − | +native-intent.tsx g1t:// links to screens (lib/links.ts) | |
| 25 | − | components/ the building blocks, themed (ui.tsx); chat/ and Code's | |
| 25 | + | ref.tsx what #N names (a work item or a pull request), opened | |
| 26 | + | +native-intent.tsx g1t:// and https links to screens (lib/links.ts) | |
| 27 | + | components/ the building blocks, themed (ui.tsx); chat/, Code's and Work's | |
| 26 | 28 | lib/ | |
| 27 | 29 | server.ts which g1t: site and API addresses | |
| 28 | 30 | oauth.ts OAuth 2.1 with PKCE against the server's metadata | |
| 29 | 31 | credentials.ts the server and credentials, in the phone's keystore | |
| 30 | 32 | client.ts every request: refresh, errors, the site or the API | |
| 31 | 33 | session.tsx who is signed in, to which workspace | |
| 32 | − | chat.ts, code.ts the data for Chat and Code (TanStack Query) | |
| 34 | + | chat.ts, code.ts, work.ts the data for Chat, Code and Work (TanStack Query) | |
| 33 | 35 | live.ts the site's live sockets, with tickets, kept open | |
| 34 | 36 | feed.tsx counts, banners and presence while the app is open | |
| 35 | 37 | push.ts notifications on the phone | |
| ⋯ | |||
| 44 | 46 | | What | Where | How it is signed in | | |
| 45 | 47 | | --- | --- | --- | | |
| 46 | 48 | | Who you are, your workspaces | `GET {api}/user` | `Authorization: Bearer g1t_…` | | |
| 49 | + | | Work | `{api}/workspaces/<workspace>/work…`, `…/labels`, `…/milestones` REST | the same | | |
| 47 | 50 | | Code | `{api}/repos…` REST | the same | | |
| 48 | 51 | | Chat | `{site}/<workspace>/-/chat/api` and its live socket, as the site's own pages use them | the same header, with `chat:*` ([below](#chat-with-the-apps-sign-in)); a socket gets a ticket from `{site}/-/live/ticket` first | | |
| 49 | 52 | | Notifications | `{site}/-/notify` and the feed socket `{site}/-/live` | the same, with `notifications:*` | | |
| 2 | 2 | "name": "@g1t/mobile", | |
| 3 | 3 | "private": true, | |
| 4 | 4 | "version": "0.1.0", | |
| 5 | − | "description": "The g1t phone app (Expo): Chat and Code first, signed in with OAuth, for Android and iOS.", | |
| 5 | + | "description": "The g1t phone app (Expo): Work, Chat and Code first, signed in with OAuth, for Android and iOS.", | |
| 6 | 6 | "main": "expo-router/entry", | |
| 7 | 7 | "scripts": { | |
| 8 | 8 | "start": "expo start", |
| 1 | 1 | import * as Notifications from "expo-notifications"; | |
| 2 | 2 | import { Tabs, router, useGlobalSearchParams, useSegments } from "expo-router"; | |
| 3 | − | import { Bell, Code2, House, Menu, MessagesSquare } from "lucide-react-native"; | |
| 3 | + | import { Bell, House, ListTodo, Menu, MessagesSquare } from "lucide-react-native"; | |
| 4 | 4 | import type { LucideIcon } from "lucide-react-native"; | |
| 5 | 5 | import { type ComponentType, useEffect, useRef } from "react"; | |
| 6 | 6 | import { StyleSheet, View } from "react-native"; | |
| ⋯ | |||
| 10 | 10 | import { routeFor } from "@/lib/links"; | |
| 11 | 11 | import { type PushData, registerForPush } from "@/lib/push"; | |
| 12 | 12 | import { useSignedIn } from "@/lib/session"; | |
| 13 | − | import { hasCodeAccess } from "@g1t/contracts"; | |
| 14 | 13 | import { useColors } from "@/theme"; | |
| 15 | 14 | ||
| 16 | 15 | /** | |
| 17 | 16 | * The signed-in app. Its bar is the site's phone bar | |
| 18 | − | * (apps/web/app/components/mobile.tsx): Home, Chat, Notifications, Code, | |
| 19 | − | * and More. Chat and Code have their screens; the others say so and open | |
| 20 | − | * the site. Your account is under More. | |
| 17 | + | * (apps/web/app/components/mobile.tsx): Home, Work, Chat, Notifications | |
| 18 | + | * and More. Work and Chat have their screens; Home and Notifications say | |
| 19 | + | * so and open the site. Code is at the top of More, for those who use it, | |
| 20 | + | * and your account is under it. | |
| 21 | 21 | */ | |
| 22 | 22 | export default function AppLayout() { | |
| 23 | 23 | return ( | |
| ⋯ | |||
| 38 | 38 | useEffect(() => { | |
| 39 | 39 | if (linked && linked.toLowerCase() !== workspace?.slug.toLowerCase()) void selectWorkspace(linked); | |
| 40 | 40 | }, [linked, workspace?.slug, selectWorkspace]); | |
| 41 | − | const code = !workspace || hasCodeAccess(workspace); | |
| 42 | 41 | // A conversation or a thread takes the whole screen, as on the site: the bar steps aside. | |
| 43 | 42 | const segments = useSegments() as string[]; | |
| 44 | 43 | const inConversation = segments[1] === "chat" && (segments[2] === "[channel]" || segments[2] === "thread"); | |
| ⋯ | |||
| 63 | 62 | > | |
| 64 | 63 | <Tabs.Screen name="index" options={{ href: null }} /> | |
| 65 | 64 | <Tabs.Screen name="home" options={{ title: "Home", tabBarIcon: (props) => <BarIcon icon={House} {...props} /> }} /> | |
| 65 | + | <Tabs.Screen name="work" options={{ title: "Work", tabBarIcon: (props) => <BarIcon icon={ListTodo} {...props} /> }} /> | |
| 66 | 66 | <Tabs.Screen | |
| 67 | 67 | name="chat" | |
| 68 | 68 | options={{ | |
| ⋯ | |||
| 73 | 73 | }} | |
| 74 | 74 | /> | |
| 75 | 75 | <Tabs.Screen name="notifications" options={{ title: "Notifications", tabBarIcon: (props) => <BarIcon icon={Bell} {...props} /> }} /> | |
| 76 | − | <Tabs.Screen name="code" options={{ title: "Code", href: code ? undefined : null, tabBarIcon: (props) => <BarIcon icon={Code2} {...props} /> }} /> | |
| 76 | + | {/* Code opens from More, so it has no place in the bar. */} | |
| 77 | + | <Tabs.Screen name="code" options={{ title: "Code", href: null }} /> | |
| 77 | 78 | <Tabs.Screen name="more" options={{ title: "More", tabBarIcon: (props) => <BarIcon icon={Menu} {...props} /> }} /> | |
| 78 | 79 | </Tabs> | |
| 79 | 80 | ); | |
| 1 | 1 | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | − | import { CircleDot, GitPullRequest } from "lucide-react-native"; | |
| 2 | + | import { GitPullRequest, ListTodo } from "lucide-react-native"; | |
| 3 | 3 | import { useState } from "react"; | |
| 4 | − | import { FlatList, RefreshControl, View } from "react-native"; | |
| 4 | + | import { ActivityIndicator, FlatList, RefreshControl, View } from "react-native"; | |
| 5 | 5 | ||
| 6 | − | import { Checks, IssueIcon, PullIcon } from "@/components/code"; | |
| 6 | + | import { Checks, PullIcon } from "@/components/code"; | |
| 7 | 7 | import { Segments } from "@/components/segments"; | |
| 8 | 8 | import { Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; | |
| 9 | − | import { type ApiIssue, type ApiPull, useDescriptions, useIssues, usePulls } from "@/lib/code"; | |
| 9 | + | import { ItemRow } from "@/components/work"; | |
| 10 | + | import { type ApiPull, useDescriptions, usePulls } from "@/lib/code"; | |
| 10 | 11 | import { ago } from "@/lib/time"; | |
| 12 | + | import { useWorkItems } from "@/lib/work"; | |
| 11 | 13 | import { space, useColors } from "@/theme"; | |
| 12 | 14 | ||
| 13 | − | type Tab = "pulls" | "issues"; | |
| 15 | + | type Tab = "pulls" | "work"; | |
| 14 | 16 | type State = "open" | "closed"; | |
| 15 | 17 | ||
| 16 | − | /** A repository: its pull requests and issues. */ | |
| 18 | + | /** | |
| 19 | + | * A repository: its pull requests, and its Work: the workspace's work | |
| 20 | + | * items linked to it, in any role. | |
| 21 | + | */ | |
| 17 | 22 | export default function Repository() { | |
| 18 | 23 | const { owner, repo } = useLocalSearchParams<{ owner: string; repo: string }>(); | |
| 19 | 24 | const [tab, setTab] = useState<Tab>("pulls"); | |
| 20 | 25 | const [state, setState] = useState<State>("open"); | |
| 21 | 26 | const description = useDescriptions(owner).data?.[`${owner}/${repo}`.toLowerCase()]; | |
| 22 | 27 | const pulls = usePulls(owner, repo, state); | |
| 23 | − | const issues = useIssues(owner, repo, state); | |
| 24 | − | const list = tab === "pulls" ? pulls : issues; | |
| 28 | + | const work = useWorkItems(owner, { state, repo }); | |
| 29 | + | const list = tab === "pulls" ? pulls : work; | |
| 25 | 30 | const colors = useColors(); | |
| 26 | 31 | ||
| 27 | 32 | return ( | |
| ⋯ | |||
| 37 | 42 | onChange={setTab} | |
| 38 | 43 | options={[ | |
| 39 | 44 | { value: "pulls", label: "Pull requests" }, | |
| 40 | − | { value: "issues", label: "Issues" }, | |
| 45 | + | { value: "work", label: "Work" }, | |
| 41 | 46 | ]} | |
| 42 | 47 | /> | |
| 43 | 48 | <Segments | |
| ⋯ | |||
| 63 | 68 | /> | |
| 64 | 69 | ) : ( | |
| 65 | 70 | <FlatList | |
| 66 | − | data={issues.data} | |
| 67 | − | keyExtractor={(issue) => issue.id} | |
| 68 | − | refreshControl={<RefreshControl refreshing={issues.isRefetching} onRefresh={() => issues.refetch()} tintColor={colors.accent} />} | |
| 69 | − | ListEmptyComponent={<Empty icon={CircleDot} title={`No ${state} issues`} />} | |
| 70 | − | renderItem={({ item }) => <IssueRow issue={item} owner={owner} repo={repo} />} | |
| 71 | + | data={work.items} | |
| 72 | + | keyExtractor={(item) => item.id} | |
| 73 | + | refreshControl={<RefreshControl refreshing={work.isRefetching && !work.isFetchingNextPage} onRefresh={() => work.refetch()} tintColor={colors.accent} />} | |
| 74 | + | onEndReached={() => work.hasNextPage && !work.isFetchingNextPage && work.fetchNextPage()} | |
| 75 | + | onEndReachedThreshold={0.5} | |
| 76 | + | ListFooterComponent={work.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null} | |
| 77 | + | ListEmptyComponent={<Empty icon={ListTodo} title={`No ${state} work items`} body={`Work items linked to ${repo} show here.`} />} | |
| 78 | + | renderItem={({ item }) => ( | |
| 79 | + | <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} /> | |
| 80 | + | )} | |
| 71 | 81 | /> | |
| 72 | 82 | )} | |
| 73 | 83 | </Screen> | |
| ⋯ | |||
| 80 | 90 | <Row | |
| 81 | 91 | leading={<PullIcon pull={pull} />} | |
| 82 | 92 | title={pull.title} | |
| 83 | − | subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""} · ${ago(pull.updated_at)}`} | |
| 93 | + | subtitle={`#${pull.number} · ${by}${pull.agent ? ` with ${pull.agent}` : ""}${pull.item ? ` · for #${pull.item}` : ""} · ${ago(pull.updated_at)}`} | |
| 84 | 94 | trailing={<Checks status={pull.check_status} />} | |
| 85 | 95 | onPress={() => router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo, number: String(pull.number) } })} | |
| 86 | − | /> | |
| 87 | − | ); | |
| 88 | − | } | |
| 89 | − | ||
| 90 | − | function IssueRow({ issue, owner, repo }: { issue: ApiIssue; owner: string; repo: string }) { | |
| 91 | − | const by = issue.author.display_username ?? issue.author.username; | |
| 92 | − | return ( | |
| 93 | − | <Row | |
| 94 | − | leading={<IssueIcon issue={issue} />} | |
| 95 | − | title={issue.title} | |
| 96 | − | subtitle={`#${issue.number} · ${by} · ${ago(issue.updated_at)}${issue.comment_count ? ` · ${issue.comment_count} comments` : ""}`} | |
| 97 | − | onPress={() => router.push({ pathname: "/code/[owner]/[repo]/issues/[number]", params: { owner, repo, number: String(issue.number) } })} | |
| 98 | 96 | /> | |
| 99 | 97 | ); | |
| 100 | 98 | } | |
| 1 | − | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | − | import * as WebBrowser from "expo-web-browser"; | |
| 3 | − | import { ExternalLink, Send } from "lucide-react-native"; | |
| 4 | − | import { useState } from "react"; | |
| 5 | − | import { View } from "react-native"; | |
| 6 | − | ||
| 7 | − | import { Body, IssueIcon, PullIcon, Timeline } from "@/components/code"; | |
| 8 | − | import { Button, ErrorView, Field, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 9 | − | import { useComment, useIssue } from "@/lib/code"; | |
| 10 | − | import { siteUrl } from "@/lib/server"; | |
| 11 | − | import { useSignedIn } from "@/lib/session"; | |
| 12 | − | import { ago } from "@/lib/time"; | |
| 13 | − | import { space } from "@/theme"; | |
| 14 | − | ||
| 15 | − | /** An issue: what it asks for, the pull requests made for it, and its conversation. */ | |
| 16 | − | export default function Issue() { | |
| 17 | − | const params = useLocalSearchParams<{ owner: string; repo: string; number: string }>(); | |
| 18 | − | const number = Number(params.number); | |
| 19 | − | const { server } = useSignedIn(); | |
| 20 | − | const issue = useIssue(params.owner, params.repo, number); | |
| 21 | − | const comment = useComment(params.owner, params.repo, number); | |
| 22 | − | const [draft, setDraft] = useState(""); | |
| 23 | − | ||
| 24 | − | if (issue.isPending || issue.error) { | |
| 25 | − | return ( | |
| 26 | − | <Screen> | |
| 27 | − | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 28 | − | {issue.error ? <ErrorView error={issue.error} retry={() => issue.refetch()} /> : <Loading />} | |
| 29 | − | </Screen> | |
| 30 | − | ); | |
| 31 | − | } | |
| 32 | − | ||
| 33 | − | const { issue: item, pulls, comments } = issue.data; | |
| 34 | − | const by = item.author.display_username ?? item.author.username; | |
| 35 | − | ||
| 36 | − | return ( | |
| 37 | − | <Screen scroll> | |
| 38 | − | <Stack.Screen options={{ title: `#${number}` }} /> | |
| 39 | − | <View style={{ gap: space.sm }}> | |
| 40 | − | <Text size="heading">{item.title}</Text> | |
| 41 | − | <View style={{ flexDirection: "row", gap: space.sm, alignItems: "center" }}> | |
| 42 | − | <IssueIcon issue={item} /> | |
| 43 | − | <Text size="small" tone="muted"> | |
| 44 | − | {item.state === "open" ? "Open" : "Closed"} · {by} · {ago(item.created_at)} | |
| 45 | − | {item.labels.length ? ` · ${item.labels.join(", ")}` : ""} | |
| 46 | − | </Text> | |
| 47 | − | </View> | |
| 48 | − | </View> | |
| 49 | − | <Body text={item.body} /> | |
| 50 | − | {pulls.length ? ( | |
| 51 | − | <View style={{ marginHorizontal: -space.lg }}> | |
| 52 | − | <SectionHeader title="Pull requests" /> | |
| 53 | − | {pulls.map((pull) => ( | |
| 54 | − | <Row | |
| 55 | − | key={pull.id} | |
| 56 | − | leading={<PullIcon pull={pull} />} | |
| 57 | − | title={pull.title} | |
| 58 | − | subtitle={`#${pull.number} · ${pull.status}`} | |
| 59 | − | onPress={() => | |
| 60 | − | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner: params.owner, repo: params.repo, number: String(pull.number) } }) | |
| 61 | − | } | |
| 62 | − | /> | |
| 63 | − | ))} | |
| 64 | − | </View> | |
| 65 | − | ) : null} | |
| 66 | − | <Timeline comments={comments} /> | |
| 67 | − | <View style={{ gap: space.sm }}> | |
| 68 | − | <Field label="Comment" placeholder="Add to the conversation" value={draft} onChangeText={setDraft} multiline /> | |
| 69 | − | <Button | |
| 70 | − | title="Comment" | |
| 71 | − | icon={Send} | |
| 72 | − | busy={comment.isPending} | |
| 73 | − | disabled={!draft.trim()} | |
| 74 | − | onPress={() => comment.mutate(draft.trim(), { onSuccess: () => setDraft("") })} | |
| 75 | − | /> | |
| 76 | − | {comment.error ? <Text tone="danger">{comment.error.message}</Text> : null} | |
| 77 | − | </View> | |
| 78 | − | <Button | |
| 79 | − | title="Open on g1t" | |
| 80 | − | variant="secondary" | |
| 81 | − | icon={ExternalLink} | |
| 82 | − | onPress={() => WebBrowser.openBrowserAsync(siteUrl(server, `/${params.owner}/${params.repo}/issues/${number}`))} | |
| 83 | − | /> | |
| 84 | − | </Screen> | |
| 85 | − | ); | |
| 86 | − | } |
| 1 | − | import { Stack, useLocalSearchParams } from "expo-router"; | |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | 2 | import * as WebBrowser from "expo-web-browser"; | |
| 3 | 3 | import { Check, ExternalLink, MessageSquareWarning } from "lucide-react-native"; | |
| 4 | 4 | import { useState } from "react"; | |
| ⋯ | |||
| 6 | 6 | ||
| 7 | 7 | import { Body, Checks, Diff, StateBadge, Timeline } from "@/components/code"; | |
| 8 | 8 | import { Segments } from "@/components/segments"; | |
| 9 | − | import { Button, ErrorView, Field, Loading, Screen, Text } from "@/components/ui"; | |
| 9 | + | import { Button, ErrorView, Field, Loading, Row, Screen, Text } from "@/components/ui"; | |
| 10 | + | import { ItemIcon, stateLabel } from "@/components/work"; | |
| 10 | 11 | import { usePull, usePullChanges, useReview } from "@/lib/code"; | |
| 11 | 12 | import { siteUrl } from "@/lib/server"; | |
| 12 | 13 | import { useSignedIn } from "@/lib/session"; | |
| ⋯ | |||
| 47 | 48 | ); | |
| 48 | 49 | } | |
| 49 | 50 | ||
| 50 | − | const { pull: pr, comments } = pull.data; | |
| 51 | + | const { pull: pr, item, comments } = pull.data; | |
| 51 | 52 | const by = pr.author.display_username ?? pr.author.username; | |
| 52 | 53 | const reviewable = pr.status === "open"; | |
| 54 | + | const refs = { repo: `${params.owner}/${params.repo}` }; | |
| 53 | 55 | ||
| 54 | 56 | return ( | |
| 55 | 57 | <Screen scroll> | |
| ⋯ | |||
| 66 | 68 | </View> | |
| 67 | 69 | </View> | |
| 68 | 70 | ||
| 71 | + | {item ? ( | |
| 72 | + | <View style={{ marginHorizontal: -space.lg }}> | |
| 73 | + | <Row | |
| 74 | + | leading={<ItemIcon item={item} />} | |
| 75 | + | title={item.title} | |
| 76 | + | subtitle={`For work item #${item.number} · ${stateLabel(item)}`} | |
| 77 | + | onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} | |
| 78 | + | /> | |
| 79 | + | </View> | |
| 80 | + | ) : null} | |
| 81 | + | ||
| 69 | 82 | <Segments | |
| 70 | 83 | value={tab} | |
| 71 | 84 | onChange={setTab} | |
| ⋯ | |||
| 77 | 90 | ||
| 78 | 91 | {tab === "conversation" ? ( | |
| 79 | 92 | <> | |
| 80 | − | <Body text={pr.body} /> | |
| 81 | − | <Timeline comments={comments} /> | |
| 93 | + | <Body text={pr.body} refs={refs} /> | |
| 94 | + | <Timeline comments={comments} refs={refs} /> | |
| 82 | 95 | {reviewable ? ( | |
| 83 | 96 | <View style={{ gap: space.md }}> | |
| 84 | 97 | <Field label="Review" placeholder="Say what you think (optional to approve)" value={note} onChangeText={setNote} multiline /> | |
| 1 | 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | 2 | import Constants from "expo-constants"; | |
| 3 | − | import { Stack } from "expo-router"; | |
| 3 | + | import { hasCodeAccess } from "@g1t/contracts"; | |
| 4 | + | import { Stack, router } from "expo-router"; | |
| 4 | 5 | import * as WebBrowser from "expo-web-browser"; | |
| 5 | − | import { Bell, BellRing, Check, ExternalLink, LogOut } from "lucide-react-native"; | |
| 6 | + | import { Bell, BellRing, Check, Code2, ExternalLink, LogOut } from "lucide-react-native"; | |
| 6 | 7 | import { Linking, View } from "react-native"; | |
| 7 | 8 | ||
| 8 | 9 | import { Avatar, Badge, Button, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| ⋯ | |||
| 11 | 12 | import { useSignedIn } from "@/lib/session"; | |
| 12 | 13 | import { space, useColors } from "@/theme"; | |
| 13 | 14 | ||
| 14 | − | /** More: your workspaces, notifications on this phone, how you signed in, and signing out. */ | |
| 15 | + | /** More: Code, notifications on this phone, your workspaces, how you signed in, and signing out. */ | |
| 15 | 16 | export default function More() { | |
| 16 | 17 | const { me, workspace, server, client, selectWorkspace, signOut } = useSignedIn(); | |
| 18 | + | const code = !workspace || hasCodeAccess(workspace); | |
| 17 | 19 | const colors = useColors(); | |
| 18 | 20 | const queries = useQueryClient(); | |
| 19 | 21 | const name = me.display_username ?? me.username; | |
| ⋯ | |||
| 42 | 44 | </View> | |
| 43 | 45 | </View> | |
| 44 | 46 | ||
| 47 | + | {code ? ( | |
| 48 | + | <View> | |
| 49 | + | <Row | |
| 50 | + | leading={<Code2 size={22} color={colors.accent} />} | |
| 51 | + | title="Code" | |
| 52 | + | subtitle="Repositories, their pull requests and changes" | |
| 53 | + | onPress={() => router.push("/code")} | |
| 54 | + | /> | |
| 55 | + | </View> | |
| 56 | + | ) : null} | |
| 57 | + | ||
| 45 | 58 | <View> | |
| 46 | 59 | <Row | |
| 47 | 60 | leading={<Bell size={22} color={colors.accent} />} | |
| 1 | + | import * as Clipboard from "expo-clipboard"; | |
| 2 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 3 | + | import { useHeaderHeight } from "expo-router/react-navigation"; | |
| 4 | + | import * as WebBrowser from "expo-web-browser"; | |
| 5 | + | import { | |
| 6 | + | CircleCheck, | |
| 7 | + | CircleDot, | |
| 8 | + | CircleX, | |
| 9 | + | Copy, | |
| 10 | + | ExternalLink, | |
| 11 | + | FolderGit2, | |
| 12 | + | Link2, | |
| 13 | + | Plus, | |
| 14 | + | Search, | |
| 15 | + | Unlink, | |
| 16 | + | } from "lucide-react-native"; | |
| 17 | + | import { useState } from "react"; | |
| 18 | + | import { Alert, KeyboardAvoidingView, RefreshControl, ScrollView, StyleSheet, TextInput, View, useWindowDimensions } from "react-native"; | |
| 19 | + | ||
| 20 | + | import { Sheet, SheetAction } from "@/components/chat/sheet"; | |
| 21 | + | import { Body, Checks, PullIcon, Timeline } from "@/components/code"; | |
| 22 | + | import { Badge, Button, ErrorView, Loading, Row, Screen, SectionHeader, Text } from "@/components/ui"; | |
| 23 | + | import { Chip, CommentBar, ItemIcon, repoName, roleLabel, stateLabel } from "@/components/work"; | |
| 24 | + | import { useRepos } from "@/lib/code"; | |
| 25 | + | import { siteUrl } from "@/lib/server"; | |
| 26 | + | import { useSignedIn } from "@/lib/session"; | |
| 27 | + | import { ago } from "@/lib/time"; | |
| 28 | + | import { type ApiItemRepo, type ApiWorkPull, type ApiWorkRepo, useItemActions, useWorkContext, useWorkItem } from "@/lib/work"; | |
| 29 | + | import { radius, space, useColors } from "@/theme"; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * A work item: what it asks for, the repositories it is linked to (where | |
| 33 | + | * it came from, where it is meant to land, and what has landed where), | |
| 34 | + | * each one's pull requests for it, and its conversation. The bar at the | |
| 35 | + | * foot comments, and its ⋯ closes, reopens and links a repository. | |
| 36 | + | */ | |
| 37 | + | export default function WorkItem() { | |
| 38 | + | const params = useLocalSearchParams<{ number: string; workspace?: string }>(); | |
| 39 | + | const { server, workspace: current } = useSignedIn(); | |
| 40 | + | const workspace = params.workspace ?? current?.slug ?? ""; | |
| 41 | + | const number = Number(params.number); | |
| 42 | + | const detail = useWorkItem(workspace, number); | |
| 43 | + | const context = useWorkContext(workspace, number); | |
| 44 | + | const actions = useItemActions(workspace, number); | |
| 45 | + | const headerHeight = useHeaderHeight(); | |
| 46 | + | const colors = useColors(); | |
| 47 | + | const [more, setMore] = useState(false); | |
| 48 | + | const [linking, setLinking] = useState(false); | |
| 49 | + | const [picked, setPicked] = useState<ApiItemRepo | null>(null); | |
| 50 | + | ||
| 51 | + | const title = <Stack.Screen options={{ title: `#${number}` }} />; | |
| 52 | + | if (detail.isPending || detail.error) { | |
| 53 | + | return ( | |
| 54 | + | <Screen> | |
| 55 | + | {title} | |
| 56 | + | {detail.error ? <ErrorView error={detail.error} retry={() => detail.refetch()} /> : <Loading />} | |
| 57 | + | </Screen> | |
| 58 | + | ); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | const { item, comments } = detail.data; | |
| 62 | + | const by = item.author.display_username ?? item.author.username; | |
| 63 | + | const refs = { workspace }; | |
| 64 | + | const sitePath = `/${workspace}/-/work/${number}`; | |
| 65 | + | // The context has each repository's pull requests; the item alone still has its repositories. | |
| 66 | + | const repos: ApiWorkRepo[] = context.data?.repos ?? item.repos.map((repo) => ({ ...repo, pulls: [] })); | |
| 67 | + | const missing = context.data?.missing ?? item.repos.filter((repo) => repo.role === "target" && !repo.landed).map((repo) => repo.path); | |
| 68 | + | const linked = new Set(repos.map((repo) => repo.path.toLowerCase())); | |
| 69 | + | const suggested = (context.data?.suggested_targets ?? []).filter((one) => !linked.has(one.path.toLowerCase())); | |
| 70 | + | ||
| 71 | + | const failed = (what: string) => (error: unknown) => Alert.alert(what, error instanceof Error ? error.message : "Something went wrong."); | |
| 72 | + | const close = (reason: "completed" | "not_planned") => { | |
| 73 | + | setMore(false); | |
| 74 | + | actions.close.mutate(reason, { onError: failed("Could not close it") }); | |
| 75 | + | }; | |
| 76 | + | const reopen = () => { | |
| 77 | + | setMore(false); | |
| 78 | + | actions.reopen.mutate(undefined, { onError: failed("Could not reopen it") }); | |
| 79 | + | }; | |
| 80 | + | const link = (repo: string) => { | |
| 81 | + | setLinking(false); | |
| 82 | + | actions.link.mutate({ repo, role: "target" }, { onError: failed("Could not link it") }); | |
| 83 | + | }; | |
| 84 | + | const openRepo = (path: string) => { | |
| 85 | + | const [owner, name] = path.split("/", 2); | |
| 86 | + | router.push({ pathname: "/code/[owner]/[repo]", params: { owner, repo: name } }); | |
| 87 | + | }; | |
| 88 | + | const openPull = (pull: ApiWorkPull) => { | |
| 89 | + | const [owner, name] = pull.repo.split("/", 2); | |
| 90 | + | router.push({ pathname: "/code/[owner]/[repo]/pull/[number]", params: { owner, repo: name, number: String(pull.number) } }); | |
| 91 | + | }; | |
| 92 | + | const busy = actions.close.isPending || actions.reopen.isPending; | |
| 93 | + | ||
| 94 | + | return ( | |
| 95 | + | <Screen> | |
| 96 | + | {title} | |
| 97 | + | <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}> | |
| 98 | + | <ScrollView | |
| 99 | + | contentContainerStyle={styles.content} | |
| 100 | + | keyboardShouldPersistTaps="handled" | |
| 101 | + | keyboardDismissMode="interactive" | |
| 102 | + | refreshControl={ | |
| 103 | + | <RefreshControl | |
| 104 | + | refreshing={detail.isRefetching} | |
| 105 | + | onRefresh={() => Promise.all([detail.refetch(), context.refetch()])} | |
| 106 | + | tintColor={colors.accent} | |
| 107 | + | /> | |
| 108 | + | } | |
| 109 | + | > | |
| 110 | + | <View style={{ gap: space.sm }}> | |
| 111 | + | <Text size="heading" selectable> | |
| 112 | + | {item.title} | |
| 113 | + | </Text> | |
| 114 | + | <View style={styles.wrap}> | |
| 115 | + | <ItemIcon item={item} size={18} /> | |
| 116 | + | <Text size="small" style={{ fontWeight: "600", color: item.state === "open" ? colors.success : colors.muted }}> | |
| 117 | + | {stateLabel(item)} | |
| 118 | + | </Text> | |
| 119 | + | <Text size="small" tone="muted"> | |
| 120 | + | #{item.number} · {by} · {ago(item.created_at)} | |
| 121 | + | </Text> | |
| 122 | + | </View> | |
| 123 | + | {item.labels.length || item.milestone || item.assignees.length ? ( | |
| 124 | + | <View style={styles.wrap}> | |
| 125 | + | {item.labels.map((label) => ( | |
| 126 | + | <Chip key={label} label={label} /> | |
| 127 | + | ))} | |
| 128 | + | {item.milestone ? <Chip label={item.milestone.title} /> : null} | |
| 129 | + | {item.assignees.map((name) => ( | |
| 130 | + | <Chip key={name} label={`@${name}`} /> | |
| 131 | + | ))} | |
| 132 | + | </View> | |
| 133 | + | ) : null} | |
| 134 | + | </View> | |
| 135 | + | ||
| 136 | + | <Body text={item.body} refs={refs} /> | |
| 137 | + | ||
| 138 | + | <View style={{ marginHorizontal: -space.lg }}> | |
| 139 | + | <SectionHeader title="Repositories" /> | |
| 140 | + | {repos.length === 0 ? ( | |
| 141 | + | <Text tone="muted" style={{ paddingHorizontal: space.lg }}> | |
| 142 | + | Not linked to a repository yet. Link the ones where the work is meant to land. | |
| 143 | + | </Text> | |
| 144 | + | ) : ( | |
| 145 | + | repos.map((repo) => ( | |
| 146 | + | <View key={repo.repo_id}> | |
| 147 | + | <Row | |
| 148 | + | leading={<FolderGit2 size={20} color={colors.muted} />} | |
| 149 | + | title={repoName(repo.path, workspace)} | |
| 150 | + | subtitle={`${roleLabel(repo.role)}${repo.legacy_number ? ` · was #${repo.legacy_number} there` : ""}`} | |
| 151 | + | trailing={ | |
| 152 | + | repo.landed ? ( | |
| 153 | + | <Badge label="Landed" tone="merged" /> | |
| 154 | + | ) : repo.role === "target" ? ( | |
| 155 | + | <Badge label="Not landed" tone="warn" /> | |
| 156 | + | ) : null | |
| 157 | + | } | |
| 158 | + | onPress={() => setPicked(repo)} | |
| 159 | + | /> | |
| 160 | + | {repo.pulls.map((pull) => ( | |
| 161 | + | <View key={`${pull.repo}#${pull.number}`} style={{ paddingLeft: space.xl }}> | |
| 162 | + | <Row | |
| 163 | + | leading={<PullIcon pull={pull} />} | |
| 164 | + | title={pull.title} | |
| 165 | + | subtitle={`#${pull.number} · ${pull.status}`} | |
| 166 | + | trailing={<Checks status={pull.check_status} />} | |
| 167 | + | onPress={() => openPull(pull)} | |
| 168 | + | /> | |
| 169 | + | </View> | |
| 170 | + | ))} | |
| 171 | + | </View> | |
| 172 | + | )) | |
| 173 | + | )} | |
| 174 | + | {missing.length && item.state === "open" ? ( | |
| 175 | + | <Text size="small" tone="muted" style={{ paddingHorizontal: space.lg, paddingTop: space.sm }}> | |
| 176 | + | Closes once it lands in {missing.map((path) => repoName(path, workspace)).join(", ")}. | |
| 177 | + | </Text> | |
| 178 | + | ) : null} | |
| 179 | + | <View style={{ paddingHorizontal: space.lg, paddingTop: space.md }}> | |
| 180 | + | <Button title="Link a repository" variant="secondary" icon={Link2} onPress={() => setLinking(true)} /> | |
| 181 | + | </View> | |
| 182 | + | </View> | |
| 183 | + | ||
| 184 | + | {suggested.length ? ( | |
| 185 | + | <View style={{ marginHorizontal: -space.lg }}> | |
| 186 | + | <SectionHeader title="Suggested" /> | |
| 187 | + | {suggested.map((one) => ( | |
| 188 | + | <Row | |
| 189 | + | key={one.repo_id} | |
| 190 | + | leading={<FolderGit2 size={20} color={colors.faint} />} | |
| 191 | + | title={repoName(one.path, workspace)} | |
| 192 | + | subtitle={one.why} | |
| 193 | + | chevron={false} | |
| 194 | + | trailing={ | |
| 195 | + | <Button | |
| 196 | + | title="Add as target" | |
| 197 | + | variant="secondary" | |
| 198 | + | icon={Plus} | |
| 199 | + | busy={actions.link.isPending && actions.link.variables?.repo === one.path} | |
| 200 | + | onPress={() => link(one.path)} | |
| 201 | + | /> | |
| 202 | + | } | |
| 203 | + | /> | |
| 204 | + | ))} | |
| 205 | + | </View> | |
| 206 | + | ) : null} | |
| 207 | + | ||
| 208 | + | <Timeline comments={comments} refs={refs} /> | |
| 209 | + | ||
| 210 | + | {item.state === "open" ? ( | |
| 211 | + | <Button title="Close" variant="secondary" icon={CircleCheck} busy={busy} onPress={() => setMore(true)} /> | |
| 212 | + | ) : ( | |
| 213 | + | <Button title="Reopen" variant="secondary" icon={CircleDot} busy={busy} onPress={reopen} /> | |
| 214 | + | )} | |
| 215 | + | </ScrollView> | |
| 216 | + | <CommentBar | |
| 217 | + | busy={actions.comment.isPending} | |
| 218 | + | onMore={() => setMore(true)} | |
| 219 | + | onSend={(body) => actions.comment.mutateAsync(body).catch((error: unknown) => (failed("Could not comment")(error), Promise.reject(error)))} | |
| 220 | + | /> | |
| 221 | + | </KeyboardAvoidingView> | |
| 222 | + | ||
| 223 | + | <Sheet visible={more} onClose={() => setMore(false)}> | |
| 224 | + | {item.state === "open" ? ( | |
| 225 | + | <> | |
| 226 | + | <SheetAction icon={CircleCheck} label="Close as done" onPress={() => close("completed")} /> | |
| 227 | + | <SheetAction icon={CircleX} label="Close as not planned" onPress={() => close("not_planned")} /> | |
| 228 | + | </> | |
| 229 | + | ) : ( | |
| 230 | + | <SheetAction icon={CircleDot} label="Reopen" onPress={reopen} /> | |
| 231 | + | )} | |
| 232 | + | <SheetAction | |
| 233 | + | icon={Link2} | |
| 234 | + | label="Link a repository" | |
| 235 | + | onPress={() => { | |
| 236 | + | setMore(false); | |
| 237 | + | setLinking(true); | |
| 238 | + | }} | |
| 239 | + | /> | |
| 240 | + | <SheetAction | |
| 241 | + | icon={Copy} | |
| 242 | + | label="Copy link" | |
| 243 | + | onPress={() => { | |
| 244 | + | setMore(false); | |
| 245 | + | void Clipboard.setStringAsync(siteUrl(server, sitePath)); | |
| 246 | + | }} | |
| 247 | + | /> | |
| 248 | + | <SheetAction | |
| 249 | + | icon={ExternalLink} | |
| 250 | + | label="Open on g1t" | |
| 251 | + | onPress={() => { | |
| 252 | + | setMore(false); | |
| 253 | + | void WebBrowser.openBrowserAsync(siteUrl(server, sitePath)); | |
| 254 | + | }} | |
| 255 | + | /> | |
| 256 | + | </Sheet> | |
| 257 | + | ||
| 258 | + | <Sheet visible={picked !== null} onClose={() => setPicked(null)}> | |
| 259 | + | {picked ? ( | |
| 260 | + | <> | |
| 261 | + | <Text size="heading" style={{ paddingHorizontal: space.lg }}> | |
| 262 | + | {picked.path} | |
| 263 | + | </Text> | |
| 264 | + | <SheetAction | |
| 265 | + | icon={FolderGit2} | |
| 266 | + | label="Open the repository" | |
| 267 | + | onPress={() => { | |
| 268 | + | setPicked(null); | |
| 269 | + | openRepo(picked.path); | |
| 270 | + | }} | |
| 271 | + | /> | |
| 272 | + | {picked.role !== "origin" ? ( | |
| 273 | + | <SheetAction | |
| 274 | + | icon={Unlink} | |
| 275 | + | label="Unlink it" | |
| 276 | + | danger | |
| 277 | + | onPress={() => { | |
| 278 | + | setPicked(null); | |
| 279 | + | actions.unlink.mutate(picked.path, { onError: failed("Could not unlink it") }); | |
| 280 | + | }} | |
| 281 | + | /> | |
| 282 | + | ) : null} | |
| 283 | + | </> | |
| 284 | + | ) : null} | |
| 285 | + | </Sheet> | |
| 286 | + | ||
| 287 | + | <LinkSheet visible={linking} linked={linked} onClose={() => setLinking(false)} onPick={link} /> | |
| 288 | + | </Screen> | |
| 289 | + | ); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /** Picks a repository of the workspace to link as a target. */ | |
| 293 | + | function LinkSheet({ visible, linked, onClose, onPick }: { visible: boolean; linked: Set<string>; onClose: () => void; onPick: (path: string) => void }) { | |
| 294 | + | const colors = useColors(); | |
| 295 | + | const { height } = useWindowDimensions(); | |
| 296 | + | const [search, setSearch] = useState(""); | |
| 297 | + | const repos = useRepos(visible ? search.trim() : ""); | |
| 298 | + | const choices = (repos.data ?? []).filter((repo) => !linked.has(`${repo.namespace}/${repo.name}`.toLowerCase())); | |
| 299 | + | return ( | |
| 300 | + | <Sheet visible={visible} onClose={onClose}> | |
| 301 | + | <View style={{ paddingHorizontal: space.lg, gap: space.sm }}> | |
| 302 | + | <Text size="heading">Link a repository</Text> | |
| 303 | + | <Text size="small" tone="muted"> | |
| 304 | + | As a target: where the work is meant to land. It closes once it has landed in every target. | |
| 305 | + | </Text> | |
| 306 | + | <View style={[styles.search, { borderColor: colors.lineStrong, backgroundColor: colors.bg }]}> | |
| 307 | + | <Search size={16} color={colors.faint} /> | |
| 308 | + | <TextInput | |
| 309 | + | value={search} | |
| 310 | + | onChangeText={setSearch} | |
| 311 | + | placeholder="Find a repository" | |
| 312 | + | placeholderTextColor={colors.faint} | |
| 313 | + | autoCapitalize="none" | |
| 314 | + | autoCorrect={false} | |
| 315 | + | style={{ flex: 1, color: colors.fg, fontSize: 15, paddingVertical: space.sm }} | |
| 316 | + | /> | |
| 317 | + | </View> | |
| 318 | + | </View> | |
| 319 | + | <ScrollView style={{ maxHeight: height * 0.5 }} keyboardShouldPersistTaps="handled"> | |
| 320 | + | {repos.isPending ? ( | |
| 321 | + | <Loading /> | |
| 322 | + | ) : choices.length === 0 ? ( | |
| 323 | + | <Text tone="muted" style={{ padding: space.lg }}> | |
| 324 | + | {search ? "Nothing matches." : "Every repository you can see is linked already."} | |
| 325 | + | </Text> | |
| 326 | + | ) : ( | |
| 327 | + | choices.map((repo) => ( | |
| 328 | + | <Row | |
| 329 | + | key={repo.id} | |
| 330 | + | leading={<FolderGit2 size={20} color={colors.muted} />} | |
| 331 | + | title={repo.name} | |
| 332 | + | subtitle={`${repo.namespace}/${repo.name}`} | |
| 333 | + | chevron={false} | |
| 334 | + | trailing={<Plus size={18} color={colors.accent} />} | |
| 335 | + | onPress={() => onPick(`${repo.namespace}/${repo.name}`)} | |
| 336 | + | /> | |
| 337 | + | )) | |
| 338 | + | )} | |
| 339 | + | </ScrollView> | |
| 340 | + | </Sheet> | |
| 341 | + | ); | |
| 342 | + | } | |
| 343 | + | ||
| 344 | + | const styles = StyleSheet.create({ | |
| 345 | + | content: { padding: space.lg, gap: space.lg, paddingBottom: space.xl }, | |
| 346 | + | wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }, | |
| 347 | + | search: { flexDirection: "row", alignItems: "center", gap: space.sm, borderWidth: 1, borderRadius: radius.md, paddingHorizontal: space.md }, | |
| 348 | + | }); |
| 1 | + | import { TabStack } from "@/components/stack"; | |
| 2 | + | ||
| 3 | + | export default TabStack; |
| 1 | + | import { Stack, router, useLocalSearchParams, useNavigation } from "expo-router"; | |
| 2 | + | import { Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native"; | |
| 3 | + | import { useEffect, useMemo, useState } from "react"; | |
| 4 | + | import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native"; | |
| 5 | + | ||
| 6 | + | import { Sheet } from "@/components/chat/sheet"; | |
| 7 | + | import { Segments } from "@/components/segments"; | |
| 8 | + | import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; | |
| 9 | + | import { Chip, ItemRow, roleLabel } from "@/components/work"; | |
| 10 | + | import { useRepos } from "@/lib/code"; | |
| 11 | + | import { useSignedIn } from "@/lib/session"; | |
| 12 | + | import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work"; | |
| 13 | + | import { radius, space, useColors } from "@/theme"; | |
| 14 | + | ||
| 15 | + | type Params = { workspace?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string }; | |
| 16 | + | ||
| 17 | + | /** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */ | |
| 18 | + | function filterOf(params: Params): WorkFilter { | |
| 19 | + | const state = params.state === "closed" || params.state === "all" ? params.state : "open"; | |
| 20 | + | const role = params.role === "origin" || params.role === "target" || params.role === "touched" ? params.role : undefined; | |
| 21 | + | const milestone = Number(params.milestone); | |
| 22 | + | return { | |
| 23 | + | state, | |
| 24 | + | label: params.label || undefined, | |
| 25 | + | milestone: Number.isInteger(milestone) && milestone > 0 ? milestone : undefined, | |
| 26 | + | assignee: params.assignee || undefined, | |
| 27 | + | repo: params.repo || undefined, | |
| 28 | + | role, | |
| 29 | + | }; | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | /** | |
| 33 | + | * Work: the workspace's work items, newest first, for every member, | |
| 34 | + | * Code or not. Filters are in a sheet (the ⚙ in the header, the chips | |
| 35 | + | * above the list, or Work in the bar again); pull down to read again, | |
| 36 | + | * and the list loads more as it nears its end. | |
| 37 | + | */ | |
| 38 | + | export default function WorkList() { | |
| 39 | + | const params = useLocalSearchParams<Params>(); | |
| 40 | + | const { workspace, me } = useSignedIn(); | |
| 41 | + | const slug = params.workspace ?? workspace?.slug; | |
| 42 | + | const colors = useColors(); | |
| 43 | + | const linked = useMemo(() => filterOf(params), [params.state, params.label, params.milestone, params.assignee, params.repo, params.role]); | |
| 44 | + | const [filter, setFilter] = useState<WorkFilter>(linked); | |
| 45 | + | useEffect(() => setFilter(linked), [linked]); | |
| 46 | + | const [filtering, setFiltering] = useState(false); | |
| 47 | + | const items = useWorkItems(slug, filter); | |
| 48 | + | const milestones = useMilestones(filter.milestone ? slug : undefined); | |
| 49 | + | ||
| 50 | + | // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu. | |
| 51 | + | const navigation = useNavigation(); | |
| 52 | + | useEffect(() => { | |
| 53 | + | const tabs = navigation.getParent(); | |
| 54 | + | if (!tabs) return; | |
| 55 | + | return tabs.addListener("tabPress" as never, () => { | |
| 56 | + | if (navigation.isFocused()) setFiltering(true); | |
| 57 | + | }); | |
| 58 | + | }, [navigation]); | |
| 59 | + | ||
| 60 | + | const active = [ | |
| 61 | + | filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null, | |
| 62 | + | filter.label ? { key: "label", label: filter.label } : null, | |
| 63 | + | filter.milestone ? { key: "milestone", label: milestones.data?.find((one) => one.number === filter.milestone)?.title ?? `Milestone ${filter.milestone}` } : null, | |
| 64 | + | filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null, | |
| 65 | + | ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null); | |
| 66 | + | ||
| 67 | + | const header = ( | |
| 68 | + | <Stack.Screen | |
| 69 | + | options={{ | |
| 70 | + | title: "Work", | |
| 71 | + | headerRight: () => ( | |
| 72 | + | <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}> | |
| 73 | + | <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} /> | |
| 74 | + | </Pressable> | |
| 75 | + | ), | |
| 76 | + | }} | |
| 77 | + | /> | |
| 78 | + | ); | |
| 79 | + | ||
| 80 | + | if (!slug) { | |
| 81 | + | return ( | |
| 82 | + | <Screen> | |
| 83 | + | {header} | |
| 84 | + | <Empty icon={ListTodo} title="No workspace yet" body="Create or join a workspace on g1t, then come back." /> | |
| 85 | + | </Screen> | |
| 86 | + | ); | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | return ( | |
| 90 | + | <Screen> | |
| 91 | + | {header} | |
| 92 | + | <View style={[styles.bar, { borderBottomColor: colors.line }]}> | |
| 93 | + | <Segments | |
| 94 | + | value={filter.state} | |
| 95 | + | onChange={(state) => setFilter({ ...filter, state })} | |
| 96 | + | options={[ | |
| 97 | + | { value: "open", label: "Open" }, | |
| 98 | + | { value: "closed", label: "Closed" }, | |
| 99 | + | { value: "all", label: "All" }, | |
| 100 | + | ]} | |
| 101 | + | /> | |
| 102 | + | {active.length ? ( | |
| 103 | + | <View style={styles.chips}> | |
| 104 | + | {active.map((one) => ( | |
| 105 | + | <Chip | |
| 106 | + | key={one.key} | |
| 107 | + | label={one.label} | |
| 108 | + | tone="accent" | |
| 109 | + | leading={<X size={12} color={colors.accent} />} | |
| 110 | + | onPress={() => setFilter({ ...filter, [one.key]: undefined, ...(one.key === "repo" ? { role: undefined } : {}) })} | |
| 111 | + | /> | |
| 112 | + | ))} | |
| 113 | + | </View> | |
| 114 | + | ) : null} | |
| 115 | + | </View> | |
| 116 | + | ||
| 117 | + | {items.isPending ? ( | |
| 118 | + | <Loading /> | |
| 119 | + | ) : items.error ? ( | |
| 120 | + | <ErrorView error={items.error} retry={() => items.refetch()} /> | |
| 121 | + | ) : ( | |
| 122 | + | <FlatList | |
| 123 | + | data={items.items} | |
| 124 | + | keyExtractor={(item) => item.id} | |
| 125 | + | refreshControl={<RefreshControl refreshing={items.isRefetching && !items.isFetchingNextPage} onRefresh={() => items.refetch()} tintColor={colors.accent} />} | |
| 126 | + | onEndReached={() => items.hasNextPage && !items.isFetchingNextPage && items.fetchNextPage()} | |
| 127 | + | onEndReachedThreshold={0.5} | |
| 128 | + | contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }} | |
| 129 | + | ListFooterComponent={items.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null} | |
| 130 | + | ListEmptyComponent={ | |
| 131 | + | <Empty | |
| 132 | + | icon={ListTodo} | |
| 133 | + | title={active.length ? "Nothing matches" : filter.state === "closed" ? "Nothing closed yet" : "No open work items"} | |
| 134 | + | body={active.length ? "Try fewer filters." : "Work items are any tracked work: a feature, a bug, a chore."} | |
| 135 | + | /> | |
| 136 | + | } | |
| 137 | + | renderItem={({ item }) => ( | |
| 138 | + | <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} /> | |
| 139 | + | )} | |
| 140 | + | /> | |
| 141 | + | )} | |
| 142 | + | ||
| 143 | + | <Pressable | |
| 144 | + | accessibilityRole="button" | |
| 145 | + | accessibilityLabel="New work item" | |
| 146 | + | onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })} | |
| 147 | + | style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]} | |
| 148 | + | > | |
| 149 | + | <Plus size={20} color={colors.onAccent} strokeWidth={2.5} /> | |
| 150 | + | <Text style={{ color: colors.onAccent, fontWeight: "600" }}>New</Text> | |
| 151 | + | </Pressable> | |
| 152 | + | ||
| 153 | + | <Filters visible={filtering} workspace={slug} filter={filter} me={me.username} onClose={() => setFiltering(false)} onChange={setFilter} /> | |
| 154 | + | </Screen> | |
| 155 | + | ); | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | /** The list's filters, in a sheet: state, assigned to you, label, milestone, repository and its role. */ | |
| 159 | + | function Filters({ | |
| 160 | + | visible, | |
| 161 | + | workspace, | |
| 162 | + | filter, | |
| 163 | + | me, | |
| 164 | + | onClose, | |
| 165 | + | onChange, | |
| 166 | + | }: { | |
| 167 | + | visible: boolean; | |
| 168 | + | workspace: string; | |
| 169 | + | filter: WorkFilter; | |
| 170 | + | me: string; | |
| 171 | + | onClose: () => void; | |
| 172 | + | onChange: (filter: WorkFilter) => void; | |
| 173 | + | }) { | |
| 174 | + | const colors = useColors(); | |
| 175 | + | const { height } = useWindowDimensions(); | |
| 176 | + | const labels = useLabels(visible ? workspace : undefined); | |
| 177 | + | const milestones = useMilestones(visible ? workspace : undefined); | |
| 178 | + | const repos = useRepos(""); | |
| 179 | + | const set = (change: Partial<WorkFilter>) => onChange({ ...filter, ...change }); | |
| 180 | + | const toggle = <K extends keyof WorkFilter>(key: K, value: WorkFilter[K]) => set({ [key]: filter[key] === value ? undefined : value } as Partial<WorkFilter>); | |
| 181 | + | ||
| 182 | + | return ( | |
| 183 | + | <Sheet visible={visible} onClose={onClose}> | |
| 184 | + | <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}> | |
| 185 | + | <Text size="heading">Filters</Text> | |
| 186 | + | <Segments | |
| 187 | + | value={filter.state} | |
| 188 | + | onChange={(state) => set({ state })} | |
| 189 | + | options={[ | |
| 190 | + | { value: "open", label: "Open" }, | |
| 191 | + | { value: "closed", label: "Closed" }, | |
| 192 | + | { value: "all", label: "All" }, | |
| 193 | + | ]} | |
| 194 | + | /> | |
| 195 | + | <View style={{ marginHorizontal: -space.lg }}> | |
| 196 | + | <Row | |
| 197 | + | title="Assigned to me" | |
| 198 | + | chevron={false} | |
| 199 | + | trailing={<Check on={filter.assignee === me} />} | |
| 200 | + | onPress={() => toggle("assignee", me)} | |
| 201 | + | /> | |
| 202 | + | </View> | |
| 203 | + | <Group title="Label" empty={labels.isPending ? "…" : "No labels yet"}> | |
| 204 | + | {(labels.data ?? []).map((label) => ( | |
| 205 | + | <Chip | |
| 206 | + | key={label.name} | |
| 207 | + | label={label.name} | |
| 208 | + | tone={filter.label === label.name ? "accent" : "neutral"} | |
| 209 | + | leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />} | |
| 210 | + | onPress={() => toggle("label", label.name)} | |
| 211 | + | /> | |
| 212 | + | ))} | |
| 213 | + | </Group> | |
| 214 | + | <Group title="Milestone" empty={milestones.isPending ? "…" : "No open milestones"}> | |
| 215 | + | {(milestones.data ?? []).map((milestone) => ( | |
| 216 | + | <Chip | |
| 217 | + | key={milestone.number} | |
| 218 | + | label={milestone.title} | |
| 219 | + | tone={filter.milestone === milestone.number ? "accent" : "neutral"} | |
| 220 | + | onPress={() => toggle("milestone", milestone.number)} | |
| 221 | + | /> | |
| 222 | + | ))} | |
| 223 | + | </Group> | |
| 224 | + | <Group title="Repository" empty={repos.isPending ? "…" : "No repositories you can see"}> | |
| 225 | + | {(repos.data ?? []).map((repo) => ( | |
| 226 | + | <Chip | |
| 227 | + | key={repo.id} | |
| 228 | + | label={repo.name} | |
| 229 | + | tone={filter.repo === repo.name ? "accent" : "neutral"} | |
| 230 | + | onPress={() => set({ repo: filter.repo === repo.name ? undefined : repo.name, role: undefined })} | |
| 231 | + | /> | |
| 232 | + | ))} | |
| 233 | + | </Group> | |
| 234 | + | {filter.repo ? ( | |
| 235 | + | <View style={{ gap: space.sm }}> | |
| 236 | + | <Text size="small" tone="muted"> | |
| 237 | + | Linked to {filter.repo} as | |
| 238 | + | </Text> | |
| 239 | + | <Segments | |
| 240 | + | value={filter.role ?? "any"} | |
| 241 | + | onChange={(role) => set({ role: role === "any" ? undefined : (role as LinkRole) })} | |
| 242 | + | options={[ | |
| 243 | + | { value: "any", label: "Any" }, | |
| 244 | + | { value: "origin", label: "Origin" }, | |
| 245 | + | { value: "target", label: "Target" }, | |
| 246 | + | { value: "touched", label: "Touched" }, | |
| 247 | + | ]} | |
| 248 | + | /> | |
| 249 | + | </View> | |
| 250 | + | ) : null} | |
| 251 | + | <View style={{ flexDirection: "row", gap: space.sm }}> | |
| 252 | + | <View style={{ flex: 1 }}> | |
| 253 | + | <Button title="Clear" variant="secondary" onPress={() => onChange({ state: "open" })} /> | |
| 254 | + | </View> | |
| 255 | + | <View style={{ flex: 1 }}> | |
| 256 | + | <Button title="Done" onPress={onClose} /> | |
| 257 | + | </View> | |
| 258 | + | </View> | |
| 259 | + | </ScrollView> | |
| 260 | + | </Sheet> | |
| 261 | + | ); | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | function Group({ title, empty, children }: { title: string; empty: string; children: React.ReactNode[] }) { | |
| 265 | + | return ( | |
| 266 | + | <View style={{ gap: space.sm }}> | |
| 267 | + | <Text size="small" tone="muted"> | |
| 268 | + | {title} | |
| 269 | + | </Text> | |
| 270 | + | {children.length ? ( | |
| 271 | + | <View style={styles.chips}>{children}</View> | |
| 272 | + | ) : ( | |
| 273 | + | <Text size="small" tone="faint"> | |
| 274 | + | {empty} | |
| 275 | + | </Text> | |
| 276 | + | )} | |
| 277 | + | </View> | |
| 278 | + | ); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | function Check({ on }: { on: boolean }) { | |
| 282 | + | const colors = useColors(); | |
| 283 | + | return ( | |
| 284 | + | <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}> | |
| 285 | + | {on ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null} | |
| 286 | + | </View> | |
| 287 | + | ); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | const styles = StyleSheet.create({ | |
| 291 | + | bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, | |
| 292 | + | chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, | |
| 293 | + | fab: { | |
| 294 | + | position: "absolute", | |
| 295 | + | right: space.lg, | |
| 296 | + | bottom: space.lg, | |
| 297 | + | flexDirection: "row", | |
| 298 | + | alignItems: "center", | |
| 299 | + | gap: space.xs, | |
| 300 | + | paddingHorizontal: space.lg, | |
| 301 | + | height: 48, | |
| 302 | + | borderRadius: radius.full, | |
| 303 | + | elevation: 4, | |
| 304 | + | shadowColor: "#000", | |
| 305 | + | shadowOpacity: 0.25, | |
| 306 | + | shadowRadius: 8, | |
| 307 | + | shadowOffset: { width: 0, height: 2 }, | |
| 308 | + | }, | |
| 309 | + | check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" }, | |
| 310 | + | }); |
| 1 | + | import { Stack, router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import { useHeaderHeight } from "expo-router/react-navigation"; | |
| 3 | + | import { Check } from "lucide-react-native"; | |
| 4 | + | import { useRef, useState } from "react"; | |
| 5 | + | import { KeyboardAvoidingView, Pressable, ScrollView, StyleSheet, TextInput, View } from "react-native"; | |
| 6 | + | ||
| 7 | + | import { Button, Text } from "@/components/ui"; | |
| 8 | + | import { Chip } from "@/components/work"; | |
| 9 | + | import { useRepos } from "@/lib/code"; | |
| 10 | + | import { useSignedIn } from "@/lib/session"; | |
| 11 | + | import { useLabels, useOpenWorkItem } from "@/lib/work"; | |
| 12 | + | import { radius, space, useColors } from "@/theme"; | |
| 13 | + | ||
| 14 | + | /** | |
| 15 | + | * A new work item: a title, what it is about, its labels, and the | |
| 16 | + | * repositories where it is meant to land (none is fine: it can be linked | |
| 17 | + | * later). Opened from a repository's Work, that repository is its origin. | |
| 18 | + | * Made for the on-screen keyboard: the title's return goes on to the | |
| 19 | + | * description, and Create stays in the header above the keyboard. | |
| 20 | + | */ | |
| 21 | + | export default function NewWorkItem() { | |
| 22 | + | const params = useLocalSearchParams<{ workspace?: string; repo?: string }>(); | |
| 23 | + | const { workspace: current } = useSignedIn(); | |
| 24 | + | const workspace = params.workspace ?? current?.slug ?? ""; | |
| 25 | + | const origin = params.repo ? `${workspace}/${params.repo}` : undefined; | |
| 26 | + | const colors = useColors(); | |
| 27 | + | const headerHeight = useHeaderHeight(); | |
| 28 | + | const [title, setTitle] = useState(""); | |
| 29 | + | const [body, setBody] = useState(""); | |
| 30 | + | const [labels, setLabels] = useState<string[]>([]); | |
| 31 | + | const [targets, setTargets] = useState<string[]>([]); | |
| 32 | + | const bodyField = useRef<TextInput>(null); | |
| 33 | + | const known = useLabels(workspace); | |
| 34 | + | const repos = useRepos(""); | |
| 35 | + | const open = useOpenWorkItem(workspace); | |
| 36 | + | const ready = title.trim().length > 0 && !open.isPending; | |
| 37 | + | ||
| 38 | + | const toggle = (list: string[], value: string) => (list.includes(value) ? list.filter((one) => one !== value) : [...list, value]); | |
| 39 | + | const create = () => { | |
| 40 | + | if (!ready) return; | |
| 41 | + | open.mutate( | |
| 42 | + | { title: title.trim(), body: body.trim(), labels, targets, ...(origin ? { repo: origin } : {}) }, | |
| 43 | + | { onSuccess: (item) => router.replace({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } }) }, | |
| 44 | + | ); | |
| 45 | + | }; | |
| 46 | + | ||
| 47 | + | return ( | |
| 48 | + | <View style={{ flex: 1, backgroundColor: colors.bg }}> | |
| 49 | + | <Stack.Screen | |
| 50 | + | options={{ | |
| 51 | + | title: "New work item", | |
| 52 | + | headerRight: () => ( | |
| 53 | + | <Pressable accessibilityRole="button" accessibilityLabel="Create" hitSlop={10} disabled={!ready} onPress={create}> | |
| 54 | + | <Text style={{ color: ready ? colors.accent : colors.faint, fontWeight: "700" }}>Create</Text> | |
| 55 | + | </Pressable> | |
| 56 | + | ), | |
| 57 | + | }} | |
| 58 | + | /> | |
| 59 | + | <KeyboardAvoidingView style={{ flex: 1 }} behavior="padding" keyboardVerticalOffset={headerHeight}> | |
| 60 | + | <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled" keyboardDismissMode="interactive"> | |
| 61 | + | {origin ? ( | |
| 62 | + | <Text size="small" tone="muted"> | |
| 63 | + | Filed from {params.repo}, in {workspace} | |
| 64 | + | </Text> | |
| 65 | + | ) : ( | |
| 66 | + | <Text size="small" tone="muted"> | |
| 67 | + | In {workspace} | |
| 68 | + | </Text> | |
| 69 | + | )} | |
| 70 | + | <TextInput | |
| 71 | + | autoFocus | |
| 72 | + | value={title} | |
| 73 | + | onChangeText={setTitle} | |
| 74 | + | placeholder="Title" | |
| 75 | + | placeholderTextColor={colors.faint} | |
| 76 | + | returnKeyType="next" | |
| 77 | + | submitBehavior="submit" | |
| 78 | + | onSubmitEditing={() => bodyField.current?.focus()} | |
| 79 | + | style={[styles.title, { color: colors.fg, borderBottomColor: colors.line }]} | |
| 80 | + | /> | |
| 81 | + | <TextInput | |
| 82 | + | ref={bodyField} | |
| 83 | + | value={body} | |
| 84 | + | onChangeText={setBody} | |
| 85 | + | placeholder="What is it about? Markdown works. Say what done means, under a “Definition of done” heading." | |
| 86 | + | placeholderTextColor={colors.faint} | |
| 87 | + | multiline | |
| 88 | + | textAlignVertical="top" | |
| 89 | + | style={[styles.body, { color: colors.fg, backgroundColor: colors.surface, borderColor: colors.lineStrong }]} | |
| 90 | + | /> | |
| 91 | + | ||
| 92 | + | <Group title="Labels" empty={known.isPending ? "…" : "This workspace has no labels yet."}> | |
| 93 | + | {(known.data ?? []).map((label) => ( | |
| 94 | + | <Chip | |
| 95 | + | key={label.name} | |
| 96 | + | label={label.name} | |
| 97 | + | tone={labels.includes(label.name) ? "accent" : "neutral"} | |
| 98 | + | leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />} | |
| 99 | + | onPress={() => setLabels(toggle(labels, label.name))} | |
| 100 | + | /> | |
| 101 | + | ))} | |
| 102 | + | </Group> | |
| 103 | + | ||
| 104 | + | <Group | |
| 105 | + | title="Where it lands" | |
| 106 | + | hint="The repositories the work is meant to land in. It closes once it has landed in each. Leave them out to pick later." | |
| 107 | + | empty={repos.isPending ? "…" : "No repositories you can see."} | |
| 108 | + | > | |
| 109 | + | {(repos.data ?? []).map((repo) => { | |
| 110 | + | const path = `${repo.namespace}/${repo.name}`; | |
| 111 | + | const on = targets.includes(path); | |
| 112 | + | return ( | |
| 113 | + | <Chip | |
| 114 | + | key={repo.id} | |
| 115 | + | label={repo.name} | |
| 116 | + | tone={on ? "accent" : "neutral"} | |
| 117 | + | leading={on ? <Check size={12} color={colors.accent} /> : null} | |
| 118 | + | onPress={() => setTargets(toggle(targets, path))} | |
| 119 | + | /> | |
| 120 | + | ); | |
| 121 | + | })} | |
| 122 | + | </Group> | |
| 123 | + | ||
| 124 | + | {open.error ? <Text tone="danger">{open.error.message}</Text> : null} | |
| 125 | + | <Button title="Create" busy={open.isPending} disabled={!ready} onPress={create} /> | |
| 126 | + | </ScrollView> | |
| 127 | + | </KeyboardAvoidingView> | |
| 128 | + | </View> | |
| 129 | + | ); | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | function Group({ title, hint, empty, children }: { title: string; hint?: string; empty: string; children: React.ReactNode[] }) { | |
| 133 | + | return ( | |
| 134 | + | <View style={{ gap: space.sm }}> | |
| 135 | + | <Text size="small" tone="muted" style={{ fontWeight: "600" }}> | |
| 136 | + | {title} | |
| 137 | + | </Text> | |
| 138 | + | {hint ? ( | |
| 139 | + | <Text size="small" tone="faint"> | |
| 140 | + | {hint} | |
| 141 | + | </Text> | |
| 142 | + | ) : null} | |
| 143 | + | {children.length ? ( | |
| 144 | + | <View style={styles.chips}>{children}</View> | |
| 145 | + | ) : ( | |
| 146 | + | <Text size="small" tone="faint"> | |
| 147 | + | {empty} | |
| 148 | + | </Text> | |
| 149 | + | )} | |
| 150 | + | </View> | |
| 151 | + | ); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | const styles = StyleSheet.create({ | |
| 155 | + | content: { padding: space.lg, gap: space.lg, paddingBottom: space.xxl }, | |
| 156 | + | title: { fontSize: 20, fontWeight: "600", paddingVertical: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, | |
| 157 | + | body: { minHeight: 160, borderWidth: 1, borderRadius: radius.md, padding: space.md, fontSize: 15, lineHeight: 21 }, | |
| 158 | + | chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, | |
| 159 | + | }); |
| 1 | − | import { routeFor, sitePathOf } from "@/lib/links"; | |
| 1 | + | import { linkPathOf, routeFor } from "@/lib/links"; | |
| 2 | 2 | ||
| 3 | 3 | /** | |
| 4 | 4 | * Where a link that opens the app goes. `g1t://` links name a site path, | |
| ⋯ | |||
| 9 | 9 | */ | |
| 10 | 10 | export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { | |
| 11 | 11 | try { | |
| 12 | − | const sitePath = sitePathOf(path) ?? sitePathOfWeb(path) ?? path; | |
| 12 | + | const sitePath = linkPathOf(path) ?? path; | |
| 13 | 13 | const route = routeFor(sitePath); | |
| 14 | 14 | return route ?? `/open?path=${encodeURIComponent(sitePath)}`; | |
| 15 | 15 | } catch { | |
| 16 | 16 | return "/"; | |
| 17 | 17 | } | |
| 18 | − | } | |
| 19 | − | ||
| 20 | − | function sitePathOfWeb(link: string): string | null { | |
| 21 | − | if (!/^https?:\/\//i.test(link)) return null; | |
| 22 | − | const url = new URL(link); | |
| 23 | − | return url.pathname + url.search; | |
| 24 | 18 | } | |
| 56 | 56 | <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}> | |
| 57 | 57 | <Stack.Protected guard={signedIn}> | |
| 58 | 58 | <Stack.Screen name="(app)" /> | |
| 59 | + | <Stack.Screen name="ref" /> | |
| 59 | 60 | </Stack.Protected> | |
| 60 | 61 | <Stack.Protected guard={!signedIn}> | |
| 61 | 62 | <Stack.Screen name="sign-in" /> |
| 1 | + | import { router, useLocalSearchParams } from "expo-router"; | |
| 2 | + | import * as WebBrowser from "expo-web-browser"; | |
| 3 | + | import { ExternalLink, SearchX } from "lucide-react-native"; | |
| 4 | + | import { useEffect } from "react"; | |
| 5 | + | ||
| 6 | + | import { Button, Empty, Loading, Screen } from "@/components/ui"; | |
| 7 | + | import { ApiError } from "@/lib/client"; | |
| 8 | + | import { type RefContext, refSitePath, targetRoute } from "@/lib/links"; | |
| 9 | + | import { siteUrl } from "@/lib/server"; | |
| 10 | + | import { useSignedIn } from "@/lib/session"; | |
| 11 | + | import { useRefTarget } from "@/lib/work"; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * `#N` read in a repository or a workspace: g1t says what it names (a | |
| 15 | + | * work item, or a pull request), and that opens in its place. Old links | |
| 16 | + | * to a repository's issues come here too, since an old issue's number | |
| 17 | + | * still names the work item it became. | |
| 18 | + | */ | |
| 19 | + | export default function Ref() { | |
| 20 | + | const params = useLocalSearchParams<{ repo?: string; workspace?: string; number: string }>(); | |
| 21 | + | const { server } = useSignedIn(); | |
| 22 | + | const number = Number(params.number); | |
| 23 | + | const context: RefContext | null = params.repo ? { repo: params.repo } : params.workspace ? { workspace: params.workspace } : null; | |
| 24 | + | const target = useRefTarget(context, number); | |
| 25 | + | const route = target.data ? targetRoute(target.data) : null; | |
| 26 | + | ||
| 27 | + | const leave = () => (router.canGoBack() ? router.back() : router.replace("/")); | |
| 28 | + | const onSite = () => { | |
| 29 | + | if (!context) return leave(); | |
| 30 | + | void WebBrowser.openBrowserAsync(siteUrl(server, refSitePath(context, number))).finally(leave); | |
| 31 | + | }; | |
| 32 | + | ||
| 33 | + | useEffect(() => { | |
| 34 | + | if (route) router.replace(route as never); | |
| 35 | + | }, [route]); | |
| 36 | + | ||
| 37 | + | // A g1t that cannot say (or a repository the app cannot read): the site reads the link itself. | |
| 38 | + | const unanswered = target.error instanceof ApiError && (target.error.code === "not_found" || target.error.code === "forbidden"); | |
| 39 | + | useEffect(() => { | |
| 40 | + | if (unanswered) onSite(); | |
| 41 | + | }, [unanswered]); | |
| 42 | + | ||
| 43 | + | if (!context || !Number.isInteger(number)) { | |
| 44 | + | return ( | |
| 45 | + | <Screen safe> | |
| 46 | + | <Empty icon={SearchX} title="Not a reference" action={<Button title="Back" variant="secondary" onPress={leave} />} /> | |
| 47 | + | </Screen> | |
| 48 | + | ); | |
| 49 | + | } | |
| 50 | + | if (target.data && !route) { | |
| 51 | + | const where = "repo" in context ? context.repo : context.workspace; | |
| 52 | + | return ( | |
| 53 | + | <Screen safe> | |
| 54 | + | <Empty | |
| 55 | + | icon={SearchX} | |
| 56 | + | title={`Nothing is #${number} in ${where}`} | |
| 57 | + | body="It may have been deleted, or be somewhere you cannot see." | |
| 58 | + | action={<Button title="Back" variant="secondary" onPress={leave} />} | |
| 59 | + | /> | |
| 60 | + | </Screen> | |
| 61 | + | ); | |
| 62 | + | } | |
| 63 | + | if (target.error && !unanswered) { | |
| 64 | + | return ( | |
| 65 | + | <Screen safe> | |
| 66 | + | <Empty | |
| 67 | + | icon={SearchX} | |
| 68 | + | title={`Could not find #${number}`} | |
| 69 | + | body={target.error.message} | |
| 70 | + | action={<Button title="Open on g1t" variant="secondary" icon={ExternalLink} onPress={onSite} />} | |
| 71 | + | /> | |
| 72 | + | </Screen> | |
| 73 | + | ); | |
| 74 | + | } | |
| 75 | + | return ( | |
| 76 | + | <Screen safe> | |
| 77 | + | <Loading /> | |
| 78 | + | </Screen> | |
| 79 | + | ); | |
| 80 | + | } |
| 1 | 1 | import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown"; | |
| 2 | 2 | import { router } from "expo-router"; | |
| 3 | 3 | import * as WebBrowser from "expo-web-browser"; | |
| 4 | − | import { Fragment, useMemo } from "react"; | |
| 4 | + | import { Fragment, createContext, useContext, useMemo } from "react"; | |
| 5 | 5 | import { ScrollView, StyleSheet, Text, View } from "react-native"; | |
| 6 | 6 | ||
| 7 | − | import { routeFor } from "@/lib/links"; | |
| 7 | + | import { type RefContext, refContext, refRoute, routeFor } from "@/lib/links"; | |
| 8 | 8 | import { siteUrl } from "@/lib/server"; | |
| 9 | 9 | import { useSignedIn } from "@/lib/session"; | |
| 10 | 10 | import { fonts, radius, space, useColors } from "@/theme"; | |
| ⋯ | |||
| 15 | 15 | * message is HTML, links go only to the web, mail or a page on g1t, and | |
| 16 | 16 | * `@mentions`, `#channels` and `#123` references stand out. A link to a | |
| 17 | 17 | * page the app has opens there; any other opens in the browser. | |
| 18 | + | * | |
| 19 | + | * `#123` is read in `refs`, where the text was written: a repository for a | |
| 20 | + | * pull request's, the workspace for Chat's and a work item's. g1t says | |
| 21 | + | * what it names when it is tapped (app/ref.tsx). | |
| 18 | 22 | */ | |
| 19 | − | export function Markdown({ text, muted }: { text: string; muted?: boolean }) { | |
| 23 | + | export function Markdown({ text, muted, refs }: { text: string; muted?: boolean; refs?: RefContext }) { | |
| 20 | 24 | const parsed = useMemo(() => blocks(text), [text]); | |
| 21 | − | return ( | |
| 25 | + | const body = ( | |
| 22 | 26 | <View style={{ gap: space.xs }}> | |
| 23 | 27 | {parsed.map((block, index) => ( | |
| 24 | 28 | <BlockView key={index} block={block} muted={muted} /> | |
| 25 | 29 | ))} | |
| 26 | 30 | </View> | |
| 27 | 31 | ); | |
| 32 | + | return refs ? <RefsHere.Provider value={refs}>{body}</RefsHere.Provider> : body; | |
| 28 | 33 | } | |
| 29 | 34 | ||
| 35 | + | /** Where `#123` in the text being drawn is read; the workspace you are in when nothing says. */ | |
| 36 | + | const RefsHere = createContext<RefContext | null>(null); | |
| 37 | + | ||
| 30 | 38 | function useOpen() { | |
| 31 | 39 | const { server, workspace } = useSignedIn(); | |
| 32 | 40 | return (href: string) => { | |
| ⋯ | |||
| 118 | 126 | const colors = useColors(); | |
| 119 | 127 | const open = useOpen(); | |
| 120 | 128 | const { workspace } = useSignedIn(); | |
| 129 | + | const given = useContext(RefsHere); | |
| 130 | + | const here = given ?? (workspace ? { workspace: workspace.slug } : null); | |
| 121 | 131 | return ( | |
| 122 | 132 | <> | |
| 123 | 133 | {spans.map((span, index) => { | |
| ⋯ | |||
| 168 | 178 | ); | |
| 169 | 179 | case "ref": | |
| 170 | 180 | return ( | |
| 171 | − | <Text key={index} style={{ color: colors.accent }} onPress={() => span.repo && open(`/${span.repo}/issues/${span.number}`)}> | |
| 181 | + | <Text key={index} style={{ color: colors.accent }} onPress={() => here && router.push(refRoute(refContext(span.repo, here), span.number) as never)}> | |
| 172 | 182 | {span.repo ? `${span.repo}#${span.number}` : `#${span.number}`} | |
| 173 | 183 | </Text> | |
| 174 | 184 | ); | |
| 1 | − | import { CircleCheck, CircleDot, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native"; | |
| 1 | + | import { CircleCheck, CircleX, GitMerge, GitPullRequest, GitPullRequestClosed, GitPullRequestDraft, Loader } from "lucide-react-native"; | |
| 2 | 2 | import { ScrollView, StyleSheet, View } from "react-native"; | |
| 3 | 3 | ||
| 4 | + | import { Markdown } from "@/components/chat/markdown"; | |
| 4 | 5 | import { Badge, Text } from "@/components/ui"; | |
| 5 | − | import type { ApiChanges, ApiComment, ApiIssue, ApiPull, CheckStatus } from "@/lib/code"; | |
| 6 | + | import type { ApiChanges, ApiComment, ApiPull, CheckStatus } from "@/lib/code"; | |
| 7 | + | import type { RefContext } from "@/lib/links"; | |
| 6 | 8 | import { ago } from "@/lib/time"; | |
| 7 | 9 | import { fonts, radius, space, useColors } from "@/theme"; | |
| 8 | 10 | ||
| ⋯ | |||
| 13 | 15 | if (pull.status === "closed") return <GitPullRequestClosed size={20} color={colors.danger} />; | |
| 14 | 16 | if (pull.status === "draft") return <GitPullRequestDraft size={20} color={colors.muted} />; | |
| 15 | 17 | return <GitPullRequest size={20} color={colors.success} />; | |
| 16 | − | } | |
| 17 | − | ||
| 18 | − | export function IssueIcon({ issue }: { issue: Pick<ApiIssue, "state" | "reason"> }) { | |
| 19 | − | const colors = useColors(); | |
| 20 | − | if (issue.state === "open") return <CircleDot size={20} color={colors.success} />; | |
| 21 | − | return issue.reason === "not_planned" ? <CircleX size={20} color={colors.muted} /> : <CircleCheck size={20} color={colors.merged} />; | |
| 22 | 18 | } | |
| 23 | 19 | ||
| 24 | 20 | export function Checks({ status }: { status: CheckStatus }) { | |
| ⋯ | |||
| 34 | 30 | return <Badge label={pull.status[0].toUpperCase() + pull.status.slice(1)} tone={tone} />; | |
| 35 | 31 | } | |
| 36 | 32 | ||
| 37 | − | /** Markdown, shown as it was written until the app renders it. */ | |
| 38 | − | export function Body({ text }: { text: string | null }) { | |
| 33 | + | /** A description in Markdown; `#123` in it is read in `refs`. */ | |
| 34 | + | export function Body({ text, refs }: { text: string | null; refs: RefContext }) { | |
| 39 | 35 | const colors = useColors(); | |
| 40 | 36 | if (!text?.trim()) { | |
| 41 | 37 | return ( | |
| ⋯ | |||
| 46 | 42 | } | |
| 47 | 43 | return ( | |
| 48 | 44 | <View style={[styles.box, { borderColor: colors.line, backgroundColor: colors.surface }]}> | |
| 49 | − | <Text selectable>{text}</Text> | |
| 45 | + | <Markdown text={text} refs={refs} /> | |
| 50 | 46 | </View> | |
| 51 | 47 | ); | |
| 52 | 48 | } | |
| 53 | 49 | ||
| 54 | − | /** The conversation under an issue or pull request: comments, reviews and what happened. */ | |
| 55 | − | export function Timeline({ comments }: { comments: ApiComment[] }) { | |
| 50 | + | /** The conversation under a work item or pull request: comments, reviews and what happened. */ | |
| 51 | + | export function Timeline({ comments, refs }: { comments: ApiComment[]; refs: RefContext }) { | |
| 56 | 52 | const colors = useColors(); | |
| 57 | 53 | if (comments.length === 0) return null; | |
| 58 | 54 | return ( | |
| ⋯ | |||
| 78 | 74 | {comment.line ? `:${comment.line}` : ""} | |
| 79 | 75 | </Text> | |
| 80 | 76 | ) : null} | |
| 81 | − | <Text selectable>{comment.body}</Text> | |
| 77 | + | <Markdown text={comment.body} refs={refs} /> | |
| 82 | 78 | </View> | |
| 83 | 79 | ), | |
| 84 | 80 | )} | |
| 1 | + | import { CircleCheck, CircleDot, CircleX, Ellipsis, FolderGit2, MessageSquare, SendHorizontal } from "lucide-react-native"; | |
| 2 | + | import { type ReactNode, useEffect, useState } from "react"; | |
| 3 | + | import { ActivityIndicator, Keyboard, Pressable, StyleSheet, TextInput, View } from "react-native"; | |
| 4 | + | import { useSafeAreaInsets } from "react-native-safe-area-context"; | |
| 5 | + | ||
| 6 | + | import { Text } from "@/components/ui"; | |
| 7 | + | import { ago } from "@/lib/time"; | |
| 8 | + | import type { ApiItemRepo, ApiWorkItem, LinkRole } from "@/lib/work"; | |
| 9 | + | import { radius, space, useColors } from "@/theme"; | |
| 10 | + | ||
| 11 | + | /** A work item's state, as an icon in its colour: open, done, or closed as not planned. */ | |
| 12 | + | export function ItemIcon({ item, size = 20 }: { item: Pick<ApiWorkItem, "state" | "reason">; size?: number }) { | |
| 13 | + | const colors = useColors(); | |
| 14 | + | if (item.state === "open") return <CircleDot size={size} color={colors.success} accessibilityLabel="Open" />; | |
| 15 | + | return item.reason === "not_planned" ? ( | |
| 16 | + | <CircleX size={size} color={colors.muted} accessibilityLabel="Closed as not planned" /> | |
| 17 | + | ) : ( | |
| 18 | + | <CircleCheck size={size} color={colors.merged} accessibilityLabel="Closed" /> | |
| 19 | + | ); | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | export function stateLabel(item: Pick<ApiWorkItem, "state" | "reason">): string { | |
| 23 | + | if (item.state === "open") return "Open"; | |
| 24 | + | return item.reason === "not_planned" ? "Not planned" : "Closed"; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /** A small rounded tag: a label, a repository, a filter. */ | |
| 28 | + | export function Chip({ label, tone = "neutral", onPress, leading }: { label: string; tone?: "neutral" | "accent"; onPress?: () => void; leading?: ReactNode }) { | |
| 29 | + | const colors = useColors(); | |
| 30 | + | const on = tone === "accent"; | |
| 31 | + | return ( | |
| 32 | + | <Pressable | |
| 33 | + | onPress={onPress} | |
| 34 | + | disabled={!onPress} | |
| 35 | + | accessibilityRole={onPress ? "button" : undefined} | |
| 36 | + | style={({ pressed }) => [ | |
| 37 | + | styles.chip, | |
| 38 | + | { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accentLow : pressed ? colors.raised : "transparent" }, | |
| 39 | + | ]} | |
| 40 | + | > | |
| 41 | + | {leading} | |
| 42 | + | <Text size="small" numberOfLines={1} style={{ color: on ? colors.accent : colors.fgSoft, fontWeight: "500", maxWidth: 180 }}> | |
| 43 | + | {label} | |
| 44 | + | </Text> | |
| 45 | + | </Pressable> | |
| 46 | + | ); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | /** A repository's name in a chip: just the name when it is in the item's workspace. */ | |
| 50 | + | export function repoName(path: string, workspace: string): string { | |
| 51 | + | const [owner, name] = path.split("/", 2); | |
| 52 | + | return name && owner.toLowerCase() === workspace.toLowerCase() ? name : path; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /** | |
| 56 | + | * The repositories an item is linked to, as chips that fit a phone's row: | |
| 57 | + | * the first `max`, then "+2" for the rest. Origin and targets come first. | |
| 58 | + | */ | |
| 59 | + | export function RepoChips({ repos, workspace, max = 2 }: { repos: ApiItemRepo[]; workspace: string; max?: number }) { | |
| 60 | + | const colors = useColors(); | |
| 61 | + | const shown = repos.filter((repo) => repo.role !== "touched").concat(repos.filter((repo) => repo.role === "touched")); | |
| 62 | + | if (shown.length === 0) return null; | |
| 63 | + | const rest = shown.length - max; | |
| 64 | + | return ( | |
| 65 | + | <> | |
| 66 | + | {shown.slice(0, max).map((repo) => ( | |
| 67 | + | <Chip key={repo.repo_id} label={repoName(repo.path, workspace)} leading={<FolderGit2 size={12} color={colors.muted} />} /> | |
| 68 | + | ))} | |
| 69 | + | {rest > 0 ? <Chip label={`+${rest}`} /> : null} | |
| 70 | + | </> | |
| 71 | + | ); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | /** One work item in a list: its number and title, then state, repositories and labels on a line that wraps. */ | |
| 75 | + | export function ItemRow({ item, onPress }: { item: ApiWorkItem; onPress: () => void }) { | |
| 76 | + | const colors = useColors(); | |
| 77 | + | return ( | |
| 78 | + | <Pressable | |
| 79 | + | onPress={onPress} | |
| 80 | + | style={({ pressed }) => [styles.row, { borderBottomColor: colors.line, backgroundColor: pressed ? colors.raised : "transparent" }]} | |
| 81 | + | > | |
| 82 | + | <View style={{ paddingTop: 1 }}> | |
| 83 | + | <ItemIcon item={item} size={18} /> | |
| 84 | + | </View> | |
| 85 | + | <View style={{ flex: 1, gap: 6 }}> | |
| 86 | + | <Text numberOfLines={2} style={{ fontWeight: "600" }}> | |
| 87 | + | {item.title} | |
| 88 | + | </Text> | |
| 89 | + | <View style={styles.meta}> | |
| 90 | + | <Text size="small" tone="muted"> | |
| 91 | + | #{item.number} · {ago(item.updated_at)} | |
| 92 | + | </Text> | |
| 93 | + | <RepoChips repos={item.repos} workspace={item.workspace} /> | |
| 94 | + | {item.labels.slice(0, 2).map((label) => ( | |
| 95 | + | <Chip key={label} label={label} /> | |
| 96 | + | ))} | |
| 97 | + | {item.labels.length > 2 ? <Chip label={`+${item.labels.length - 2}`} /> : null} | |
| 98 | + | {item.comment_count ? ( | |
| 99 | + | <View style={{ flexDirection: "row", alignItems: "center", gap: 3 }}> | |
| 100 | + | <MessageSquare size={13} color={colors.faint} /> | |
| 101 | + | <Text size="small" tone="faint"> | |
| 102 | + | {item.comment_count} | |
| 103 | + | </Text> | |
| 104 | + | </View> | |
| 105 | + | ) : null} | |
| 106 | + | </View> | |
| 107 | + | </View> | |
| 108 | + | </Pressable> | |
| 109 | + | ); | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | const ROLES: Record<LinkRole, string> = { origin: "Origin", target: "Target", touched: "Touched" }; | |
| 113 | + | ||
| 114 | + | export function roleLabel(role: LinkRole): string { | |
| 115 | + | return ROLES[role]; | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /** | |
| 119 | + | * The bar at the foot of an item: a box to comment in, the arrow to send | |
| 120 | + | * it, and ⋯ for everything else (close, reopen, link a repository), all | |
| 121 | + | * where a thumb reaches. What was typed stays until it is sent. | |
| 122 | + | */ | |
| 123 | + | export function CommentBar({ busy, onSend, onMore }: { busy: boolean; onSend: (body: string) => Promise<unknown>; onMore: () => void }) { | |
| 124 | + | const colors = useColors(); | |
| 125 | + | const insets = useSafeAreaInsets(); | |
| 126 | + | const [text, setText] = useState(""); | |
| 127 | + | const [keyboard, setKeyboard] = useState(false); | |
| 128 | + | useEffect(() => { | |
| 129 | + | const shown = Keyboard.addListener("keyboardDidShow", () => setKeyboard(true)); | |
| 130 | + | const hidden = Keyboard.addListener("keyboardDidHide", () => setKeyboard(false)); | |
| 131 | + | return () => { | |
| 132 | + | shown.remove(); | |
| 133 | + | hidden.remove(); | |
| 134 | + | }; | |
| 135 | + | }, []); | |
| 136 | + | const ready = text.trim().length > 0 && !busy; | |
| 137 | + | const send = () => { | |
| 138 | + | if (!ready) return; | |
| 139 | + | void onSend(text.trim()).then(() => setText(""), () => undefined); | |
| 140 | + | }; | |
| 141 | + | return ( | |
| 142 | + | <View style={[styles.bar, { borderTopColor: colors.line, backgroundColor: colors.surface, paddingBottom: keyboard ? space.sm : Math.max(insets.bottom, space.sm) }]}> | |
| 143 | + | <Pressable accessibilityRole="button" accessibilityLabel="More actions" onPress={onMore} style={[styles.round, { backgroundColor: colors.raised }]}> | |
| 144 | + | <Ellipsis size={20} color={colors.fg} /> | |
| 145 | + | </Pressable> | |
| 146 | + | <TextInput | |
| 147 | + | value={text} | |
| 148 | + | onChangeText={setText} | |
| 149 | + | placeholder="Comment" | |
| 150 | + | placeholderTextColor={colors.faint} | |
| 151 | + | multiline | |
| 152 | + | style={[styles.input, { color: colors.fg, backgroundColor: colors.bg, borderColor: colors.lineStrong }]} | |
| 153 | + | /> | |
| 154 | + | <Pressable | |
| 155 | + | accessibilityRole="button" | |
| 156 | + | accessibilityLabel="Comment" | |
| 157 | + | onPress={send} | |
| 158 | + | disabled={!ready} | |
| 159 | + | style={[styles.round, { backgroundColor: ready ? colors.accent : colors.raised }]} | |
| 160 | + | > | |
| 161 | + | {busy ? <ActivityIndicator color={colors.fg} /> : <SendHorizontal size={18} color={ready ? colors.onAccent : colors.faint} />} | |
| 162 | + | </Pressable> | |
| 163 | + | </View> | |
| 164 | + | ); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | const styles = StyleSheet.create({ | |
| 168 | + | chip: { flexDirection: "row", alignItems: "center", gap: 4, borderWidth: 1, borderRadius: radius.full, paddingHorizontal: space.sm, paddingVertical: 1 }, | |
| 169 | + | row: { flexDirection: "row", gap: space.md, paddingHorizontal: space.lg, paddingVertical: space.md, borderBottomWidth: StyleSheet.hairlineWidth }, | |
| 170 | + | meta: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 6 }, | |
| 171 | + | bar: { flexDirection: "row", alignItems: "flex-end", gap: space.sm, paddingHorizontal: space.md, paddingTop: space.sm, borderTopWidth: StyleSheet.hairlineWidth }, | |
| 172 | + | input: { flex: 1, minHeight: 40, maxHeight: 140, borderRadius: radius.lg, borderWidth: 1, paddingHorizontal: space.md, paddingTop: 10, paddingBottom: 10, fontSize: 15 }, | |
| 173 | + | round: { width: 40, height: 40, borderRadius: 20, alignItems: "center", justifyContent: "center" }, | |
| 174 | + | }); |
| 1 | 1 | import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; | |
| 2 | 2 | ||
| 3 | 3 | import { useSignedIn } from "./session"; | |
| 4 | + | import type { ApiWorkItem } from "./work"; | |
| 4 | 5 | ||
| 5 | 6 | /** | |
| 6 | 7 | * Code, through the REST API (docs.g1t.sh/reference/api/). The API | |
| ⋯ | |||
| 22 | 23 | archived_at?: string | null; | |
| 23 | 24 | }; | |
| 24 | 25 | ||
| 25 | − | export type ApiIssue = { | |
| 26 | − | id: string; | |
| 27 | − | number: number; | |
| 28 | − | title: string; | |
| 29 | − | body: string; | |
| 30 | − | labels: string[]; | |
| 31 | − | state: "open" | "closed"; | |
| 32 | − | reason: "completed" | "not_planned" | null; | |
| 33 | − | author: ApiUser; | |
| 34 | − | created_at: string; | |
| 35 | − | updated_at: string; | |
| 36 | − | comment_count: number; | |
| 37 | − | pull_count: number; | |
| 38 | − | }; | |
| 39 | − | ||
| 40 | 26 | export type CheckStatus = "queued" | "running" | "passed" | "failed" | "errored" | null; | |
| 41 | 27 | ||
| 42 | 28 | export type ApiPull = { | |
| 43 | 29 | id: string; | |
| 44 | 30 | number: number; | |
| 45 | − | issue: number | null; | |
| 31 | + | /** The work item it is for, by number in the workspace. */ | |
| 32 | + | item: number | null; | |
| 46 | 33 | title: string; | |
| 47 | 34 | body: string | null; | |
| 48 | 35 | /** The coding agent it was made with (`claude-code`, `codex`), from the session recorded on it; null when none was. */ | |
| ⋯ | |||
| 129 | 116 | }); | |
| 130 | 117 | } | |
| 131 | 118 | ||
| 132 | − | export function useIssues(owner: string, name: string, state: "open" | "closed" | "all") { | |
| 133 | − | const { client } = useSignedIn(); | |
| 134 | − | return useQuery({ | |
| 135 | − | queryKey: ["code", "issues", owner, name, state], | |
| 136 | − | queryFn: () => client.api<ApiIssue[]>(`${repoPath(owner, name)}/issues?state=${state}`), | |
| 137 | − | }); | |
| 138 | − | } | |
| 139 | − | ||
| 140 | 119 | export function usePull(owner: string, name: string, number: number) { | |
| 141 | 120 | const { client } = useSignedIn(); | |
| 142 | 121 | return useQuery({ | |
| 143 | 122 | queryKey: ["code", "pull", owner, name, number], | |
| 144 | 123 | queryFn: () => | |
| 145 | − | client.api<{ pull: ApiPull; issue: ApiIssue | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`), | |
| 124 | + | client.api<{ pull: ApiPull; item: ApiWorkItem | null; comments: ApiComment[] }>(`${repoPath(owner, name)}/pulls/${number}`), | |
| 146 | 125 | }); | |
| 147 | 126 | } | |
| 148 | 127 | ||
| ⋯ | |||
| 152 | 131 | queryKey: ["code", "pull-changes", owner, name, number], | |
| 153 | 132 | queryFn: () => client.api<ApiChanges>(`${repoPath(owner, name)}/pulls/${number}/changes`), | |
| 154 | 133 | enabled, | |
| 155 | − | }); | |
| 156 | − | } | |
| 157 | − | ||
| 158 | − | export function useIssue(owner: string, name: string, number: number) { | |
| 159 | − | const { client } = useSignedIn(); | |
| 160 | − | return useQuery({ | |
| 161 | − | queryKey: ["code", "issue", owner, name, number], | |
| 162 | − | queryFn: () => | |
| 163 | − | client.api<{ issue: ApiIssue; pulls: ApiPull[]; comments: ApiComment[] }>(`${repoPath(owner, name)}/issues/${number}`), | |
| 164 | − | }); | |
| 165 | − | } | |
| 166 | − | ||
| 167 | − | /** Comments on an issue (`POST /repos/{owner}/{name}/issues/{number}/comments`). */ | |
| 168 | − | export function useComment(owner: string, name: string, number: number) { | |
| 169 | − | const { client } = useSignedIn(); | |
| 170 | − | const queries = useQueryClient(); | |
| 171 | − | return useMutation({ | |
| 172 | − | mutationFn: (body: string) => | |
| 173 | − | client.api<ApiComment>(`${repoPath(owner, name)}/issues/${number}/comments`, { method: "POST", body: JSON.stringify({ body }) }), | |
| 174 | − | onSuccess: () => queries.invalidateQueries({ queryKey: ["code", "issue", owner, name, number] }), | |
| 175 | 134 | }); | |
| 176 | 135 | } | |
| 177 | 136 | ||
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import { routeFor, sitePathOf } from "./links.ts"; | |
| 4 | + | import { linkPathOf, refContext, refRoute, refSitePath, routeFor, sitePathOf, targetRoute } from "./links.ts"; | |
| 5 | 5 | ||
| 6 | 6 | test("a g1t:// link names a site path, as on the desktop", () => { | |
| 7 | 7 | assert.equal(sitePathOf("g1t://acme/-/chat/dm/chn_1"), "/acme/-/chat/dm/chn_1"); | |
| ⋯ | |||
| 9 | 9 | assert.equal(sitePathOf("https://g1t.sh/acme"), null); | |
| 10 | 10 | }); | |
| 11 | 11 | ||
| 12 | + | test("an https link to the site names the same path as a g1t:// one", () => { | |
| 13 | + | assert.equal(linkPathOf("https://g1t.sh/acme/-/work/12"), "/acme/-/work/12"); | |
| 14 | + | assert.equal(linkPathOf("g1t://acme/-/work/12"), "/acme/-/work/12"); | |
| 15 | + | assert.equal(linkPathOf("https://g1t.sh/acme/-/work?repo=web#top"), "/acme/-/work?repo=web"); | |
| 16 | + | assert.equal(linkPathOf("https://g1t.sh"), "/"); | |
| 17 | + | assert.equal(linkPathOf("mailto:someone@example.com"), null); | |
| 18 | + | }); | |
| 19 | + | ||
| 12 | 20 | test("Chat's pages open the app's Chat", () => { | |
| 13 | 21 | assert.equal(routeFor("/acme/-/chat"), "/chat?workspace=acme"); | |
| 14 | 22 | assert.equal(routeFor("/acme/-/chat/general"), "/chat/general?workspace=acme"); | |
| ⋯ | |||
| 16 | 24 | assert.equal(routeFor("/acme/-/chat/browse"), null); | |
| 17 | 25 | }); | |
| 18 | 26 | ||
| 27 | + | test("Work's pages open the app's Work", () => { | |
| 28 | + | assert.equal(routeFor("/acme/-/work"), "/work?workspace=acme"); | |
| 29 | + | assert.equal(routeFor("/acme/-/work/12"), "/work/12?workspace=acme"); | |
| 30 | + | assert.equal(routeFor(linkPathOf("g1t://acme/-/work/12")!), "/work/12?workspace=acme"); | |
| 31 | + | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/-/work/12")!), "/work/12?workspace=acme"); | |
| 32 | + | assert.equal(routeFor("/acme/-/work/new"), "/work/new?workspace=acme"); | |
| 33 | + | // The list keeps the filters the site's does, and drops anything else. | |
| 34 | + | assert.equal(routeFor("/acme/-/work?repo=web&label=bug&page=3"), "/work?workspace=acme&label=bug&repo=web"); | |
| 35 | + | assert.equal(routeFor("/acme/-/work/labels"), null); | |
| 36 | + | assert.equal(routeFor("/acme/-/work/milestones/2"), null); | |
| 37 | + | assert.equal(routeFor("/acme/-/work/12/edit"), null); | |
| 38 | + | }); | |
| 39 | + | ||
| 40 | + | test("an old link to an issue asks g1t what its number names in its repository", () => { | |
| 41 | + | assert.equal(routeFor("/acme/rocket/issues/12"), "/ref?repo=acme%2Frocket&number=12"); | |
| 42 | + | assert.equal(routeFor(linkPathOf("https://g1t.sh/acme/rocket/issues/12")!), "/ref?repo=acme%2Frocket&number=12"); | |
| 43 | + | assert.equal(routeFor("/acme/rocket/issues"), "/work?workspace=acme&repo=rocket"); | |
| 44 | + | assert.equal(routeFor("/acme/rocket/issues/new"), "/work/new?workspace=acme&repo=rocket"); | |
| 45 | + | assert.equal(routeFor("/acme/rocket/issues/12/edit"), null); | |
| 46 | + | }); | |
| 47 | + | ||
| 19 | 48 | test("Code's pages open the app's Code", () => { | |
| 20 | 49 | assert.equal(routeFor("/acme/rocket"), "/code/acme/rocket"); | |
| 21 | 50 | assert.equal(routeFor("/acme/rocket/pull/14"), "/code/acme/rocket/pull/14"); | |
| 22 | − | assert.equal(routeFor("/acme/rocket/issues/12"), "/code/acme/rocket/issues/12"); | |
| 23 | 51 | assert.equal(routeFor("/acme/-/overview"), "/code?workspace=acme"); | |
| 24 | 52 | assert.equal(routeFor("/acme/rocket/pull/14/files"), null); | |
| 25 | − | assert.equal(routeFor("/acme/rocket/issues/new"), null); | |
| 53 | + | assert.equal(routeFor("/acme/rocket/sessions"), null); | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | test("#N is read where it was written, owner/name#N in that repository, workspace#N in that workspace", () => { | |
| 57 | + | const here = { workspace: "acme" }; | |
| 58 | + | assert.deepEqual(refContext(null, here), { workspace: "acme" }); | |
| 59 | + | assert.deepEqual(refContext(null, { repo: "acme/web" }), { repo: "acme/web" }); | |
| 60 | + | assert.deepEqual(refContext("acme/api", here), { repo: "acme/api" }); | |
| 61 | + | assert.deepEqual(refContext("globex", here), { workspace: "globex" }); | |
| 62 | + | assert.equal(refRoute({ workspace: "acme" }, 7), "/ref?workspace=acme&number=7"); | |
| 63 | + | assert.equal(refRoute({ repo: "acme/web" }, 7), "/ref?repo=acme%2Fweb&number=7"); | |
| 64 | + | }); | |
| 65 | + | ||
| 66 | + | test("what a reference names opens its screen, and the site reads one the app cannot", () => { | |
| 67 | + | assert.equal(targetRoute({ kind: "work_item", workspace: "acme", number: 31, item_id: "wki_1" }), "/work/31?workspace=acme"); | |
| 68 | + | assert.equal(targetRoute({ kind: "pull", repo: "acme/web", number: 9 }), "/code/acme/web/pull/9"); | |
| 69 | + | assert.equal(targetRoute({ kind: "pull", repo: "nonsense", number: 9 }), null); | |
| 70 | + | assert.equal(targetRoute({ kind: "none" }), null); | |
| 71 | + | assert.equal(refSitePath({ repo: "acme/web" }, 9), "/acme/web/issues/9"); | |
| 72 | + | assert.equal(refSitePath({ workspace: "acme" }, 9), "/acme/-/work/9"); | |
| 26 | 73 | }); | |
| 27 | 74 | ||
| 28 | 75 | test("old links to Agents, Customize and the Marketplace open Home", () => { | |
| 1 | 1 | /** | |
| 2 | 2 | * Links into the app. A `g1t://` link names a site path, as on the desktop | |
| 3 | 3 | * (apps/desktop/src-tauri/src/links.rs): `g1t://acme/-/chat/dm/chn_1` is | |
| 4 | − | * the page `/acme/-/chat/dm/chn_1`. The app has screens for some of the | |
| 5 | − | * site's pages; `routeFor` says which screen shows a site path, and the | |
| 6 | − | * rest open on the site. | |
| 4 | + | * the page `/acme/-/chat/dm/chn_1`, as `https://g1t.sh/acme/-/chat/dm/chn_1` | |
| 5 | + | * is. The app has screens for some of the site's pages; `routeFor` says | |
| 6 | + | * which screen shows a site path, and the rest open on the site. | |
| 7 | 7 | * | |
| 8 | 8 | * Kept free of React Native imports so `npm test` runs it under Node. | |
| 9 | 9 | */ | |
| ⋯ | |||
| 20 | 20 | /** Path segments a workspace or account can never be named (the site's own pages). */ | |
| 21 | 21 | const NOT_OWNERS = new Set(["settings", "notifications", "oauth", "device", "login", "register", "-"]); | |
| 22 | 22 | ||
| 23 | + | /** The filters a link to Work's list may carry, as the site's: `/acme/-/work?repo=web&label=bug`. */ | |
| 24 | + | export const WORK_FILTERS = ["state", "label", "milestone", "assignee", "repo", "role"] as const; | |
| 25 | + | ||
| 23 | 26 | /** | |
| 24 | 27 | * The app's screen for a site path, or null when the app has none and the | |
| 25 | 28 | * page should open on the site. | |
| ⋯ | |||
| 44 | 47 | if (fourth !== "browse" && fourth !== "dm" && !fifth) return `/chat/${encodeURIComponent(fourth)}?${workspace}`; | |
| 45 | 48 | return null; | |
| 46 | 49 | } | |
| 50 | + | if (third === "work") { | |
| 51 | + | if (!fourth) return `/work?${workspace}${filtersOf(query)}`; | |
| 52 | + | if (fourth === "new" && !fifth) return `/work/new?${workspace}`; | |
| 53 | + | if (isNumber(fourth) && !fifth) return `/work/${fourth}?${workspace}`; | |
| 54 | + | // Labels and milestones stay on the site. | |
| 55 | + | return null; | |
| 56 | + | } | |
| 47 | 57 | if (third === "overview" && !fourth) return `/code?${workspace}`; | |
| 48 | 58 | if (third && RETIRED.has(third)) return `/home?${workspace}`; | |
| 49 | 59 | return null; | |
| 50 | 60 | } | |
| 51 | 61 | ||
| 52 | − | // A repository's pages: /<owner>/<repo>[/pull/<n> | /issues/<n>]. | |
| 62 | + | // A repository's pages: /<owner>/<repo>[/pull/<n>]. | |
| 53 | 63 | if (!second) return null; | |
| 54 | 64 | const repo = `/code/${encodeURIComponent(owner)}/${encodeURIComponent(second)}`; | |
| 55 | 65 | if (!third) return repo; | |
| 56 | − | if (third === "pull" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/pull/${fourth}`; | |
| 57 | − | if (third === "issues" && fourth && /^\d+$/.test(fourth) && !fifth) return `${repo}/issues/${fourth}`; | |
| 66 | + | if (third === "pull" && isNumber(fourth) && !fifth) return `${repo}/pull/${fourth}`; | |
| 67 | + | // Issues became the workspace's work items. An old issue's number is | |
| 68 | + | // read in its repository, where it may name a pull request (they shared | |
| 69 | + | // numbers then): the ref screen asks g1t which. | |
| 70 | + | if (third === "issues") { | |
| 71 | + | const name = `repo=${encodeURIComponent(second)}`; | |
| 72 | + | if (!fourth) return `/work?${workspace}&${name}`; | |
| 73 | + | if (fourth === "new" && !fifth) return `/work/new?${workspace}&${name}`; | |
| 74 | + | if (isNumber(fourth) && !fifth) return refRoute({ repo: `${owner}/${second}` }, Number(fourth)); | |
| 75 | + | return null; | |
| 76 | + | } | |
| 77 | + | return null; | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | /** Where `#N` is read: in a repository (`owner/name`), or in a workspace alone. */ | |
| 81 | + | export type RefContext = { repo: string } | { workspace: string }; | |
| 82 | + | ||
| 83 | + | /** The screen that asks g1t what `#N` names in a context, and opens it (app/ref.tsx). */ | |
| 84 | + | export function refRoute(context: RefContext, number: number): string { | |
| 85 | + | const where = "repo" in context ? `repo=${encodeURIComponent(context.repo)}` : `workspace=${encodeURIComponent(context.workspace)}`; | |
| 86 | + | return `/ref?${where}&number=${number}`; | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | /** | |
| 90 | + | * The context a reference in text is read in (crates/contracts/src/work/refs.rs): | |
| 91 | + | * `owner/name#N` in that repository, `workspace#N` in that workspace, and | |
| 92 | + | * `#N` where it was written. | |
| 93 | + | */ | |
| 94 | + | export function refContext(written: string | null, here: RefContext): RefContext { | |
| 95 | + | if (!written) return here; | |
| 96 | + | return written.includes("/") ? { repo: written } : { workspace: written }; | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /** What `#N` names, as the API answers (`RefTarget`, in snake_case). */ | |
| 100 | + | export type RefTarget = | |
| 101 | + | | { kind: "work_item"; workspace: string; number: number; item_id?: string } | |
| 102 | + | | { kind: "pull"; repo: string; number: number } | |
| 103 | + | | { kind: "none" }; | |
| 104 | + | ||
| 105 | + | /** The app's screen for what a reference names, or null for nothing. */ | |
| 106 | + | export function targetRoute(target: RefTarget): string | null { | |
| 107 | + | if (target.kind === "work_item") return `/work/${target.number}?workspace=${encodeURIComponent(target.workspace)}`; | |
| 108 | + | if (target.kind === "pull") { | |
| 109 | + | const [owner, name] = target.repo.split("/", 2); | |
| 110 | + | if (!owner || !name) return null; | |
| 111 | + | return `/code/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pull/${target.number}`; | |
| 112 | + | } | |
| 58 | 113 | return null; | |
| 59 | 114 | } | |
| 60 | 115 | ||
| 61 | 116 | /** | |
| 117 | + | * The site's page for a reference, for when the app cannot ask: the site | |
| 118 | + | * reads it the same way and redirects. | |
| 119 | + | */ | |
| 120 | + | export function refSitePath(context: RefContext, number: number): string { | |
| 121 | + | return "repo" in context ? `/${context.repo}/issues/${number}` : `/${context.workspace}/-/work/${number}`; | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /** | |
| 62 | 125 | * The site path a `g1t://` link names, or null for any other link. | |
| 63 | 126 | * `g1t://acme/-/chat?x` is `/acme/-/chat?x`. | |
| 64 | 127 | */ | |
| ⋯ | |||
| 69 | 132 | return path + (match[2] ?? ""); | |
| 70 | 133 | } | |
| 71 | 134 | ||
| 135 | + | /** | |
| 136 | + | * The site path any link that opens the app names: a `g1t://` one, or an | |
| 137 | + | * `https` one to the site (Android App Links, iOS Universal Links), as | |
| 138 | + | * `https://g1t.sh/acme/-/work/12` is `/acme/-/work/12`. Null for others. | |
| 139 | + | */ | |
| 140 | + | export function linkPathOf(link: string): string | null { | |
| 141 | + | const own = sitePathOf(link); | |
| 142 | + | if (own) return own; | |
| 143 | + | const web = /^https?:\/\/[^/?#]+(\/[^?#]*)?(\?[^#]*)?/i.exec(link); | |
| 144 | + | return web ? (web[1] || "/") + (web[2] ?? "") : null; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | function filtersOf(query: string): string { | |
| 148 | + | const params = new URLSearchParams(query); | |
| 149 | + | return WORK_FILTERS.filter((name) => params.get(name)) | |
| 150 | + | .map((name) => `&${name}=${encodeURIComponent(params.get(name)!)}`) | |
| 151 | + | .join(""); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | function isNumber(part: string | undefined): part is string { | |
| 155 | + | return Boolean(part && /^\d+$/.test(part)); | |
| 156 | + | } | |
| 157 | + | ||
| 72 | 158 | function decodeURIComponentSafe(part: string): string { | |
| 73 | 159 | try { | |
| 74 | 160 | return decodeURIComponent(part); | |
| 27 | 27 | WebBrowser.maybeCompleteAuthSession(); | |
| 28 | 28 | ||
| 29 | 29 | /** | |
| 30 | − | * What the app asks for: what Chat and Code show and do. The | |
| 30 | + | * What the app asks for: what Work, Chat and Code show and do. The | |
| 31 | 31 | * person can untick any of these on the approval page. `chat:*` opens | |
| 32 | 32 | * Chat's routes and live sockets on the site, and `notifications:*` the | |
| 33 | 33 | * phone's registration for pushes (`/-/notify`), and nothing else there. | |
| ⋯ | |||
| 39 | 39 | "workspace:read", | |
| 40 | 40 | "repo:read", | |
| 41 | 41 | "code:read", | |
| 42 | − | "issues:read", | |
| 43 | − | "issues:write", | |
| 42 | + | "work:read", | |
| 43 | + | "work:write", | |
| 44 | 44 | "pull_requests:read", | |
| 45 | 45 | "pull_requests:write", | |
| 46 | 46 | "checks:read", | |
Loading the diff…
Comments
Sign in to comment.