Two unrelated bits the user asked for in the same breath:
1. Activity row swipe-to-reveal actions
----------------------------------------
On the mobile activity tab, drag a row left to reveal an Archive
button (Restore when already archived) and a Delete button. Past a
60 px threshold the shelf locks open; below that it springs back.
Tapping anywhere outside an open row closes it. Desktop keeps a
table layout but gains the same two row-level icon-buttons in a
new Actions column, since hover-then-discover is more natural with
a mouse than a swipe.
- New `<SwipeableRow>` (apps/web/src/components/swipeable-row.tsx)
— pointer-events only (no third-party gesture lib), 130 lines.
The drag math lives in a pure helper `computeSwipeNext` so it's
unit-testable without a DOM.
- Migration 0007 adds `reminder_runs.archived_at timestamptz`
(null = visible by default, non-null = archived). Soft-archive
keeps the row queryable under a new "Archived" filter tab; hard
Delete drops the row entirely (run_targets cascade via FK).
- Server actions: `archiveRunAction` / `unarchiveRunAction` /
`deleteRunAction`. Each rate-limits to 30/min/IP. Ownership
check piggybacks on the same operator-or-orphan rule the
activity query already uses.
- `listActivityRuns(operatorId, { archived })` extended to filter
in or out of the archived window. Default is archived: false so
the existing tabs (All / Success / Partial / Failed / Skipped)
keep showing only live runs.
- Tests
* `swipeable-row.test.tsx` — 6 unit tests covering the drag math
(clamp at 0 / -SHELF_WIDTH, snap-to-closed below threshold,
snap-to-open at or past threshold, snap math respects the
previous offset) plus 2 SSR markup contracts (data-testid /
aria-hidden / starts at translateX(0px) / data-state="closed").
* Total web suite: 154 passing (was 146).
2. Send-test toast text trim
----------------------------------------
"Sent ✓ — check the WhatsApp group." → "Sent ✓". The trailing
note told the user something they could already see (they're the
one who clicked Send Test on a specific group). Less noise.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
111 lines
3.4 KiB
TypeScript
111 lines
3.4 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useActionState } from "react";
|
|
import { CheckCircle2Icon, AlertCircleIcon, Loader2Icon } from "lucide-react";
|
|
import { sendTestAction, type SendTestResult } from "@/actions/groups";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useEvents } from "@/hooks/use-events";
|
|
|
|
const initial: SendTestResult | null = null;
|
|
|
|
type Outcome =
|
|
| { kind: "idle" }
|
|
| { kind: "in-flight"; message: string }
|
|
| { kind: "sent"; message: string }
|
|
| { kind: "error"; message: string };
|
|
|
|
export function SendTestForm({ groupId }: { groupId: string }) {
|
|
const [state, formAction, isPending] = useActionState(sendTestAction, initial);
|
|
const [outcome, setOutcome] = useState<Outcome>({ kind: "idle" });
|
|
|
|
// Bridge the optimistic action result into our richer Outcome state.
|
|
// The action's `ok` only confirms the IPC NOTIFY was published — we
|
|
// wait for the bot's `send_test.done` event below to know whether
|
|
// WhatsApp actually accepted the message.
|
|
useEffect(() => {
|
|
if (!state) return;
|
|
if (state.ok) {
|
|
setOutcome({ kind: "in-flight", message: state.message });
|
|
} else {
|
|
setOutcome({ kind: "error", message: state.error });
|
|
}
|
|
}, [state]);
|
|
|
|
// Subscribe to the bot's reply. Only act on events for OUR groupId so
|
|
// a parallel send-test on another group doesn't move our state.
|
|
useEvents({
|
|
"send_test.done": (data) => {
|
|
if (data.groupId !== groupId) return;
|
|
if (data.ok) {
|
|
setOutcome({ kind: "sent", message: "Sent ✓" });
|
|
} else {
|
|
setOutcome({
|
|
kind: "error",
|
|
message: data.error ?? "Send failed — see bot logs for details.",
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
const sending = isPending || outcome.kind === "in-flight";
|
|
|
|
return (
|
|
<form action={formAction} className="space-y-3">
|
|
<input type="hidden" name="groupId" value={groupId} />
|
|
<div>
|
|
<Label htmlFor="text" className="text-sm">
|
|
Message
|
|
</Label>
|
|
<Textarea
|
|
id="text"
|
|
name="text"
|
|
placeholder="What should I send to this group?"
|
|
rows={4}
|
|
required
|
|
maxLength={4000}
|
|
className="mt-1"
|
|
/>
|
|
</div>
|
|
|
|
{outcome.kind === "in-flight" && (
|
|
<p
|
|
className="flex items-center gap-1.5 text-sm text-muted-foreground"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<Loader2Icon className="size-3.5 animate-spin" aria-hidden />
|
|
{outcome.message}
|
|
</p>
|
|
)}
|
|
{outcome.kind === "sent" && (
|
|
<p
|
|
className="flex items-center gap-1.5 text-sm text-emerald-600 dark:text-emerald-400"
|
|
role="status"
|
|
aria-live="polite"
|
|
>
|
|
<CheckCircle2Icon className="size-3.5" aria-hidden />
|
|
{outcome.message}
|
|
</p>
|
|
)}
|
|
{outcome.kind === "error" && (
|
|
<p
|
|
className="flex items-center gap-1.5 text-sm text-destructive"
|
|
role="alert"
|
|
>
|
|
<AlertCircleIcon className="size-3.5" aria-hidden />
|
|
{outcome.message}
|
|
</p>
|
|
)}
|
|
|
|
<div className="flex justify-end">
|
|
<Button type="submit" disabled={sending}>
|
|
{sending ? "Sending…" : "Send Test"}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
);
|
|
}
|