From f5817fc6506dec48c43a3f76c24959ad1c5893dd Mon Sep 17 00:00:00 2001 From: 0xMosas Date: Sun, 15 Mar 2026 16:46:07 +0100 Subject: [PATCH] fix(activity): use address transactions for tip history --- frontend/src/components/TipHistory.jsx | 188 ++++++++++-------- frontend/src/test/TipHistory.refresh.test.jsx | 151 +++++--------- 2 files changed, 165 insertions(+), 174 deletions(-) diff --git a/frontend/src/components/TipHistory.jsx b/frontend/src/components/TipHistory.jsx index 00f80fb6..327ab38d 100644 --- a/frontend/src/components/TipHistory.jsx +++ b/frontend/src/components/TipHistory.jsx @@ -1,10 +1,8 @@ import { useEffect, useState, useCallback, useMemo } from 'react'; import { fetchCallReadOnlyFunction, cvToJSON, principalCV } from '@stacks/transactions'; import { network } from '../utils/stacks'; -import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_GET_USER_STATS } from '../config/contracts'; +import { CONTRACT_ADDRESS, CONTRACT_NAME, FN_GET_USER_STATS, STACKS_API_BASE } from '../config/contracts'; import { formatSTX, formatAddress } from '../lib/utils'; -import { fetchTipMessages, clearTipCache } from '../lib/fetchTipDetails'; -import { useTipContext } from '../context/TipContext'; import CopyButton from './ui/copy-button'; import ShareTip from './ShareTip'; @@ -12,6 +10,24 @@ const CATEGORY_LABELS = { 0: 'General', 1: 'Content Creation', 2: 'Open Source', 3: 'Community Help', 4: 'Appreciation', 5: 'Education', 6: 'Bug Bounty', }; +const TRANSACTIONS_PAGE_SIZE = 50; +const TRANSACTIONS_REFRESH_MS = 30_000; + +function parsePrincipal(repr) { + if (!repr || typeof repr !== 'string') return null; + return repr.startsWith("'") ? repr.slice(1) : repr; +} + +function parseUint(repr) { + if (!repr || typeof repr !== 'string' || !repr.startsWith('u')) return null; + return repr.slice(1); +} + +function parseUtf8(repr) { + if (!repr || typeof repr !== 'string') return null; + if (!repr.startsWith('u"') || !repr.endsWith('"')) return null; + return repr.slice(2, -1); +} /** * TipHistory -- displays a user's personal tip activity with stats, @@ -26,81 +42,99 @@ const CATEGORY_LABELS = { * @param {string} props.userAddress - The STX address of the logged-in user. */ export default function TipHistory({ userAddress }) { - const { - events, - eventsLoading, - eventsError, - eventsMeta, - lastEventRefresh, - refreshEvents, - loadMoreEvents: contextLoadMore, - } = useTipContext(); + const [tips, setTips] = useState([]); + const [tipsLoading, setTipsLoading] = useState(true); + const [tipsError, setTipsError] = useState(null); + const [tipsMeta, setTipsMeta] = useState({ offset: 0, total: 0, hasMore: false }); + const [lastTipsRefresh, setLastTipsRefresh] = useState(null); const [stats, setStats] = useState(null); const [statsLoading, setStatsLoading] = useState(true); - const [messagesLoading, setMessagesLoading] = useState(false); const [tab, setTab] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); const [loadingMore, setLoadingMore] = useState(false); - // Manual refresh only: invalidate local tip-detail cache, then ask - // TipContext to refresh shared events. Keep this out of auto effects. - const handleRefresh = useCallback(() => { - clearTipCache(); - refreshEvents(); - }, [refreshEvents]); + const contractId = `${CONTRACT_ADDRESS}.${CONTRACT_NAME}`; - // Build a category lookup from tip-categorized events in the cache. - const categoryMap = useMemo(() => { - const map = {}; - events.filter(e => e.event === 'tip-categorized').forEach(e => { - map[e.tipId] = Number(e.category || 0); - }); - return map; - }, [events]); + const fetchTips = useCallback(async (reset = true) => { + if (!userAddress) { + setTips([]); + setTipsLoading(false); + setTipsError(null); + setTipsMeta({ offset: 0, total: 0, hasMore: false }); + return; + } - // Derive this user's tips from the shared event cache. - const tips = useMemo( - () => events - .filter(t => t.event === 'tip-sent') - .filter(t => t.sender === userAddress || t.recipient === userAddress) - .map(t => ({ - ...t, - direction: t.sender === userAddress ? 'sent' : 'received', - category: categoryMap[t.tipId] ?? null, - })), - [events, userAddress, categoryMap], - ); - const tipIds = useMemo( - () => [...new Set(tips.map(t => t.tipId).filter(id => id && id !== '0'))], - [tips], - ); + const offset = reset ? 0 : tipsMeta.offset; + if (reset) { + setTipsLoading(true); + } + + try { + setTipsError(null); + const response = await fetch( + `${STACKS_API_BASE}/extended/v1/address/${userAddress}/transactions?limit=${TRANSACTIONS_PAGE_SIZE}&offset=${offset}` + ); + if (!response.ok) throw new Error(`Stacks API returned ${response.status}`); + + const data = await response.json(); + const rows = Array.isArray(data?.results) ? data.results : []; + + const parsed = rows + .filter((tx) => tx?.tx_type === 'contract_call' && tx?.contract_call?.contract_id === contractId) + .map((tx) => { + const args = tx?.contract_call?.function_args || []; + const recipient = parsePrincipal(args[0]?.repr) || ''; + const amount = parseUint(args[1]?.repr) || '0'; + const message = parseUtf8(args[2]?.repr); + const category = parseUint(args[3]?.repr); + const sender = tx?.sender_address || ''; + + let direction = null; + if (sender === userAddress) direction = 'sent'; + else if (recipient === userAddress) direction = 'received'; + if (!direction) return null; + + return { + tipId: tx.tx_id, + txId: tx.tx_id, + sender, + recipient, + amount, + message, + category: category !== null ? Number(category) : null, + direction, + timestamp: tx?.burn_block_time || tx?.block_time || null, + }; + }) + .filter(Boolean); + + const nextOffset = offset + rows.length; + setTips(prev => reset ? parsed : [...prev, ...parsed]); + setTipsMeta({ + offset: nextOffset, + total: data?.total || 0, + hasMore: nextOffset < (data?.total || 0), + }); + setLastTipsRefresh(new Date()); + } catch (err) { + setTipsError(err.message || 'Failed to load activity'); + } finally { + setTipsLoading(false); + } + }, [userAddress, tipsMeta.offset, contractId]); + + const handleRefresh = useCallback(() => { + fetchTips(true); + }, [fetchTips]); - // Enrich tips with on-chain messages whenever the tip list changes. - const [tipMessages, setTipMessages] = useState({}); useEffect(() => { - if (tipIds.length === 0) return; - let cancelled = false; - setMessagesLoading(true); - fetchTipMessages(tipIds) - .then(messageMap => { - if (cancelled) return; - const obj = {}; - messageMap.forEach((v, k) => { obj[k] = v; }); - setTipMessages(obj); - }) - .catch(err => { if (!cancelled) console.warn('Failed to fetch tip messages:', err.message || err); }) - .finally(() => { if (!cancelled) setMessagesLoading(false); }); - return () => { cancelled = true; }; - }, [tipIds]); + fetchTips(true); + }, [fetchTips]); - // Merge messages into the tip objects for display. - const enrichedTips = useMemo( - () => tips.map(t => { - const msg = tipMessages[String(t.tipId)]; - return msg ? { ...t, message: msg } : t; - }), - [tips, tipMessages], - ); + useEffect(() => { + const id = setInterval(() => fetchTips(true), TRANSACTIONS_REFRESH_MS); + return () => clearInterval(id); + }, [fetchTips]); // Fetch on-chain user stats (tips sent/received counts and volume). // This is user-specific data not available from the shared event cache. @@ -123,26 +157,26 @@ export default function TipHistory({ userAddress }) { const handleLoadMore = async () => { setLoadingMore(true); - try { await contextLoadMore(); } finally { setLoadingMore(false); } + try { await fetchTips(false); } finally { setLoadingMore(false); } }; - const filteredTips = enrichedTips.filter(t => { + const filteredTips = tips.filter(t => { if (tab === 'sent' && t.direction !== 'sent') return false; if (tab === 'received' && t.direction !== 'received') return false; if (categoryFilter !== 'all' && t.category !== Number(categoryFilter)) return false; return true; }); - if (eventsLoading || statsLoading) return ( + if (tipsLoading || statsLoading) return (

