]> git.mxchange.org Git - friendica.git/commitdiff
Better support for "audience" / simplified Lemmy processing
authorMichael <heluecht@pirati.ca>
Thu, 15 Jun 2023 22:04:28 +0000 (22:04 +0000)
committerMichael <heluecht@pirati.ca>
Thu, 15 Jun 2023 22:04:28 +0000 (22:04 +0000)
src/Model/APContact.php
src/Model/Contact.php
src/Model/Item.php
src/Model/Tag.php
src/Network/Probe.php
src/Protocol/ActivityPub/Delivery.php
src/Protocol/ActivityPub/Processor.php
src/Protocol/ActivityPub/Receiver.php
src/Protocol/ActivityPub/Transmitter.php

index dc062fe5b506afd222a92f7b4f8c75f70d413a0b..2a5b89928f80597cddaf7ba8908d2f9fcf35cde8 100644 (file)
@@ -119,6 +119,11 @@ class APContact
                        return [];
                }
 
+               if (!Network::isValidHttpUrl($url) && !filter_var($url, FILTER_VALIDATE_EMAIL)) {
+                       Logger::info('Invalid URL', ['url' => $url]);
+                       return [];
+               }
+
                $fetched_contact = [];
 
                if (empty($update)) {
index 720d2638c4ce23ac15312176b23d6581a1a0bf6c..1a52164561c13ac2e9fc6a7ab94dde627ac779e6 100644 (file)
@@ -2773,7 +2773,7 @@ class Contact
                }
 
                $update = false;
-               $guid = ($ret['guid'] ?? '') ?: Item::guidFromUri($ret['url'], $ret['baseurl'] ?: $ret['alias']);
+               $guid = ($ret['guid'] ?? '') ?: Item::guidFromUri($ret['url'], $ret['baseurl'] ?? $ret['alias']);
 
                // make sure to not overwrite existing values with blank entries except some technical fields
                $keep = ['batch', 'notify', 'poll', 'request', 'confirm', 'poco', 'baseurl'];
