load more messages
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
import { useState, useEffect, useLayoutEffect, useRef } from 'react'
|
||||||
import { useLocation } from 'react-router-dom'
|
import { useLocation } from 'react-router-dom'
|
||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { format } from 'date-fns'
|
import { format } from 'date-fns'
|
||||||
@@ -11,20 +11,47 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
const [loading, setLoading] = useState(false)
|
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 [highlightedMessageId, setHighlightedMessageId] = useState(null)
|
||||||
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
|
const [isPreprintingMedia, setIsPreprintingMedia] = useState(false)
|
||||||
const [showMediaOnly, setShowMediaOnly] = useState(false)
|
const [showMediaOnly, setShowMediaOnly] = useState(false)
|
||||||
const messageRefs = useRef({})
|
const messageRefs = useRef({})
|
||||||
const printTriggeredRef = useRef(false)
|
const printTriggeredRef = useRef(false)
|
||||||
|
const scrollContainerRef = useRef(null)
|
||||||
|
const suppressAutoScrollRef = useRef(false)
|
||||||
|
const scrollToItemIdRef = useRef(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
|
setOffset(0)
|
||||||
|
setTailOffset(0)
|
||||||
|
setTotalCount(0)
|
||||||
|
setItems([])
|
||||||
fetchItems()
|
fetchItems()
|
||||||
setShowMediaOnly(false) // Reset to message view when conversation changes
|
setShowMediaOnly(false)
|
||||||
} else {
|
} else {
|
||||||
setItems([])
|
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
|
// Scroll to specific message if messageId is in URL
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -104,6 +131,11 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
|
|
||||||
// Automatically scroll to the last message when opening a conversation
|
// Automatically scroll to the last message when opening a conversation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (suppressAutoScrollRef.current) {
|
||||||
|
suppressAutoScrollRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (items.length > 0) {
|
if (items.length > 0) {
|
||||||
const params = new URLSearchParams(location.search)
|
const params = new URLSearchParams(location.search)
|
||||||
const messageId = params.get('messageId')
|
const messageId = params.get('messageId')
|
||||||
@@ -229,16 +261,34 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
const fetchItems = async () => {
|
const fetchItems = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
const limit = messageLimit || 100000
|
||||||
const params = {
|
const params = {
|
||||||
address: conversation.address,
|
address: conversation.address,
|
||||||
type: conversation.type
|
type: conversation.type,
|
||||||
|
limit,
|
||||||
|
offset: 0,
|
||||||
}
|
}
|
||||||
if (startDate) params.start = startDate.toISOString()
|
if (startDate) params.start = startDate.toISOString()
|
||||||
if (endDate) params.end = endDate.toISOString()
|
if (endDate) params.end = endDate.toISOString()
|
||||||
if (messageLimit) params.limit = messageLimit
|
|
||||||
|
|
||||||
const response = await axios.get(`${API_BASE}/messages`, { params })
|
// Fetch with offset 0 first to get the total count, then re-fetch the last page
|
||||||
setItems(response.data || [])
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching items:', error)
|
console.error('Error fetching items:', error)
|
||||||
} finally {
|
} 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 = () => {
|
const handleExportPDF = () => {
|
||||||
// Build URL parameters for print view
|
// Build URL parameters for print view
|
||||||
const params = new URLSearchParams()
|
const params = new URLSearchParams()
|
||||||
@@ -464,7 +595,14 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
})()}
|
})()}
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
<span className="badge bg-primary" style={{fontSize: '0.7rem'}}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -494,7 +632,7 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* 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 ? (
|
{showMediaOnly && !isCallLog ? (
|
||||||
// Media Grid View
|
// Media Grid View
|
||||||
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
|
<MediaGrid conversation={conversation} startDate={startDate} endDate={endDate} />
|
||||||
@@ -542,8 +680,26 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
) : (
|
) : (
|
||||||
// Unified Message and Call View
|
// Unified Message and Call View
|
||||||
<div className="d-flex flex-column gap-1">
|
<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) => {
|
{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 isActivityItem = item.type === 'message' || item.type === 'call'
|
||||||
const isCall = isActivityItem && item.type === 'call'
|
const isCall = isActivityItem && item.type === 'call'
|
||||||
const message = isActivityItem ? item.message : item
|
const message = isActivityItem ? item.message : item
|
||||||
@@ -636,6 +792,25 @@ function MessageThread({ conversation, startDate, endDate, messageLimit }) {
|
|||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -852,6 +852,27 @@ func GetActivityByAddress(userDB *sql.DB, address string, startDate, endDate *ti
|
|||||||
return activities, nil
|
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
|
// GetMediaByAddress fetches only media items (images/videos) for a specific address
|
||||||
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
|
func GetMediaByAddress(userDB *sql.DB, address string, startDate, endDate *time.Time) ([]Message, error) {
|
||||||
query := `
|
query := `
|
||||||
|
|||||||
+10
-1
@@ -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)
|
activities, err := GetActivityByAddress(userDB, address, startDate, endDate, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting activity", "error", err)
|
slog.Error("Error getting activity", "error", err)
|
||||||
@@ -229,7 +235,10 @@ func HandleMessages(c echo.Context) error {
|
|||||||
activities = filteredActivities
|
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)
|
messages, err := GetMessages(userDB, address, startDate, endDate)
|
||||||
|
|||||||
Reference in New Issue
Block a user