From 65fb8abd423fd449211019df6f1425957b7d1b92 Mon Sep 17 00:00:00 2001
From: tobi <31960611+tsmethurst@users.noreply.github.com>
Date: Mon, 27 Jan 2025 19:22:15 +0100
Subject: [PATCH] [feature] Implement `deliveryRecipientPreSort` to prioritize
 delivery to mentioned accounts (#3668)

* weeeeenus

* update to latest activity

* update to use latest release tag of superseriousbusiness/activity

---------

Co-authored-by: kim <grufwub@gmail.com>
---
 go.mod                                        |   2 +-
 go.sum                                        |   4 +-
 internal/federation/federatingactor.go        |  58 ++-
 .../activity/pub/side_effect_actor.go         | 399 ++++++++++++------
 .../superseriousbusiness/activity/pub/util.go |  86 ++--
 vendor/modules.txt                            |   2 +-
 6 files changed, 386 insertions(+), 165 deletions(-)

diff --git a/go.mod b/go.mod
index f7abc31e1..6d5096387 100644
--- a/go.mod
+++ b/go.mod
@@ -70,7 +70,7 @@ require (
 	github.com/spf13/cobra v1.8.1
 	github.com/spf13/viper v1.19.0
 	github.com/stretchr/testify v1.10.0
-	github.com/superseriousbusiness/activity v1.9.0-gts
+	github.com/superseriousbusiness/activity v1.10.0-gts
 	github.com/superseriousbusiness/httpsig v1.2.0-SSB
 	github.com/superseriousbusiness/oauth2/v4 v4.3.2-SSB.0.20230227143000-f4900831d6c8
 	github.com/tdewolff/minify/v2 v2.21.2
diff --git a/go.sum b/go.sum
index 1771e1654..a04c4fdaf 100644
--- a/go.sum
+++ b/go.sum
@@ -533,8 +533,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/superseriousbusiness/activity v1.9.0-gts h1:qWMDeiGdnVi+XG7CfuM7ET87qe9adousU6utWItBX/o=
-github.com/superseriousbusiness/activity v1.9.0-gts/go.mod h1:9l74ZCv8zw07vipNMzahq8oQZt2xPaJZ+L+gLicQntQ=
+github.com/superseriousbusiness/activity v1.10.0-gts h1:uYIHU0/jDpLxj0lA3Jg24lM8p3X/Vb3J7hn3yQJR+C8=
+github.com/superseriousbusiness/activity v1.10.0-gts/go.mod h1:9l74ZCv8zw07vipNMzahq8oQZt2xPaJZ+L+gLicQntQ=
 github.com/superseriousbusiness/go-jpeg-image-structure/v2 v2.0.0-20220321154430-d89a106fdabe h1:ksl2oCx/Qo8sNDc3Grb8WGKBM9nkvhCm25uvlT86azE=
 github.com/superseriousbusiness/go-jpeg-image-structure/v2 v2.0.0-20220321154430-d89a106fdabe/go.mod h1:gH4P6gN1V+wmIw5o97KGaa1RgXB/tVpC2UNzijhg3E4=
 github.com/superseriousbusiness/go-png-image-structure/v2 v2.0.1-SSB h1:8psprYSK1KdOSH7yQ4PbJq0YYaGQY+gzdW/B0ExDb/8=
diff --git a/internal/federation/federatingactor.go b/internal/federation/federatingactor.go
index 83cee4b04..6ea737eb0 100644
--- a/internal/federation/federatingactor.go
+++ b/internal/federation/federatingactor.go
@@ -23,6 +23,7 @@ import (
 	"fmt"
 	"net/http"
 	"net/url"
+	"slices"
 
 	errorsv2 "codeberg.org/gruf/go-errors/v2"
 	"codeberg.org/gruf/go-kv"
@@ -30,9 +31,11 @@ import (
 	"github.com/superseriousbusiness/activity/streams/vocab"
 	"github.com/superseriousbusiness/gotosocial/internal/ap"
 	apiutil "github.com/superseriousbusiness/gotosocial/internal/api/util"
+	"github.com/superseriousbusiness/gotosocial/internal/config"
 	"github.com/superseriousbusiness/gotosocial/internal/db"
 	"github.com/superseriousbusiness/gotosocial/internal/gtserror"
 	"github.com/superseriousbusiness/gotosocial/internal/log"
+	"github.com/superseriousbusiness/gotosocial/internal/uris"
 )
 
 // federatingActor wraps the pub.FederatingActor
@@ -42,10 +45,63 @@ type federatingActor struct {
 	wrapped         pub.FederatingActor
 }
 
+func deliveryRecipientPreSort(actorAndCollectionIRIs []*url.URL) []*url.URL {
+	var (
+		thisHost       = config.GetHost()
+		thisAcctDomain = config.GetAccountDomain()
+	)
+
+	slices.SortFunc(
+		actorAndCollectionIRIs,
+		func(a *url.URL, b *url.URL) int {
+			// We want to sort by putting more specific actor URIs *before* collection URIs.
+			// Since the only collection URIs we ever address are our own followers URIs, we
+			// can just use host and regexes to identify these collections, and shove them
+			// to the back of the slice. This ensures that directly addressed (ie., mentioned)
+			// accounts get delivery-attempted *first*, and then delivery attempts move on to
+			// followers of the author. This should have the effect of making conversation
+			/// threads feel more snappy, as replies will be sent quicker to participants.
+			var (
+				aIsFollowers = (a.Host == thisHost || a.Host == thisAcctDomain) && uris.IsFollowersPath(a)
+				bIsFollowers = (b.Host == thisHost || b.Host == thisAcctDomain) && uris.IsFollowersPath(b)
+			)
+
+			switch {
+			case aIsFollowers == bIsFollowers:
+				// Both followers URIs or
+				// both not followers URIs,
+				// order doesn't matter.
+				return 0
+
+			case aIsFollowers:
+				// a is followers
+				// URI, b is not.
+				//
+				// Sort b before a.
+				return 1
+
+			default:
+				// b is followers
+				// URI, a is not.
+				//
+				// Sort a before b.
+				return -1
+			}
+		},
+	)
+
+	return actorAndCollectionIRIs
+}
+
 // newFederatingActor returns a federatingActor.
 func newFederatingActor(c pub.CommonBehavior, s2s pub.FederatingProtocol, db pub.Database, clock pub.Clock) pub.FederatingActor {
 	sideEffectActor := pub.NewSideEffectActor(c, s2s, nil, db, clock)
-	sideEffectActor.Serialize = ap.Serialize // hook in our own custom Serialize function
+
+	// Hook in our own custom Serialize function.
+	sideEffectActor.Serialize = ap.Serialize
+
+	// Hook in our own custom recipient pre-sort function.
+	sideEffectActor.DeliveryRecipientPreSort = deliveryRecipientPreSort
 
 	return &federatingActor{
 		sideEffectActor: sideEffectActor,
diff --git a/vendor/github.com/superseriousbusiness/activity/pub/side_effect_actor.go b/vendor/github.com/superseriousbusiness/activity/pub/side_effect_actor.go
index 0c1da9e91..c89ab60e6 100644
--- a/vendor/github.com/superseriousbusiness/activity/pub/side_effect_actor.go
+++ b/vendor/github.com/superseriousbusiness/activity/pub/side_effect_actor.go
@@ -20,25 +20,58 @@ var _ DelegateActor = &SideEffectActor{}
 // Note that when using the SideEffectActor with an application that good-faith
 // implements its required interfaces, the ActivityPub specification is
 // guaranteed to be correctly followed.
-//
-// When doing deliveries to remote servers via the s2s protocol, the side effect
-// actor will by default use the Serialize function from the streams package.
-// However, this can be overridden after the side effect actor is intantiated,
-// by setting the exposed Serialize function on the struct. For example:
-//
-//	a := NewSideEffectActor(...)
-//	a.Serialize = func(a vocab.Type) (m map[string]interface{}, e error) {
-//	  // Put your custom serializer logic here.
-//	}
-//
-// Note that you should only do this *immediately* after instantiating the side
-// effect actor -- never while your application is already running, as this will
-// likely cause race conditions or other problems! In most cases, you will never
-// need to change this; it's provided solely to allow easier customization by
-// applications.
 type SideEffectActor struct {
+	// When doing deliveries to remote servers via the s2s protocol, the side effect
+	// actor will by default use the Serialize function from the streams package.
+	// However, this can be overridden after the side effect actor is intantiated,
+	// by setting the exposed Serialize function on the struct. For example:
+	//
+	//	a := NewSideEffectActor(...)
+	//	a.Serialize = func(a vocab.Type) (m map[string]interface{}, e error) {
+	//	  // Put your custom serializer logic here.
+	//	}
+	//
+	// Note that you should only do this *immediately* after instantiating the side
+	// effect actor -- never while your application is already running, as this will
+	// likely cause race conditions or other problems! In most cases, you will never
+	// need to change this; it's provided solely to allow easier customization by
+	// applications.
 	Serialize func(a vocab.Type) (m map[string]interface{}, e error)
 
+	// When doing deliveries to remote servers via the s2s protocol, it may be desirable
+	// for implementations to be able to pre-sort recipients so that higher-priority
+	// recipients are higher up in the delivery queue, and lower-priority recipients
+	// are further down. This can be achieved by setting the DeliveryRecipientPreSort
+	// function on the side effect actor after it's instantiated. For example:
+	//
+	//	a := NewSideEffectActor(...)
+	//	a.DeliveryRecipientPreSort = func(actorAndCollectionIRIs []*url.URL) []*url.URL {
+	//	  // Put your sorting logic here.
+	//	}
+	//
+	// The actorAndCollectionIRIs parameter will be the initial list of IRIs derived by
+	// looking at the "to", "cc", "bto", "bcc", and "audience" properties of the activity
+	// being delivered, excluding the AP public IRI, and before dereferencing of inboxes.
+	// It may look something like this:
+	//
+	//	[
+	//		"https://example.org/users/someone/followers",     // <-- collection IRI
+	//		"https://another.example.org/users/someone_else",  // <-- actor IRI
+	//		"[...]"                                            // <-- etc
+	//	]
+	//
+	// In this case, implementers may wish to sort the slice so that the directly-addressed
+	// actor "https://another.example.org/users/someone_else" occurs at an earlier index in
+	// the slice than the followers collection "https://example.org/users/someone/followers",
+	// so that "@someone_else" receives the delivery first.
+	//
+	// Note that you should only do this *immediately* after instantiating the side
+	// effect actor -- never while your application is already running, as this will
+	// likely cause race conditions or other problems! It's also completely fine to not
+	// set this function at all -- in this case, no pre-sorting of recipients will be
+	// performed, and delivery will occur in a non-determinate order.
+	DeliveryRecipientPreSort func(actorAndCollectionIRIs []*url.URL) []*url.URL
+
 	common CommonBehavior
 	s2s    FederatingProtocol
 	c2s    SocialProtocol
@@ -652,195 +685,315 @@ func (a *SideEffectActor) hasInboxForwardingValues(c context.Context, inboxIRI *
 	return false, nil
 }
 
-// prepare takes a deliverableObject and returns a list of the proper recipient
-// target URIs. Additionally, the deliverableObject will have any hidden
-// hidden recipients ("bto" and "bcc") stripped from it.
+// prepare takes a deliverableObject and returns a list of the final
+// recipient inbox IRIs. Additionally, the deliverableObject will have
+// any hidden hidden recipients ("bto" and "bcc") stripped from it.
 //
 // Only call if both the social and federated protocol are supported.
-func (a *SideEffectActor) prepare(c context.Context, outboxIRI *url.URL, activity Activity) (r []*url.URL, err error) {
-	// Get inboxes of recipients
+func (a *SideEffectActor) prepare(
+	ctx context.Context,
+	outboxIRI *url.URL,
+	activity Activity,
+) ([]*url.URL, error) {
+	// Iterate through to, bto, cc, bcc, and audience
+	// to extract a slice of addressee IRIs / IDs.
+	//
+	// The resulting slice might look something like:
+	//
+	//	[
+	//		"https://example.org/users/someone/followers",     // <-- collection IRI
+	//		"https://another.example.org/users/someone_else",  // <-- actor IRI
+	//		"[...]"                                            // <-- etc
+	//	]
+	var actorsAndCollections []*url.URL
 	if to := activity.GetActivityStreamsTo(); to != nil {
 		for iter := to.Begin(); iter != to.End(); iter = iter.Next() {
-			var val *url.URL
-			val, err = ToId(iter)
+			var err error
+			actorsAndCollections, err = appendToActorsAndCollectionsIRIs(
+				iter, actorsAndCollections,
+			)
 			if err != nil {
-				return
+				return nil, err
 			}
-			r = append(r, val)
 		}
 	}
+
 	if bto := activity.GetActivityStreamsBto(); bto != nil {
 		for iter := bto.Begin(); iter != bto.End(); iter = iter.Next() {
-			var val *url.URL
-			val, err = ToId(iter)
+			var err error
+			actorsAndCollections, err = appendToActorsAndCollectionsIRIs(
+				iter, actorsAndCollections,
+			)
 			if err != nil {
-				return
+				return nil, err
 			}
-			r = append(r, val)
 		}
 	}
+
 	if cc := activity.GetActivityStreamsCc(); cc != nil {
 		for iter := cc.Begin(); iter != cc.End(); iter = iter.Next() {
-			var val *url.URL
-			val, err = ToId(iter)
+			var err error
+			actorsAndCollections, err = appendToActorsAndCollectionsIRIs(
+				iter, actorsAndCollections,
+			)
 			if err != nil {
-				return
+				return nil, err
 			}
-			r = append(r, val)
 		}
 	}
+
 	if bcc := activity.GetActivityStreamsBcc(); bcc != nil {
 		for iter := bcc.Begin(); iter != bcc.End(); iter = iter.Next() {
-			var val *url.URL
-			val, err = ToId(iter)
+			var err error
+			actorsAndCollections, err = appendToActorsAndCollectionsIRIs(
+				iter, actorsAndCollections,
+			)
 			if err != nil {
-				return
+				return nil, err
 			}
-			r = append(r, val)
 		}
 	}
+
 	if audience := activity.GetActivityStreamsAudience(); audience != nil {
 		for iter := audience.Begin(); iter != audience.End(); iter = iter.Next() {
-			var val *url.URL
-			val, err = ToId(iter)
+			var err error
+			actorsAndCollections, err = appendToActorsAndCollectionsIRIs(
+				iter, actorsAndCollections,
+			)
 			if err != nil {
-				return
+				return nil, err
 			}
-			r = append(r, val)
 		}
 	}
-	// 1. When an object is being delivered to the originating actor's
-	//    followers, a server MAY reduce the number of receiving actors
-	//    delivered to by identifying all followers which share the same
-	//    sharedInbox who would otherwise be individual recipients and
-	//    instead deliver objects to said sharedInbox.
-	// 2. If an object is addressed to the Public special collection, a
-	//    server MAY deliver that object to all known sharedInbox endpoints
-	//    on the network.
-	r = filterURLs(r, IsPublic)
 
-	// first check if the implemented database logic can return any inboxes
-	// from our list of actor IRIs.
-	foundInboxesFromDB := []*url.URL{}
-	for _, actorIRI := range r {
-		// BEGIN LOCK
-		var unlock func()
-		unlock, err = a.db.Lock(c, actorIRI)
-		if err != nil {
-			return
+	// PRE-SORTING
+
+	// If the pre-delivery sort function is defined,
+	// call it now so that implementations can sort
+	// delivery order to their preferences.
+	if a.DeliveryRecipientPreSort != nil {
+		actorsAndCollections = a.DeliveryRecipientPreSort(actorsAndCollections)
+	}
+
+	// We now need to dereference the actor or collection
+	// IRIs to derive inboxes that we can POST requests to.
+	var (
+		inboxes       = make([]*url.URL, 0, len(actorsAndCollections))
+		derefdEntries = make(map[string]struct{}, len(actorsAndCollections))
+	)
+
+	// First check if the implemented database logic
+	// can return any of these inboxes without having
+	// to make remote dereference calls (much cheaper).
+	for _, actorOrCollection := range actorsAndCollections {
+		actorOrCollectionStr := actorOrCollection.String()
+		if _, derefd := derefdEntries[actorOrCollectionStr]; derefd {
+			// Ignore potential duplicates
+			// we've already derefd to inbox(es).
+			continue
 		}
 
-		inboxes, err := a.db.InboxesForIRI(c, actorIRI)
+		// BEGIN LOCK
+		unlock, err := a.db.Lock(ctx, actorOrCollection)
 		if err != nil {
-			// bail on error
-			unlock()
 			return nil, err
 		}
 
-		if len(inboxes) > 0 {
-			// we have a hit
-			foundInboxesFromDB = append(foundInboxesFromDB, inboxes...)
-
-			// if we found inboxes for this iri, we should remove it from
-			// the list of actors/iris we still need to dereference
-			r = removeOne(r, actorIRI)
-		}
+		// Try to get inbox(es) for this actor or collection.
+		gotInboxes, err := a.db.InboxesForIRI(ctx, actorOrCollection)
 
 		// END LOCK
 		unlock()
+
+		if err != nil {
+			return nil, err
+		}
+
+		if len(gotInboxes) == 0 {
+			// No hit(s).
+			continue
+		}
+
+		// We have one or more hits.
+		inboxes = append(inboxes, gotInboxes...)
+
+		// Mark this actor or collection as deref'd.
+		derefdEntries[actorOrCollectionStr] = struct{}{}
 	}
 
-	// look for any actors' inboxes that weren't already discovered above;
-	// find these by making dereference calls to remote instances
-	t, err := a.common.NewTransport(c, outboxIRI, goFedUserAgent())
-	if err != nil {
-		return nil, err
-	}
-	foundActorsFromRemote, err := a.resolveActors(c, t, r, 0, a.s2s.MaxDeliveryRecursionDepth(c))
-	if err != nil {
-		return nil, err
-	}
-	foundInboxesFromRemote, err := getInboxes(foundActorsFromRemote)
+	// Now look for any remaining actors/collections
+	// that weren't already dereferenced into inboxes
+	// with db calls; find these by making deref calls
+	// to remote instances.
+	//
+	// First get a transport to do the http calls.
+	t, err := a.common.NewTransport(ctx, outboxIRI, goFedUserAgent())
 	if err != nil {
 		return nil, err
 	}
 
-	// combine this list of dereferenced inbox IRIs with the inboxes we already
-	// found in the db, to make a complete list of target IRIs
-	targets := []*url.URL{}
-	targets = append(targets, foundInboxesFromDB...)
-	targets = append(targets, foundInboxesFromRemote...)
-
-	// Get inboxes of sender.
-	var unlock func()
-	unlock, err = a.db.Lock(c, outboxIRI)
-	if err != nil {
-		return
-	}
-	// WARNING: No deferring the Unlock
-	actorIRI, err := a.db.ActorForOutbox(c, outboxIRI)
-	unlock() // unlock after regardless
-	if err != nil {
-		return
-	}
-	// Get the inbox on the sender.
-	unlock, err = a.db.Lock(c, actorIRI)
+	// Make HTTP calls to unpack collection IRIs into
+	// Actor IRIs and then into Actor types, ignoring
+	// actors or collections we've already deref'd.
+	actorsFromRemote, err := a.resolveActors(
+		ctx,
+		t,
+		actorsAndCollections,
+		derefdEntries,
+		0, a.s2s.MaxDeliveryRecursionDepth(ctx),
+	)
 	if err != nil {
 		return nil, err
 	}
+
+	// Release no-longer-needed collections.
+	clear(derefdEntries)
+	clear(actorsAndCollections)
+
+	// Extract inbox IRI from each deref'd Actor (if any).
+	inboxesFromRemote, err := actorsToInboxIRIs(actorsFromRemote)
+	if err != nil {
+		return nil, err
+	}
+
+	// Combine db-discovered inboxes and remote-discovered
+	// inboxes into a final list of destination inboxes.
+	inboxes = append(inboxes, inboxesFromRemote...)
+
+	// POST FILTERING
+
+	// Do a final pass of the inboxes to:
+	//
+	// 1. Deduplicate entries.
+	// 2. Ensure that the list of inboxes doesn't
+	// contain the inbox of whoever the outbox
+	// belongs to, no point delivering to oneself.
+	//
+	// To do this we first need to get the
+	// inbox IRI of this outbox's Actor.
+
 	// BEGIN LOCK
-	thisActor, err := a.db.Get(c, actorIRI)
+	unlock, err := a.db.Lock(ctx, outboxIRI)
+	if err != nil {
+		return nil, err
+	}
+
+	// Get the IRI of the Actor who owns this outbox.
+	outboxActorIRI, err := a.db.ActorForOutbox(ctx, outboxIRI)
+
+	// END LOCK
 	unlock()
-	// END LOCK -- Still need to handle err
+
 	if err != nil {
 		return nil, err
 	}
-	// Post-processing
-	var ignore *url.URL
-	ignore, err = getInbox(thisActor)
+
+	// BEGIN LOCK
+	unlock, err = a.db.Lock(ctx, outboxActorIRI)
 	if err != nil {
 		return nil, err
 	}
-	r = dedupeIRIs(targets, []*url.URL{ignore})
+
+	// Now get the Actor who owns this outbox.
+	outboxActor, err := a.db.Get(ctx, outboxActorIRI)
+
+	// END LOCK
+	unlock()
+
+	if err != nil {
+		return nil, err
+	}
+
+	// Extract the inbox IRI for the outbox Actor.
+	inboxOfOutboxActor, err := getInbox(outboxActor)
+	if err != nil {
+		return nil, err
+	}
+
+	// Deduplicate the final inboxes slice, and filter
+	// out of the inbox of this outbox actor (if present).
+	inboxes = filterInboxIRIs(inboxes, inboxOfOutboxActor)
+
+	// Now that we've derived inboxes to deliver
+	// the activity to, strip off any bto or bcc
+	// recipients, as per the AP spec requirements.
 	stripHiddenRecipients(activity)
-	return r, nil
+
+	// All done!
+	return inboxes, nil
 }
 
 // resolveActors takes a list of Actor id URIs and returns them as concrete
 // instances of actorObject. It attempts to apply recursively when it encounters
 // a target that is a Collection or OrderedCollection.
 //
+// Any IRI strings in the ignores map will be skipped (use this when
+// you've already dereferenced some of the actorAndCollectionIRIs).
+//
 // If maxDepth is zero or negative, then recursion is infinitely applied.
 //
 // If a recipient is a Collection or OrderedCollection, then the server MUST
 // dereference the collection, WITH the user's credentials.
 //
 // Note that this also applies to CollectionPage and OrderedCollectionPage.
-func (a *SideEffectActor) resolveActors(c context.Context, t Transport, r []*url.URL, depth, maxDepth int) (actors []vocab.Type, err error) {
+func (a *SideEffectActor) resolveActors(
+	ctx context.Context,
+	t Transport,
+	actorAndCollectionIRIs []*url.URL,
+	ignores map[string]struct{},
+	depth, maxDepth int,
+) ([]vocab.Type, error) {
 	if maxDepth > 0 && depth >= maxDepth {
-		return
+		// Hit our max depth.
+		return nil, nil
 	}
-	for _, u := range r {
-		var act vocab.Type
-		var more []*url.URL
-		// TODO: Determine if more logic is needed here for inaccessible
-		// collections owned by peer servers.
-		act, more, err = a.dereferenceForResolvingInboxes(c, t, u)
+
+	if len(actorAndCollectionIRIs) == 0 {
+		// Nothing to do.
+		return nil, nil
+	}
+
+	// Optimistically assume 1:1 mapping of IRIs to actors.
+	actors := make([]vocab.Type, 0, len(actorAndCollectionIRIs))
+	
+	// Deref each actorOrCollectionIRI if not ignored.
+	for _, actorOrCollectionIRI := range actorAndCollectionIRIs {
+		_, ignore := ignores[actorOrCollectionIRI.String()]
+		if ignore {
+			// Don't try to
+			// deref this one.
+			continue
+		}
+
+		// TODO: Determine if more logic is needed here for
+		// inaccessible collections owned by peer servers.
+		actor, more, err := a.dereferenceForResolvingInboxes(ctx, t, actorOrCollectionIRI)
 		if err != nil {
 			// Missing recipient -- skip.
 			continue
 		}
-		var recurActors []vocab.Type
-		recurActors, err = a.resolveActors(c, t, more, depth+1, maxDepth)
+
+		if actor != nil {
+			// Got a hit.
+			actors = append(actors, actor)
+		}
+
+		// If this was a collection, get more.
+		recurActors, err := a.resolveActors(
+			ctx,
+			t,
+			more,
+			ignores,
+			depth+1, maxDepth,
+		)
 		if err != nil {
-			return
-		}
-		if act != nil {
-			actors = append(actors, act)
+			return nil, err
 		}
+
 		actors = append(actors, recurActors...)
 	}
-	return
+
+	return actors, nil
 }
 
 // dereferenceForResolvingInboxes dereferences an IRI solely for finding an
diff --git a/vendor/github.com/superseriousbusiness/activity/pub/util.go b/vendor/github.com/superseriousbusiness/activity/pub/util.go
index e917205ee..20bf09780 100644
--- a/vendor/github.com/superseriousbusiness/activity/pub/util.go
+++ b/vendor/github.com/superseriousbusiness/activity/pub/util.go
@@ -385,19 +385,6 @@ func wrapInCreate(ctx context.Context, o vocab.Type, actor *url.URL) (c vocab.Ac
 	return
 }
 
-// filterURLs removes urls whose strings match the provided filter
-func filterURLs(u []*url.URL, fn func(s string) bool) []*url.URL {
-	i := 0
-	for i < len(u) {
-		if fn(u[i].String()) {
-			u = append(u[:i], u[i+1:]...)
-		} else {
-			i++
-		}
-	}
-	return u
-}
-
 const (
 	// PublicActivityPubIRI is the IRI that indicates an Activity is meant
 	// to be visible for general public consumption.
@@ -412,8 +399,28 @@ func IsPublic(s string) bool {
 	return s == PublicActivityPubIRI || s == publicJsonLD || s == publicJsonLDAS
 }
 
-// getInboxes extracts the 'inbox' IRIs from actor types.
-func getInboxes(t []vocab.Type) (u []*url.URL, err error) {
+// Derives an ID URI from the given IdProperty and, if it's not the
+// magic AP Public IRI, appends it to the actorsAndCollections slice.
+func appendToActorsAndCollectionsIRIs(
+	iter IdProperty,
+	actorsAndCollections []*url.URL,
+) ([]*url.URL, error) {
+	id, err := ToId(iter)
+	if err != nil {
+		return nil, err
+	}
+
+	// Ignore Public IRI as we
+	// can't deliver to it directly.
+	if !IsPublic(id.String()) {
+		actorsAndCollections = append(actorsAndCollections, id)
+	}
+
+	return actorsAndCollections, nil
+}
+
+// actorsToInboxIRIs extracts the 'inbox' IRIs from actor types.
+func actorsToInboxIRIs(t []vocab.Type) (u []*url.URL, err error) {
 	for _, elem := range t {
 		var iri *url.URL
 		iri, err = getInbox(elem)
@@ -436,32 +443,37 @@ func getInbox(t vocab.Type) (u *url.URL, err error) {
 	return ToId(inbox)
 }
 
-// dedupeIRIs will deduplicate final inbox IRIs. The ignore list is applied to
-// the final list.
-func dedupeIRIs(recipients, ignored []*url.URL) (out []*url.URL) {
-	ignoredMap := make(map[string]bool, len(ignored))
-	for _, elem := range ignored {
-		ignoredMap[elem.String()] = true
+// filterInboxIRIs will deduplicate the given inboxes
+// slice, while also leaving out any filtered IRIs.
+func filterInboxIRIs(
+	inboxes []*url.URL,
+	filtered ...*url.URL,
+) []*url.URL {
+	// Prepopulate the ignored map with each filtered IRI.
+	ignored := make(map[string]struct{}, len(filtered)+len(inboxes))
+	for _, filteredIRI := range filtered {
+		ignored[filteredIRI.String()] = struct{}{}
 	}
-	outMap := make(map[string]bool, len(recipients))
-	for _, k := range recipients {
-		kStr := k.String()
-		if !ignoredMap[kStr] && !outMap[kStr] {
-			out = append(out, k)
-			outMap[kStr] = true
-		}
-	}
-	return
-}
 
-// removeOne removes any occurrences of entry from a slice of entries.
-func removeOne(entries []*url.URL, entry *url.URL) (out []*url.URL) {
-	for _, e := range entries {
-		if e.String() != entry.String() {
-			out = append(out, e)
+	deduped := make([]*url.URL, 0, len(inboxes))
+	for _, inbox := range inboxes {
+		inboxStr := inbox.String()
+		_, ignore := ignored[inboxStr]
+		if ignore {
+			// We already included
+			// this URI in out, or
+			// we should ignore it.
+			continue
 		}
+
+		// Include this IRI in output, and
+		// add entry to the ignored map to
+		// ensure we don't include it again.
+		deduped = append(deduped, inbox)
+		ignored[inboxStr] = struct{}{}
 	}
-	return out
+
+	return deduped
 }
 
 // stripHiddenRecipients removes "bto" and "bcc" from the activity.
diff --git a/vendor/modules.txt b/vendor/modules.txt
index edebc0503..64ef34bca 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -660,7 +660,7 @@ github.com/stretchr/testify/suite
 # github.com/subosito/gotenv v1.6.0
 ## explicit; go 1.18
 github.com/subosito/gotenv
-# github.com/superseriousbusiness/activity v1.9.0-gts
+# github.com/superseriousbusiness/activity v1.10.0-gts
 ## explicit; go 1.21
 github.com/superseriousbusiness/activity/pub
 github.com/superseriousbusiness/activity/streams