Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions apps/web/src/components/PullToRefresh.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState, type HTMLAttributes } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { usePrefersReducedMotion } from "@corbits/react-ui";
import { RotateCw } from "lucide-react";

const THRESHOLD = 64;

type PullParts = { scroller: HTMLElement; mark: HTMLElement; icon: SVGElement; reduceMotion: boolean };

/** Finger travel slowed toward twice the threshold, so the mark arms after 128px of travel and never runs away. */
function pullDistance(travel: number) {
return (travel * THRESHOLD * 2) / (travel + THRESHOLD * 2);
}

/** Follows one finger pulling down from the top of `scroller` and calls `onPulled` when it lets go past the threshold. */
function listenForPull({ scroller, mark, icon, reduceMotion }: PullParts, onPulled: () => void) {
let finger: { id: number; x: number; y: number } | null = null;
let distance: number | null = null;

function show(pulled: number) {
const progress = Math.min(pulled / THRESHOLD, 1);
mark.style.transition = "none";
mark.style.opacity = String(progress);
if (!reduceMotion) {
mark.style.transform = `translateY(${pulled}px)`;
icon.style.transition = "none";
icon.style.transform = `rotate(${progress * 270}deg)`;
}
mark.toggleAttribute("data-armed", pulled >= THRESHOLD);
}

function cancel() {
finger = null;
distance = null;
mark.removeAttribute("style");
mark.removeAttribute("data-armed");
icon.removeAttribute("style");
}

/** On a phone the page scrolls rather than the container, so both must be at the top. */
function atTop() {
return scroller.scrollTop === 0 && window.scrollY === 0;
}

/** A drag that starts in a text box or a scrolled inner area scrolls that, not the container. */
function scrollsSomethingElse(target: EventTarget | null) {
for (let node = target instanceof Element ? target : null; node !== null && node !== scroller; node = node.parentElement) {
if (node instanceof HTMLTextAreaElement || node.scrollTop > 0) return true;
}
return false;
}

function trackedTouch(touches: TouchList) {
if (finger === null) return undefined;
const id = finger.id;
return [...touches].find((touch) => touch.identifier === id);
}

function onStart(event: TouchEvent) {
// The tracked finger's own touchend never arrives when its row is removed mid-pull.
if (finger !== null && trackedTouch(event.touches) === undefined) cancel();
if (finger !== null || event.touches.length !== 1 || !atTop() || scrollsSomethingElse(event.target)) return;
const touch = event.touches[0];
finger = { id: touch.identifier, x: touch.clientX, y: touch.clientY };
}

function onMove(event: TouchEvent) {
const touch = trackedTouch(event.changedTouches);
if (finger === null || touch === undefined) return;
const down = touch.clientY - finger.y;
const sideways = Math.abs(touch.clientX - finger.x) > down;
if (down <= 0 || !atTop() || (distance === null && sideways)) {
cancel();
return;
}
event.preventDefault();
distance = pullDistance(down);
show(distance);
}

function onEnd(event: TouchEvent) {
if (trackedTouch(event.changedTouches) === undefined) return;
if (distance === null || distance < THRESHOLD) {
cancel();
return;
}
finger = null;
distance = null;
// The icon keeps its rotation, so the spin carries on from where the finger left it.
mark.setAttribute("data-refreshing", "");
mark.removeAttribute("style");
mark.removeAttribute("data-armed");
icon.style.removeProperty("transition");
onPulled();
}

scroller.addEventListener("touchstart", onStart, { passive: true });
scroller.addEventListener("touchmove", onMove, { passive: false });
scroller.addEventListener("touchend", onEnd);
scroller.addEventListener("touchcancel", cancel);
return function stopListening() {
scroller.removeEventListener("touchstart", onStart);
scroller.removeEventListener("touchmove", onMove);
scroller.removeEventListener("touchend", onEnd);
scroller.removeEventListener("touchcancel", cancel);
if (finger !== null) cancel();
};
}

