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
55 changed files with 256 additions and 126 deletions
+44
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 won’t 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, you’ll 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.
@@ -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
}
@@ -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())
}
}
}
@@ -33,44 +33,39 @@ 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
Button {
context.send(viewAction: .tappedItem(item))
} label: {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
viewForTimelineItem(item)
ForEach(context.viewState.groups) { group in
Section(footer: sectionFooterForGroup(group)) {
ForEach(group.items) { item in
Button {
context.send(viewAction: .tappedItem(item))
} label: {
Color.clear // Let the image aspect fill in place
.aspectRatio(1, contentMode: .fill)
.overlay {
viewForTimelineItem(item)
}
.clipped()
.scaleEffect(.init(width: -1, height: -1))
}
.clipped()
.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")
}
.previewDisplayName("Files")
}
}
@@ -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 {
@@ -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,
@@ -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)
}
}
@@ -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 {
@@ -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
@@ -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))
}
@@ -9,5 +9,5 @@ import Foundation
struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier
let text: String
let timestamp: Date
}