| 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 | }); |