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
37 changes: 36 additions & 1 deletion src/lib/components/IntentListDetailRow.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script lang="ts">
import type { TimedIntentRow } from "$lib/libraries/intentList";
import { copyToClipboard } from "$lib/utils/clipboard";

let {
row,
Expand All @@ -10,6 +11,26 @@
rightBadge: string;
rightText: string;
} = $props();

let copied = $state(false);
let copyTimer: ReturnType<typeof setTimeout> | undefined;

async function handleCopyOrderId(event: Event) {
// Don't let the click select / toggle the parent card button.
event.stopPropagation();
if (!(await copyToClipboard(row.orderId))) return;
copied = true;
clearTimeout(copyTimer);
copyTimer = setTimeout(() => (copied = false), 1200);
}

function handleCopyKeydown(event: KeyboardEvent) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
event.stopPropagation();
handleCopyOrderId(event);
}
}
</script>

<div class="flex items-start justify-between gap-2">
Expand Down Expand Up @@ -56,7 +77,21 @@
{#each row.protocolBadges as badge (badge)}
<span class="rounded bg-gray-100 px-1.5 py-0.5">{badge}</span>
{/each}
<span class="rounded bg-gray-100 px-1.5 py-0.5">Order {row.orderIdShort}</span>
<span
role="button"
tabindex="0"
class="cursor-pointer rounded px-1.5 py-0.5 transition-colors"
class:bg-gray-100={!copied}
class:hover:bg-gray-200={!copied}
class:bg-emerald-100={copied}
class:text-emerald-800={copied}
style="-webkit-tap-highlight-color: transparent;"
title={copied ? "Copied!" : `Copy full order id (${row.orderId})`}
aria-label={copied ? "Order id copied" : `Copy full order id ${row.orderId}`}
data-testid="intent-copy-order-id"
onclick={handleCopyOrderId}
onkeydown={handleCopyKeydown}>{copied ? "Copied!" : `Order ${row.orderIdShort}`}</span
>
<span class="rounded bg-gray-100 px-1.5 py-0.5">User {row.userShort}</span>
<span class="rounded bg-gray-100 px-1.5 py-0.5"
>{row.inputCount} inputs • {row.outputCount} outputs</span
Expand Down
16 changes: 16 additions & 0 deletions src/lib/libraries/intentList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type BaseIntentRow = {
orderIdShort: string;
userShort: string;
fillDeadline: number;
/** Submit time in unix seconds: order-server submitTime if known, else local created time. */
submitTime?: number;
inputCount: number;
outputCount: number;
chainScope: ChainScope;
Expand All @@ -52,6 +54,17 @@ export type TimedIntentRow = BaseIntentRow & {
export const EXPIRING_THRESHOLD_SECONDS = 5 * 60;
export const MAX_CHIPS_PER_SIDE = 2;

// Sort the intent list by submit time, latest first. `fillDeadline` is the
// deterministic tiebreaker (ascending for active = soonest expiry first;
// descending for expired = preserves prior behaviour). Rows without a submit
// time sort last. Array.prototype.sort is stable, so ties on both keys keep
// source order.
export const compareActiveRows = (a: BaseIntentRow, b: BaseIntentRow) =>
(b.submitTime ?? 0) - (a.submitTime ?? 0) || a.fillDeadline - b.fillDeadline;

export const compareExpiredRows = (a: BaseIntentRow, b: BaseIntentRow) =>
(b.submitTime ?? 0) - (a.submitTime ?? 0) || b.fillDeadline - a.fillDeadline;

function flattenInputs(inputs: { chainId: bigint; inputs: [bigint, bigint][] }[]) {
return inputs.flatMap((chainInput) => {
return chainInput.inputs.map((input) => ({
Expand Down Expand Up @@ -211,6 +224,9 @@ export function buildBaseIntentRow(orderContainer: OrderContainer): BaseIntentRo
orderIdShort: shortAddress(orderId, 10, 4),
userShort: shortAddress(order.user, 8, 4),
fillDeadline: order.fillDeadline,
submitTime: ((orderContainer as any).submitTime ?? (orderContainer as any).createdAt) as
| number
| undefined,
inputCount: inputChipsRaw.length,
outputCount: outputChipsRaw.length,
chainScope,
Expand Down
13 changes: 12 additions & 1 deletion src/lib/libraries/orderServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,23 @@ export function parseOrderStatusPayload(payload: unknown): OrderContainer {
? normalizeStandardOrder(rawOrder)
: normalizeMultichainOrder(rawOrder);

return {
const container: OrderContainer = {
inputSettler: toHexString(envelope.inputSettler, "inputSettler"),
order,
sponsorSignature: normalizeSignature(envelope.sponsorSignature),
allocatorSignature: normalizeSignature(envelope.allocatorSignature)
};

// Capture the order-server submit time so the intent list can sort by it.
// Normalize ms -> s defensively (the field is only typed as `number`).
const meta = (envelope as Record<string, unknown>).meta as { submitTime?: unknown } | undefined;
const rawSubmit = typeof meta?.submitTime === "number" ? meta.submitTime : undefined;
if (rawSubmit !== undefined) {
(container as { submitTime?: number }).submitTime =
rawSubmit > 1e12 ? Math.floor(rawSubmit / 1000) : rawSubmit;
}

return container;
}

export class OrderServer {
Expand Down
55 changes: 36 additions & 19 deletions src/lib/screens/IntentList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
withTiming,
formatRelativeDeadline,
formatRemaining,
compareActiveRows,
compareExpiredRows,
type TimedIntentRow
} from "$lib/libraries/intentList";
import ScreenFrame from "$lib/components/ui/ScreenFrame.svelte";
Expand Down Expand Up @@ -86,21 +88,37 @@
}, 1000);
onDestroy(() => clearInterval(clock));

async function handleSelectActive(row: TimedIntentRow) {
selectedOrder = row.orderContainer;
await tick();
scroll(3)();
}

function toggleExpired(orderId: string) {
expandedExpiredOrderId = expandedExpiredOrderId === orderId ? undefined : orderId;
}

// The card wrappers use role="button" (not a native <button>) so the copy
// affordance inside the row is valid interactive content. Mirror native
// button activation for keyboard users.
function handleCardKeydown(event: KeyboardEvent, activate: () => void) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
activate();
}
}

const baseRows = $derived(
orderContainers.map((orderContainer) => buildBaseIntentRow(orderContainer))
);
const rows = $derived(baseRows.map((row) => withTiming(row, nowSeconds)));

const activeRows = $derived(
[...rows]
.filter((row) => row.status !== "expired")
.sort((a, b) => a.fillDeadline - b.fillDeadline)
[...rows].filter((row) => row.status !== "expired").sort(compareActiveRows)
);

const expiredRows = $derived(
[...rows]
.filter((row) => row.status === "expired")
.sort((a, b) => b.fillDeadline - a.fillDeadline)
[...rows].filter((row) => row.status === "expired").sort(compareExpiredRows)
);

const selectedOrderId = $derived(
Expand Down Expand Up @@ -150,23 +168,22 @@
<div class="space-y-2">
{#each activeRows as row (row.orderId)}
<div class="relative">
<button
<div
role="button"
tabindex="0"
class:border-amber-300={row.status === "expiring"}
class:bg-amber-50={row.status === "expiring"}
class="w-full cursor-pointer rounded border border-gray-200 bg-white px-2 py-2 text-left transition-shadow ease-linear select-none hover:shadow-md focus:outline-none focus-visible:outline-none"
style="-webkit-tap-highlight-color: transparent;"
onclick={async () => {
selectedOrder = row.orderContainer;
await tick();
scroll(3)();
}}
onclick={() => handleSelectActive(row)}
onkeydown={(event) => handleCardKeydown(event, () => handleSelectActive(row))}
>
<IntentListDetailRow
{row}
rightBadge={getActiveRightBadge(row, selectedOrderId)}
rightText={getActiveRightText(row, selectedOrderId)}
/>
</button>
</div>
<button
type="button"
class="absolute right-2 bottom-2 rounded border border-rose-200 bg-white px-1.5 py-0.5 text-[10px] font-semibold text-rose-700 hover:border-rose-300 disabled:cursor-not-allowed disabled:text-rose-300"
Expand All @@ -184,13 +201,13 @@
<div class="space-y-1">
{#each expiredRows as row (row.orderId)}
<div class="relative rounded border border-gray-200 bg-gray-50">
<button
<div
role="button"
tabindex="0"
class="w-full cursor-pointer px-2 py-1.5 text-left text-xs text-gray-500 transition-colors select-none hover:bg-gray-100 focus:outline-none focus-visible:outline-none"
style="-webkit-tap-highlight-color: transparent;"
onclick={async () => {
expandedExpiredOrderId =
expandedExpiredOrderId === row.orderId ? undefined : row.orderId;
}}
onclick={() => toggleExpired(row.orderId)}
onkeydown={(event) => handleCardKeydown(event, () => toggleExpired(row.orderId))}
>
{#if expandedExpiredOrderId === row.orderId}
<IntentListDetailRow
Expand All @@ -206,7 +223,7 @@
<div class="flex-shrink-0">{formatRelativeDeadline(row.secondsToDeadline)}</div>
</div>
{/if}
</button>
</div>
{#if expandedExpiredOrderId === row.orderId}
<button
type="button"
Expand Down
15 changes: 14 additions & 1 deletion src/lib/state.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,27 @@ class Store {
if (!db) await initDb();
if (!db) return;
const rows = await db!.select().from(intents);
this.orders = rows.map((r: any) => JSON.parse(r.data) as OrderContainer);
this.orders = rows.map((r: any) => {
const order = JSON.parse(r.data) as OrderContainer;
// Re-attach the authoritative column values dropped by JSON.parse. The
// dedicated `created_at` column always wins over anything stale in the
// blob; `submitTime` (order-server time) round-trips inside the blob.
(order as any).id = r.id;
(order as any).intentType = r.intentType;
(order as any).createdAt = r.createdAt;
return order;
});
}

async saveOrderToDb(order: OrderContainer) {
if (!browser) return;
if (!db) await initDb();
const orderId = orderToIntent(order).orderId();
const now = Math.floor(Date.now() / 1000);
// Stamp the local "added to list" time onto the in-memory object so freshly
// created/imported intents sort correctly immediately (the column is only
// read back on reload). Guard so re-saves don't overwrite the original.
if ((order as any).createdAt === undefined) (order as any).createdAt = now;
const id =
(order as any).id ?? (typeof crypto !== "undefined" ? crypto.randomUUID() : String(now));
const intentType = (order as any).intentType ?? "escrow";
Expand Down
34 changes: 34 additions & 0 deletions src/lib/utils/clipboard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Copy text to the clipboard. Returns true on success, false otherwise.
*
* Uses the async Clipboard API when available (secure contexts: https / localhost),
* with a legacy execCommand fallback for insecure contexts (e.g. plain-http on a
* LAN IP, where `navigator.clipboard` is undefined) and older browsers. The
* `typeof` guards keep this import safe under SSR/prerender.
*/
export async function copyToClipboard(text: string): Promise<boolean> {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through to the legacy path
}
}

if (typeof document === "undefined") return false;

try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.cssText = "position:fixed;top:-9999px;opacity:0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
41 changes: 41 additions & 0 deletions tests/unit/intentList.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { describe, expect, it } from "bun:test";
import {
EXPIRING_THRESHOLD_SECONDS,
compareActiveRows,
compareExpiredRows,
formatRelativeDeadline,
formatRemaining,
withTiming,
Expand Down Expand Up @@ -69,3 +71,42 @@ describe("intentList timing and formatting", () => {
expect(formatRelativeDeadline(-30)).toBe("30s ago");
});
});

describe("intentList sort comparators", () => {
const row = (submitTime: number | undefined, fillDeadline: number): BaseIntentRow => ({
...baseRow,
submitTime,
fillDeadline
});

it("sorts the most recent submit time first (active)", () => {
const rows = [row(100, 10), row(300, 10), row(200, 10)];
rows.sort(compareActiveRows);
expect(rows.map((r) => r.submitTime)).toEqual([300, 200, 100]);
});

it("sorts the most recent submit time first (expired)", () => {
const rows = [row(100, 10), row(300, 10), row(200, 10)];
rows.sort(compareExpiredRows);
expect(rows.map((r) => r.submitTime)).toEqual([300, 200, 100]);
});

it("breaks submit-time ties by fillDeadline (active: soonest first)", () => {
const rows = [row(100, 30), row(100, 10), row(100, 20)];
rows.sort(compareActiveRows);
expect(rows.map((r) => r.fillDeadline)).toEqual([10, 20, 30]);
});

it("breaks submit-time ties by fillDeadline (expired: latest first)", () => {
const rows = [row(100, 10), row(100, 30), row(100, 20)];
rows.sort(compareExpiredRows);
expect(rows.map((r) => r.fillDeadline)).toEqual([30, 20, 10]);
});

it("sorts rows without a submit time last", () => {
const rows = [row(undefined, 10), row(50, 10), row(undefined, 5)];
rows.sort(compareActiveRows);
expect(rows[0].submitTime).toBe(50);
expect(rows.slice(1).every((r) => r.submitTime === undefined)).toBe(true);
});
});
Loading
Loading