From f495741e621665f27b9ac456532d92461f8c484f Mon Sep 17 00:00:00 2001
From: Joas Schilling <coding@schilljs.com>
Date: Tue, 5 Mar 2024 13:05:05 +0100
Subject: [PATCH 1/5] feat(federation): Add columns to save the creation
 datetime and meta data

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 appinfo/info.xml                              |  2 +-
 .../Version19000Date20240305115243.php        | 63 +++++++++++++++++++
 2 files changed, 64 insertions(+), 1 deletion(-)
 create mode 100644 lib/Migration/Version19000Date20240305115243.php

diff --git a/appinfo/info.xml b/appinfo/info.xml
index 8af180d8da9..7eaad44889f 100644
--- a/appinfo/info.xml
+++ b/appinfo/info.xml
@@ -16,7 +16,7 @@ And in the works for the [coming versions](https://github.com/nextcloud/spreed/m
 
 	]]></description>
 
-	<version>19.0.0-dev.3</version>
+	<version>19.0.0-dev.4</version>
 	<licence>agpl</licence>
 
 	<author>Daniel Calviño Sánchez</author>
diff --git a/lib/Migration/Version19000Date20240305115243.php b/lib/Migration/Version19000Date20240305115243.php
new file mode 100644
index 00000000000..58545a26b74
--- /dev/null
+++ b/lib/Migration/Version19000Date20240305115243.php
@@ -0,0 +1,63 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2024 Joas Schilling <coding@schilljs.com>
+ *
+ * @author Joas Schilling <coding@schilljs.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Talk\Migration;
+
+use Closure;
+use OCP\DB\ISchemaWrapper;
+use OCP\DB\Types;
+use OCP\Migration\IOutput;
+use OCP\Migration\SimpleMigrationStep;
+
+/**
+ * Add creation datetime and meta-data columns to the proxy cache
+ */
+class Version19000Date20240305115243 extends SimpleMigrationStep {
+	/**
+	 * @param IOutput $output
+	 * @param Closure(): ISchemaWrapper $schemaClosure
+	 * @param array $options
+	 * @return null|ISchemaWrapper
+	 */
+	public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
+		/** @var ISchemaWrapper $schema */
+		$schema = $schemaClosure();
+
+		$table = $schema->getTable('talk_proxy_messages');
+		if (!$table->hasColumn('creation_datetime')) {
+			$table->addColumn('creation_datetime', Types::DATETIME, [
+				'notnull' => false,
+			]);
+			$table->addColumn('meta_data', Types::TEXT, [
+				'notnull' => false,
+			]);
+
+			return $schema;
+		}
+
+		return null;
+	}
+}

From d4c11cc68708324df6746db40d37f6a5ebdee8f0 Mon Sep 17 00:00:00 2001
From: Joas Schilling <coding@schilljs.com>
Date: Tue, 5 Mar 2024 14:40:47 +0100
Subject: [PATCH 2/5] fix(events): Expose the parent comment in events

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 docs/events.md                         | 2 ++
 lib/Chat/ChatManager.php               | 8 ++++----
 lib/Events/AMessageSentEvent.php       | 5 +++++
 lib/Events/ASystemMessageSentEvent.php | 4 +++-
 4 files changed, 14 insertions(+), 5 deletions(-)

diff --git a/docs/events.md b/docs/events.md
index ad21c67ff42..ea6bd421ec4 100644
--- a/docs/events.md
+++ b/docs/events.md
@@ -134,6 +134,7 @@ Used to parse mentions, replace parameters in messages with rich objects, transf
 * Before event: `OCA\Talk\Events\BeforeChatMessageSentEvent`
 * After event: `OCA\Talk\Events\ChatMessageSentEvent`
 * Since: 18.0.0
+* Since: 19.0.0 - Method `getParent()` was added
 
 ### Duplicate share sent
 
@@ -153,6 +154,7 @@ listen to the `OCA\Talk\Events\SystemMessagesMultipleSentEvent` event instead.
 * After event: `OCA\Talk\Events\SystemMessageSentEvent`
 * Final event: `OCA\Talk\Events\SystemMessagesMultipleSentEvent` - Only sent once as per above explanation
 * Since: 18.0.0
+* Since: 19.0.0 - Method `getParent()` was added
 
 ### Deprecated events
 
diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php
index 7a731213da3..e51f12a0ce3 100644
--- a/lib/Chat/ChatManager.php
+++ b/lib/Chat/ChatManager.php
@@ -173,7 +173,7 @@ public function addSystemMessage(
 
 		$shouldFlush = $this->notificationManager->defer();
 
-		$event = new BeforeSystemMessageSentEvent($chat, $comment, silent: $silent, skipLastActivityUpdate: $shouldSkipLastMessageUpdate);
+		$event = new BeforeSystemMessageSentEvent($chat, $comment, silent: $silent, skipLastActivityUpdate: $shouldSkipLastMessageUpdate, parent: $replyTo);
 		$this->dispatcher->dispatchTyped($event);
 		try {
 			$this->commentsManager->save($comment);
@@ -228,7 +228,7 @@ public function addSystemMessage(
 				}
 			}
 
-			$event = new SystemMessageSentEvent($chat, $comment, silent: $silent, skipLastActivityUpdate: $shouldSkipLastMessageUpdate);
+			$event = new SystemMessageSentEvent($chat, $comment, silent: $silent, skipLastActivityUpdate: $shouldSkipLastMessageUpdate, parent: $replyTo);
 			$this->dispatcher->dispatchTyped($event);
 		} catch (NotFoundException $e) {
 		}
@@ -326,7 +326,7 @@ public function sendMessage(Room $chat, ?Participant $participant, string $actor
 			]);
 		}
 
-		$event = new BeforeChatMessageSentEvent($chat, $comment, $participant, $silent);
+		$event = new BeforeChatMessageSentEvent($chat, $comment, $participant, $silent, $replyTo);
 		$this->dispatcher->dispatchTyped($event);
 
 		$shouldFlush = $this->notificationManager->defer();
@@ -371,7 +371,7 @@ public function sendMessage(Room $chat, ?Participant $participant, string $actor
 			// User was not mentioned, send a normal notification
 			$this->notifier->notifyOtherParticipant($chat, $comment, $alreadyNotifiedUsers, $silent);
 
-			$event = new ChatMessageSentEvent($chat, $comment, $participant, $silent);
+			$event = new ChatMessageSentEvent($chat, $comment, $participant, $silent, $replyTo);
 			$this->dispatcher->dispatchTyped($event);
 		} catch (NotFoundException $e) {
 		}