index cdeb6d3d13c7e44c9c6f0f1b19ecb0768a020bf9..51ac03b4eff2abfe1689faa427f8b7fd143381a6 100644 (file)
@@ -2210,8 +2210,6 @@ class Item
         */
        private static function tagDeliver(int $uid, int $item_id): bool
        {
-               $mention = false;
-
                $owner = User::getOwnerDataById($uid);
                if (!DBA::isResult($owner)) {
                        Logger::warning('User not found, quitting here.', ['uid' => $uid]);
@@ -3664,6 +3662,7 @@ class Item
 
        /**
         * Does the given uri-id belongs to a post that is sent as starting post to a group?
+        * This does not apply to posts that are sent only in parallel to a group.
         *
         * @param int $uri_id
         *
@@ -3671,7 +3670,13 @@ class Item
         */
        public static function isGroupPost(int $uri_id): bool
        {
-               foreach (Tag::getByURIId($uri_id, [Tag::EXCLUSIVE_MENTION]) as $tag) {
+               if (Post::exists(['private' => Item::PUBLIC, 'uri-id' => $uri_id])) {
+                       return false;
+               }
+
+               foreach (Tag::getByURIId($uri_id, [Tag::EXCLUSIVE_MENTION, Tag::AUDIENCE]) as $tag) {
+                       // @todo Possibly check for a public audience in the future, see https://socialhub.activitypub.rocks/t/fep-1b12-group-federation/2724
+                       // and https://codeberg.org/fediverse/fep/src/branch/main/feps/fep-1b12.md
                        if (DBA::exists('contact', ['uid' => 0, 'nurl' => Strings::normaliseLink($tag['url']), 'contact-type' => Contact::TYPE_COMMUNITY])) {
                                return true;
                        }
index 9f0f8d29a672738a034cd3167bccf01cfa6814ad..1645dc1255b540a9f66742b3c987f6a7bd93e78b 100644 (file)
@@ -487,7 +487,7 @@ class Tag
         *
         * @return boolean
         */
-       public static function isMentioned(int $uriId, string $url, array $type = [self::MENTION, self::EXCLUSIVE_MENTION]): bool
+       public static function isMentioned(int $uriId, string $url, array $type = [self::MENTION, self::EXCLUSIVE_MENTION, self::AUDIENCE]): bool
        {
                $tags = self::getByURIId($uriId, $type);
                foreach ($tags as $tag) {
index b1931ae6d36bb6d5cbf8b43bd693cf92ae3024ba..c7b6a84e8b1833161cac182c88f826a4d06b381c 100644 (file)
@@ -341,7 +341,6 @@ class Probe
         * @param string  $uri     Address that should be probed
         * @param string  $network Test for this specific network
         * @param integer $uid     User ID for the probe (only used for mails)
-        * @param boolean $cache   Use cached values?
         *
         * @return array uri data
         * @throws HTTPException\InternalServerErrorException
index ddf5816ed5eb3ba7242e38facaa30d413e774d2a..04f5841c2ed9643f32e8dbc1b6038cbc1ab2f726 100644 (file)
@@ -160,7 +160,7 @@ class Delivery
                                                if (!empty($actor)) {
                                                        $drop = !ActivityPub\Transmitter::sendRelayFollow($actor);
                                                        Logger::notice('Resubscribed to relay', ['url' => $actor, 'success' => !$drop]);
-                                               } elseif ($cmd = ProtocolDelivery::DELETION) {
+                                               } elseif ($cmd == ProtocolDelivery::DELETION) {
                                                        // Remote systems not always accept our deletion requests, so we drop them if rejected.
                                                        // Situation is: In Friendica we allow the thread owner to delete foreign comments to their thread.
                                                        // Most AP systems don't allow this, so they will reject the deletion request.
index 453525454a51bb0f1589b96473c2b2351d92c5b3..ac2e719718796b7c0894e3d364f71f0771a28808 100644 (file)
@@ -1533,6 +1533,7 @@ class Processor
                $activity['id'] = $object['id'];
                $activity['to'] = $object['to'] ?? [];
                $activity['cc'] = $object['cc'] ?? [];
+               $activity['audience'] = $object['audience'] ?? [];
                $activity['actor'] = $actor;
                $activity['object'] = $object;
                $activity['published'] = $published;
index b272c31dd3457f5da1a6196506d396b40852f875..7a07e1a7f2ab52f4e6739d06201c68ee8408ae47 100644 (file)
@@ -291,16 +291,17 @@ class Receiver
        /**
         * Prepare the object array
         *
-        * @param array   $activity     Array with activity data
-        * @param integer $uid          User ID
-        * @param boolean $push         Message had been pushed to our system
-        * @param boolean $trust_source Do we trust the source?
+        * @param array   $activity       Array with activity data
+        * @param integer $uid            User ID
+        * @param boolean $push           Message had been pushed to our system
+        * @param boolean $trust_source   Do we trust the source?
+        * @param string  $original_actor Actor of the original activity. Used for receiver detection. (Optional)
         *
         * @return array with object data
         * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         * @throws \ImagickException
         */
-       public static function prepareObjectData(array $activity, int $uid, bool $push, bool &$trust_source): array
+       public static function prepareObjectData(array $activity, int $uid, bool $push, bool &$trust_source, string $original_actor = ''): array
        {
                $id        = JsonLD::fetchElement($activity, '@id');
                $type      = JsonLD::fetchElement($activity, '@type');
@@ -319,7 +320,7 @@ class Receiver
                $fetched = false;
 
                if (!empty($id) && !$trust_source) {
-                       $fetch_uid = $uid ?: self::getBestUserForActivity($activity);
+                       $fetch_uid = $uid ?: self::getBestUserForActivity($activity, $original_actor);
 
                        $fetched_activity = Processor::fetchCachedActivity($fetch_id, $fetch_uid);
                        if (!empty($fetched_activity)) {
@@ -355,7 +356,7 @@ class Receiver
                $type = JsonLD::fetchElement($activity, '@type');
 
                // Fetch all receivers from to, cc, bto and bcc
-               $receiverdata = self::getReceivers($activity, $actor, [], false, $push || $fetched);
+               $receiverdata = self::getReceivers($activity, $original_actor ?: $actor, [], false, $push || $fetched);
                $receivers = $reception_types = [];
                foreach ($receiverdata as $key => $data) {
                        $receivers[$key] = $data['uid'];
@@ -379,7 +380,7 @@ class Receiver
 
                // We possibly need some user to fetch private content,
                // so we fetch one out of the receivers if no uid is provided.
-               $fetch_uid = $uid ?: self::getBestUserForActivity($activity);
+               $fetch_uid = $uid ?: self::getBestUserForActivity($activity, $original_actor);
 
                $object_id = JsonLD::fetchElement($activity, 'as:object', '@id');
                if (empty($object_id)) {
@@ -394,28 +395,6 @@ class Receiver
 
                $object_type = self::fetchObjectType($activity, $object_id, $fetch_uid);
 
-               // Fetch the activity on Lemmy "Announce" messages (announces of activities)
-               if (($type == 'as:Announce') && in_array($object_type, array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) {
-                       Logger::debug('Fetch announced activity', ['object' => $object_id, 'uid' => $fetch_uid]);
-                       $data = Processor::fetchCachedActivity($object_id, $fetch_uid);
-                       if (!empty($data)) {
-                               $type = $object_type;
-                               $announced_activity = JsonLD::compact($data);
-
-                               // Some variables need to be refetched since the activity changed
-                               $actor = JsonLD::fetchElement($announced_activity, 'as:actor', '@id');
-                               $announced_id = JsonLD::fetchElement($announced_activity, 'as:object', '@id');
-                               if (empty($announced_id)) {
-                                       Logger::warning('No object id in announced activity', ['id' => $object_id, 'activity' => $activity, 'announced' => $announced_activity]);
-                                       return [];
-                               } else {
-                                       $activity  = $announced_activity;
-                                       $object_id = $announced_id;
-                               }
-                               $object_type = self::fetchObjectType($activity, $object_id, $fetch_uid);
-                       }
-               }
-
                // Any activities on account types must not be altered
                if (in_array($type, ['as:Flag'])) {
                        $object_data = [];
@@ -454,7 +433,7 @@ class Receiver
                } elseif (in_array($type, array_merge(self::ACTIVITY_TYPES, ['as:Announce', 'as:Follow'])) && in_array($object_type, self::CONTENT_TYPES)) {
                        // Create a mostly empty array out of the activity data (instead of the object).
                        // This way we later don't have to check for the existence of each individual array element.
-                       $object_data = self::processObject($activity);
+                       $object_data = self::processObject($activity, $original_actor);
                        $object_data['name'] = $type;
                        $object_data['author'] = JsonLD::fetchElement($activity, 'as:actor', '@id');
                        $object_data['object_id'] = $object_id;
@@ -598,20 +577,34 @@ class Receiver
                        }
                }
 
+               // Lemmy announces activities.
+               // To simplify the further processing, we modify the received object.
+               // For announced "create" activities we remove the middle layer.
+               // For the rest (like, dislike, update, ...) we just process the activity directly.
+               $original_actor = '';
+               $object_type = JsonLD::fetchElement($activity['as:object'] ?? [], '@type');
+               if (($type == 'as:Announce') && !empty($object_type) && !in_array($object_type, self::CONTENT_TYPES) && self::isGroup($actor)) {
+                       $object_object_type = JsonLD::fetchElement($activity['as:object']['as:object'] ?? [], '@type');
+                       if (in_array($object_type, ['as:Create']) && in_array($object_object_type, self::CONTENT_TYPES)) {
+                               Logger::debug('Replace "create" activity with inner object', ['type' => $object_type, 'object_type' => $object_object_type]);
+                               $activity['as:object'] = $activity['as:object']['as:object'];
+                       } elseif (in_array($object_type, array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) {
+                               Logger::debug('Change announced activity to activity', ['type' => $object_type]);
+                               $original_actor = $actor;
+                               $type = $object_type;
+                               $activity = $activity['as:object'];
+                       } else {
+                               Logger::info('Unhandled announced activity', ['type' => $object_type, 'object_type' => $object_object_type]);
+                       }
+               }
+
                // $trust_source is called by reference and is set to true if the content was retrieved successfully
-               $object_data = self::prepareObjectData($activity, $uid, $push, $trust_source);
+               $object_data = self::prepareObjectData($activity, $uid, $push, $trust_source, $original_actor);
                if (empty($object_data)) {
-                       Logger::info('No object data found', ['activity' => $activity]);
+                       Logger::info('No object data found', ['activity' => $activity, 'callstack' => System::callstack(20)]);
                        return true;
                }
 
-               // Lemmy is announcing activities.
-               // We are changing the announces into regular activities.
-               if (($type == 'as:Announce') && in_array($object_data['type'] ?? '', array_merge(self::ACTIVITY_TYPES, ['as:Delete', 'as:Undo', 'as:Update']))) {
-                       Logger::debug('Change type of announce to activity', ['type' => $object_data['type']]);
-                       $type = $object_data['type'];
-               }
-
                if (!empty($body) && empty($object_data['raw'])) {
                        $object_data['raw'] = $body;
                }
@@ -688,6 +681,18 @@ class Receiver
                return true;
        }
 
+       /**
+        * Checks if the provided actor is a group account
+        *
+        * @param string $actor
+        * @return boolean
+        */
+       private static function isGroup(string $actor): bool
+       {
+               $profile = APContact::getByURL($actor);
+               return ($profile['type'] ?? '') == 'Group';
+       }
+
        /**
         * Route activities
         *
@@ -1009,10 +1014,10 @@ class Receiver
         *
         * @return int   user id
         */
-       public static function getBestUserForActivity(array $activity): int
+       public static function getBestUserForActivity(array $activity, string $actor = ''): int
        {
                $uid = 0;
-               $actor = JsonLD::fetchElement($activity, 'as:actor', '@id') ?? '';
+               $actor = $actor ?: JsonLD::fetchElement($activity, 'as:actor', '@id') ?? '';
 
                $receivers = self::getReceivers($activity, $actor, [], false, false);
                foreach ($receivers as $receiver) {
@@ -1129,7 +1134,7 @@ class Receiver
                                }
 
                                // Fetch the receivers for the public and the followers collection
-                               if ((($receiver == $followers) || (($receiver == self::PUBLIC_COLLECTION) && !$isGroup)) && !empty($actor)) {
+                               if ((($receiver == $followers) || (($receiver == self::PUBLIC_COLLECTION) && !$isGroup) || ($isGroup && ($element == 'as:audience'))) && !empty($actor)) {
                                        $receivers = self::getReceiverForActor($actor, $tags, $receivers, $follower_target, $profile);
                                        continue;
                                }
@@ -1196,12 +1201,16 @@ class Receiver
                // "birdsitelive" is a service that mirrors tweets into the fediverse
                // These posts can be fetched without authentication, but are not marked as public
                // We treat them as unlisted posts to be able to handle them.
+               // We always process deletion activities.
+               $activity_type = JsonLD::fetchElement($activity, '@type');
                if (empty($receivers) && $fetch_unlisted && Contact::isPlatform($actor, 'birdsitelive')) {
                        $receivers[0]  = ['uid' => 0, 'type' => self::TARGET_GLOBAL];
                        $receivers[-1] = ['uid' => -1, 'type' => self::TARGET_GLOBAL];
                        Logger::notice('Post from "birdsitelive" is set to "unlisted"', ['id' => JsonLD::fetchElement($activity, '@id')]);
+               } elseif (empty($receivers) && in_array($activity_type, ['as:Delete', 'as:Undo'])) {
+                       $receivers[0] = ['uid' => 0, 'type' => self::TARGET_GLOBAL];
                } elseif (empty($receivers)) {
-                       Logger::notice('Post has got no receivers', ['fetch_unlisted' => $fetch_unlisted, 'actor' => $actor, 'id' => JsonLD::fetchElement($activity, '@id'), 'type' => JsonLD::fetchElement($activity, '@type')]);
+                       Logger::notice('Post has got no receivers', ['fetch_unlisted' => $fetch_unlisted, 'actor' => $actor, 'id' => JsonLD::fetchElement($activity, '@id'), 'type' => $activity_type, 'callstack' => System::callstack(20)]);
                }
 
                return $receivers;
@@ -1437,21 +1446,9 @@ class Receiver
                        return false;
                }
 
-               // Lemmy is resharing "create" activities instead of content
-               // We fetch the content from the activity.
-               if (in_array($type, ['as:Create'])) {
-                       $object = $object['as:object'];
-                       $type = JsonLD::fetchElement($object, '@type');
-                       if (empty($type)) {
-                               Logger::info('Empty type');
-                               return false;
-                       }
-                       $object_data = self::processObject($object);
-               }
-
                // We currently don't handle 'pt:CacheFile', but with this step we avoid logging
                if (in_array($type, self::CONTENT_TYPES) || ($type == 'pt:CacheFile')) {
-                       $object_data = self::processObject($object);
+                       $object_data = self::processObject($object, '');
 
                        if (!empty($data)) {
                                $object_data['raw-object'] = json_encode($data);
@@ -1855,12 +1852,13 @@ class Receiver
        /**
         * Fetches data from the object part of an activity
         *
-        * @param array $object
+        * @param array  $object
+        * @param string $actor
         *
         * @return array|bool Object data or FALSE if $object does not contain @id element
         * @throws \Exception
         */
-       private static function processObject(array $object)
+       private static function processObject(array $object, string $actor)
        {
                if (!JsonLD::fetchElement($object, '@id')) {
                        return false;
@@ -1868,7 +1866,7 @@ class Receiver
 
                $object_data = self::getObjectDataFromActivity($object);
 
-               $receiverdata = self::getReceivers($object, $object_data['actor'] ?? '', $object_data['tags'], true, false);
+               $receiverdata = self::getReceivers($object, $actor ?: $object_data['actor'] ?? '', $object_data['tags'], true, false);
                $receivers = $reception_types = [];
                foreach ($receiverdata as $key => $data) {
                        $receivers[$key] = $data['uid'];
index e817198ec685e5e4ea0d6ccd570147325fbfdfdb..ec61eb2eb34668e8c24ddb402f3e3157d8e3ee26 100644 (file)
@@ -492,13 +492,12 @@ class Transmitter
         * Returns an array with permissions of the thread parent of the given item array
         *
         * @param array $item
-        * @param bool  $is_group_thread
         *
         * @return array with permissions
         * @throws \Friendica\Network\HTTPException\InternalServerErrorException
         * @throws \ImagickException
         */
-       private static function fetchPermissionBlockFromThreadParent(array $item, bool $is_group_thread): array
+       private static function fetchPermissionBlockFromThreadParent(array $item): array
        {
                if (empty($item['thr-parent-id'])) {
                        return [];
@@ -514,6 +513,7 @@ class Transmitter
                        'cc' => [],
                        'bto' => [],
                        'bcc' => [],
+                       'audience' => [],
                ];
 
                $parent_profile = APContact::getByURL($parent['author-link']);
@@ -525,12 +525,10 @@ class Transmitter
                        $exclude[] = $item['owner-link'];
                }
 
-               $type = [Tag::TO => 'to', Tag::CC => 'cc', Tag::BTO => 'bto', Tag::BCC => 'bcc'];
-               foreach (Tag::getByURIId($item['thr-parent-id'], [Tag::TO, Tag::CC, Tag::BTO, Tag::BCC]) as $receiver) {
+               $type = [Tag::TO => 'to', Tag::CC => 'cc', Tag::BTO => 'bto', Tag::BCC => 'bcc', Tag::AUDIENCE => 'audience'];
+               foreach (Tag::getByURIId($item['thr-parent-id'], [Tag::TO, Tag::CC, Tag::BTO, Tag::BCC, Tag::AUDIENCE]) as $receiver) {
                        if (!empty($parent_profile['followers']) && $receiver['url'] == $parent_profile['followers'] && !empty($item_profile['followers'])) {
-                               if (!$is_group_thread) {
-                                       $permissions[$type[$receiver['type']]][] = $item_profile['followers'];
-                               }
+                               $permissions[$type[$receiver['type']]][] = $item_profile['followers'];
                        } elseif (!in_array($receiver['url'], $exclude)) {
                                $permissions[$type[$receiver['type']]][] = $receiver['url'];
                        }
@@ -600,6 +598,42 @@ class Transmitter
                        $is_group_thread = false;
                }
 
+               $exclusive = false;
+               $mention   = false;
+
+               $parent_tags = Tag::getByURIId($item['parent-uri-id'], [Tag::AUDIENCE, Tag::MENTION]);
+               if (!empty($parent_tags)) {
+                       $is_group_thread = false;
+                       foreach ($parent_tags as $tag) {
+                               if ($tag['type'] != Tag::AUDIENCE) {
+                                       continue;
+                               }
+                               $profile = APContact::getByURL($tag['url'], false);
+                               if (!empty($profile) && ($profile['type'] == 'Group')) {
+                                       $is_group_thread = true;
+                               }
+                       }
+                       if ($is_group_thread) {
+                               foreach ($parent_tags as $tag) {
+                                       if (($tag['type'] == Tag::MENTION) && ($tag['url'] == $profile['url'])) {
+                                               $mention = false;
+                                       }
+                               }
+                               $exclusive = !$mention;
+                       }
+               } elseif ($is_group_thread) {
+                       foreach (Tag::getByURIId($item['parent-uri-id'], [Tag::MENTION, Tag::EXCLUSIVE_MENTION]) as $term) {
+                               $profile = APContact::getByURL($term['url'], false);
+                               if (!empty($profile) && ($profile['type'] == 'Group')) {
+                                       if ($term['type'] == Tag::EXCLUSIVE_MENTION) {
+                                               $exclusive = true;
+                                       } elseif ($term['type'] == Tag::MENTION) {
+                                               $mention = true;
+                                       }
+                               }
+                       }
+               }
+
                if (self::isAnnounce($item) || self::isAPPost($last_id)) {
                        // Will be activated in a later step
                        $networks = Protocol::FEDERATED;
@@ -616,21 +650,6 @@ class Transmitter
                        $actor_profile = APContact::getByURL($item['author-link']);
                }
 
-               $exclusive = false;
-               $mention   = false;
-
-               if ($is_group_thread) {
-                       foreach (Tag::getByURIId($item['parent-uri-id'], [Tag::MENTION, Tag::EXCLUSIVE_MENTION]) as $term) {
-                               $profile = APContact::getByURL($term['url'], false);
-                               if (!empty($profile) && ($profile['type'] == 'Group')) {
-                                       if ($term['type'] == Tag::EXCLUSIVE_MENTION) {
-                                               $exclusive = true;
-                                       } elseif ($term['type'] == Tag::MENTION) {
-                                               $mention = true;
-                                       }
-                               }
-                       }
-               }
 
                $terms = Tag::getByURIId($item['uri-id'], [Tag::MENTION, Tag::IMPLICIT_MENTION, Tag::EXCLUSIVE_MENTION]);
 
@@ -644,7 +663,9 @@ class Transmitter
                                $data['cc'][] = $announce['actor']['url'];
                        }
 
-                       $data = array_merge($data, self::fetchPermissionBlockFromThreadParent($item, $is_group_thread));
+                       if (!$is_group_thread) {
+                               $data = array_merge($data, self::fetchPermissionBlockFromThreadParent($item));
+                       }
 
                        // Check if the item is completely public or unlisted
                        if ($item['private'] == Item::PUBLIC) {
@@ -727,7 +748,7 @@ class Transmitter
                        }
                }
 
-               if (!empty($item['parent'])) {
+               if (!empty($item['parent']) && (!$is_group_thread || ($item['private'] == Item::PRIVATE))) {
                        if ($item['private'] == Item::PRIVATE) {
                                $condition = ['parent' => $item['parent'], 'uri-id' => $item['thr-parent-id']];
                        } else {
@@ -814,20 +835,13 @@ class Transmitter
                        }
                }
 
-               $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc'])];
-
-               if (!empty($data['audience'])) {
-                       $receivers['audience'] = array_values($data['audience']);
-                       if (count($receivers['audience']) == 1) {
-                               $receivers['audience'] = $receivers['audience'][0];
-                       }
-               }
+               $receivers = ['to' => array_values($data['to']), 'cc' => array_values($data['cc']), 'bcc' => array_values($data['bcc']), 'audience' => array_values($data['audience'])];
 
                if (!$blindcopy) {
                        unset($receivers['bcc']);
                }
 
-               foreach (['to' => Tag::TO, 'cc' => Tag::CC, 'bcc' => Tag::BCC] as $element => $type) {
+               foreach (['to' => Tag::TO, 'cc' => Tag::CC, 'bcc' => Tag::BCC, 'audience' => Tag::AUDIENCE] as $element => $type) {
                        if (!empty($receivers[$element])) {
                                foreach ($receivers[$element] as $receiver) {
                                        if ($receiver == ActivityPub::PUBLIC_COLLECTION) {
@@ -840,6 +854,12 @@ class Transmitter
                        }
                }
 
+               if (!$blindcopy && count($receivers['audience']) == 1) {
+                       $receivers['audience'] = $receivers['audience'][0];
+               } elseif (!$receivers['audience']) {
+                       unset($receivers['audience']);
+               }
+
                return $receivers;
        }
 
@@ -976,7 +996,7 @@ class Transmitter
 
                $profile_uid = User::getIdForURL($item_profile['url']);
 
-               foreach (['to', 'cc', 'bto', 'bcc'] as $element) {
+               foreach (['to', 'cc', 'bto', 'bcc', 'audience'] as $element) {
                        if (empty($permissions[$element])) {
                                continue;
                        }
@@ -1000,7 +1020,7 @@ class Transmitter
                                                } else {
                                                        $target = $profile['sharedinbox'];
                                                }
-                                               if (!self::archivedInbox($target)) {
+                                               if (!self::archivedInbox($target) && !in_array($contact['id'], $inboxes[$target] ?? [])) {
                                                        $inboxes[$target][] = $contact['id'] ?? 0;
                                                }
                                        }
@@ -1101,12 +1121,14 @@ class Transmitter
 
                unset($data['cc']);
                unset($data['bcc']);
+               unset($data['audience']);
 
                $object['to'] = $data['to'];
                $object['tag'] = [['type' => 'Mention', 'href' => $object['to'][0], 'name' => '']];
 
                unset($object['cc']);
                unset($object['bcc']);
+               unset($object['audience']);
 
                $data['directMessage'] = true;