Server pagination
- blockchain.PostsByAuthor signature extended with beforeTs int64;
passing 0 keeps the previous "everything, newest first" behaviour,
non-zero skips posts with CreatedAt >= beforeTs so clients can
paginate older results.
- node.FeedConfig.PostsByAuthor callback type updated; the two
/feed endpoints that use it (timeline + author) now accept
`?before=<unix_seconds>` and forward it through. /feed/author
limit default dropped from 50 to 30 to match the client's page
size.
- node/api_common.go: new queryInt64 helper for parsing the cursor
param safely (matches the queryInt pattern already used).
Client infinite scroll (Feed tab)
- lib/feed.ts: fetchTimeline / fetchAuthorPosts accept
`{limit?, before?}` options. Old signatures still work for other
callers (fetchForYou / fetchTrending / fetchHashtag) — those are
ranked feeds that don't have a stable cursor so they stay
single-shot.
- feed/index.tsx: tracks loadingMore / exhausted state. onEndReached
(threshold 0.6) fires loadMore() which fetches the next 20 posts
using the oldest currently-loaded post's created_at as `before`.
Deduplicates on post_id before appending. Stops when the server
returns < PAGE_SIZE items. ListFooterComponent shows a small
spinner during paginated fetches.
- FlatList lazy-render tuning on all feed lists (index + hashtag):
initialNumToRender:10, maxToRenderPerBatch:8, windowSize:7,
removeClippedSubviews — first paint stays quick even with 100+
posts loaded.
Chat lazy render
- chats/[id].tsx FlatList: initialNumToRender:25 (~1.5 screens),
maxToRenderPerBatch:12, windowSize:10, removeClippedSubviews.
Keeps initial chat open snappy on conversations with thousands
of messages; RN re-renders a small window around the viewport
and drops the rest.
Tests
- chain_test.go updated for new PostsByAuthor signature.
- All 7 Go packages green.
- tsc --noEmit clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
196 lines
4.5 KiB
Go
196 lines
4.5 KiB
Go
package node
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"go-blockchain/blockchain"
|
|
"go-blockchain/identity"
|
|
)
|
|
|
|
func jsonOK(w http.ResponseWriter, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func jsonErr(w http.ResponseWriter, err error, code int) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
w.WriteHeader(code)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
|
}
|
|
|
|
func queryInt(r *http.Request, key string, def int) int {
|
|
s := r.URL.Query().Get(key)
|
|
if s == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n <= 0 {
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
// queryInt64 reads a non-negative int64 query param — typically a unix
|
|
// timestamp cursor for pagination. Returns def when missing or invalid.
|
|
func queryInt64(r *http.Request, key string, def int64) int64 {
|
|
s := r.URL.Query().Get(key)
|
|
if s == "" {
|
|
return def
|
|
}
|
|
n, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil || n < 0 {
|
|
return def
|
|
}
|
|
return n
|
|
}
|
|
|
|
// queryIntMin0 parses a query param as a non-negative integer; returns 0 if absent or invalid.
|
|
func queryIntMin0(r *http.Request, key string) int {
|
|
s := r.URL.Query().Get(key)
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.Atoi(s)
|
|
if err != nil || n < 0 {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|
|
|
|
func queryUint64Optional(r *http.Request, key string) (*uint64, error) {
|
|
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
|
if raw == "" {
|
|
return nil, nil
|
|
}
|
|
n, err := strconv.ParseUint(raw, 10, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid %s: %s", key, raw)
|
|
}
|
|
return &n, nil
|
|
}
|
|
|
|
func resolveAccountID(q ExplorerQuery, accountID string) (string, error) {
|
|
if accountID == "" {
|
|
return "", fmt.Errorf("account id required")
|
|
}
|
|
if strings.HasPrefix(accountID, "DC") {
|
|
pubKey, err := q.AddressToPubKey(accountID)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if pubKey == "" {
|
|
return "", fmt.Errorf("account not found")
|
|
}
|
|
return pubKey, nil
|
|
}
|
|
return accountID, nil
|
|
}
|
|
|
|
func verifyTransactionSignature(tx *blockchain.Transaction) error {
|
|
if tx == nil {
|
|
return fmt.Errorf("transaction is nil")
|
|
}
|
|
return identity.VerifyTx(tx)
|
|
}
|
|
|
|
func decodeTransactionEnvelope(raw string) (*blockchain.Transaction, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil, fmt.Errorf("empty transaction envelope")
|
|
}
|
|
|
|
tryDecodeJSON := func(data []byte) (*blockchain.Transaction, error) {
|
|
var tx blockchain.Transaction
|
|
if err := json.Unmarshal(data, &tx); err != nil {
|
|
return nil, err
|
|
}
|
|
if tx.ID == "" || tx.From == "" || tx.Type == "" {
|
|
return nil, fmt.Errorf("invalid tx payload")
|
|
}
|
|
return &tx, nil
|
|
}
|
|
|
|
if strings.HasPrefix(raw, "{") {
|
|
return tryDecodeJSON([]byte(raw))
|
|
}
|
|
|
|
base64Decoders := []*base64.Encoding{
|
|
base64.StdEncoding,
|
|
base64.RawStdEncoding,
|
|
base64.URLEncoding,
|
|
base64.RawURLEncoding,
|
|
}
|
|
for _, enc := range base64Decoders {
|
|
if b, err := enc.DecodeString(raw); err == nil {
|
|
if tx, txErr := tryDecodeJSON(b); txErr == nil {
|
|
return tx, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if b, err := hex.DecodeString(raw); err == nil {
|
|
if tx, txErr := tryDecodeJSON(b); txErr == nil {
|
|
return tx, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to decode transaction envelope")
|
|
}
|
|
|
|
func txMemo(tx *blockchain.Transaction) string {
|
|
if tx == nil {
|
|
return ""
|
|
}
|
|
if memo := strings.TrimSpace(tx.Memo); memo != "" {
|
|
return memo
|
|
}
|
|
switch tx.Type {
|
|
case blockchain.EventTransfer:
|
|
var p blockchain.TransferPayload
|
|
if err := json.Unmarshal(tx.Payload, &p); err == nil {
|
|
return strings.TrimSpace(p.Memo)
|
|
}
|
|
case blockchain.EventBlockReward:
|
|
return blockRewardReason(tx.Payload)
|
|
case blockchain.EventRelayProof:
|
|
return "Relay delivery fee"
|
|
case blockchain.EventHeartbeat:
|
|
return "Liveness heartbeat"
|
|
case blockchain.EventRegisterRelay:
|
|
return "Register relay service"
|
|
case blockchain.EventBindWallet:
|
|
return "Bind payout wallet"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func blockRewardReason(payload []byte) string {
|
|
var p blockchain.BlockRewardPayload
|
|
if err := json.Unmarshal(payload, &p); err != nil {
|
|
return "Block fees"
|
|
}
|
|
if p.FeeReward == 0 && p.TotalReward > 0 {
|
|
return "Genesis allocation"
|
|
}
|
|
return "Block fees collected"
|
|
}
|
|
|
|
func decodeTxPayload(payload []byte) (any, string) {
|
|
if len(payload) == 0 {
|
|
return nil, ""
|
|
}
|
|
var decoded any
|
|
if err := json.Unmarshal(payload, &decoded); err == nil {
|
|
return decoded, ""
|
|
}
|
|
return nil, hex.EncodeToString(payload)
|
|
}
|