mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Sort reactions on the timeline by count and then timestamp and show the date on the summary view (#1320)
* Sort timetamps on timeline and show the date on the summary view - Sort reaction aggregations on the timeline by count and then by most recent timestamp ascending(tagging new reactions on the end) - Show the timestamp on the summary view and sort descending(newest at the top) * Address comments. - Fix ID case. - Improve readability of components in body. - Improve the comments that describe the sorting.
This commit is contained in:
@@ -23,42 +23,50 @@ extension AggregatedReaction {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func mockReaction(key: String, senderIDs: [String]) -> AggregatedReaction {
|
||||||
|
let senders = senderIDs
|
||||||
|
.map { id in
|
||||||
|
ReactionSender(senderID: id, timestamp: Date())
|
||||||
|
}
|
||||||
|
return AggregatedReaction(accountOwnerID: alice, key: key, senders: senders)
|
||||||
|
}
|
||||||
|
|
||||||
private static var alice: String {
|
private static var alice: String {
|
||||||
RoomMemberProxyMock.mockAlice.userID
|
RoomMemberProxyMock.mockAlice.userID
|
||||||
}
|
}
|
||||||
|
|
||||||
static var mockThumbsUpHighlighted: AggregatedReaction {
|
static var mockThumbsUpHighlighted: AggregatedReaction {
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "👍", senders: [alice] + mockIds(4))
|
mockReaction(key: "👍", senderIDs: [alice] + mockIds(4))
|
||||||
}
|
}
|
||||||
|
|
||||||
static var mockClap: AggregatedReaction {
|
static var mockClap: AggregatedReaction {
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "👏", senders: mockIds(1))
|
mockReaction(key: "👏", senderIDs: mockIds(1))
|
||||||
}
|
}
|
||||||
|
|
||||||
static var mockParty: AggregatedReaction {
|
static var mockParty: AggregatedReaction {
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🎉", senders: mockIds(20))
|
mockReaction(key: "🎉", senderIDs: mockIds(20))
|
||||||
}
|
}
|
||||||
|
|
||||||
static var mockReactions: [AggregatedReaction] {
|
static var mockReactions: [AggregatedReaction] {
|
||||||
[
|
[
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "😅", senders: [alice]),
|
mockReaction(key: "😅", senderIDs: [alice]),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🤷♂️", senders: mockIds(1)),
|
mockReaction(key: "🤷♂️", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🎨", senders: [alice] + mockIds(5)),
|
mockReaction(key: "🎨", senderIDs: [alice] + mockIds(5)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🎉", senders: mockIds(8)),
|
mockReaction(key: "🎉", senderIDs: mockIds(8)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🤯", senders: [alice] + mockIds(14)),
|
mockReaction(key: "🤯", senderIDs: [alice] + mockIds(14)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🫣", senders: mockIds(1)),
|
mockReaction(key: "🫣", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🚀", senders: [alice] + mockIds(3)),
|
mockReaction(key: "🚀", senderIDs: [alice] + mockIds(3)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "😇", senders: mockIds(2)),
|
mockReaction(key: "😇", senderIDs: mockIds(2)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🤭", senders: [alice] + mockIds(8)),
|
mockReaction(key: "🤭", senderIDs: [alice] + mockIds(8)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🫤", senders: mockIds(10)),
|
mockReaction(key: "🫤", senderIDs: mockIds(10)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐶", senders: mockIds(1)),
|
mockReaction(key: "🐶", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐱", senders: mockIds(1)),
|
mockReaction(key: "🐱", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐭", senders: mockIds(1)),
|
mockReaction(key: "🐭", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐹", senders: mockIds(1)),
|
mockReaction(key: "🐹", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐰", senders: mockIds(1)),
|
mockReaction(key: "🐰", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🦊", senders: mockIds(1)),
|
mockReaction(key: "🦊", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐻", senders: mockIds(1)),
|
mockReaction(key: "🐻", senderIDs: mockIds(1)),
|
||||||
AggregatedReaction(accountOwnerID: alice, key: "🐼", senders: mockIds(1))
|
mockReaction(key: "🐼", senderIDs: mockIds(1))
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ struct ReactionsSummaryView: View {
|
|||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
ForEach(reaction.senders, id: \.self) { sender in
|
ForEach(reaction.senders, id: \.self) { sender in
|
||||||
ReactionSummarySenderView(sender: sender, member: members[sender], imageProvider: imageProvider)
|
ReactionSummarySenderView(sender: sender, member: members[sender.senderID], imageProvider: imageProvider)
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,28 +107,34 @@ private struct ReactionSummaryButton: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private struct ReactionSummarySenderView: View {
|
private struct ReactionSummarySenderView: View {
|
||||||
var sender: String
|
var sender: ReactionSender
|
||||||
var member: RoomMemberState?
|
var member: RoomMemberState?
|
||||||
let imageProvider: ImageProviderProtocol?
|
let imageProvider: ImageProviderProtocol?
|
||||||
|
|
||||||
var displayName: String {
|
var displayName: String {
|
||||||
member?.displayName ?? sender
|
member?.displayName ?? sender.senderID
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
LoadableAvatarImage(url: member?.avatarURL,
|
LoadableAvatarImage(url: member?.avatarURL,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
contentID: sender,
|
contentID: sender.senderID,
|
||||||
avatarSize: .user(on: .timeline),
|
avatarSize: .user(on: .timeline),
|
||||||
imageProvider: imageProvider)
|
imageProvider: imageProvider)
|
||||||
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
Text(displayName)
|
Text(displayName)
|
||||||
.font(.compound.bodyMDSemibold)
|
.font(.compound.bodyMDSemibold)
|
||||||
Text(sender)
|
Text(sender.senderID)
|
||||||
.font(.compound.bodySM)
|
.font(.compound.bodySM)
|
||||||
.foregroundColor(.compound.textSecondary)
|
.foregroundColor(.compound.textSecondary)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
Text(sender.timestamp.formattedMinimal())
|
||||||
|
.font(.compound.bodyXS)
|
||||||
|
.foregroundColor(.compound.textSecondary)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ enum RoomTimelineItemFixtures {
|
|||||||
sender: .init(id: "", displayName: "Helena"),
|
sender: .init(id: "", displayName: "Helena"),
|
||||||
content: .init(body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗"),
|
content: .init(body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗"),
|
||||||
properties: RoomTimelineItemProperties(reactions: [
|
properties: RoomTimelineItemProperties(reactions: [
|
||||||
AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: ["me"])
|
AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: [ReactionSender(senderID: "me", timestamp: Date())])
|
||||||
])),
|
])),
|
||||||
TextRoomTimelineItem(id: .init(timelineID: UUID().uuidString),
|
TextRoomTimelineItem(id: .init(timelineID: UUID().uuidString),
|
||||||
timestamp: "10:11 AM",
|
timestamp: "10:11 AM",
|
||||||
@@ -43,8 +43,14 @@ enum RoomTimelineItemFixtures {
|
|||||||
sender: .init(id: "", displayName: "Helena"),
|
sender: .init(id: "", displayName: "Helena"),
|
||||||
content: .init(body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/"),
|
content: .init(body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/"),
|
||||||
properties: RoomTimelineItemProperties(reactions: [
|
properties: RoomTimelineItemProperties(reactions: [
|
||||||
AggregatedReaction(accountOwnerID: "me", key: "🙏", senders: ["helena"]),
|
AggregatedReaction(accountOwnerID: "me", key: "🙏", senders: [ReactionSender(senderID: "helena", timestamp: Date())]),
|
||||||
AggregatedReaction(accountOwnerID: "me", key: "🙌", senders: ["me", "helena", "jacob"])
|
AggregatedReaction(accountOwnerID: "me",
|
||||||
|
key: "🙌",
|
||||||
|
senders: [
|
||||||
|
ReactionSender(senderID: "me", timestamp: Date()),
|
||||||
|
ReactionSender(senderID: "helena", timestamp: Date()),
|
||||||
|
ReactionSender(senderID: "jacob", timestamp: Date())
|
||||||
|
])
|
||||||
])),
|
])),
|
||||||
SeparatorRoomTimelineItem(id: .init(timelineID: "Today"), text: "Today"),
|
SeparatorRoomTimelineItem(id: .init(timelineID: "Today"), text: "Today"),
|
||||||
TextRoomTimelineItem(id: .init(timelineID: UUID().uuidString),
|
TextRoomTimelineItem(id: .init(timelineID: UUID().uuidString),
|
||||||
|
|||||||
@@ -23,7 +23,15 @@ struct AggregatedReaction: Hashable {
|
|||||||
/// The reaction that was sent.
|
/// The reaction that was sent.
|
||||||
let key: String
|
let key: String
|
||||||
/// The user ids of those who sent the reactions
|
/// The user ids of those who sent the reactions
|
||||||
let senders: [String]
|
let senders: [ReactionSender]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Details of who sent the reaction
|
||||||
|
struct ReactionSender: Hashable {
|
||||||
|
/// The id of the user who sent the reaction
|
||||||
|
let senderID: String
|
||||||
|
/// The time that the reaction was received on the original homeserver
|
||||||
|
let timestamp: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
extension AggregatedReaction {
|
extension AggregatedReaction {
|
||||||
@@ -34,6 +42,6 @@ extension AggregatedReaction {
|
|||||||
|
|
||||||
/// Whether to highlight the reaction, indicating that the current user sent this reaction.
|
/// Whether to highlight the reaction, indicating that the current user sent this reaction.
|
||||||
var isHighlighted: Bool {
|
var isHighlighted: Bool {
|
||||||
senders.contains(where: { $0 == accountOwnerID })
|
senders.contains(where: { $0.senderID == accountOwnerID })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,13 +313,28 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
|||||||
|
|
||||||
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
|
private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] {
|
||||||
reactions.map { reaction in
|
reactions.map { reaction in
|
||||||
AggregatedReaction(accountOwnerID: userID, key: reaction.key, senders: reaction.senders.map(\.senderId))
|
let senders = reaction.senders
|
||||||
|
.map { senderData in
|
||||||
|
ReactionSender(senderID: senderData.senderId, timestamp: Date(timeIntervalSince1970: TimeInterval(senderData.timestamp / 1000)))
|
||||||
|
}
|
||||||
|
.sorted { a, b in
|
||||||
|
// Sort reactions within an aggregation by timestamp descending.
|
||||||
|
// This puts the most recent at the top, useful in cases like the
|
||||||
|
// reaction summary view.
|
||||||
|
a.timestamp > b.timestamp
|
||||||
|
}
|
||||||
|
return AggregatedReaction(accountOwnerID: userID, key: reaction.key, senders: senders)
|
||||||
}
|
}
|
||||||
.sorted { a, b in
|
.sorted { a, b in
|
||||||
// Sort by count and then by key for a consistence experience.
|
// Sort aggregated reactions by count and then timestamp ascending, using
|
||||||
// Otherwise emojis can switch around. We can replace
|
// the most recent reaction in the aggregation(hence index 0).
|
||||||
// with timestamp as a secondary sort when it is available.
|
// This appends new aggregations on the end of the reaction layout
|
||||||
(a.count, a.key) > (b.count, b.key)
|
// and the deterministic sort avoids reactions jumping around if the reactions timeline
|
||||||
|
// view reloads.
|
||||||
|
if a.count == b.count {
|
||||||
|
return a.senders[0].timestamp < b.senders[0].timestamp
|
||||||
|
}
|
||||||
|
return a.count > b.count
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -375,7 +375,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
|||||||
|
|
||||||
private extension TextRoomTimelineItem {
|
private extension TextRoomTimelineItem {
|
||||||
init(text: String, sender: String, addReactions: Bool = false) {
|
init(text: String, sender: String, addReactions: Bool = false) {
|
||||||
let reactions = addReactions ? [AggregatedReaction(accountOwnerID: "bob", key: "🦄", senders: [sender])] : []
|
let reactions = addReactions ? [AggregatedReaction(accountOwnerID: "bob", key: "🦄", senders: [ReactionSender(senderID: sender, timestamp: Date())])] : []
|
||||||
self.init(id: .init(timelineID: UUID().uuidString),
|
self.init(id: .init(timelineID: UUID().uuidString),
|
||||||
timestamp: "10:47 am",
|
timestamp: "10:47 am",
|
||||||
isOutgoing: sender == "bob",
|
isOutgoing: sender == "bob",
|
||||||
|
|||||||
Reference in New Issue
Block a user