| 1 | import { Stack, router, useFocusEffect, useLocalSearchParams, useNavigation } from "expo-router"; |
| 2 | import { Activity, Check as CheckIcon, ListTodo, Plus, SlidersHorizontal, X } from "lucide-react-native"; |
| 3 | import { useCallback, useEffect, useMemo, useState } from "react"; |
| 4 | import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, StyleSheet, View, useWindowDimensions } from "react-native"; |
| 5 | |
| 6 | import { Board } from "@/components/board"; |
| 7 | import { Sheet } from "@/components/chat/sheet"; |
| 8 | import { Segments } from "@/components/segments"; |
| 9 | import { Button, Empty, ErrorView, Loading, Row, Screen, Text } from "@/components/ui"; |
| 10 | import { Chip, ItemRow, roleLabel } from "@/components/work"; |
| 11 | import { useRepos } from "@/lib/code"; |
| 12 | import { useSignedIn } from "@/lib/session"; |
| 13 | import { type LinkRole, type WorkFilter, useLabels, useMilestones, useWorkItems } from "@/lib/work"; |
| 14 | import { radius, space, useColors } from "@/theme"; |
| 15 | |
| 16 | type Params = { workspace?: string; view?: string; state?: string; label?: string; milestone?: string; assignee?: string; repo?: string; role?: string }; |
| 17 | |
| 18 | /** The list of items, or the board. */ |
| 19 | type Shown = "items" | "board"; |
| 20 | |
| 21 | /** The filter a link to the list names (`/acme/-/work?repo=web&label=bug`), open items when it names none. */ |
| 22 | function filterOf(params: Params): WorkFilter { |
| 23 | const state = params.state === "closed" || params.state === "all" ? params.state : "open"; |
| 24 | const role = params.role === "origin" || params.role === "target" || params.role === "touched" ? params.role : undefined; |
| 25 | const milestone = Number(params.milestone); |
| 26 | return { |
| 27 | state, |
| 28 | label: params.label || undefined, |
| 29 | milestone: Number.isInteger(milestone) && milestone > 0 ? milestone : undefined, |
| 30 | assignee: params.assignee || undefined, |
| 31 | repo: params.repo || undefined, |
| 32 | role, |
| 33 | }; |
| 34 | } |
| 35 | |
| 36 | /** |
| 37 | * Work: the workspace's work items, newest first, for every member, |
| 38 | * Code or not, or its board (`/acme/-/work/board`). Filters are in a |
| 39 | * sheet (the ⚙ in the header, the chips above the list, or Work in the |
| 40 | * bar again); pull down to read again, and the list loads more as it |
| 41 | * nears its end. Sessions opens from the header. |
| 42 | */ |
| 43 | export default function WorkList() { |
| 44 | const params = useLocalSearchParams<Params>(); |
| 45 | const { workspace, me } = useSignedIn(); |
| 46 | const slug = params.workspace ?? workspace?.slug; |
| 47 | const colors = useColors(); |
| 48 | const linked = useMemo(() => filterOf(params), [params.state, params.label, params.milestone, params.assignee, params.repo, params.role]); |
| 49 | const [filter, setFilter] = useState<WorkFilter>(linked); |
| 50 | useEffect(() => setFilter(linked), [linked]); |
| 51 | const [filtering, setFiltering] = useState(false); |
| 52 | const linkedView: Shown = params.view === "board" ? "board" : "items"; |
| 53 | const [view, setView] = useState<Shown>(linkedView); |
| 54 | useEffect(() => setView(linkedView), [linkedView]); |
| 55 | const [focused, setFocused] = useState(false); |
| 56 | useFocusEffect( |
| 57 | useCallback(() => { |
| 58 | setFocused(true); |
| 59 | return () => setFocused(false); |
| 60 | }, []), |
| 61 | ); |
| 62 | const items = useWorkItems(view === "items" ? slug : undefined, filter); |
| 63 | const milestones = useMilestones(filter.milestone ? slug : undefined); |
| 64 | |
| 65 | // Work in the bar again, on the list itself, opens its filters, as the site's bar opens Work's menu. |
| 66 | const navigation = useNavigation(); |
| 67 | useEffect(() => { |
| 68 | const tabs = navigation.getParent(); |
| 69 | if (!tabs) return; |
| 70 | return tabs.addListener("tabPress" as never, () => { |
| 71 | if (navigation.isFocused() && view === "items") setFiltering(true); |
| 72 | }); |
| 73 | }, [navigation, view]); |
| 74 | |
| 75 | const active = [ |
| 76 | filter.assignee ? { key: "assignee", label: filter.assignee === me.username ? "Assigned to me" : `@${filter.assignee}` } : null, |
| 77 | filter.label ? { key: "label", label: filter.label } : null, |
| 78 | filter.milestone ? { key: "milestone", label: milestones.data?.find((one) => one.number === filter.milestone)?.title ?? `Milestone ${filter.milestone}` } : null, |
| 79 | filter.repo ? { key: "repo", label: filter.role ? `${filter.repo} · ${roleLabel(filter.role)}` : filter.repo } : null, |
| 80 | ].filter((one): one is { key: keyof WorkFilter; label: string } => one !== null); |
| 81 | |
| 82 | const header = ( |
| 83 | <Stack.Screen |
| 84 | options={{ |
| 85 | title: "Work", |
| 86 | headerRight: () => ( |
| 87 | <View style={styles.actions}> |
| 88 | {slug ? ( |
| 89 | <Pressable accessibilityLabel="Sessions" hitSlop={10} onPress={() => router.push({ pathname: "/work/sessions", params: { workspace: slug } })}> |
| 90 | <Activity size={22} color={colors.fg} /> |
| 91 | </Pressable> |
| 92 | ) : null} |
| 93 | {view === "items" ? ( |
| 94 | <Pressable accessibilityLabel="Filters" hitSlop={10} onPress={() => setFiltering(true)}> |
| 95 | <SlidersHorizontal size={22} color={active.length ? colors.accent : colors.fg} /> |
| 96 | </Pressable> |
| 97 | ) : null} |
| 98 | </View> |
| 99 | ), |
| 100 | }} |
| 101 | /> |
| 102 | ); |
| 103 | |
| 104 | if (!slug) { |
| 105 | return ( |
| 106 | <Screen> |
| 107 | {header} |
| 108 | <Empty icon={ListTodo} title="No workspace yet" body="Create or join a workspace on g1t, then come back." /> |
| 109 | </Screen> |
| 110 | ); |
| 111 | } |
| 112 | |
| 113 | return ( |
| 114 | <Screen> |
| 115 | {header} |
| 116 | <View style={[styles.bar, { borderBottomColor: colors.line }]}> |
| 117 | <Segments |
| 118 | value={view} |
| 119 | onChange={setView} |
| 120 | options={[ |
| 121 | { value: "items", label: "Items" }, |
| 122 | { value: "board", label: "Board" }, |
| 123 | ]} |
| 124 | /> |
| 125 | {view === "items" ? ( |
| 126 | <Segments |
| 127 | value={filter.state} |
| 128 | onChange={(state) => setFilter({ ...filter, state })} |
| 129 | options={[ |
| 130 | { value: "open", label: "Open" }, |
| 131 | { value: "closed", label: "Closed" }, |
| 132 | { value: "all", label: "All" }, |
| 133 | ]} |
| 134 | /> |
| 135 | ) : null} |
| 136 | {view === "items" && active.length ? ( |
| 137 | <View style={styles.chips}> |
| 138 | {active.map((one) => ( |
| 139 | <Chip |
| 140 | key={one.key} |
| 141 | label={one.label} |
| 142 | tone="accent" |
| 143 | leading={<X size={12} color={colors.accent} />} |
| 144 | onPress={() => setFilter({ ...filter, [one.key]: undefined, ...(one.key === "repo" ? { role: undefined } : {}) })} |
| 145 | /> |
| 146 | ))} |
| 147 | </View> |
| 148 | ) : null} |
| 149 | </View> |
| 150 | |
| 151 | {view === "board" ? ( |
| 152 | <Board workspace={slug} focused={focused} /> |
| 153 | ) : items.isPending ? ( |
| 154 | <Loading /> |
| 155 | ) : items.error ? ( |
| 156 | <ErrorView error={items.error} retry={() => items.refetch()} /> |
| 157 | ) : ( |
| 158 | <FlatList |
| 159 | data={items.items} |
| 160 | keyExtractor={(item) => item.id} |
| 161 | refreshControl={<RefreshControl refreshing={items.isRefetching && !items.isFetchingNextPage} onRefresh={() => items.refetch()} tintColor={colors.accent} />} |
| 162 | onEndReached={() => items.hasNextPage && !items.isFetchingNextPage && items.fetchNextPage()} |
| 163 | onEndReachedThreshold={0.5} |
| 164 | contentContainerStyle={{ paddingBottom: 96, flexGrow: 1 }} |
| 165 | ListFooterComponent={items.isFetchingNextPage ? <ActivityIndicator color={colors.accent} style={{ padding: space.lg }} /> : null} |
| 166 | ListEmptyComponent={ |
| 167 | <Empty |
| 168 | icon={ListTodo} |
| 169 | title={active.length ? "Nothing matches" : filter.state === "closed" ? "Nothing closed yet" : "No open work items"} |
| 170 | body={active.length ? "Try fewer filters." : "Work items are any tracked work: a feature, a bug, a chore."} |
| 171 | /> |
| 172 | } |
| 173 | renderItem={({ item }) => ( |
| 174 | <ItemRow item={item} onPress={() => router.push({ pathname: "/work/[number]", params: { number: String(item.number), workspace: item.workspace } })} /> |
| 175 | )} |
| 176 | /> |
| 177 | )} |
| 178 | |
| 179 | <Pressable |
| 180 | accessibilityRole="button" |
| 181 | accessibilityLabel="New work item" |
| 182 | onPress={() => router.push({ pathname: "/work/new", params: { workspace: slug } })} |
| 183 | style={({ pressed }) => [styles.fab, { backgroundColor: colors.accent, opacity: pressed ? 0.85 : 1 }]} |
| 184 | > |
| 185 | <Plus size={20} color={colors.onAccent} strokeWidth={2.5} /> |
| 186 | <Text style={{ color: colors.onAccent, fontWeight: "600" }}>New</Text> |
| 187 | </Pressable> |
| 188 | |
| 189 | <Filters visible={filtering} workspace={slug} filter={filter} me={me.username} onClose={() => setFiltering(false)} onChange={setFilter} /> |
| 190 | </Screen> |
| 191 | ); |
| 192 | } |
| 193 | |
| 194 | /** The list's filters, in a sheet: state, assigned to you, label, milestone, repository and its role. */ |
| 195 | function Filters({ |
| 196 | visible, |
| 197 | workspace, |
| 198 | filter, |
| 199 | me, |
| 200 | onClose, |
| 201 | onChange, |
| 202 | }: { |
| 203 | visible: boolean; |
| 204 | workspace: string; |
| 205 | filter: WorkFilter; |
| 206 | me: string; |
| 207 | onClose: () => void; |
| 208 | onChange: (filter: WorkFilter) => void; |
| 209 | }) { |
| 210 | const colors = useColors(); |
| 211 | const { height } = useWindowDimensions(); |
| 212 | const labels = useLabels(visible ? workspace : undefined); |
| 213 | const milestones = useMilestones(visible ? workspace : undefined); |
| 214 | const repos = useRepos(""); |
| 215 | const set = (change: Partial<WorkFilter>) => onChange({ ...filter, ...change }); |
| 216 | const toggle = <K extends keyof WorkFilter>(key: K, value: WorkFilter[K]) => set({ [key]: filter[key] === value ? undefined : value } as Partial<WorkFilter>); |
| 217 | |
| 218 | return ( |
| 219 | <Sheet visible={visible} onClose={onClose}> |
| 220 | <ScrollView style={{ maxHeight: height * 0.7 }} contentContainerStyle={{ paddingHorizontal: space.lg, gap: space.lg, paddingBottom: space.md }}> |
| 221 | <Text size="heading">Filters</Text> |
| 222 | <Segments |
| 223 | value={filter.state} |
| 224 | onChange={(state) => set({ state })} |
| 225 | options={[ |
| 226 | { value: "open", label: "Open" }, |
| 227 | { value: "closed", label: "Closed" }, |
| 228 | { value: "all", label: "All" }, |
| 229 | ]} |
| 230 | /> |
| 231 | <View style={{ marginHorizontal: -space.lg }}> |
| 232 | <Row |
| 233 | title="Assigned to me" |
| 234 | chevron={false} |
| 235 | trailing={<Check on={filter.assignee === me} />} |
| 236 | onPress={() => toggle("assignee", me)} |
| 237 | /> |
| 238 | </View> |
| 239 | <Group title="Label" empty={labels.isPending ? "…" : "No labels yet"}> |
| 240 | {(labels.data ?? []).map((label) => ( |
| 241 | <Chip |
| 242 | key={label.name} |
| 243 | label={label.name} |
| 244 | tone={filter.label === label.name ? "accent" : "neutral"} |
| 245 | leading={<View style={{ width: 8, height: 8, borderRadius: 4, backgroundColor: `#${label.color}` }} />} |
| 246 | onPress={() => toggle("label", label.name)} |
| 247 | /> |
| 248 | ))} |
| 249 | </Group> |
| 250 | <Group title="Milestone" empty={milestones.isPending ? "…" : "No open milestones"}> |
| 251 | {(milestones.data ?? []).map((milestone) => ( |
| 252 | <Chip |
| 253 | key={milestone.number} |
| 254 | label={milestone.title} |
| 255 | tone={filter.milestone === milestone.number ? "accent" : "neutral"} |
| 256 | onPress={() => toggle("milestone", milestone.number)} |
| 257 | /> |
| 258 | ))} |
| 259 | </Group> |
| 260 | <Group title="Repository" empty={repos.isPending ? "…" : "No repositories you can see"}> |
| 261 | {(repos.data ?? []).map((repo) => ( |
| 262 | <Chip |
| 263 | key={repo.id} |
| 264 | label={repo.name} |
| 265 | tone={filter.repo === repo.name ? "accent" : "neutral"} |
| 266 | onPress={() => set({ repo: filter.repo === repo.name ? undefined : repo.name, role: undefined })} |
| 267 | /> |
| 268 | ))} |
| 269 | </Group> |
| 270 | {filter.repo ? ( |
| 271 | <View style={{ gap: space.sm }}> |
| 272 | <Text size="small" tone="muted"> |
| 273 | Linked to {filter.repo} as |
| 274 | </Text> |
| 275 | <Segments |
| 276 | value={filter.role ?? "any"} |
| 277 | onChange={(role) => set({ role: role === "any" ? undefined : (role as LinkRole) })} |
| 278 | options={[ |
| 279 | { value: "any", label: "Any" }, |
| 280 | { value: "origin", label: "Origin" }, |
| 281 | { value: "target", label: "Target" }, |
| 282 | { value: "touched", label: "Touched" }, |
| 283 | ]} |
| 284 | /> |
| 285 | </View> |
| 286 | ) : null} |
| 287 | <View style={{ flexDirection: "row", gap: space.sm }}> |
| 288 | <View style={{ flex: 1 }}> |
| 289 | <Button title="Clear" variant="secondary" onPress={() => onChange({ state: "open" })} /> |
| 290 | </View> |
| 291 | <View style={{ flex: 1 }}> |
| 292 | <Button title="Done" onPress={onClose} /> |
| 293 | </View> |
| 294 | </View> |
| 295 | </ScrollView> |
| 296 | </Sheet> |
| 297 | ); |
| 298 | } |
| 299 | |
| 300 | function Group({ title, empty, children }: { title: string; empty: string; children: React.ReactNode[] }) { |
| 301 | return ( |
| 302 | <View style={{ gap: space.sm }}> |
| 303 | <Text size="small" tone="muted"> |
| 304 | {title} |
| 305 | </Text> |
| 306 | {children.length ? ( |
| 307 | <View style={styles.chips}>{children}</View> |
| 308 | ) : ( |
| 309 | <Text size="small" tone="faint"> |
| 310 | {empty} |
| 311 | </Text> |
| 312 | )} |
| 313 | </View> |
| 314 | ); |
| 315 | } |
| 316 | |
| 317 | function Check({ on }: { on: boolean }) { |
| 318 | const colors = useColors(); |
| 319 | return ( |
| 320 | <View style={[styles.check, { borderColor: on ? colors.accent : colors.lineStrong, backgroundColor: on ? colors.accent : "transparent" }]}> |
| 321 | {on ? <CheckIcon size={14} color={colors.onAccent} strokeWidth={3} /> : null} |
| 322 | </View> |
| 323 | ); |
| 324 | } |
| 325 | |
| 326 | const styles = StyleSheet.create({ |
| 327 | bar: { paddingHorizontal: space.lg, paddingVertical: space.md, gap: space.sm, borderBottomWidth: StyleSheet.hairlineWidth }, |
| 328 | chips: { flexDirection: "row", flexWrap: "wrap", gap: 6 }, |
| 329 | actions: { flexDirection: "row", alignItems: "center", gap: space.lg }, |
| 330 | fab: { |
| 331 | position: "absolute", |
| 332 | right: space.lg, |
| 333 | bottom: space.lg, |
| 334 | flexDirection: "row", |
| 335 | alignItems: "center", |
| 336 | gap: space.xs, |
| 337 | paddingHorizontal: space.lg, |
| 338 | height: 48, |
| 339 | borderRadius: radius.full, |
| 340 | elevation: 4, |
| 341 | shadowColor: "#000", |
| 342 | shadowOpacity: 0.25, |
| 343 | shadowRadius: 8, |
| 344 | shadowOffset: { width: 0, height: 2 }, |
| 345 | }, |
| 346 | check: { width: 22, height: 22, borderRadius: 6, borderWidth: 2, alignItems: "center", justifyContent: "center" }, |
| 347 | }); |