diff --git a/frontend/src/components/MessageThread.jsx b/frontend/src/components/MessageThread.jsx index 6b5e9e8..7f96812 100644 --- a/frontend/src/components/MessageThread.jsx +++ b/frontend/src/components/MessageThread.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useLayoutEffect, useRef } from 'react' import { useLocation } from 'react-router-dom' import axios from 'axios' import { format } from 'date-fns' @@ -11,20 +11,47 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { const location = useLocation() const [items, setItems] = useState([]) const [loading, setLoading] = useState(false) + const [loadingOlder, setLoadingOlder] = useState(false) + const [loadingNewer, setLoadingNewer] = useState(false) + const [offset, setOffset] = useState(0) + const [tailOffset, setTailOffset] = useState(0) + const [totalCount, setTotalCount] = useState(0) const [highlightedMessageId, setHighlightedMessageId] = useState(null) const [isPreprintingMedia, setIsPreprintingMedia] = useState(false) const [showMediaOnly, setShowMediaOnly] = useState(false) const messageRefs = useRef({}) const printTriggeredRef = useRef(false) + const scrollContainerRef = useRef(null) + const suppressAutoScrollRef = useRef(false) + const scrollToItemIdRef = useRef(null) useEffect(() => { if (conversation) { + setOffset(0) + setTailOffset(0) + setTotalCount(0) + setItems([]) fetchItems() - setShowMediaOnly(false) // Reset to message view when conversation changes + setShowMediaOnly(false) } else { setItems([]) + setOffset(0) + setTailOffset(0) + setTotalCount(0) } - }, [conversation, startDate, endDate]) + }, [conversation, startDate, endDate, messageLimit]) + + // After loading older items, scroll to the first new item so the user sees + // where the new content starts. + useLayoutEffect(() => { + if (scrollToItemIdRef.current !== null) { + const el = messageRefs.current[scrollToItemIdRef.current] + if (el) { + el.scrollIntoView({ block: 'start' }) + } + scrollToItemIdRef.current = null + } + }) // Scroll to specific message if messageId is in URL useEffect(() => { @@ -104,6 +131,11 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { // Automatically scroll to the last message when opening a conversation useEffect(() => { + if (suppressAutoScrollRef.current) { + suppressAutoScrollRef.current = false + return + } + if (items.length > 0) { const params = new URLSearchParams(location.search) const messageId = params.get('messageId') @@ -229,16 +261,34 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { const fetchItems = async () => { setLoading(true) try { + const limit = messageLimit || 100000 const params = { address: conversation.address, - type: conversation.type + type: conversation.type, + limit, + offset: 0, } if (startDate) params.start = startDate.toISOString() if (endDate) params.end = endDate.toISOString() - if (messageLimit) params.limit = messageLimit - const response = await axios.get(`${API_BASE}/messages`, { params }) - setItems(response.data || []) + // Fetch with offset 0 first to get the total count, then re-fetch the last page + const probe = await axios.get(`${API_BASE}/messages`, { params }) + const total = probe.data.total || 0 + setTotalCount(total) + + // If all messages fit in one page, we're done + if (total <= limit) { + setOffset(0) + setItems(probe.data.items || []) + return + } + + // Otherwise fetch the last page so the most recent messages are shown + const lastPageOffset = Math.max(0, total - limit) + const lastPageParams = { ...params, offset: lastPageOffset } + const response = await axios.get(`${API_BASE}/messages`, { params: lastPageParams }) + setOffset(lastPageOffset) + setItems(response.data.items || []) } catch (error) { console.error('Error fetching items:', error) } finally { @@ -246,6 +296,87 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { } } + const fetchOlderItems = async () => { + const limit = messageLimit || 100000 + const newOffset = Math.max(0, offset - limit) + // How many rows to fetch: exactly the gap between newOffset and current offset + const fetchLimit = offset - newOffset + setLoadingOlder(true) + try { + const params = { + address: conversation.address, + type: conversation.type, + limit: fetchLimit, + offset: newOffset, + } + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/messages`, { params }) + const olderItems = response.data.items || [] + + // Record the last new item's id so the layout effect can scroll to it — + // user lands at the newest of the older messages and can scroll up from there + const lastNewItem = olderItems[olderItems.length - 1] + if (lastNewItem) { + const msg = lastNewItem.type === 'message' ? lastNewItem.message : lastNewItem.call + scrollToItemIdRef.current = msg?.id ?? lastNewItem.id + } + suppressAutoScrollRef.current = true + + const trimCount = olderItems.length + setItems(prev => { + const combined = [...olderItems, ...prev] + return (trimCount > 0 && combined.length > trimCount) + ? combined.slice(0, combined.length - trimCount) + : combined + }) + setOffset(newOffset) + setTailOffset(to => to + trimCount) + } catch (error) { + console.error('Error fetching older items:', error) + } finally { + setLoadingOlder(false) + } + } + + const fetchNewerItems = async () => { + const limit = messageLimit || 100000 + // tailOffset is the DB offset of the first trimmed row; fetch up to limit rows from there + const fetchLimit = Math.min(limit, tailOffset) + const fetchOffset = tailOffset - fetchLimit + setLoadingNewer(true) + try { + const params = { + address: conversation.address, + type: conversation.type, + limit: fetchLimit, + offset: fetchOffset, + } + if (startDate) params.start = startDate.toISOString() + if (endDate) params.end = endDate.toISOString() + + const response = await axios.get(`${API_BASE}/messages`, { params }) + const newerItems = response.data.items || [] + + setTailOffset(fetchOffset) + setItems(prev => { + const combined = [...prev, ...newerItems] + // Trim the same number of rows from the head to keep memory bounded + const trimCount = newerItems.length + if (trimCount > 0 && combined.length > trimCount) { + setOffset(o => o + trimCount) + return combined.slice(trimCount) + } + return combined + }) + } catch (error) { + console.error('Error fetching newer items:', error) + } finally { + setLoadingNewer(false) + } + } + const handleExportPDF = () => { // Build URL parameters for print view const params = new URLSearchParams() @@ -464,7 +595,14 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { })()}
- {items.length} {isCallLog ? 'call' : 'message'}{items.length !== 1 ? 's' : ''} + {totalCount > items.length + ? (() => { + const end = totalCount - tailOffset + const start = end - items.length + 1 + return `${isCallLog ? 'call' : 'message'}s ${start}–${end} of ${totalCount}` + })() + : `${items.length} ${isCallLog ? 'call' : 'message'}${items.length !== 1 ? 's' : ''}` + }
@@ -494,7 +632,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { {/* Content */} -
+
{showMediaOnly && !isCallLog ? ( // Media Grid View @@ -542,8 +680,26 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) { ) : ( // Unified Message and Call View
+ {/* Load older messages button */} + {offset > 0 && ( +
+ +
+ )} {items.map((item) => { - // Check if this is an ActivityItem (has type field) or a direct Message const isActivityItem = item.type === 'message' || item.type === 'call' const isCall = isActivityItem && item.type === 'call' const message = isActivityItem ? item.message : item @@ -636,6 +792,25 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
) })} + {/* Load newer messages button */} + {tailOffset > 0 && ( +
+ +
+ )}
)}
diff --git a/internal/database.go b/internal/database.go index 567cc8b..f95095e 100644 --- a/internal/database.go +++ b/internal/database.go @@ -852,6 +852,27 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti return activities, nil } +// CountActivityByAddress returns the total number of activity rows for a given address and date range +func CountActivityByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) (int, error) { + query := `SELECT COUNT(*) FROM messages WHERE 1=1` + args := []interface{}{} + if address != "" { + query += " AND address = ?" + args = append(args, address) + } + if startDate != nil { + query += " AND date >= ?" + args = append(args, startDate.Unix()) + } + if endDate != nil { + query += " AND date <= ?" + args = append(args, endDate.Unix()) + } + var count int + err := userDB.QueryRow(query, args...).Scan(&count) + return count, err +} + // GetMediaByAddress fetches only media items (images/videos) for a specific address func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) { query := ` diff --git a/internal/handlers.go b/internal/handlers.go index bbc3e99..df567a8 100644 --- a/internal/handlers.go +++ b/internal/handlers.go @@ -210,6 +210,12 @@ func HandleMessages(c echo.Context) error { } } + total, err := CountActivityByAddress(userDB, address, startDate, endDate) + if err != nil { + slog.Error("Error counting activity", "error", err) + total = 0 + } + activities, err := GetActivityByAddress(userDB, address, startDate, endDate, limit, offset) if err != nil { slog.Error("Error getting activity", "error", err) @@ -229,7 +235,10 @@ func HandleMessages(c echo.Context) error { activities = filteredActivities } - return c.JSON(http.StatusOK, activities) + return c.JSON(http.StatusOK, map[string]interface{}{ + "items": activities, + "total": total, + }) } messages, err := GetMessages(userDB, address, startDate, endDate)