load more messages

This commit is contained in:
lowcarbdev
2026-04-30 23:43:28 -06:00
parent 250b9030ea
commit 3fef1925d6
3 changed files with 216 additions and 11 deletions
+185 -10
View File
@@ -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 }) {
})()}
<div className="d-flex align-items-center gap-2">
<span className="badge bg-primary" style={{fontSize: '0.7rem'}}>
{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' : ''}`
}
</span>
</div>
</div>
@@ -494,7 +632,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
</div>
{/* Content */}
<div className="flex-fill overflow-auto p-2 p-md-4 bg-light">
<div ref={scrollContainerRef} className="flex-fill overflow-auto p-2 p-md-4 bg-light">
{showMediaOnly && !isCallLog ? (
// Media Grid View
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
@@ -542,8 +680,26 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
) : (
// Unified Message and Call View
<div className="d-flex flex-column gap-1">
{/* Load older messages button */}
{offset > 0 && (
<div className="d-flex justify-content-center mb-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={fetchOlderItems}
disabled={loadingOlder}
>
{loadingOlder ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Loading...
</>
) : (
`↑ Load older messages`
)}
</button>
</div>
)}
{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 }) {
</div>
)
})}
{/* Load newer messages button */}
{tailOffset > 0 && (
<div className="d-flex justify-content-center mt-2">
<button
className="btn btn-sm btn-outline-secondary"
onClick={fetchNewerItems}
disabled={loadingNewer}
>
{loadingNewer ? (
<>
<span className="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>
Loading...
</>
) : (
`↓ Load newer messages`
)}
</button>
</div>
)}
</div>
)}
</div>
+21
View File
@@ -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 := `
+10 -1
View File
@@ -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)