Loading activity...

); - if (eventsError) return ( + if (tipsError) return (
-

{eventsError}

+

{tipsError}

@@ -222,8 +256,6 @@ export default function TipHistory({ userAddress }) { {tip.message ? ( “{tip.message}” - ) : messagesLoading ? ( - ) : null} @@ -240,15 +272,15 @@ export default function TipHistory({ userAddress }) { {/* Load More from API */} - {eventsMeta.hasMore && ( + {tipsMeta.hasMore && (
- {eventsMeta.total > 0 && ( - Showing {enrichedTips.length} of {eventsMeta.total} on-chain events + {tipsMeta.total > 0 && ( + Showing {tips.length} of {tipsMeta.total} address transactions )}
)} diff --git a/frontend/src/test/TipHistory.refresh.test.jsx b/frontend/src/test/TipHistory.refresh.test.jsx index 3b24201e..49ec861b 100644 --- a/frontend/src/test/TipHistory.refresh.test.jsx +++ b/frontend/src/test/TipHistory.refresh.test.jsx @@ -1,19 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import TipHistory from '../components/TipHistory'; -import { useTipContext } from '../context/TipContext'; -import { fetchTipMessages, clearTipCache } from '../lib/fetchTipDetails'; import { fetchCallReadOnlyFunction, cvToJSON } from '@stacks/transactions'; -vi.mock('../context/TipContext', () => ({ - useTipContext: vi.fn(), -})); - -vi.mock('../lib/fetchTipDetails', () => ({ - fetchTipMessages: vi.fn(), - clearTipCache: vi.fn(), -})); - vi.mock('@stacks/transactions', () => ({ fetchCallReadOnlyFunction: vi.fn(), cvToJSON: vi.fn(), @@ -25,33 +14,36 @@ vi.mock('../utils/stacks', () => ({ })); describe('TipHistory refresh behavior', () => { - const refreshEvents = vi.fn(); + const USER = 'SP1SENDER'; + const CONTRACT_ID = 'SP31PKQVQZVZCK3FM3NH67CGD6G1FMR17VQVS2W5T.tipstream'; beforeEach(() => { vi.clearAllMocks(); - useTipContext.mockReturnValue({ - events: [ - { - event: 'tip-sent', - tipId: '1', - sender: 'SP1SENDER', - recipient: 'SP2RECIPIENT', - amount: '1000000', - fee: '50000', - timestamp: 1700000000, - txId: '0xabc', - }, - ], - eventsLoading: false, - eventsError: null, - eventsMeta: { total: 1, hasMore: false }, - lastEventRefresh: new Date('2026-03-12T12:00:00Z'), - refreshEvents, - loadMoreEvents: vi.fn(), + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + total: 1, + results: [ + { + tx_id: '0xabc', + tx_type: 'contract_call', + sender_address: USER, + burn_block_time: 1700000000, + contract_call: { + contract_id: CONTRACT_ID, + function_args: [ + { repr: "'SP2RECIPIENT" }, + { repr: 'u1000000' }, + { repr: 'u"hello"' }, + { repr: 'u1' }, + ], + }, + }, + ], + }), }); - fetchTipMessages.mockResolvedValue(new Map([['1', 'hello']])); fetchCallReadOnlyFunction.mockResolvedValue({}); cvToJSON.mockReturnValue({ value: { @@ -63,86 +55,53 @@ describe('TipHistory refresh behavior', () => { }); }); - it('does not clear the tip cache during automatic message enrichment', async () => { - render(); + it('fetches address-specific transactions on mount', async () => { + render(); await waitFor(() => { - expect(fetchTipMessages).toHaveBeenCalledWith(['1']); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/extended/v1/address/${USER}/transactions?limit=50&offset=0`) + ); }); - - expect(clearTipCache).not.toHaveBeenCalled(); }); - it('deduplicates repeated tip IDs before message enrichment fetch', async () => { - const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - useTipContext.mockReturnValue({ - events: [ - { - event: 'tip-sent', - tipId: '1', - sender: 'SP1SENDER', - recipient: 'SP2RECIPIENT', - amount: '1000000', - fee: '50000', - timestamp: 1700000000, - txId: '0xaaa', - }, - { - event: 'tip-sent', - tipId: '1', - sender: 'SP1SENDER', - recipient: 'SP2RECIPIENT', - amount: '1000000', - fee: '50000', - timestamp: 1700000001, - txId: '0xbbb', - }, - ], - eventsLoading: false, - eventsError: null, - eventsMeta: { total: 2, hasMore: false }, - lastEventRefresh: new Date('2026-03-12T12:00:00Z'), - refreshEvents, - loadMoreEvents: vi.fn(), - }); + it('renders parsed transaction details from contract call args', async () => { + render(); + + expect(await screen.findByText(/\u201chello\u201d/)).toBeInTheDocument(); + expect(screen.getByText(/-1\.00 STX/)).toBeInTheDocument(); + }); - render(); + it('refreshes transactions when user clicks Refresh', async () => { + render(); await waitFor(() => { - expect(fetchTipMessages).toHaveBeenCalledWith(['1']); + expect(global.fetch).toHaveBeenCalledTimes(1); }); - consoleSpy.mockRestore(); - }); - - it('clears the tip cache when user clicks Refresh', async () => { - render(); - const refreshButton = await screen.findByLabelText('Refresh activity'); fireEvent.click(refreshButton); - expect(clearTipCache).toHaveBeenCalledTimes(1); - expect(refreshEvents).toHaveBeenCalledTimes(1); - expect(clearTipCache.mock.invocationCallOrder[0]).toBeLessThan(refreshEvents.mock.invocationCallOrder[0]); + await waitFor(() => { + expect(global.fetch.mock.calls.length).toBeGreaterThanOrEqual(2); + }); }); - it('clears the tip cache when user clicks Retry in error state', async () => { - useTipContext.mockReturnValue({ - events: [], - eventsLoading: false, - eventsError: 'Failed to load events', - eventsMeta: { total: 0, hasMore: false }, - lastEventRefresh: null, - refreshEvents, - loadMoreEvents: vi.fn(), - }); + it('retries when initial fetch fails', async () => { + global.fetch + .mockResolvedValueOnce({ ok: false, status: 503 }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ total: 0, results: [] }), + }); + + render(); + expect(await screen.findByText('Stacks API returned 503')).toBeInTheDocument(); - render(); - const retryButton = await screen.findByText('Retry'); - fireEvent.click(retryButton); + fireEvent.click(screen.getByText('Retry')); - expect(clearTipCache).toHaveBeenCalledTimes(1); - expect(refreshEvents).toHaveBeenCalledTimes(1); - expect(clearTipCache.mock.invocationCallOrder[0]).toBeLessThan(refreshEvents.mock.invocationCallOrder[0]); + await waitFor(() => { + expect(global.fetch).toHaveBeenCalledTimes(2); + }); }); });