From: Michael <heluecht@pirati.ca>
Date: Thu, 15 Jun 2023 22:04:28 +0000 (+0000)
Subject: Better support for "audience" / simplified Lemmy processing
X-Git-Url: https://git.mxchange.org/?a=commitdiff_plain;h=6d911a8f395a99337d4317b2441dc529a74d9e45;p=friendica.git

Better support for "audience" / simplified Lemmy processing
---

diff --git a/src/Model/APContact.php b/src/Model/APContact.php
index dc062fe5b5..2a5b89928f 100644
--- a/src/Model/APContact.php
+++ b/src/Model/APContact.php
@@ -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)) {
diff --git a/src/Model/Contact.php b/src/Model/Contact.php
index 720d2638c4..1a52164561 100644
--- a/src/Model/Contact.php
+++ b/src/Model/Contact.php
@@ -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'];
diff --git a/src/Model/Item.php b/src/Model/Item.php
index cdeb6d3d13..51ac03b4ef 100644
--- a/src/Model/Item.php
+++ b/src/Model/Item.php
@@ -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;
 			}
diff --git a/src/Model/Tag.php b/src/Model/Tag.php
index 9f0f8d29a6..1645dc1255 100644
--- a/src/Model/Tag.php
+++ b/src/Model/Tag.php
@@ -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) {
diff --git a/src/Network/Probe.php b/src/Network/Probe.php
index b1931ae6d3..c7b6a84e8b 100644
--- a/src/Network/Probe.php
+++ b/src/Network/Probe.php
@@ -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
diff --git a/src/Protocol/ActivityPub/Delivery.php b/src/Protocol/ActivityPub/Delivery.php
index ddf5816ed5..04f5841c2e 100644
--- a/src/Protocol/ActivityPub/Delivery.php
+++ b/src/Protocol/ActivityPub/Delivery.php
@@ -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.
diff --git a/src/Protocol/ActivityPub/Processor.php b/src/Protocol/ActivityPub/Processor.php
index 453525454a..ac2e719718 100644
--- a/src/Protocol/ActivityPub/Processor.php
+++ b/src/Protocol/ActivityPub/Processor.php
@@ -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;
diff --git a/src/Protocol/ActivityPub/Receiver.php b/src/Protocol/ActivityPub/Receiver.php
index b272c31dd3..7a07e1a7f2 100644
--- a/src/Protocol/ActivityPub/Receiver.php
+++ b/src/Protocol/ActivityPub/Receiver.php
@@ -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'];
diff --git a/src/Protocol/ActivityPub/Transmitter.php b/src/Protocol/ActivityPub/Transmitter.php
index e817198ec6..ec61eb2eb3 100644
--- a/src/Protocol/ActivityPub/Transmitter.php
+++ b/src/Protocol/ActivityPub/Transmitter.php
@@ -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;