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: 30 additions & 7 deletions app/app/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,10 @@ function buildSnapshot(streams: StreamData[], range: string): AnalyticsSnapshot
}

export default function AnalyticsPage() {
const { all } = useStreams({ enablePolling: false })
// Issue #674: `loading` was never destructured, so the four stat cards
// below rendered misleading "0" values before real data resolved instead
// of a loading state.
const { all, loading } = useStreams({ enablePolling: false })
const { network } = useNetwork()
const [range, setRange] = useState('30d')
const [mounted, setMounted] = useState(false)
Expand Down Expand Up @@ -260,9 +263,13 @@ export default function AnalyticsPage() {
<CardHeader className="pb-2">
<CardDescription>Total volume streamed</CardDescription>
<CardTitle className="text-2xl font-semibold">
{snapshot.totalVolume > 0n
? formatCompactAmount(snapshot.totalVolume, snapshot.tokenShares[0]?.decimals ?? 7)
: '0'}
{loading ? (
<span className="inline-block h-7 w-24 animate-pulse rounded bg-muted" />
) : snapshot.totalVolume > 0n ? (
formatCompactAmount(snapshot.totalVolume, snapshot.tokenShares[0]?.decimals ?? 7)
) : (
'0'
)}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
Expand All @@ -272,7 +279,13 @@ export default function AnalyticsPage() {
<Card>
<CardHeader className="pb-2">
<CardDescription>Active streams</CardDescription>
<CardTitle className="text-2xl font-semibold">{snapshot.activeCount}</CardTitle>
<CardTitle className="text-2xl font-semibold">
{loading ? (
<span className="inline-block h-7 w-12 animate-pulse rounded bg-muted" />
) : (
snapshot.activeCount
)}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
<TrendingUp className="size-4" /> Currently streaming now
Expand All @@ -281,7 +294,13 @@ export default function AnalyticsPage() {
<Card>
<CardHeader className="pb-2">
<CardDescription>Total streams created</CardDescription>
<CardTitle className="text-2xl font-semibold">{snapshot.totalStreams}</CardTitle>
<CardTitle className="text-2xl font-semibold">
{loading ? (
<span className="inline-block h-7 w-12 animate-pulse rounded bg-muted" />
) : (
snapshot.totalStreams
)}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
<BarChart3 className="size-4" /> All-time stream count
Expand All @@ -291,7 +310,11 @@ export default function AnalyticsPage() {
<CardHeader className="pb-2">
<CardDescription>Average duration</CardDescription>
<CardTitle className="text-2xl font-semibold">
{snapshot.averageDurationDays.toFixed(1)}d
{loading ? (
<span className="inline-block h-7 w-14 animate-pulse rounded bg-muted" />
) : (
`${snapshot.averageDurationDays.toFixed(1)}d`
)}
</CardTitle>
</CardHeader>
<CardContent className="flex items-center gap-2 text-sm text-muted-foreground">
Expand Down
30 changes: 29 additions & 1 deletion app/app/create/create-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,13 @@ export function CreateForm() {
setRecipientInput(draft.recipient);
},
true,
// Issue #676: surface draft-save failures instead of silently
// discarding them.
() =>
toast.warning("Your draft isn't being saved", {
description:
"Storage is full or unavailable — your progress won't be restored if you leave this page.",
}),
);

// Check for existing draft on first mount
Expand Down Expand Up @@ -888,7 +895,19 @@ export function CreateForm() {
)}
</div>

{/* Issue #186: USD equivalent + per-second rate */}
{/* Issue #186: USD equivalent + per-second rate.
Issue #675: `priceLoading` used to be nested inside this
block, but by the time `usdEquivalent` is truthy the price
has already resolved (loading is always false here) — so
it could never actually render. Show a distinct loading
indicator whenever a price is being fetched and the user
has entered an amount, separately from the "here's the
USD value" case below. */}
{priceLoading && tokenAmountNum > 0 && !usdInputMode && (
<p className="text-xs text-muted-foreground opacity-60">
Fetching price…
</p>
)}
{usdEquivalent && !usdInputMode && (
<p className="text-xs text-muted-foreground">
{copy.amountSection.usdEquivalent(usdEquivalent)}
Expand All @@ -910,6 +929,15 @@ export function CreateForm() {
)}
</span>
)}
</p>
)}
{/* Issue #675: distinct from the loading state above — the
price fetch has finished but no price is available for
this token (not XLM/USDC/EURC, or the fetch failed with no
cached fallback). */}
{!priceLoading && !supportsUsd && tokenAmountNum > 0 && !usdInputMode && (
<p className="text-xs text-muted-foreground">
Price unavailable for {selectedToken.symbol}
{priceLoading && (
<span className="ml-1 opacity-60">
{copy.amountSection.fetchingPrice}
Expand Down
13 changes: 10 additions & 3 deletions components/streams/stream-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function StreamCardInner({
const interval = getInterval(stream)
const now = useNow(interval)
const { address } = useWallet()
const { usdPrice } = useTokenPrice(stream.token.symbol)
const { usdPrice, loading: priceLoading } = useTokenPrice(stream.token.symbol)
const [showUsd] = useShowUsd()
const isCancelling = useIsStreamCancelling(stream.id)
const { isBlocked, hideStream, unhideStream, blockSender, unblockSender } = useHiddenStreams()
Expand Down Expand Up @@ -239,8 +239,15 @@ function StreamCardInner({
className="text-lg font-semibold"
maxFractionDigits={2}
/>
{usdValue !== null && (
<p className="text-xs text-muted-foreground">{formatUsd(usdValue)}</p>
{/* Issue #675: loading and unavailable previously rendered
identically (nothing) — show a distinct skeleton while the
price is still being fetched. */}
{showUsd && usdValue === null && priceLoading ? (
<div className="mt-0.5 h-3 w-12 animate-pulse rounded bg-muted" aria-label="Loading price" />
) : (
usdValue !== null && (
<p className="text-xs text-muted-foreground">{formatUsd(usdValue)}</p>
)
)}
</div>
<div className="text-right">
Expand Down
9 changes: 8 additions & 1 deletion components/webhooks/webhook-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@ const ALL_EVENTS: { value: WebhookEventType; label: string }[] = [
]

export function WebhookSettings() {
const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks()
// Issue #677: surface webhook-config save failures instead of letting
// localStorage.setItem throw uncaught / fail silently.
const { webhooks, history, addWebhook, removeWebhook, toggleWebhook, testWebhook } = useWebhooks(
() =>
toast.warning("Webhook settings aren't being saved", {
description: 'Storage is full or unavailable — your changes may not persist.',
}),
)

const [url, setUrl] = useState('')
const [urlError, setUrlError] = useState('')
Expand Down
18 changes: 16 additions & 2 deletions hooks/use-form-draft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,16 @@ export function useFormDraft<T>(
value: T,
onChange: (draft: T) => void,
enabled = true,
// Issue #676: draft-save failures (quota exceeded, private browsing) used
// to be silently discarded — the caller can surface this however fits
// (toast, inline notice, ...). Only fires once per failure streak, not on
// every debounced save, so it stays a lightweight one-time warning rather
// than spamming the user.
onSaveError?: () => void,
) {
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const isRestoringRef = useRef(false)
const hasWarnedRef = useRef(false)

const storageKey = `flowstar_draft_${key}`

Expand All @@ -25,11 +32,18 @@ export function useFormDraft<T>(
try {
const entry: DraftEntry<T> = { data, savedAt: Date.now() }
localStorage.setItem(storageKey, JSON.stringify(entry))
hasWarnedRef.current = false
} catch {
// storage quota exceeded or unavailable — silently skip
// storage quota exceeded or unavailable — the draft itself is still
// silently skipped (nothing else we can do), but the user is now
// told their progress isn't being saved.
if (!hasWarnedRef.current) {
hasWarnedRef.current = true
onSaveError?.()
}
}
},
[storageKey],
[storageKey, onSaveError],
)

const discard = useCallback(() => {
Expand Down
61 changes: 47 additions & 14 deletions hooks/use-webhooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'

export type WebhookEventType =
| 'stream.created'
Expand Down Expand Up @@ -45,8 +45,18 @@ function loadWebhooks(): WebhookConfig[] {
}
}

function saveWebhooks(hooks: WebhookConfig[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(hooks))
// Issue #677: these used to call localStorage.setItem unguarded, which can
// throw (quota exceeded, private browsing) and crash the settings UI
// interaction that triggered it. Now wrapped in try/catch, matching
// use-form-draft.ts's already-guarded pattern, and reports success so
// callers can surface a warning instead of silently losing the write.
function saveWebhooks(hooks: WebhookConfig[]): boolean {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(hooks))
return true
} catch {
return false
}
}

function loadHistory(): WebhookDelivery[] {
Expand All @@ -58,8 +68,13 @@ function loadHistory(): WebhookDelivery[] {
}
}

function saveHistory(history: WebhookDelivery[]) {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)))
function saveHistory(history: WebhookDelivery[]): boolean {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(0, MAX_HISTORY)))
return true
} catch {
return false
}
}

async function deliverWithRetry(
Expand All @@ -85,9 +100,27 @@ async function deliverWithRetry(
return { statusCode: null, success: false }
}

export function useWebhooks() {
export function useWebhooks(onSaveError?: () => void) {
const [webhooks, setWebhooks] = useState<WebhookConfig[]>([])
const [history, setHistory] = useState<WebhookDelivery[]>([])
const hasWarnedRef = useRef(false)

// Issue #677: only warn once per failure streak, resetting as soon as a
// save succeeds again — a lightweight one-time notice, not a toast per
// keystroke/action while storage stays unavailable.
const reportSaveResult = useCallback(
(ok: boolean) => {
if (ok) {
hasWarnedRef.current = false
return
}
if (!hasWarnedRef.current) {
hasWarnedRef.current = true
onSaveError?.()
}
},
[onSaveError],
)

useEffect(() => {
setWebhooks(loadWebhooks())
Expand All @@ -104,26 +137,26 @@ export function useWebhooks() {
}
setWebhooks((prev) => {
const next = [...prev, hook]
saveWebhooks(next)
reportSaveResult(saveWebhooks(next))
return next
})
}, [])
}, [reportSaveResult])

const removeWebhook = useCallback((id: string) => {
setWebhooks((prev) => {
const next = prev.filter((h) => h.id !== id)
saveWebhooks(next)
reportSaveResult(saveWebhooks(next))
return next
})
}, [])
}, [reportSaveResult])

const toggleWebhook = useCallback((id: string) => {
setWebhooks((prev) => {
const next = prev.map((h) => (h.id === id ? { ...h, enabled: !h.enabled } : h))
saveWebhooks(next)
reportSaveResult(saveWebhooks(next))
return next
})
}, [])
}, [reportSaveResult])

const fireEvent = useCallback(
async (eventType: WebhookEventType, data: WebhookPayload) => {
Expand All @@ -140,12 +173,12 @@ export function useWebhooks() {
}
setHistory((prev) => {
const next = [delivery, ...prev]
saveHistory(next)
reportSaveResult(saveHistory(next))
return next
})
}
},
[webhooks]
[webhooks, reportSaveResult]
)

const testWebhook = useCallback(async (id: string): Promise<boolean> => {
Expand Down
Loading