~mariusor/go-activitypub-dev

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch

[PATCH] Improvements to page cursors

Details
Message ID
<20250120150405.29343-1-marius@federated.id>
Sender timestamp
1737389045
DKIM signature
pass
Download raw message
Patch: +130 -115
---
 cursor.go                      |   6 +-
 filters.go                     |  55 ++++++++++++++-
 handlers.go                    |   2 +-
 models.go                      |  34 +++++----
 repository.go                  | 121 ++++++++++-----------------------
 templates/partials/footer.html |   6 +-
 templates/partials/head.html   |   4 +-
 view.go                        |  17 +++--
 8 files changed, 130 insertions(+), 115 deletions(-)

diff --git a/cursor.go b/cursor.go
index a1ae9f2c..7b4daabc 100644
--- a/cursor.go
+++ b/cursor.go
@@ -3,11 +3,13 @@ package brutalinks
import (
	"sort"
	"time"

	vocab "github.com/go-ap/activitypub"
)

type Cursor struct {
	after  Hash
	before Hash
	after  vocab.IRI
	before vocab.IRI
	items  RenderableList
	total  uint
}
diff --git a/filters.go b/filters.go
index d446bc87..9cf6ef32 100644
--- a/filters.go
+++ b/filters.go
@@ -4,10 +4,13 @@ import (
	"context"
	"fmt"
	"net/http"
	"net/url"
	"strconv"

	vocab "github.com/go-ap/activitypub"
	"github.com/go-ap/errors"
	"github.com/go-ap/filters"
	"github.com/go-ap/filters/index"
	"github.com/go-chi/chi/v5"
	"github.com/mariusor/qstring"
	"gitlab.com/golang-commonmark/puny"
@@ -90,8 +93,58 @@ func FollowedChecks(next http.Handler) http.Handler {
	})
}

const (
	refBase   = 36
	keyAfter  = "after"
	keyBefore = "before"
)

type cursorRef uint64

func (c cursorRef) Match(it vocab.Item) bool {
	ref := index.HashFn(it.GetLink())
	return uint64(c) == ref
}

func (c cursorRef) String() string {
	return strconv.FormatUint(uint64(c), refBase)
}

var _ filters.Check = cursorRef(0)

func RefValue(r uint64) cursorRef {
	return cursorRef(r)
}

func IRIRef(i vocab.IRI) cursorRef {
	return cursorRef(index.HashFn(i))
}

func FromURL(u url.URL) filters.Checks {
	q := u.Query()
	appendChecks := func(checks *filters.Checks, check func(...filters.Check) filters.Check, vv []string) {
		for _, v := range vv {
			if r, err := strconv.ParseUint(v, refBase, 64); err == nil {
				*checks = append(*checks, check(RefValue(r)))
			}
		}
	}
	checks := make(filters.Checks, 0, len(q))
	for k, vv := range q {
		if k == keyAfter {
			appendChecks(&checks, filters.After, vv)
			q.Del(k)
		}
		if k == keyBefore {
			appendChecks(&checks, filters.Before, vv)
			q.Del(k)
		}
	}
	return append(filters.FromValues(q), checks...)
}

func requestChecks(r *http.Request) filters.Checks {
	return append(filters.FromURL(*r.URL), filters.WithMaxCount(MaxContentItems))
	return append(FromURL(*r.URL), filters.WithMaxCount(MaxContentItems))
}