diff --git a/lib/Events/AMessageSentEvent.php b/lib/Events/AMessageSentEvent.php
index 8f3654c3121..9c8a5d7f47b 100644
--- a/lib/Events/AMessageSentEvent.php
+++ b/lib/Events/AMessageSentEvent.php
@@ -33,6 +33,7 @@ public function __construct(
 		protected IComment $comment,
 		protected ?Participant $participant = null,
 		protected bool $silent = false,
+		protected ?IComment $parent = null,
 	) {
 		parent::__construct(
 			$room,
@@ -50,4 +51,8 @@ public function getParticipant(): ?Participant {
 	public function isSilentMessage(): bool {
 		return $this->silent;
 	}
+
+	public function getParent(): ?IComment {
+		return $this->parent;
+	}
 }
diff --git a/lib/Events/ASystemMessageSentEvent.php b/lib/Events/ASystemMessageSentEvent.php
index a540edc5aa9..4dd55374b9d 100644
--- a/lib/Events/ASystemMessageSentEvent.php
+++ b/lib/Events/ASystemMessageSentEvent.php
@@ -33,13 +33,15 @@ public function __construct(
 		IComment $comment,
 		?Participant $participant = null,
 		bool $silent = false,
+		?IComment $parent = null,
 		protected bool $skipLastActivityUpdate = false,
 	) {
 		parent::__construct(
 			$room,
 			$comment,
 			$participant,
-			$silent
+			$silent,
+			$parent,
 		);
 	}
 

From 1a26108cdd7ae6680f22ee51b8763f1dea0850ed Mon Sep 17 00:00:00 2001
From: Joas Schilling <coding@schilljs.com>
Date: Tue, 5 Mar 2024 14:42:42 +0100
Subject: [PATCH 3/5] fix(API): Add constants for known comments metadata

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 lib/Chat/ChatManager.php                      | 21 ++++++++++---------
 lib/Chat/Parser/SystemMessage.php             |  2 +-
 lib/Chat/SystemMessage/Listener.php           |  5 +++--
 lib/Model/Message.php                         |  8 +++++--
 .../features/bootstrap/FeatureContext.php     |  4 ++--
 5 files changed, 23 insertions(+), 17 deletions(-)

diff --git a/lib/Chat/ChatManager.php b/lib/Chat/ChatManager.php
index e51f12a0ce3..6c0d3b4786d 100644
--- a/lib/Chat/ChatManager.php
+++ b/lib/Chat/ChatManager.php
@@ -34,6 +34,7 @@
 use OCA\Talk\Exceptions\MessagingNotAllowedException;
 use OCA\Talk\Exceptions\ParticipantNotFoundException;
 use OCA\Talk\Model\Attendee;
+use OCA\Talk\Model\Message;
 use OCA\Talk\Model\Poll;
 use OCA\Talk\Participant;
 use OCA\Talk\Room;
@@ -165,7 +166,7 @@ public function addSystemMessage(
 
 		if ($silent) {
 			$comment->setMetaData([
-				'silent' => true,
+				Message::METADATA_SILENT => true,
 			]);
 		}
 
@@ -322,7 +323,7 @@ public function sendMessage(Room $chat, ?Participant $participant, string $actor
 
 		if ($silent) {
 			$comment->setMetaData([
-				'silent' => true,
+				Message::METADATA_SILENT => true,
 			]);
 		}
 
@@ -500,11 +501,11 @@ public function deleteMessage(Room $chat, IComment $comment, Participant $partic
 		$comment->setVerb(self::VERB_MESSAGE_DELETED);
 
 		$metaData = $comment->getMetaData() ?? [];
-		if (isset($metaData['last_edited_by_type'])) {
+		if (isset($metaData[Message::METADATA_LAST_EDITED_BY_TYPE])) {
 			unset(
-				$metaData['last_edited_by_type'],
-				$metaData['last_edited_by_id'],
-				$metaData['last_edited_time']
+				$metaData[Message::METADATA_LAST_EDITED_BY_TYPE],
+				$metaData[Message::METADATA_LAST_EDITED_BY_ID],
+				$metaData[Message::METADATA_LAST_EDITED_TIME],
 			);
 			$comment->setMetaData($metaData);
 		}
@@ -557,12 +558,12 @@ public function editMessage(Room $chat, IComment $comment, Participant $particip
 		}
 
 		$metaData = $comment->getMetaData() ?? [];
-		$metaData['last_edited_by_type'] = $participant->getAttendee()->getActorType();
-		$metaData['last_edited_by_id'] = $participant->getAttendee()->getActorId();
-		$metaData['last_edited_time'] = $editTime->getTimestamp();
+		$metaData[Message::METADATA_LAST_EDITED_BY_TYPE] = $participant->getAttendee()->getActorType();
+		$metaData[Message::METADATA_LAST_EDITED_BY_ID] = $participant->getAttendee()->getActorId();
+		$metaData[Message::METADATA_LAST_EDITED_TIME] = $editTime->getTimestamp();
 		$comment->setMetaData($metaData);
 
-		$wasSilent = $metaData['silent'] ?? false;
+		$wasSilent = $metaData[Message::METADATA_SILENT] ?? false;
 
 		if (!$wasSilent) {
 			$mentionsBefore = $comment->getMentions();
diff --git a/lib/Chat/Parser/SystemMessage.php b/lib/Chat/Parser/SystemMessage.php
index d7f48e61106..645e0e14765 100644
--- a/lib/Chat/Parser/SystemMessage.php
+++ b/lib/Chat/Parser/SystemMessage.php
@@ -207,7 +207,7 @@ protected function parseMessage(Message $chatMessage): void {
 			}
 		} elseif ($message === 'call_started') {
 			$metaData = $comment->getMetaData() ?? [];
-			$silentCall = $metaData['silent'] ?? false;
+			$silentCall = $metaData[Message::METADATA_SILENT] ?? false;
 			if ($silentCall) {
 				if ($currentUserIsActor) {
 					$parsedMessage = $this->l->t('You started a silent call');
diff --git a/lib/Chat/SystemMessage/Listener.php b/lib/Chat/SystemMessage/Listener.php
index ddd680ab22d..8f0ff6839ad 100644
--- a/lib/Chat/SystemMessage/Listener.php
+++ b/lib/Chat/SystemMessage/Listener.php
@@ -43,6 +43,7 @@
 use OCA\Talk\Manager;
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\BreakoutRoom;
+use OCA\Talk\Model\Message;
 use OCA\Talk\Model\Session;
 use OCA\Talk\Participant;
 use OCA\Talk\Room;
@@ -425,8 +426,8 @@ protected function fixMimeTypeOfVoiceMessage(ShareCreatedEvent|BeforeDuplicateSh
 			}
 		}
 
-		if (isset($metaData['silent'])) {
-			$silent = (bool) $metaData['silent'];
+		if (isset($metaData[Message::METADATA_SILENT])) {
+			$silent = (bool) $metaData[Message::METADATA_SILENT];
 		} else {
 			$silent = false;
 		}
diff --git a/lib/Model/Message.php b/lib/Model/Message.php
index d279f583db7..49ae97d4f5d 100644
--- a/lib/Model/Message.php
+++ b/lib/Model/Message.php
@@ -36,6 +36,10 @@
  * @psalm-import-type TalkChatMessage from ResponseDefinitions
  */
 class Message {
+	public const METADATA_LAST_EDITED_BY_TYPE = 'last_edited_by_type';
+	public const METADATA_LAST_EDITED_BY_ID = 'last_edited_by_id';
+	public const METADATA_LAST_EDITED_TIME = 'last_edited_time';
+	public const METADATA_SILENT = 'silent';
 
 	/** @var bool */
 	protected $visible = true;
@@ -226,8 +230,8 @@ public function toArray(string $format): array {
 		}
 
 		$metaData = $this->comment->getMetaData() ?? [];
-		if (!empty($metaData['silent'])) {
-			$data['silent'] = true;
+		if (!empty($metaData[self::METADATA_SILENT])) {
+			$data[self::METADATA_SILENT] = true;
 		}
 
 		return $data;
diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php
index a462025eccb..d9fc9f60b22 100644
--- a/tests/integration/features/bootstrap/FeatureContext.php
+++ b/tests/integration/features/bootstrap/FeatureContext.php
@@ -2728,16 +2728,16 @@ protected function compareDataResponse(TableNode $formData = null) {
 					$data['reactionsSelf'] = null;
 				}
 			}
+
 			if ($includeLastEdit) {
 				$data['lastEditActorType'] = $message['lastEditActorType'] ?? '';
 				$data['lastEditActorDisplayName'] = $message['lastEditActorDisplayName'] ?? '';
 				$data['lastEditActorId'] = $message['lastEditActorId'] ?? '';
-				if ($message['lastEditActorType'] === 'guests') {
+				if (($message['lastEditActorType'] ?? '') === 'guests') {
 					$data['lastEditActorId'] = self::$sessionIdToUser[$message['lastEditActorId']];
 				}
 			}
 
-
 			return $data;
 		}, $messages, $expected));
 	}

From 578b1d5873784490daed3d47540ae30781581c8d Mon Sep 17 00:00:00 2001
From: Joas Schilling <coding@schilljs.com>
Date: Wed, 6 Mar 2024 12:31:22 +0100
Subject: [PATCH 4/5] feat(federation): Allow to convert a single type and id

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 lib/Federation/Proxy/TalkV1/UserConverter.php | 22 ++++++++++++++++++-
 1 file changed, 21 insertions(+), 1 deletion(-)

diff --git a/lib/Federation/Proxy/TalkV1/UserConverter.php b/lib/Federation/Proxy/TalkV1/UserConverter.php
index debc018839a..d58aa8e4004 100644
--- a/lib/Federation/Proxy/TalkV1/UserConverter.php
+++ b/lib/Federation/Proxy/TalkV1/UserConverter.php
@@ -45,6 +45,26 @@ public function __construct(
 	) {
 	}
 
+	/**
+	 * @return array{type: string, id: string}
+	 */
+	public function convertTypeAndId(Room $room, string $type, string $id): array {
+		if ($type === Attendee::ACTOR_USERS) {
+			$type = Attendee::ACTOR_FEDERATED_USERS;
+			$id .= '@' . $room->getRemoteServer();
+		} elseif ($type === Attendee::ACTOR_FEDERATED_USERS) {
+			$localParticipants = $this->getLocalParticipants($room);
+			if (isset($localParticipants[$id])) {
+				$local = $localParticipants[$id];
+
+				$type = Attendee::ACTOR_USERS;
+				$id = $local['userId'];
+			}
+		}
+
+		return ['type' => $type, 'id' => $id];
+	}
+
 	public function convertAttendee(Room $room, array $entry, string $typeField, string $idField, string $displayNameField): array {
 		if (!isset($entry[$typeField])) {
 			return $entry;
@@ -89,7 +109,7 @@ protected function convertMessageParameter(Room $room, array $parameter): array
 		return $parameter;
 	}
 
-	protected function convertMessageParameters(Room $room, array $message): array {
+	public function convertMessageParameters(Room $room, array $message): array {
 		$message['messageParameters'] = array_map(
 			fn (array $message): array => $this->convertMessageParameter($room, $message),
 			$message['messageParameters']

From cdc988c7ffc28f212f6df847a02f8136ada0d9ea Mon Sep 17 00:00:00 2001
From: Joas Schilling <coding@schilljs.com>
Date: Thu, 29 Feb 2024 17:13:31 +0100
Subject: [PATCH 5/5] feat(federation): Implement notifications for mentions,
 reply and full

Signed-off-by: Joas Schilling <coding@schilljs.com>
---
 lib/Chat/MessageParser.php                    |  26 +++-
 lib/Chat/Parser/UserMention.php               |   2 +-
 lib/Controller/ChatController.php             |   2 +-
 lib/Federation/BackendNotifier.php            |   1 +
 .../CloudFederationProviderTalk.php           |  39 ++++-
 .../TalkV1/Notifier/MessageSentListener.php   |  14 +-
 lib/Manager.php                               |  10 +-
 lib/Model/Message.php                         |  13 +-
 ...acheMessages.php => ProxyCacheMessage.php} |  32 +++-
 ...Mapper.php => ProxyCacheMessageMapper.php} |  26 +++-
 lib/Notification/FederationChatNotifier.php   | 140 ++++++++++++++++++
 lib/Notification/Notifier.php                 |  64 ++++----
 lib/Service/RoomFormatter.php                 |   6 +-
 .../features/bootstrap/FeatureContext.php     |   9 +-
 .../features/federation/chat.feature          |  29 ++++
 tests/php/Federation/FederationTest.php       |  14 +-
 tests/php/Notification/NotifierTest.php       |   4 +
 17 files changed, 365 insertions(+), 66 deletions(-)
 rename lib/Model/{ProxyCacheMessages.php => ProxyCacheMessage.php} (75%)
 rename lib/Model/{ProxyCacheMessagesMapper.php => ProxyCacheMessageMapper.php} (72%)
 create mode 100644 lib/Notification/FederationChatNotifier.php

diff --git a/lib/Chat/MessageParser.php b/lib/Chat/MessageParser.php
index 77e42316a9b..de88ab506bc 100644
--- a/lib/Chat/MessageParser.php
+++ b/lib/Chat/MessageParser.php
@@ -29,6 +29,7 @@
 use OCA\Talk\MatterbridgeManager;
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\Message;
+use OCA\Talk\Model\ProxyCacheMessage;
 use OCA\Talk\Participant;
 use OCA\Talk\Room;
 use OCA\Talk\Service\BotService;
@@ -51,10 +52,10 @@ class MessageParser {
 	protected array $botNames = [];
 
 	public function __construct(
-		protected IEventDispatcher   $dispatcher,
-		protected IUserManager       $userManager,
+		protected IEventDispatcher $dispatcher,
+		protected IUserManager $userManager,
 		protected ParticipantService $participantService,
-		protected BotService         $botService,
+		protected BotService $botService,
 	) {
 	}
 
@@ -62,6 +63,25 @@ public function createMessage(Room $room, ?Participant $participant, IComment $c
 		return new Message($room, $participant, $comment, $l);
 	}
 
+	public function createMessageFromProxyCache(Room $room, ?Participant $participant, ProxyCacheMessage $proxy, IL10N $l): Message {
+		$message = new Message($room, $participant, null, $l, $proxy);
+
+		$message->setActor(
+			$proxy->getActorType(),
+			$proxy->getActorId(),
+			$proxy->getActorDisplayName(),
+		);
+
+		$message->setMessageType($proxy->getMessageType());
+
+		$message->setMessage(
+			$proxy->getMessage(),
+			$proxy->getParsedMessageParameters()
+		);
+
+		return $message;
+	}
+
 	public function parseMessage(Message $message): void {
 		$message->setMessage($message->getComment()->getMessage(), []);
 
diff --git a/lib/Chat/Parser/UserMention.php b/lib/Chat/Parser/UserMention.php
index 257ae64eeaa..1b241088ea9 100644
--- a/lib/Chat/Parser/UserMention.php
+++ b/lib/Chat/Parser/UserMention.php
@@ -162,7 +162,7 @@ protected function parseMessage(Message $chatMessage): void {
 				$messageParameters[$mentionParameterId] = [
 					'type' => $mention['type'],
 					'id' => $chatMessage->getRoom()->getToken(),
-					'name' => $chatMessage->getRoom()->getDisplayName($userId),
+					'name' => $chatMessage->getRoom()->getDisplayName($userId, true),
 					'call-type' => $this->getRoomType($chatMessage->getRoom()),
 					'icon-url' => $this->avatarService->getAvatarUrl($chatMessage->getRoom()),
 				];
diff --git a/lib/Controller/ChatController.php b/lib/Controller/ChatController.php
index 5b6a9804902..bad6ee7ea7e 100644
--- a/lib/Controller/ChatController.php
+++ b/lib/Controller/ChatController.php
@@ -530,7 +530,7 @@ protected function prepareCommentsAsDataResponse(array $comments, int $lastCommo
 			$message = $this->messageParser->createMessage($this->room, $this->participant, $comment, $this->l);
 			$this->messageParser->parseMessage($message);
 
-			$expireDate = $message->getComment()->getExpireDate();
+			$expireDate = $message->getExpirationDateTime();
 			if ($expireDate instanceof \DateTime && $expireDate < $now) {
 				$commentIdToIndex[$id] = null;
 				continue;
diff --git a/lib/Federation/BackendNotifier.php b/lib/Federation/BackendNotifier.php
index 1b13b260ee7..ccb422b23bd 100644
--- a/lib/Federation/BackendNotifier.php
+++ b/lib/Federation/BackendNotifier.php
@@ -313,6 +313,7 @@ public function sendRoomModifiedUpdate(
 	 * Send information to remote participants that a message was posted
 	 * Sent from Host server to Remote participant server
 	 *
+	 * @param array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string} $messageData
 	 * @param array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool} $unreadInfo
 	 */
 	public function sendMessageUpdate(
diff --git a/lib/Federation/CloudFederationProviderTalk.php b/lib/Federation/CloudFederationProviderTalk.php
index adf439b65c2..abe0dccd632 100644
--- a/lib/Federation/CloudFederationProviderTalk.php
+++ b/lib/Federation/CloudFederationProviderTalk.php
@@ -34,13 +34,15 @@
 use OCA\Talk\Events\AttendeesAddedEvent;
 use OCA\Talk\Exceptions\ParticipantNotFoundException;
 use OCA\Talk\Exceptions\RoomNotFoundException;
+use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
 use OCA\Talk\Manager;
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\AttendeeMapper;
 use OCA\Talk\Model\Invitation;
 use OCA\Talk\Model\InvitationMapper;
-use OCA\Talk\Model\ProxyCacheMessages;
-use OCA\Talk\Model\ProxyCacheMessagesMapper;
+use OCA\Talk\Model\ProxyCacheMessage;
+use OCA\Talk\Model\ProxyCacheMessageMapper;
+use OCA\Talk\Notification\FederationChatNotifier;
 use OCA\Talk\Participant;
 use OCA\Talk\Room;
 use OCA\Talk\Service\ParticipantService;
@@ -87,7 +89,9 @@ public function __construct(
 		private ISession $session,
 		private IEventDispatcher $dispatcher,
 		private LoggerInterface $logger,
-		private ProxyCacheMessagesMapper $proxyCacheMessagesMapper,
+		private ProxyCacheMessageMapper $proxyCacheMessageMapper,
+		private FederationChatNotifier $federationChatNotifier,
+		private UserConverter $userConverter,
 		ICacheFactory $cacheFactory,
 	) {
 		$this->proxyCacheMessages = $cacheFactory->isAvailable() ? $cacheFactory->createDistributed('talk/pcm/') : null;
@@ -316,7 +320,7 @@ private function roomModified(int $remoteAttendeeId, array $notification): array
 
 	/**
 	 * @param int $remoteAttendeeId
-	 * @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool}} $notification
+	 * @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool}} $notification
 	 * @return array
 	 * @throws ActionNotSupportedException
 	 * @throws AuthenticationFailedException
@@ -335,7 +339,7 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra
 			throw new ShareNotFound();
 		}
 
-		$message = new ProxyCacheMessages();
+		$message = new ProxyCacheMessage();
 		$message->setLocalToken($room->getToken());
 		$message->setRemoteServerUrl($notification['remoteServerUrl']);
 		$message->setRemoteToken($notification['remoteToken']);
@@ -346,12 +350,25 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra
 		$message->setMessageType($notification['messageData']['messageType']);
 		$message->setSystemMessage($notification['messageData']['systemMessage']);
 		if ($notification['messageData']['expirationDatetime']) {
-			$message->setExpirationDatetime(new \DateTimeImmutable($notification['messageData']['expirationDatetime']));
+			$message->setExpirationDatetime(new \DateTime($notification['messageData']['expirationDatetime']));
 		}
+
+		// We transform the parameters when storing in the PCM, so we only have
+		// to do it once for each message.
+		$convertedParameters = $this->userConverter->convertMessageParameters($room, [
+			'message' => $notification['messageData']['message'],
+			'messageParameters' => json_decode($notification['messageData']['messageParameter'], true, flags: JSON_THROW_ON_ERROR),
+		]);
+		$notification['messageData']['message'] = $convertedParameters['message'];
+		$notification['messageData']['messageParameter'] = json_encode($convertedParameters['messageParameters'], JSON_THROW_ON_ERROR);
+
 		$message->setMessage($notification['messageData']['message']);
 		$message->setMessageParameters($notification['messageData']['messageParameter']);
+		$message->setCreationDatetime(new \DateTime($notification['messageData']['creationDatetime']));
+		$message->setMetaData($notification['messageData']['metaData']);
+
 		try {
-			$this->proxyCacheMessagesMapper->insert($message);
+			$this->proxyCacheMessageMapper->insert($message);
 
 			$lastMessageId = $room->getLastMessageId();
 			if ($notification['messageData']['remoteMessageId'] > $lastMessageId) {
@@ -374,6 +391,12 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra
 				$this->logger->error('Error saving proxy cache message failed: ' . $e->getMessage(), ['exception' => $e]);
 				throw $e;
 			}
+
+			$message = $this->proxyCacheMessageMapper->findByRemote(
+				$notification['remoteServerUrl'],
+				$notification['remoteToken'],
+				$notification['messageData']['remoteMessageId'],
+			);
 		}
 
 		try {
@@ -390,6 +413,8 @@ private function messagePosted(int $remoteAttendeeId, array $notification): arra
 			$notification['unreadInfo']['unreadMentionDirect'],
 		);
 
+		$this->federationChatNotifier->handleChatMessage($room, $participant, $message, $notification);
+
 		return [];
 	}
 
diff --git a/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php b/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php
index e6920cc0654..fb795b80425 100644
--- a/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php
+++ b/lib/Federation/Proxy/TalkV1/Notifier/MessageSentListener.php
@@ -32,7 +32,9 @@
 use OCA\Talk\Events\SystemMessagesMultipleSentEvent;
 use OCA\Talk\Federation\BackendNotifier;
 use OCA\Talk\Model\Attendee;
+use OCA\Talk\Model\ProxyCacheMessage;
 use OCA\Talk\Service\ParticipantService;
+use OCP\Comments\IComment;
 use OCP\EventDispatcher\Event;
 use OCP\EventDispatcher\IEventListener;
 use OCP\Federation\ICloudIdManager;
@@ -78,6 +80,14 @@ public function handle(Event $event): void {
 		}
 
 		$expireDate = $event->getComment()->getExpireDate();
+		$creationDate = $event->getComment()->getCreationDateTime();
+
+		$metaData = $event->getComment()->getMetaData() ?? [];
+		$parent = $event->getParent();
+		if ($parent instanceof IComment) {
+			$metaData[ProxyCacheMessage::METADATA_REPLYTO_TYPE] = $parent->getActorType();
+			$metaData[ProxyCacheMessage::METADATA_REPLYTO_ID] = $parent->getActorId();
+		}
 
 		$messageData = [
 			'remoteMessageId' => (int) $event->getComment()->getId(),
@@ -88,7 +98,9 @@ public function handle(Event $event): void {
 			'systemMessage' => $chatMessage->getMessageType() === ChatManager::VERB_SYSTEM ? $chatMessage->getMessageRaw() : '',
 			'expirationDatetime' => $expireDate ? $expireDate->format(\DateTime::ATOM) : '',
 			'message' => $chatMessage->getMessage(),
-			'messageParameter' => json_encode($chatMessage->getMessageParameters()),
+			'messageParameter' => json_encode($chatMessage->getMessageParameters(), JSON_THROW_ON_ERROR),
+			'creationDatetime' => $creationDate->format(\DateTime::ATOM),
+			'metaData' => json_encode($metaData, JSON_THROW_ON_ERROR),
 		];
 
 		$participants = $this->participantService->getParticipantsByActorType($event->getRoom(), Attendee::ACTOR_FEDERATED_USERS);
diff --git a/lib/Manager.php b/lib/Manager.php
index a3ff4435722..2761fb8726d 100644
--- a/lib/Manager.php
+++ b/lib/Manager.php
@@ -1096,10 +1096,6 @@ public function resolveRoomDisplayName(Room $room, string $userId, bool $forceNa
 			return $this->l->t('Talk updates ✅');
 		}
 
-		if ($forceName) {
-			return $room->getName();
-		}
-
 		if ($this->federationAuthenticator->isFederationRequest()) {
 			try {
 				$authenticatedRoom = $this->federationAuthenticator->getRoom();
@@ -1110,7 +1106,7 @@ public function resolveRoomDisplayName(Room $room, string $userId, bool $forceNa
 			}
 		}
 
-		if ($userId === '' && $room->getType() !== Room::TYPE_PUBLIC) {
+		if (!$forceName && $userId === '' && $room->getType() !== Room::TYPE_PUBLIC) {
 			return $this->l->t('Private conversation');
 		}
 
@@ -1151,6 +1147,10 @@ public function resolveRoomDisplayName(Room $room, string $userId, bool $forceNa
 			return $otherParticipant;
 		}
 
+		if ($forceName) {
+			return $room->getName();
+		}
+
 		if (!$this->isRoomListableByUser($room, $userId)) {
 			try {
 				if ($userId === '') {
diff --git a/lib/Model/Message.php b/lib/Model/Message.php
index 49ae97d4f5d..6f2eb993301 100644
--- a/lib/Model/Message.php
+++ b/lib/Model/Message.php
@@ -80,8 +80,9 @@ class Message {
 	public function __construct(
 		protected Room $room,
 		protected ?Participant $participant,
-		protected IComment $comment,
+		protected ?IComment $comment,
 		protected IL10N $l,
+		protected ?ProxyCacheMessage $proxy = null,
 	) {
 	}
 
@@ -93,7 +94,7 @@ public function getRoom(): Room {
 		return $this->room;
 	}
 
-	public function getComment(): IComment {
+	public function getComment(): ?IComment {
 		return $this->comment;
 	}
 
@@ -109,6 +110,14 @@ public function getParticipant(): ?Participant {
 	 * Parsed message information
 	 */
 
+	public function getMessageId(): int {
+		return $this->comment ? (int) $this->comment->getId() : $this->proxy->getRemoteMessageId();
+	}
+
+	public function getExpirationDateTime(): ?\DateTimeInterface {
+		return $this->comment ? $this->comment->getExpireDate() : $this->proxy->getExpirationDatetime();
+	}
+
 	public function setVisibility(bool $visible): void {
 		$this->visible = $visible;
 	}
diff --git a/lib/Model/ProxyCacheMessages.php b/lib/Model/ProxyCacheMessage.php
similarity index 75%
rename from lib/Model/ProxyCacheMessages.php
rename to lib/Model/ProxyCacheMessage.php
index 9803f3e2b70..e08afbb3914 100644
--- a/lib/Model/ProxyCacheMessages.php
+++ b/lib/Model/ProxyCacheMessage.php
@@ -3,7 +3,7 @@
 declare(strict_types=1);
 
 /**
- * @copyright Copyright (c) 2022 Joas Schilling <coding@schilljs.com>
+ * @copyright Copyright (c) 2024 Joas Schilling <coding@schilljs.com>
  *
  * @author Joas Schilling <coding@schilljs.com>
  *
@@ -48,16 +48,23 @@
  * @method string getMessageType()
  * @method void setSystemMessage(?string $systemMessage)
  * @method string|null getSystemMessage()
- * @method void setExpirationDatetime(?\DateTimeImmutable $expirationDatetime)
- * @method \DateTimeImmutable|null getExpirationDatetime()
+ * @method void setExpirationDatetime(?\DateTime $expirationDatetime)
+ * @method \DateTime|null getExpirationDatetime()
  * @method void setMessage(?string $message)
  * @method string|null getMessage()
  * @method void setMessageParameters(?string $messageParameters)
  * @method string|null getMessageParameters()
+ * @method void setCreationDatetime(?\DateTime $creationDatetime)
+ * @method \DateTime|null getCreationDatetime()
+ * @method void setMetaData(?string $metaData)
+ * @method string|null getMetaData()
  *
  * @psalm-import-type TalkRoomProxyMessage from ResponseDefinitions
  */
-class ProxyCacheMessages extends Entity implements \JsonSerializable {
+class ProxyCacheMessage extends Entity implements \JsonSerializable {
+	public const METADATA_REPLYTO_TYPE = 'replyToActorType';
+	public const METADATA_REPLYTO_ID = 'replyToActorId';
+
 
 	protected string $localToken = '';
 	protected string $remoteServerUrl = '';
@@ -68,9 +75,11 @@ class ProxyCacheMessages extends Entity implements \JsonSerializable {
 	protected ?string $actorDisplayName = null;
 	protected ?string $messageType = null;
 	protected ?string $systemMessage = null;
-	protected ?\DateTimeImmutable $expirationDatetime = null;
+	protected ?\DateTime $expirationDatetime = null;
 	protected ?string $message = null;
 	protected ?string $messageParameters = null;
+	protected ?\DateTime $creationDatetime = null;
+	protected ?string $metaData = null;
 
 	public function __construct() {
 		$this->addType('localToken', 'string');
@@ -85,6 +94,16 @@ public function __construct() {
 		$this->addType('expirationDatetime', 'datetime');
 		$this->addType('message', 'string');
 		$this->addType('messageParameters', 'string');
+		$this->addType('creationDatetime', 'datetime');
+		$this->addType('metaData', 'string');
+	}
+
+	public function getParsedMessageParameters(): array {
+		return json_decode($this->getMessageParameters() ?? '[]', true);
+	}
+
+	public function getParsedMetaData(): array {
+		return json_decode($this->getMetaData() ?? '[]', true);
 	}
 
 	/**
@@ -100,11 +119,12 @@ public function jsonSerialize(): array {
 			'actorType' => $this->getActorType(),
 			'actorId' => $this->getActorId(),
 			'actorDisplayName' => $this->getActorDisplayName(),
+			'timestamp' => $this->getCreationDatetime()->getTimestamp(),
 			'expirationTimestamp' => $expirationTimestamp,
 			'messageType' => $this->getMessageType(),
 			'systemMessage' => $this->getSystemMessage() ?? '',
 			'message' => $this->getMessage() ?? '',
-			'messageParameters' => json_decode($this->getMessageParameters() ?? '[]', true),
+			'messageParameters' => $this->getParsedMessageParameters(),
 		];
 	}
 }
diff --git a/lib/Model/ProxyCacheMessagesMapper.php b/lib/Model/ProxyCacheMessageMapper.php
similarity index 72%
rename from lib/Model/ProxyCacheMessagesMapper.php
rename to lib/Model/ProxyCacheMessageMapper.php
index 7d6855fa143..2f1664949c8 100644
--- a/lib/Model/ProxyCacheMessagesMapper.php
+++ b/lib/Model/ProxyCacheMessageMapper.php
@@ -32,24 +32,36 @@
 use OCP\IDBConnection;
 
 /**
- * @method ProxyCacheMessages mapRowToEntity(array $row)
- * @method ProxyCacheMessages findEntity(IQueryBuilder $query)
- * @method ProxyCacheMessages[] findEntities(IQueryBuilder $query)
- * @template-extends QBMapper<ProxyCacheMessages>
+ * @method ProxyCacheMessage mapRowToEntity(array $row)
+ * @method ProxyCacheMessage findEntity(IQueryBuilder $query)
+ * @method ProxyCacheMessage[] findEntities(IQueryBuilder $query)
+ * @template-extends QBMapper<ProxyCacheMessage>
  */
-class ProxyCacheMessagesMapper extends QBMapper {
+class ProxyCacheMessageMapper extends QBMapper {
 	use TTransactional;
 
 	public function __construct(
 		IDBConnection $db,
 	) {
-		parent::__construct($db, 'talk_proxy_messages', ProxyCacheMessages::class);
+		parent::__construct($db, 'talk_proxy_messages', ProxyCacheMessage::class);
 	}
 
 	/**
 	 * @throws DoesNotExistException
 	 */
-	public function findByRemote(string $remoteServerUrl, string $remoteToken, int $remoteMessageId): ProxyCacheMessages {
+	public function findById(int $proxyId): ProxyCacheMessage {
+		$query = $this->db->getQueryBuilder();
+		$query->select('*')
+			->from($this->getTableName())
+			->where($query->expr()->eq('id', $query->createNamedParameter($proxyId, IQueryBuilder::PARAM_INT)));
+
+		return $this->findEntity($query);
+	}
+
+	/**
+	 * @throws DoesNotExistException
+	 */
+	public function findByRemote(string $remoteServerUrl, string $remoteToken, int $remoteMessageId): ProxyCacheMessage {
 		$query = $this->db->getQueryBuilder();
 		$query->select('*')
 			->from($this->getTableName())
diff --git a/lib/Notification/FederationChatNotifier.php b/lib/Notification/FederationChatNotifier.php
new file mode 100644
index 00000000000..8a630c541b0
--- /dev/null
+++ b/lib/Notification/FederationChatNotifier.php
@@ -0,0 +1,140 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * @copyright Copyright (c) 2024 Joas Schilling <coding@schilljs.com>
+ *
+ * @author Joas Schilling <coding@schilljs.com>
+ *
+ * @license GNU AGPL version 3 or any later version
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+namespace OCA\Talk\Notification;
+
+use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
+use OCA\Talk\Model\Attendee;
+use OCA\Talk\Model\Message;
+use OCA\Talk\Model\ProxyCacheMessage;
+use OCA\Talk\Participant;
+use OCA\Talk\Room;
+use OCP\AppFramework\Services\IAppConfig;
+use OCP\Notification\IManager;
+use OCP\Notification\INotification;
+
+class FederationChatNotifier {
+	public function __construct(
+		protected IAppConfig $appConfig,
+		protected IManager $notificationManager,
+		protected UserConverter $userConverter,
+	) {
+	}
+
+	/**
+	 * @param array{remoteServerUrl: string, sharedSecret: string, remoteToken: string, messageData: array{remoteMessageId: int, actorType: string, actorId: string, actorDisplayName: string, messageType: string, systemMessage: string, expirationDatetime: string, message: string, messageParameter: string, creationDatetime: string, metaData: string}, unreadInfo: array{unreadMessages: int, unreadMention: bool, unreadMentionDirect: bool}} $inboundNotification
+	 */
+	public function handleChatMessage(Room $room, Participant $participant, ProxyCacheMessage $message, array $inboundNotification): void {
+		$metaData = json_decode($inboundNotification['messageData']['metaData'] ?? '', true, flags: JSON_THROW_ON_ERROR);
+
+		if (isset($metaData[Message::METADATA_SILENT])) {
+			// Silent message, skip notification handling
+			return;
+		}
+
+		// Also notify default participants in one-to-one chats or when the admin default is "always"
+		$defaultLevel = $this->appConfig->getAppValueInt('default_group_notification', Participant::NOTIFY_MENTION);
+		if ($participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_MENTION
+			|| ($defaultLevel !== Participant::NOTIFY_NEVER && $participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_DEFAULT)) {
+			if ($this->isRepliedTo($room, $participant, $metaData)) {
+				$notification = $this->createNotification($room, $message, 'reply');
+				$notification->setUser($participant->getAttendee()->getActorId());
+				$this->notificationManager->notify($notification);
+			} elseif ($this->isMentioned($participant, $message)) {
+				$notification = $this->createNotification($room, $message, 'mention');
+				$notification->setUser($participant->getAttendee()->getActorId());
+				$this->notificationManager->notify($notification);
+			} elseif ($this->isMentionedAll($room, $message)) {
+				$notification = $this->createNotification($room, $message, 'mention_all');
+				$notification->setUser($participant->getAttendee()->getActorId());
+				$this->notificationManager->notify($notification);
+			}
+		} elseif ($participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_ALWAYS
+			|| ($defaultLevel === Participant::NOTIFY_ALWAYS && $participant->getAttendee()->getNotificationLevel() === Participant::NOTIFY_DEFAULT)) {
+			$notification = $this->createNotification($room, $message, 'chat');
+			$notification->setUser($participant->getAttendee()->getActorId());
+			$this->notificationManager->notify($notification);
+		}
+	}
+
+	protected function isRepliedTo(Room $room, Participant $participant, array $metaData): bool {
+		if ($metaData[ProxyCacheMessage::METADATA_REPLYTO_TYPE] !== Attendee::ACTOR_FEDERATED_USERS) {
+			return false;
+		}
+
+		$repliedTo = $this->userConverter->convertTypeAndId($room, $metaData[ProxyCacheMessage::METADATA_REPLYTO_TYPE], $metaData[ProxyCacheMessage::METADATA_REPLYTO_ID]);
+		return $repliedTo['type'] === $participant->getAttendee()->getActorType()
+			&& $repliedTo['id'] === $participant->getAttendee()->getActorId();
+	}
+
+	protected function isMentioned(Participant $participant, ProxyCacheMessage $message): bool {
+		if ($participant->getAttendee()->getActorType() !== Attendee::ACTOR_USERS) {
+			return false;
+		}
+
+		foreach ($message->getParsedMessageParameters() as $parameter) {
+			if ($parameter['type'] === 'user' // RichObjectDefinition, not Attendee::ACTOR_USERS
+				&& $parameter['id'] === $participant->getAttendee()->getActorId()
+				&& empty($parameter['server'])) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	protected function isMentionedAll(Room $room, ProxyCacheMessage $message): bool {
+		foreach ($message->getParsedMessageParameters() as $parameter) {
+			if ($parameter['type'] === 'call' // RichObjectDefinition
+				&& $parameter['id'] === $room->getRemoteToken()) {
+				return true;
+			}
+		}
+
+		return false;
+	}
+
+	/**
+	 * Creates a notification for the given proxy message and mentioned users
+	 */
+	protected function createNotification(Room $chat, ProxyCacheMessage $message, string $subject, array $subjectData = []): INotification {
+		$subjectData['userType'] = $message->getActorType();
+		$subjectData['userId'] = $message->getActorId();
+
+		$notification = $this->notificationManager->createNotification();
+		$notification
+			->setApp('spreed')
+			->setObject('chat', $chat->getToken())
+			->setSubject($subject, $subjectData)
+			->setMessage($message->getMessageType(), [
+				'proxyId' => $message->getId(),
+				// FIXME Store more info to allow querying remote?
+			])
+			->setDateTime($message->getCreationDatetime());
+
+		return $notification;
+	}
+}
diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php
index e29f7b7d6ff..939c7b8da90 100644
--- a/lib/Notification/Notifier.php
+++ b/lib/Notification/Notifier.php
@@ -36,6 +36,8 @@
 use OCA\Talk\Manager;
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\BotServerMapper;
+use OCA\Talk\Model\Message;
+use OCA\Talk\Model\ProxyCacheMessageMapper;
 use OCA\Talk\Participant;
 use OCA\Talk\Room;
 use OCA\Talk\Service\AvatarService;
@@ -83,6 +85,7 @@ public function __construct(
 		protected AvatarService $avatarService,
 		protected INotificationManager $notificationManager,
 		CommentsManager $commentManager,
+		protected ProxyCacheMessageMapper $proxyCacheMessageMapper,
 		protected MessageParser $messageParser,
 		protected IRootFolder $rootFolder,
 		protected ITimeFactory $timeFactory,
@@ -517,42 +520,51 @@ protected function parseChatMessage(INotification $notification, Room $room, Par
 		];
 
 		$messageParameters = $notification->getMessageParameters();
-		if (!isset($messageParameters['commentId'])) {
+		if (!isset($messageParameters['commentId']) && !isset($messageParameters['proxyId'])) {
 			throw new AlreadyProcessedException();
 		}
 
-		if (!$this->notificationManager->isPreparingPushNotification()
-			&& $notification->getObjectType() === 'chat'
-			/**
-			 * Notification only contains the message id of the target comment
-			 * not the one of the reaction, so we can't determine if it was read.
-			 * @see Listener::markReactionNotificationsRead()
-			 */
-			&& $notification->getSubject() !== 'reaction'
-			&& ((int) $messageParameters['commentId']) <= $participant->getAttendee()->getLastReadMessage()) {
-			// Mark notifications of messages that are read as processed
-			throw new AlreadyProcessedException();
-		}
+		if (isset($messageParameters['commentId'])) {
+			if (!$this->notificationManager->isPreparingPushNotification()
+				&& $notification->getObjectType() === 'chat'
+				/**
+				 * Notification only contains the message id of the target comment
+				 * not the one of the reaction, so we can't determine if it was read.
+				 * @see Listener::markReactionNotificationsRead()
+				 */
+				&& $notification->getSubject() !== 'reaction'
+				&& ((int) $messageParameters['commentId']) <= $participant->getAttendee()->getLastReadMessage()) {
+				// Mark notifications of messages that are read as processed
+				throw new AlreadyProcessedException();
+			}
 
-		try {
-			$comment = $this->commentManager->get($messageParameters['commentId']);
-		} catch (NotFoundException $e) {
-			throw new AlreadyProcessedException();
-		}
+			try {
+				$comment = $this->commentManager->get($messageParameters['commentId']);
+			} catch (NotFoundException $e) {
+				throw new AlreadyProcessedException();
+			}
 
-		$message = $this->messageParser->createMessage($room, $participant, $comment, $l);
-		$this->messageParser->parseMessage($message);
+			$message = $this->messageParser->createMessage($room, $participant, $comment, $l);
+			$this->messageParser->parseMessage($message);
 
-		if (!$message->getVisibility()) {
-			throw new AlreadyProcessedException();
+			if (!$message->getVisibility()) {
+				throw new AlreadyProcessedException();
+			}
+		} else {
+			try {
+				$proxy = $this->proxyCacheMessageMapper->findById($messageParameters['proxyId']);
+				$message = $this->messageParser->createMessageFromProxyCache($room, $participant, $proxy, $l);
+			} catch (DoesNotExistException) {
+				throw new AlreadyProcessedException();
+			}
 		}
 
 		// Set the link to the specific message
-		$notification->setLink($this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]) . '#message_' . $comment->getId());
+		$notification->setLink($this->url->linkToRouteAbsolute('spreed.Page.showCall', ['token' => $room->getToken()]) . '#message_' . $message->getMessageId());
 
 		$now = $this->timeFactory->getDateTime();
-		$expireDate = $message->getComment()->getExpireDate();
-		if ($expireDate instanceof \DateTime && $expireDate < $now) {
+		$expireDate = $message->getExpirationDateTime();
+		if ($expireDate instanceof \DateTimeInterface && $expireDate < $now) {
 			throw new AlreadyProcessedException();
 		}
 
@@ -576,7 +588,7 @@ protected function parseChatMessage(INotification $notification, Room $room, Par
 			$notification->setRichMessage($message->getMessage(), $message->getMessageParameters());
 
 			// Forward the message ID as well to the clients, so they can quote the message on replies
-			$notification->setObject($notification->getObjectType(), $notification->getObjectId() . '/' . $comment->getId());
+			$notification->setObject($notification->getObjectType(), $notification->getObjectId() . '/' . $message->getMessageId());
 		}
 
 		$richSubjectParameters = [
diff --git a/lib/Service/RoomFormatter.php b/lib/Service/RoomFormatter.php
index 1cd8d9fb5f3..9f2455433c0 100644
--- a/lib/Service/RoomFormatter.php
+++ b/lib/Service/RoomFormatter.php
@@ -31,7 +31,7 @@
 use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\BreakoutRoom;
-use OCA\Talk\Model\ProxyCacheMessagesMapper;
+use OCA\Talk\Model\ProxyCacheMessageMapper;
 use OCA\Talk\Model\Session;
 use OCA\Talk\Participant;
 use OCA\Talk\ResponseDefinitions;
@@ -64,7 +64,7 @@ public function __construct(
 		protected IAppManager $appManager,
 		protected IManager $userStatusManager,
 		protected IUserManager $userManager,
-		protected ProxyCacheMessagesMapper $proxyCacheMessagesMapper,
+		protected ProxyCacheMessageMapper $proxyCacheMessageMapper,
 		protected UserConverter $userConverter,
 		protected IL10N $l10n,
 		protected ?string $userId,
@@ -390,7 +390,7 @@ public function formatRoomV4(
 			);
 		} elseif ($room->getRemoteServer() !== '') {
 			try {
-				$cachedMessage = $this->proxyCacheMessagesMapper->findByRemote(
+				$cachedMessage = $this->proxyCacheMessageMapper->findByRemote(
 					$room->getRemoteServer(),
 					$room->getRemoteToken(),
 					$room->getLastMessageId(),
diff --git a/tests/integration/features/bootstrap/FeatureContext.php b/tests/integration/features/bootstrap/FeatureContext.php
index d9fc9f60b22..98209188dd2 100644
--- a/tests/integration/features/bootstrap/FeatureContext.php
+++ b/tests/integration/features/bootstrap/FeatureContext.php
@@ -1946,6 +1946,8 @@ public function userSeesPeersInCall(string $user, int $numPeers, string $identif
 	public function userSendsMessageToRoom(string $user, string $sendingMode, string $message, string $identifier, string $statusCode, string $apiVersion = 'v1') {
 		$message = substr($message, 1, -1);
 		$message = str_replace('\n', "\n", $message);
+		$message = str_replace('{$BASE_URL}', $this->baseUrl, $message);
+		$message = str_replace('{$REMOTE_URL}', $this->baseRemoteUrl, $message);
 
 		if ($message === '413 Payload Too Large') {
 			$message .= "\n" . str_repeat('1', 32000);
@@ -3285,7 +3287,12 @@ private function assertNotifications($notifications, TableNode $formData) {
 			if (isset($expectedNotification['object_id'])) {
 				if (strpos($notification['object_id'], '/') !== false) {
 					[$roomToken, $message] = explode('/', $notification['object_id']);
-					$data['object_id'] = self::$tokenToIdentifier[$roomToken] . '/' . self::$messageIdToText[$message] ?? 'UNKNOWN_MESSAGE';
+					$messageText = self::$messageIdToText[$message] ?? 'UNKNOWN_MESSAGE';
+
+					$messageText = str_replace($this->baseUrl, '{$BASE_URL}', $messageText);
+					$messageText = str_replace($this->baseRemoteUrl, '{$REMOTE_URL}', $messageText);
+
+					$data['object_id'] = self::$tokenToIdentifier[$roomToken] . '/' . $messageText;
 				} elseif (strpos($expectedNotification['object_id'], 'INVITE_ID') !== false) {
 					$data['object_id'] = 'INVITE_ID(' . self::$inviteIdToRemote[$notification['object_id']] . ')';
 				} else {
diff --git a/tests/integration/features/federation/chat.feature b/tests/integration/features/federation/chat.feature
index a797d15fc24..c56f18ff243 100644
--- a/tests/integration/features/federation/chat.feature
+++ b/tests/integration/features/federation/chat.feature
@@ -150,3 +150,32 @@ Feature: federation/chat
       | id   | type |
       | room | 2    |
     And user "participant2" sends message "413 Payload Too Large" to room "LOCAL::room" with 413
+
+  Scenario: Mentioning a federated user triggers a notification for them
+    Given the following "spreed" app config is set
+      | federation_enabled | yes |
+    Given user "participant1" creates room "room" (v4)
+      | roomType | 2 |
+      | roomName | room |
+    And user "participant1" adds federated_user "participant2" to room "room" with 200 (v4)
+    And user "participant2" has the following invitations (v1)
+      | remoteServerUrl | remoteToken | state | inviterCloudId                     | inviterDisplayName       |
+      | LOCAL           | room        | 0     | participant1@http://localhost:8080 | participant1-displayname |
+    And user "participant2" accepts invite to room "room" of server "LOCAL" with 200 (v1)
+      | id   | name | type | remoteServer | remoteToken |
+      | room | room | 2    | LOCAL        | room        |
+    Then user "participant2" is participant of the following rooms (v4)
+      | id   | type |
+      | room | 2    |
+    # Join and leave to clear the invite notification
+    Given user "participant2" joins room "LOCAL::room" with 200 (v4)
+    Given user "participant2" leaves room "LOCAL::room" with 200 (v4)
+    And user "participant2" sends message "Message 1" to room "LOCAL::room" with 201
+    When user "participant1" sends reply "Message 1-1" on message "Message 1" to room "room" with 201
+    And user "participant1" sends message 'Hi @"federated_user/participant2@{$REMOTE_URL}" bye' to room "room" with 201
+    And user "participant1" sends message 'Hi @all bye' to room "room" with 201
+    Then user "participant2" has the following notifications
+      | app    | object_type | object_id                | subject                                                                | message     |
+      | spreed | chat        | room/Hi @all bye         | participant1-displayname mentioned everyone in conversation room       | Hi room bye |
+      | spreed | chat        | room/Hi @"federated_user/participant2@{$REMOTE_URL}" bye | participant1-displayname mentioned you in conversation room | Hi @participant2-displayname bye |
+      | spreed | chat        | room/Message 1-1         | participant1-displayname replied to your message in conversation room  | Message 1-1 |
diff --git a/tests/php/Federation/FederationTest.php b/tests/php/Federation/FederationTest.php
index f53fa64144e..2d94caca280 100644
--- a/tests/php/Federation/FederationTest.php
+++ b/tests/php/Federation/FederationTest.php
@@ -28,12 +28,14 @@
 use OCA\Talk\Federation\BackendNotifier;
 use OCA\Talk\Federation\CloudFederationProviderTalk;
 use OCA\Talk\Federation\FederationManager;
+use OCA\Talk\Federation\Proxy\TalkV1\UserConverter;
 use OCA\Talk\Manager;
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\AttendeeMapper;
 use OCA\Talk\Model\Invitation;
 use OCA\Talk\Model\InvitationMapper;
-use OCA\Talk\Model\ProxyCacheMessagesMapper;
+use OCA\Talk\Model\ProxyCacheMessageMapper;
+use OCA\Talk\Notification\FederationChatNotifier;
 use OCA\Talk\Room;
 use OCA\Talk\Service\ParticipantService;
 use OCA\Talk\Service\RoomService;
@@ -95,7 +97,9 @@ class FederationTest extends TestCase {
 	/** @var AttendeeMapper|MockObject */
 	protected $attendeeMapper;
 
-	protected ProxyCacheMessagesMapper|MockObject $proxyCacheMessageMapper;
+	protected ProxyCacheMessageMapper|MockObject $proxyCacheMessageMapper;
+	protected FederationChatNotifier|MockObject $federationChatNotifier;
+	protected UserConverter|MockObject $userConverter;
 	protected ICacheFactory|MockObject $cacheFactory;
 
 	public function setUp(): void {
@@ -112,7 +116,7 @@ public function setUp(): void {
 		$this->appManager = $this->createMock(IAppManager::class);
 		$this->logger = $this->createMock(LoggerInterface::class);
 		$this->url = $this->createMock(IURLGenerator::class);
-		$this->proxyCacheMessageMapper = $this->createMock(ProxyCacheMessagesMapper::class);
+		$this->proxyCacheMessageMapper = $this->createMock(ProxyCacheMessageMapper::class);
 		$this->cacheFactory = $this->createMock(ICacheFactory::class);
 
 		$this->backendNotifier = new BackendNotifier(
@@ -130,6 +134,8 @@ public function setUp(): void {
 
 		$this->federationManager = $this->createMock(FederationManager::class);
 		$this->notificationManager = $this->createMock(INotificationManager::class);
+		$this->federationChatNotifier = $this->createMock(FederationChatNotifier::class);
+		$this->userConverter = $this->createMock(UserConverter::class);
 
 		$this->cloudFederationProvider = new CloudFederationProviderTalk(
 			$this->cloudIdManager,
@@ -148,6 +154,8 @@ public function setUp(): void {
 			$this->createMock(IEventDispatcher::class),
 			$this->logger,
 			$this->proxyCacheMessageMapper,
+			$this->federationChatNotifier,
+			$this->userConverter,
 			$this->cacheFactory,
 		);
 	}
diff --git a/tests/php/Notification/NotifierTest.php b/tests/php/Notification/NotifierTest.php
index 7f2b362d411..db22eff66f1 100644
--- a/tests/php/Notification/NotifierTest.php
+++ b/tests/php/Notification/NotifierTest.php
@@ -33,6 +33,7 @@
 use OCA\Talk\Model\Attendee;
 use OCA\Talk\Model\BotServerMapper;
 use OCA\Talk\Model\Message;
+use OCA\Talk\Model\ProxyCacheMessageMapper;
 use OCA\Talk\Notification\Notifier;
 use OCA\Talk\Participant;
 use OCA\Talk\Room;
@@ -81,6 +82,7 @@ class NotifierTest extends TestCase {
 	protected $notificationManager;
 	/** @var CommentsManager|MockObject */
 	protected $commentsManager;
+	protected ProxyCacheMessageMapper|MockObject $proxyCacheMessageMapper;
 	/** @var MessageParser|MockObject */
 	protected $messageParser;
 	/** @var IRootFolder|MockObject */
@@ -112,6 +114,7 @@ public function setUp(): void {
 		$this->avatarService = $this->createMock(AvatarService::class);
 		$this->notificationManager = $this->createMock(INotificationManager::class);
 		$this->commentsManager = $this->createMock(CommentsManager::class);
+		$this->proxyCacheMessageMapper = $this->createMock(ProxyCacheMessageMapper::class);
 		$this->messageParser = $this->createMock(MessageParser::class);
 		$this->rootFolder = $this->createMock(IRootFolder::class);
 		$this->timeFactory = $this->createMock(ITimeFactory::class);
@@ -133,6 +136,7 @@ public function setUp(): void {
 			$this->avatarService,
 			$this->notificationManager,
 			$this->commentsManager,
+			$this->proxyCacheMessageMapper,
 			$this->messageParser,
 			$this->rootFolder,
 			$this->timeFactory,