Skip to content
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
40 changes: 38 additions & 2 deletions lib/src/room.dart
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,16 @@ class Room {

if (heroes == null) return [];

// Filter out functional members (bots/bridges) when in a DM room,
// so that hero user loading only fetches the real human participant.
final directChatMatrixID = this.directChatMatrixID;
if (directChatMatrixID != null) {
final fMembers = functionalMembers;
if (fMembers.isNotEmpty) {
heroes = heroes.where((h) => !fMembers.contains(h)).toList();
}
}

return await Future.wait(
heroes.map(
(hero) async =>
Expand Down Expand Up @@ -257,10 +267,21 @@ class Room {
heroes.add(directChatMatrixID);
}
if (heroes.isNotEmpty) {
// When this is a DM room, filter out functional/service members (e.g.
// bridge bots listed in the `io.element.functional_members` state event)
// so that the room name reflects only the real human participant.
// This mirrors the behaviour of the JS SDK's getFunctionalMembers().
// See: https://github.com/element-hq/element-meta/blob/develop/spec/functional_members.md
final functionalMembers = this.functionalMembers;
final result = heroes
.where(
// removing oneself from the hero list
(hero) => hero.isNotEmpty && hero != client.userID,
// removing oneself from the hero list, and removing functional
// members (bots/bridges) when in a DM room
(hero) =>
hero.isNotEmpty &&
hero != client.userID &&
(directChatMatrixID == null ||
!functionalMembers.contains(hero)),
)
.map(
(hero) => unsafeGetUserFromMemoryOrFallback(hero)
Expand Down Expand Up @@ -381,6 +402,21 @@ class Room {
/// `isDirect: true` when creating the room.
bool get isDirectChat => directChatMatrixID != null;

/// Returns the list of user IDs considered "functional members" (e.g. bots,
/// bridge ghosts) as specified in the `io.element.functional_members` state
/// event. These should be excluded from room names in DM rooms so that only
/// the real human participant's name is shown.
///
/// See: https://github.com/element-hq/element-meta/blob/develop/spec/functional_members.md
List<String> get functionalMembers {
final event = getState('io.element.functional_members');
final serviceMembers = event?.content['service_members'];
if (serviceMembers is List) {
return serviceMembers.whereType<String>().toList();
}
return [];
}

Event? lastEvent;

/// Fetches the most recent event in the timeline from the server to have
Expand Down
140 changes: 140 additions & 0 deletions test/room_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,146 @@ void main() {
expect(nonDmRoom.directChatMatrixID, isNull);
});

test(
'functional members filtered from DM room display name',
() async {
// Helper to add member state events to a room
void addMember(
Room r,
String userId,
String displayName,
String evId,
) {
r.setState(
Event(
room: r,
eventId: evId,
originServerTs: DateTime.fromMillisecondsSinceEpoch(0),
senderId: userId,
type: 'm.room.member',
content: {'membership': 'join', 'displayname': displayName},
stateKey: userId,
),
);
}

// Set up client-level m.direct so directChatMatrixID is detected.
// We register both test room IDs as DMs with @john:example.com.
await matrix.handleSync(
SyncUpdate.fromJson(
jsonDecode('''
{
"next_batch": "sync_fm1",
"account_data": {
"events": [{
"type": "m.direct",
"content": {
"@john:example.com": ["!dmtest:example.com", "!dmtest2:example.com"]
}
}]
}
}
'''),
),
);

// ── Positive test: functional_members set → bot is filtered out ────────
final dmRoom = Room(
client: matrix,
id: '!dmtest:example.com',
membership: Membership.join,
highlightCount: 0,
notificationCount: 0,
prev_batch: '',
summary: RoomSummary.fromJson({
'm.joined_member_count': 2,
'm.invited_member_count': 0,
'm.heroes': [
'@signal-bot:example.com',
'@john:example.com',
],
}),
);

addMember(dmRoom, matrix.userID!, 'Me', 'ev_self');
addMember(dmRoom, '@john:example.com', 'John Doe', 'ev_john');
addMember(
dmRoom,
'@signal-bot:example.com',
'Signal Bridge Bot',
'ev_bot',
);

// io.element.functional_members lists the bridge bot
dmRoom.setState(
Event(
room: dmRoom,
eventId: 'ev_fm',
originServerTs: DateTime.fromMillisecondsSinceEpoch(0),
senderId: '@server:example.com',
type: 'io.element.functional_members',
content: {
'service_members': ['@signal-bot:example.com'],
},
stateKey: '',
),
);

expect(
dmRoom.directChatMatrixID,
'@john:example.com',
reason: 'Room should be detected as a DM',
);
expect(
dmRoom.getLocalizedDisplayname(),
'John Doe',
reason:
'Bridge bot should be filtered from DM room name when io.element.functional_members is set',
);

// ── Negative test: no functional_members → bot appears in name ────────
final dmRoomNoFilter = Room(
client: matrix,
id: '!dmtest2:example.com',
membership: Membership.join,
highlightCount: 0,
notificationCount: 0,
prev_batch: '',
summary: RoomSummary.fromJson({
'm.joined_member_count': 2,
'm.invited_member_count': 0,
'm.heroes': [
'@signal-bot:example.com',
'@john:example.com',
],
}),
);

addMember(dmRoomNoFilter, matrix.userID!, 'Me', 'ev_self2');
addMember(dmRoomNoFilter, '@john:example.com', 'John Doe', 'ev_john2');
addMember(
dmRoomNoFilter,
'@signal-bot:example.com',
'Signal Bridge Bot',
'ev_bot2',
);
// No io.element.functional_members state event set here

expect(
dmRoomNoFilter.directChatMatrixID,
'@john:example.com',
reason: 'Room should be detected as a DM',
);
// Without functional_members, the bot appears alongside the real user
expect(
dmRoomNoFilter.getLocalizedDisplayname(),
isNot('John Doe'),
reason:
'Without io.element.functional_members, bot should still appear in room name',
);
},
);

test('getTimeline', () async {
final timeline = await room.getTimeline();
expect(timeline.events.length, 17);
Expand Down