Monthly media gallery separators (#3601)

* Fix the order of the items in the media grid.
* Add media gallery timeline separators.
* Change the `SeparatorRoomTimelineItem` to have it expose a Date timestamp instead of a text string.
This commit is contained in:
Stefan Ceriu 2024-12-11 13:32:29 +02:00 committed by GitHub
parent ee2da536af
commit 0b85964f73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
55 changed files with 256 additions and 126 deletions

View File

@ -138,6 +138,7 @@
"common_creating_room" = "Creating room…";
"common_current_user_left_room" = "Left room";
"common_dark" = "Dark";
"common_date_separator_this_month" = "This month";
"common_decryption_error" = "Decryption error";
"common_developer_options" = "Developer options";
"common_device_id" = "Device ID";
@ -378,13 +379,17 @@
"screen_knock_requests_list_accept_all_alert_description" = "Are you sure you want to accept all requests to join?";
"screen_knock_requests_list_accept_all_alert_title" = "Accept all requests";
"screen_knock_requests_list_accept_all_button_title" = "Accept all";
"screen_knock_requests_list_accept_all_loading_title" = "Accepting all requests to join";
"screen_knock_requests_list_accept_loading_title" = "Accepting request to join";
"screen_knock_requests_list_ban_alert_confirm_button_title" = "Yes, decline and ban";
"screen_knock_requests_list_ban_alert_description" = "Are you sure you want to decline and ban %1$@? This user wont be able to request access to join this room again.";
"screen_knock_requests_list_ban_alert_title" = "Decline and ban from accessing";
"screen_knock_requests_list_ban_loading_title" = "Declining and banning access";
"screen_knock_requests_list_decline_alert_confirm_button_title" = "Yes, decline";
"screen_knock_requests_list_decline_alert_description" = "Are you sure you want to decline %1$@ request to join this room?";
"screen_knock_requests_list_decline_alert_title" = "Decline access";
"screen_knock_requests_list_decline_and_ban_action_title" = "Decline and ban";
"screen_knock_requests_list_decline_loading_title" = "Declining request to join";
"screen_knock_requests_list_empty_state_description" = "When somebody will ask to join the room, youll be able to see their request here.";
"screen_knock_requests_list_empty_state_title" = "No pending request to join";
"screen_knock_requests_list_title" = "Requests to join";
@ -419,7 +424,22 @@
"screen_room_single_knock_request_view_button_title" = "View";
"screen_room_details_pinned_events_row_title" = "Pinned messages";
"screen_room_details_requests_to_join_title" = "Requests to join";
"screen_room_details_security_and_privacy_title" = "Security & privacy";
"screen_roomlist_knock_event_sent_description" = "Request to join sent";
"screen_security_and_privacy_ask_to_join_option_description" = "Anyone can ask to join the room but an administrator or moderator will have to accept the request.";
"screen_security_and_privacy_ask_to_join_option_title" = "Ask to join";
"screen_security_and_privacy_enable_encryption_alert_confirm_button_title" = "Yes, enable encryption";
"screen_security_and_privacy_enable_encryption_alert_description" = "Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room.\nNo one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly.\nWe do not recommend enabling encryption for rooms that anyone can find and join.";
"screen_security_and_privacy_enable_encryption_alert_title" = "Enable encryption?";
"screen_security_and_privacy_encryption_section_footer" = "Once enabled, encryption cannot be disabled.";
"screen_security_and_privacy_encryption_section_title" = "Encryption";
"screen_security_and_privacy_encryption_toggle_title" = "Enable end-to-end encryption";
"screen_security_and_privacy_room_access_anyone_option_description" = "Anyone can find and join";
"screen_security_and_privacy_room_access_anyone_option_title" = "Anyone";
"screen_security_and_privacy_room_access_invite_only_option_description" = "People can only join if they are invited";
"screen_security_and_privacy_room_access_invite_only_option_title" = "Invite only";
"screen_security_and_privacy_room_access_section_title" = "Room access";
"screen_security_and_privacy_title" = "Security & privacy";
"screen_timeline_item_menu_send_failure_changed_identity" = "Message not sent because %1$@s verified identity has changed.";
"screen_timeline_item_menu_send_failure_unsigned_device" = "Message not sent because %1$@ has not verified all devices.";
"screen_timeline_item_menu_send_failure_you_unsigned_device" = "Message not sent because you have not verified one or more of your devices.";
@ -1004,6 +1024,7 @@
"test_language_identifier" = "en";
"test_untranslated_default_language_identifier" = "en";
"timeline_decryption_failure_historical_event_no_key_backup" = "Historical messages are not available on this device";
"timeline_decryption_failure_historical_event_user_not_joined" = "You don't have access to this message";
"timeline_decryption_failure_unable_to_decrypt" = "Unable to decrypt message";
"timeline_decryption_failure_withheld_unverified" = "This message was blocked either because you did not verify your device or because the sender needs to verify your identity.";
"troubleshoot_notifications_entry_point_section" = "Troubleshoot";

View File

@ -314,6 +314,8 @@ internal enum L10n {
internal static var commonCurrentUserLeftRoom: String { return L10n.tr("Localizable", "common_current_user_left_room") }
/// Dark
internal static var commonDark: String { return L10n.tr("Localizable", "common_dark") }
/// This month
internal static var commonDateSeparatorThisMonth: String { return L10n.tr("Localizable", "common_date_separator_this_month") }
/// Decryption error
internal static var commonDecryptionError: String { return L10n.tr("Localizable", "common_decryption_error") }
/// Developer options
@ -1314,6 +1316,10 @@ internal enum L10n {
internal static var screenKnockRequestsListAcceptAllAlertTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_accept_all_alert_title") }
/// Accept all
internal static var screenKnockRequestsListAcceptAllButtonTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_accept_all_button_title") }
/// Accepting all requests to join
internal static var screenKnockRequestsListAcceptAllLoadingTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_accept_all_loading_title") }
/// Accepting request to join
internal static var screenKnockRequestsListAcceptLoadingTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_accept_loading_title") }
/// Yes, decline and ban
internal static var screenKnockRequestsListBanAlertConfirmButtonTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_ban_alert_confirm_button_title") }
/// Are you sure you want to decline and ban %1$@? This user wont be able to request access to join this room again.
@ -1322,6 +1328,8 @@ internal enum L10n {
}
/// Decline and ban from accessing
internal static var screenKnockRequestsListBanAlertTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_ban_alert_title") }
/// Declining and banning access
internal static var screenKnockRequestsListBanLoadingTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_ban_loading_title") }
/// Yes, decline
internal static var screenKnockRequestsListDeclineAlertConfirmButtonTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_decline_alert_confirm_button_title") }
/// Are you sure you want to decline %1$@ request to join this room?
@ -1332,6 +1340,8 @@ internal enum L10n {
internal static var screenKnockRequestsListDeclineAlertTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_decline_alert_title") }
/// Decline and ban
internal static var screenKnockRequestsListDeclineAndBanActionTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_decline_and_ban_action_title") }
/// Declining request to join
internal static var screenKnockRequestsListDeclineLoadingTitle: String { return L10n.tr("Localizable", "screen_knock_requests_list_decline_loading_title") }
/// When somebody will ask to join the room, youll be able to see their request here.
internal static var screenKnockRequestsListEmptyStateDescription: String { return L10n.tr("Localizable", "screen_knock_requests_list_empty_state_description") }
/// No pending request to join
@ -1826,6 +1836,8 @@ internal enum L10n {
internal static var screenRoomDetailsRolesAndPermissions: String { return L10n.tr("Localizable", "screen_room_details_roles_and_permissions") }
/// Room name
internal static var screenRoomDetailsRoomNameLabel: String { return L10n.tr("Localizable", "screen_room_details_room_name_label") }
/// Security & privacy
internal static var screenRoomDetailsSecurityAndPrivacyTitle: String { return L10n.tr("Localizable", "screen_room_details_security_and_privacy_title") }
/// Security
internal static var screenRoomDetailsSecurityTitle: String { return L10n.tr("Localizable", "screen_room_details_security_title") }
/// Share room
@ -2118,6 +2130,36 @@ internal enum L10n {
internal static var screenRoomlistMarkAsUnread: String { return L10n.tr("Localizable", "screen_roomlist_mark_as_unread") }
/// Browse all rooms
internal static var screenRoomlistRoomDirectoryButtonTitle: String { return L10n.tr("Localizable", "screen_roomlist_room_directory_button_title") }
/// Anyone can ask to join the room but an administrator or moderator will have to accept the request.
internal static var screenSecurityAndPrivacyAskToJoinOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_ask_to_join_option_description") }
/// Ask to join
internal static var screenSecurityAndPrivacyAskToJoinOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_ask_to_join_option_title") }
/// Yes, enable encryption
internal static var screenSecurityAndPrivacyEnableEncryptionAlertConfirmButtonTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_enable_encryption_alert_confirm_button_title") }
/// Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room.
/// No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly.
/// We do not recommend enabling encryption for rooms that anyone can find and join.
internal static var screenSecurityAndPrivacyEnableEncryptionAlertDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_enable_encryption_alert_description") }
/// Enable encryption?
internal static var screenSecurityAndPrivacyEnableEncryptionAlertTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_enable_encryption_alert_title") }
/// Once enabled, encryption cannot be disabled.
internal static var screenSecurityAndPrivacyEncryptionSectionFooter: String { return L10n.tr("Localizable", "screen_security_and_privacy_encryption_section_footer") }
/// Encryption
internal static var screenSecurityAndPrivacyEncryptionSectionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_encryption_section_title") }
/// Enable end-to-end encryption
internal static var screenSecurityAndPrivacyEncryptionToggleTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_encryption_toggle_title") }
/// Anyone can find and join
internal static var screenSecurityAndPrivacyRoomAccessAnyoneOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_anyone_option_description") }
/// Anyone
internal static var screenSecurityAndPrivacyRoomAccessAnyoneOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_anyone_option_title") }
/// People can only join if they are invited
internal static var screenSecurityAndPrivacyRoomAccessInviteOnlyOptionDescription: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_invite_only_option_description") }
/// Invite only
internal static var screenSecurityAndPrivacyRoomAccessInviteOnlyOptionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_invite_only_option_title") }
/// Room access
internal static var screenSecurityAndPrivacyRoomAccessSectionTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_room_access_section_title") }
/// Security & privacy
internal static var screenSecurityAndPrivacyTitle: String { return L10n.tr("Localizable", "screen_security_and_privacy_title") }
/// Change account provider
internal static var screenServerConfirmationChangeServer: String { return L10n.tr("Localizable", "screen_server_confirmation_change_server") }
/// A private server for Element employees.
@ -2526,6 +2568,8 @@ internal enum L10n {
internal static var testUntranslatedDefaultLanguageIdentifier: String { return L10n.tr("Localizable", "test_untranslated_default_language_identifier") }
/// Historical messages are not available on this device
internal static var timelineDecryptionFailureHistoricalEventNoKeyBackup: String { return L10n.tr("Localizable", "timeline_decryption_failure_historical_event_no_key_backup") }
/// You don't have access to this message
internal static var timelineDecryptionFailureHistoricalEventUserNotJoined: String { return L10n.tr("Localizable", "timeline_decryption_failure_historical_event_user_not_joined") }
/// Unable to decrypt message
internal static var timelineDecryptionFailureUnableToDecrypt: String { return L10n.tr("Localizable", "timeline_decryption_failure_unable_to_decrypt") }
/// This message was blocked either because you did not verify your device or because the sender needs to verify your identity.

View File

@ -14,9 +14,15 @@ enum MediaEventsTimelineScreenMode {
case files
}
struct MediaEventsTimelineGroup: Identifiable {
var id: String
var title: String
var items: [RoomTimelineItemViewState]
}
struct MediaEventsTimelineScreenViewState: BindableState {
var isBackPaginating = false
var items = [RoomTimelineItemViewState]()
var groups = [MediaEventsTimelineGroup]()
var bindings: MediaEventsTimelineScreenViewStateBindings
}

View File

@ -84,16 +84,41 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
// MARK: - Private
private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) {
state.items = timelineViewState.timelineState.itemViewStates.filter { itemViewState in
var newGroups = [MediaEventsTimelineGroup]()
var currentItems = [RoomTimelineItemViewState]()
timelineViewState.timelineState.itemViewStates.filter { itemViewState in
switch itemViewState.type {
case .image, .video:
state.bindings.screenMode == .media
case .audio, .file:
case .audio, .file, .voice:
state.bindings.screenMode == .files
case .separator:
true
default:
false
}
}.reversed()
}.reversed().forEach { item in
if case .separator(let item) = item.type {
let group = MediaEventsTimelineGroup(id: item.id.uniqueID.id,
title: titleForDate(item.timestamp),
items: currentItems)
currentItems = []
newGroups.append(group)
} else {
currentItems.append(item)
}
}
if !currentItems.isEmpty {
MXLog.warning("Found ungrouped timeline items, appending them at end.")
let group = MediaEventsTimelineGroup(id: UUID().uuidString,
title: titleForDate(.now),
items: currentItems)
newGroups.append(group)
}
state.groups = newGroups
state.isBackPaginating = (timelineViewState.timelineState.paginationState.backward == .paginating)
backPaginateIfNecessary(paginationStatus: timelineViewState.timelineState.paginationState.backward)
@ -124,4 +149,12 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
userIndicatorController: userIndicatorController)
state.bindings.mediaPreviewViewModel = viewModel
}
private func titleForDate(_ date: Date) -> String {
if Calendar.current.isDate(date, equalTo: .now, toGranularity: .month) {
L10n.commonDateSeparatorThisMonth
} else {
date.formatted(.dateTime.month(.wide).year())
}
}
}

View File

@ -33,13 +33,21 @@ struct MediaEventsTimelineScreen: View {
.timelineMediaQuickLook(viewModel: $context.mediaPreviewViewModel)
}
// The scale effects do the following:
// * flip the scrollView vertically to keep the items
// at the bottom and have pagination working properly
// * flip the grid vertically to counteract the scroll view
// but also horizontally to preserve the corect item order
// * flip the items on both axes have them render correctly
@ViewBuilder
private var content: some View {
ScrollView {
Group {
let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)]
LazyVGrid(columns: columns, alignment: .center, spacing: 1) {
ForEach(context.viewState.items) { item in
ForEach(context.viewState.groups) { group in
Section(footer: sectionFooterForGroup(group)) {
ForEach(group.items) { item in
Button {
context.send(viewAction: .tappedItem(item))
} label: {
@ -49,28 +57,15 @@ struct MediaEventsTimelineScreen: View {
viewForTimelineItem(item)
}
.clipped()
.scaleEffect(.init(width: 1, height: -1))
.scaleEffect(.init(width: -1, height: -1))
}
}
}
}
}
.scaleEffect(.init(width: -1, height: 1))
// Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger
LazyVStack(spacing: 0) {
Rectangle()
.frame(height: 44)
.foregroundStyle(.compound.bgCanvasDefault)
.overlay {
if context.viewState.isBackPaginating {
ProgressView()
}
}
.onAppear {
context.send(viewAction: .oldestItemDidAppear)
}
.onDisappear {
context.send(viewAction: .oldestItemDidDisappear)
}
}
header
}
}
.scaleEffect(.init(width: 1, height: -1))
@ -79,7 +74,37 @@ struct MediaEventsTimelineScreen: View {
}
}
@ViewBuilder func viewForTimelineItem(_ item: RoomTimelineItemViewState) -> some View {
private var header: some View {
// Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger
LazyVStack(spacing: 0) {
ProgressView()
.padding()
.opacity(context.viewState.isBackPaginating ? 1 : 0)
Rectangle()
.frame(height: 1)
.foregroundStyle(.compound.bgCanvasDefault)
.onAppear {
context.send(viewAction: .oldestItemDidAppear)
}
.onDisappear {
context.send(viewAction: .oldestItemDidDisappear)
}
}
}
@ViewBuilder
func sectionFooterForGroup(_ group: MediaEventsTimelineGroup) -> some View {
Text(group.title)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.frame(alignment: .center)
.scaleEffect(.init(width: -1, height: -1))
.padding(.vertical, 16)
}
@ViewBuilder
func viewForTimelineItem(_ item: RoomTimelineItemViewState) -> some View {
switch item.type {
case .image(let timelineItem):
#warning("Make this work for gifs")
@ -107,8 +132,6 @@ struct MediaEventsTimelineScreen: View {
} else {
playIcon
}
case .separator(let timelineItem):
Text(timelineItem.text)
default:
EmptyView()
}
@ -169,12 +192,12 @@ struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
NavigationStack {
MediaEventsTimelineScreen(context: mediaViewModel.context)
.previewDisplayName("Media")
}
.previewDisplayName("Media")
NavigationStack {
MediaEventsTimelineScreen(context: filesViewModel.context)
}
.previewDisplayName("Files")
}
}
}

View File

@ -52,8 +52,8 @@ struct CollapsibleRoomTimelineView: View {
struct CollapsibleRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let item = CollapsibleTimelineItem(items: [
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "First separator")), text: "This is a separator"),
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Second separator")), text: "This is another separator")
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "First separator")), timestamp: .mock),
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Second separator")), timestamp: .mock)
])
static var previews: some View {

View File

@ -33,7 +33,7 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(alignment: .leading, spacing: 0) {
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .virtual(uniqueID: .init(id: "Separator")), text: "Today")), groupStyle: .single))
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .virtual(uniqueID: .init(id: "Separator")), timestamp: .mock)), groupStyle: .single))
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .randomEvent,
timestamp: .mock,
isOutgoing: true,
@ -45,7 +45,7 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
ReadMarkerRoomTimelineView(timelineItem: item)
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .virtual(uniqueID: .init(id: "Separator")), text: "Today")), groupStyle: .single))
RoomTimelineItemView(viewState: .init(type: .separator(.init(id: .virtual(uniqueID: .init(id: "Separator")), timestamp: .mock)), groupStyle: .single))
RoomTimelineItemView(viewState: .init(type: .text(.init(id: .randomEvent,
timestamp: .mock,
isOutgoing: false,

View File

@ -11,7 +11,7 @@ struct SeparatorRoomTimelineView: View {
let timelineItem: SeparatorRoomTimelineItem
var body: some View {
Text(timelineItem.text)
Text(timelineItem.timestamp.formatted(date: .complete, time: .omitted))
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textPrimary)
.frame(maxWidth: .infinity)
@ -24,7 +24,7 @@ struct SeparatorRoomTimelineView: View {
struct SeparatorRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
let item = SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Separator")),
text: "This is a separator")
timestamp: .mock)
SeparatorRoomTimelineView(timelineItem: item)
}
}

View File

@ -10,7 +10,7 @@ import Foundation
enum RoomTimelineItemFixtures {
/// The default timeline items used in Xcode previews etc.
static var `default`: [RoomTimelineItemProtocol] = [
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Yesterday")), text: "Yesterday"),
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Yesterday")), timestamp: .mock),
TextRoomTimelineItem(id: .event(uniqueID: .init(id: ".RoomTimelineItemFixtures.default.0"),
eventOrTransactionID: .eventId(eventId: "RoomTimelineItemFixtures.default.0")),
timestamp: .mock,
@ -52,7 +52,7 @@ enum RoomTimelineItemFixtures {
ReactionSender(id: "jacob", timestamp: Date())
])
])),
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Today")), text: "Today"),
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: "Today")), timestamp: .mock),
TextRoomTimelineItem(id: .event(uniqueID: .init(id: "RoomTimelineItemFixtures.default.3"),
eventOrTransactionID: .eventId(eventId: "RoomTimelineItemFixtures.default.3")),
timestamp: .mock,
@ -262,6 +262,11 @@ enum RoomTimelineItemFixtures {
VoiceMessageRoomTimelineItem(isOutgoing: true)
]
}
static var separator: SeparatorRoomTimelineItem {
SeparatorRoomTimelineItem(id: .virtual(uniqueID: .init(id: UUID().uuidString)),
timestamp: .now)
}
}
private extension TextRoomTimelineItem {

View File

@ -42,7 +42,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
switch timelineKind {
case .media:
timelineItems = (0..<5).reduce([]) { partialResult, _ in
partialResult + RoomTimelineItemFixtures.mediaChunk
partialResult + [RoomTimelineItemFixtures.separator] + RoomTimelineItemFixtures.mediaChunk
}
default:
break

View File

@ -472,9 +472,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
switch virtualItem {
case .dateDivider(let timestamp):
let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
let dateString = date.formatted(date: .complete, time: .omitted)
return SeparatorRoomTimelineItem(id: .virtual(uniqueID: uniqueID), text: dateString)
return SeparatorRoomTimelineItem(id: .virtual(uniqueID: uniqueID), timestamp: date)
case .readMarker:
return ReadMarkerRoomTimelineItem(id: .virtual(uniqueID: uniqueID))
}

View File

@ -9,5 +9,5 @@ import Foundation
struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier
let text: String
let timestamp: Date
}

View File

@ -441,7 +441,7 @@ private extension TextRoomTimelineItem {
private extension SeparatorRoomTimelineItem {
init(uniqueID: TimelineUniqueId) {
self.init(id: .virtual(uniqueID: uniqueID), text: "")
self.init(id: .virtual(uniqueID: uniqueID), timestamp: .mock)
}
}