/** A scroll container that refetches every active query when a finger pulls it down from the top. */
export default function PullToRefresh({ children, ...props }: HTMLAttributes<HTMLDivElement>) {
const scrollRef = useRef<HTMLDivElement>(null);
const markRef = useRef<HTMLDivElement>(null);
const iconRef = useRef<SVGSVGElement>(null);
const queryClient = useQueryClient();
const reduceMotion = usePrefersReducedMotion();
const [refreshing, setRefreshing] = useState(false);

useEffect(function watchForPull() {
const scroller = scrollRef.current;
const mark = markRef.current;
const icon = iconRef.current;
if (scroller === null || mark === null || icon === null || refreshing) return;
async function refresh() {
setRefreshing(true);
await queryClient.refetchQueries({ type: "active" });
setRefreshing(false);
}
return listenForPull({ scroller, mark, icon, reduceMotion }, function refreshOnPull() {
void refresh();
});
}, [queryClient, reduceMotion, refreshing]);

return (
<div {...props} ref={scrollRef}>
<div className="pull-refresh" aria-hidden="true">
<div className="pull-mark" ref={markRef} data-refreshing={refreshing ? "" : undefined}>
<RotateCw ref={iconRef} size={16} strokeWidth={1.8} />
</div>
</div>
<p role="status" className="sr-only">{refreshing ? "Refreshing" : ""}</p>
{children}
</div>
);
}
2 changes: 1 addition & 1 deletion apps/web/src/lib/held-dos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function none(): string[] {
}

function useKeys(queryKey: unknown[]): string[] {
const { data } = useQuery({ queryKey, queryFn: none, staleTime: Infinity, gcTime: Infinity });
const { data } = useQuery({ queryKey, queryFn: none, staleTime: "static", gcTime: Infinity });
return data ?? [];
}

Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/lib/open-pulls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function noSentReplies(): SentReplies {

function useSentReplies(): SentReplies {
const { snapshot } = usePortal();
const { data } = useQuery({ queryKey: sentRepliesKey(snapshot?.workspace.tenantId), queryFn: noSentReplies, staleTime: Infinity });
const { data } = useQuery({ queryKey: sentRepliesKey(snapshot?.workspace.tenantId), queryFn: noSentReplies, staleTime: "static" });
return data ?? {};
}

Expand Down Expand Up @@ -102,7 +102,7 @@ export function useActedPulls(): { acted: string[]; replace: (keys: string[]) =>
const { snapshot } = usePortal();
const queryClient = useQueryClient();
const tenantId = snapshot?.workspace.tenantId;
const { data } = useQuery({ queryKey: actedKey(tenantId), queryFn: noneActed, staleTime: Infinity, gcTime: Infinity });
const { data } = useQuery({ queryKey: actedKey(tenantId), queryFn: noneActed, staleTime: "static", gcTime: Infinity });
const replace = useCallback(function replaceActed(keys: string[]) {
queryClient.setQueryData<string[]>(actedKey(tenantId), keys);
}, [queryClient, tenantId]);
Expand All @@ -119,7 +119,7 @@ function noneHandled(): Handled {
}

function useHandledQuery(queryKey: Array<string | undefined>): Handled {
const { data } = useQuery({ queryKey, queryFn: noneHandled, staleTime: Infinity, gcTime: Infinity });
const { data } = useQuery({ queryKey, queryFn: noneHandled, staleTime: "static", gcTime: Infinity });
return data ?? {};
}

Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/lib/pulse-visit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ function usePulseState(open: OpenItem[]): PulseState {
const saved = readSaved(storage);
return saved === null ? null : resumeVisit(saved, Date.now());
},
staleTime: Infinity,
staleTime: "static",
gcTime: Infinity,
});
const entries = useMemo(() => allOpen(open, triageEnabledRepos(snapshot?.repos ?? [])), [open, snapshot]);
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/pages/Inbox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import { findPrItem } from "../lib/triage-view.ts";
import { useCheckPacks, useRepoPack } from "../lib/check-packs.ts";
import { FilterMenu, FilterPills } from "../components/InboxFilter.tsx";
import { ExternalIcon, SearchIcon } from "../components/inbox-icons.tsx";
import PullToRefresh from "../components/PullToRefresh.tsx";
import { pullUrl, viewOnHost } from "../lib/pull-link.ts";

