Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add plain-text handling for rich-text topics as per MSC3765 #18195

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/18195.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add plain-text handling for rich-text topics as per [MSC3765](https://github.com/matrix-org/matrix-spec-proposals/pull/3765).
2 changes: 1 addition & 1 deletion synapse/handlers/room.py
Original file line number Diff line number Diff line change
Expand Up @@ -1296,7 +1296,7 @@ async def create_event(
topic = room_config["topic"]
topic_event, topic_context = await create_event(
EventTypes.Topic,
{"topic": topic},
{"topic": topic, "m.topic": {"m.text": [{"body": topic}]}},
True,
)
events_to_send.append((topic_event, topic_context))
Expand Down
5 changes: 4 additions & 1 deletion synapse/handlers/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from synapse.metrics.background_process_metrics import run_as_background_process
from synapse.storage.databases.main.state_deltas import StateDelta
from synapse.types import JsonDict
from synapse.util.events import get_plain_text_topic_from_event_content

if TYPE_CHECKING:
from synapse.server import HomeServer
Expand Down Expand Up @@ -299,7 +300,9 @@ async def _handle_deltas(
elif delta.event_type == EventTypes.Name:
room_state["name"] = event_content.get("name")
elif delta.event_type == EventTypes.Topic:
room_state["topic"] = event_content.get("topic")
room_state["topic"] = get_plain_text_topic_from_event_content(
event_content
)
elif delta.event_type == EventTypes.RoomAvatar:
room_state["avatar"] = event_content.get("url")
elif delta.event_type == EventTypes.CanonicalAlias:
Expand Down
6 changes: 5 additions & 1 deletion synapse/storage/databases/main/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
from synapse.types.handlers import SLIDING_SYNC_DEFAULT_BUMP_EVENT_TYPES
from synapse.types.state import StateFilter
from synapse.util import json_encoder
from synapse.util.events import get_plain_text_topic_from_event_content
from synapse.util.iterutils import batch_iter, sorted_topologically
from synapse.util.stringutils import non_null_str_or_none

Expand Down Expand Up @@ -3102,7 +3103,10 @@ def _handle_redact_relations(
def _store_room_topic_txn(self, txn: LoggingTransaction, event: EventBase) -> None:
if isinstance(event.content.get("topic"), str):
self.store_event_search_txn(
txn, event, "content.topic", event.content["topic"]
txn,
event,
"content.topic",
get_plain_text_topic_from_event_content(event.content) or "",
)

def _store_room_name_txn(self, txn: LoggingTransaction, event: EventBase) -> None:
Expand Down
5 changes: 4 additions & 1 deletion synapse/storage/databases/main/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@
from synapse.storage.databases.main.state_deltas import StateDeltasStore
from synapse.types import JsonDict
from synapse.util.caches.descriptors import cached
from synapse.util.events import get_plain_text_topic_from_event_content

if TYPE_CHECKING:
from synapse.server import HomeServer
Expand Down Expand Up @@ -611,7 +612,9 @@ def _fetch_current_state_stats(
elif event.type == EventTypes.Name:
room_state["name"] = event.content.get("name")
elif event.type == EventTypes.Topic:
room_state["topic"] = event.content.get("topic")
room_state["topic"] = get_plain_text_topic_from_event_content(
event.content
)
elif event.type == EventTypes.RoomAvatar:
room_state["avatar"] = event.content.get("url")
elif event.type == EventTypes.CanonicalAlias:
Expand Down
31 changes: 31 additions & 0 deletions synapse/util/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@
#
#

from typing import Optional

from synapse.types import JsonDict
from synapse.util.stringutils import random_string


Expand All @@ -27,3 +30,31 @@ def generate_fake_event_id() -> str:
A string intended to look like an event ID, but with no actual meaning.
"""
return "$" + random_string(43)


def get_plain_text_topic_from_event_content(content: JsonDict) -> Optional[str]:
"""
Given the content of an m.room.topic event returns the plain text topic
representation if any exists.

Returns:
A string representing the plain text topic.
"""
topic = content.get("topic")

m_topic = content.get("m.topic")
if not m_topic:
return topic

m_text = m_topic.get("m.text")
if not m_text:
return topic

representation = next(
(r for r in m_text if "mimetype" not in r or r["mimetype"] == "text/plain"),
None,
)
if not representation or "body" not in representation:
return topic

return representation["body"]
53 changes: 53 additions & 0 deletions tests/rest/client/test_rooms.py
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,59 @@ def test_post_room_initial_state(self) -> None:
assert channel.resource_usage is not None
self.assertEqual(37, channel.resource_usage.db_txn_count)

def test_post_room_topic(self) -> None:
# POST with topic key, expect new room id
channel = self.make_request("POST", "/createRoom", b'{"topic":"shenanigans"}')
self.assertEqual(HTTPStatus.OK, channel.code)
self.assertTrue("room_id" in channel.json_body)
room_id = channel.json_body["room_id"]

# GET topic event, expect content from topic key
channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,))
self.assertEqual(HTTPStatus.OK, channel.code)
self.assertEqual(
{"topic": "shenanigans", "m.topic": {"m.text": [{"body": "shenanigans"}]}},
channel.json_body,
)

def test_post_room_topic_initial_state(self) -> None:
# POST with m.room.topic in initial state, expect new room id
channel = self.make_request(
"POST",
"/createRoom",
b'{"initial_state":[{"type": "m.room.topic", "content": {"topic": "foobar"}}]}',
)
self.assertEqual(HTTPStatus.OK, channel.code)
self.assertTrue("room_id" in channel.json_body)
room_id = channel.json_body["room_id"]

# GET topic event, expect content from initial state
channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,))
self.assertEqual(HTTPStatus.OK, channel.code)
self.assertEqual(
{"topic": "foobar"},
channel.json_body,
)

def test_post_room_topic_overriding_initial_state(self) -> None:
# POST with m.room.topic in initial state and topic key, expect new room id
channel = self.make_request(
"POST",
"/createRoom",
b'{"initial_state":[{"type": "m.room.topic", "content": {"topic": "foobar"}}], "topic":"shenanigans"}',
)
self.assertEqual(HTTPStatus.OK, channel.code)
self.assertTrue("room_id" in channel.json_body)
room_id = channel.json_body["room_id"]

# GET topic event, expect content from topic key
channel = self.make_request("GET", "/rooms/%s/state/m.room.topic" % (room_id,))
self.assertEqual(HTTPStatus.OK, channel.code)
self.assertEqual(
{"topic": "shenanigans", "m.topic": {"m.text": [{"body": "shenanigans"}]}},
channel.json_body,
)

def test_post_room_visibility_key(self) -> None:
# POST with visibility config key, expect new room id
channel = self.make_request("POST", "/createRoom", b'{"visibility":"private"}')
Expand Down
97 changes: 97 additions & 0 deletions tests/util/test_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#
# This file is licensed under the Affero General Public License (AGPL) version 3.
#
# Copyright (C) 2025 New Vector, Ltd
#
# 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.
#
# See the GNU Affero General Public License for more details:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-2.0>.
#
# [This file includes modifications made by New Vector Limited]
#
#


from synapse.util.events import get_plain_text_topic_from_event_content

from tests import unittest


class EventsTestCase(unittest.TestCase):
def test_get_plain_text_topic_no_topic(self) -> None:
# No legacy or rich topic, expect None
topic = get_plain_text_topic_from_event_content({})
self.assertEqual(None, topic)

def test_get_plain_text_topic_no_rich_topic(self) -> None:
# Only legacy topic, expect legacy topic
topic = get_plain_text_topic_from_event_content({"topic": "shenanigans"})
self.assertEqual("shenanigans", topic)

def test_get_plain_text_topic_rich_topic_without_representations(self) -> None:
# Legacy topic and rich topic without representations, expect legacy topic
topic = get_plain_text_topic_from_event_content(
{"topic": "shenanigans", "m.topic": {"m.text": []}}
)
self.assertEqual("shenanigans", topic)

def test_get_plain_text_topic_rich_topic_without_plain_text_representation(
self,
) -> None:
# Legacy topic and rich topic without plain text representation, expect legacy topic
topic = get_plain_text_topic_from_event_content(
{
"topic": "shenanigans",
"m.topic": {
"m.text": [
{"mimetype": "text/html", "body": "<strong>foobar</strong>"}
]
},
}
)
self.assertEqual("shenanigans", topic)

def test_get_plain_text_topic_rich_topic_with_plain_text_representation(
self,
) -> None:
# Legacy topic and rich topic with plain text representation, expect plain text representation
topic = get_plain_text_topic_from_event_content(
{
"topic": "shenanigans",
"m.topic": {"m.text": [{"mimetype": "text/plain", "body": "foobar"}]},
}
)
self.assertEqual("foobar", topic)

def test_get_plain_text_topic_rich_topic_with_implicit_plain_text_representation(
self,
) -> None:
# Legacy topic and rich topic with implicit plain text representation, expect plain text representation
topic = get_plain_text_topic_from_event_content(
{"topic": "shenanigans", "m.topic": {"m.text": [{"body": "foobar"}]}}
)
self.assertEqual("foobar", topic)

def test_get_plain_text_topic_rich_topic_with_plain_text_and_other_representation(
self,
) -> None:
# Legacy topic and rich topic with plain text representation, expect plain text representation
topic = get_plain_text_topic_from_event_content(
{
"topic": "shenanigans",
"m.topic": {
"m.text": [
{"mimetype": "text/html", "body": "<strong>foobar</strong>"},
{"mimetype": "text/plain", "body": "foobar"},
]
},
}
)
self.assertEqual("foobar", topic)
Loading