From 5ee1e422a176a81b832c93dcb898b7cab74a0ae6 Mon Sep 17 00:00:00 2001 From: Kegan Dougal Date: Fri, 8 Oct 2021 17:24:06 +0100 Subject: [PATCH] Implement the room name calculation algorithm --- internal/roomname.go | 88 +++++++++++++++++ internal/roomname_test.go | 196 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+) create mode 100644 internal/roomname.go create mode 100644 internal/roomname_test.go diff --git a/internal/roomname.go b/internal/roomname.go new file mode 100644 index 0000000..496a085 --- /dev/null +++ b/internal/roomname.go @@ -0,0 +1,88 @@ +package internal + +import ( + "fmt" + "strings" +) + +type Hero struct { + ID string + Name string +} + +func CalculateRoomName(roomName, canonicalAlias string, maxNumNamesPerRoom int, heroes []Hero, joinedCount, invitedCount int) string { + // If the room has an m.room.name state event with a non-empty name field, use the name given by that field. + if roomName != "" { + return roomName + } + // If the room has an m.room.canonical_alias state event with a valid alias field, use the alias given by that field as the name. + if canonicalAlias != "" { + return canonicalAlias + } + // If none of the above conditions are met, a name should be composed based on the members of the room. + disambiguatedNames := disambiguate(heroes) + totalNumOtherUsers := (joinedCount + invitedCount - 1) + isAlone := totalNumOtherUsers <= 0 + + // If m.joined_member_count + m.invited_member_count is less than or equal to 1 (indicating the member is alone), + // the client should use the rules BELOW to indicate that the room was empty. For example, "Empty Room (was Alice)", + // "Empty Room (was Alice and 1234 others)", or "Empty Room" if there are no heroes. + if len(heroes) == 0 && isAlone { + return "Empty Room" + } + + // If the number of m.heroes for the room are greater or equal to m.joined_member_count + m.invited_member_count - 1, + // then use the membership events for the heroes to calculate display names for the users (disambiguating them if required) + // and concatenating them. + if len(heroes) >= totalNumOtherUsers { + if len(disambiguatedNames) == 1 { + return disambiguatedNames[0] + } + calculatedRoomName := strings.Join(disambiguatedNames[:len(disambiguatedNames)-1], ", ") + " and " + disambiguatedNames[len(disambiguatedNames)-1] + if isAlone { + return fmt.Sprintf("Empty Room (was %s)", calculatedRoomName) + } + return calculatedRoomName + } + + // if we're here then len(heroes) < (joinedCount + invitedCount - 1) + numEntries := len(disambiguatedNames) + if numEntries > maxNumNamesPerRoom { + numEntries = maxNumNamesPerRoom + } + calculatedRoomName := fmt.Sprintf( + "%s and %d others", strings.Join(disambiguatedNames[:numEntries], ", "), totalNumOtherUsers-numEntries, + ) + + // If there are fewer heroes than m.joined_member_count + m.invited_member_count - 1, + // and m.joined_member_count + m.invited_member_count is greater than 1, the client should use the heroes to calculate + // display names for the users (disambiguating them if required) and concatenating them alongside a count of the remaining users. + if (joinedCount + invitedCount) > 1 { + return calculatedRoomName + } + + // If m.joined_member_count + m.invited_member_count is less than or equal to 1 (indicating the member is alone), + // the client should use the rules above to indicate that the room was empty. For example, "Empty Room (was Alice)", + // "Empty Room (was Alice and 1234 others)", or "Empty Room" if there are no heroes. + return fmt.Sprintf("Empty Room (was %s)", calculatedRoomName) +} + +func disambiguate(heroes []Hero) []string { + displayNames := make(map[string][]int) + for i, h := range heroes { + displayNames[h.Name] = append(displayNames[h.Name], i) + } + disambiguatedNames := make([]string, len(heroes)) + for _, indexes := range displayNames { + if len(indexes) == 1 { + disambiguatedNames[indexes[0]] = heroes[indexes[0]].Name + continue + } + // disambiguate all these heroes + for _, i := range indexes { + h := heroes[i] + disambiguatedNames[i] = fmt.Sprintf("%s (%s)", h.Name, h.ID) + } + } + return disambiguatedNames +} diff --git a/internal/roomname_test.go b/internal/roomname_test.go new file mode 100644 index 0000000..2907d3a --- /dev/null +++ b/internal/roomname_test.go @@ -0,0 +1,196 @@ +package internal + +import "testing" + +func TestCalculateRoomName(t *testing.T) { + testCases := []struct { + roomName string + canonicalAlias string + heroes []Hero + joinedCount int + invitedCount int + maxNumNamesPerRoom int + + wantRoomName string + }{ + // Room name takes precedence + { + roomName: "My Room Name", + canonicalAlias: "#alias:localhost", + joinedCount: 5, + invitedCount: 1, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + }, + wantRoomName: "My Room Name", + }, + // Alias takes precedence if room name is missing + { + canonicalAlias: "#alias:localhost", + joinedCount: 5, + invitedCount: 1, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + }, + wantRoomName: "#alias:localhost", + }, + // ... and N others (large group chat) + { + joinedCount: 5, + invitedCount: 1, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + }, + wantRoomName: "Alice, Bob and 3 others", + }, + // Small group chat + { + joinedCount: 4, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + { + ID: "@charlie:localhost", + Name: "Charlie", + }, + }, + wantRoomName: "Alice, Bob and Charlie", + }, + // DM room + { + joinedCount: 2, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + }, + wantRoomName: "Alice", + }, + // 3-way room + { + joinedCount: 3, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + }, + wantRoomName: "Alice and Bob", + }, + // disambiguation all + { + joinedCount: 10, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Alice", + }, + { + ID: "@charlie:localhost", + Name: "Alice", + }, + }, + wantRoomName: "Alice (@alice:localhost), Alice (@bob:localhost), Alice (@charlie:localhost) and 6 others", + }, + // disambiguation some + { + joinedCount: 10, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + { + ID: "@charlie:localhost", + Name: "Alice", + }, + }, + wantRoomName: "Alice (@alice:localhost), Bob, Alice (@charlie:localhost) and 6 others", + }, + // left room + { + joinedCount: 1, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{ + { + ID: "@alice:localhost", + Name: "Alice", + }, + { + ID: "@bob:localhost", + Name: "Bob", + }, + }, + wantRoomName: "Empty Room (was Alice and Bob)", + }, + // empty room + { + joinedCount: 1, + invitedCount: 0, + maxNumNamesPerRoom: 3, + heroes: []Hero{}, + wantRoomName: "Empty Room", + }, + } + + for _, tc := range testCases { + gotName := CalculateRoomName(tc.roomName, tc.canonicalAlias, tc.maxNumNamesPerRoom, tc.heroes, tc.joinedCount, tc.invitedCount) + if gotName != tc.wantRoomName { + t.Errorf("got %s want %s for test case: %+v", gotName, tc.wantRoomName, tc) + } + } +}