const GROUPING_LABEL: Record<InboxGrouping | OpenGrouping, string> = { action: "Action", repo: "Repo", waits: "Waits on", owner: "Owner" };
Expand Down Expand Up @@ -407,7 +408,7 @@ function Pane({ item, handled, listTitle, restored, sectionRef, onHold, onBack }
return (
<section ref={sectionRef} className="panel pane" aria-label="Selected pull request" tabIndex={-1}>
<Button type="button" variant="ghost" size="sm" className="pane-back" onClick={onBack}>Back to {listTitle}</Button>
<div className="scroll">
<PullToRefresh className="scroll">
<div className="pane-in r3">
<header className="ph">
<div className="crumb">
Expand All @@ -434,7 +435,7 @@ function Pane({ item, handled, listTitle, restored, sectionRef, onHold, onBack }
{done === null ? null : <p role="status" className="note">{done}</p>}
<ReportRows item={item} pull={{ data: pull.data, loading: pull.isLoading, error: pull.error }} pack={pack} />
</div>
</div>
</PullToRefresh>
{!ended && <BottomBar
bar={bar}
draft={draft}
Expand Down Expand Up @@ -754,7 +755,7 @@ export default function Inbox({ view }: { view: ListView }) {
) : null}
{searchable || activeFilters(filters).length > 0 ? <FilterPills filters={filters} onChange={setFilters} triggerRef={filterRef} /> : null}
</div>
<div className="scroll" id="main">
<PullToRefresh className="scroll" id="main">
<RefusedPolicies repos={enabledNames} />
<PolicyUpgradeNotice />
{verdictsError && <p role="alert" className="error">Could not read triage results. {verdictsError.message}</p>}
Expand All @@ -765,7 +766,7 @@ export default function Inbox({ view }: { view: ListView }) {
{piles.map((pile) => <Pile key={pile.key} view={view} byRepo={byRepo} pile={pile} selectedKey={selected?.key} search={search} />)}
</div>
)}
</div>
</PullToRefresh>
</section>
{selected ? (
<Pane
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/shell.css
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,21 @@
/* Shared by the inbox and the repositories table. Panels are scoped to those two because other pages still use the older .panel. */
:is(.inbox, .repos) .panel { background: var(--white); border: 1px solid var(--rule); border-radius: var(--r-lg); padding: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; position: relative; }
:is(.inbox, .repos) .scroll { flex: 1; min-height: 0; overflow: auto; }

/* Pull to refresh: a mark drops in over the top of a list or pane, so the content and its scroll never move. */
.pull-refresh { position: sticky; top: 0; z-index: 2; height: 0; pointer-events: none; }
.pull-mark { position: absolute; top: -44px; left: calc(50% - 18px); display: grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--rule); border-radius: 50%; background: var(--white); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); color: var(--ink-mute); opacity: 0; transition: transform 200ms var(--ease-out), opacity 200ms var(--ease-out); }
.pull-mark svg { transition: transform 200ms var(--ease-out); }
.pull-mark:is([data-armed], [data-refreshing]) { color: var(--ink); }
.pull-mark[data-refreshing] { opacity: 1; transform: translateY(64px); }
.pull-mark[data-refreshing] svg { animation: pull-spin 600ms linear infinite; }
@keyframes pull-spin { to { rotate: 360deg; } }
@keyframes pull-pulse { to { opacity: 0.4; } }
@media (prefers-reduced-motion: reduce) {
.pull-mark { transform: translateY(64px); }
.pull-mark[data-refreshing] { animation: pull-pulse 600ms ease-in-out infinite alternate; }
.pull-mark[data-refreshing] svg { animation: none; }
}
:is(.inbox, .repos) .lh { padding: 16px 16px 10px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; border-bottom: 1px solid var(--rule); }
:is(.inbox, .repos) .lh-top { display: flex; align-items: baseline; gap: 10px; }
:is(.inbox, .repos) .lh-top h1 { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.01em; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -395,7 +395,7 @@ When a newer version of Corbits Triage is released while the portal is open, a n

Each view with pull requests in it has a search box and a **Filter** button. **Needs you** and **All open** add quick filters such as Action, Repo and Owner; **All open** and **Waiting** add Waits on. The three counters in the corner show how many pull requests are new since your last visit, how many were resolved, and how many are stuck.

On a phone the strip becomes a bar at the bottom of the screen with **Needs you**, **Ready**, **All open**, **Repos** and **Settings**; tap the counters for Pulse. Each row shows the title on its own line, with the number, status and age beneath it.
On a phone the strip becomes a bar at the bottom of the screen with **Needs you**, **Ready**, **All open**, **Repos** and **Settings**; tap the counters for Pulse. Each row shows the title on its own line, with the number, status and age beneath it. To refresh a list or an open pull request, pull it down from the top until the round arrow turns dark, then let go; it spins until the latest is in, and your place in the list stays where it was.

### Needs you

Expand Down
Loading