diff --git a/app/controllers/discourse_activity_pub/actor_controller.rb b/app/controllers/discourse_activity_pub/actor_controller.rb
index 00a52be2..8102f028 100644
--- a/app/controllers/discourse_activity_pub/actor_controller.rb
+++ b/app/controllers/discourse_activity_pub/actor_controller.rb
@@ -18,7 +18,7 @@ class ActorController < ApplicationController
     before_action :find_target_actor, only: %i[follow unfollow reject]
 
     def show
-      render_serialized(@actor, DiscourseActivityPub::ActorSerializer, include_model: true)
+      render_serialized(@actor, DiscourseActivityPub::DetailedActorSerializer)
     end
 
     def follow
@@ -102,7 +102,7 @@ def followers
 
     def render_actors
       render_json_dump(
-        actors: serialize_data(actors, ActorSerializer, root: false, include_model: true),
+        actors: serialize_data(actors, DetailedActorSerializer, root: false),
         meta: {
           total: @total,
           load_more_url: load_more_url(@page),
diff --git a/app/controllers/discourse_activity_pub/admin/actor_controller.rb b/app/controllers/discourse_activity_pub/admin/actor_controller.rb
index f2c17892..ccc5474c 100644
--- a/app/controllers/discourse_activity_pub/admin/actor_controller.rb
+++ b/app/controllers/discourse_activity_pub/admin/actor_controller.rb
@@ -50,9 +50,8 @@ def index
 
         render_serialized(
           actors,
-          DiscourseActivityPub::ActorSerializer,
+          DiscourseActivityPub::DetailedActorSerializer,
           root: "actors",
-          include_model: true,
           meta: {
             total: total,
             load_more_url: load_more_url.to_s,
@@ -61,12 +60,7 @@ def index
       end
 
       def show
-        render_serialized(
-          @actor,
-          DiscourseActivityPub::ActorSerializer,
-          root: false,
-          include_model: true,
-        )
+        render_serialized(@actor, DiscourseActivityPub::DetailedActorSerializer, root: false)
       end
 
       def create
@@ -105,10 +99,9 @@ def update_or_create
           render json:
                    success_json.merge(
                      actor:
-                       DiscourseActivityPub::ActorSerializer.new(
+                       DiscourseActivityPub::DetailedActorSerializer.new(
                          actor,
                          root: false,
-                         include_model: true,
                        ).as_json,
                    )
         else
diff --git a/app/controllers/discourse_activity_pub/post_controller.rb b/app/controllers/discourse_activity_pub/post_controller.rb
index 18911635..5102a9e4 100644
--- a/app/controllers/discourse_activity_pub/post_controller.rb
+++ b/app/controllers/discourse_activity_pub/post_controller.rb
@@ -10,10 +10,42 @@ class PostController < ApplicationController
     before_action :ensure_staff
     before_action :find_post
     before_action :ensure_first_post, only: %i[schedule unschedule]
-    before_action :ensure_can_schedule, only: [:schedule]
-    before_action :ensure_can_unschedule, only: [:unschedule]
+
+    def deliver
+      if !@post.activity_pub_published? || !@post.topic.activity_pub_published? ||
+           @post.activity_pub_taxonomy_followers.empty? ||
+           (!@post.topic.activity_pub_delivered? && !@post.is_first_post?)
+        return render_post_error("cant_deliver_post", 422)
+      end
+
+      if @post.activity_pub_deliver!
+        render json: success_json
+      else
+        render json: failed_json, status: 422
+      end
+    end
+
+    def publish
+      if @post.activity_pub_published? || @post.activity_pub_scheduled? ||
+           (!@post.topic.activity_pub_published? && !@post.is_first_post?)
+        return render_post_error("cant_publish_post", 422)
+      end
+
+      @post.performing_activity_delivery_delay = 0
+
+      if @post.activity_pub_publish!
+        render json: success_json
+      else
+        render json: failed_json, status: 422
+      end
+    end
 
     def schedule
+      if @post.activity_pub_published? || @post.activity_pub_scheduled? ||
+           @post.activity_pub_taxonomy_followers.blank?
+        return render_post_error("cant_schedule_post", 422)
+      end
+
       if @post.activity_pub_schedule!
         render json: success_json
       else
@@ -22,6 +54,10 @@ def schedule
     end
 
     def unschedule
+      if (@post.activity_pub_published? || !@post.activity_pub_scheduled?)
+        return render_post_error("cant_unschedule_post", 422)
+      end
+
       if @post.activity_pub_unschedule!
         render json: success_json
       else
@@ -32,19 +68,7 @@ def unschedule
     protected
 
     def ensure_first_post
-      render_post_error("not_first_post", 422) unless @post.activity_pub_is_first_post?
-    end
-
-    def ensure_can_schedule
-      if (@post.activity_pub_published? || @post.activity_pub_scheduled?)
-        render_post_error("cant_schedule_post", 422)
-      end
-    end
-
-    def ensure_can_unschedule
-      if (@post.activity_pub_published? || !@post.activity_pub_scheduled?)
-        render_post_error("cant_unschedule_post", 422)
-      end
+      render_post_error("not_first_post", 422) unless @post.is_first_post?
     end
 
     def find_post
diff --git a/app/controllers/discourse_activity_pub/topic_controller.rb b/app/controllers/discourse_activity_pub/topic_controller.rb
new file mode 100644
index 00000000..962f303a
--- /dev/null
+++ b/app/controllers/discourse_activity_pub/topic_controller.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module DiscourseActivityPub
+  class TopicController < ApplicationController
+    requires_plugin DiscourseActivityPub::PLUGIN_NAME
+
+    include DiscourseActivityPub::EnabledVerification
+
+    before_action :ensure_site_enabled
+    before_action :ensure_staff
+    before_action :find_topic
+    before_action :ensure_can_publish
+
+    def publish
+      @topic.activity_pub_publish!
+      render json: success_json
+    end
+
+    protected
+
+    def ensure_can_publish
+      if !@topic.activity_pub_full_topic || @topic.activity_pub_all_posts_published? ||
+           @topic.activity_pub_scheduled?
+        render_topic_error("cant_publish_topic", 422)
+      end
+    end
+
+    def find_topic
+      @topic = Topic.find_by(id: params[:topic_id])
+      render_topic_error("topic_not_found", 400) if @topic.blank?
+    end
+
+    def render_topic_error(key, status)
+      render_json_error I18n.t("discourse_activity_pub.topic.error.#{key}"), status: status
+    end
+  end
+end
diff --git a/app/jobs/discourse_activity_pub_publish.rb b/app/jobs/discourse_activity_pub_publish.rb
new file mode 100644
index 00000000..4cbbc0df
--- /dev/null
+++ b/app/jobs/discourse_activity_pub_publish.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module Jobs
+  class DiscourseActivityPubPublish < ::Jobs::Base
+    def execute(args)
+      topic = Topic.find_by(id: args[:topic_id])
+      return unless topic
+
+      result = DiscourseActivityPub::Bulk::Publish.perform(topic_id: topic.id)
+      topic.reload.activity_pub_publish_state if result.finished
+    end
+  end
+end
diff --git a/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb b/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb
index bac7cfb4..7849527e 100644
--- a/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb
+++ b/app/models/concerns/discourse_activity_pub/ap/model_callbacks.rb
@@ -8,11 +8,12 @@ module ModelCallbacks
         attr_accessor :performing_activity,
                       :performing_activity_object,
                       :performing_activity_actor,
-                      :target_activity
+                      :performing_activity_target_activity
+        attr_writer :performing_activity_delivery_delay
       end
 
       def perform_activity_pub_activity(activity_type, target_activity_type = nil)
-        return nil unless DiscourseActivityPub.publishing_enabled && activity_pub_publish?
+        return false unless activity_pub_publishing_enabled
 
         @performing_activity = DiscourseActivityPub::AP::Object.from_type(activity_type)
 
@@ -21,25 +22,57 @@ def perform_activity_pub_activity(activity_type, target_activity_type = nil)
           return true unless performing_activity
         end
 
-        @target_activity =
+        return false unless activity_pub_perform_activity?
+
+        @performing_activity_target_activity =
           DiscourseActivityPub::AP::Object.from_type(target_activity_type) if target_activity_type
-        return unless valid_activity_pub_activity?
+        return false unless valid_activity_pub_activity?
 
         return false unless ensure_activity_pub_actor
 
         @performing_activity_object = get_performing_activity_object
-        return unless performing_activity_object
+        return false unless performing_activity_object
 
         @performing_activity_actor = get_performing_activity_actor
-        return unless performing_activity_actor
+        return false unless performing_activity_actor
 
         ActiveRecord::Base.transaction do
           update_activity_pub_activity_object
           create_activity_pub_activity
         end
 
-        activity_pub_deliver_activity
-        perform_activity_pub_activity_cleanup
+        if performing_activity_pre_publication?
+          if self.respond_to?(:activity_pub_after_scheduled)
+            activity_pub_after_scheduled(scheduled_at: activity_pub_scheduled_at)
+          end
+          return true
+        end
+
+        if performing_activity_can_deliver?
+          performing_activity_deliver
+        else
+          @performing_activity.stored.publish! if @performing_activity.stored
+        end
+
+        performing_activity_cleanup
+
+        true
+      end
+
+      def activity_pub_deliver_create
+        return unless activity_pub_object
+
+        @performing_activity =
+          DiscourseActivityPub::AP::Activity::Create.new(
+            stored: activity_pub_object.create_activity,
+          )
+        @performing_activity_object = activity_pub_object
+        @performing_activity_actor = activity_pub_actor
+
+        return unless performing_activity_can_deliver?
+
+        performing_activity_deliver
+        performing_activity_cleanup
 
         true
       end
@@ -48,7 +81,12 @@ def perform_activity_pub_activity(activity_type, target_activity_type = nil)
 
       def valid_activity_pub_activity?
         return false unless activity_pub_enabled
-        return false unless activity_pub_valid_activity?(performing_activity, target_activity)
+        unless activity_pub_valid_activity?(
+                 performing_activity,
+                 performing_activity_target_activity,
+               )
+          return false
+        end
 
         # We don't permit updates if object has been deleted.
         return false if self.activity_pub_deleted? && performing_activity.update?
@@ -68,6 +106,7 @@ def get_performing_activity_object
             attrs[:reply_to_id] = self.activity_pub_reply_to_object.ap_id
           end
           if self.activity_pub_full_topic
+            return nil unless self.topic.activity_pub_object
             attrs[:collection_id] = self.topic.activity_pub_object.id
             attrs[:attributed_to_id] = self.activity_pub_actor.ap_id
           end
@@ -76,7 +115,7 @@ def get_performing_activity_object
           activity_pub_actor
             .activities
             .where(object_id: self.activity_pub_object.id)
-            .find_by(ap_type: target_activity.type)
+            .find_by(ap_type: performing_activity_target_activity.type)
         else
           nil
         end
@@ -95,8 +134,12 @@ def get_performing_activity_actor
         acting_user.activity_pub_actor
       end
 
+      def performing_activity_pre_publication?
+        !self.destroyed? && !activity_pub_published? && !performing_activity.create?
+      end
+
       def update_activity_pub_activity_object
-        return unless performing_activity
+        return unless performing_activity && performing_activity_object
 
         if performing_activity.create? || performing_activity.update?
           performing_activity_object.name = self.activity_pub_name if self.activity_pub_name
@@ -106,9 +149,7 @@ def update_activity_pub_activity_object
       end
 
       def create_activity_pub_activity
-        if !performing_activity || (performing_activity.update? && !self.activity_pub_published?)
-          return
-        end
+        return unless performing_activity
 
         activity_attrs = {
           local: true,
@@ -127,36 +168,55 @@ def create_activity_pub_activity
         @performing_activity.stored = DiscourseActivityPubActivity.create!(activity_attrs)
       end
 
-      def activity_pub_deliver_activity
-        return if !activity_pub_delivery_object
+      def performing_activity_can_deliver?
+        performing_activity&.stored && performing_activity_deliveries.present?
+      end
 
-        if !self.destroyed? && !activity_pub_published? && !performing_activity.create?
-          if self.respond_to?(:activity_pub_after_scheduled)
-            activity_pub_after_scheduled(scheduled_at: activity_pub_scheduled_at)
+      def performing_activity_deliveries
+        @performing_activity_deliveries ||=
+          begin
+            deliveries = []
+            recipient_ids = []
+
+            performing_activity_delivery_actors.each do |delivery_actor|
+              delivery_actor_actor_recipient_ids =
+                performing_activity_delivery_recipient_ids(delivery_actor).select do |recipient_id|
+                  recipient_ids.exclude?(recipient_id)
+                end
+
+              if delivery_actor_actor_recipient_ids.present?
+                recipient_ids += delivery_actor_actor_recipient_ids
+                deliveries << OpenStruct.new(
+                  actor: delivery_actor,
+                  object: performing_activity&.stored,
+                  recipient_ids: delivery_actor_actor_recipient_ids,
+                  delay: performing_activity_delivery_delay,
+                )
+              end
+            end
+
+            deliveries
           end
-          return
+      end
+
+      def performing_activity_delivery_actors
+        if performing_activity_announce?
+          activity_pub_taxonomy_actors
+        else
+          [performing_activity_actor]
         end
+      end
 
-        deliveries = []
-        all_recipient_ids = []
-        object = activity_pub_delivery_object
-        delay = activity_pub_delivery_delay
+      def performing_activity_announce?
+        performing_like = performing_activity.like? || performing_activity.undo_like?
+        preforming_create_first_post = performing_activity.create? && is_first_post?
+        preforming_create_first_post || performing_like
+      end
 
-        activity_pub_delivery_actors.each do |actor|
-          recipient_ids =
-            activity_pub_delivery_recipient_ids(actor).select do |recipient_id|
-              all_recipient_ids.exclude?(recipient_id)
-            end
-          all_recipient_ids += recipient_ids
-          deliveries << OpenStruct.new(
-            actor: actor,
-            object: object,
-            recipient_ids: recipient_ids,
-            delay: delay,
-          )
-        end
+      def performing_activity_deliver
+        return unless performing_activity_can_deliver?
 
-        deliveries.each do |delivery|
+        performing_activity_deliveries.each do |delivery|
           DiscourseActivityPub::DeliveryHandler.perform(
             actor: delivery.actor,
             object: delivery.object,
@@ -166,14 +226,27 @@ def activity_pub_deliver_activity
         end
       end
 
-      def perform_activity_pub_activity_cleanup
+      def performing_activity_cleanup
         @performing_activity = nil
+        @performing_activity_target_activity = nil
         @performing_activity_object = nil
         @performing_activity_actor = nil
+        @performing_activity_deliveries = nil
+        @performing_activity_delivery_delay = nil
       end
 
-      def activity_pub_delivery_recipient_ids(actor)
-        actor_ids = actor.reload.followers.map(&:id)
+      def performing_activity_delivery_recipient_ids(delivery_actor)
+        actor_ids = delivery_actor.reload.followers.map(&:id)
+
+        if delivery_actor.ap.person?
+          activity_pub_taxonomy_actors.each do |taxonomy_actor|
+            taxonomy_actor
+              .reload
+              .followers
+              .map(&:id)
+              .each { |actor_id| actor_ids << actor_id if actor_ids.exclude?(actor_id) }
+          end
+        end
 
         if self.respond_to?(:activity_pub_collection) && activity_pub_collection.present?
           activity_pub_collection
@@ -186,28 +259,18 @@ def activity_pub_delivery_recipient_ids(actor)
             end
         end
 
-        actor_ids
+        actor_ids.uniq
       end
 
-      def activity_pub_delivery_actors
-        if performing_activity.create? || performing_activity.like? ||
-             performing_activity.undo_like?
-          activity_pub_group_actors
-        else
-          [performing_activity_actor]
-        end
-      end
-
-      def activity_pub_delivery_object
-        performing_activity.stored
-      end
-
-      def activity_pub_delivery_delay
-        if !self.destroyed? && !activity_pub_topic_published?
-          SiteSetting.activity_pub_delivery_delay_minutes.to_i
-        else
-          nil
-        end
+      def performing_activity_delivery_delay
+        @performing_activity_delivery_delay ||=
+          begin
+            if !self.destroyed? && !activity_pub_topic_published?
+              SiteSetting.activity_pub_delivery_delay_minutes.to_i
+            else
+              nil
+            end
+          end
       end
 
       def ensure_activity_pub_actor
diff --git a/app/models/concerns/discourse_activity_pub/ap/object_helpers.rb b/app/models/concerns/discourse_activity_pub/ap/object_helpers.rb
new file mode 100644
index 00000000..fde3649d
--- /dev/null
+++ b/app/models/concerns/discourse_activity_pub/ap/object_helpers.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+module DiscourseActivityPub
+  module AP
+    module ObjectHelpers
+      extend ActiveSupport::Concern
+
+      def get_published_at
+        self.published_at ? self.published_at.to_time.utc.iso8601 : Time.now.utc.iso8601
+      end
+
+      def get_delivered_at
+        Time.now.utc.iso8601
+      end
+    end
+  end
+end
diff --git a/app/models/discourse_activity_pub_activity.rb b/app/models/discourse_activity_pub_activity.rb
index 6ef637c4..42a3b0fb 100644
--- a/app/models/discourse_activity_pub_activity.rb
+++ b/app/models/discourse_activity_pub_activity.rb
@@ -2,6 +2,7 @@
 class DiscourseActivityPubActivity < ActiveRecord::Base
   include DiscourseActivityPub::AP::IdentifierValidations
   include DiscourseActivityPub::AP::ObjectValidations
+  include DiscourseActivityPub::AP::ObjectHelpers
 
   belongs_to :actor, class_name: "DiscourseActivityPubActor"
   belongs_to :object, polymorphic: true
@@ -93,9 +94,14 @@ def announce!(actor_id)
     )
   end
 
+  def publish!
+    _published_at = get_published_at
+    self.update(published_at: _published_at) if !self.published_at
+    after_published(_published_at, self)
+  end
+
   def before_deliver
-    # We have to set "published" on the JSON we deliver
-    after_published(Time.now.utc.iso8601, self)
+    publish!
   end
 
   def after_deliver(delivered = true)
@@ -116,6 +122,8 @@ def after_deliver(delivered = true)
         followed_id: actor_id,
       ).destroy_all
     end
+
+    object.after_deliver(delivered) if object.respond_to?(:after_deliver)
   end
 
   def undo_follow?
@@ -131,7 +139,6 @@ def after_scheduled(scheduled_at, _activity = nil)
   end
 
   def after_published(published_at, _activity = nil)
-    self.update(published_at: published_at) if !self.published_at
     object.after_published(published_at, self) if object.respond_to?(:after_published)
   end
 
diff --git a/app/models/discourse_activity_pub_collection.rb b/app/models/discourse_activity_pub_collection.rb
index bd41e099..a089e081 100644
--- a/app/models/discourse_activity_pub_collection.rb
+++ b/app/models/discourse_activity_pub_collection.rb
@@ -3,6 +3,7 @@
 class DiscourseActivityPubCollection < ActiveRecord::Base
   include DiscourseActivityPub::AP::IdentifierValidations
   include DiscourseActivityPub::AP::ModelValidations
+  include DiscourseActivityPub::AP::ObjectHelpers
 
   belongs_to :model, -> { unscope(where: :deleted_at) }, polymorphic: true, optional: true
 
@@ -39,7 +40,7 @@ def public?
 
   def before_deliver
     @context = :activities
-    after_published(Time.now.utc.iso8601)
+    after_published(get_published_at)
   end
 
   def after_deliver(delivered = true)
@@ -52,7 +53,6 @@ def after_scheduled(scheduled_at, activity = nil)
 
   def after_published(published_at, activity = nil)
     self.update(published_at: published_at)
-    send_to_collection("after_published", published_at)
   end
 
   def actor
diff --git a/app/models/discourse_activity_pub_object.rb b/app/models/discourse_activity_pub_object.rb
index eaafeeb3..66e12c7e 100644
--- a/app/models/discourse_activity_pub_object.rb
+++ b/app/models/discourse_activity_pub_object.rb
@@ -3,6 +3,7 @@
 class DiscourseActivityPubObject < ActiveRecord::Base
   include DiscourseActivityPub::AP::IdentifierValidations
   include DiscourseActivityPub::AP::ModelValidations
+  include DiscourseActivityPub::AP::ObjectHelpers
 
   belongs_to :model, -> { unscope(where: :deleted_at) }, polymorphic: true, optional: true
   belongs_to :collection, class_name: "DiscourseActivityPubCollection", foreign_key: "collection_id"
@@ -90,32 +91,38 @@ def in_reply_to_post
   end
 
   def before_deliver
-    after_published(Time.now.utc.iso8601)
   end
 
   def after_deliver(delivered = true)
+    if delivered && model.respond_to?(:activity_pub_after_deliver)
+      args = { delivered_at: get_delivered_at }
+      model.activity_pub_after_deliver(args)
+    end
   end
 
   def after_scheduled(scheduled_at, activity = nil)
     if model.respond_to?(:activity_pub_after_scheduled)
       args = { scheduled_at: scheduled_at }
-      if activity&.ap&.create?
-        args[:published_at] = nil
-        args[:deleted_at] = nil
-        args[:updated_at] = nil
-      end
       model.activity_pub_after_scheduled(args)
     end
   end
 
   def after_published(published_at, activity = nil)
-    self.update(published_at: published_at)
+    self.update(published_at: published_at) if !self.published_at.present?
 
     if model.respond_to?(:activity_pub_after_publish)
       args = {}
-      args[:published_at] = published_at if activity&.ap&.create?
-      args[:deleted_at] = published_at if activity&.ap&.delete?
+      if activity&.ap&.create? && model.activity_pub_published_at.blank?
+        args[:published_at] = published_at
+        args[:deleted_at] = nil
+      end
       args[:updated_at] = published_at if activity&.ap&.update?
+      if activity&.ap&.delete?
+        args[:published_at] = nil
+        args[:updated_at] = nil
+        args[:scheduled_at] = nil
+        args[:deleted_at] = published_at
+      end
       model.activity_pub_after_publish(args)
     end
   end
@@ -145,7 +152,7 @@ def topic_actor
   end
 
   def attributed_to
-    if model&.activity_pub_first_post
+    if local? && model&.activity_pub_first_post
       topic_actor
     else
       super
diff --git a/app/serializers/discourse_activity_pub/actor_serializer.rb b/app/serializers/discourse_activity_pub/actor_serializer.rb
index 56538dd6..5bf9afdf 100644
--- a/app/serializers/discourse_activity_pub/actor_serializer.rb
+++ b/app/serializers/discourse_activity_pub/actor_serializer.rb
@@ -2,21 +2,59 @@
 
 module DiscourseActivityPub
   class ActorSerializer < BasicActorSerializer
-    attributes :local, :domain, :url, :icon_url, :followed_at, :model
+    attributes :name,
+               :model_id,
+               :model_type,
+               :model_name,
+               :can_admin,
+               :default_visibility,
+               :publication_type,
+               :post_object_type,
+               :enabled,
+               :ready
 
-    def model
-      case object.model_type
-      when "User"
-        BasicUserSerializer.new(object.model, root: false).as_json
-      when "Category"
-        SiteCategorySerializer.new(object.model, root: false).as_json
-      when "Tag"
-        TagSerializer.new(object.model, root: false).as_json
-      end
+    def model_type
+      object.model_type&.downcase
     end
 
-    def include_model?
-      @options[:include_model]
+    def model_name
+      object.model.name
+    end
+
+    def include_model_name?
+      object.model_type === "Tag"
+    end
+
+    def can_admin
+      scope&.can_admin?(object)
+    end
+
+    def default_visibility
+      object.default_visibility
+    end
+
+    def publication_type
+      object.publication_type
+    end
+
+    def post_object_type
+      object.post_object_type
+    end
+
+    def enabled
+      object.enabled
+    end
+
+    def include_enabled?
+      object.model.present?
+    end
+
+    def ready
+      object.model.activity_pub_ready?
+    end
+
+    def include_ready?
+      object.model.present?
     end
   end
 end
diff --git a/app/serializers/discourse_activity_pub/admin/actor_serializer.rb b/app/serializers/discourse_activity_pub/admin/actor_serializer.rb
deleted file mode 100644
index 01cca865..00000000
--- a/app/serializers/discourse_activity_pub/admin/actor_serializer.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# frozen_string_literal: true
-
-module DiscourseActivityPub
-  class Admin::ActorSerializer < ActorSerializer
-    def include_model?
-      true
-    end
-  end
-end
diff --git a/app/serializers/discourse_activity_pub/authorization_serializer.rb b/app/serializers/discourse_activity_pub/authorization_serializer.rb
index 488ce37c..b418e47d 100644
--- a/app/serializers/discourse_activity_pub/authorization_serializer.rb
+++ b/app/serializers/discourse_activity_pub/authorization_serializer.rb
@@ -4,7 +4,7 @@ module DiscourseActivityPub
   class AuthorizationSerializer < ActiveModel::Serializer
     attributes :id, :user_id, :auth_type
 
-    has_one :actor, serializer: BasicActorSerializer, embed: :objects
+    has_one :actor, serializer: ActorSerializer, embed: :objects
 
     def auth_type
       DiscourseActivityPubClient.auth_types[object.client.auth_type].to_s
diff --git a/app/serializers/discourse_activity_pub/basic_actor_serializer.rb b/app/serializers/discourse_activity_pub/basic_actor_serializer.rb
index 15e7fe52..0b45549d 100644
--- a/app/serializers/discourse_activity_pub/basic_actor_serializer.rb
+++ b/app/serializers/discourse_activity_pub/basic_actor_serializer.rb
@@ -2,62 +2,13 @@
 
 module DiscourseActivityPub
   class BasicActorSerializer < ActiveModel::Serializer
-    attributes :id,
-               :handle,
-               :name,
-               :username,
-               :model_id,
-               :model_type,
-               :model_name,
-               :can_admin,
-               :default_visibility,
-               :publication_type,
-               :post_object_type,
-               :enabled,
-               :ready
+    attributes :id, :username, :domain, :handle, :ap_id
 
-    def model_type
-      object.model_type&.downcase
-    end
-
-    def model_name
-      object.model.name
-    end
-
-    def include_model_name?
-      object.model_type === "Tag"
-    end
-
-    def can_admin
-      scope&.can_admin?(object)
-    end
-
-    def default_visibility
-      object.default_visibility
-    end
-
-    def publication_type
-      object.publication_type
-    end
-
-    def post_object_type
-      object.post_object_type
-    end
-
-    def enabled
-      object.enabled
-    end
-
-    def include_enabled?
-      object.model.present?
-    end
-
-    def ready
-      object.model.activity_pub_ready?
-    end
-
-    def include_ready?
-      object.model.present?
+    def handle
+      DiscourseActivityPub::Webfinger::Handle.new(
+        username: object.username,
+        domain: object.domain || DiscourseActivityPub.host,
+      ).to_s
     end
   end
 end
diff --git a/app/serializers/discourse_activity_pub/detailed_actor_serializer.rb b/app/serializers/discourse_activity_pub/detailed_actor_serializer.rb
new file mode 100644
index 00000000..cda9e20a
--- /dev/null
+++ b/app/serializers/discourse_activity_pub/detailed_actor_serializer.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module DiscourseActivityPub
+  class DetailedActorSerializer < ActorSerializer
+    attributes :local, :url, :icon_url, :followed_at, :model
+
+    def model
+      case object.model_type
+      when "User"
+        BasicUserSerializer.new(object.model, root: false).as_json
+      when "Category"
+        SiteCategorySerializer.new(object.model, root: false).as_json
+      when "Tag"
+        TagSerializer.new(object.model, root: false).as_json
+      end
+    end
+  end
+end
diff --git a/app/serializers/discourse_activity_pub/site_actor_serializer.rb b/app/serializers/discourse_activity_pub/site_actor_serializer.rb
new file mode 100644
index 00000000..1c4bfc31
--- /dev/null
+++ b/app/serializers/discourse_activity_pub/site_actor_serializer.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module DiscourseActivityPub
+  class SiteActorSerializer < ActorSerializer
+    attributes :follower_count
+
+    def follower_count
+      object.followers.count
+    end
+  end
+end
diff --git a/assets/javascripts/discourse/components/activity-pub-status.gjs b/assets/javascripts/discourse/components/activity-pub-actor-status.gjs
similarity index 96%
rename from assets/javascripts/discourse/components/activity-pub-status.gjs
rename to assets/javascripts/discourse/components/activity-pub-actor-status.gjs
index ea7bfe6d..4a5e9bc6 100644
--- a/assets/javascripts/discourse/components/activity-pub-status.gjs
+++ b/assets/javascripts/discourse/components/activity-pub-actor-status.gjs
@@ -7,7 +7,7 @@ import { bind } from "discourse/lib/decorators";
 import { i18n } from "discourse-i18n";
 import ActivityPubActor from "../models/activity-pub-actor";
 
-export default class ActivityPubStatus extends Component {
+export default class ActivityPubActorStatus extends Component {
   @service siteSettings;
   @service site;
   @service messageBus;
@@ -127,7 +127,7 @@ export default class ActivityPubStatus extends Component {
   }
 
   get classes() {
-    let result = `activity-pub-status ${dasherize(this.statusKey)}`;
+    let result = `activity-pub-actor-status ${dasherize(this.statusKey)}`;
     if (this.args.onClick) {
       result += " clickable";
     }
diff --git a/assets/javascripts/discourse/components/activity-pub-admin-info.gjs b/assets/javascripts/discourse/components/activity-pub-admin-info.gjs
new file mode 100644
index 00000000..0199bd6a
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-admin-info.gjs
@@ -0,0 +1,19 @@
+import Component from "@glimmer/component";
+import { activityPubTopicActors } from "../lib/activity-pub-utilities";
+import ActivityPubHandle from "./activity-pub-handle";
+
+export default class ActivityPubPublicationInfo extends Component {
+  get actors() {
+    return activityPubTopicActors(this.args.topic);
+  }
+
+  <template>
+    <div class="activity-pub-admin-info">
+      <div class="activity-pub-admin-info-actors">
+        {{#each this.actors as |actor|}}
+          <ActivityPubHandle @actor={{actor}} @hideCopy={{true}} />
+        {{/each}}
+      </div>
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-attribute.gjs b/assets/javascripts/discourse/components/activity-pub-attribute.gjs
new file mode 100644
index 00000000..88bcc48c
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-attribute.gjs
@@ -0,0 +1,77 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import { dasherize } from "@ember/string";
+import dIcon from "discourse/helpers/d-icon";
+import { camelCaseToSnakeCase } from "discourse/lib/case-converter";
+import { clipboardCopy } from "discourse/lib/utilities";
+import { i18n } from "discourse-i18n";
+
+const icons = {
+  note: "file",
+  article: "file",
+  collection: "folder",
+  orderedcollection: "folder",
+  public: "globe",
+  private: "lock",
+  actor: "user",
+};
+
+export default class ActivityPubAttribute extends Component {
+  @service toasts;
+
+  @action
+  async copyURI() {
+    if (!this.args.uri) {
+      return;
+    }
+    await clipboardCopy(this.args.uri);
+    this.toasts.success({
+      duration: 2500,
+      data: {
+        message: i18n("discourse_activity_pub.copy_uri.copied"),
+      },
+    });
+  }
+
+  get icon() {
+    let key = this.args.value?.toLowerCase() || "note";
+    if (this.args.attribute === "actor") {
+      key = "actor";
+    }
+    return icons[key];
+  }
+
+  get label() {
+    if (!this.args.value) {
+      return "";
+    }
+    if (this.args.attribute === "actor") {
+      return this.args.value;
+    } else {
+      return i18n(
+        `discourse_activity_pub.${
+          this.args.attribute
+        }.label.${camelCaseToSnakeCase(this.args.value)}`
+      );
+    }
+  }
+
+  get classes() {
+    let classes = `activity-pub-attribute ${dasherize(
+      this.args.attribute
+    )} ${this.args.value?.toLowerCase()}`;
+    if (this.args.uri) {
+      classes += " copiable";
+    }
+    return classes;
+  }
+
+  <template>
+    <div class={{this.classes}} {{on "click" this.copyURI}} role="button">
+      {{dIcon this.icon}}
+      {{this.label}}
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-attributes.gjs b/assets/javascripts/discourse/components/activity-pub-attributes.gjs
new file mode 100644
index 00000000..4124b801
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-attributes.gjs
@@ -0,0 +1,44 @@
+import Component from "@glimmer/component";
+import ActivityPubAttribute from "./activity-pub-attribute";
+
+export default class ActivityPubAttributes extends Component {
+  get topic() {
+    return this.args.topic;
+  }
+
+  get post() {
+    return this.args.post;
+  }
+
+  get postActor() {
+    return this.post.topic.getActivityPubPostActor(this.post.id);
+  }
+
+  <template>
+    <div class="activity-pub-attributes">
+      {{#if this.topic}}
+        <ActivityPubAttribute
+          @attribute="object_type"
+          @value={{this.topic.activity_pub_object_type}}
+          @uri={{this.topic.activity_pub_object_id}}
+        />
+      {{/if}}
+      <ActivityPubAttribute
+        @attribute="object_type"
+        @value={{this.post.activity_pub_object_type}}
+        @uri={{this.post.activity_pub_object_id}}
+      />
+      <ActivityPubAttribute
+        @attribute="visibility"
+        @value={{this.post.activity_pub_visibility}}
+      />
+      {{#if this.postActor}}
+        <ActivityPubAttribute
+          @attribute="actor"
+          @value={{this.postActor.actor.handle}}
+          @uri={{this.postActor.actor.ap_id}}
+        />
+      {{/if}}
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-handle.gjs b/assets/javascripts/discourse/components/activity-pub-handle.gjs
index ad10af4a..f463813b 100644
--- a/assets/javascripts/discourse/components/activity-pub-handle.gjs
+++ b/assets/javascripts/discourse/components/activity-pub-handle.gjs
@@ -34,11 +34,13 @@ export default class ActivityPubHandle extends Component {
             class="btn btn-icon no-text"
           >{{icon "up-right-from-square"}}</a>
         {{/if}}
-        {{#if this.copied}}
-          <DButton @icon="copy" @label="ip_lookup.copied" class="btn-hover" />
-        {{else}}
-          <DButton @action={{this.copy}} @icon="copy" class="no-text" />
-        {{/if}}
+        {{#unless @hideCopy}}
+          {{#if this.copied}}
+            <DButton @icon="copy" @label="ip_lookup.copied" class="btn-hover" />
+          {{else}}
+            <DButton @action={{this.copy}} @icon="copy" class="no-text" />
+          {{/if}}
+        {{/unless}}
       </div>
     </div>
   </template>
diff --git a/assets/javascripts/discourse/components/activity-pub-post-actions.gjs b/assets/javascripts/discourse/components/activity-pub-post-actions.gjs
new file mode 100644
index 00000000..2eb5f646
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-post-actions.gjs
@@ -0,0 +1,307 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import { htmlSafe } from "@ember/template";
+import DButton from "discourse/components/d-button";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { i18n } from "discourse-i18n";
+import { activityPubTopicActors } from "../lib/activity-pub-utilities";
+
+export default class ActivityPubPostActions extends Component {
+  @service siteSettings;
+  @service appEvents;
+  @tracked status;
+  @tracked post;
+  @tracked topic;
+
+  constructor() {
+    super(...arguments);
+
+    this.post = this.args.post;
+    this.topic = this.args.post.topic;
+    this.appEvents.on("activity-pub:post-updated", this, "postUpdated");
+    this.appEvents.on("activity-pub:topic-updated", this, "topicUpdated");
+
+    let status = "unpublished";
+    if (this.post.activity_pub_published_at) {
+      status = "published";
+    } else if (this.post.activity_pub_scheduled_at) {
+      status = "scheduled";
+    }
+    this.status = status;
+  }
+
+  postUpdated(postId, postProps) {
+    if (this.post.id === postId) {
+      this.post.setProperties(postProps);
+    }
+  }
+
+  topicUpdated(topicId, topicProps) {
+    if (this.topic.id === topicId) {
+      this.topic.setProperties(topicProps);
+    }
+  }
+
+  get actors() {
+    return activityPubTopicActors(this.topic);
+  }
+
+  get actorsString() {
+    return this.actors
+      .map(
+        (actor) => `<span class="activity-pub-handle">${actor.handle}</span>`
+      )
+      .join(" ");
+  }
+
+  get noFollowers() {
+    return (
+      this.actors.reduce(
+        (total, actor) => (actor.follower_count || 0) + total,
+        0
+      ) === 0
+    );
+  }
+
+  get showActions() {
+    return this.showDeliver || this.showPublish || this.showSchedule;
+  }
+
+  get showDeliver() {
+    return ["published", "delivered"].includes(this.status);
+  }
+
+  get deliverLabel() {
+    return i18n(`post.discourse_activity_pub.actions.deliver.label`, {
+      post_number: this.post.post_number,
+    });
+  }
+
+  get deliverDescription() {
+    let args = {
+      post_number: this.post.post_number,
+    };
+    let i18nKey;
+
+    if (this.status === "delivered") {
+      i18nKey = "delivered";
+    } else if (
+      this.post.post_number !== 1 &&
+      !this.topic.activity_pub_delivered_at
+    ) {
+      i18nKey = "topic_not_delivered";
+      args.topic_id = this.topic.id;
+    } else if (this.noFollowers) {
+      i18nKey = "no_followers";
+    } else {
+      i18nKey = "followers";
+    }
+
+    return i18n(
+      `post.discourse_activity_pub.actions.deliver.description.${i18nKey}`,
+      args
+    );
+  }
+
+  get deliverDisabled() {
+    return (
+      this.status === "delivered" ||
+      !this.post.activity_pub_published_at ||
+      this.noFollowers ||
+      (!this.topic.activity_pub_delivered_at && this.post.post_number !== 1)
+    );
+  }
+
+  get showPublish() {
+    return !["published", "delivered"].includes(this.status);
+  }
+
+  get publishLabel() {
+    return i18n(`post.discourse_activity_pub.actions.publish.label`, {
+      post_number: this.post.post_number,
+      actors: this.actorsString,
+    });
+  }
+
+  get publishDescription() {
+    let args = {
+      post_number: this.post.post_number,
+    };
+    let i18nKey;
+
+    if (this.post.post_number !== 1 && !this.topic.activity_pub_published_at) {
+      i18nKey = "topic_not_published";
+      args.topic_id = this.topic.id;
+    } else if (this.post.activity_pub_scheduled_at) {
+      i18nKey = "post_is_scheduled";
+    } else if (this.noFollowers) {
+      i18nKey = "no_followers";
+    } else {
+      args.actors = this.actorsString;
+      i18nKey = "followers";
+    }
+
+    return i18n(
+      `post.discourse_activity_pub.actions.publish.description.${i18nKey}`,
+      args
+    );
+  }
+
+  get publishDisabled() {
+    return (
+      !!this.post.activity_pub_published_at ||
+      !!this.post.activity_pub_scheduled_at ||
+      (!this.topic.activity_pub_published_at && this.post.post_number !== 1)
+    );
+  }
+
+  get showSchedule() {
+    return (
+      ["unpublished", "scheduled"].includes(this.status) &&
+      this.post.post_number === 1
+    );
+  }
+
+  get scheduleAction() {
+    return this.status === "scheduled" ? "unschedule" : "schedule";
+  }
+
+  get scheduleLabel() {
+    return i18n(
+      `post.discourse_activity_pub.actions.${this.scheduleAction}.label`,
+      {
+        post_number: this.post.post_number,
+      }
+    );
+  }
+
+  get scheduleDescription() {
+    let args = {
+      post_number: this.post.post_number,
+    };
+    let i18nKey = "description";
+
+    if (this.scheduleAction === "schedule") {
+      args.minutes = this.siteSettings.activity_pub_delivery_delay_minutes;
+
+      if (this.noFollowers) {
+        i18nKey += ".no_followers";
+      } else {
+        args.actors = this.actorsString;
+        i18nKey += ".followers";
+      }
+    }
+
+    return i18n(
+      `post.discourse_activity_pub.actions.${this.scheduleAction}.${i18nKey}`,
+      args
+    );
+  }
+
+  @action
+  unschedule() {
+    ajax(`/ap/post/schedule/${this.post.id}`, {
+      type: "DELETE",
+    })
+      .then((result) => {
+        if (result.success) {
+          this.status = "unpublished";
+        }
+      })
+      .catch(popupAjaxError);
+  }
+
+  @action
+  schedule() {
+    ajax(`/ap/post/schedule/${this.post.id}`, {
+      type: "POST",
+    })
+      .then((result) => {
+        if (result.success) {
+          this.status = "scheduled";
+        }
+      })
+      .catch(popupAjaxError);
+  }
+
+  @action
+  deliver() {
+    ajax(`/ap/post/deliver/${this.post.id}`, {
+      type: "POST",
+    })
+      .then((result) => {
+        if (result.success) {
+          this.status = "delivered";
+        }
+      })
+      .catch(popupAjaxError);
+  }
+
+  @action
+  publish() {
+    ajax(`/ap/post/publish/${this.post.id}`, {
+      type: "POST",
+    })
+      .then((result) => {
+        if (result.success) {
+          this.status = "published";
+        }
+      })
+      .catch(popupAjaxError);
+  }
+
+  <template>
+    {{#if this.showActions}}
+      <div class="activity-pub-post-actions">
+        {{#if this.showDeliver}}
+          <div class="action deliver">
+            <div class="action-button">
+              <DButton
+                @translatedLabel={{this.deliverLabel}}
+                @action={{this.deliver}}
+                @disabled={{this.deliverDisabled}}
+                class="deliver"
+              />
+            </div>
+            <div class="action-description">
+              {{htmlSafe this.deliverDescription}}
+            </div>
+          </div>
+        {{/if}}
+        {{#if this.showPublish}}
+          <div class="action publish">
+            <div class="action-button">
+              <DButton
+                @translatedLabel={{this.publishLabel}}
+                @action={{this.publish}}
+                @disabled={{this.publishDisabled}}
+                class="publish"
+              />
+            </div>
+            <div class="action-description">
+              {{htmlSafe this.publishDescription}}
+            </div>
+          </div>
+        {{/if}}
+        {{#if this.showSchedule}}
+          <div class="action schedule">
+            <div class="action-button">
+              <DButton
+                @translatedLabel={{this.scheduleLabel}}
+                @action={{action this.scheduleAction}}
+                @disabled={{this.noFollowers}}
+                class={{this.scheduleAction}}
+              />
+            </div>
+            <div class="action-description">
+              {{htmlSafe this.scheduleDescription}}
+            </div>
+          </div>
+        {{/if}}
+      </div>
+    {{/if}}
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-post-info.gjs b/assets/javascripts/discourse/components/activity-pub-post-info.gjs
new file mode 100644
index 00000000..4d0df6f1
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-post-info.gjs
@@ -0,0 +1,71 @@
+import Component from "@glimmer/component";
+import dIcon from "discourse/helpers/d-icon";
+import {
+  activityPubPostStatus,
+  activityPubPostStatusText,
+} from "../lib/activity-pub-utilities";
+
+export default class ActivityPubPostInfo extends Component {
+  get post() {
+    return this.args.post;
+  }
+
+  get status() {
+    return activityPubPostStatus(this.post);
+  }
+
+  get statusText() {
+    return activityPubPostStatusText(this.post, {
+      infoStatus: true,
+      postActor: this.post.topic.getActivityPubPostActor(this.post.id),
+    });
+  }
+
+  get statusIcon() {
+    if (this.status === "not_published") {
+      return "far-circle-dot";
+    } else {
+      return this.post.activity_pub_local
+        ? "file-arrow-up"
+        : "up-right-from-square";
+    }
+  }
+
+  get linkPostStatus() {
+    return !this.post.activity_pub_local && this.post.activity_pub_url;
+  }
+
+  get showDelivered() {
+    return !!this.post.activity_pub_delivered_at;
+  }
+
+  get deliveredText() {
+    return activityPubPostStatusText(this.post, {
+      status: "delivered",
+      infoStatus: true,
+    });
+  }
+
+  <template>
+    <div class="activity-pub-post-info">
+      <span class="activity-pub-post-status">
+        {{#if this.linkPostStatus}}
+          <a
+            href={{this.post.activity_pub_url}}
+            target="_blank"
+            rel="noopener noreferrer"
+          >
+            {{dIcon this.statusIcon}}{{this.statusText}}
+          </a>
+        {{else}}
+          {{dIcon this.statusIcon}}{{this.statusText}}
+        {{/if}}
+      </span>
+      {{#if this.showDelivered}}
+        <span class="activity-pub-delivered">{{dIcon
+            "envelope"
+          }}{{this.deliveredText}}</span>
+      {{/if}}
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-post-object-type-dropdown.js b/assets/javascripts/discourse/components/activity-pub-post-object-type-dropdown.js
index 16460d32..3ce4391f 100644
--- a/assets/javascripts/discourse/components/activity-pub-post-object-type-dropdown.js
+++ b/assets/javascripts/discourse/components/activity-pub-post-object-type-dropdown.js
@@ -12,15 +12,13 @@ export default class ActivityPubPostObjectTypeDropdown extends ComboBoxComponent
     return [
       {
         id: "Note",
-        label: i18n("discourse_activity_pub.post_object_type.label.note"),
-        title: i18n("discourse_activity_pub.post_object_type.description.note"),
+        label: i18n("discourse_activity_pub.object_type.label.note"),
+        title: i18n("discourse_activity_pub.object_type.description.note"),
       },
       {
         id: "Article",
-        label: i18n("discourse_activity_pub.post_object_type.label.article"),
-        title: i18n(
-          "discourse_activity_pub.post_object_type.description.article"
-        ),
+        label: i18n("discourse_activity_pub.object_type.label.article"),
+        title: i18n("discourse_activity_pub.object_type.description.article"),
       },
     ];
   }
diff --git a/assets/javascripts/discourse/components/activity-pub-post-status.gjs b/assets/javascripts/discourse/components/activity-pub-post-status.gjs
new file mode 100644
index 00000000..c2e8722b
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-post-status.gjs
@@ -0,0 +1,60 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import { dasherize } from "@ember/string";
+import dIcon from "discourse/helpers/d-icon";
+import {
+  activityPubPostStatus,
+  activityPubPostStatusText,
+} from "../lib/activity-pub-utilities";
+import ActivityPubPostInfoModal from "./modal/activity-pub-post-info";
+
+export default class ActivityPubPostStatus extends Component {
+  @service modal;
+
+  get post() {
+    return this.args.post;
+  }
+
+  get statusText() {
+    return activityPubPostStatusText(this.post, {
+      postActor: this.post.topic.getActivityPubPostActor(this.post.id),
+    });
+  }
+
+  get status() {
+    return activityPubPostStatus(this.post);
+  }
+
+  get icon() {
+    return this.status === "not_published"
+      ? "discourse-activity-pub-slash"
+      : "discourse-activity-pub";
+  }
+
+  get classes() {
+    let placeClass = this.post.activity_pub_local ? "local" : "remote";
+    return `activity-pub-post-status ${dasherize(this.status)} ${placeClass}`;
+  }
+
+  @action
+  showInfoModal() {
+    this.modal.show(ActivityPubPostInfoModal, {
+      model: {
+        post: this.post,
+      },
+    });
+  }
+
+  <template>
+    <div
+      role="button"
+      class={{this.classes}}
+      title={{this.statusText}}
+      {{on "click" this.showInfoModal}}
+    >
+      {{dIcon this.icon}}
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-topic-actions.gjs b/assets/javascripts/discourse/components/activity-pub-topic-actions.gjs
new file mode 100644
index 00000000..8a5783d8
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-topic-actions.gjs
@@ -0,0 +1,99 @@
+import Component from "@glimmer/component";
+import { tracked } from "@glimmer/tracking";
+import EmberObject, { action } from "@ember/object";
+import { service } from "@ember/service";
+import { htmlSafe } from "@ember/template";
+import DButton from "discourse/components/d-button";
+import { ajax } from "discourse/lib/ajax";
+import { popupAjaxError } from "discourse/lib/ajax-error";
+import { i18n } from "discourse-i18n";
+import { activityPubTopicActors } from "../lib/activity-pub-utilities";
+
+export default class ActivityPubTopicActions extends Component {
+  @service appEvents;
+  @tracked status;
+
+  constructor() {
+    super(...arguments);
+
+    this.topic = EmberObject.create(this.args.topic);
+    this.appEvents.on("activity-pub:topic-updated", this, "topicUpdated");
+
+    let status = "unpublished";
+    if (this.topic.activity_pub_scheduled_at) {
+      status = "scheduled";
+    } else if (
+      this.topic.activity_pub_published_post_count ===
+      this.topic.activity_pub_total_post_count
+    ) {
+      status = "published";
+    }
+    this.status = status;
+  }
+
+  topicUpdated(topicId, topicProps) {
+    if (this.topic.id === topicId) {
+      this.topic.setProperties(topicProps);
+    }
+  }
+
+  get actors() {
+    return activityPubTopicActors(this.topic);
+  }
+
+  get actorsString() {
+    return this.actors
+      .map(
+        (actor) => `<span class="activity-pub-handle">${actor.handle}</span>`
+      )
+      .join(" ");
+  }
+
+  get publishDisabled() {
+    return this.status !== "unpublished";
+  }
+
+  get publishDescription() {
+    return i18n(
+      `topic.discourse_activity_pub.publish.description.${this.status}`,
+      {
+        count:
+          this.topic.activity_pub_total_post_count -
+          this.topic.activity_pub_published_post_count,
+        topic_id: this.topic.id,
+        actors: this.actorsString,
+      }
+    );
+  }
+
+  @action
+  publish() {
+    ajax(`/ap/topic/publish/${this.topic.id}`, {
+      type: "POST",
+    })
+      .then((result) => {
+        if (result.success) {
+          this.status = "published";
+        }
+      })
+      .catch(popupAjaxError);
+  }
+
+  <template>
+    <div class="activity-pub-topic-actions">
+      <div class="action publish-all">
+        <div class="action-button">
+          <DButton
+            @label="topic.discourse_activity_pub.publish.label"
+            @action={{this.publish}}
+            @disabled={{this.publishDisabled}}
+            class="publish"
+          />
+        </div>
+        <div class="action-description">
+          {{htmlSafe this.publishDescription}}
+        </div>
+      </div>
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-topic-info.gjs b/assets/javascripts/discourse/components/activity-pub-topic-info.gjs
new file mode 100644
index 00000000..57e4072a
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-topic-info.gjs
@@ -0,0 +1,40 @@
+import Component from "@glimmer/component";
+import dIcon from "discourse/helpers/d-icon";
+import {
+  activityPubTopicStatus,
+  activityPubTopicStatusText,
+} from "../lib/activity-pub-utilities";
+
+export default class ActivityPubTopicInfo extends Component {
+  get topic() {
+    return this.args.topic;
+  }
+
+  get status() {
+    return activityPubTopicStatus(this.topic);
+  }
+
+  get statusText() {
+    return activityPubTopicStatusText(this.topic, {
+      infoStatus: true,
+    });
+  }
+
+  get statusIcon() {
+    if (this.status === "not_published") {
+      return "far-circle-dot";
+    } else {
+      return this.topic.activity_pub_local
+        ? "file-arrow-up"
+        : "up-right-from-square";
+    }
+  }
+
+  <template>
+    <div class="activity-pub-topic-info">
+      <span class="activity-pub-topic-status">{{dIcon
+          this.statusIcon
+        }}{{this.statusText}}</span>
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-topic-map.gjs b/assets/javascripts/discourse/components/activity-pub-topic-map.gjs
new file mode 100644
index 00000000..74e42fab
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-topic-map.gjs
@@ -0,0 +1,38 @@
+import Component from "@glimmer/component";
+import { service } from "@ember/service";
+import {
+  activityPubTopicStatus,
+  showStatusToUser,
+} from "../lib/activity-pub-utilities";
+import ActivityPubTopicStatus from "./activity-pub-topic-status";
+
+export default class ActivityPubTopicMap extends Component {
+  @service currentUser;
+  @service siteSettings;
+  @service site;
+
+  get topic() {
+    return this.args.outletArgs.topic;
+  }
+
+  get showActivityPubTopicMap() {
+    return (
+      this.site.activity_pub_enabled &&
+      this.topic.activity_pub_enabled &&
+      showStatusToUser(this.currentUser, this.siteSettings)
+    );
+  }
+
+  get topicStatus() {
+    return activityPubTopicStatus(this.topic);
+  }
+
+  <template>
+    {{yield}}
+    {{#if this.showActivityPubTopicMap}}
+      <section class="topic-map__activity-pub">
+        <ActivityPubTopicStatus @topic={{this.topic}} />
+      </section>
+    {{/if}}
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/activity-pub-topic-status.gjs b/assets/javascripts/discourse/components/activity-pub-topic-status.gjs
new file mode 100644
index 00000000..5c633035
--- /dev/null
+++ b/assets/javascripts/discourse/components/activity-pub-topic-status.gjs
@@ -0,0 +1,57 @@
+import Component from "@glimmer/component";
+import { on } from "@ember/modifier";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import { dasherize } from "@ember/string";
+import dIcon from "discourse/helpers/d-icon";
+import {
+  activityPubTopicStatus,
+  activityPubTopicStatusText,
+} from "../lib/activity-pub-utilities";
+import ActivityPubTopicInfoModal from "./modal/activity-pub-topic-info";
+
+export default class ActivityPubTopicStatus extends Component {
+  @service modal;
+
+  get topic() {
+    return this.args.topic;
+  }
+
+  get statusText() {
+    return activityPubTopicStatusText(this.topic);
+  }
+
+  get status() {
+    return activityPubTopicStatus(this.topic);
+  }
+
+  get icon() {
+    return this.status === "not_published"
+      ? "discourse-activity-pub-slash"
+      : "discourse-activity-pub";
+  }
+
+  get classes() {
+    let placeClass = this.topic.activity_pub_local ? "local" : "remote";
+    return `activity-pub-topic-status ${dasherize(this.status)} ${placeClass}`;
+  }
+
+  @action
+  async showInfoModal() {
+    const topic = this.topic;
+    const firstPost = await topic.firstPost();
+    this.modal.show(ActivityPubTopicInfoModal, { model: { topic, firstPost } });
+  }
+
+  <template>
+    <div
+      role="button"
+      class={{this.classes}}
+      title={{this.statusText}}
+      {{on "click" this.showInfoModal}}
+    >
+      {{dIcon this.icon}}
+      {{this.statusText}}
+    </div>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/modal/activity-pub-post-admin.gjs b/assets/javascripts/discourse/components/modal/activity-pub-post-admin.gjs
new file mode 100644
index 00000000..750b819c
--- /dev/null
+++ b/assets/javascripts/discourse/components/modal/activity-pub-post-admin.gjs
@@ -0,0 +1,73 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { i18n } from "discourse-i18n";
+import ActivityPubAdminInfo from "../activity-pub-admin-info";
+import ActivityPubPostActions from "../activity-pub-post-actions";
+import ActivityPubPostInfoModal from "./activity-pub-post-info";
+
+export default class ActivityPubPostAdminModal extends Component {
+  @service modal;
+
+  get title() {
+    return i18n("post.discourse_activity_pub.admin.title", {
+      post_number: this.post.post_number,
+    });
+  }
+
+  get post() {
+    return this.args.model.post;
+  }
+
+  get topic() {
+    return this.post.topic;
+  }
+
+  @action
+  showInfo() {
+    this.modal.show(ActivityPubPostInfoModal, {
+      model: {
+        post: this.post,
+      },
+    });
+  }
+
+  <template>
+    <DModal
+      @closeModal={{@closeModal}}
+      @title={{this.title}}
+      class="activity-pub-topic-modal activity-pub-post-admin-modal"
+    >
+      <:body>
+        <div class="control-group">
+          <label>{{i18n
+              "post.discourse_activity_pub.info.group_actors"
+            }}</label>
+          <div class="controls">
+            <ActivityPubAdminInfo
+              @post={{this.post}}
+              @topic={{this.topic}}
+              @context="post"
+            />
+          </div>
+        </div>
+        <div class="control-group">
+          <label>{{i18n "post.discourse_activity_pub.actions.label"}}</label>
+          <div class="controls">
+            <ActivityPubPostActions @post={{this.post}} />
+          </div>
+        </div>
+      </:body>
+      <:footer>
+        <DButton
+          @icon="circle-info"
+          @label="post.discourse_activity_pub.info.label"
+          @action={{this.showInfo}}
+          class="show-info"
+        />
+      </:footer>
+    </DModal>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/modal/activity-pub-post-info.gjs b/assets/javascripts/discourse/components/modal/activity-pub-post-info.gjs
new file mode 100644
index 00000000..95de80ed
--- /dev/null
+++ b/assets/javascripts/discourse/components/modal/activity-pub-post-info.gjs
@@ -0,0 +1,70 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { i18n } from "discourse-i18n";
+import ActivityPubAttributes from "../activity-pub-attributes";
+import ActivityPubPostInfo from "../activity-pub-post-info";
+import ActivityPubPostAdminModal from "./activity-pub-post-admin";
+
+export default class ActivityPubPostInfoModal extends Component {
+  @service modal;
+  @service currentUser;
+
+  get post() {
+    return this.args.model.post;
+  }
+
+  get title() {
+    return i18n("post.discourse_activity_pub.info.title", {
+      post_number: this.post.post_number,
+    });
+  }
+
+  get canAdmin() {
+    return this.currentUser?.staff;
+  }
+
+  @action
+  showAdmin() {
+    this.modal.show(ActivityPubPostAdminModal, {
+      model: {
+        post: this.post,
+      },
+    });
+  }
+
+  <template>
+    <DModal
+      @closeModal={{@closeModal}}
+      @title={{this.title}}
+      class="activity-pub-topic-modal activity-pub-post-info-modal"
+    >
+      <:body>
+        <div class="control-group">
+          <label>{{i18n "post.discourse_activity_pub.info.status"}}</label>
+          <div class="controls">
+            <ActivityPubPostInfo @post={{this.post}} />
+          </div>
+        </div>
+        <div class="control-group">
+          <label>{{i18n "post.discourse_activity_pub.info.attributes"}}</label>
+          <div class="controls">
+            <ActivityPubAttributes @post={{this.post}} />
+          </div>
+        </div>
+      </:body>
+      <:footer>
+        {{#if this.canAdmin}}
+          <DButton
+            @icon="gear"
+            @label="post.discourse_activity_pub.admin.label"
+            @action={{this.showAdmin}}
+            class="show-admin"
+          />
+        {{/if}}
+      </:footer>
+    </DModal>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/modal/activity-pub-post-info.hbs b/assets/javascripts/discourse/components/modal/activity-pub-post-info.hbs
deleted file mode 100644
index 1d6c07f9..00000000
--- a/assets/javascripts/discourse/components/modal/activity-pub-post-info.hbs
+++ /dev/null
@@ -1,53 +0,0 @@
-<DModal
-  @closeModal={{@closeModal}}
-  @title={{this.title}}
-  class="activity-pub-post-info-modal"
->
-  <:body>
-    <div class="activity-pub-post-info-list">
-      <span class="activity-pub-state">{{d-icon
-          this.stateIcon
-        }}{{this.stateText}}</span>
-      {{#if this.showVisibility}}
-        <span class="activity-pub-visibility">{{d-icon
-            this.visibilityIcon
-          }}{{this.visibilityText}}</span>
-      {{/if}}
-      {{#if this.showUrl}}
-        <span class="activity-pub-url">
-          <a
-            href={{@model.post.activity_pub_url}}
-            target="_blank"
-            rel="noopener noreferrer"
-          >
-            {{d-icon "up-right-from-square"}}
-            {{this.urlText}}
-          </a>
-        </span>
-      {{/if}}
-      {{#if this.showObjectId}}
-        <span class="activity-pub-object-id">
-          {{d-icon "link"}}
-          <span>{{@model.post.activity_pub_object_id}}</span>
-          {{#if this.copiedObjectId}}
-            <DButton
-              @icon="check"
-              @title="ip_lookup.copied"
-              class="activity-pub-object-id-copy success"
-            >
-              <div class="activity-pub-object-id-copy-text">
-                {{i18n "post.controls.link_copied"}}
-              </div>
-            </DButton>
-          {{else}}
-            <DButton
-              @action={{this.copyObjectId}}
-              @icon="copy"
-              class="no-text activity-pub-object-id-copy"
-            />
-          {{/if}}
-        </span>
-      {{/if}}
-    </div>
-  </:body>
-</DModal>
\ No newline at end of file
diff --git a/assets/javascripts/discourse/components/modal/activity-pub-post-info.js b/assets/javascripts/discourse/components/modal/activity-pub-post-info.js
deleted file mode 100644
index 40118ed7..00000000
--- a/assets/javascripts/discourse/components/modal/activity-pub-post-info.js
+++ /dev/null
@@ -1,85 +0,0 @@
-import Component from "@glimmer/component";
-import { tracked } from "@glimmer/tracking";
-import { action } from "@ember/object";
-import { clipboardCopy } from "discourse/lib/utilities";
-import { i18n } from "discourse-i18n";
-
-export default class ActivityPubPostInfo extends Component {
-  @tracked copiedObjectId = false;
-
-  @action
-  copyObjectId() {
-    clipboardCopy(this.args.model.post.activity_pub_object_id);
-    this.copiedObjectId = true;
-    setTimeout(() => {
-      this.copiedObjectId = false;
-    }, 2500);
-  }
-
-  get title() {
-    return i18n("post.discourse_activity_pub.info.title", {
-      post_number: this.args.model.post.post_number,
-    });
-  }
-
-  get stateText() {
-    let opts = {
-      domain: this.args.model.post.activity_pub_domain,
-      object_type: this.args.model.post.activity_pub_object_type,
-    };
-    if (this.args.model.time) {
-      opts.time = this.args.model.time.format("h:mm a, MMM D");
-    }
-    return i18n(
-      `post.discourse_activity_pub.title.${this.args.model.state}`,
-      opts
-    );
-  }
-
-  get stateIcon() {
-    if (this.args.model.state === "not_published") {
-      return "far-circle-dot";
-    } else {
-      return this.args.model.post.activity_pub_local
-        ? "arrow-up"
-        : "arrow-down";
-    }
-  }
-
-  get visibilityText() {
-    return i18n(
-      `discourse_activity_pub.visibility.description.${this.args.model.post.activity_pub_visibility}`,
-      {
-        object_type: this.args.model.post.activity_pub_object_type,
-      }
-    );
-  }
-
-  get visibilityIcon() {
-    return this.args.model.post.activity_pub_visibility === "public"
-      ? "earth-americas"
-      : "lock";
-  }
-
-  get showVisibility() {
-    return this.args.model.state !== "not_published";
-  }
-
-  get urlText() {
-    return i18n("post.discourse_activity_pub.info.url", {
-      object_type: this.args.model.post.activity_pub_object_type,
-      domain: this.args.model.post.activity_pub_domain,
-    });
-  }
-
-  get showUrl() {
-    return (
-      !this.args.model.post.activity_pub_local &&
-      this.args.model.post.activity_pub_url
-    );
-  }
-
-  get showObjectId() {
-    return this.args.model.post.activity_pub_local;
-  }
-}
diff --git a/assets/javascripts/discourse/components/modal/activity-pub-topic-admin.gjs b/assets/javascripts/discourse/components/modal/activity-pub-topic-admin.gjs
new file mode 100644
index 00000000..2f6b339e
--- /dev/null
+++ b/assets/javascripts/discourse/components/modal/activity-pub-topic-admin.gjs
@@ -0,0 +1,77 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { i18n } from "discourse-i18n";
+import ActivityPubAdminInfo from "../activity-pub-admin-info";
+import ActivityPubPostActions from "../activity-pub-post-actions";
+import ActivityPubTopicActions from "../activity-pub-topic-actions";
+import ActivityPubTopicInfo from "./activity-pub-topic-info";
+
+export default class ActivityPubTopicAdminModal extends Component {
+  @service modal;
+
+  get title() {
+    return i18n("topic.discourse_activity_pub.admin.title", {
+      topic_id: this.topic.id,
+    });
+  }
+
+  get topic() {
+    return this.args.model.topic;
+  }
+
+  get firstPost() {
+    return this.args.model.firstPost;
+  }
+
+  @action
+  showInfo() {
+    this.modal.show(ActivityPubTopicInfo, {
+      model: {
+        firstPost: this.firstPost,
+        topic: this.topic,
+      },
+    });
+  }
+
+  <template>
+    <DModal
+      @closeModal={{@closeModal}}
+      @title={{this.title}}
+      class="activity-pub-topic-modal activity-pub-topic-admin-modal"
+    >
+      <:body>
+        <div class="control-group">
+          <label>{{i18n
+              "topic.discourse_activity_pub.info.group_actors"
+            }}</label>
+          <div class="controls">
+            <ActivityPubAdminInfo @topic={{this.topic}} @context="topic" />
+          </div>
+        </div>
+        <div class="control-group">
+          <label>{{i18n "topic.discourse_activity_pub.actions.label"}}</label>
+          <div class="controls">
+            <ActivityPubTopicActions @topic={{this.topic}} />
+          </div>
+        </div>
+        <div class="control-group">
+          <label>{{i18n "post.discourse_activity_pub.actions.label"}}</label>
+          <div class="controls">
+            <ActivityPubPostActions @post={{this.firstPost}} />
+          </div>
+        </div>
+      </:body>
+      <:footer>
+        <DButton
+          @icon="circle-info"
+          @label="topic.discourse_activity_pub.info.label"
+          @action={{this.showInfo}}
+          class="show-info"
+        />
+      </:footer>
+    </DModal>
+  </template>
+}
diff --git a/assets/javascripts/discourse/components/modal/activity-pub-topic-info.gjs b/assets/javascripts/discourse/components/modal/activity-pub-topic-info.gjs
new file mode 100644
index 00000000..23cb49be
--- /dev/null
+++ b/assets/javascripts/discourse/components/modal/activity-pub-topic-info.gjs
@@ -0,0 +1,81 @@
+import Component from "@glimmer/component";
+import { action } from "@ember/object";
+import { service } from "@ember/service";
+import DButton from "discourse/components/d-button";
+import DModal from "discourse/components/d-modal";
+import { i18n } from "discourse-i18n";
+import ActivityPubAttributes from "../activity-pub-attributes";
+import ActivityPubPostInfo from "../activity-pub-post-info";
+import ActivityPubTopicInfo from "../activity-pub-topic-info";
+import ActivityPubTopicAdminModal from "./activity-pub-topic-admin";
+
+export default class ActivityPubTopicInfoModal extends Component {
+  @service modal;
+  @service currentUser;
+
+  get topic() {
+    return this.args.model.topic;
+  }
+
+  get firstPost() {
+    return this.args.model.firstPost;
+  }
+
+  get title() {
+    return i18n("topic.discourse_activity_pub.info.title", {
+      topic_id: this.topic.id,
+    });
+  }
+
+  get canAdmin() {
+    return this.currentUser?.staff;
+  }
+
+  @action
+  showAdmin() {
+    this.modal.show(ActivityPubTopicAdminModal, {
+      model: {
+        firstPost: this.firstPost,
+        topic: this.topic,
+      },
+    });
+  }
+
+  <template>
+    <DModal
+      @closeModal={{@closeModal}}
+      @title={{this.title}}
+      class="activity-pub-topic-modal activity-pub-topic-info-modal"
+    >
+      <:body>
+        <div class="control-group">
+          <label>{{i18n "topic.discourse_activity_pub.info.status"}}</label>
+          <div class="controls">
+            <ActivityPubTopicInfo @topic={{this.topic}} />
+            <ActivityPubPostInfo @post={{this.firstPost}} />
+          </div>
+        </div>
+        <div class="control-group">
+          <label>{{i18n "topic.discourse_activity_pub.info.attributes"}}</label>
+          <div class="controls">
+            <ActivityPubAttributes
+              @topic={{this.topic}}
+              @post={{this.firstPost}}
+            />
+          </div>
+        </div>
+      </:body>
+
+      <:footer>
+        {{#if this.canAdmin}}
+          <DButton
+            @icon="gear"
+            @label="topic.discourse_activity_pub.admin.label"
+            @action={{this.showAdmin}}
+            class="show-admin"
+          />
+        {{/if}}
+      </:footer>
+    </DModal>
+  </template>
+}
diff --git a/assets/javascripts/discourse/connectors/composer-action-after/activity-pub-category-status.hbs b/assets/javascripts/discourse/connectors/composer-action-after/activity-pub-category-status.hbs
index 0e0e08b9..55a5c172 100644
--- a/assets/javascripts/discourse/connectors/composer-action-after/activity-pub-category-status.hbs
+++ b/assets/javascripts/discourse/connectors/composer-action-after/activity-pub-category-status.hbs
@@ -1,3 +1,3 @@
 {{#if this.showStatus}}
-  <ActivityPubStatus @model={{this.model}} @modelType="composer" />
+  <ActivityPubActorStatus @model={{this.model}} @modelType="composer" />
 {{/if}}
\ No newline at end of file
diff --git a/assets/javascripts/discourse/initializers/activity-pub-initializer.js b/assets/javascripts/discourse/initializers/activity-pub-initializer.js
index 5fe547f9..57afce34 100644
--- a/assets/javascripts/discourse/initializers/activity-pub-initializer.js
+++ b/assets/javascripts/discourse/initializers/activity-pub-initializer.js
@@ -1,14 +1,22 @@
+import { hbs } from "ember-cli-htmlbars";
 import { Promise } from "rsvp";
-import { ajax } from "discourse/lib/ajax";
-import { popupAjaxError } from "discourse/lib/ajax-error";
-import { AUTO_GROUPS } from "discourse/lib/constants";
 import { bind } from "discourse/lib/decorators";
 import { withPluginApi } from "discourse/lib/plugin-api";
+import RenderGlimmer from "discourse/widgets/render-glimmer";
+import ActivityPubTopicMap from "../components/activity-pub-topic-map";
+import ActivityPubPostAdminModal from "../components/modal/activity-pub-post-admin";
+import ActivityPubTopicAdminModal from "../components/modal/activity-pub-topic-admin";
+import {
+  activityPubPostStatus,
+  showStatusToUser,
+} from "../lib/activity-pub-utilities";
 
 export default {
   name: "activity-pub",
   initialize(container) {
     const site = container.lookup("service:site");
+    const siteSettings = container.lookup("service:site-settings");
+    const modal = container.lookup("service:modal");
 
     withPluginApi("1.6.0", (api) => {
       const currentUser = api.getCurrentUser();
@@ -17,6 +25,7 @@ export default {
         "activity_pub_enabled",
         "activity_pub_scheduled_at",
         "activity_pub_published_at",
+        "activity_pub_delivered_at",
         "activity_pub_deleted_at",
         "activity_pub_updated_at",
         "activity_pub_visibility",
@@ -25,28 +34,12 @@ export default {
         "activity_pub_object_type",
         "activity_pub_domain",
         "activity_pub_first_post",
-        "activity_pub_is_first_post",
         "activity_pub_object_id"
       );
       api.serializeOnCreate("activity_pub_visibility");
 
       // TODO (future): PR discourse/discourse to add post infos via api
       api.reopenWidget("post-meta-data", {
-        showStatusToUser(user) {
-          if (!user) {
-            return false;
-          }
-          const groupIds =
-            this.siteSettings.activity_pub_post_status_visibility_groups
-              .split("|")
-              .map(Number);
-          return user.groups.some(
-            (group) =>
-              groupIds.includes(AUTO_GROUPS.everyone.id) ||
-              groupIds.includes(group.id)
-          );
-        },
-
         html(attrs) {
           const result = this._super(attrs);
           let postStatuses = result[result.length - 1].children;
@@ -56,43 +49,26 @@ export default {
           if (
             site.activity_pub_enabled &&
             attrs.activity_pub_enabled &&
-            this.showStatusToUser(this.currentUser)
+            attrs.post_number !== 1 &&
+            showStatusToUser(currentUser, siteSettings)
           ) {
-            let time;
-            let state;
-
-            if (attrs.activity_pub_deleted_at) {
-              time = moment(attrs.activity_pub_deleted_at);
-              state = "deleted";
-            } else if (attrs.activity_pub_updated_at) {
-              time = moment(attrs.activity_pub_updated_at);
-              state = "updated";
-            } else if (attrs.activity_pub_published_at) {
-              time = moment(attrs.activity_pub_published_at);
-              state = attrs.activity_pub_local
-                ? "published"
-                : "published_remote";
-            } else if (attrs.activity_pub_scheduled_at) {
-              time = moment(attrs.activity_pub_scheduled_at);
-              state = moment().isAfter(moment(time))
-                ? "scheduled_past"
-                : "scheduled";
-            } else {
-              state = "not_published";
-            }
-
-            if (state) {
+            const status = activityPubPostStatus(attrs);
+            if (status) {
               let replyToTabIndex = postStatuses.findIndex((postStatus) => {
                 return postStatus.name === "reply-to-tab";
               });
+              const post = this.findAncestorModel();
               postStatuses.splice(
                 replyToTabIndex !== -1 ? replyToTabIndex + 1 : 0,
                 0,
-                this.attach("post-activity-pub-indicator", {
-                  post: attrs,
-                  time,
-                  state,
-                })
+                new RenderGlimmer(
+                  this,
+                  "div.post-info.activity-pub",
+                  hbs`<ActivityPubPostStatus @post={{@data.post}} />`,
+                  {
+                    post,
+                  }
+                )
               );
             }
           }
@@ -101,91 +77,50 @@ export default {
         },
       });
 
-      if (api.addPostAdminMenuButton) {
-        api.addPostAdminMenuButton((attrs) => {
-          if (!attrs.activity_pub_enabled) {
-            return;
-          }
+      api.addPostAdminMenuButton((attrs) => {
+        if (
+          attrs.activity_pub_enabled &&
+          currentUser?.staff &&
+          !attrs.firstPost
+        ) {
+          return {
+            secondaryAction: "closeAdminMenu",
+            icon: "discourse-activity-pub",
+            className: "show-activity-pub-post-admin",
+            label: "post.discourse_activity_pub.admin.menu_label",
+            position: "second-last-hidden",
+            action: async (post) => {
+              modal.show(ActivityPubPostAdminModal, {
+                model: {
+                  post,
+                },
+              });
+            },
+          };
+        }
+      });
 
-          const canSchedule =
-            currentUser?.staff &&
-            attrs.activity_pub_is_first_post &&
-            !attrs.activity_pub_published_at;
-
-          if (canSchedule) {
-            const scheduled = !!attrs.activity_pub_scheduled_at;
-            const type = scheduled ? "unschedule" : "schedule";
-            return {
-              secondaryAction: "closeAdminMenu",
-              icon: "discourse-activity-pub",
-              className: `activity-pub-${type}`,
-              title: `post.discourse_activity_pub.${type}.title`,
-              label: `post.discourse_activity_pub.${type}.label`,
-              position: "second-last-hidden",
-              action: async (post) => {
-                if (scheduled) {
-                  ajax(`/ap/post/schedule/${post.id}`, {
-                    type: "DELETE",
-                  }).catch(popupAjaxError);
-                } else {
-                  ajax(`/ap/post/schedule/${post.id}`, {
-                    type: "POST",
-                  }).catch(popupAjaxError);
-                }
-              },
-            };
-          }
-        });
-      } else {
-        // TODO: remove support for older Discourse versions in December 2023
-        api.reopenWidget("post-admin-menu", {
-          pluginId: "discourse-activity-pub",
-
-          html(attrs) {
-            let result = this._super(attrs);
-
-            if (attrs.activity_pub_enabled) {
-              const buttons = result.children.filter(
-                (widget) => widget.attrs.action !== "changePostOwner"
-              );
-              const canSchedule =
-                currentUser?.staff &&
-                attrs.activity_pub_first_post &&
-                attrs.activity_pub_is_first_post &&
-                !attrs.activity_pub_published_at;
-
-              if (canSchedule) {
-                const scheduled = !!attrs.activity_pub_scheduled_at;
-                const type = scheduled ? "unschedule" : "schedule";
-                const button = {
-                  action: `${type}ActivityPublication`,
-                  secondaryAction: "closeAdminMenu",
-                  icon: "discourse-activity-pub",
-                  className: `activity-pub-${type}`,
-                  title: `post.discourse_activity_pub.${type}.title`,
-                  label: `post.discourse_activity_pub.${type}.label`,
-                  position: "second-last-hidden",
-                };
-                buttons.push(this.attach("post-admin-menu-button", button));
-              }
-              result.children = buttons;
-            }
-            return result;
-          },
-
-          scheduleActivityPublication() {
-            ajax(`/ap/post/schedule/${this.attrs.id}`, {
-              type: "POST",
-            }).catch(popupAjaxError);
-          },
-
-          unscheduleActivityPublication() {
-            ajax(`/ap/post/schedule/${this.attrs.id}`, {
-              type: "DELETE",
-            }).catch(popupAjaxError);
-          },
-        });
-      }
+      api.addTopicAdminMenuButton((topic) => {
+        if (topic.activity_pub_enabled && currentUser?.staff) {
+          const firstPost = topic
+            .get("postStream.posts")
+            .findBy("post_number", 1);
+          return {
+            icon: "discourse-activity-pub",
+            className: "show-activity-pub-topic-admin",
+            title: "topic.discourse_activity_pub.admin.title",
+            label: "topic.discourse_activity_pub.admin.menu_label",
+            action: async () => {
+              modal.show(ActivityPubTopicAdminModal, {
+                model: {
+                  topic,
+                  firstPost,
+                },
+              });
+            },
+          };
+        }
+      });
 
       api.modifyClass("model:post-stream", {
         pluginId: "discourse-activity-pub",
@@ -203,27 +138,67 @@ export default {
         },
       });
 
+      api.modifyClass("model:topic", {
+        pluginId: "discourse-activity-pub",
+
+        getActivityPubPostActor(postId) {
+          const postActors = this.activity_pub_post_actors || [];
+          return postActors.findBy("post_id", postId);
+        },
+      });
+
       api.modifyClass("controller:topic", {
         pluginId: "discourse-activity-pub",
 
         @bind
         handleActivityPubMessage(data) {
-          const postStream = this.get("model.postStream");
+          const topic = this.get("model");
+          if (!topic) {
+            return;
+          }
+
+          const postStream = topic.get("postStream");
 
           if (data.model.type === "post" && postStream) {
-            let stateProps = {
+            let postProps = {
               activity_pub_scheduled_at: data.model.scheduled_at,
               activity_pub_published_at: data.model.published_at,
               activity_pub_deleted_at: data.model.deleted_at,
               activity_pub_updated_at: data.model.updated_at,
+              activity_pub_delivered_at: data.model.delivered_at,
             };
+
             postStream
-              .triggerActivityPubStateChange(data.model.id, stateProps)
+              .triggerActivityPubStateChange(data.model.id, postProps)
               .then(() =>
                 this.appEvents.trigger("post-stream:refresh", {
                   id: data.model.id,
                 })
               );
+            this.appEvents.trigger(
+              "activity-pub:post-updated",
+              data.model.id,
+              postProps
+            );
+          }
+
+          if (data.model.type === "topic" && topic) {
+            let topicProps = {
+              activity_pub_published: data.model.published,
+              activity_pub_published_post_count:
+                data.model.published_post_count,
+              activity_pub_total_post_count: data.model.total_post_count,
+              activity_pub_scheduled_at: data.model.scheduled_at,
+              activity_pub_published_at: data.model.published_at,
+              activity_pub_deleted_at: data.model.deleted_at,
+            };
+            topic.setProperties(topicProps);
+            postStream.refresh();
+            this.appEvents.trigger(
+              "activity-pub:topic-updated",
+              data.model.id,
+              topicProps
+            );
           }
         },
 
@@ -246,6 +221,8 @@ export default {
           );
         },
       });
+
+      api.renderInOutlet("topic-map", ActivityPubTopicMap);
     });
   },
 };
diff --git a/assets/javascripts/discourse/lib/activity-pub-utilities.js b/assets/javascripts/discourse/lib/activity-pub-utilities.js
index 5c958fb6..2b5964c3 100644
--- a/assets/javascripts/discourse/lib/activity-pub-utilities.js
+++ b/assets/javascripts/discourse/lib/activity-pub-utilities.js
@@ -1,3 +1,13 @@
+import { AUTO_GROUPS } from "discourse/lib/constants";
+import { i18n } from "discourse-i18n";
+import ActivityPubActor from "../models/activity-pub-actor";
+
+function getStatusDatetimeFormat(infoStatus = false) {
+  return infoStatus
+    ? i18n("dates.long_with_year")
+    : i18n("dates.time_short_day");
+}
+
 export function buildHandle({ actor, model, site }) {
   if ((!actor && !model) || (model && !site)) {
     return undefined;
@@ -7,3 +17,136 @@ export function buildHandle({ actor, model, site }) {
     return `@${username}@${domain}`;
   }
 }
+
+export function showStatusToUser(user, siteSettings) {
+  if (!user || !siteSettings) {
+    return false;
+  }
+  const groupIds = siteSettings.activity_pub_post_status_visibility_groups
+    .split("|")
+    .map(Number);
+  return user.groups.some(
+    (group) =>
+      groupIds.includes(AUTO_GROUPS.everyone.id) || groupIds.includes(group.id)
+  );
+}
+
+export function activityPubPostStatus(post) {
+  let status;
+
+  if (post.activity_pub_deleted_at) {
+    status = "deleted";
+  } else if (post.activity_pub_updated_at) {
+    status = "updated";
+  } else if (post.activity_pub_published_at) {
+    status = post.activity_pub_local ? "published" : "published_remote";
+  } else if (post.activity_pub_scheduled_at) {
+    status = moment().isAfter(moment(post.activity_pub_scheduled_at))
+      ? "scheduled_past"
+      : "scheduled";
+  } else {
+    status = "not_published";
+  }
+
+  return status;
+}
+
+export function activityPubPostStatusText(post, opts = {}) {
+  const status = opts.status || activityPubPostStatus(post);
+
+  let i18nKey = opts.infoStatus ? "info_status" : "status";
+  let i18nOpts = {
+    actor: opts.postActor?.actor.handle,
+  };
+
+  let datetime;
+  if (status === "delivered") {
+    datetime = post.activity_pub_delivered_at;
+  } else if (status === "deleted") {
+    datetime = post.activity_pub_deleted_at;
+  } else if (status === "updated") {
+    datetime = post.activity_pub_updated_at;
+  } else if (status === "published") {
+    datetime = post.activity_pub_published_at;
+  } else if (status === "published_remote") {
+    datetime = post.activity_pub_published_at;
+  } else if (status.includes("scheduled")) {
+    datetime = post.activity_pub_scheduled_at;
+  }
+
+  if (datetime) {
+    i18nOpts.datetime = moment(datetime).format(
+      getStatusDatetimeFormat(opts.infoStatus)
+    );
+  }
+
+  return i18n(`post.discourse_activity_pub.${i18nKey}.${status}`, i18nOpts);
+}
+
+export function activityPubTopicStatus(topic) {
+  let status;
+
+  if (topic.activity_pub_deleted_at) {
+    status = "deleted";
+  } else if (topic.activity_pub_published_at) {
+    status = topic.activity_pub_local ? "published" : "published_remote";
+  } else if (topic.activity_pub_scheduled_at) {
+    status = moment().isAfter(moment(topic.activity_pub_scheduled_at))
+      ? "scheduled_past"
+      : "scheduled";
+  } else {
+    status = "not_published";
+  }
+
+  return status;
+}
+
+export function activityPubTopicStatusText(topic, opts = {}) {
+  const status = activityPubTopicStatus(topic);
+
+  let i18nKey = opts.infoStatus ? "info_status" : "status";
+  let i18nOpts = {
+    actor: topic.activity_pub_actor.handle,
+  };
+
+  let datetime;
+  if (status === "deleted") {
+    datetime = topic.activity_pub_deleted_at;
+  } else if (status === "published") {
+    datetime = topic.activity_pub_published_at;
+  } else if (status === "published_remote") {
+    datetime = topic.activity_pub_published_at;
+  } else if (status.includes("scheduled")) {
+    datetime = topic.activity_pub_scheduled_at;
+  }
+
+  if (datetime) {
+    i18nOpts.datetime = moment(datetime).format(
+      getStatusDatetimeFormat(opts.infoStatus)
+    );
+  }
+
+  return i18n(`topic.discourse_activity_pub.${i18nKey}.${status}`, i18nOpts);
+}
+
+export function activityPubTopicActors(topic) {
+  let result = [];
+  if (topic.category_id) {
+    let actor = ActivityPubActor.findByModel(
+      { id: topic.category_id },
+      "category"
+    );
+    if (actor) {
+      result.push(actor);
+    }
+  }
+  if (topic.tags) {
+    topic.tags.forEach((tag) => {
+      let actor = ActivityPubActor.findByModel(tag, "tag");
+      if (actor) {
+        result.push(actor);
+      }
+    });
+  }
+  return result;
+}
diff --git a/assets/javascripts/discourse/templates/admin-plugins-activity-pub-actor-show.hbs b/assets/javascripts/discourse/templates/admin-plugins-activity-pub-actor-show.hbs
index 709806ae..2840c4e9 100644
--- a/assets/javascripts/discourse/templates/admin-plugins-activity-pub-actor-show.hbs
+++ b/assets/javascripts/discourse/templates/admin-plugins-activity-pub-actor-show.hbs
@@ -38,7 +38,7 @@
       </div>
     {{else}}
       <ActivityPubActorModel @actor={{this.actor}} />
-      <ActivityPubStatus
+      <ActivityPubActorStatus
         @model={{this.actor.model}}
         @modelType={{this.actor.model_type}}
       />
@@ -109,7 +109,7 @@
             class="activity-pub-actor-setting activity-pub-post-object-type"
           >
             <label for="activity-pub-post-object-type">{{i18n
-                "admin.discourse_activity_pub.actor.post_object_type"
+                "admin.discourse_activity_pub.actor.object_type"
               }}</label>
             <ActivityPubPostObjectTypeDropdown
               @value={{this.actor.post_object_type}}
diff --git a/assets/javascripts/discourse/widgets/post-activity-pub-indicator.js b/assets/javascripts/discourse/widgets/post-activity-pub-indicator.js
deleted file mode 100644
index 6eeb71c7..00000000
--- a/assets/javascripts/discourse/widgets/post-activity-pub-indicator.js
+++ /dev/null
@@ -1,38 +0,0 @@
-import { dasherize } from "@ember/string";
-import { iconNode } from "discourse/lib/icon-library";
-import { createWidget } from "discourse/widgets/widget";
-import { i18n } from "discourse-i18n";
-import ActivityPubPostInfoModal from "../components/modal/activity-pub-post-info";
-
-createWidget("post-activity-pub-indicator", {
-  tagName: "div.post-info.activity-pub",
-  services: ["modal"],
-
-  title(attrs) {
-    let opts = {
-      domain: attrs.post.activity_pub_domain,
-      object_type: attrs.post.activity_pub_object_type,
-    };
-    if (attrs.time) {
-      opts.time = attrs.time.format("h:mm a, MMM D");
-    }
-    return i18n(`post.discourse_activity_pub.title.${attrs.state}`, opts);
-  },
-
-  buildClasses(attrs) {
-    let placeClass = attrs.post.activity_pub_local ? "local" : "remote";
-    return [dasherize(attrs.state), placeClass];
-  },
-
-  html(attrs) {
-    let iconName =
-      attrs.state === "not_published"
-        ? "discourse-activity-pub-slash"
-        : "discourse-activity-pub";
-    return iconNode(iconName);
-  },
-
-  click() {
-    this.modal.show(ActivityPubPostInfoModal, { model: this.attrs });
-  },
-});
diff --git a/assets/stylesheets/common/common.scss b/assets/stylesheets/common/common.scss
index 689fa1e3..3a372bc0 100644
--- a/assets/stylesheets/common/common.scss
+++ b/assets/stylesheets/common/common.scss
@@ -6,7 +6,7 @@
   line-height: var(--line-height-large);
 }
 
-div.activity-pub-status {
+div.activity-pub-actor-status {
   padding: 2px 8px;
   border-radius: var(--d-border-radius);
   line-height: 0.9em;
@@ -118,7 +118,7 @@ section.activity-pub-actor-setting {
     align-items: stretch;
     border-radius: var(--d-border-radius);
     border: 1px solid var(--primary-low);
-    line-height: 32px;
+    line-height: 37px;
     overflow: hidden;
   }
 
@@ -152,20 +152,17 @@ section.activity-pub-actor-setting {
   }
 }
 
-#reply-control .activity-pub-status {
+#reply-control .activity-pub-actor-status {
   margin-left: 1em;
 }
 
-.post-info.activity-pub {
+.activity-pub-topic-status,
+.activity-pub-post-status {
   display: flex;
   align-items: center;
-  gap: 0.3em;
+  gap: 0.5em;
   cursor: pointer;
 
-  .d-icon {
-    color: var(--primary-medium);
-  }
-
   &.published .d-icon-discourse-activity-pub,
   &.updated .d-icon-discourse-activity-pub {
     color: var(--success);
@@ -176,22 +173,19 @@ section.activity-pub-actor-setting {
   &.published-remote .d-icon-discourse-activity-pub {
     color: var(--tertiary);
   }
-
-  .activity-pub-visibility .d-icon {
-    font-size: 0.85em;
-  }
 }
 
-.activity-pub-post-info-list {
+.activity-pub-post-info,
+.activity-pub-topic-info {
   display: flex;
   flex-direction: column;
-  gap: 0.5em;
+  gap: 0.25em;
 
   span,
   a {
     display: flex;
     align-items: center;
-    gap: 1em;
+    gap: 0.5em;
     min-height: 35px;
   }
 
@@ -200,29 +194,23 @@ section.activity-pub-actor-setting {
   }
 }
 
-.activity-pub-object-id {
-  > span {
-    white-space: nowrap;
-    max-width: 90%;
-    overflow: hidden;
-  }
-
-  .activity-pub-object-id-copy {
-    position: relative;
+.activity-pub-attributes {
+  display: flex;
+  gap: 0.5em;
+  flex-flow: wrap;
+}
 
-    .activity-pub-object-id-copy-text {
-      position: absolute;
-      top: -25px;
-      width: 90px;
-      left: -30px;
-    }
-  }
+.activity-pub-attribute {
+  font-size: var(--font-down-1);
+  background-color: var(--primary-100);
+  padding: 0.15em 0.5em;
+  display: flex;
+  align-items: center;
+  white-space: nowrap;
+  gap: 0.5em;
 
-  .activity-pub-object-id-copy.success {
-    .d-icon,
-    .activity-pub-object-id-copy-text {
-      color: var(--success);
-    }
+  &.copiable {
+    cursor: pointer;
   }
 }
 
@@ -505,7 +493,7 @@ body.user-preferences-activity-pub-page {
   gap: 1em;
 
   .activity-pub-actor-model,
-  .activity-pub-status {
+  .activity-pub-actor-status {
     min-height: 34px;
     box-sizing: border-box;
     display: flex;
@@ -518,7 +506,7 @@ body.user-preferences-activity-pub-page {
     padding: 0 0.5em;
   }
 
-  .activity-pub-status {
+  .activity-pub-actor-status {
     padding: 0 0.7em;
   }
 
@@ -587,3 +575,61 @@ body.user-preferences-activity-pub-page {
     color: var(--primary-high);
   }
 }
+
+.d-modal {
+  .activity-pub-topic-status,
+  .activity-pub-post-status {
+    cursor: unset;
+  }
+}
+
+.activity-pub-topic-actions,
+.activity-pub-post-actions {
+  display: flex;
+  flex-direction: column;
+  gap: 1em;
+
+  .action {
+    display: flex;
+    gap: 0.75em;
+    width: 100%;
+    align-items: center;
+
+    .action-button {
+      .d-button-label {
+        white-space: nowrap;
+      }
+    }
+
+    .action-description {
+      font-size: var(--font-down-1);
+    }
+  }
+}
+
+.topic-map__activity-pub {
+  margin-top: 1em;
+  flex-basis: 100%;
+  display: flex;
+  align-items: center;
+  color: var(--primary-700);
+
+  > .activity-pub-topic-status {
+    font-size: var(--font-down-1);
+  }
+}
+
+.activity-pub-topic-modal {
+  .control-group:not(:last-child) {
+    margin-bottom: 1.3em;
+  }
+}
+
+.activity-pub-admin-info-actors {
+  display: flex;
+  gap: 0.75em;
+
+  .handle {
+    color: var(--primary-700);
+  }
+}
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index b4130b31..787317b2 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -164,10 +164,12 @@ en:
         description:
           private: "%{object_type} is addressed to followers."
           public: "%{object_type} is publicly addressed."
-      post_object_type:
+      object_type:
         label:
           note: Note
           article: Article
+          collection: Collection
+          ordered_collection: Ordered Collection
         description:
           note: Best for microblogging platforms (e.g. Mastodon).
           article: Best for longform platforms (e.g. Lemmy).
@@ -178,26 +180,107 @@ en:
         description:
           first_post: First post of every topic in this %{model_type} is published via ActivityPub.
           full_topic: Every post of every topic in this %{model_type} is published via ActivityPub and replies on other ActivityPub servers are imported as replies in Discourse.
+      copy_uri:
+        copied: URI Copied!
 
     post:
       discourse_activity_pub:
-        title:
-          published: "%{object_type} was published via ActivityPub from this site at %{time}."
-          scheduled: "%{object_type} is scheduled to be published via ActivityPub from this site at %{time}."
-          scheduled_past: "%{object_type} was scheduled to be published via ActivityPub from this site at %{time}."
-          deleted: "%{object_type} was deleted via ActivityPub at %{time}."
-          updated: "%{object_type} was updated via ActivityPub at %{time}."
-          published_remote: "%{object_type} was published via ActivityPub from %{domain} at %{time}."
-          not_published: "%{object_type} was not published via ActivityPub."
         info:
-          title: "ActivityPub for Post #%{post_number}"
-          url: Original %{object_type} on %{domain}.
-        schedule:
-          label: Schedule Publish
-          title: Schedule post to be published via ActivityPub.
-        unschedule:
-          label: Unschedule Publish
-          title: Unschedule post to be published via ActivityPub.
+          title: "ActivityPub Info for Post #%{post_number}"
+          label: "Post Info"
+          group_actors: "Group Actors"
+          attributes: Post Attributes
+          status: Post Status
+        status:
+          published: "Post was published via ActivityPub on %{datetime}."
+          scheduled: "Post is scheduled to be published via ActivityPub on %{datetime}."
+          scheduled_past: "Post was scheduled to be published via ActivityPub on %{datetime}."
+          deleted: "Post was deleted via ActivityPub on %{datetime}."
+          updated: "Post was updated via ActivityPub on %{datetime}."
+          published_remote: "Post was published via ActivityPub by %{actor} on %{datetime}."
+          not_published: "Post is not published via ActivityPub."
+          delivered: "Post was delivered via ActivityPub on %{datetime}."
+        info_status:
+          published: "Post was published on %{datetime}."
+          scheduled: "Post is scheduled to be published on %{datetime}."
+          scheduled_past: "Post was scheduled to be published on %{datetime}."
+          deleted: "Post was deleted on %{datetime}."
+          updated: "Post was updated on %{datetime}."
+          published_remote: "Post was published on %{datetime}."
+          not_published: "Post is not published."
+          delivered: "Post was delivered on %{datetime}."
+        admin:
+          title: "ActivityPub Admin for Post #%{post_number}"
+          label: Post Admin
+          menu_label: ActivityPub Post Admin...
+        actions:
+          label: "Post Actions"
+          publish:
+            label: "Publish Post #%{post_number}"
+            description:
+              followers: "Publish Post #%{post_number} and deliver it to the followers of the Group Actors."
+              no_followers: "Publish Post #%{post_number} without delivering it. The Group Actors have no followers to deliver to."
+              topic_not_published: "Publish is disabled for Post #%{post_number}. Topic #%{topic_id} is not published."
+              post_is_scheduled: "Publish is disabled for Post #%{post_number}. Post #%{post_number} is scheduled to be published."
+          deliver:
+            label: "Deliver Post #%{post_number}"
+            description:
+              followers: "Deliver Post #%{post_number} to the followers of the Group Actors."
+              no_followers: "Delivery is disabled for Post #%{post_number}. The Group Actors have no followers to deliver to."
+              topic_not_delivered: "Delivery is disabled for Post #%{post_number}. Topic #%{topic_id} has not been delivered."
+              delivered: "Post #%{post_number} was just delivered to the followers of the Group Actors."
+          schedule:
+            label: "Schedule Post #%{post_number}"
+            description:
+              followers: "Publish Post #%{post_number} and deliver it to the followers of the Group Actors in %{minutes} minutes."
+              no_followers: "Scheduling is disabled for Post #%{post_number}. The Group Actors have no followers to deliver to."
+          unschedule:
+            label: "Unschedule Post #%{post_number}"
+            description: "Unschedule publication of Post #%{post_number}."
+
+    topic:
+      discourse_activity_pub:
+        info:
+          title: "ActivityPub Info for Topic #%{topic_id}"
+          label: Topic Info
+          group_actors: "Group Actors"
+          attributes: Topic Attributes
+          status: Topic Status
+        status:
+          scheduled: Topic is scheduled to be published via ActivityPub on %{datetime}.
+          scheduled_past: Topic was scheduled to be published via ActivityPub on %{datetime}.
+          published: Topic was published via ActivityPub on %{datetime}.
+          published_remote: Topic was published via ActivityPub by %{actor} on %{datetime}.
+          deleted: Topic was deleted via ActivityPub on %{datetime}.
+          not_published: Topic is not published via ActivityPub.
+        info_status:
+          scheduled: "Topic is scheduled to be published on %{datetime}."
+          scheduled_past: "Topic was scheduled to be published on %{datetime}."
+          published: "Topic was published on %{datetime}."
+          published_remote: "Topic was published on %{datetime}."
+          deleted: "Topic was deleted on %{datetime}."
+          not_published: "Topic is not published."
+        posts_status:
+          label: Posts Status
+          published: All posts in this topic are published.
+          some_published: "%{count} of %{total} posts in this topic are published."
+          none_published: No posts in this topic are published.
+          scheduled: This topic is scheduled to be published.
+        admin:
+          title: "ActivityPub Admin for Topic #%{topic_id}"
+          label: Topic Admin
+          menu_label: ActivityPub Topic Admin...
+        actions:
+          label: Topic Actions
+        actors:
+          label: Topic Actors
+        publish:
+          label: Publish All Posts
+          description:
+            unpublished: "Publish %{count} unpublished posts in Topic #%{topic_id}. Posts will not be delivered to the followers of the Group Actors."
+            published: "Publish all posts is disabled. All posts in Topic #%{topic_id} are already published."
+            scheduled: "Publish all posts is disabled. Topic #%{topic_id} is scheduled to be published."
+
     user:
       discourse_activity_pub:
         title: ActivityPub
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 9df336fb..1b66e287 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -116,10 +116,16 @@ en:
     post:
       error:
         post_not_found: "Post not found"
+        cant_deliver_post: "Can't deliver post"
+        cant_publish_post: "Can't publish post"
         cant_schedule_post: "Can't schedule post"
         cant_unschedule_post: "Can't unschedule post"
         not_first_post: "Post is not the first post"
         failed_to_create: "Failed to create post for %{object_id}: %{message}"
+    topic:
+      error:
+        topic_not_found: "Topic not found"
+        cant_publish_topic: "Can't publish topic"
     user:
       error:
         failed_to_create: "Failed to create user for %{actor_id}: %{message}"
@@ -225,12 +231,13 @@ en:
             other: "Updated %{count} replies."
       publish:
         warning:
-          publish_did_not_start: "Failed to bulk publish the activities of %{actor}"
+          publish_did_not_start: "Failed to bulk publish the activities of %{target}"
+          actor_not_found: "Actor not found"
           actor_not_ready: "Actor is not ready"
           actor_model_not_supported: "Actor model is not supported"
         info:
-          started: "Started publishing the activities of %{actor}."
-          finished: "Finished publishing the activities of %{actor}."
+          started: "Started publishing the activities of %{target}."
+          finished: "Finished publishing the activities of %{target}."
           created_actors:
             one: "Created 1 actor."
             other: "Created %{count} actors."  
diff --git a/config/routes.rb b/config/routes.rb
index 64a2f0a3..e5397def 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -4,6 +4,12 @@
   scope "/post", defaults: { format: :json } do
     post "schedule/:post_id" => "post#schedule"
     delete "schedule/:post_id" => "post#unschedule"
+    post "deliver/:post_id" => "post#deliver"
+    post "publish/:post_id" => "post#publish"
+  end
+
+  scope "/topic", defaults: { format: :json } do
+    post "publish/:topic_id" => "topic#publish"
   end
 
   get "/auth" => "authorization#index", :defaults => { format: :json }
diff --git a/extensions/discourse_activity_pub_post_extension.rb b/extensions/discourse_activity_pub_post_extension.rb
new file mode 100644
index 00000000..f9e9075b
--- /dev/null
+++ b/extensions/discourse_activity_pub_post_extension.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+module DiscourseActivityPubPostExtension
+  def reload
+    @activity_pub_taxonomy_actors = nil
+    @activity_pub_taxonomy_followers = nil
+    super
+  end
+
+  def activity_pub_taxonomy_actors
+    @activity_pub_taxonomy_actors ||=
+      begin
+        if !@destroyed_post_activity_pub_taxonomy_actors.nil?
+          return @destroyed_post_activity_pub_taxonomy_actors
+        end
+        activity_pub_topic.activity_pub_taxonomies.map { |taxonomy| taxonomy.activity_pub_actor }
+      end
+  end
+
+  def activity_pub_taxonomy_followers
+    @activity_pub_taxonomy_followers ||=
+      activity_pub_taxonomy_actors.reduce([]) do |result, actor|
+        actor.followers.each { |follower| result << follower }
+        result
+      end
+  end
+end
diff --git a/extensions/discourse_activity_pub_topic_extension.rb b/extensions/discourse_activity_pub_topic_extension.rb
new file mode 100644
index 00000000..21432263
--- /dev/null
+++ b/extensions/discourse_activity_pub_topic_extension.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+module DiscourseActivityPubTopicExtension
+  def reload(options = nil)
+    @activity_pub_total_posts = nil
+    @activity_pub_total_post_count = nil
+    @activity_pub_published_posts = nil
+    @activity_pub_published_post_count = nil
+    @with_deleted_first_post = nil
+    super(options)
+  end
+
+  def activity_pub_total_posts
+    @activity_pub_total_posts ||= Post.where(topic_id: self.id, post_type: Post.types[:regular])
+  end
+
+  def activity_pub_published_posts
+    @activity_pub_published_posts ||=
+      activity_pub_total_posts.where(
+        "posts.id IN (SELECT post_id FROM post_custom_fields WHERE name = 'activity_pub_published_at' AND value IS NOT NULL)",
+      )
+  end
+
+  def activity_pub_total_post_count
+    @activity_pub_total_post_count ||= activity_pub_total_posts.count
+  end
+
+  def activity_pub_published_post_count
+    @activity_pub_published_post_count ||= activity_pub_published_posts.count
+  end
+
+  def activity_pub_all_posts_published?
+    activity_pub_total_post_count == activity_pub_published_post_count
+  end
+
+  def with_deleted_first_post
+    @with_deleted_first_post ||= posts.with_deleted.find_by(post_number: 1)
+  end
+
+  def activity_pub_published?
+    activity_pub_published_at.present?
+  end
+
+  def activity_pub_published_at
+    return nil unless activity_pub_enabled && with_deleted_first_post
+    with_deleted_first_post.activity_pub_published_at
+  end
+
+  def activity_pub_scheduled?
+    activity_pub_scheduled_at.present?
+  end
+
+  def activity_pub_scheduled_at
+    unless activity_pub_enabled && with_deleted_first_post &&
+             !with_deleted_first_post.activity_pub_published?
+      return nil
+    end
+    with_deleted_first_post.activity_pub_scheduled_at
+  end
+
+  def activity_pub_deleted?
+    activity_pub_deleted_at.present?
+  end
+
+  def activity_pub_deleted_at
+    return nil unless activity_pub_enabled && with_deleted_first_post
+    with_deleted_first_post.activity_pub_deleted_at
+  end
+
+  def activity_pub_delivered?
+    activity_pub_delivered_at.present?
+  end
+
+  def activity_pub_delivered_at
+    return nil unless activity_pub_enabled && with_deleted_first_post
+    with_deleted_first_post.activity_pub_delivered_at
+  end
+
+  def activity_pub_post_actors
+    @activity_pub_post_actors ||=
+      begin
+        sql = <<~SQL
+        SELECT objects.model_id AS post_id, actors.id, actors.username, actors.domain, actors.ap_id
+        FROM discourse_activity_pub_objects AS objects
+        JOIN discourse_activity_pub_actors AS actors ON actors.ap_id = objects.attributed_to_id
+        WHERE objects.collection_id = :collection_id
+        ORDER BY objects.model_id
+      SQL
+        DB.query(sql, collection_id: self.activity_pub_object.id)
+      end
+  end
+end
diff --git a/lib/discourse_activity_pub/bulk/publish.rb b/lib/discourse_activity_pub/bulk/publish.rb
index 3f0c27ff..e0603f6e 100644
--- a/lib/discourse_activity_pub/bulk/publish.rb
+++ b/lib/discourse_activity_pub/bulk/publish.rb
@@ -5,55 +5,84 @@ module Bulk
     class Publish
       include JsonLd
 
-      attr_reader :actor
+      attr_reader :actor, :topic
       attr_accessor :published_at, :result
 
-      SUPPORTED_MODEL_TYPES = %w[category]
+      ACTOR_MODEL_TYPES = %w[category tag]
 
-      def initialize(actor_id: nil)
-        @actor = DiscourseActivityPubActor.find_by(id: actor_id)
+      def initialize(actor_id: nil, topic_id: nil)
+        @topic = Topic.find_by(id: topic_id)
+        @actor = topic ? topic.activity_pub_actor : DiscourseActivityPubActor.find_by(id: actor_id)
       end
 
       def perform
+        log_started
+
+        return log_publish_failed("actor_not_found") if !actor
         return log_publish_failed("actor_not_ready") if !actor&.ready?
-        model_type = actor.model_type.downcase
-        if !SUPPORTED_MODEL_TYPES.include?(model_type)
+        if !ACTOR_MODEL_TYPES.include?(actor.model_type.downcase)
           return log_publish_failed("actor_model_not_supported")
         end
 
-        log_started
-
         @published_at = Time.now.utc.iso8601(3)
         @result = PublishResult.new
 
-        self.send("publish_#{model_type}")
+        create_collections_from_topics if full_topic?
+        create_actors_from_users
+        create_objects_from_posts
+        create_activities_from_posts
+        announce_activities
 
         log_finished
 
         result.finished = true
-
         result
       end
 
-      def self.perform(actor_id: nil)
-        new(actor_id: actor_id).perform
+      def self.perform(actor_id: nil, topic_id: nil)
+        new(actor_id: actor_id, topic_id: topic_id).perform
       end
 
       protected
 
-      def publish_category
-        create_collections_from_topics if actor.model.activity_pub_full_topic
-        create_actors_from_users
-        create_objects_from_posts
-        create_activities_from_posts
-        announce_activities
+      def category_actor?
+        actor.model_type.downcase == "category"
+      end
+
+      def tag_actor?
+        actor.model_type.downcase == "tag"
+      end
+
+      def first_post?
+        @first_post ||= actor.model.activity_pub_first_post
+      end
+
+      def full_topic?
+        @full_topic ||= actor.model.activity_pub_full_topic
       end
 
       def create_collections_from_topics
+        topics = Topic
+
+        if category_actor?
+          topics =
+            topics
+              .where("topics.category_id = ?", actor.model.id)
+              .where.not("topics.id = ?", actor.model.topic_id.to_i)
+        end
+
+        if tag_actor?
+          topics =
+            topics.where(
+              "topics.id IN (SELECT topic_id FROM topic_tags WHERE topic_tags.tag_id = ?)",
+              actor.model.id,
+            )
+        end
+
+        topics = topics.where("topics.id = ?", topic.id) if topic
+
         topics =
-          Topic
-            .where("topics.category_id = ?", actor.model.id)
-            .where.not("topics.id = ?", actor.model.topic_id.to_i)
+          topics
             .joins(
               "LEFT JOIN discourse_activity_pub_collections c ON c.model_type = 'Topic' AND topics.id = c.model_id",
             )
@@ -67,20 +96,34 @@ def create_collections_from_topics
       end
 
       def create_actors_from_users
+        users = User.real.joins(posts: :topic)
+
+        if category_actor?
+          users =
+            users
+              .where("topics.category_id = ?", actor.model.id)
+              .where.not("topics.id = ?", actor.model.topic_id.to_i)
+        end
+
+        if tag_actor?
+          users =
+            users.where(
+              "topics.id IN (SELECT topic_id FROM topic_tags WHERE topic_tags.tag_id = ?)",
+              actor.model.id,
+            )
+        end
+
+        users = users.where("topics.id = ?", topic.id) if topic
+        users = users.where("posts.post_number = 1") if first_post?
+
         users =
-          User
-            .real
-            .joins(posts: :topic)
-            .where("topics.category_id = ?", actor.model.id)
-            .where.not("topics.id = ?", actor.model.topic_id.to_i)
+          users
             .joins(
               "LEFT JOIN discourse_activity_pub_actors a ON a.model_type = 'User' AND users.id = a.model_id",
             )
             .where("a.id IS NULL")
             .distinct
 
-        users = users.where("posts.post_number = 1") if actor.model.activity_pub_first_post
-
         if users.any?
           actors = build_actors(users)
           create_actors(actors)
@@ -88,20 +131,34 @@ def create_actors_from_users
       end
 
       def create_objects_from_posts
+        posts = Post.joins(:topic)
+
+        if category_actor?
+          posts =
+            posts
+              .where("topics.category_id = ?", actor.model.id)
+              .where.not("topics.id = ?", actor.model.topic_id.to_i)
+        end
+
+        if tag_actor?
+          posts =
+            posts.where(
+              "topics.id IN (SELECT topic_id FROM topic_tags WHERE topic_tags.tag_id = ?)",
+              actor.model.id,
+            )
+        end
+
+        posts = posts.where("topics.id = ?", topic.id) if topic
+        posts = posts.where("posts.post_number = 1") if first_post?
+
         posts =
-          Post
-            .joins(:topic)
-            .where("topics.category_id = ?", actor.model.id)
-            .where.not("topics.id = ?", actor.model.topic_id.to_i)
+          posts
             .joins(
               "LEFT JOIN discourse_activity_pub_objects o ON o.model_type = 'Post' AND posts.id = o.model_id",
             )
             .where("o.id IS NULL OR o.published_at IS NULL")
             .distinct
-
-        posts = posts.where("posts.post_number = 1") if actor.model.activity_pub_first_post
-
-        posts = posts.order("posts.topic_id, posts.post_number")
+            .order("posts.topic_id, posts.post_number")
 
         if posts.any?
           objects, post_custom_fields = build_objects_and_post_custom_fields(posts)
@@ -111,17 +168,28 @@ def create_objects_from_posts
       end
 
       def create_activities_from_posts
-        posts =
-          Post
-            .joins(:topic)
-            .where("topics.category_id = ?", actor.model.id)
-            .where.not("topics.id = ?", actor.model.topic_id.to_i)
-            .includes(:activity_pub_object)
-            .distinct
+        posts = Post.joins(:topic)
 
-        posts = posts.where("posts.post_number = 1") if actor.model.activity_pub_first_post
+        if category_actor?
+          posts =
+            posts
+              .where("topics.category_id = ?", actor.model.id)
+              .where.not("topics.id = ?", actor.model.topic_id.to_i)
+        end
+
+        if tag_actor?
+          posts =
+            posts.where(
+              "topics.id IN (SELECT topic_id FROM topic_tags WHERE topic_tags.tag_id = ?)",
+              actor.model.id,
+            )
+        end
 
-        posts = posts.order("posts.topic_id, posts.post_number")
+        posts = posts.where("topics.id = ?", topic.id) if topic
+        posts = posts.where("posts.post_number = 1") if first_post?
+
+        posts =
+          posts.includes(:activity_pub_object).distinct.order("posts.topic_id, posts.post_number")
 
         if posts.any?
           activities = build_activities(posts)
@@ -137,14 +205,27 @@ def announce_activities
             )
             .joins("JOIN posts ON o.model_type = 'Post' AND o.model_id = posts.id")
             .joins("JOIN topics ON topics.id = posts.topic_id")
-            .where("topics.category_id = ?", actor.model.id)
-            .where.not("topics.id = ?", actor.model.topic_id.to_i)
-            .distinct
 
-        if actor.model.activity_pub_first_post
-          activities = activities.where("posts.post_number = 1")
+        if category_actor?
+          activities =
+            activities
+              .where("topics.category_id = ?", actor.model.id)
+              .where.not("topics.id = ?", actor.model.topic_id.to_i)
         end
 
+        if tag_actor?
+          activities =
+            activities.where(
+              "topics.id IN (SELECT topic_id FROM topic_tags WHERE topic_tags.tag_id = ?)",
+              actor.model.id,
+            )
+        end
+
+        activities = activities.where("topics.id = ?", topic.id) if topic
+        activities = activities.where("posts.post_number = 1") if first_post?
+
+        activities = activities.distinct
+
         if activities.any?
           announcements = build_announcements(activities)
           create_announcements(announcements)
@@ -197,7 +278,7 @@ def build_objects_and_post_custom_fields(posts)
               attributed_to_id: nil,
             )
 
-          if !post.activity_pub_is_first_post?
+          if !post.is_first_post?
             object[:reply_to_id] = post_number_id_map.dig(
               post.topic_id,
               post.reply_to_post_number,
@@ -205,7 +286,7 @@ def build_objects_and_post_custom_fields(posts)
               post.activity_pub_reply_to_object&.ap_id
           end
 
-          if actor.model.activity_pub_full_topic
+          if full_topic?
             object[:collection_id] = post.topic.activity_pub_object.id
             object[:attributed_to_id] = post.activity_pub_actor.ap_id
           end
@@ -368,26 +449,30 @@ def increment_published_at
         @published_at = (@published_at.to_time + 1 / 1000.0).iso8601(3)
       end
 
+      def log_target
+        @log_target ||= topic ? topic.title : actor.handle
+      end
+
       def log_publish_failed(key)
         message =
           I18n.t(
             "discourse_activity_pub.bulk.publish.warning.publish_did_not_start",
-            actor: actor.handle,
+            target: log_target,
           )
         message +=
-          ": " + I18n.t("discourse_activity_pub.bulk.publish.warning.#{key}", actor: actor.handle)
+          ": " + I18n.t("discourse_activity_pub.bulk.publish.warning.#{key}", target: log_target)
         DiscourseActivityPub::Logger.warn(message)
       end
 
       def log_started
         DiscourseActivityPub::Logger.info(
-          I18n.t("discourse_activity_pub.bulk.publish.info.started", actor: actor.handle),
+          I18n.t("discourse_activity_pub.bulk.publish.info.started", target: log_target),
         )
       end
 
       def log_finished
         DiscourseActivityPub::Logger.info(
-          I18n.t("discourse_activity_pub.bulk.publish.info.finished", actor: actor.handle),
+          I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: log_target),
         )
       end
 
diff --git a/lib/discourse_activity_pub/delivery_handler.rb b/lib/discourse_activity_pub/delivery_handler.rb
index fb371f5c..32b2ad2d 100644
--- a/lib/discourse_activity_pub/delivery_handler.rb
+++ b/lib/discourse_activity_pub/delivery_handler.rb
@@ -76,17 +76,18 @@ def schedule_delivery(send_to: nil, delay: nil)
 
       Jobs.cancel_scheduled_job(:discourse_activity_pub_deliver, args)
 
-      if delay
+      if delay.to_i > 0
         Jobs.enqueue_in(delay.to_i.minutes, :discourse_activity_pub_deliver, args)
         @scheduled_at = (Time.now.utc + delay.to_i.minutes).iso8601
       else
         Jobs.enqueue(:discourse_activity_pub_deliver, args)
-        @scheduled_at = Time.now.utc.iso8601
       end
     end
 
     def after_scheduled
-      object.after_scheduled(scheduled_at) if object.respond_to?(:after_scheduled)
+      if scheduled_at.present? && object.respond_to?(:after_scheduled)
+        object.after_scheduled(scheduled_at)
+      end
     end
 
     def log_failure(reason)
diff --git a/plugin.rb b/plugin.rb
index 979d1e7e..2873f9dc 100644
--- a/plugin.rb
+++ b/plugin.rb
@@ -10,6 +10,7 @@
 register_svg_icon "discourse-activity-pub"
 register_svg_icon "fingerprint"
 register_svg_icon "user-check"
+register_svg_icon "file-arrow-up"
 
 add_admin_route "admin.discourse_activity_pub.label", "activityPub"
 
@@ -73,6 +74,7 @@
   require_relative "app/models/concerns/discourse_activity_pub/ap/model_validations"
   require_relative "app/models/concerns/discourse_activity_pub/ap/model_callbacks"
   require_relative "app/models/concerns/discourse_activity_pub/ap/model_helpers"
+  require_relative "app/models/concerns/discourse_activity_pub/ap/object_helpers"
   require_relative "app/models/concerns/discourse_activity_pub/webfinger_actor_attributes"
   require_relative "app/models/discourse_activity_pub_actor"
   require_relative "app/models/discourse_activity_pub_activity"
@@ -85,6 +87,7 @@
   require_relative "app/jobs/discourse_activity_pub_process"
   require_relative "app/jobs/discourse_activity_pub_deliver"
   require_relative "app/jobs/discourse_activity_pub_log_rotate"
+  require_relative "app/jobs/discourse_activity_pub_publish"
   require_relative "app/controllers/concerns/discourse_activity_pub/domain_verification"
   require_relative "app/controllers/concerns/discourse_activity_pub/signature_verification"
   require_relative "app/controllers/concerns/discourse_activity_pub/enabled_verification"
@@ -103,6 +106,7 @@
   require_relative "app/controllers/discourse_activity_pub/admin/log_controller"
   require_relative "app/controllers/discourse_activity_pub/authorization_controller"
   require_relative "app/controllers/discourse_activity_pub/post_controller"
+  require_relative "app/controllers/discourse_activity_pub/topic_controller"
   require_relative "app/controllers/discourse_activity_pub/actor_controller"
   require_relative "app/serializers/discourse_activity_pub/ap/object_serializer"
   require_relative "app/serializers/discourse_activity_pub/ap/activity_serializer"
@@ -128,11 +132,14 @@
   require_relative "app/serializers/discourse_activity_pub/webfinger_serializer"
   require_relative "app/serializers/discourse_activity_pub/basic_actor_serializer"
   require_relative "app/serializers/discourse_activity_pub/actor_serializer"
+  require_relative "app/serializers/discourse_activity_pub/site_actor_serializer"
+  require_relative "app/serializers/discourse_activity_pub/detailed_actor_serializer"
   require_relative "app/serializers/discourse_activity_pub/authorization_serializer"
-  require_relative "app/serializers/discourse_activity_pub/admin/actor_serializer"
   require_relative "app/serializers/discourse_activity_pub/admin/log_serializer"
   require_relative "config/routes"
   require_relative "extensions/discourse_activity_pub_guardian_extension"
+  require_relative "extensions/discourse_activity_pub_topic_extension"
+  require_relative "extensions/discourse_activity_pub_post_extension"
 
   # DiscourseActivityPub.enabled is the single source of truth for whether
   # ActivityPub is enabled on the site level
@@ -222,7 +229,7 @@
   add_to_serializer(:site, :activity_pub_actors) do
     actors = { category: [], tag: [] }
     DiscourseActivityPubActor.active.each do |actor|
-      actors[actor.model_type.downcase.to_sym] << DiscourseActivityPub::BasicActorSerializer.new(
+      actors[actor.model_type.downcase.to_sym] << DiscourseActivityPub::SiteActorSerializer.new(
         actor,
         root: false,
       ).as_json
@@ -241,18 +248,11 @@
     regular? && DiscourseActivityPub.enabled && activity_pub_taxonomy&.activity_pub_ready?
   end
   add_to_class(:topic, :activity_pub_ready?) do
-    activity_pub_enabled &&
-      (activity_pub_first_post || (activity_pub_full_topic && activity_pub_object))
+    activity_pub_enabled && (activity_pub_first_post || activity_pub_full_topic)
   end
   add_to_class(:topic, :activity_pub_full_url) do
     "#{DiscourseActivityPub.base_url}#{self.relative_url}"
   end
-  add_to_class(:topic, :activity_pub_published?) do
-    return false unless activity_pub_enabled
-
-    first_post = posts.with_deleted.find_by(post_number: 1)
-    first_post&.activity_pub_published?
-  end
   add_to_class(:topic, :activity_pub_first_post) { activity_pub_taxonomy&.activity_pub_first_post }
   add_to_class(:topic, :activity_pub_full_topic) { activity_pub_taxonomy&.activity_pub_full_topic }
   add_to_class(:topic, :activity_pub_full_topic_enabled) do
@@ -275,13 +275,39 @@
     !first_post&.activity_pub_object || first_post.activity_pub_object.local
   end
   add_to_class(:topic, :activity_pub_remote?) { !activity_pub_local? }
+  add_to_class(:topic, :activity_pub_publish!) do
+    return false if activity_pub_published?
+    Jobs.enqueue(:discourse_activity_pub_publish, topic_id: self.id)
+  end
+  add_to_class(:topic, :activity_pub_publish_state) do
+    model = {
+      id: self.id,
+      type: "topic",
+      published: self.activity_pub_published?,
+      published_post_count: self.activity_pub_published_post_count,
+      total_post_count: self.activity_pub_total_post_count,
+      scheduled_at: self.activity_pub_scheduled_at,
+      published_at: self.activity_pub_published_at,
+      deleted_at: self.activity_pub_deleted_at,
+    }
+    MessageBus.publish("/activity-pub", { model: model })
+  end
   Post.has_one :activity_pub_object, class_name: "DiscourseActivityPubObject", as: :model
 
   Post.include DiscourseActivityPub::AP::ModelCallbacks
   Post.include DiscourseActivityPub::AP::ModelHelpers
   Guardian.prepend DiscourseActivityPubGuardianExtension
-
-  activity_pub_post_custom_fields = %i[scheduled_at published_at deleted_at updated_at visibility]
+  Topic.prepend DiscourseActivityPubTopicExtension
+  Post.prepend DiscourseActivityPubPostExtension
+
+  activity_pub_post_custom_fields = %i[
+    delivered_at
+    scheduled_at
+    published_at
+    deleted_at
+    updated_at
+    visibility
+  ]
   activity_pub_post_custom_field_names =
     activity_pub_post_custom_fields.map { |field_name| "activity_pub_#{field_name}" }
   activity_pub_post_custom_field_names.each do |field_name|
@@ -307,9 +333,13 @@
     return @destroyed_post_activity_pub_enabled if !@destroyed_post_activity_pub_enabled.nil?
     return false unless DiscourseActivityPub.enabled
     return false unless activity_pub_topic&.activity_pub_ready?
+    return false if whisper?
 
     is_first_post? || activity_pub_full_topic
   end
+  add_to_class(:post, :activity_pub_publishing_enabled) do
+    DiscourseActivityPub.publishing_enabled && activity_pub_enabled
+  end
   add_to_class(:post, :activity_pub_content) do
     return nil unless activity_pub_enabled
 
@@ -338,9 +368,15 @@
   end
   add_to_class(:post, :activity_pub_after_publish) do |args = {}|
     activity_pub_update_custom_fields(args)
+    activity_pub_topic&.activity_pub_publish_state if is_first_post?
   end
   add_to_class(:post, :activity_pub_after_scheduled) do |args = {}|
     activity_pub_update_custom_fields(args)
+    activity_pub_topic&.activity_pub_publish_state if is_first_post?
+  end
+  add_to_class(:post, :activity_pub_after_deliver) do |args = {}|
+    activity_pub_update_custom_fields(args)
+    activity_pub_topic&.activity_pub_publish_state if is_first_post?
   end
   activity_pub_post_custom_field_names.each do |field_name|
     add_to_class(:post, field_name.to_sym) { custom_fields[field_name] }
@@ -358,11 +394,12 @@
   add_to_class(:post, :activity_pub_published?) { !!activity_pub_published_at }
   add_to_class(:post, :activity_pub_deleted?) { !!activity_pub_deleted_at }
   add_to_class(:post, :activity_pub_scheduled?) { !!activity_pub_scheduled_at }
+  add_to_class(:post, :activity_pub_delivered?) { !!activity_pub_delivered_at }
   add_to_class(:post, :activity_pub_publish_state) do
     return false unless activity_pub_enabled
     return false unless activity_pub_topic
 
-    model = { id: self.id, type: "post" }
+    model = { id: self.id, type: "post", post_number: self.post_number }
 
     activity_pub_post_custom_fields.each do |field|
       model[field.to_sym] = self.send("activity_pub_#{field}")
@@ -387,12 +424,20 @@
     if performing_activity.delete?
       self.clear_all_activity_pub_objects
       if is_first_post? && activity_pub_full_topic
+        self.activity_pub_topic.posts.each { |post| post.clear_all_activity_pub_objects }
         self.activity_pub_topic.clear_all_activity_pub_objects
+        topic&.activity_pub_publish_state
       end
       self.activity_pub_publish_state
       return nil
     end
 
+    if performing_activity.update?
+      @performing_activity_object = activity_pub_object
+      update_activity_pub_activity_object
+      return nil
+    end
+
     performing_activity
   end
   add_to_class(:post, :activity_pub_object_type) do
@@ -419,16 +464,11 @@
     activity_pub_enabled && (!activity_pub_object || activity_pub_object.local)
   end
   add_to_class(:post, :activity_pub_remote?) { activity_pub_enabled && !activity_pub_local? }
-  add_to_class(:post, :activity_pub_topic_published?) { activity_pub_topic.activity_pub_published? }
-  add_to_class(:post, :activity_pub_is_first_post?) { is_first_post? }
-  add_to_class(:post, :activity_pub_first_post_scheduled_at) do
-    activity_pub_topic.first_post&.activity_pub_scheduled_at
-  end
-  add_to_class(:post, :activity_pub_group_actors) do
-    if !@destroyed_post_activity_pub_group_actors.nil?
-      return @destroyed_post_activity_pub_group_actors
-    end
-    activity_pub_topic.activity_pub_taxonomies.map { |t| t.activity_pub_actor }
+  add_to_class(:post, :activity_pub_topic_published?) do
+    activity_pub_topic&.activity_pub_published?
+  end
+  add_to_class(:post, :activity_pub_topic_scheduled?) do
+    activity_pub_topic&.activity_pub_scheduled?
   end
   add_to_class(:post, :activity_pub_collection) { activity_pub_topic.activity_pub_object }
   add_to_class(:post, :activity_pub_valid_activity?) do |activity, target_activity|
@@ -436,12 +476,15 @@
   end
   add_to_class(:post, :activity_pub_visibility_on_create) do
     if is_first_post?
-      activity_pub_topic&.category&.activity_pub_default_visibility
+      activity_pub_topic&.activity_pub_taxonomy&.activity_pub_default_visibility
     else
       activity_pub_topic.first_post.activity_pub_visibility
     end
   end
-  add_to_class(:post, :activity_pub_publish?) { !whisper? }
+  add_to_class(:post, :activity_pub_perform_activity?) do
+    is_first_post? || activity_pub_topic_scheduled? || activity_pub_topic_published? ||
+      activity_pub_published?
+  end
   add_to_class(:post, :activity_pub_publish!) do
     return false if activity_pub_published?
 
@@ -459,14 +502,24 @@
     custom_fields["activity_pub_visibility"] = visibility
     save_custom_fields(true)
 
+    if topic.activity_pub_full_topic_enabled && !topic.activity_pub_object
+      topic.create_activity_pub_collection!
+    end
+
     perform_activity_pub_activity(:create)
   end
   add_to_class(:post, :activity_pub_delete!) do
     return false unless activity_pub_local?
     perform_activity_pub_activity(:delete)
   end
+  add_to_class(:post, :activity_pub_deliver!) do
+    return false if !activity_pub_published? || activity_pub_taxonomy_followers.blank?
+    activity_pub_deliver_create
+  end
   add_to_class(:post, :activity_pub_schedule!) do
-    return false if activity_pub_published? || activity_pub_scheduled?
+    if activity_pub_published? || activity_pub_scheduled? || activity_pub_taxonomy_followers.blank?
+      return false
+    end
     activity_pub_publish!
   end
   add_to_class(:post, :activity_pub_unschedule!) do
@@ -483,19 +536,25 @@
   add_to_class(:post, :activity_pub_topic_trashed) do
     @activity_pub_topic_trashed ||= Topic.with_deleted.find_by(id: self.topic_id)
   end
-  add_to_class(:post, :activity_pub_object_id) { activity_pub_local? && activity_pub_object&.ap_id }
+  add_to_class(:post, :activity_pub_object_id) { activity_pub_object&.ap_id }
 
   add_model_callback(:post, :after_destroy) do
     # We need these to create a Delete activity when the post is actually destroyed
     @destroyed_post_activity_pub_enabled = self.activity_pub_enabled
     @destroyed_post_activity_pub_actor = self.activity_pub_actor
     @destroyed_post_activity_pub_visibility = self.activity_pub_visibility
-    @destroyed_post_activity_pub_group_actors = self.activity_pub_group_actors
+    @destroyed_post_activity_pub_taxonomy_actors = self.activity_pub_taxonomy_actors
     @destroyed_post_activity_pub_full_topic = self.activity_pub_full_topic
     @destroyed_post_activity_pub_first_post = self.activity_pub_first_post
   end
 
   add_to_serializer(:post, :activity_pub_enabled) { object.activity_pub_enabled }
+
+  # Do not include in web hook post serializer
+  add_to_serializer(:web_hook_post, :activity_pub_enabled, include_condition: -> { false }) do
+    false
+  end
+
   activity_pub_post_custom_field_names.each do |field_name|
     add_to_serializer(
       :post,
@@ -528,17 +587,89 @@
     :activity_pub_first_post,
     include_condition: -> { object.activity_pub_enabled },
   ) { object.activity_pub_first_post }
-  add_to_serializer(
-    :post,
-    :activity_pub_is_first_post,
-    include_condition: -> { object.activity_pub_enabled },
-  ) { object.activity_pub_is_first_post? }
   add_to_serializer(
     :post,
     :activity_pub_object_id,
     include_condition: -> { object.activity_pub_enabled },
   ) { object.activity_pub_object_id }
 
+  add_to_serializer(:topic_view, :activity_pub_enabled) { object.topic.activity_pub_enabled }
+
+  # Do not include in web hook topic view serializer
+  add_to_serializer(:web_hook_topic_view, :activity_pub_enabled, include_condition: -> { false }) do
+    false
+  end
+
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_local,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_local? }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_deleted_at,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_deleted_at }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_published_at,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_published_at }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_scheduled_at,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_scheduled_at }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_delivered_at,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_delivered_at }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_full_topic,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_full_topic }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_published_post_count,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_published_post_count }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_total_post_count,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_total_post_count }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_object_id,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_object&.ap_id }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_object_type,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) { object.topic.activity_pub_object&.ap_type }
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_actor,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) do
+    DiscourseActivityPub::ActorSerializer.new(object.topic.activity_pub_actor, root: false).as_json
+  end
+  add_to_serializer(
+    :topic_view,
+    :activity_pub_post_actors,
+    include_condition: -> { object.topic.activity_pub_enabled },
+  ) do
+    object.topic.activity_pub_post_actors.map do |post_actor|
+      {
+        post_id: post_actor.post_id,
+        actor: DiscourseActivityPub::BasicActorSerializer.new(post_actor, root: false).as_json,
+      }
+    end
+  end
+
   TopicView.on_preload do |topic_view|
     if topic_view.topic.activity_pub_enabled
       Post.preload_custom_fields(topic_view.posts, activity_pub_post_custom_field_names)
@@ -552,17 +683,21 @@
   PostAction.include DiscourseActivityPub::AP::ModelCallbacks
 
   add_to_class(:post_action, :activity_pub_enabled) { post.activity_pub_enabled }
-  add_to_class(:post_action, :activity_pub_publish?) { true }
+  add_to_class(:post_action, :activity_pub_publishing_enabled) do
+    post.activity_pub_publishing_enabled
+  end
+  add_to_class(:post_action, :activity_pub_perform_activity?) do
+    post.activity_pub_perform_activity?
+  end
   add_to_class(:post_action, :activity_pub_deleted?) { nil }
   add_to_class(:post_action, :activity_pub_published?) { !!post.activity_pub_published_at }
   add_to_class(:post_action, :activity_pub_visibility) { "public" }
   add_to_class(:post_action, :activity_pub_actor) { user.activity_pub_actor }
-  add_to_class(:post_action, :activity_pub_group_actors) { post.activity_pub_group_actors }
+  add_to_class(:post_action, :activity_pub_taxonomy_actors) { post.activity_pub_taxonomy_actors }
   add_to_class(:post_action, :activity_pub_object) { post.activity_pub_object }
   add_to_class(:post_action, :activity_pub_full_topic) { post.activity_pub_full_topic }
   add_to_class(:post_action, :activity_pub_first_post) { post.activity_pub_first_post }
   add_to_class(:post_action, :activity_pub_topic_published?) { post.activity_pub_topic_published? }
-  add_to_class(:post_action, :activity_pub_is_first_post?) { false }
   add_to_class(:post_action, :activity_pub_collection) { post.activity_pub_collection }
   add_to_class(:post_action, :activity_pub_valid_activity?) do |activity, target_activity|
     return false unless activity_pub_full_topic
diff --git a/spec/lib/discourse_activity_pub/activity_forwarder_spec.rb b/spec/lib/discourse_activity_pub/activity_forwarder_spec.rb
index 03150de6..da7cccc7 100644
--- a/spec/lib/discourse_activity_pub/activity_forwarder_spec.rb
+++ b/spec/lib/discourse_activity_pub/activity_forwarder_spec.rb
@@ -160,6 +160,7 @@ def perform_process(activity)
 
             activity.ap.json[:to] = follower2.ap_id
             activity.ap.json[:cc] = []
+            activity.ap.json[:audience] = nil
           end
 
           it "does not forward to the topic actor's followers" do
@@ -183,6 +184,7 @@ def perform_process(activity)
 
             before do
               note1.local = false
+              note1.attributed_to = remote_topic_actor
               note1.save!
             end
 
diff --git a/spec/lib/discourse_activity_pub/bulk/publish_spec.rb b/spec/lib/discourse_activity_pub/bulk/publish_spec.rb
index c4566d2d..7f2a61ad 100644
--- a/spec/lib/discourse_activity_pub/bulk/publish_spec.rb
+++ b/spec/lib/discourse_activity_pub/bulk/publish_spec.rb
@@ -4,6 +4,17 @@
   let!(:category) { Fabricate(:category) }
   let!(:actor) { Fabricate(:discourse_activity_pub_actor_group, model: category) }
 
+  def build_warning_log(key)
+    message =
+      I18n.t(
+        "discourse_activity_pub.bulk.publish.warning.publish_did_not_start",
+        actor: actor.handle,
+      )
+    message +=
+      ": " + I18n.t("discourse_activity_pub.bulk.publish.warning.#{key}", actor: actor.handle)
+    prefix_log(message)
+  end
+
   describe "#perform" do
     let!(:non_performing_user) { Fabricate(:user) }
     let!(:non_performing_category) { Fabricate(:category) }
@@ -15,410 +26,268 @@
     before { freeze_time }
     after { unfreeze_time }
 
-    def build_warning_log(key)
-      message =
-        I18n.t(
-          "discourse_activity_pub.bulk.publish.warning.publish_did_not_start",
-          actor: actor.handle,
-        )
-      message +=
-        ": " + I18n.t("discourse_activity_pub.bulk.publish.warning.#{key}", actor: actor.handle)
-      prefix_log(message)
-    end
-
-    context "when the actor has full topic enabled" do
-      before { toggle_activity_pub(category, publication_type: "full_topic") }
-
-      context "when the actor has models without ap objects" do
-        let!(:topic1) { Fabricate(:topic, category: category) }
-        let!(:topic2) { Fabricate(:topic, category: category) }
-        let!(:post1) { Fabricate(:post, topic: topic1) }
-        let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
-        let!(:post3) { Fabricate(:post, topic: topic2) }
-        let!(:post4) { Fabricate(:post, topic: topic2) }
-
-        it "returns the right result" do
-          result = described_class.perform(actor_id: actor.id)
-          expect(result.collections.count).to eq(2)
-          expect(result.actors.count).to eq(4)
-          expect(result.objects.count).to eq(4)
-          expect(result.activities.count).to eq(4)
-          expect(result.announcements.count).to eq(4)
-          expect(result.ap_ids.count).to eq(18)
-        end
-
-        it "creates the right collections" do
-          described_class.perform(actor_id: actor.id)
-          expect(topic1.activity_pub_object.name).to eq(topic1.title)
-          expect(topic2.activity_pub_object.name).to eq(topic2.title)
-          expect(topic1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(topic2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-        end
-
-        it "creates the right actors" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.reload.user.activity_pub_actor.name).to eq(post1.user.name)
-          expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
-          expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
-          expect(post4.reload.user.activity_pub_actor.name).to eq(post4.user.name)
-          expect(post1.user.activity_pub_actor.username).to eq(post1.user.username)
-          expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
-          expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
-          expect(post4.user.activity_pub_actor.username).to eq(post4.user.username)
-        end
-
-        it "creates the right post objects" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.reload.activity_pub_object.content).to eq(post1.cooked)
-          expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
-          expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
-          expect(post4.reload.activity_pub_object.content).to eq(post4.cooked)
-          expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
-          expect(post4.activity_pub_object.reply_to_id).to eq(post3.activity_pub_object.ap_id)
-          expect(post1.activity_pub_object.collection_id).to eq(post1.topic.activity_pub_object.id)
-          expect(post2.activity_pub_object.collection_id).to eq(post2.topic.activity_pub_object.id)
-          expect(post3.activity_pub_object.collection_id).to eq(post3.topic.activity_pub_object.id)
-          expect(post4.activity_pub_object.collection_id).to eq(post4.topic.activity_pub_object.id)
-          expect(post1.activity_pub_object.attributed_to_id).to eq(
-            post1.user.activity_pub_actor.ap_id,
-          )
-          expect(post2.activity_pub_object.attributed_to_id).to eq(
-            post2.user.activity_pub_actor.ap_id,
-          )
-          expect(post3.activity_pub_object.attributed_to_id).to eq(
-            post3.user.activity_pub_actor.ap_id,
-          )
-          expect(post4.activity_pub_object.attributed_to_id).to eq(
-            post4.user.activity_pub_actor.ap_id,
-          )
-          expect(post1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(post4.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(post1.activity_pub_content).to eq(post1.cooked)
-          expect(post2.activity_pub_content).to eq(post2.cooked)
-          expect(post3.activity_pub_content).to eq(post3.cooked)
-          expect(post4.activity_pub_content).to eq(post3.cooked)
-          expect(post1.activity_pub_visibility).to eq("public")
-          expect(post2.activity_pub_visibility).to eq("public")
-          expect(post3.activity_pub_visibility).to eq("public")
-          expect(post4.activity_pub_visibility).to eq("public")
-          expect(
-            post1.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post2.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post4.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-        end
-
-        it "creates the right activities" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post4.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post1.activity_pub_object.activities.first.actor.id).to eq(
-            post1.user.activity_pub_actor.id,
-          )
-          expect(post2.activity_pub_object.activities.first.actor.id).to eq(
-            post2.user.activity_pub_actor.id,
-          )
-          expect(post3.activity_pub_object.activities.first.actor.id).to eq(
-            post3.user.activity_pub_actor.id,
-          )
-          expect(post4.activity_pub_object.activities.first.actor.id).to eq(
-            post4.user.activity_pub_actor.id,
-          )
-          expect(post1.activity_pub_object.activities.first.object.id).to eq(
-            post1.activity_pub_object.id,
-          )
-          expect(post2.activity_pub_object.activities.first.object.id).to eq(
-            post2.activity_pub_object.id,
-          )
-          expect(post3.activity_pub_object.activities.first.object.id).to eq(
-            post3.activity_pub_object.id,
-          )
-          expect(post4.activity_pub_object.activities.first.object.id).to eq(
-            post4.activity_pub_object.id,
-          )
-          expect(post1.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(post4.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(
-            post1.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post2.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post4.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-        end
-
-        it "creates the right announcements" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(post4.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(
-            post1.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post2.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post4.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-        end
-
-        it "creates objects in the right order" do
-          described_class.perform(actor_id: actor.id)
-          expect(
-            DiscourseActivityPubObject
-              .joins(
-                "JOIN posts ON discourse_activity_pub_objects.model_type = 'Post' AND discourse_activity_pub_objects.model_id = posts.id",
-              )
-              .order("discourse_activity_pub_objects.created_at")
-              .pluck("posts.id"),
-          ).to eq([post1.id, post2.id, post3.id, post4.id])
-        end
+    context "with a category actor" do
+      context "with full topic enabled" do
+        before { toggle_activity_pub(category, publication_type: "full_topic") }
 
-        it "creates activities in the right order" do
-          described_class.perform(actor_id: actor.id)
-          expect(
-            DiscourseActivityPubActivity
-              .joins(
-                "JOIN discourse_activity_pub_objects o ON discourse_activity_pub_activities.object_type = 'DiscourseActivityPubObject' AND discourse_activity_pub_activities.object_id = o.id",
-              )
-              .joins("JOIN posts ON o.model_type = 'Post' AND o.model_id = posts.id")
-              .order("o.created_at")
-              .pluck("posts.id"),
-          ).to eq([post1.id, post2.id, post3.id, post4.id])
-        end
+        context "with models without ap objects" do
+          let!(:topic1) { Fabricate(:topic, category: category) }
+          let!(:topic2) { Fabricate(:topic, category: category) }
+          let!(:post1) { Fabricate(:post, topic: topic1) }
+          let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
+          let!(:post3) { Fabricate(:post, topic: topic2) }
+          let!(:post4) { Fabricate(:post, topic: topic2) }
 
-        context "with verbose logging enabled" do
-          before { setup_logging }
-          after { teardown_logging }
+          it "returns the right result" do
+            result = described_class.perform(actor_id: actor.id)
+            expect(result.collections.count).to eq(2)
+            expect(result.actors.count).to eq(4)
+            expect(result.objects.count).to eq(4)
+            expect(result.activities.count).to eq(4)
+            expect(result.announcements.count).to eq(4)
+            expect(result.ap_ids.count).to eq(18)
+          end
 
-          it "logs the right info" do
-            actor.reload
+          it "creates the right collections" do
             described_class.perform(actor_id: actor.id)
-            [
-              I18n.t("discourse_activity_pub.bulk.publish.info.started", actor: actor.handle),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 4),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 4),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_collections", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 4),
-              I18n.t("discourse_activity_pub.bulk.publish.info.finished", actor: actor.handle),
-            ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            expect(topic1.activity_pub_object.name).to eq(topic1.title)
+            expect(topic2.activity_pub_object.name).to eq(topic2.title)
+            expect(topic1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(topic2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
           end
-        end
-      end
-
-      context "when the actor has models with published ap objects and models without ap objects" do
-        let!(:topic1) { Fabricate(:topic, category: category) }
-        let!(:topic2) { Fabricate(:topic, category: category) }
-        let!(:post1) { Fabricate(:post, topic: topic1) }
-        let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
-        let!(:post3) { Fabricate(:post, topic: topic2) }
-        let!(:collection1) do
-          Fabricate(
-            :discourse_activity_pub_ordered_collection,
-            model: topic1,
-            published_at: Time.now,
-          )
-        end
-        let!(:actor1) do
-          Fabricate(:discourse_activity_pub_actor, ap_type: "Person", model: post1.user)
-        end
-        let!(:object1) do
-          Fabricate(
-            :discourse_activity_pub_object_note,
-            model: post1,
-            published_at: Time.now,
-            collection_id: collection1.id,
-            attributed_to: actor1,
-          )
-        end
-        let!(:activity1) do
-          Fabricate(
-            :discourse_activity_pub_activity_create,
-            actor: actor1,
-            object: object1,
-            published_at: Time.now,
-          )
-        end
 
-        it "returns the right result" do
-          result = described_class.perform(actor_id: actor.id)
-          expect(result.collections.count).to eq(1)
-          expect(result.actors.count).to eq(2)
-          expect(result.objects.count).to eq(2)
-          expect(result.activities.count).to eq(2)
-          expect(result.announcements.count).to eq(3)
-          expect(result.ap_ids.count).to eq(10)
-        end
+          it "creates the right actors" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.user.activity_pub_actor.name).to eq(post1.user.name)
+            expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
+            expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
+            expect(post4.reload.user.activity_pub_actor.name).to eq(post4.user.name)
+            expect(post1.user.activity_pub_actor.username).to eq(post1.user.username)
+            expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
+            expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
+            expect(post4.user.activity_pub_actor.username).to eq(post4.user.username)
+          end
 
-        it "creates the right collections" do
-          described_class.perform(actor_id: actor.id)
-          expect(topic2.activity_pub_object.name).to eq(topic2.title)
-          expect(topic2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-        end
+          it "creates the right post objects" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.activity_pub_object.content).to eq(post1.cooked)
+            expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
+            expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
+            expect(post4.reload.activity_pub_object.content).to eq(post4.cooked)
+            expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
+            expect(post4.activity_pub_object.reply_to_id).to eq(post3.activity_pub_object.ap_id)
+            expect(post1.activity_pub_object.collection_id).to eq(
+              post1.topic.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.collection_id).to eq(
+              post2.topic.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.collection_id).to eq(
+              post3.topic.activity_pub_object.id,
+            )
+            expect(post4.activity_pub_object.collection_id).to eq(
+              post4.topic.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.attributed_to_id).to eq(
+              post1.user.activity_pub_actor.ap_id,
+            )
+            expect(post2.activity_pub_object.attributed_to_id).to eq(
+              post2.user.activity_pub_actor.ap_id,
+            )
+            expect(post3.activity_pub_object.attributed_to_id).to eq(
+              post3.user.activity_pub_actor.ap_id,
+            )
+            expect(post4.activity_pub_object.attributed_to_id).to eq(
+              post4.user.activity_pub_actor.ap_id,
+            )
+            expect(post1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post4.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post1.activity_pub_content).to eq(post1.cooked)
+            expect(post2.activity_pub_content).to eq(post2.cooked)
+            expect(post3.activity_pub_content).to eq(post3.cooked)
+            expect(post4.activity_pub_content).to eq(post3.cooked)
+            expect(post1.activity_pub_visibility).to eq("public")
+            expect(post2.activity_pub_visibility).to eq("public")
+            expect(post3.activity_pub_visibility).to eq("public")
+            expect(post4.activity_pub_visibility).to eq("public")
+            expect(
+              post1.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post4.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+          end
 
-        it "creates the right actors" do
-          described_class.perform(actor_id: actor.id)
-          expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
-          expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
-          expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
-          expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
-        end
+          it "creates the right activities" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post4.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post1.activity_pub_object.activities.first.actor.id).to eq(
+              post1.user.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.actor.id).to eq(
+              post2.user.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.actor.id).to eq(
+              post3.user.activity_pub_actor.id,
+            )
+            expect(post4.activity_pub_object.activities.first.actor.id).to eq(
+              post4.user.activity_pub_actor.id,
+            )
+            expect(post1.activity_pub_object.activities.first.object.id).to eq(
+              post1.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.activities.first.object.id).to eq(
+              post2.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.activities.first.object.id).to eq(
+              post3.activity_pub_object.id,
+            )
+            expect(post4.activity_pub_object.activities.first.object.id).to eq(
+              post4.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post4.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(
+              post1.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post4.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
 
-        it "creates the right post objects" do
-          described_class.perform(actor_id: actor.id)
-          expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
-          expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
-          expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
-          expect(post2.activity_pub_object.collection_id).to eq(post2.topic.activity_pub_object.id)
-          expect(post3.activity_pub_object.collection_id).to eq(post3.topic.activity_pub_object.id)
-          expect(post2.activity_pub_object.attributed_to_id).to eq(
-            post2.user.activity_pub_actor.ap_id,
-          )
-          expect(post3.activity_pub_object.attributed_to_id).to eq(
-            post3.user.activity_pub_actor.ap_id,
-          )
-          expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
-          expect(post2.activity_pub_content).to eq(post2.cooked)
-          expect(post3.activity_pub_content).to eq(post3.cooked)
-          expect(post2.activity_pub_visibility).to eq("public")
-          expect(post3.activity_pub_visibility).to eq("public")
-          expect(
-            post2.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-        end
+          it "creates the right announcements" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post4.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(
+              post1.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post4.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
 
-        it "creates the right activities" do
-          described_class.perform(actor_id: actor.id)
-          expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post2.activity_pub_object.activities.first.actor.id).to eq(
-            post2.user.activity_pub_actor.id,
-          )
-          expect(post3.activity_pub_object.activities.first.actor.id).to eq(
-            post3.user.activity_pub_actor.id,
-          )
-          expect(post2.activity_pub_object.activities.first.object.id).to eq(
-            post2.activity_pub_object.id,
-          )
-          expect(post3.activity_pub_object.activities.first.object.id).to eq(
-            post3.activity_pub_object.id,
-          )
-          expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(
-            post2.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-        end
+          it "creates objects in the right order" do
+            described_class.perform(actor_id: actor.id)
+            expect(
+              DiscourseActivityPubObject
+                .joins(
+                  "JOIN posts ON discourse_activity_pub_objects.model_type = 'Post' AND discourse_activity_pub_objects.model_id = posts.id",
+                )
+                .order("discourse_activity_pub_objects.created_at")
+                .pluck("posts.id"),
+            ).to eq([post1.id, post2.id, post3.id, post4.id])
+          end
 
-        it "creates the right announcements" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(
-            post1.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post2.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-        end
+          it "creates activities in the right order" do
+            described_class.perform(actor_id: actor.id)
+            expect(
+              DiscourseActivityPubActivity
+                .joins(
+                  "JOIN discourse_activity_pub_objects o ON discourse_activity_pub_activities.object_type = 'DiscourseActivityPubObject' AND discourse_activity_pub_activities.object_id = o.id",
+                )
+                .joins("JOIN posts ON o.model_type = 'Post' AND o.model_id = posts.id")
+                .order("o.created_at")
+                .pluck("posts.id"),
+            ).to eq([post1.id, post2.id, post3.id, post4.id])
+          end
 
-        context "with verbose logging enabled" do
-          before { setup_logging }
-          after { teardown_logging }
+          context "with verbose logging enabled" do
+            before { setup_logging }
+            after { teardown_logging }
 
-          it "logs the right info" do
-            actor.reload
-            described_class.perform(actor_id: actor.id)
-            [
-              I18n.t("discourse_activity_pub.bulk.publish.info.started", actor: actor.handle),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_collections", count: 1),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.finished", actor: actor.handle),
-            ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            it "logs the right info" do
+              actor.reload
+              described_class.perform(actor_id: actor.id)
+              [
+                I18n.t("discourse_activity_pub.bulk.publish.info.started", target: actor.handle),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 4),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 4),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_collections", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 4),
+                I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: actor.handle),
+              ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            end
           end
         end
 
-        context "with models with unpublished ap objects" do
-          let!(:actor2) do
-            Fabricate(:discourse_activity_pub_actor, ap_type: "Person", model: post2.user)
+        context "with models with published ap objects and models without ap objects" do
+          let!(:topic1) { Fabricate(:topic, category: category) }
+          let!(:topic2) { Fabricate(:topic, category: category) }
+          let!(:post1) { Fabricate(:post, topic: topic1) }
+          let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
+          let!(:post3) { Fabricate(:post, topic: topic2) }
+          let!(:collection1) do
+            Fabricate(
+              :discourse_activity_pub_ordered_collection,
+              model: topic1,
+              published_at: Time.now,
+            )
           end
-          let!(:object2) do
+          let!(:actor1) do
+            Fabricate(:discourse_activity_pub_actor, ap_type: "Person", model: post1.user)
+          end
+          let!(:object1) do
             Fabricate(
               :discourse_activity_pub_object_note,
-              model: post2,
-              published_at: nil,
+              model: post1,
+              published_at: Time.now,
               collection_id: collection1.id,
-              attributed_to: actor2,
-              reply_to_id: object1.ap_id,
+              attributed_to: actor1,
             )
           end
-          let!(:activity2) do
+          let!(:activity1) do
             Fabricate(
               :discourse_activity_pub_activity_create,
-              actor: actor2,
-              object: object2,
-              published_at: nil,
+              actor: actor1,
+              object: object1,
+              published_at: Time.now,
             )
           end
 
           it "returns the right result" do
             result = described_class.perform(actor_id: actor.id)
             expect(result.collections.count).to eq(1)
-            expect(result.actors.count).to eq(1)
+            expect(result.actors.count).to eq(2)
             expect(result.objects.count).to eq(2)
             expect(result.activities.count).to eq(2)
             expect(result.announcements.count).to eq(3)
-            expect(result.ap_ids.count).to eq(9)
+            expect(result.ap_ids.count).to eq(10)
           end
 
           it "creates the right collections" do
@@ -429,24 +298,34 @@ def build_warning_log(key)
 
           it "creates the right actors" do
             described_class.perform(actor_id: actor.id)
+            expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
             expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
+            expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
             expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
           end
 
-          it "creates and publishes the right post objects" do
+          it "creates the right post objects" do
             described_class.perform(actor_id: actor.id)
+            expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
             expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
+            expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
+            expect(post2.activity_pub_object.collection_id).to eq(
+              post2.topic.activity_pub_object.id,
+            )
             expect(post3.activity_pub_object.collection_id).to eq(
               post3.topic.activity_pub_object.id,
             )
+            expect(post2.activity_pub_object.attributed_to_id).to eq(
+              post2.user.activity_pub_actor.ap_id,
+            )
             expect(post3.activity_pub_object.attributed_to_id).to eq(
               post3.user.activity_pub_actor.ap_id,
             )
-            expect(post2.reload.reload.activity_pub_object.published_at).to be_within_one_second_of(
-              Time.now,
-            )
+            expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
             expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post2.activity_pub_content).to eq(post2.cooked)
             expect(post3.activity_pub_content).to eq(post3.cooked)
+            expect(post2.activity_pub_visibility).to eq("public")
             expect(post3.activity_pub_visibility).to eq("public")
             expect(
               post2.custom_fields["activity_pub_published_at"].to_time,
@@ -456,19 +335,29 @@ def build_warning_log(key)
             ).to be_within_one_second_of(Time.now)
           end
 
-          it "creates and publishes the right activities" do
+          it "creates the right activities" do
             described_class.perform(actor_id: actor.id)
+            expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
             expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post2.activity_pub_object.activities.first.actor.id).to eq(
+              post2.user.activity_pub_actor.id,
+            )
             expect(post3.activity_pub_object.activities.first.actor.id).to eq(
               post3.user.activity_pub_actor.id,
             )
+            expect(post2.activity_pub_object.activities.first.object.id).to eq(
+              post2.activity_pub_object.id,
+            )
             expect(post3.activity_pub_object.activities.first.object.id).to eq(
               post3.activity_pub_object.id,
             )
+            expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
             expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
-            expect(activity2.reload.published_at).to be_within_one_second_of(Time.now)
             expect(
-              post3.activity_pub_object.activities.first.reload.published_at,
+              post2.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.published_at,
             ).to be_within_one_second_of(Time.now)
           end
 
@@ -493,110 +382,820 @@ def build_warning_log(key)
               post3.activity_pub_object.activities.first.announcement.published_at,
             ).to be_within_one_second_of(Time.now)
           end
+
+          context "with verbose logging enabled" do
+            before { setup_logging }
+            after { teardown_logging }
+
+            it "logs the right info" do
+              actor.reload
+              described_class.perform(actor_id: actor.id)
+              [
+                I18n.t("discourse_activity_pub.bulk.publish.info.started", target: actor.handle),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_collections", count: 1),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: actor.handle),
+              ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            end
+          end
+
+          context "with models with unpublished ap objects" do
+            let!(:actor2) do
+              Fabricate(:discourse_activity_pub_actor, ap_type: "Person", model: post2.user)
+            end
+            let!(:object2) do
+              Fabricate(
+                :discourse_activity_pub_object_note,
+                model: post2,
+                published_at: nil,
+                collection_id: collection1.id,
+                attributed_to: actor2,
+                reply_to_id: object1.ap_id,
+              )
+            end
+            let!(:activity2) do
+              Fabricate(
+                :discourse_activity_pub_activity_create,
+                actor: actor2,
+                object: object2,
+                published_at: nil,
+              )
+            end
+
+            it "returns the right result" do
+              result = described_class.perform(actor_id: actor.id)
+              expect(result.collections.count).to eq(1)
+              expect(result.actors.count).to eq(1)
+              expect(result.objects.count).to eq(2)
+              expect(result.activities.count).to eq(2)
+              expect(result.announcements.count).to eq(3)
+              expect(result.ap_ids.count).to eq(9)
+            end
+
+            it "creates the right collections" do
+              described_class.perform(actor_id: actor.id)
+              expect(topic2.activity_pub_object.name).to eq(topic2.title)
+              expect(topic2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            end
+
+            it "creates the right actors" do
+              described_class.perform(actor_id: actor.id)
+              expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
+              expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
+            end
+
+            it "creates and publishes the right post objects" do
+              described_class.perform(actor_id: actor.id)
+              expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
+              expect(post3.activity_pub_object.collection_id).to eq(
+                post3.topic.activity_pub_object.id,
+              )
+              expect(post3.activity_pub_object.attributed_to_id).to eq(
+                post3.user.activity_pub_actor.ap_id,
+              )
+              expect(
+                post2.reload.reload.activity_pub_object.published_at,
+              ).to be_within_one_second_of(Time.now)
+              expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+              expect(post3.activity_pub_content).to eq(post3.cooked)
+              expect(post3.activity_pub_visibility).to eq("public")
+              expect(
+                post2.custom_fields["activity_pub_published_at"].to_time,
+              ).to be_within_one_second_of(Time.now)
+              expect(
+                post3.custom_fields["activity_pub_published_at"].to_time,
+              ).to be_within_one_second_of(Time.now)
+            end
+
+            it "creates and publishes the right activities" do
+              described_class.perform(actor_id: actor.id)
+              expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+              expect(post3.activity_pub_object.activities.first.actor.id).to eq(
+                post3.user.activity_pub_actor.id,
+              )
+              expect(post3.activity_pub_object.activities.first.object.id).to eq(
+                post3.activity_pub_object.id,
+              )
+              expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
+              expect(activity2.reload.published_at).to be_within_one_second_of(Time.now)
+              expect(
+                post3.activity_pub_object.activities.first.reload.published_at,
+              ).to be_within_one_second_of(Time.now)
+            end
+
+            it "creates the right announcements" do
+              described_class.perform(actor_id: actor.id)
+              expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+                category.activity_pub_actor.id,
+              )
+              expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
+                category.activity_pub_actor.id,
+              )
+              expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
+                category.activity_pub_actor.id,
+              )
+              expect(
+                post1.activity_pub_object.activities.first.announcement.published_at,
+              ).to be_within_one_second_of(Time.now)
+              expect(
+                post2.activity_pub_object.activities.first.announcement.published_at,
+              ).to be_within_one_second_of(Time.now)
+              expect(
+                post3.activity_pub_object.activities.first.announcement.published_at,
+              ).to be_within_one_second_of(Time.now)
+            end
+          end
         end
       end
-    end
 
-    context "when the actor has first_post enabled" do
-      before { toggle_activity_pub(category, publication_type: "first_post") }
-
-      context "when the actor has models without ap objects" do
-        let!(:topic1) { Fabricate(:topic, category: category) }
-        let!(:topic2) { Fabricate(:topic, category: category) }
-        let!(:post1) { Fabricate(:post, topic: topic1) }
-        let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
-        let!(:post3) { Fabricate(:post, topic: topic2) }
-
-        it "returns the right result" do
-          result = described_class.perform(actor_id: actor.id)
-          expect(result.collections.count).to eq(0)
-          expect(result.actors.count).to eq(2)
-          expect(result.objects.count).to eq(2)
-          expect(result.activities.count).to eq(2)
-          expect(result.announcements.count).to eq(2)
-          expect(result.ap_ids.count).to eq(8)
-        end
+      context "with first_post enabled" do
+        before { toggle_activity_pub(category, publication_type: "first_post") }
 
-        it "creates the right actors" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.reload.user.activity_pub_actor.name).to eq(post1.user.name)
-          expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
-          expect(post1.user.activity_pub_actor.username).to eq(post1.user.username)
-          expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
-        end
+        context "with models without ap objects" do
+          let!(:topic1) { Fabricate(:topic, category: category) }
+          let!(:topic2) { Fabricate(:topic, category: category) }
+          let!(:post1) { Fabricate(:post, topic: topic1) }
+          let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
+          let!(:post3) { Fabricate(:post, topic: topic2) }
 
-        it "creates the right post objects" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.activity_pub_object.content).to eq(post1.cooked)
-          expect(post3.activity_pub_object.content).to eq(post3.cooked)
-          expect(post1.activity_pub_content).to eq(post1.cooked)
-          expect(post3.activity_pub_content).to eq(post3.cooked)
-          expect(post1.activity_pub_visibility).to eq("public")
-          expect(post3.activity_pub_visibility).to eq("public")
-          expect(
-            post1.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.custom_fields["activity_pub_published_at"].to_time,
-          ).to be_within_one_second_of(Time.now)
-        end
+          it "returns the right result" do
+            result = described_class.perform(actor_id: actor.id)
+            expect(result.collections.count).to eq(0)
+            expect(result.actors.count).to eq(2)
+            expect(result.objects.count).to eq(2)
+            expect(result.activities.count).to eq(2)
+            expect(result.announcements.count).to eq(2)
+            expect(result.ap_ids.count).to eq(8)
+          end
 
-        it "creates the right activities" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
-          expect(post1.activity_pub_object.activities.first.actor.id).to eq(
-            post1.user.activity_pub_actor.id,
-          )
-          expect(post3.activity_pub_object.activities.first.actor.id).to eq(
-            post3.user.activity_pub_actor.id,
-          )
-          expect(post1.activity_pub_object.activities.first.object.id).to eq(
-            post1.activity_pub_object.id,
-          )
-          expect(post3.activity_pub_object.activities.first.object.id).to eq(
-            post3.activity_pub_object.id,
-          )
-          expect(post1.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
-          expect(
-            post1.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.activity_pub_object.activities.first.published_at,
-          ).to be_within_one_second_of(Time.now)
+          it "creates the right actors" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.user.activity_pub_actor.name).to eq(post1.user.name)
+            expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
+            expect(post1.user.activity_pub_actor.username).to eq(post1.user.username)
+            expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
+          end
+
+          it "creates the right post objects" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.activity_pub_object.content).to eq(post1.cooked)
+            expect(post3.activity_pub_object.content).to eq(post3.cooked)
+            expect(post1.activity_pub_content).to eq(post1.cooked)
+            expect(post3.activity_pub_content).to eq(post3.cooked)
+            expect(post1.activity_pub_visibility).to eq("public")
+            expect(post3.activity_pub_visibility).to eq("public")
+            expect(
+              post1.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right activities" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post1.activity_pub_object.activities.first.actor.id).to eq(
+              post1.user.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.actor.id).to eq(
+              post3.user.activity_pub_actor.id,
+            )
+            expect(post1.activity_pub_object.activities.first.object.id).to eq(
+              post1.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.activities.first.object.id).to eq(
+              post3.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(
+              post1.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right announcements" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(
+              post1.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          context "with verbose logging enabled" do
+            before { setup_logging }
+            after { teardown_logging }
+
+            it "logs the right info" do
+              actor.reload
+              described_class.perform(actor_id: actor.id)
+              [
+                I18n.t("discourse_activity_pub.bulk.publish.info.started", target: actor.handle),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: actor.handle),
+              ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            end
+          end
         end
+      end
+    end
+
+    context "with a tag actor" do
+      context "with full topic enabled" do
+        let!(:tag) { Fabricate(:tag) }
+        let!(:actor) { Fabricate(:discourse_activity_pub_actor_group, model: tag, enabled: true) }
+
+        before { toggle_activity_pub(tag, publication_type: "full_topic") }
+
+        context "with models without ap objects" do
+          let!(:topic1) { Fabricate(:topic, tags: [tag]) }
+          let!(:topic2) { Fabricate(:topic, tags: [tag]) }
+          let!(:post1) { Fabricate(:post, topic: topic1) }
+          let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
+          let!(:post3) { Fabricate(:post, topic: topic2) }
+          let!(:post4) { Fabricate(:post, topic: topic2) }
+
+          it "returns the right result" do
+            result = described_class.perform(actor_id: actor.id)
+            expect(result.collections.count).to eq(2)
+            expect(result.actors.count).to eq(4)
+            expect(result.objects.count).to eq(4)
+            expect(result.activities.count).to eq(4)
+            expect(result.announcements.count).to eq(4)
+            expect(result.ap_ids.count).to eq(18)
+          end
+
+          it "creates the right collections" do
+            described_class.perform(actor_id: actor.id)
+            expect(topic1.activity_pub_object.name).to eq(topic1.title)
+            expect(topic2.activity_pub_object.name).to eq(topic2.title)
+            expect(topic1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(topic2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right actors" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.user.activity_pub_actor.name).to eq(post1.user.name)
+            expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
+            expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
+            expect(post4.reload.user.activity_pub_actor.name).to eq(post4.user.name)
+            expect(post1.user.activity_pub_actor.username).to eq(post1.user.username)
+            expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
+            expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
+            expect(post4.user.activity_pub_actor.username).to eq(post4.user.username)
+          end
+
+          it "creates the right post objects" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.activity_pub_object.content).to eq(post1.cooked)
+            expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
+            expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
+            expect(post4.reload.activity_pub_object.content).to eq(post4.cooked)
+            expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
+            expect(post4.activity_pub_object.reply_to_id).to eq(post3.activity_pub_object.ap_id)
+            expect(post1.activity_pub_object.collection_id).to eq(
+              post1.topic.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.collection_id).to eq(
+              post2.topic.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.collection_id).to eq(
+              post3.topic.activity_pub_object.id,
+            )
+            expect(post4.activity_pub_object.collection_id).to eq(
+              post4.topic.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.attributed_to_id).to eq(
+              post1.user.activity_pub_actor.ap_id,
+            )
+            expect(post2.activity_pub_object.attributed_to_id).to eq(
+              post2.user.activity_pub_actor.ap_id,
+            )
+            expect(post3.activity_pub_object.attributed_to_id).to eq(
+              post3.user.activity_pub_actor.ap_id,
+            )
+            expect(post4.activity_pub_object.attributed_to_id).to eq(
+              post4.user.activity_pub_actor.ap_id,
+            )
+            expect(post1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post4.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post1.activity_pub_content).to eq(post1.cooked)
+            expect(post2.activity_pub_content).to eq(post2.cooked)
+            expect(post3.activity_pub_content).to eq(post3.cooked)
+            expect(post4.activity_pub_content).to eq(post3.cooked)
+            expect(post1.activity_pub_visibility).to eq("public")
+            expect(post2.activity_pub_visibility).to eq("public")
+            expect(post3.activity_pub_visibility).to eq("public")
+            expect(post4.activity_pub_visibility).to eq("public")
+            expect(
+              post1.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post4.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right activities" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post4.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post1.activity_pub_object.activities.first.actor.id).to eq(
+              post1.user.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.actor.id).to eq(
+              post2.user.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.actor.id).to eq(
+              post3.user.activity_pub_actor.id,
+            )
+            expect(post4.activity_pub_object.activities.first.actor.id).to eq(
+              post4.user.activity_pub_actor.id,
+            )
+            expect(post1.activity_pub_object.activities.first.object.id).to eq(
+              post1.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.activities.first.object.id).to eq(
+              post2.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.activities.first.object.id).to eq(
+              post3.activity_pub_object.id,
+            )
+            expect(post4.activity_pub_object.activities.first.object.id).to eq(
+              post4.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post4.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(
+              post1.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post4.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right announcements" do
+            described_class.perform(actor_id: actor.id)
+            expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              tag.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              tag.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              tag.activity_pub_actor.id,
+            )
+            expect(post4.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              tag.activity_pub_actor.id,
+            )
+            expect(
+              post1.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post4.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates objects in the right order" do
+            described_class.perform(actor_id: actor.id)
+            expect(
+              DiscourseActivityPubObject
+                .joins(
+                  "JOIN posts ON discourse_activity_pub_objects.model_type = 'Post' AND discourse_activity_pub_objects.model_id = posts.id",
+                )
+                .order("discourse_activity_pub_objects.created_at")
+                .pluck("posts.id"),
+            ).to eq([post1.id, post2.id, post3.id, post4.id])
+          end
 
-        it "creates the right announcements" do
-          described_class.perform(actor_id: actor.id)
-          expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
-            category.activity_pub_actor.id,
-          )
-          expect(
-            post1.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
-          expect(
-            post3.activity_pub_object.activities.first.announcement.published_at,
-          ).to be_within_one_second_of(Time.now)
+          it "creates activities in the right order" do
+            described_class.perform(actor_id: actor.id)
+            expect(
+              DiscourseActivityPubActivity
+                .joins(
+                  "JOIN discourse_activity_pub_objects o ON discourse_activity_pub_activities.object_type = 'DiscourseActivityPubObject' AND discourse_activity_pub_activities.object_id = o.id",
+                )
+                .joins("JOIN posts ON o.model_type = 'Post' AND o.model_id = posts.id")
+                .order("o.created_at")
+                .pluck("posts.id"),
+            ).to eq([post1.id, post2.id, post3.id, post4.id])
+          end
+
+          context "with verbose logging enabled" do
+            before { setup_logging }
+            after { teardown_logging }
+
+            it "logs the right info" do
+              actor.reload
+              described_class.perform(actor_id: actor.id)
+              [
+                I18n.t("discourse_activity_pub.bulk.publish.info.started", target: actor.handle),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 4),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 4),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_collections", count: 2),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 4),
+                I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: actor.handle),
+              ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            end
+          end
         end
+      end
+    end
+
+    context "with a topic" do
+      context "with full topic enabled" do
+        before { toggle_activity_pub(category, publication_type: "full_topic") }
 
-        context "with verbose logging enabled" do
-          before { setup_logging }
-          after { teardown_logging }
+        context "without ap objects" do
+          let!(:topic1) { Fabricate(:topic, category: category) }
+          let!(:post1) { Fabricate(:post, topic: topic1) }
+          let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
+          let!(:post3) { Fabricate(:post, topic: topic1) }
 
-          it "logs the right info" do
-            actor.reload
+          it "returns the right result" do
+            result = described_class.perform(topic_id: topic1.id)
+            expect(result.collections.count).to eq(1)
+            expect(result.actors.count).to eq(3)
+            expect(result.objects.count).to eq(3)
+            expect(result.activities.count).to eq(3)
+            expect(result.announcements.count).to eq(3)
+            expect(result.ap_ids.count).to eq(13)
+          end
+
+          it "creates the right collections" do
+            described_class.perform(topic_id: topic1.id)
+            expect(topic1.activity_pub_object.name).to eq(topic1.title)
+            expect(topic1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right actors" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post1.reload.user.activity_pub_actor.name).to eq(post1.user.name)
+            expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
+            expect(post3.reload.user.activity_pub_actor.name).to eq(post3.user.name)
+            expect(post1.user.activity_pub_actor.username).to eq(post1.user.username)
+            expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
+            expect(post3.user.activity_pub_actor.username).to eq(post3.user.username)
+          end
+
+          it "creates the right post objects" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post1.reload.activity_pub_object.content).to eq(post1.cooked)
+            expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
+            expect(post3.reload.activity_pub_object.content).to eq(post3.cooked)
+            expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
+            expect(post1.activity_pub_object.collection_id).to eq(
+              post1.topic.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.collection_id).to eq(
+              post2.topic.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.collection_id).to eq(
+              post3.topic.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.attributed_to_id).to eq(
+              post1.user.activity_pub_actor.ap_id,
+            )
+            expect(post2.activity_pub_object.attributed_to_id).to eq(
+              post2.user.activity_pub_actor.ap_id,
+            )
+            expect(post3.activity_pub_object.attributed_to_id).to eq(
+              post3.user.activity_pub_actor.ap_id,
+            )
+            expect(post1.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post3.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post1.activity_pub_content).to eq(post1.cooked)
+            expect(post2.activity_pub_content).to eq(post2.cooked)
+            expect(post3.activity_pub_content).to eq(post3.cooked)
+            expect(post1.activity_pub_visibility).to eq("public")
+            expect(post2.activity_pub_visibility).to eq("public")
+            expect(post3.activity_pub_visibility).to eq("public")
+            expect(
+              post1.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right activities" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post1.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post3.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post1.activity_pub_object.activities.first.actor.id).to eq(
+              post1.user.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.actor.id).to eq(
+              post2.user.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.actor.id).to eq(
+              post3.user.activity_pub_actor.id,
+            )
+            expect(post1.activity_pub_object.activities.first.object.id).to eq(
+              post1.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.activities.first.object.id).to eq(
+              post2.activity_pub_object.id,
+            )
+            expect(post3.activity_pub_object.activities.first.object.id).to eq(
+              post3.activity_pub_object.id,
+            )
+            expect(post1.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(post3.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(
+              post1.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right announcements" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post3.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(
+              post1.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post3.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates objects in the right order" do
+            described_class.perform(topic_id: topic1.id)
+            expect(
+              DiscourseActivityPubObject
+                .joins(
+                  "JOIN posts ON discourse_activity_pub_objects.model_type = 'Post' AND discourse_activity_pub_objects.model_id = posts.id",
+                )
+                .order("discourse_activity_pub_objects.created_at")
+                .pluck("posts.id"),
+            ).to eq([post1.id, post2.id, post3.id])
+          end
+
+          it "creates activities in the right order" do
             described_class.perform(actor_id: actor.id)
-            [
-              I18n.t("discourse_activity_pub.bulk.publish.info.started", actor: actor.handle),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 2),
-              I18n.t("discourse_activity_pub.bulk.publish.info.finished", actor: actor.handle),
-            ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            expect(
+              DiscourseActivityPubActivity
+                .joins(
+                  "JOIN discourse_activity_pub_objects o ON discourse_activity_pub_activities.object_type = 'DiscourseActivityPubObject' AND discourse_activity_pub_activities.object_id = o.id",
+                )
+                .joins("JOIN posts ON o.model_type = 'Post' AND o.model_id = posts.id")
+                .order("o.created_at")
+                .pluck("posts.id"),
+            ).to eq([post1.id, post2.id, post3.id])
+          end
+
+          context "with verbose logging enabled" do
+            before { setup_logging }
+            after { teardown_logging }
+
+            it "logs the right info" do
+              actor.reload
+              described_class.perform(actor_id: actor.id)
+              [
+                I18n.t("discourse_activity_pub.bulk.publish.info.started", target: actor.handle),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 3),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 3),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_collections", count: 1),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 3),
+                I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: actor.handle),
+              ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            end
+          end
+        end
+
+        context "with and without ap objects" do
+          let!(:topic1) { Fabricate(:topic, category: category) }
+          let!(:post1) { Fabricate(:post, topic: topic1) }
+          let!(:post2) { Fabricate(:post, topic: topic1, reply_to_post_number: 1) }
+          let!(:collection1) do
+            Fabricate(
+              :discourse_activity_pub_ordered_collection,
+              model: topic1,
+              published_at: Time.now,
+            )
+          end
+          let!(:actor1) do
+            Fabricate(:discourse_activity_pub_actor, ap_type: "Person", model: post1.user)
+          end
+          let!(:object1) do
+            Fabricate(
+              :discourse_activity_pub_object_note,
+              model: post1,
+              published_at: Time.now,
+              collection_id: collection1.id,
+              attributed_to: actor1,
+            )
+          end
+          let!(:activity1) do
+            Fabricate(
+              :discourse_activity_pub_activity_create,
+              actor: actor1,
+              object: object1,
+              published_at: Time.now,
+            )
+          end
+
+          it "returns the right result" do
+            result = described_class.perform(topic_id: topic1.id)
+            expect(result.collections.count).to eq(0)
+            expect(result.actors.count).to eq(1)
+            expect(result.objects.count).to eq(1)
+            expect(result.activities.count).to eq(1)
+            expect(result.announcements.count).to eq(2)
+            expect(result.ap_ids.count).to eq(5)
+          end
+
+          it "does not create collections" do
+            expect { described_class.perform(topic_id: topic1.id) }.to_not change {
+              DiscourseActivityPubCollection.count
+            }
+          end
+
+          it "creates the right actors" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post2.reload.user.activity_pub_actor.name).to eq(post2.user.name)
+            expect(post2.user.activity_pub_actor.username).to eq(post2.user.username)
+          end
+
+          it "creates the right post objects" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post2.reload.activity_pub_object.content).to eq(post2.cooked)
+            expect(post2.activity_pub_object.reply_to_id).to eq(post1.activity_pub_object.ap_id)
+            expect(post2.activity_pub_object.collection_id).to eq(
+              post2.topic.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.attributed_to_id).to eq(
+              post2.user.activity_pub_actor.ap_id,
+            )
+            expect(post2.activity_pub_object.published_at).to be_within_one_second_of(Time.now)
+            expect(post2.activity_pub_content).to eq(post2.cooked)
+            expect(post2.activity_pub_visibility).to eq("public")
+            expect(
+              post2.custom_fields["activity_pub_published_at"].to_time,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right activities" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post2.reload.activity_pub_object.activities.first.ap_type).to eq("Create")
+            expect(post2.activity_pub_object.activities.first.actor.id).to eq(
+              post2.user.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.object.id).to eq(
+              post2.activity_pub_object.id,
+            )
+            expect(post2.activity_pub_object.activities.first.visibility).to eq(2)
+            expect(
+              post2.activity_pub_object.activities.first.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          it "creates the right announcements" do
+            described_class.perform(topic_id: topic1.id)
+            expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
+              category.activity_pub_actor.id,
+            )
+            expect(
+              post1.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+            expect(
+              post2.activity_pub_object.activities.first.announcement.published_at,
+            ).to be_within_one_second_of(Time.now)
+          end
+
+          context "with verbose logging enabled" do
+            before { setup_logging }
+            after { teardown_logging }
+
+            it "logs the right info" do
+              described_class.perform(topic_id: topic1.id)
+              [
+                I18n.t("discourse_activity_pub.bulk.publish.info.started", target: topic1.title),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_actors", count: 1),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_objects", count: 1),
+                I18n.t("discourse_activity_pub.bulk.publish.info.created_activities", count: 1),
+                I18n.t("discourse_activity_pub.bulk.publish.info.finished", target: topic1.title),
+              ].each { |info| expect(@fake_logger.info).to include(prefix_log(info)) }
+            end
+          end
+
+          context "with unpublished ap objects" do
+            let!(:actor2) do
+              Fabricate(:discourse_activity_pub_actor, ap_type: "Person", model: post2.user)
+            end
+            let!(:object2) do
+              Fabricate(
+                :discourse_activity_pub_object_note,
+                model: post2,
+                published_at: nil,
+                collection_id: collection1.id,
+                attributed_to: actor2,
+                reply_to_id: object1.ap_id,
+              )
+            end
+            let!(:activity2) do
+              Fabricate(
+                :discourse_activity_pub_activity_create,
+                actor: actor2,
+                object: object2,
+                published_at: nil,
+              )
+            end
+
+            it "publishes the right objects" do
+              described_class.perform(topic_id: topic1.id)
+              expect(
+                post2.reload.reload.activity_pub_object.published_at,
+              ).to be_within_one_second_of(Time.now)
+              expect(
+                post2.custom_fields["activity_pub_published_at"].to_time,
+              ).to be_within_one_second_of(Time.now)
+            end
+
+            it "publishes the right activities" do
+              described_class.perform(topic_id: topic1.id)
+              expect(activity2.reload.published_at).to be_within_one_second_of(Time.now)
+            end
+
+            it "creates the right announcements" do
+              described_class.perform(actor_id: actor.id)
+              expect(post1.activity_pub_object.activities.first.announcement.actor_id).to eq(
+                category.activity_pub_actor.id,
+              )
+              expect(post2.activity_pub_object.activities.first.announcement.actor_id).to eq(
+                category.activity_pub_actor.id,
+              )
+              expect(
+                post1.activity_pub_object.activities.first.announcement.published_at,
+              ).to be_within_one_second_of(Time.now)
+              expect(
+                post2.activity_pub_object.activities.first.announcement.published_at,
+              ).to be_within_one_second_of(Time.now)
+            end
           end
         end
       end
diff --git a/spec/models/discourse_activity_pub_activity_spec.rb b/spec/models/discourse_activity_pub_activity_spec.rb
index d399d1f3..efac2e05 100644
--- a/spec/models/discourse_activity_pub_activity_spec.rb
+++ b/spec/models/discourse_activity_pub_activity_spec.rb
@@ -114,14 +114,7 @@
         Post
           .any_instance
           .expects(:activity_pub_after_scheduled)
-          .with(
-            {
-              scheduled_at: Time.now.utc.iso8601,
-              published_at: nil,
-              deleted_at: nil,
-              updated_at: nil,
-            },
-          )
+          .with({ scheduled_at: Time.now.utc.iso8601 })
           .once
         activity.after_scheduled(Time.now.utc.iso8601)
       end
@@ -139,14 +132,7 @@
         Post
           .any_instance
           .expects(:activity_pub_after_scheduled)
-          .with(
-            {
-              scheduled_at: Time.now.utc.iso8601,
-              published_at: nil,
-              deleted_at: nil,
-              updated_at: nil,
-            },
-          )
+          .with({ scheduled_at: Time.now.utc.iso8601 })
           .once
         collection.after_scheduled(Time.now.utc.iso8601)
       end
@@ -176,7 +162,7 @@
         Post
           .any_instance
           .expects(:activity_pub_after_publish)
-          .with({ published_at: Time.now.utc.iso8601 })
+          .with({ published_at: Time.now.utc.iso8601, deleted_at: nil })
           .once
         create_activity.before_deliver
       end
@@ -189,7 +175,14 @@
         Post
           .any_instance
           .expects(:activity_pub_after_publish)
-          .with({ deleted_at: Time.now.utc.iso8601 })
+          .with(
+            {
+              deleted_at: Time.now.utc.iso8601,
+              published_at: nil,
+              updated_at: nil,
+              scheduled_at: nil,
+            },
+          )
           .once
         delete_activity.before_deliver
       end
@@ -232,7 +225,7 @@
         Post
           .any_instance
           .expects(:activity_pub_after_publish)
-          .with({ published_at: Time.now.utc.iso8601 })
+          .with({ published_at: Time.now.utc.iso8601, deleted_at: nil })
           .once
         activity.before_deliver
       end
@@ -367,5 +360,18 @@
         end
       end
     end
+
+    context "with create activity" do
+      let(:activity) { Fabricate(:discourse_activity_pub_activity_create, actor: actor) }
+
+      it "calls activity_pub_after_deliver with correct arguments" do
+        Post
+          .any_instance
+          .expects(:activity_pub_after_deliver)
+          .with({ delivered_at: Time.now.utc.iso8601 })
+          .once
+        activity.after_deliver(Time.now.utc.iso8601)
+      end
+    end
   end
 end
diff --git a/spec/models/post_action_spec.rb b/spec/models/post_action_spec.rb
index 6d6ffa8c..845a4d54 100644
--- a/spec/models/post_action_spec.rb
+++ b/spec/models/post_action_spec.rb
@@ -32,14 +32,14 @@
   describe "#perform_activity_pub_activity" do
     context "without activty pub enabled on the category" do
       it "does nothing" do
-        expect(post_action.perform_activity_pub_activity(:like)).to eq(nil)
+        expect(post_action.perform_activity_pub_activity(:like)).to eq(false)
         expect(post.activity_pub_object.reload.likes.present?).to eq(false)
       end
     end
 
     context "with an invalid activity type" do
       it "does nothing" do
-        expect(post_action.perform_activity_pub_activity(:create)).to eq(nil)
+        expect(post_action.perform_activity_pub_activity(:create)).to eq(false)
         expect(post.activity_pub_object.reload.likes.present?).to eq(false)
       end
     end
@@ -51,7 +51,7 @@
       end
 
       it "does nothing" do
-        expect(post_action.perform_activity_pub_activity(:like)).to eq(nil)
+        expect(post_action.perform_activity_pub_activity(:like)).to eq(false)
         expect(post.activity_pub_object.reload.likes.any?).to eq(false)
       end
     end
diff --git a/spec/models/post_spec.rb b/spec/models/post_spec.rb
index 64ee058d..4e50f2b3 100644
--- a/spec/models/post_spec.rb
+++ b/spec/models/post_spec.rb
@@ -170,37 +170,64 @@
           post.stubs(:perform_activity_pub_activity).with(:create).returns(true)
           expect(post.activity_pub_publish!).to eq(true)
         end
+
+        context "with a reply" do
+          let!(:reply) { Fabricate(:post, topic: topic, post_number: 2) }
+
+          it "does not create an Activity" do
+            expect { reply.activity_pub_publish! }.not_to change {
+              DiscourseActivityPubActivity.count
+            }
+          end
+
+          it "does not send anything for delivery" do
+            expect_no_delivery
+            reply.activity_pub_publish!
+          end
+
+          it "returns false" do
+            expect(reply.activity_pub_publish!).to eq(false)
+          end
+        end
       end
 
       context "with full_topic enabled on category" do
-        before do
-          toggle_activity_pub(category, publication_type: "full_topic")
-          post.topic.create_activity_pub_collection!
-        end
+        before { toggle_activity_pub(category, publication_type: "full_topic") }
 
-        it "attemps to create a post user actor" do
-          DiscourseActivityPub::ActorHandler.expects(:update_or_create_actor).once
-          post.activity_pub_publish!
-        end
+        context "with a topic collection" do
+          before { post.topic.create_activity_pub_collection! }
 
-        it "sets the post content" do
-          post.activity_pub_publish!
-          expect(post.reload.activity_pub_content).to eq(post.cooked)
-        end
+          it "attemps to create a post user actor" do
+            DiscourseActivityPub::ActorHandler.expects(:update_or_create_actor).once
+            post.activity_pub_publish!
+          end
 
-        it "sets the post visibility" do
-          post.activity_pub_publish!
-          expect(post.reload.activity_pub_visibility).to eq("public")
-        end
+          it "sets the post content" do
+            post.activity_pub_publish!
+            expect(post.reload.activity_pub_content).to eq(post.cooked)
+          end
 
-        it "attempts a create activity" do
-          post.expects(:perform_activity_pub_activity).with(:create).once
-          post.activity_pub_publish!
+          it "sets the post visibility" do
+            post.activity_pub_publish!
+            expect(post.reload.activity_pub_visibility).to eq("public")
+          end
+
+          it "attempts a create activity" do
+            post.expects(:perform_activity_pub_activity).with(:create).once
+            post.activity_pub_publish!
+          end
+
+          it "returns the outcome of the create activity" do
+            post.stubs(:perform_activity_pub_activity).with(:create).returns(true)
+            expect(post.activity_pub_publish!).to eq(true)
+          end
         end
 
-        it "returns the outcome of the create activity" do
-          post.stubs(:perform_activity_pub_activity).with(:create).returns(true)
-          expect(post.activity_pub_publish!).to eq(true)
+        context "without a topic collection" do
+          it "creates the topic collections" do
+            post.activity_pub_publish!
+            expect(post.topic.activity_pub_object).to be_present
+          end
         end
       end
 
@@ -286,11 +313,23 @@
     context "with a post with a local Note" do
       let!(:note) { Fabricate(:discourse_activity_pub_object_note, model: post, local: true) }
 
+      before do
+        post.custom_fields["activity_pub_scheduled_at"] = Time.now
+        post.custom_fields["activity_pub_published_at"] = Time.now
+        post.save_custom_fields(true)
+      end
+
       it "attempts a delete activity" do
         post.expects(:perform_activity_pub_activity).with(:delete).once
         post.activity_pub_delete!
       end
 
+      it "removes published_at and scheduled_at timestamps" do
+        post.activity_pub_delete!
+        expect(post.reload.activity_pub_scheduled_at).to eq(nil)
+        expect(post.activity_pub_published_at).to eq(nil)
+      end
+
       it "returns the outcome of the delete activity" do
         post.stubs(:perform_activity_pub_activity).with(:delete).returns(true)
         expect(post.activity_pub_delete!).to eq(true)
@@ -334,14 +373,25 @@
     end
 
     context "with a unscheduled unpublished post" do
-      it "attempts to publish" do
-        post.expects(:activity_pub_publish!).once
-        post.activity_pub_schedule!
-      end
+      context "with followers" do
+        let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+        let!(:follow1) do
+          Fabricate(
+            :discourse_activity_pub_follow,
+            follower: follower1,
+            followed: category.activity_pub_actor,
+          )
+        end
 
-      it "returns the outcome of the publish attempt" do
-        post.stubs(:activity_pub_publish!).returns(false)
-        expect(post.activity_pub_schedule!).to eq(false)
+        it "attempts to publish" do
+          post.expects(:activity_pub_publish!).once
+          post.activity_pub_schedule!
+        end
+
+        it "returns the outcome of the publish attempt" do
+          post.stubs(:activity_pub_publish!).returns(false)
+          expect(post.activity_pub_schedule!).to eq(false)
+        end
       end
     end
   end
@@ -452,16 +502,11 @@
         perform_delete
         expect(DiscourseActivityPubActivity.exists?(id: create.id)).to eq(true)
       end
-
-      it "sends the activity as the post actor for delivery without delay" do
-        expect_delivery(actor: post.activity_pub_actor, object_type: "Delete")
-        perform_delete
-      end
     end
 
     context "without activty pub enabled on the category" do
       it "does nothing" do
-        expect(post.perform_activity_pub_activity(:create)).to eq(nil)
+        expect(post.perform_activity_pub_activity(:create)).to eq(false)
         expect(post.reload.activity_pub_object.present?).to eq(false)
       end
     end
@@ -476,14 +521,14 @@
         before { SiteSetting.login_required = true }
 
         it "does nothing" do
-          expect(post.perform_activity_pub_activity(:create)).to eq(nil)
+          expect(post.perform_activity_pub_activity(:create)).to eq(false)
           expect(post.reload.activity_pub_object.present?).to eq(false)
         end
       end
 
       context "with an invalid activity type" do
         it "does nothing" do
-          expect(post.perform_activity_pub_activity(:follow)).to eq(nil)
+          expect(post.perform_activity_pub_activity(:follow)).to eq(false)
           expect(post.reload.activity_pub_object.present?).to eq(false)
         end
       end
@@ -495,7 +540,7 @@
         end
 
         it "does nothing" do
-          expect(post.perform_activity_pub_activity(:create)).to eq(nil)
+          expect(post.perform_activity_pub_activity(:create)).to eq(false)
           expect(post.reload.activity_pub_object.present?).to eq(false)
         end
       end
@@ -540,6 +585,21 @@ def perform_create
           ).to eq(true)
         end
 
+        context "when post category has no followers" do
+          it "publishes the post's ap objects" do
+            freeze_time
+            published_at = Time.now.utc.iso8601
+            perform_create
+            expect(post.activity_pub_published?).to eq(true)
+            # rubocop:disable Discourse/TimeEqMatcher
+            expect(post.activity_pub_published_at).to eq(published_at)
+            expect(post.activity_pub_object.published_at).to eq(published_at)
+            expect(post.activity_pub_object.create_activity.published_at).to eq(published_at)
+            # rubocop:enable Discourse/TimeEqMatcher
+            unfreeze_time
+          end
+        end
+
         context "when post category has followers" do
           let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
           let!(:follow1) do
@@ -605,6 +665,28 @@ def perform_create
             expect(reply.activity_pub_actor).to eq(nil)
           end
         end
+
+        context "when post is deleted" do
+          before do
+            post.custom_fields["activity_pub_deleted_at"] = Time.now
+            post.save_custom_fields(true)
+            post.trash!
+          end
+
+          it "publishes the post's ap objects" do
+            freeze_time
+            published_at = Time.now.utc.to_i
+            perform_create
+            expect(post.activity_pub_published?).to eq(true)
+            expect(post.activity_pub_published_at.to_datetime.to_i).to eq_time(published_at)
+            expect(post.activity_pub_deleted_at).to eq(nil)
+            expect(post.activity_pub_object.published_at.to_datetime.to_i).to eq_time(published_at)
+            expect(
+              post.activity_pub_object.create_activity.published_at.to_datetime.to_i,
+            ).to eq_time(published_at)
+            unfreeze_time
+          end
+        end
       end
 
       context "with update" do
@@ -657,36 +739,40 @@ def perform_update
             ).to eq(true)
           end
 
-          it "doesn't create multiple unpublished activities" do
-            perform_update
-            perform_update
-            expect(
-              post
-                .activity_pub_actor
-                .activities
-                .where(
-                  object_id: post.activity_pub_object.id,
-                  object_type: "DiscourseActivityPubObject",
-                  ap_type: "Update",
-                )
-                .size,
-            ).to eq(1)
+          context "when the category has no followers" do
+            it "creates multiple published activities" do
+              perform_update
+              perform_update
+              attrs = {
+                object_id: post.activity_pub_object.id,
+                object_type: "DiscourseActivityPubObject",
+                ap_type: "Update",
+              }
+              expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
+            end
           end
 
-          it "creates multiple published activities" do
-            perform_update
-            perform_update
-
-            attrs = {
-              object_id: post.activity_pub_object.id,
-              object_type: "DiscourseActivityPubObject",
-              ap_type: "Update",
-            }
-            post.activity_pub_actor.activities.where(attrs).update_all(published_at: Time.now)
-
-            perform_update
+          context "when the category has followers" do
+            let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+            let!(:follow1) do
+              Fabricate(
+                :discourse_activity_pub_follow,
+                follower: follower1,
+                followed: category.activity_pub_actor,
+              )
+            end
 
-            expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
+            it "does not create multiple unpublished activities" do
+              perform_update
+              perform_update
+              attrs = {
+                object_id: post.activity_pub_object.id,
+                object_type: "DiscourseActivityPubObject",
+                ap_type: "Update",
+                published_at: nil,
+              }
+              expect(post.activity_pub_actor.activities.where(attrs).size).to eq(1)
+            end
           end
 
           context "when the acting user is different from the post user" do
@@ -1082,36 +1168,40 @@ def perform_update
             ).to eq(true)
           end
 
-          it "doesn't create multiple unpublished activities" do
-            perform_update
-            perform_update
-            expect(
-              post
-                .activity_pub_actor
-                .activities
-                .where(
-                  object_id: post.activity_pub_object.id,
-                  object_type: "DiscourseActivityPubObject",
-                  ap_type: "Update",
-                )
-                .size,
-            ).to eq(1)
+          context "when the tag has no followers" do
+            it "creates multiple published activities" do
+              perform_update
+              perform_update
+              attrs = {
+                object_id: post.activity_pub_object.id,
+                object_type: "DiscourseActivityPubObject",
+                ap_type: "Update",
+              }
+              expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
+            end
           end
 
-          it "creates multiple published activities" do
-            perform_update
-            perform_update
-
-            attrs = {
-              object_id: post.activity_pub_object.id,
-              object_type: "DiscourseActivityPubObject",
-              ap_type: "Update",
-            }
-            post.activity_pub_actor.activities.where(attrs).update_all(published_at: Time.now)
-
-            perform_update
+          context "when the tag has followers" do
+            let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+            let!(:follow1) do
+              Fabricate(
+                :discourse_activity_pub_follow,
+                follower: follower1,
+                followed: tag.activity_pub_actor,
+              )
+            end
 
-            expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
+            it "doe not create multiple unpublished activities" do
+              perform_update
+              perform_update
+              attrs = {
+                object_id: post.activity_pub_object.id,
+                object_type: "DiscourseActivityPubObject",
+                ap_type: "Update",
+                published_at: nil,
+              }
+              expect(post.activity_pub_actor.activities.where(attrs).size).to eq(1)
+            end
           end
 
           context "when the acting user is different from the post user" do
@@ -1494,7 +1584,7 @@ def perform_create
 
       context "without a topic collection" do
         it "does not perform the activity" do
-          expect(post.perform_activity_pub_activity(:create)).to eq(nil)
+          expect(post.perform_activity_pub_activity(:create)).to eq(false)
           expect(DiscourseActivityPubActivity.exists?(ap_type: "Create")).to eq(false)
         end
       end
@@ -1553,13 +1643,24 @@ def perform_create
               )
             end
 
-            it "sends the activity for delayed delivery" do
-              expect_delivery(
-                actor: topic.activity_pub_actor,
-                object_type: "Create",
-                delay: SiteSetting.activity_pub_delivery_delay_minutes.to_i,
-              )
-              perform_create
+            context "with followers" do
+              let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+              let!(:follow1) do
+                Fabricate(
+                  :discourse_activity_pub_follow,
+                  follower: follower1,
+                  followed: category.activity_pub_actor,
+                )
+              end
+
+              it "sends the activity for delayed delivery" do
+                expect_delivery(
+                  actor: topic.activity_pub_actor,
+                  object_type: "Create",
+                  delay: SiteSetting.activity_pub_delivery_delay_minutes.to_i,
+                )
+                perform_create
+              end
             end
           end
 
@@ -1665,39 +1766,49 @@ def perform_update
                 ).to eq(true)
               end
 
-              it "doesn't create multiple unpublished activities" do
-                perform_update
-                expect(
-                  post
-                    .activity_pub_actor
-                    .activities
-                    .where(
-                      object_id: post.activity_pub_object.id,
-                      object_type: "DiscourseActivityPubObject",
-                      ap_type: "Update",
-                    )
-                    .size,
-                ).to eq(1)
+              context "with no followers" do
+                it "creates multiple published activities" do
+                  perform_update
+                  perform_update
+                  attrs = {
+                    object_id: post.activity_pub_object.id,
+                    object_type: "DiscourseActivityPubObject",
+                    ap_type: "Update",
+                  }
+                  expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
+                end
               end
 
-              it "creates multiple published activities" do
-                perform_update
-
-                attrs = {
-                  object_id: post.activity_pub_object.id,
-                  object_type: "DiscourseActivityPubObject",
-                  ap_type: "Update",
-                }
-                post.activity_pub_actor.activities.where(attrs).update_all(published_at: Time.now)
-
-                perform_update
+              context "when the category has followers" do
+                let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                let!(:follow1) do
+                  Fabricate(
+                    :discourse_activity_pub_follow,
+                    follower: follower1,
+                    followed: category.activity_pub_actor,
+                  )
+                end
 
-                expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
-              end
+                it "does not create multiple unpublished activities" do
+                  perform_update
+                  perform_update
+                  attrs = {
+                    object_id: post.activity_pub_object.id,
+                    object_type: "DiscourseActivityPubObject",
+                    ap_type: "Update",
+                    published_at: nil,
+                  }
+                  expect(post.activity_pub_actor.activities.where(attrs).size).to eq(1)
+                end
 
-              it "sends the activity as the post actor for delivery without delay" do
-                expect_delivery(actor: post.activity_pub_actor, object_type: "Update")
-                perform_update
+                it "sends the activity as the post actor to the category followers for delivery without delay" do
+                  expect_delivery(
+                    actor: post.activity_pub_actor,
+                    object_type: "Update",
+                    recipient_ids: [follower1.id],
+                  )
+                  perform_update
+                end
               end
 
               context "when the acting user is different from the post user" do
@@ -1755,51 +1866,54 @@ def perform_create
               reply.reload
             end
 
-            it "creates the right object" do
-              perform_create
-              expect(reply.activity_pub_object&.content).to eq(reply.activity_pub_content)
-              expect(reply.activity_pub_object&.reply_to_id).to eq(post_note.ap_id)
-              expect(reply.activity_pub_object&.collection_id).to eq(topic.activity_pub_object.id)
-            end
-
-            it "creates the right activity" do
-              perform_create
-              expect(
-                reply
-                  .activity_pub_actor
-                  .activities
-                  .where(
-                    object_id: reply.activity_pub_object.id,
-                    object_type: "DiscourseActivityPubObject",
-                    ap_type: "Create",
-                  )
-                  .exists?,
-              ).to eq(true)
-            end
-
-            context "while not published" do
-              it "sends the activity for delayed delivery" do
-                expect_delivery(
-                  actor: topic.activity_pub_actor,
-                  object_type: "Create",
-                  delay: SiteSetting.activity_pub_delivery_delay_minutes.to_i,
+            context "when topic is not published" do
+              it "does not create an Activity" do
+                perform_create
+                expect(reply.activity_pub_actor.activities.where(ap_type: "Create").exists?).to eq(
+                  false,
                 )
+              end
+
+              it "does not send anything for delivery" do
+                expect_no_delivery
                 perform_create
               end
             end
 
-            context "after topic publication" do
+            context "when the topic is published" do
               before do
                 post.custom_fields["activity_pub_published_at"] = Time.now
                 post.save_custom_fields(true)
               end
 
+              it "creates the right activity" do
+                perform_create
+                expect(
+                  reply
+                    .activity_pub_actor
+                    .activities
+                    .where(
+                      object_id: reply.activity_pub_object.id,
+                      object_type: "DiscourseActivityPubObject",
+                      ap_type: "Create",
+                    )
+                    .exists?,
+                ).to eq(true)
+              end
+
+              it "creates the right object" do
+                perform_create
+                expect(reply.activity_pub_object&.content).to eq(reply.activity_pub_content)
+                expect(reply.activity_pub_object&.reply_to_id).to eq(post_note.ap_id)
+                expect(reply.activity_pub_object&.collection_id).to eq(topic.activity_pub_object.id)
+              end
+
               context "when the topic has a remote contributor" do
                 before { post.activity_pub_actor.update(local: false) }
 
                 it "sends to remote contributors for delivery without delay" do
                   expect_delivery(
-                    actor: topic.activity_pub_actor,
+                    actor: reply.activity_pub_actor,
                     object_type: "Create",
                     recipient_ids: [post.activity_pub_actor.id],
                   )
@@ -1818,7 +1932,7 @@ def perform_create
 
                   it "sends to followers and remote contributors for delivery without delay" do
                     expect_delivery(
-                      actor: topic.activity_pub_actor,
+                      actor: reply.activity_pub_actor,
                       object_type: "Create",
                       recipient_ids: [follower1.id] + [post.activity_pub_actor.id],
                     )
@@ -1838,7 +1952,7 @@ def perform_update
               reply.perform_activity_pub_activity(:update)
             end
 
-            context "while not published" do
+            context "when the topic is not published" do
               it "updates the Note content" do
                 perform_update
                 expect(note.reload.content).to eq("Updated content")
@@ -1857,67 +1971,91 @@ def perform_update
               end
             end
 
-            context "after publication" do
+            context "when the topic is published" do
               before do
                 post.custom_fields["activity_pub_published_at"] = Time.now
                 post.save_custom_fields(true)
-                reply.custom_fields["activity_pub_published_at"] = Time.now
-                reply.save_custom_fields(true)
               end
 
-              it "updates the Note content" do
-                perform_update
-                expect(note.reload.content).to eq("Updated content")
-              end
+              context "when the reply is published" do
+                before do
+                  reply.custom_fields["activity_pub_published_at"] = Time.now
+                  reply.save_custom_fields(true)
+                end
 
-              it "creates an Update Activity" do
-                perform_update
-                expect(
-                  reply
-                    .activity_pub_actor
-                    .activities
-                    .where(
-                      object_id: reply.activity_pub_object.id,
-                      object_type: "DiscourseActivityPubObject",
-                      ap_type: "Update",
-                    )
-                    .exists?,
-                ).to eq(true)
-              end
+                it "updates the Note content" do
+                  perform_update
+                  expect(note.reload.content).to eq("Updated content")
+                end
 
-              it "doesn't create multiple unpublished activities" do
-                perform_update
-                expect(
+                it "creates an Update Activity" do
+                  perform_update
+                  expect(
+                    reply
+                      .activity_pub_actor
+                      .activities
+                      .where(
+                        object_id: reply.activity_pub_object.id,
+                        object_type: "DiscourseActivityPubObject",
+                        ap_type: "Update",
+                      )
+                      .exists?,
+                  ).to eq(true)
+                end
+
+                it "doesn't create multiple unpublished activities" do
+                  perform_update
+                  expect(
+                    reply
+                      .activity_pub_actor
+                      .activities
+                      .where(
+                        object_id: reply.activity_pub_object.id,
+                        object_type: "DiscourseActivityPubObject",
+                        ap_type: "Update",
+                      )
+                      .size,
+                  ).to eq(1)
+                end
+
+                it "creates multiple published activities" do
+                  perform_update
+
+                  attrs = {
+                    object_id: reply.activity_pub_object.id,
+                    object_type: "DiscourseActivityPubObject",
+                    ap_type: "Update",
+                  }
                   reply
                     .activity_pub_actor
                     .activities
-                    .where(
-                      object_id: reply.activity_pub_object.id,
-                      object_type: "DiscourseActivityPubObject",
-                      ap_type: "Update",
-                    )
-                    .size,
-                ).to eq(1)
-              end
+                    .where(attrs)
+                    .update_all(published_at: Time.now)
 
-              it "creates multiple published activities" do
-                perform_update
-
-                attrs = {
-                  object_id: reply.activity_pub_object.id,
-                  object_type: "DiscourseActivityPubObject",
-                  ap_type: "Update",
-                }
-                reply.activity_pub_actor.activities.where(attrs).update_all(published_at: Time.now)
+                  perform_update
 
-                perform_update
+                  expect(reply.activity_pub_actor.activities.where(attrs).size).to eq(2)
+                end
 
-                expect(reply.activity_pub_actor.activities.where(attrs).size).to eq(2)
-              end
+                context "when the category has followers" do
+                  let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                  let!(:follow1) do
+                    Fabricate(
+                      :discourse_activity_pub_follow,
+                      follower: follower1,
+                      followed: category.activity_pub_actor,
+                    )
+                  end
 
-              it "sends the activity as the post actor for delivery without delay" do
-                expect_delivery(actor: reply.activity_pub_actor, object_type: "Update")
-                perform_update
+                  it "sends the activity as the reply actor to the category followers for delivery without delay" do
+                    expect_delivery(
+                      actor: reply.activity_pub_actor,
+                      object_type: "Update",
+                      recipient_ids: [follower1.id],
+                    )
+                    perform_update
+                  end
+                end
               end
             end
           end
@@ -1931,12 +2069,7 @@ def perform_delete
               reply.perform_activity_pub_activity(:delete)
             end
 
-            context "while in pre publication period" do
-              it "does not create an object" do
-                perform_delete
-                expect(DiscourseActivityPubObject.exists?(model_id: reply.id)).to eq(false)
-              end
-
+            context "when the topic is not published" do
               it "does not create an activity" do
                 perform_delete
                 expect(reply.activity_pub_actor.activities.where(ap_type: "Delete").exists?).to eq(
@@ -1976,40 +2109,62 @@ def perform_delete
               end
             end
 
-            context "after publication" do
+            context "when the topic is published" do
               before do
                 post.custom_fields["activity_pub_published_at"] = Time.now
                 post.save_custom_fields(true)
-                reply.custom_fields["activity_pub_published_at"] = Time.now
-                reply.save_custom_fields(true)
               end
 
-              it "creates the right activity" do
-                perform_delete
-                expect(reply.activity_pub_actor.activities.where(ap_type: "Delete").exists?).to eq(
-                  true,
-                )
-              end
+              context "when the reply is published" do
+                before do
+                  reply.custom_fields["activity_pub_published_at"] = Time.now
+                  reply.save_custom_fields(true)
+                end
 
-              it "does not destroy associated objects" do
-                perform_delete
-                expect(DiscourseActivityPubObject.exists?(id: note.id)).to eq(true)
-              end
+                it "creates the right activity" do
+                  perform_delete
+                  expect(
+                    reply.activity_pub_actor.activities.where(ap_type: "Delete").exists?,
+                  ).to eq(true)
+                end
 
-              it "does not destroy associated activities" do
-                perform_delete
-                expect(DiscourseActivityPubActivity.exists?(id: create.id)).to eq(true)
-              end
+                it "does not destroy associated objects" do
+                  perform_delete
+                  expect(DiscourseActivityPubObject.exists?(id: note.id)).to eq(true)
+                end
 
-              it "sends the activity as the post actor for delivery without delay" do
-                expect_delivery(actor: reply.activity_pub_actor, object_type: "Delete")
-                perform_delete
+                it "does not destroy associated activities" do
+                  perform_delete
+                  expect(DiscourseActivityPubActivity.exists?(id: create.id)).to eq(true)
+                end
+
+                context "when the category has followers" do
+                  let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                  let!(:follow1) do
+                    Fabricate(
+                      :discourse_activity_pub_follow,
+                      follower: follower1,
+                      followed: category.activity_pub_actor,
+                    )
+                  end
+
+                  it "sends the activity as the post actor to the category followers for delivery without delay" do
+                    expect_delivery(
+                      actor: reply.activity_pub_actor,
+                      object_type: "Delete",
+                      recipient_ids: [follower1.id],
+                    )
+                    perform_delete
+                  end
+                end
               end
             end
           end
 
           context "with no reply_to_post_number" do
             before do
+              post.custom_fields["activity_pub_published_at"] = Time.now
+              post.save_custom_fields(true)
               reply.reply_to_post_number = nil
               reply.save!
               reply.perform_activity_pub_activity(:create)
@@ -2034,7 +2189,7 @@ def perform_delete
 
       context "without a topic collection" do
         it "does not perform the activity" do
-          expect(post.perform_activity_pub_activity(:create)).to eq(nil)
+          expect(post.perform_activity_pub_activity(:create)).to eq(false)
           expect(DiscourseActivityPubActivity.exists?(ap_type: "Create")).to eq(false)
         end
       end
@@ -2093,13 +2248,24 @@ def perform_create
               )
             end
 
-            it "sends activity as the topic actor for delayed delivery" do
-              expect_delivery(
-                actor: topic.activity_pub_actor,
-                object_type: "Create",
-                delay: SiteSetting.activity_pub_delivery_delay_minutes.to_i,
-              )
-              perform_create
+            context "with followers" do
+              let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+              let!(:follow1) do
+                Fabricate(
+                  :discourse_activity_pub_follow,
+                  follower: follower1,
+                  followed: tag.activity_pub_actor,
+                )
+              end
+
+              it "sends activity as the topic actor for delayed delivery" do
+                expect_delivery(
+                  actor: topic.activity_pub_actor,
+                  object_type: "Create",
+                  delay: SiteSetting.activity_pub_delivery_delay_minutes.to_i,
+                )
+                perform_create
+              end
             end
           end
 
@@ -2211,39 +2377,49 @@ def perform_update
                 ).to eq(true)
               end
 
-              it "doesn't create multiple unpublished activities" do
-                perform_update
-                expect(
-                  post
-                    .activity_pub_actor
-                    .activities
-                    .where(
-                      object_id: post.activity_pub_object.id,
-                      object_type: "DiscourseActivityPubObject",
-                      ap_type: "Update",
-                    )
-                    .size,
-                ).to eq(1)
+              context "when the tag has no followers" do
+                it "creates multiple published activities" do
+                  perform_update
+                  perform_update
+                  attrs = {
+                    object_id: post.activity_pub_object.id,
+                    object_type: "DiscourseActivityPubObject",
+                    ap_type: "Update",
+                  }
+                  expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
+                end
               end
 
-              it "creates multiple published activities" do
-                perform_update
-
-                attrs = {
-                  object_id: post.activity_pub_object.id,
-                  object_type: "DiscourseActivityPubObject",
-                  ap_type: "Update",
-                }
-                post.activity_pub_actor.activities.where(attrs).update_all(published_at: Time.now)
-
-                perform_update
+              context "when the tag has followers" do
+                let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                let!(:follow1) do
+                  Fabricate(
+                    :discourse_activity_pub_follow,
+                    follower: follower1,
+                    followed: tag.activity_pub_actor,
+                  )
+                end
 
-                expect(post.activity_pub_actor.activities.where(attrs).size).to eq(2)
-              end
+                it "does not create multiple unpublished activities" do
+                  perform_update
+                  perform_update
+                  attrs = {
+                    object_id: post.activity_pub_object.id,
+                    object_type: "DiscourseActivityPubObject",
+                    ap_type: "Update",
+                    published_at: nil,
+                  }
+                  expect(post.activity_pub_actor.activities.where(attrs).size).to eq(1)
+                end
 
-              it "sends the activity as the post actor for delivery without delay" do
-                expect_delivery(actor: post.activity_pub_actor, object_type: "Update")
-                perform_update
+                it "sends the activity as the post actor to the tag followers for delivery without delay" do
+                  expect_delivery(
+                    actor: post.activity_pub_actor,
+                    object_type: "Update",
+                    recipient_ids: [follower1.id],
+                  )
+                  perform_update
+                end
               end
 
               context "when the acting user is different from the post user" do
@@ -2301,36 +2477,67 @@ def perform_create
               reply.reload
             end
 
-            it "creates the right object" do
-              perform_create
-              expect(reply.activity_pub_object&.content).to eq(reply.activity_pub_content)
-              expect(reply.activity_pub_object&.reply_to_id).to eq(post_note.ap_id)
-              expect(reply.activity_pub_object&.collection_id).to eq(topic.activity_pub_object.id)
-            end
+            context "when the topic is not published" do
+              it "does not create an activity" do
+                expect { perform_create }.not_to change { DiscourseActivityPubActivity.count }
+              end
 
-            it "creates the right activity" do
-              perform_create
-              expect(
-                reply
-                  .activity_pub_actor
-                  .activities
-                  .where(
-                    object_id: reply.activity_pub_object.id,
-                    object_type: "DiscourseActivityPubObject",
-                    ap_type: "Create",
-                  )
-                  .exists?,
-              ).to eq(true)
+              it "does not send anything for delivery" do
+                expect_no_delivery
+                perform_create
+              end
             end
 
-            context "while not published" do
-              it "enqueues the activity for delivery" do
-                expect_delivery(actor: topic.activity_pub_actor, object_type: "Create")
+            context "when the topic is scheduled to be published" do
+              before do
+                post.custom_fields["activity_pub_scheduled_at"] = Time.now
+                post.save_custom_fields(true)
+              end
+
+              it "creates the right object" do
                 perform_create
+                expect(reply.activity_pub_object&.content).to eq(reply.activity_pub_content)
+                expect(reply.activity_pub_object&.reply_to_id).to eq(post_note.ap_id)
+                expect(reply.activity_pub_object&.collection_id).to eq(topic.activity_pub_object.id)
+              end
+
+              it "creates the right activity" do
+                perform_create
+                expect(
+                  reply
+                    .activity_pub_actor
+                    .activities
+                    .where(
+                      object_id: reply.activity_pub_object.id,
+                      object_type: "DiscourseActivityPubObject",
+                      ap_type: "Create",
+                    )
+                    .exists?,
+                ).to eq(true)
+              end
+
+              context "when the tag has followers" do
+                let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                let!(:follow1) do
+                  Fabricate(
+                    :discourse_activity_pub_follow,
+                    follower: follower1,
+                    followed: tag.activity_pub_actor,
+                  )
+                end
+
+                it "sends the activity as the reply actor to the tag followers for delivery without delay" do
+                  expect_delivery(
+                    actor: reply.activity_pub_actor,
+                    object_type: "Create",
+                    recipient_ids: [follower1.id],
+                  )
+                  perform_create
+                end
               end
             end
 
-            context "after topic publication" do
+            context "when the topic is published" do
               before do
                 post.custom_fields["activity_pub_published_at"] = Time.now
                 post.save_custom_fields(true)
@@ -2341,7 +2548,7 @@ def perform_create
 
                 it "sends to remote contributors for delivery without delay" do
                   expect_delivery(
-                    actor: topic.activity_pub_actor,
+                    actor: reply.activity_pub_actor,
                     object_type: "Create",
                     recipient_ids: [post.activity_pub_actor.id],
                   )
@@ -2360,7 +2567,7 @@ def perform_create
 
                   it "sends to followers and remote contributors for delivery without delay" do
                     expect_delivery(
-                      actor: topic.activity_pub_actor,
+                      actor: reply.activity_pub_actor,
                       object_type: "Create",
                       recipient_ids: [follower1.id] + [post.activity_pub_actor.id],
                     )
@@ -2380,7 +2587,7 @@ def perform_update
               reply.perform_activity_pub_activity(:update)
             end
 
-            context "while not published" do
+            context "when the topic is not published" do
               it "updates the Note content" do
                 perform_update
                 expect(note.reload.content).to eq("Updated content")
@@ -2399,67 +2606,82 @@ def perform_update
               end
             end
 
-            context "after publication" do
+            context "when the topic is published" do
               before do
                 post.custom_fields["activity_pub_published_at"] = Time.now
                 post.save_custom_fields(true)
-                reply.custom_fields["activity_pub_published_at"] = Time.now
-                reply.save_custom_fields(true)
               end
 
-              it "updates the Note content" do
-                perform_update
-                expect(note.reload.content).to eq("Updated content")
-              end
+              context "when the reply is published" do
+                before do
+                  reply.custom_fields["activity_pub_published_at"] = Time.now
+                  reply.save_custom_fields(true)
+                end
 
-              it "creates an Update Activity" do
-                perform_update
-                expect(
-                  reply
-                    .activity_pub_actor
-                    .activities
-                    .where(
+                it "updates the Note content" do
+                  perform_update
+                  expect(note.reload.content).to eq("Updated content")
+                end
+
+                it "creates an Update Activity" do
+                  perform_update
+                  expect(
+                    reply
+                      .activity_pub_actor
+                      .activities
+                      .where(
+                        object_id: reply.activity_pub_object.id,
+                        object_type: "DiscourseActivityPubObject",
+                        ap_type: "Update",
+                      )
+                      .exists?,
+                  ).to eq(true)
+                end
+
+                context "with no followers" do
+                  it "creates multiple published activities" do
+                    perform_update
+                    perform_update
+                    attrs = {
                       object_id: reply.activity_pub_object.id,
                       object_type: "DiscourseActivityPubObject",
                       ap_type: "Update",
+                    }
+                    expect(reply.activity_pub_actor.activities.where(attrs).size).to eq(2)
+                  end
+                end
+
+                context "with followers" do
+                  let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                  let!(:follow1) do
+                    Fabricate(
+                      :discourse_activity_pub_follow,
+                      follower: follower1,
+                      followed: tag.activity_pub_actor,
                     )
-                    .exists?,
-                ).to eq(true)
-              end
+                  end
 
-              it "doesn't create multiple unpublished activities" do
-                perform_update
-                expect(
-                  reply
-                    .activity_pub_actor
-                    .activities
-                    .where(
+                  it "does not create multiple unpublished activities" do
+                    perform_update
+                    perform_update
+                    attrs = {
                       object_id: reply.activity_pub_object.id,
                       object_type: "DiscourseActivityPubObject",
                       ap_type: "Update",
-                    )
-                    .size,
-                ).to eq(1)
-              end
-
-              it "creates multiple published activities" do
-                perform_update
-
-                attrs = {
-                  object_id: reply.activity_pub_object.id,
-                  object_type: "DiscourseActivityPubObject",
-                  ap_type: "Update",
-                }
-                reply.activity_pub_actor.activities.where(attrs).update_all(published_at: Time.now)
-
-                perform_update
-
-                expect(reply.activity_pub_actor.activities.where(attrs).size).to eq(2)
-              end
+                      published_at: nil,
+                    }
+                    expect(reply.activity_pub_actor.activities.where(attrs).size).to eq(1)
+                  end
 
-              it "sends the activity as the post actor for delivery without delay" do
-                expect_delivery(actor: reply.activity_pub_actor, object_type: "Update")
-                perform_update
+                  it "sends the activity as the reply actor to the tag followers for delivery without delay" do
+                    expect_delivery(
+                      actor: reply.activity_pub_actor,
+                      object_type: "Update",
+                      recipient_ids: [follower1.id],
+                    )
+                    perform_update
+                  end
+                end
               end
             end
           end
@@ -2543,15 +2765,32 @@ def perform_delete
                 expect(DiscourseActivityPubActivity.exists?(id: create.id)).to eq(true)
               end
 
-              it "sends the activity as the post actor for delivery without delay" do
-                expect_delivery(actor: reply.activity_pub_actor, object_type: "Delete")
-                perform_delete
+              context "when the tag has followers" do
+                let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                let!(:follow1) do
+                  Fabricate(
+                    :discourse_activity_pub_follow,
+                    follower: follower1,
+                    followed: tag.activity_pub_actor,
+                  )
+                end
+
+                it "sends the activity as the reply actor to the tag followers for delivery without delay" do
+                  expect_delivery(
+                    actor: reply.activity_pub_actor,
+                    object_type: "Delete",
+                    recipient_ids: [follower1.id],
+                  )
+                  perform_delete
+                end
               end
             end
           end
 
           context "with no reply_to_post_number" do
             before do
+              post.custom_fields["activity_pub_published_at"] = Time.now
+              post.save_custom_fields(true)
               reply.reply_to_post_number = nil
               reply.save!
               reply.perform_activity_pub_activity(:create)
diff --git a/spec/models/topic_spec.rb b/spec/models/topic_spec.rb
index 3e899f51..0b419ea3 100644
--- a/spec/models/topic_spec.rb
+++ b/spec/models/topic_spec.rb
@@ -13,23 +13,9 @@
   fab!(:topic1) { Fabricate(:topic, user: user1, category: category1, created_at: 4.hours.ago) }
   fab!(:topic2) { Fabricate(:topic, user: user2, category: category1, created_at: 2.days.ago) }
   fab!(:topic3) { Fabricate(:topic, user: user1, category: category2, created_at: 2.days.ago) }
-  let!(:collection1) { Fabricate(:discourse_activity_pub_ordered_collection, model: topic1) }
-  let!(:collection2) { Fabricate(:discourse_activity_pub_ordered_collection, model: topic2) }
   let!(:post1) { Fabricate(:post, topic: topic1) }
   let!(:post2) { Fabricate(:post, topic: topic1) }
   let!(:post3) { Fabricate(:post, topic: topic1) }
-  let!(:note1) do
-    Fabricate(:discourse_activity_pub_object_note, model: post1, collection_id: collection1.id)
-  end
-  let!(:note2) do
-    Fabricate(:discourse_activity_pub_object_note, model: post2, collection_id: collection1.id)
-  end
-  let!(:note3) do
-    Fabricate(:discourse_activity_pub_object_note, model: post3, collection_id: collection1.id)
-  end
-  let!(:activity1) { Fabricate(:discourse_activity_pub_activity_create, object: note1) }
-  let!(:activity2) { Fabricate(:discourse_activity_pub_activity_create, object: note2) }
-  let!(:activity3) { Fabricate(:discourse_activity_pub_activity_create, object: note3) }
 
   describe "#activity_pub_enabled" do
     context "with activity pub plugin enabled" do
@@ -42,126 +28,372 @@
   end
 
   describe "move_posts" do
-    before { toggle_activity_pub(category1, publication_type: "full_topic") }
-
-    context "with an ap full_topic topic to a new ap full_topic topic" do
-      before do
-        @new_topic =
-          topic1.move_posts(
-            user1,
-            [post1.id, post3.id],
-            title: "New topic in ap category",
-            category_id: category1.id,
+    context "with posts in an ap category" do
+      let!(:note1) { Fabricate(:discourse_activity_pub_object_note, model: post1) }
+      let!(:activity1) { Fabricate(:discourse_activity_pub_activity_create, object: note1) }
+
+      context "with full_topic enabled" do
+        let!(:collection1) { Fabricate(:discourse_activity_pub_ordered_collection, model: topic1) }
+        let!(:collection2) { Fabricate(:discourse_activity_pub_ordered_collection, model: topic2) }
+        let!(:note2) do
+          Fabricate(
+            :discourse_activity_pub_object_note,
+            model: post2,
+            collection_id: collection1.id,
           )
-      end
+        end
+        let!(:note3) do
+          Fabricate(
+            :discourse_activity_pub_object_note,
+            model: post3,
+            collection_id: collection1.id,
+          )
+        end
+        let!(:activity2) { Fabricate(:discourse_activity_pub_activity_create, object: note2) }
+        let!(:activity3) { Fabricate(:discourse_activity_pub_activity_create, object: note3) }
 
-      it "moves the posts" do
-        expect([topic1.id, topic2.id, topic3.id]).to_not include(@new_topic.id)
-        expect(@new_topic.first_post.raw).to eq(post1.raw)
-        expect(post2.reload.topic.id).to eq(topic1.id)
-      end
+        before do
+          toggle_activity_pub(category1, publication_type: "full_topic")
+          note1.collection_id = collection1.id
+          note1.save!
+        end
 
-      it "creates a collection for the new topic" do
-        expect(@new_topic.activity_pub_object&.ap&.collection?).to eq(true)
-      end
+        context "when moved to a new full_topic topic" do
+          before do
+            @new_topic =
+              topic1.move_posts(
+                user1,
+                [post1.id, post3.id],
+                title: "New topic in ap category",
+                category_id: category1.id,
+              )
+          end
 
-      it "does not create new objects or activities" do
-        expect(DiscourseActivityPubObject.all.size).to eq(3)
-        expect(DiscourseActivityPubActivity.all.size).to eq(3)
-      end
+          it "moves the posts" do
+            expect([topic1.id, topic2.id, topic3.id]).to_not include(@new_topic.id)
+            expect(@new_topic.first_post.raw).to eq(post1.raw)
+            expect(post2.reload.topic.id).to eq(topic1.id)
+          end
 
-      it "updates the note references" do
-        expect(note1.reload.model_id).to eq(@new_topic.first_post.id)
-        expect(note1.collection_id).to eq(@new_topic.activity_pub_object.id)
-        expect(note2.reload.collection_id).to eq(collection1.id)
-      end
-    end
+          it "creates a collection for the new topic" do
+            expect(@new_topic.activity_pub_object&.ap&.collection?).to eq(true)
+          end
 
-    context "with an ap full_topic topic to an existing ap full_topic topic" do
-      before do
-        topic1.move_posts(user1, [post1.id, post3.id], destination_topic_id: topic2.id)
-        @first_post = topic2.first_post
-      end
+          it "does not create new objects or activities" do
+            expect(DiscourseActivityPubObject.all.size).to eq(3)
+            expect(DiscourseActivityPubActivity.all.size).to eq(3)
+          end
 
-      it "moves the posts" do
-        expect(topic2.posts.size).to eq(2)
-        expect(@first_post.raw).to eq(post1.raw)
-        expect(post2.reload.topic_id).to eq(topic1.id)
-        expect(post3.reload.topic_id).to eq(topic2.id)
-      end
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@new_topic.first_post.id)
+            expect(note1.collection_id).to eq(@new_topic.activity_pub_object.id)
+            expect(note2.reload.collection_id).to eq(collection1.id)
+          end
+        end
 
-      it "does not create new collections, objects or activities" do
-        expect(DiscourseActivityPubCollection.all.size).to eq(2)
-        expect(DiscourseActivityPubObject.all.size).to eq(3)
-        expect(DiscourseActivityPubActivity.all.size).to eq(3)
-      end
+        context "when moved to an existing full_topic topic" do
+          before do
+            topic1.move_posts(user1, [post1.id, post3.id], destination_topic_id: topic2.id)
+            @first_post = topic2.first_post
+          end
 
-      it "updates the note references" do
-        expect(note1.reload.model_id).to eq(@first_post.reload.id)
-        expect(note1.collection_id).to eq(collection2.id)
-        expect(note2.reload.collection_id).to eq(collection1.id)
-      end
-    end
+          it "moves the posts" do
+            expect(topic2.posts.size).to eq(2)
+            expect(@first_post.raw).to eq(post1.raw)
+            expect(post2.reload.topic_id).to eq(topic1.id)
+            expect(post3.reload.topic_id).to eq(topic2.id)
+          end
 
-    context "with an ap full_topic topic to a new non ap topic" do
-      before do
-        topic1.move_posts(
-          user1,
-          [post1.id, post3.id],
-          title: "New topic in another category",
-          category_id: category2.id,
-        )
-        @new_topic = post3.reload.topic
-        @first_post = @new_topic.first_post
-      end
+          it "does not create new collections, objects or activities" do
+            expect(DiscourseActivityPubCollection.all.size).to eq(2)
+            expect(DiscourseActivityPubObject.all.size).to eq(3)
+            expect(DiscourseActivityPubActivity.all.size).to eq(3)
+          end
 
-      it "moves the posts" do
-        expect(@new_topic.category_id).to eq(category2.id)
-        expect(post2.reload.topic.category_id).to eq(category1.id)
-        expect(post3.reload.topic.category_id).to eq(category2.id)
-      end
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@first_post.reload.id)
+            expect(note1.collection_id).to eq(collection2.id)
+            expect(note2.reload.collection_id).to eq(collection1.id)
+          end
+        end
+
+        context "when moved to a new non-ap topic" do
+          before do
+            topic1.move_posts(
+              user1,
+              [post1.id, post3.id],
+              title: "New topic in another category",
+              category_id: category2.id,
+            )
+            @new_topic = post3.reload.topic
+            @first_post = @new_topic.first_post
+          end
+
+          it "moves the posts" do
+            expect(@new_topic.category_id).to eq(category2.id)
+            expect(post2.reload.topic.category_id).to eq(category1.id)
+            expect(post3.reload.topic.category_id).to eq(category2.id)
+          end
+
+          it "does not create new collections, objects or activities" do
+            expect(DiscourseActivityPubCollection.all.size).to eq(2)
+            expect(DiscourseActivityPubObject.all.size).to eq(3)
+            expect(DiscourseActivityPubActivity.all.size).to eq(3)
+          end
 
-      it "does not create new collections, objects or activities" do
-        expect(DiscourseActivityPubCollection.all.size).to eq(2)
-        expect(DiscourseActivityPubObject.all.size).to eq(3)
-        expect(DiscourseActivityPubActivity.all.size).to eq(3)
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@first_post.id)
+            expect(note1.reload.collection_id).to eq(nil)
+            expect(note2.reload.collection_id).to eq(collection1.id)
+          end
+        end
+
+        context "when moved to an existing non-ap topic" do
+          before do
+            topic1.move_posts(
+              user1,
+              [post1.id, post3.id],
+              destination_topic_id: topic3.id,
+              category_id: category2.id,
+            )
+            @first_post = topic3.first_post
+          end
+
+          it "moves the posts" do
+            expect(topic3.posts.size).to eq(2)
+            expect(@first_post.raw).to eq(post1.raw)
+            expect(post2.reload.topic_id).to eq(topic1.id)
+            expect(post3.reload.topic_id).to eq(topic3.id)
+          end
+
+          it "does not create new collections, objects or activities" do
+            expect(DiscourseActivityPubCollection.all.size).to eq(2)
+            expect(DiscourseActivityPubObject.all.size).to eq(3)
+            expect(DiscourseActivityPubActivity.all.size).to eq(3)
+          end
+
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@first_post.id)
+            expect(note1.reload.collection_id).to eq(nil)
+            expect(note2.reload.collection_id).to eq(collection1.id)
+          end
+        end
       end
 
-      it "updates the note references" do
-        expect(note1.reload.model_id).to eq(@first_post.id)
-        expect(note1.reload.collection_id).to eq(nil)
-        expect(note2.reload.collection_id).to eq(collection1.id)
+      context "with first_post enabled" do
+        before { toggle_activity_pub(category1, publication_type: "first_post") }
+
+        context "when moved to a new first_post topic" do
+          before do
+            @new_topic =
+              topic1.move_posts(
+                user1,
+                [post1.id, post3.id],
+                title: "New topic in ap category",
+                category_id: category1.id,
+              )
+          end
+
+          it "moves the posts" do
+            expect([topic1.id, topic2.id, topic3.id]).to_not include(@new_topic.id)
+            expect(@new_topic.first_post.raw).to eq(post1.raw)
+            expect(post2.reload.topic.id).to eq(topic1.id)
+          end
+
+          it "does not create a collection for the new topic" do
+            expect(@new_topic.activity_pub_object).to eq(nil)
+          end
+
+          it "does not create new objects or activities" do
+            expect(DiscourseActivityPubObject.all.size).to eq(1)
+            expect(DiscourseActivityPubActivity.all.size).to eq(1)
+          end
+
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@new_topic.first_post.id)
+          end
+        end
+
+        context "when moved to an existing first_post topic" do
+          before do
+            topic1.move_posts(user1, [post1.id, post3.id], destination_topic_id: topic2.id)
+            @first_post = topic2.first_post
+          end
+
+          it "moves the posts" do
+            expect(topic2.posts.size).to eq(2)
+            expect(@first_post.raw).to eq(post1.raw)
+            expect(post2.reload.topic_id).to eq(topic1.id)
+            expect(post3.reload.topic_id).to eq(topic2.id)
+          end
+
+          it "does not create new objects or activities" do
+            expect(DiscourseActivityPubObject.all.size).to eq(1)
+            expect(DiscourseActivityPubActivity.all.size).to eq(1)
+          end
+
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@first_post.reload.id)
+          end
+        end
+
+        context "when moved to a new non-ap topic" do
+          before do
+            topic1.move_posts(
+              user1,
+              [post1.id, post3.id],
+              title: "New topic in another category",
+              category_id: category2.id,
+            )
+            @new_topic = post3.reload.topic
+            @first_post = @new_topic.first_post
+          end
+
+          it "moves the posts" do
+            expect(@new_topic.category_id).to eq(category2.id)
+            expect(post2.reload.topic.category_id).to eq(category1.id)
+            expect(post3.reload.topic.category_id).to eq(category2.id)
+          end
+
+          it "does not create new objects or activities" do
+            expect(DiscourseActivityPubObject.all.size).to eq(1)
+            expect(DiscourseActivityPubActivity.all.size).to eq(1)
+          end
+
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@first_post.id)
+          end
+        end
+
+        context "when moved to an existing non-ap topic" do
+          before do
+            topic1.move_posts(
+              user1,
+              [post1.id, post3.id],
+              destination_topic_id: topic3.id,
+              category_id: category2.id,
+            )
+            @first_post = topic3.first_post
+          end
+
+          it "moves the posts" do
+            expect(topic3.posts.size).to eq(2)
+            expect(@first_post.raw).to eq(post1.raw)
+            expect(post2.reload.topic_id).to eq(topic1.id)
+            expect(post3.reload.topic_id).to eq(topic3.id)
+          end
+
+          it "does not create new objects or activities" do
+            expect(DiscourseActivityPubObject.all.size).to eq(1)
+            expect(DiscourseActivityPubActivity.all.size).to eq(1)
+          end
+
+          it "updates the note references" do
+            expect(note1.reload.model_id).to eq(@first_post.id)
+          end
+        end
       end
     end
 
-    context "with an ap full_topic topic to an existing non ap topic" do
-      before do
-        topic1.move_posts(
-          user1,
-          [post1.id, post3.id],
-          destination_topic_id: topic3.id,
-          category_id: category2.id,
-        )
-        @first_post = topic3.first_post
+    context "with posts in a non ap category" do
+      context "when moved to a new full_topic topic" do
+        before do
+          toggle_activity_pub(category2, publication_type: "full_topic")
+          @new_topic =
+            topic1.move_posts(
+              user1,
+              [post1.id, post3.id],
+              title: "New topic in ap category",
+              category_id: category2.id,
+            )
+        end
+
+        it "moves the posts" do
+          expect([topic1.id, topic2.id, topic3.id]).to_not include(@new_topic.id)
+          expect(@new_topic.first_post.raw).to eq(post1.raw)
+          expect(post2.reload.topic.id).to eq(topic1.id)
+        end
+
+        it "creates a collection for the new topic" do
+          expect(@new_topic.activity_pub_object&.ap&.collection?).to eq(true)
+        end
+
+        it "does not create new objects or activities" do
+          expect(DiscourseActivityPubObject.all.size).to eq(0)
+          expect(DiscourseActivityPubActivity.all.size).to eq(0)
+        end
       end
 
-      it "moves the posts" do
-        expect(topic3.posts.size).to eq(2)
-        expect(@first_post.raw).to eq(post1.raw)
-        expect(post2.reload.topic_id).to eq(topic1.id)
-        expect(post3.reload.topic_id).to eq(topic3.id)
+      context "when moved to an existing full_topic topic" do
+        let!(:collection1) { Fabricate(:discourse_activity_pub_ordered_collection, model: topic3) }
+
+        before do
+          toggle_activity_pub(category2, publication_type: "full_topic")
+          topic1.move_posts(user1, [post1.id, post3.id], destination_topic_id: topic3.id)
+          @first_post = topic3.first_post
+        end
+
+        it "moves the posts" do
+          expect(topic3.posts.size).to eq(2)
+          expect(@first_post.raw).to eq(post1.raw)
+          expect(post2.reload.topic_id).to eq(topic1.id)
+          expect(post3.reload.topic_id).to eq(topic3.id)
+        end
+
+        it "does not create new collections, objects or activities" do
+          expect(DiscourseActivityPubCollection.all.size).to eq(1)
+          expect(DiscourseActivityPubObject.all.size).to eq(0)
+          expect(DiscourseActivityPubActivity.all.size).to eq(0)
+        end
       end
 
-      it "does not create new collections, objects or activities" do
-        expect(DiscourseActivityPubCollection.all.size).to eq(2)
-        expect(DiscourseActivityPubObject.all.size).to eq(3)
-        expect(DiscourseActivityPubActivity.all.size).to eq(3)
+      context "when moved to a new first_post topic" do
+        before do
+          toggle_activity_pub(category2, publication_type: "first_post")
+          @new_topic =
+            topic1.move_posts(
+              user1,
+              [post1.id, post3.id],
+              title: "New topic in ap category",
+              category_id: category2.id,
+            )
+        end
+
+        it "moves the posts" do
+          expect([topic1.id, topic2.id, topic3.id]).to_not include(@new_topic.id)
+          expect(@new_topic.first_post.raw).to eq(post1.raw)
+          expect(post2.reload.topic.id).to eq(topic1.id)
+        end
+
+        it "does not create a collection for the new topic" do
+          expect(@new_topic.activity_pub_object).to eq(nil)
+        end
+
+        it "does not create new objects or activities" do
+          expect(DiscourseActivityPubObject.all.size).to eq(0)
+          expect(DiscourseActivityPubActivity.all.size).to eq(0)
+        end
       end
 
-      it "updates the note references" do
-        expect(note1.reload.model_id).to eq(@first_post.id)
-        expect(note1.reload.collection_id).to eq(nil)
-        expect(note2.reload.collection_id).to eq(collection1.id)
+      context "when moved to an existing first_post topic" do
+        before do
+          toggle_activity_pub(category2, publication_type: "first_post")
+          topic1.move_posts(user1, [post1.id, post3.id], destination_topic_id: topic3.id)
+          @first_post = topic3.first_post
+        end
+
+        it "moves the posts" do
+          expect(topic3.posts.size).to eq(2)
+          expect(@first_post.raw).to eq(post1.raw)
+          expect(post2.reload.topic_id).to eq(topic1.id)
+          expect(post3.reload.topic_id).to eq(topic3.id)
+        end
+
+        it "does not create new collections, objects or activities" do
+          expect(DiscourseActivityPubCollection.all.size).to eq(0)
+          expect(DiscourseActivityPubObject.all.size).to eq(0)
+          expect(DiscourseActivityPubActivity.all.size).to eq(0)
+        end
       end
     end
   end
diff --git a/spec/requests/discourse_activity_pub/post_controller_spec.rb b/spec/requests/discourse_activity_pub/post_controller_spec.rb
index d39a773e..e3da0e97 100644
--- a/spec/requests/discourse_activity_pub/post_controller_spec.rb
+++ b/spec/requests/discourse_activity_pub/post_controller_spec.rb
@@ -70,24 +70,38 @@ def build_error(key)
               end
 
               context "when the post is not scheduled or published" do
-                it "schedules the post" do
-                  Post.any_instance.expects(:activity_pub_schedule!)
-                  post "/ap/post/schedule/#{post1.id}"
-                end
+                context "with followers" do
+                  let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+                  let!(:follow1) do
+                    Fabricate(
+                      :discourse_activity_pub_follow,
+                      follower: follower1,
+                      followed: category.activity_pub_actor,
+                    )
+                  end
 
-                context "when scheduling succeeds" do
-                  it "returns a success response" do
-                    Post.any_instance.expects(:activity_pub_schedule!).returns(true)
-                    post "/ap/post/schedule/#{post1.id}"
-                    expect(response).to be_successful
+                  context "when scheduling succeeds" do
+                    it "returns a success response" do
+                      Post.any_instance.expects(:activity_pub_schedule!).returns(true)
+                      post "/ap/post/schedule/#{post1.id}"
+                      expect(response).to be_successful
+                    end
+                  end
+
+                  context "when scheduling fails" do
+                    it "returns a failed response" do
+                      Post.any_instance.expects(:activity_pub_schedule!).returns(false)
+                      post "/ap/post/schedule/#{post1.id}"
+                      expect(response).not_to be_successful
+                    end
                   end
                 end
 
-                context "when scheduling fails" do
-                  it "returns a failed response" do
-                    Post.any_instance.expects(:activity_pub_schedule!).returns(false)
+                context "without followers" do
+                  it "returns a can't schedule post error" do
                     post "/ap/post/schedule/#{post1.id}"
-                    expect(response).not_to be_successful
+                    expect(response.status).to eq(422)
+                    expect(response.parsed_body).to eq(build_error("cant_schedule_post"))
                   end
                 end
               end
@@ -261,4 +275,237 @@ def build_error(key)
       end
     end
   end
+
+  describe "#deliver" do
+    context "without activity pub enabled" do
+      before { SiteSetting.activity_pub_enabled = false }
+
+      it "returns a not enabled error" do
+        post "/ap/post/deliver/#{post1.id}"
+        expect_not_enabled(response)
+      end
+    end
+
+    context "with activity pub enabled" do
+      before do
+        SiteSetting.activity_pub_enabled = true
+        toggle_activity_pub(category, publication_type: "full_topic")
+      end
+
+      context "with signed in staff" do
+        let!(:user) { Fabricate(:user, moderator: true) }
+
+        before { sign_in(user) }
+
+        context "without a valid post id" do
+          it "returns a post not found error" do
+            post "/ap/post/deliver/#{post1.id + 1}"
+            expect(response.status).to eq(400)
+            expect(response.parsed_body).to eq(build_error("post_not_found"))
+          end
+        end
+
+        context "with the first post" do
+          context "when the post is published" do
+            let!(:note) { Fabricate(:discourse_activity_pub_object_note, model: post1) }
+            let!(:create) { Fabricate(:discourse_activity_pub_activity_create, object: note) }
+
+            before do
+              post1.custom_fields["activity_pub_published_at"] = Time.now
+              post1.save_custom_fields(true)
+            end
+
+            context "with followers" do
+              let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+              let!(:follow1) do
+                Fabricate(
+                  :discourse_activity_pub_follow,
+                  follower: follower1,
+                  followed: category.activity_pub_actor,
+                )
+              end
+
+              it "schedules the create activity for delivery immediately" do
+                expect_delivery(actor: topic.activity_pub_actor, object_type: "Create", delay: nil)
+                post "/ap/post/deliver/#{post1.id}"
+                expect(response.status).to eq(200)
+              end
+            end
+
+            context "without followers" do
+              it "returns a can't deliver post error" do
+                post "/ap/post/deliver/#{post1.id}"
+                expect(response.status).to eq(422)
+                expect(response.parsed_body).to eq(build_error("cant_deliver_post"))
+              end
+            end
+          end
+
+          context "when the post is not published" do
+            it "returns a can't deliver post error" do
+              post "/ap/post/deliver/#{post1.id}"
+              expect(response.status).to eq(422)
+              expect(response.parsed_body).to eq(build_error("cant_deliver_post"))
+            end
+          end
+        end
+
+        context "with a reply" do
+          let!(:post2) { Fabricate(:post, topic: topic) }
+
+          context "when the reply is published" do
+            let!(:note2) { Fabricate(:discourse_activity_pub_object_note, model: post2) }
+            let!(:create2) { Fabricate(:discourse_activity_pub_activity_create, object: note2) }
+
+            before do
+              post2.custom_fields["activity_pub_published_at"] = Time.now
+              post2.save_custom_fields(true)
+            end
+
+            context "with followers" do
+              let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+              let!(:follow1) do
+                Fabricate(
+                  :discourse_activity_pub_follow,
+                  follower: follower1,
+                  followed: category.activity_pub_actor,
+                )
+              end
+
+              context "when the first post is not published" do
+                it "returns a can't deliver post error" do
+                  post "/ap/post/deliver/#{post1.id}"
+                  expect(response.status).to eq(422)
+                  expect(response.parsed_body).to eq(build_error("cant_deliver_post"))
+                end
+              end
+
+              context "when the first post is published" do
+                let!(:note) { Fabricate(:discourse_activity_pub_object_note, model: post1) }
+                let!(:create) { Fabricate(:discourse_activity_pub_activity_create, object: note) }
+
+                before do
+                  post1.custom_fields["activity_pub_published_at"] = Time.now
+                  post1.save_custom_fields(true)
+                end
+
+                it "schedules the create activity for delivery immediately" do
+                  expect_delivery(
+                    actor: topic.activity_pub_actor,
+                    object_type: "Create",
+                    delay: nil,
+                  )
+                  post "/ap/post/deliver/#{post1.id}"
+                  expect(response.status).to eq(200)
+                end
+              end
+            end
+          end
+        end
+      end
+
+      context "without signed in staff" do
+        let!(:user) { Fabricate(:user) }
+
+        before { sign_in(user) }
+
+        it "returns an invalid access error" do
+          post "/ap/post/deliver/#{post1.id}"
+          expect(response.status).to eq(403)
+        end
+      end
+    end
+  end
+
+  describe "#publish" do
+    context "without activity pub enabled" do
+      before { SiteSetting.activity_pub_enabled = false }
+
+      it "returns a not enabled error" do
+        post "/ap/post/publish/#{post1.id}"
+        expect_not_enabled(response)
+      end
+    end
+
+    context "with activity pub enabled" do
+      before do
+        SiteSetting.activity_pub_enabled = true
+        toggle_activity_pub(category, publication_type: "full_topic")
+      end
+
+      context "with signed in staff" do
+        let!(:user) { Fabricate(:user, moderator: true) }
+
+        before { sign_in(user) }
+
+        context "without a valid post id" do
+          it "returns a post not found error" do
+            post "/ap/post/publish/#{post1.id + 1}"
+            expect(response.status).to eq(400)
+            expect(response.parsed_body).to eq(build_error("post_not_found"))
+          end
+        end
+
+        context "with a valid post id" do
+          context "when the post is published" do
+            before do
+              post1.custom_fields["activity_pub_published_at"] = Time.now
+              post1.save_custom_fields(true)
+            end
+
+            it "returns a can't publish post error" do
+              post "/ap/post/publish/#{post1.id}"
+              expect(response.status).to eq(422)
+              expect(response.parsed_body).to eq(build_error("cant_publish_post"))
+            end
+          end
+
+          context "when the post is not published" do
+            let!(:note) { Fabricate(:discourse_activity_pub_object_note, model: post1) }
+            let!(:create) { Fabricate(:discourse_activity_pub_activity_create, object: note) }
+
+            it "publishes the post" do
+              post "/ap/post/publish/#{post1.id}"
+              expect(response.status).to eq(200)
+              expect(post1.reload.activity_pub_published?).to eq(true)
+            end
+
+            context "with followers" do
+              let!(:follower1) { Fabricate(:discourse_activity_pub_actor_person) }
+              let!(:follow1) do
+                Fabricate(
+                  :discourse_activity_pub_follow,
+                  follower: follower1,
+                  followed: category.activity_pub_actor,
+                )
+              end
+
+              it "delivers the activity" do
+                expect_delivery(actor: topic.activity_pub_actor, object_type: "Create", delay: nil)
+                post "/ap/post/publish/#{post1.id}"
+                expect(response.status).to eq(200)
+              end
+
+              it "does not set secheduled_at" do
+                post "/ap/post/publish/#{post1.id}"
+                expect(response.status).to eq(200)
+                expect(post1.reload.activity_pub_scheduled_at).to eq(nil)
+              end
+            end
+          end
+        end
+      end
+
+      context "without signed in staff" do
+        let!(:user) { Fabricate(:user) }
+
+        before { sign_in(user) }
+
+        it "returns an invalid access error" do
+          post "/ap/post/publish/#{post1.id}"
+          expect(response.status).to eq(403)
+        end
+      end
+    end
+  end
 end
diff --git a/spec/requests/discourse_activity_pub/topic_controller_spec.rb b/spec/requests/discourse_activity_pub/topic_controller_spec.rb
new file mode 100644
index 00000000..8ec72f5e
--- /dev/null
+++ b/spec/requests/discourse_activity_pub/topic_controller_spec.rb
@@ -0,0 +1,94 @@
+# frozen_string_literal: true
+
+RSpec.describe DiscourseActivityPub::TopicController do
+  let!(:category) { Fabricate(:category) }
+  let!(:topic) { Fabricate(:topic, category: category) }
+  let!(:post1) { Fabricate(:post, topic: topic, post_number: 1) }
+  let!(:post2) { Fabricate(:post, topic: topic, post_number: 2) }
+
+  def build_error(key)
+    { "errors" => [I18n.t("discourse_activity_pub.topic.error.#{key}")] }
+  end
+
+  before { Jobs.run_immediately! }
+
+  describe "#publish" do
+    context "without activity pub enabled" do
+      before { SiteSetting.activity_pub_enabled = false }
+
+      it "returns a not enabled error" do
+        post "/ap/topic/publish/#{topic.id}"
+        expect_not_enabled(response)
+      end
+    end
+
+    context "with activity pub enabled" do
+      before { SiteSetting.activity_pub_enabled = true }
+
+      context "with signed in staff" do
+        let!(:user) { Fabricate(:user, moderator: true) }
+
+        before { sign_in(user) }
+
+        context "without a valid topic id" do
+          it "returns a topic not found error" do
+            post "/ap/topic/publish/#{topic.id + 1}"
+            expect(response.status).to eq(400)
+            expect(response.parsed_body).to eq(build_error("topic_not_found"))
+          end
+        end
+
+        context "with a valid topic id" do
+          context "with a first_post activity pub category" do
+            before { toggle_activity_pub(category, publication_type: "first_post") }
+
+            it "publishes the topic" do
+              post "/ap/topic/publish/#{topic.id}"
+              expect(response.status).to eq(422)
+              expect(response.parsed_body).to eq(build_error("cant_publish_topic"))
+            end
+          end
+
+          context "with a full_topic activity pub category" do
+            before { toggle_activity_pub(category, publication_type: "full_topic") }
+
+            it "publishes all the posts in the topic" do
+              post "/ap/topic/publish/#{topic.id}"
+              expect(response.status).to eq(200)
+              expect(post1.reload.activity_pub_published?).to eq(true)
+              expect(post2.reload.activity_pub_published?).to eq(true)
+            end
+
+            context "when first post is scheduled" do
+              before do
+                post1.custom_fields["activity_pub_scheduled_at"] = Time.now
+                post1.save_custom_fields(true)
+              end
+
+              it "returns a can't publish topic error" do
+                post "/ap/topic/publish/#{topic.id}"
+                expect(response.status).to eq(422)
+                expect(response.parsed_body).to eq(build_error("cant_publish_topic"))
+              end
+            end
+
+            context "when all posts are published" do
+              before do
+                post1.custom_fields["activity_pub_published_at"] = Time.now
+                post1.save_custom_fields(true)
+                post2.custom_fields["activity_pub_published_at"] = Time.now
+                post2.save_custom_fields(true)
+              end
+
+              it "returns a can't publish topic error" do
+                post "/ap/topic/publish/#{topic.id}"
+                expect(response.status).to eq(422)
+                expect(response.parsed_body).to eq(build_error("cant_publish_topic"))
+              end
+            end
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/test/javascripts/acceptance/activity-pub-composer-test.js b/test/javascripts/acceptance/activity-pub-composer-test.js
index 3e566d45..5a50981d 100644
--- a/test/javascripts/acceptance/activity-pub-composer-test.js
+++ b/test/javascripts/acceptance/activity-pub-composer-test.js
@@ -25,7 +25,7 @@ acceptance("Discourse Activity Pub | composer", function (needs) {
     await click("#create-topic");
 
     assert.notOk(
-      exists("#reply-control .activity-pub-status"),
+      exists("#reply-control .activity-pub-actor-status"),
       "the status label is not visible"
     );
   });
@@ -45,11 +45,13 @@ acceptance("Discourse Activity Pub | composer", function (needs) {
     await categoryChooser.selectRowByValue(2);
 
     assert.ok(
-      exists("#reply-control .activity-pub-status"),
+      exists("#reply-control .activity-pub-actor-status"),
       "the status label is visible"
     );
     assert.strictEqual(
-      query("#reply-control .activity-pub-status .label").innerText.trim(),
+      query(
+        "#reply-control .activity-pub-actor-status .label"
+      ).innerText.trim(),
       i18n("discourse_activity_pub.visibility.label.public"),
       "the status label has the right text"
     );
@@ -70,7 +72,7 @@ acceptance("Discourse Activity Pub | composer", function (needs) {
     await categoryChooser.selectRowByValue(2);
 
     assert.notOk(
-      exists("#reply-control .activity-pub-status"),
+      exists("#reply-control .activity-pub-actor-status"),
       "the status label is not visible"
     );
   });
diff --git a/test/javascripts/acceptance/activity-pub-topic-test.js b/test/javascripts/acceptance/activity-pub-topic-test.js
index 4f2d0dd2..97f0d673 100644
--- a/test/javascripts/acceptance/activity-pub-topic-test.js
+++ b/test/javascripts/acceptance/activity-pub-topic-test.js
@@ -10,25 +10,47 @@ import {
   publishToMessageBus,
   query,
 } from "discourse/tests/helpers/qunit-helpers";
+import { i18n } from "discourse-i18n";
+import { default as SiteActors } from "../fixtures/site-actors-fixtures";
 
 const createdAt = moment().subtract(2, "days");
-const scheduledAt = moment().subtract(2, "days");
+const scheduledAt = moment().add(3, "minutes");
 const publishedAt = moment().subtract(1, "days");
 const deletedAt = moment();
 
-const setupServer = (needs, attrs = {}) => {
+const setupServer = (needs, postAttrs = [], topicAttrs = {}) => {
   needs.pretender((server, helper) => {
     const topicResponse = cloneJSON(topicFixtures["/t/280/1.json"]);
-    const firstPost = topicResponse.post_stream.posts[0];
-    firstPost.cooked += '<div class="note">This is my note</div>';
-    firstPost.created_at = createdAt;
-    firstPost.activity_pub_enabled = true;
-    firstPost.activity_pub_scheduled_at = scheduledAt;
-    firstPost.activity_pub_object_type = "Note";
-    firstPost.activity_pub_first_post = true;
-    firstPost.activity_pub_is_first_post = true;
-    Object.keys(attrs).forEach((attr) => {
-      firstPost[attr] = attrs[attr];
+    postAttrs.forEach((attrs, i) => {
+      let post = topicResponse.post_stream.posts[i];
+      post.cooked += `<div class="note">This is my note ${i}</div>`;
+      post.created_at = createdAt;
+      post.activity_pub_enabled = true;
+      post.activity_pub_object_type = "Note";
+      post.activity_pub_first_post = true;
+      Object.keys(attrs).forEach((attr) => {
+        post[attr] = attrs[attr];
+      });
+    });
+    topicResponse.activity_pub_total_post_count = 20;
+    topicResponse.activity_pub_published_post_count =
+      topicResponse.post_stream.posts.filter(
+        (p) => !!p.activity_pub_published_at
+      ).length;
+    topicResponse.activity_pub_enabled = true;
+    topicResponse.activity_pub_local = true;
+    topicResponse.activity_pub_actor = SiteActors.category[0];
+    Object.keys(topicAttrs).forEach((topicAttr) => {
+      topicResponse[topicAttr] = topicAttrs[topicAttr];
+    });
+    topicResponse.activity_pub_post_actors = postAttrs.map((attrs, i) => {
+      let post = topicResponse.post_stream.posts[i];
+      return {
+        post_id: post.id,
+        actor: {
+          handle: `actor${i}@domain.com`,
+        },
+      };
     });
     server.get("/t/280.json", () => helper.response(topicResponse));
   });
@@ -42,19 +64,30 @@ acceptance(
       admin: false,
       groups: [AUTO_GROUPS.trust_level_0, AUTO_GROUPS.trust_level_1],
     });
-    setupServer(needs, {
-      activity_pub_published_at: publishedAt,
-    });
-
-    test("ActivityPub indicator element", async function (assert) {
+    setupServer(needs, [
+      {
+        activity_pub_published_at: publishedAt,
+        activity_pub_local: true,
+      },
+      {
+        activity_pub_published_at: publishedAt,
+        activity_pub_local: true,
+      },
+    ]);
+
+    test("ActivityPub topic and post elements", async function (assert) {
       this.siteSettings.activity_pub_post_status_visibility_groups = "1";
       Site.current().set("activity_pub_enabled", true);
 
       await visit("/t/280");
 
       assert.notOk(
-        exists(".topic-post:nth-of-type(1) .post-info.activity-pub"),
-        "is not visible"
+        exists(".topic-map__activity-pub"),
+        "the topic map is not visible"
+      );
+      assert.notOk(
+        exists(".topic-post:nth-of-type(2) .post-info.activity-pub"),
+        "the post status is not visible"
       );
     });
   }
@@ -64,11 +97,19 @@ acceptance(
   "Discourse Activity Pub | ActivityPub topic as user in a group with post status visible",
   function (needs) {
     needs.user({ moderator: true, admin: false });
-    setupServer(needs, {
-      activity_pub_published_at: publishedAt,
-      activity_pub_visibility: "public",
-      activity_pub_domain: "external.com",
-    });
+    setupServer(needs, [
+      {
+        activity_pub_published_at: publishedAt,
+        activity_pub_visibility: "public",
+        activity_pub_domain: "external.com",
+        activity_pub_local: false,
+      },
+      {
+        activity_pub_published_at: publishedAt,
+        activity_pub_visibility: "public",
+        activity_pub_local: true,
+      },
+    ]);
 
     test("When the plugin is disabled", async function (assert) {
       this.siteSettings.activity_pub_post_status_visibility_groups = "2";
@@ -80,12 +121,12 @@ acceptance(
       await visit("/t/280");
 
       assert.notOk(
-        exists(".topic-post:nth-of-type(1) .post-info.activity-pub"),
-        "the activity pub indicator is not visible"
+        exists(".topic-map__activity-pub"),
+        "the activity pub topic map is not visible"
       );
     });
 
-    test("ActivityPub indicator element", async function (assert) {
+    test("When the plugin is enabled", async function (assert) {
       this.siteSettings.activity_pub_post_status_visibility_groups = "2";
       Site.current().setProperties({
         activity_pub_enabled: true,
@@ -94,16 +135,11 @@ acceptance(
 
       await visit("/t/280");
 
+      assert.ok(exists(".topic-map__activity-pub"), "the topic map is visible");
       assert.ok(
-        exists(".topic-post:nth-of-type(1) .post-info.activity-pub"),
+        exists(".topic-post:nth-of-type(3) .post-info.activity-pub"),
         "is visible"
       );
-      assert.ok(
-        exists(
-          ".topic-post:nth-of-type(1) .post-info.activity-pub .d-icon-discourse-activity-pub"
-        ),
-        "displays the ActivityPub icon"
-      );
     });
   }
 );
@@ -112,9 +148,20 @@ acceptance(
   "Discourse Activity Pub | Scheduled ActivityPub topic as staff",
   function (needs) {
     needs.user({ moderator: true, admin: false });
-    setupServer(needs);
+    setupServer(
+      needs,
+      [
+        {
+          activity_pub_scheduled_at: scheduledAt,
+          activity_pub_visibility: "public",
+        },
+      ],
+      {
+        activity_pub_scheduled_at: scheduledAt,
+      }
+    );
 
-    test("ActivityPub indicator element", async function (assert) {
+    test("topic map", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
         activity_pub_publishing_enabled: true,
@@ -122,82 +169,41 @@ acceptance(
 
       await visit("/t/280");
 
-      assert.ok(
-        exists(
-          `.topic-post:nth-of-type(1) .post-info.activity-pub[title='Note was scheduled to be published via ActivityPub from this site at ${scheduledAt.format(
-            "h:mm a, MMM D"
-          )}.']`
-        ),
-        "shows the right title"
-      );
-    });
-
-    test("Post admin menu", async function (assert) {
-      await visit("/t/280");
-      await click(".show-more-actions");
-      await click(".show-post-admin-menu");
-
-      assert.ok(
-        exists(".fk-d-menu .activity-pub-unschedule"),
-        "The unschedule button was rendered"
-      );
-    });
-  }
-);
-
-acceptance(
-  "Discourse Activity Pub | Unscheduled ActivityPub topic as staff with First Post enabled",
-  function (needs) {
-    needs.user({ moderator: true, admin: false });
-    setupServer(needs, {
-      activity_pub_scheduled_at: null,
-      activity_pub_first_post: true,
-    });
-
-    test("Post admin menu", async function (assert) {
-      await visit("/t/280");
-      await click(".show-more-actions");
-      await click(".show-post-admin-menu");
-
-      assert.ok(
-        exists(".fk-d-menu .activity-pub-schedule"),
-        "The schedule button was rendered"
-      );
-    });
-  }
-);
-
-acceptance(
-  "Discourse Activity Pub | Unscheduled ActivityPub topic as staff with Full Topic enabled",
-  function (needs) {
-    needs.user({ moderator: true, admin: false });
-    setupServer(needs, {
-      activity_pub_scheduled_at: null,
-      activity_pub_first_post: false,
-    });
-
-    test("Post admin menu", async function (assert) {
-      await visit("/t/280");
-      await click(".show-more-actions");
-      await click(".show-post-admin-menu");
-
-      assert.ok(
-        exists(".fk-d-menu .activity-pub-schedule"),
-        "The schedule button was rendered"
+      assert.strictEqual(
+        query(
+          ".topic-map__activity-pub .activity-pub-topic-status"
+        ).innerText.trim(),
+        `Topic is scheduled to be published via ActivityPub on ${scheduledAt.format(
+          i18n("dates.time_short_day")
+        )}.`,
+        "shows the right status text"
       );
     });
   }
 );
 
 acceptance(
-  "Discourse Activity Pub | Published ActivityPub topic as staff with a local Note",
+  "Discourse Activity Pub | Published ActivityPub topic as staff",
   function (needs) {
     needs.user({ moderator: true, admin: false });
-    setupServer(needs, {
-      activity_pub_published_at: publishedAt,
-      activity_pub_visibility: "public",
-      activity_pub_local: true,
-    });
+    setupServer(
+      needs,
+      [
+        {
+          activity_pub_published_at: publishedAt,
+          activity_pub_visibility: "public",
+          activity_pub_local: true,
+        },
+        {
+          activity_pub_published_at: publishedAt,
+          activity_pub_visibility: "public",
+          activity_pub_local: true,
+        },
+      ],
+      {
+        activity_pub_published_at: publishedAt,
+      }
+    );
 
     test("When the plugin is disabled", async function (assert) {
       Site.current().setProperties({
@@ -208,12 +214,12 @@ acceptance(
       await visit("/t/280");
 
       assert.notOk(
-        exists(".topic-post:nth-of-type(1) .post-info.activity-pub"),
-        "the activity pub indicator is not visible"
+        exists(".topic-map__activity-pub"),
+        "the topic map is not visible"
       );
     });
 
-    test("ActivityPub indicator element", async function (assert) {
+    test("topic map", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
         activity_pub_publishing_enabled: true,
@@ -221,17 +227,18 @@ acceptance(
 
       await visit("/t/280");
 
-      assert.ok(
-        exists(
-          `.topic-post:nth-of-type(1) .post-info.activity-pub[title='Note was published via ActivityPub from this site at ${publishedAt.format(
-            "h:mm a, MMM D"
-          )}.']`
-        ),
-        "shows the right title"
+      assert.strictEqual(
+        query(
+          ".topic-map__activity-pub .activity-pub-topic-status"
+        ).innerText.trim(),
+        `Topic was published via ActivityPub on ${publishedAt.format(
+          i18n("dates.time_short_day")
+        )}.`,
+        "shows the right status text"
       );
     });
 
-    test("ActivityPub state update", async function (assert) {
+    test("ActivityPub status update", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
         activity_pub_publishing_enabled: true,
@@ -241,25 +248,26 @@ acceptance(
 
       const stateUpdate = {
         model: {
-          id: 398,
-          type: "post",
+          id: 280,
+          type: "topic",
           published_at: publishedAt,
           deleted_at: deletedAt,
         },
       };
       await publishToMessageBus("/activity-pub", stateUpdate);
 
-      assert.ok(
-        exists(
-          `.topic-post:nth-of-type(1) .post-info.activity-pub[title='Note was deleted via ActivityPub at ${deletedAt.format(
-            "h:mm a, MMM D"
-          )}.']`
-        ),
-        "shows the right title"
+      assert.strictEqual(
+        query(
+          ".topic-map__activity-pub .activity-pub-topic-status"
+        ).innerText.trim(),
+        `Topic was deleted via ActivityPub on ${deletedAt.format(
+          i18n("dates.time_short_day")
+        )}.`,
+        "shows the right status text"
       );
     });
 
-    test("ActivityPub post info modal", async function (assert) {
+    test("ActivityPub topic info modal", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
         activity_pub_publishing_enabled: true,
@@ -267,69 +275,59 @@ acceptance(
 
       await visit("/t/280");
 
-      await click(".topic-post:nth-of-type(1) .post-info.activity-pub");
-      assert.ok(exists(".activity-pub-post-info-modal"), "shows the modal");
+      await click(".topic-map__activity-pub .activity-pub-topic-status");
+      assert.ok(exists(".activity-pub-topic-info-modal"), "shows the modal");
+
       assert.strictEqual(
         query(
-          ".activity-pub-post-info-modal .activity-pub-state"
+          ".activity-pub-topic-info-modal .activity-pub-topic-status"
         ).innerText.trim(),
-        `Note was published via ActivityPub from this site at ${publishedAt.format(
-          "h:mm a, MMM D"
+        `Topic was published on ${publishedAt.format(
+          i18n("dates.long_with_year")
         )}.`,
-        "shows the right state text"
+        "shows the right topic status text"
       );
       assert.strictEqual(
         query(
-          ".activity-pub-post-info-modal .activity-pub-visibility"
+          ".activity-pub-topic-info-modal .activity-pub-post-status"
         ).innerText.trim(),
-        "Note is publicly addressed.",
-        "shows the right visibility text"
-      );
-    });
-  }
-);
-
-acceptance(
-  "Discourse Activity Pub | Published ActivityPub topic as staff with a remote Note",
-  function (needs) {
-    needs.user({ moderator: true, admin: false });
-    setupServer(needs, {
-      activity_pub_published_at: publishedAt,
-      activity_pub_visibility: "public",
-      activity_pub_local: false,
-      activity_pub_domain: "external.com",
-      activity_pub_url: "https://external.com/note/1",
-    });
-
-    test("When the plugin is disabled", async function (assert) {
-      Site.current().setProperties({
-        activity_pub_enabled: false,
-        activity_pub_publishing_enabled: false,
-      });
-
-      await visit("/t/280");
-
-      assert.notOk(
-        exists(".topic-post:nth-of-type(1) .post-info.activity-pub"),
-        "the activity pub indicator is not visible"
+        `Post was published on ${publishedAt.format(
+          i18n("dates.long_with_year")
+        )}.`,
+        "shows the right post status text"
       );
     });
 
-    test("ActivityPub indicator element", async function (assert) {
+    test("ActivityPub topic admin modal", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
         activity_pub_publishing_enabled: true,
+        activity_pub_actors: SiteActors,
       });
 
       await visit("/t/280");
+      await click(".topic-admin-menu-trigger");
+      await click(".show-activity-pub-topic-admin");
 
+      assert.ok(exists(".activity-pub-topic-admin-modal"), "shows the modal");
       assert.ok(
-        exists(
-          `.topic-post:nth-of-type(1) .post-info.activity-pub[title='Note was published via ActivityPub from external.com at ${publishedAt.format(
-            "h:mm a, MMM D"
-          )}.']`
+        query(
+          ".activity-pub-topic-admin-modal .activity-pub-topic-actions .action.publish-all"
+        ),
+        "shows the publish all posts action"
+      );
+      assert.strictEqual(
+        query(
+          ".activity-pub-topic-admin-modal .activity-pub-topic-actions .action.publish-all .action-description"
+        ).innerText.trim(),
+        `Publish 18 unpublished posts in Topic #280. Posts will not be delivered to the followers of the Group Actors.`,
+        "shows the right publish all description"
+      );
+      assert.ok(
+        query(
+          ".activity-pub-topic-admin-modal .activity-pub-post-actions .action.deliver"
         ),
-        "shows the right title"
+        "shows the post deliver action"
       );
     });
 
@@ -341,100 +339,140 @@ acceptance(
 
       await visit("/t/280");
 
-      await click(".topic-post:nth-of-type(1) .post-info.activity-pub");
+      await click(".topic-post:nth-of-type(3) .activity-pub-post-status");
       assert.ok(exists(".activity-pub-post-info-modal"), "shows the modal");
+
       assert.strictEqual(
         query(
-          ".activity-pub-post-info-modal .activity-pub-state"
+          ".activity-pub-post-info-modal .activity-pub-post-status"
         ).innerText.trim(),
-        `Note was published via ActivityPub from external.com at ${publishedAt.format(
-          "h:mm a, MMM D"
+        `Post was published on ${publishedAt.format(
+          i18n("dates.long_with_year")
         )}.`,
-        "shows the right state text"
+        "shows the right status text"
       );
       assert.strictEqual(
         query(
-          ".activity-pub-post-info-modal .activity-pub-visibility"
+          ".activity-pub-post-info-modal .activity-pub-attribute.visibility"
         ).innerText.trim(),
-        "Note is publicly addressed.",
+        "Public",
         "shows the right visibility text"
       );
-      assert.strictEqual(
-        query(
-          ".activity-pub-post-info-modal .activity-pub-url a"
-        ).innerText.trim(),
-        "Original Note on external.com.",
-        "shows the right url text"
-      );
-      assert.strictEqual(
-        query(".activity-pub-post-info-modal .activity-pub-url a").href,
-        "https://external.com/note/1",
-        "shows the right url href"
-      );
     });
   }
 );
 
 acceptance(
-  "Discourse Activity Pub | Published ActivityPub topic as staff with a unpublished Note",
+  "Discourse Activity Pub | Published ActivityPub topic as staff with a remote Note",
   function (needs) {
     needs.user({ moderator: true, admin: false });
-    setupServer(needs, {
-      activity_pub_scheduled_at: null,
-    });
-
-    test("When the plugin is disabled", async function (assert) {
+    setupServer(
+      needs,
+      [
+        {
+          post_number: 1,
+          activity_pub_published_at: publishedAt,
+          activity_pub_visibility: "public",
+          activity_pub_local: false,
+          activity_pub_domain: "external.com",
+          activity_pub_url: "https://external.com/note/1",
+        },
+        {
+          post_number: 2,
+          activity_pub_published_at: publishedAt,
+          activity_pub_visibility: "public",
+          activity_pub_local: false,
+          activity_pub_domain: "external.com",
+          activity_pub_url: "https://external.com/note/3",
+        },
+      ],
+      {
+        activity_pub_published_at: publishedAt,
+        activity_pub_local: false,
+      }
+    );
+
+    test("ActivityPub topic and post status", async function (assert) {
       Site.current().setProperties({
-        activity_pub_enabled: false,
-        activity_pub_publishing_enabled: false,
+        activity_pub_enabled: true,
+        activity_pub_publishing_enabled: true,
       });
 
       await visit("/t/280");
 
-      assert.notOk(
-        exists(".topic-post:nth-of-type(1) .post-info.activity-pub"),
-        "the activity pub indicator is not visible"
+      assert.strictEqual(
+        query(".activity-pub-topic-status").innerText.trim(),
+        `Topic was published via ActivityPub by @cat_1@test.local on ${publishedAt.format(
+          i18n("dates.time_short_day")
+        )}.`,
+        "shows the right topic status text"
+      );
+      assert.ok(
+        exists(
+          `.topic-post:nth-of-type(3) .activity-pub-post-status[title='Post was published via ActivityPub by actor1@domain.com on ${publishedAt.format(
+            i18n("dates.time_short_day")
+          )}.']`
+        ),
+        "shows the right post status text"
       );
     });
 
-    test("ActivityPub indicator element", async function (assert) {
+    test("ActivityPub topic info modal", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
-        activity_pub_publishing_enabled: false,
+        activity_pub_publishing_enabled: true,
       });
 
       await visit("/t/280");
 
-      assert.ok(
-        exists(
-          ".topic-post:nth-of-type(1) .post-info.activity-pub[title='Note was not published via ActivityPub.']"
-        ),
-        "shows the right title"
+      await click(".topic-map__activity-pub .activity-pub-topic-status");
+      assert.ok(exists(".activity-pub-topic-info-modal"), "shows the modal");
+
+      assert.strictEqual(
+        query(
+          ".activity-pub-topic-info-modal .activity-pub-topic-status"
+        ).innerText.trim(),
+        `Topic was published on ${publishedAt.format(
+          i18n("dates.long_with_year")
+        )}.`,
+        "shows the right topic status text"
       );
-      assert.ok(
-        exists(
-          ".topic-post:nth-of-type(1) .post-info.activity-pub .d-icon-discourse-activity-pub-slash"
-        ),
-        "shows the right icon"
+      assert.strictEqual(
+        query(
+          ".activity-pub-topic-info-modal .activity-pub-post-status"
+        ).innerText.trim(),
+        `Post was published on ${publishedAt.format(
+          i18n("dates.long_with_year")
+        )}.`,
+        "shows the right post status text"
       );
     });
 
     test("ActivityPub post info modal", async function (assert) {
       Site.current().setProperties({
         activity_pub_enabled: true,
-        activity_pub_publishing_enabled: false,
+        activity_pub_publishing_enabled: true,
       });
 
       await visit("/t/280");
 
-      await click(".topic-post:nth-of-type(1) .post-info.activity-pub");
+      await click(".topic-post:nth-of-type(3) .activity-pub-post-status");
       assert.ok(exists(".activity-pub-post-info-modal"), "shows the modal");
       assert.strictEqual(
         query(
-          ".activity-pub-post-info-modal .activity-pub-state"
+          ".activity-pub-post-info-modal .activity-pub-post-status"
+        ).innerText.trim(),
+        `Post was published on ${publishedAt.format(
+          i18n("dates.long_with_year")
+        )}.`,
+        "shows the right status text"
+      );
+      assert.strictEqual(
+        query(
+          ".activity-pub-post-info-modal .activity-pub-attribute.visibility"
         ).innerText.trim(),
-        "Note was not published via ActivityPub.",
-        "shows the right state text"
+        "Public",
+        "shows the right visibility text"
       );
     });
   }
diff --git a/test/javascripts/components/activity-pub-status-test.js b/test/javascripts/components/activity-pub-status-test.js
index f058f151..ea092ffb 100644
--- a/test/javascripts/components/activity-pub-status-test.js
+++ b/test/javascripts/components/activity-pub-status-test.js
@@ -45,10 +45,10 @@ function setComposer(context, opts = {}) {
 }
 
 module(
-  "Discourse Activity Pub | Component | activity-pub-status with category",
+  "Discourse Activity Pub | Component | activity-pub-actor-status with category",
   function (hooks) {
     setupRenderingTest(hooks);
-    const template = hbs`<ActivityPubStatus @model={{this.category}} @modelType="category" />`;
+    const template = hbs`<ActivityPubActorStatus @model={{this.category}} @modelType="category" />`;
 
     test("with publishing disabled", async function (assert) {
       setSite(this, {
@@ -60,7 +60,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.publishing-disabled");
+      const status = query(".activity-pub-actor-status.publishing-disabled");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -84,7 +84,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -116,7 +116,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -151,7 +151,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -177,7 +177,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.active");
+      const status = query(".activity-pub-actor-status.active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -211,7 +211,7 @@ module(
         },
       });
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -228,7 +228,7 @@ module(
     });
 
     test("when in the composer", async function (assert) {
-      const composerTemplate = hbs`<ActivityPubStatus @model={{this.composer}} @modelType="composer" />`;
+      const composerTemplate = hbs`<ActivityPubActorStatus @model={{this.composer}} @modelType="composer" />`;
 
       setSite(this, {
         activity_pub_enabled: true,
@@ -242,7 +242,7 @@ module(
 
       await render(composerTemplate);
 
-      const label = query(".activity-pub-status .label");
+      const label = query(".activity-pub-actor-status .label");
       assert.strictEqual(
         label.innerText.trim(),
         i18n("discourse_activity_pub.visibility.label.public"),
@@ -253,10 +253,10 @@ module(
 );
 
 module(
-  "Discourse Activity Pub | Component | activity-pub-status with tag",
+  "Discourse Activity Pub | Component | activity-pub-actor-status with tag",
   function (hooks) {
     setupRenderingTest(hooks);
-    const template = hbs`<ActivityPubStatus @model={{this.tag}} @modelType="tag" />`;
+    const template = hbs`<ActivityPubActorStatus @model={{this.tag}} @modelType="tag" />`;
 
     test("with publishing disabled", async function (assert) {
       setSite(this, {
@@ -268,7 +268,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.publishing-disabled");
+      const status = query(".activity-pub-actor-status.publishing-disabled");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -292,7 +292,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -324,7 +324,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -359,7 +359,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -385,7 +385,7 @@ module(
 
       await render(template);
 
-      const status = query(".activity-pub-status.active");
+      const status = query(".activity-pub-actor-status.active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -419,7 +419,7 @@ module(
         },
       });
 
-      const status = query(".activity-pub-status.not-active");
+      const status = query(".activity-pub-actor-status.not-active");
       assert.ok(status, "has the right class");
       assert.strictEqual(
         status.title,
@@ -436,7 +436,7 @@ module(
     });
 
     test("when in the composer", async function (assert) {
-      const composerTemplate = hbs`<ActivityPubStatus @model={{this.composer}} @modelType="composer" />`;
+      const composerTemplate = hbs`<ActivityPubActorStatus @model={{this.composer}} @modelType="composer" />`;
 
       setSite(this, {
         activity_pub_enabled: true,
@@ -450,7 +450,7 @@ module(
 
       await render(composerTemplate);
 
-      const label = query(".activity-pub-status .label");
+      const label = query(".activity-pub-actor-status .label");
       assert.strictEqual(
         label.innerText.trim(),
         i18n("discourse_activity_pub.visibility.label.public"),