func defaultChecks(r *http.Request) filters.Checks {
diff --git a/handlers.go b/handlers.go
index 2565d3a9..24aacc19 100644
--- a/handlers.go
+++ b/handlers.go
@@ -1180,7 +1180,7 @@ func (h *handler) HandleShow(w http.ResponseWriter, r *http.Request) {
		}
	}
	if cursor := ContextCursor(r.Context()); cursor != nil {
		if mod, ok := m.(Paginator); ok {
		if mod, ok := m.(CursorSetter); ok {
			mod.SetCursor(cursor)
		}
	}
diff --git a/models.go b/models.go
index 32e18902..052f2721 100644
--- a/models.go
+++ b/models.go
@@ -7,10 +7,14 @@ import (
	vocab "github.com/go-ap/activitypub"
)

type Paginator interface {
type CursorSetter interface {
	SetCursor(*Cursor)
	NextPage() Hash
	PrevPage() Hash
}

type Paginator interface {
	CursorSetter
	NextPage() vocab.IRI
	PrevPage() vocab.IRI
}

type Model interface {
@@ -25,8 +29,8 @@ type listingModel struct {
	ShowChildren bool
	children     RenderableList
	ShowText     bool
	after        Hash
	before       Hash
	after        vocab.IRI
	before       vocab.IRI
	sortFn       func(list RenderableList) []Renderable
}

@@ -56,11 +60,11 @@ func (m listingModel) ID() Hash {
	return Hash{}
}

func (m listingModel) NextPage() Hash {
func (m listingModel) NextPage() vocab.IRI {
	return m.after
}

func (m listingModel) PrevPage() Hash {
func (m listingModel) PrevPage() vocab.IRI {
	return m.before
}

@@ -108,19 +112,19 @@ type contentModel struct {
	Content      Renderable
	ShowChildren bool
	Message      mBox
	after        Hash
	before       Hash
	after        vocab.IRI
	before       vocab.IRI
}

func (m contentModel) ID() Hash {
	return Hash{}
}

func (m contentModel) NextPage() Hash {
func (m contentModel) NextPage() vocab.IRI {
	return m.after
}

func (m contentModel) PrevPage() Hash {
func (m contentModel) PrevPage() vocab.IRI {
	return m.before
}

@@ -211,15 +215,15 @@ type moderationModel struct {
	Content      *ModerationOp
	ShowChildren bool
	Message      mBox
	after        Hash
	before       Hash
	after        vocab.IRI
	before       vocab.IRI
}

func (m moderationModel) NextPage() Hash {
func (m moderationModel) NextPage() vocab.IRI {
	return m.after
}

func (m moderationModel) PrevPage() Hash {
func (m moderationModel) PrevPage() vocab.IRI {
	return m.before
}

diff --git a/repository.go b/repository.go
index 28f0af09..5be18891 100644
--- a/repository.go
+++ b/repository.go
@@ -650,15 +650,21 @@ func (r *repository) loadAccountsOutbox(ctx context.Context, acc *Account) error
		return nil
	}

	now := time.Now().UTC()
	lastUpdated := acc.Metadata.OutboxUpdated
	if now.Sub(lastUpdated)-5*time.Minute < 0 {
		return nil
	}

	ac := acc.AP()
	validTypes := append(vocab.ActivityVocabularyTypes{vocab.LikeType}, vocab.CreateType, vocab.DeleteType)
	check := filters.All(
	check := []filters.Check{
		filters.HasType(validTypes...),
		filters.SameAttributedTo(ac.GetLink()),
		filters.WithMaxCount(200),
	)
	}

	result, err := r.b.Search(check)
	result, err := r.b.Search(check...)
	if err != nil {
		return err
	}
@@ -705,6 +711,7 @@ func (r *repository) loadAccountsOutbox(ctx context.Context, acc *Account) error
		//	return nil
		//})
	}
	acc.Metadata.OutboxUpdated = now
	return nil
}

@@ -784,7 +791,7 @@ func irisFromItems(items ...Item) vocab.IRIs {
		if it.Deleted() {
			continue
		}
		iris = append(iris, it.Pub.GetLink())
		_ = iris.Append(it.AP())
	}
	return iris
}
@@ -831,12 +838,15 @@ func (r *repository) loadItemsVotes(ctx context.Context, items ...Item) (ItemCol
}

func AccountIRIChecks(accounts ...Account) filters.Check {
	filter := make(filters.Checks, 0, len(accounts))
	iris := make(vocab.IRIs, 0, len(accounts))
	for _, ac := range accounts {
		if ac.Pub == nil {
			continue
		if it := ac.AP(); !vocab.IsNil(it) {
			_ = iris.Append(it.GetLink())
		}
		filter = append(filter, filters.SameIRI(ac.AP().GetLink()))
	}
	filter := make(filters.Checks, 0, len(accounts))
	for _, i := range iris {
		filter = append(filter, filters.SameIRI(i))
	}
	return filters.Any(filter...)
}
@@ -1175,62 +1185,12 @@ func (r *repository) loadItemsAuthors(ctx context.Context, items ...Item) (ItemC
	return col, nil
}

func getCollectionPrevNext(col vocab.CollectionInterface) (prev, next vocab.IRI) {
	qFn := func(i vocab.Item) url.Values {
		if i == nil {
			return url.Values{}
		}
		if u, err := i.GetLink().URL(); err == nil {
			return u.Query()
		}
		return url.Values{}
	}
	beforeFn := func(i vocab.Item) vocab.IRI {
		return vocab.IRI(qFn(i).Get("before"))
	}
	afterFn := func(i vocab.Item) vocab.IRI {
		return vocab.IRI(qFn(i).Get("after"))
	}
	nextFromLastFn := func(i vocab.Item) vocab.IRI {
		return i.GetLink()
	}
	switch col.GetType() {
	case vocab.OrderedCollectionPageType:
		if c, ok := col.(*vocab.OrderedCollectionPage); ok {
			prev = beforeFn(c.Prev)
			if int(c.TotalItems) > len(c.OrderedItems) {
				next = afterFn(c.Next)
			}
		}
	case vocab.OrderedCollectionType:
		if c, ok := col.(*vocab.OrderedCollection); ok {
			if len(c.OrderedItems) > 0 && int(c.TotalItems) > len(c.OrderedItems) {
				next = nextFromLastFn(c.OrderedItems[len(c.OrderedItems)-1])
			}
		}
	case vocab.CollectionPageType:
		if c, ok := col.(*vocab.CollectionPage); ok {
			prev = beforeFn(c.Prev)
			if int(c.TotalItems) > len(c.Items) {
				next = afterFn(c.Next)
			}
		}
	case vocab.CollectionType:
		if c, ok := col.(*vocab.Collection); ok {
			if len(c.Items) > 0 && int(c.TotalItems) > len(c.Items) {
				next = nextFromLastFn(c.Items[len(c.Items)-1])
			}
		}
func getCollectionPrevNext(col vocab.ItemCollection) (prev, next vocab.IRI) {
	if len(col) > 0 {
		prev = col.First().GetLink()
	}
	// NOTE(marius): we check if current Collection id contains a cursor, and if `after` points to the same URL
	//   we don't take it into consideration.
	if next != "" {
		f := struct {
			Next vocab.IRI `qstring:"after"`
		}{}
		if err := qstring.Unmarshal(qFn(col.GetLink()), &f); err == nil && next.Equals(f.Next, true) {
			next = ""
		}
	if len(col) > 1 {
		next = col[len(col)-1].GetLink()
	}
	return prev, next
}
@@ -1404,8 +1364,6 @@ func (r *repository) LoadSearches(ctx context.Context, deps deps, checks ...filt
	result := make(RenderableList, 0)
	resM := new(sync.RWMutex)

	var next, prev vocab.IRI

	results, err := r.b.Search(checks...)
	if err != nil {
		return emptyCursor, err
@@ -1507,6 +1465,7 @@ func (r *repository) LoadSearches(ctx context.Context, deps deps, checks ...filt
		return emptyCursor, err
	}

	next, prev := getCollectionPrevNext(results)
	if len(deferredRemote) > 0 {
		searchIRIs := make(filters.Checks, 0, len(deferredRemote))
		for _, iri := range deferredRemote {
@@ -1601,8 +1560,8 @@ func (r *repository) LoadSearches(ctx context.Context, deps deps, checks ...filt
	})

	return Cursor{
		after:  HashFromIRI(next),
		before: HashFromIRI(prev),
		after:  next,
		before: prev,
		items:  result,
		total:  uint(len(result)),
	}, nil
@@ -2238,11 +2197,6 @@ func (r *repository) ValidateRemoteAccount(ctx context.Context, acc *Account) er
}

func (r *repository) LoadAccountDetails(ctx context.Context, acc *Account) error {
	now := time.Now().UTC()
	lastUpdated := acc.Metadata.OutboxUpdated
	if now.Sub(lastUpdated)-5*time.Minute < 0 {
		return nil
	}
	var err error
	ltx := log.Ctx{"handle": acc.Handle, "hash": acc.Hash}
	r.infoFn(ltx)("loading account details")
@@ -2250,18 +2204,17 @@ func (r *repository) LoadAccountDetails(ctx context.Context, acc *Account) error
	if err = r.loadAccountsOutbox(ctx, acc); err != nil {
		r.infoFn(ltx, log.Ctx{"err": err.Error()})("unable to load outbox")
	}
	if len(acc.Followers) == 0 {
		// TODO(marius): this needs to be moved to where we're handling all Inbox activities, not on page load
		if err = r.loadAccountsFollowers(ctx, acc); err != nil {
			r.infoFn(ltx, log.Ctx{"err": err.Error()})("unable to load followers")
		}
	}
	if len(acc.Following) == 0 {
		if err = r.loadAccountsFollowing(ctx, acc); err != nil {
			r.infoFn(ltx, log.Ctx{"err": err.Error()})("unable to load following")
		}
	}
	acc.Metadata.OutboxUpdated = now
	//if len(acc.Followers) == 0 {
	//	// TODO(marius): this needs to be moved to where we're handling all Inbox activities, not on page load
	//	if err = r.loadAccountsFollowers(ctx, acc); err != nil {
	//		r.infoFn(ltx, log.Ctx{"err": err.Error()})("unable to load followers")
	//	}
	//}
	//if len(acc.Following) == 0 {
	//	if err = r.loadAccountsFollowing(ctx, acc); err != nil {
	//		r.infoFn(ltx, log.Ctx{"err": err.Error()})("unable to load following")
	//	}
	//}
	return err
}

diff --git a/templates/partials/footer.html b/templates/partials/footer.html
index 346fadca..d4dec80e 100644
--- a/templates/partials/footer.html
+++ b/templates/partials/footer.html
@@ -1,11 +1,11 @@
{{ if and (CanPaginate .) -}}
{{ if  (or .PrevPage.IsValid .NextPage.IsValid) }}
{{ if  (or (ne .PrevPage "") (ne .NextPage "")) }}
<nav class="pagination">View more:
    <ul>
        {{ if .PrevPage.IsValid -}}
        {{ if ne .PrevPage "" -}}
            <li><a href="{{ .PrevPage | PrevPageLink }}" rel="prev prefetch">{{icon "angle-double-right" "v-mirror"}}prev</a></li>
        {{- end -}}
        {{ if .NextPage.IsValid -}}
        {{ if ne .NextPage "" -}}
            <li><a href="{{.NextPage | NextPageLink }}" rel="next prefetch">next{{icon "angle-double-right"}}</a></li>
        {{- end}}
    </ul>
diff --git a/templates/partials/head.html b/templates/partials/head.html
index 4722e005..baa9d8e4 100644
--- a/templates/partials/head.html
+++ b/templates/partials/head.html
@@ -2,10 +2,10 @@
<title>{{ .Title }}</title>
<style>{{- style "/css/inline.css" -}}</style>
{{- if and (not Config.Env.IsDev) (CanPaginate .) -}}
{{- if .PrevPage.IsValid }}
{{- if ne .PrevPage "" }}
<link href="{{ .PrevPage | PrevPageLink }}" rel="prev prefetch" />
{{end -}}
{{- if .NextPage.IsValid }}
{{- if ne .NextPage "" }}
<link href="{{ .NextPage | NextPageLink }}" rel="next prefetch" />
{{end -}}
{{end -}}
diff --git a/view.go b/view.go
index d9ea63e6..3f500cd9 100644
--- a/view.go
+++ b/view.go
@@ -23,6 +23,7 @@ import (
	log "git.sr.ht/~mariusor/lw"
	vocab "github.com/go-ap/activitypub"
	"github.com/go-ap/errors"
	"github.com/go-ap/filters"
	"github.com/gorilla/csrf"
	"github.com/mariusor/qstring"
	"github.com/mariusor/render"
@@ -298,7 +299,7 @@ func (v *view) RenderTemplate(r *http.Request, w http.ResponseWriter, name strin
		v.errFn(log.Ctx{"err": err, "model": m})("failed to render template %s", name)
		return errors.Annotatef(err, "failed to render template")
	}
	io.Copy(w, &wrt)
	_, _ = io.Copy(w, &wrt)
	return nil
}

@@ -881,16 +882,18 @@ func rejectLink(f FollowRequest) string {
	return path.Join(followLink(f), "reject")
}

func nextPageLink(p Hash) template.HTML {
	if p.String() != "" {
		return template.HTML(fmt.Sprintf("?after=%s", p))
func nextPageLink(p vocab.IRI) template.HTML {
	if p != "" {
		u := filters.ToValues(filters.After(IRIRef(p)))
		return template.HTML("?" + u.Encode())
	}
	return ""
}

func prevPageLink(p Hash) template.HTML {
	if p.String() != "" {
		return template.HTML(fmt.Sprintf("?before=%s", p))
func prevPageLink(p vocab.IRI) template.HTML {
	if p != "" {
		u := filters.ToValues(filters.Before(IRIRef(p)))
		return template.HTML("?" + u.Encode())
	}
	return ""
}
-- 
2.48.1
Reply to thread Export thread (mbox)