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