Explorar o código

Merge pull request #739 from deltachat/gallery

Gallery for chat detail
cyBerta %!s(int64=5) %!d(string=hai) anos
pai
achega
56a60aab02

+ 20 - 0
deltachat-ios.xcodeproj/project.pbxproj

@@ -130,6 +130,9 @@
 		7A9FB14E1FB061E2001FEA36 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 7A9FB14C1FB061E2001FEA36 /* LaunchScreen.storyboard */; };
 		7AE0A5491FC42F65005ECB4B /* NewChatViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AE0A5481FC42F65005ECB4B /* NewChatViewController.swift */; };
 		8B6D425BC604F7C43B65D436 /* Pods_deltachat_ios.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6241BE1534A653E79AD5D01D /* Pods_deltachat_ios.framework */; };
+		AE0AA952247800E700D42A7F /* GalleryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0AA951247800E700D42A7F /* GalleryCell.swift */; };
+		AE0AA9562478191900D42A7F /* GridCollectionViewFlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0AA9552478191900D42A7F /* GridCollectionViewFlowLayout.swift */; };
+		AE0AA958247834A400D42A7F /* Date+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0AA957247834A400D42A7F /* Date+Extension.swift */; };
 		AE0D26FD1FB1FE88002FAFCE /* ChatListController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE0D26FC1FB1FE88002FAFCE /* ChatListController.swift */; };
 		AE18F294228C602A0007B1BE /* SecuritySettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE18F293228C602A0007B1BE /* SecuritySettingsController.swift */; };
 		AE19887523EB264000B4CD5F /* HelpViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE19887423EB264000B4CD5F /* HelpViewController.swift */; };
@@ -152,6 +155,7 @@
 		AE851AC7227C776400ED86F0 /* Location.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE851AC6227C776400ED86F0 /* Location.swift */; };
 		AE851AC9227C77CF00ED86F0 /* Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE851AC8227C77CF00ED86F0 /* Media.swift */; };
 		AE851AD0227DF50900ED86F0 /* GroupChatDetailViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE851ACF227DF50900ED86F0 /* GroupChatDetailViewController.swift */; };
+		AE8F503524753DFE007FEE0B /* GalleryViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8F503424753DFE007FEE0B /* GalleryViewController.swift */; };
 		AE9DAF0D22C1215D004C9591 /* EditContactController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9DAF0C22C1215D004C9591 /* EditContactController.swift */; };
 		AE9DAF0F22C278C6004C9591 /* ChatTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE9DAF0E22C278C6004C9591 /* ChatTitleView.swift */; };
 		AEA0F6A124474146009F887B /* ProfileInfoViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA0F6A024474146009F887B /* ProfileInfoViewController.swift */; };
@@ -170,6 +174,7 @@
 		AEE6EC412282DF5700EDC689 /* MailboxViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE6EC402282DF5700EDC689 /* MailboxViewController.swift */; };
 		AEE6EC482283045D00EDC689 /* EditSettingsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE6EC472283045D00EDC689 /* EditSettingsController.swift */; };
 		AEE700252438E0E500D6992E /* ProgressAlertHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE700242438E0E500D6992E /* ProgressAlertHandler.swift */; };
+		AEF53BD5248904BF00D309C1 /* GalleryTimeLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF53BD4248904BF00D309C1 /* GalleryTimeLabel.swift */; };
 		AEFBE22F23FEF23D0045327A /* ProviderInfoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFBE22E23FEF23D0045327A /* ProviderInfoCell.swift */; };
 		AEFBE23123FF09B20045327A /* TypeAlias.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEFBE23023FF09B20045327A /* TypeAlias.swift */; };
 		B20462E42440A4A600367A57 /* SettingsAutodelOverviewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20462E32440A4A600367A57 /* SettingsAutodelOverviewController.swift */; };
@@ -409,6 +414,9 @@
 		8DE110C607A0E4485C43B5FA /* Pods-deltachat-ios.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-deltachat-ios.debug.xcconfig"; path = "Pods/Target Support Files/Pods-deltachat-ios/Pods-deltachat-ios.debug.xcconfig"; sourceTree = "<group>"; };
 		914DDA723E78965D83162E78 /* Pods-deltachat-ios-DcShare.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-deltachat-ios-DcShare.debug.xcconfig"; path = "Pods/Target Support Files/Pods-deltachat-ios-DcShare/Pods-deltachat-ios-DcShare.debug.xcconfig"; sourceTree = "<group>"; };
 		A8615D4600859851E53CAA9C /* Pods-deltachat-ios.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-deltachat-ios.release.xcconfig"; path = "Pods/Target Support Files/Pods-deltachat-ios/Pods-deltachat-ios.release.xcconfig"; sourceTree = "<group>"; };
+		AE0AA951247800E700D42A7F /* GalleryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryCell.swift; sourceTree = "<group>"; };
+		AE0AA9552478191900D42A7F /* GridCollectionViewFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GridCollectionViewFlowLayout.swift; sourceTree = "<group>"; };
+		AE0AA957247834A400D42A7F /* Date+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Date+Extension.swift"; sourceTree = "<group>"; };
 		AE0D26FC1FB1FE88002FAFCE /* ChatListController.swift */ = {isa = PBXFileReference; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = ChatListController.swift; sourceTree = "<group>"; tabWidth = 4; };
 		AE18F293228C602A0007B1BE /* SecuritySettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecuritySettingsController.swift; sourceTree = "<group>"; };
 		AE19887423EB264000B4CD5F /* HelpViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpViewController.swift; sourceTree = "<group>"; };
@@ -433,6 +441,7 @@
 		AE851AC6227C776400ED86F0 /* Location.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Location.swift; sourceTree = "<group>"; };
 		AE851AC8227C77CF00ED86F0 /* Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Media.swift; sourceTree = "<group>"; };
 		AE851ACF227DF50900ED86F0 /* GroupChatDetailViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatDetailViewController.swift; sourceTree = "<group>"; };
+		AE8F503424753DFE007FEE0B /* GalleryViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GalleryViewController.swift; sourceTree = "<group>"; };
 		AE9DAF0C22C1215D004C9591 /* EditContactController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditContactController.swift; sourceTree = "<group>"; };
 		AE9DAF0E22C278C6004C9591 /* ChatTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatTitleView.swift; sourceTree = "<group>"; };
 		AEA0F6A024474146009F887B /* ProfileInfoViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInfoViewController.swift; sourceTree = "<group>"; };
@@ -451,6 +460,7 @@
 		AEE6EC402282DF5700EDC689 /* MailboxViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MailboxViewController.swift; sourceTree = "<group>"; };
 		AEE6EC472283045D00EDC689 /* EditSettingsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditSettingsController.swift; sourceTree = "<group>"; };
 		AEE700242438E0E500D6992E /* ProgressAlertHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressAlertHandler.swift; sourceTree = "<group>"; };
+		AEF53BD4248904BF00D309C1 /* GalleryTimeLabel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GalleryTimeLabel.swift; sourceTree = "<group>"; };
 		AEFBE22E23FEF23D0045327A /* ProviderInfoCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProviderInfoCell.swift; sourceTree = "<group>"; };
 		AEFBE23023FF09B20045327A /* TypeAlias.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypeAlias.swift; sourceTree = "<group>"; };
 		B20462E02440805C00367A57 /* id */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = id; path = id.lproj/InfoPlist.strings; sourceTree = "<group>"; };
@@ -588,6 +598,7 @@
 				3059620D234614E700C80F33 /* DcContact+Extension.swift */,
 				3059620F2346154D00C80F33 /* String+Extension.swift */,
 				302B84CD2397F6CD001C261F /* URL+Extension.swift */,
+				AE0AA957247834A400D42A7F /* Date+Extension.swift */,
 			);
 			path = Extensions;
 			sourceTree = "<group>";
@@ -837,6 +848,7 @@
 			isa = PBXGroup;
 			children = (
 				AE406EEF240FF8FF005F7022 /* ProfileCell.swift */,
+				AE0AA951247800E700D42A7F /* GalleryCell.swift */,
 			);
 			path = Cell;
 			sourceTree = "<group>";
@@ -902,6 +914,7 @@
 				B20462E32440A4A600367A57 /* SettingsAutodelOverviewController.swift */,
 				B20462E52440C99600367A57 /* SettingsAutodelSetController.swift */,
 				AE76E5ED242BF2EA003CF461 /* WelcomeViewController.swift */,
+				AE8F503424753DFE007FEE0B /* GalleryViewController.swift */,
 			);
 			path = Controller;
 			sourceTree = "<group>";
@@ -926,6 +939,7 @@
 				AE1988A423EB2FBA00B4CD5F /* Errors.swift */,
 				AEFBE23023FF09B20045327A /* TypeAlias.swift */,
 				307D822D241669C7006D2490 /* LocationManager.swift */,
+				AE0AA9552478191900D42A7F /* GridCollectionViewFlowLayout.swift */,
 			);
 			path = Helper;
 			sourceTree = "<group>";
@@ -933,6 +947,7 @@
 		AE851AC3227C695900ED86F0 /* View */ = {
 			isa = PBXGroup;
 			children = (
+				AEF53BD4248904BF00D309C1 /* GalleryTimeLabel.swift */,
 				AE406EEE240FA454005F7022 /* Cell */,
 				B26B3BC6236DC3DC008ED35A /* SwitchCell.swift */,
 				70B8882D2091B8550074812E /* ContactCell.swift */,
@@ -1393,9 +1408,11 @@
 				308FEA50246AB67100FCEAD6 /* FileMessageCell.swift in Sources */,
 				7A0052C81FBE6CB40048C3BF /* NewContactController.swift in Sources */,
 				AEE56D762253431E007DC082 /* AccountSetupController.swift in Sources */,
+				AE8F503524753DFE007FEE0B /* GalleryViewController.swift in Sources */,
 				305FE03623A81B4C0053BE90 /* EmptyStateLabel.swift in Sources */,
 				AEACE2DD1FB323CA00DCDD78 /* ChatViewController.swift in Sources */,
 				AEE6EC412282DF5700EDC689 /* MailboxViewController.swift in Sources */,
+				AEF53BD5248904BF00D309C1 /* GalleryTimeLabel.swift in Sources */,
 				AEE6EC482283045D00EDC689 /* EditSettingsController.swift in Sources */,
 				305961DF2346125100C80F33 /* MessageCellDelegate.swift in Sources */,
 				302B84CE2397F6CD001C261F /* URL+Extension.swift in Sources */,
@@ -1405,6 +1422,7 @@
 				308FEA52246ABA2700FCEAD6 /* FileMessageSizeCalculator.swift in Sources */,
 				305961F52346125100C80F33 /* TypingIndicatorCell.swift in Sources */,
 				305961FF2346125100C80F33 /* AvatarView.swift in Sources */,
+				AE0AA9562478191900D42A7F /* GridCollectionViewFlowLayout.swift in Sources */,
 				3059620C2346125100C80F33 /* MessageSizeCalculator.swift in Sources */,
 				305962042346125100C80F33 /* MessagesCollectionViewLayoutAttributes.swift in Sources */,
 				AEA0F6A124474146009F887B /* ProfileInfoViewController.swift in Sources */,
@@ -1449,6 +1467,8 @@
 				7092474120B3869500AF8799 /* ContactDetailViewController.swift in Sources */,
 				300C50A1234BDAB800F8AE22 /* TextMediaMessageSizeCalculator.swift in Sources */,
 				30F9B9EC235F2116006E7ACF /* MessageCounter.swift in Sources */,
+				AE0AA952247800E700D42A7F /* GalleryCell.swift in Sources */,
+				AE0AA958247834A400D42A7F /* Date+Extension.swift in Sources */,
 				307D822E241669C7006D2490 /* LocationManager.swift in Sources */,
 				305961F12346125100C80F33 /* ContactMessageCell.swift in Sources */,
 				AE851AD0227DF50900ED86F0 /* GroupChatDetailViewController.swift in Sources */,

+ 6 - 1
deltachat-ios/Controller/ContactDetailViewController.swift

@@ -330,7 +330,12 @@ class ContactDetailViewController: UITableViewController {
     }
 
     private func showGallery() {
-        presentPreview(for: DC_MSG_IMAGE, messageType2: DC_MSG_GIF, messageType3: DC_MSG_VIDEO)
+        let messageIds = viewModel.context.getChatMedia(chatId: viewModel.chatId,
+                                                        messageType: DC_MSG_IMAGE,
+                                                        messageType2: DC_MSG_GIF,
+                                                        messageType3: DC_MSG_VIDEO)
+        let galleryController = GalleryViewController(mediaMessageIds: messageIds)
+            navigationController?.pushViewController(galleryController, animated: true)
     }
 
     private func presentPreview(for messageType: Int32, messageType2: Int32, messageType3: Int32) {

+ 198 - 0
deltachat-ios/Controller/GalleryViewController.swift

@@ -0,0 +1,198 @@
+import UIKit
+import DcCore
+
+class GalleryViewController: UIViewController {
+
+    // MARK: - data
+    private let mediaMessageIds: [Int]
+
+    // MARK: - subview specs
+    private let gridDefaultSpacing: CGFloat = 5
+
+    private lazy var gridLayout: GridCollectionViewFlowLayout = {
+        let layout = GridCollectionViewFlowLayout()
+        layout.minimumLineSpacing = gridDefaultSpacing
+        layout.minimumInteritemSpacing = gridDefaultSpacing
+        layout.format = .square
+        return layout
+    }()
+
+    private lazy var grid: UICollectionView = {
+        let collection = UICollectionView(frame: .zero, collectionViewLayout: gridLayout)
+        collection.dataSource = self
+        collection.delegate = self
+        collection.register(GalleryCell.self, forCellWithReuseIdentifier: GalleryCell.reuseIdentifier)
+        collection.contentInset = UIEdgeInsets(top: gridDefaultSpacing, left: gridDefaultSpacing, bottom: gridDefaultSpacing, right: gridDefaultSpacing)
+        collection.backgroundColor = .white
+        collection.delaysContentTouches = false
+        collection.alwaysBounceVertical = true
+        return collection
+    }()
+
+    private lazy var timeLabel: GalleryTimeLabel = {
+        let view = GalleryTimeLabel()
+        view.hide(animated: false)
+        return view
+    }()
+
+    private lazy var emptyStateView: EmptyStateLabel = {
+        let label = EmptyStateLabel()
+        label.text = String.localized("chat_gallery_empty_state")
+        label.isHidden = true
+        return label
+    }()
+
+    init(mediaMessageIds: [Int]) {
+        self.mediaMessageIds = mediaMessageIds.reversed()
+        super.init(nibName: nil, bundle: nil)
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    // MARK: - lifecycle
+    override func viewDidLoad() {
+        super.viewDidLoad()
+        setupSubviews()
+        title = String.localized("gallery")
+        if mediaMessageIds.isEmpty {
+            emptyStateView.isHidden = false
+        }
+    }
+
+    override func viewWillAppear(_ animated: Bool) {
+        grid.reloadData()
+    }
+
+    override func viewWillLayoutSubviews() {
+        super.viewWillLayoutSubviews()
+        self.reloadCollectionViewLayout()
+    }
+
+    // MARK: - setup
+    private func setupSubviews() {
+        view.addSubview(grid)
+        grid.translatesAutoresizingMaskIntoConstraints = false
+        grid.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 0).isActive = true
+        grid.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
+        grid.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: 0).isActive = true
+        grid.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
+
+        view.addSubview(timeLabel)
+        timeLabel.translatesAutoresizingMaskIntoConstraints = false
+        timeLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 10).isActive = true
+        timeLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
+
+        view.addSubview(emptyStateView)
+        emptyStateView.translatesAutoresizingMaskIntoConstraints = false
+        emptyStateView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor).isActive = true
+        emptyStateView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor).isActive = true
+        emptyStateView.centerYAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerYAnchor).isActive = true
+        emptyStateView.centerXAnchor.constraint(equalTo: view.safeAreaLayoutGuide.centerXAnchor).isActive = true
+    }
+
+    // MARK: - updates
+    private func updateFloatingTimeLabel() {
+        if let indexPath = grid.indexPathsForVisibleItems.min() {
+            let msgId = mediaMessageIds[indexPath.row]
+            let msg = DcMsg(id: msgId)
+            timeLabel.update(date: msg.sentDate)
+        }
+    }
+}
+
+// MARK: - UICollectionViewDataSource, UICollectionViewDelegate
+extension GalleryViewController: UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
+
+    func numberOfSections(in collectionView: UICollectionView) -> Int {
+        return 1
+    }
+
+    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
+        return mediaMessageIds.count
+    }
+
+    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
+        guard let galleryCell = collectionView.dequeueReusableCell(
+            withReuseIdentifier: GalleryCell.reuseIdentifier,
+            for: indexPath) as? GalleryCell else {
+            return UICollectionViewCell()
+        }
+        let msgId = mediaMessageIds[indexPath.row]
+        let msg = DcMsg(id: msgId)
+        galleryCell.update(msg: msg)
+        return galleryCell
+    }
+
+    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
+        let msgId = mediaMessageIds[indexPath.row]
+        showPreview(msgId: msgId)
+    }
+
+    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
+        updateFloatingTimeLabel()
+        timeLabel.show(animated: true)
+    }
+
+    func scrollViewDidScroll(_ scrollView: UIScrollView) {
+        updateFloatingTimeLabel()
+    }
+
+    func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
+        timeLabel.hide(animated: true)
+    }
+}
+
+// MARK: - grid layout + updates
+private extension GalleryViewController {
+    func reloadCollectionViewLayout() {
+
+        // columns specification
+        let phonePortrait = 3
+        let phoneLandscape = 4
+        let padPortrait = 5
+        let padLandscape = 8
+
+        let orientation = UIApplication.shared.statusBarOrientation
+        let deviceType = UIDevice.current.userInterfaceIdiom
+
+        var gridDisplay: GridDisplay?
+        if deviceType == .phone {
+            if orientation.isPortrait {
+                gridDisplay = .grid(columns: phonePortrait)
+            } else {
+                gridDisplay = .grid(columns: phoneLandscape)
+            }
+        } else if deviceType == .pad {
+            if orientation.isPortrait {
+                gridDisplay = .grid(columns: padPortrait)
+            } else {
+                gridDisplay = .grid(columns: padLandscape)
+            }
+        }
+
+        if let gridDisplay = gridDisplay {
+            gridLayout.display = gridDisplay
+        } else {
+            safe_fatalError("undefined format")
+        }
+        let containerWidth = view.bounds.width - view.safeAreaInsets.left - view.safeAreaInsets.right - 2 * gridDefaultSpacing
+        gridLayout.containerWidth = containerWidth
+    }
+}
+
+// MARK: - coordinator
+extension GalleryViewController {
+    func showPreview(msgId: Int) {
+        guard let index = mediaMessageIds.index(of: msgId) else {
+            return
+        }
+
+        let mediaUrls = mediaMessageIds.compactMap {
+            return DcMsg(id: $0).fileURL
+        }
+        let previewController = PreviewController(currentIndex: index, urls: mediaUrls)
+        present(previewController, animated: true, completion: nil)
+    }
+}

+ 8 - 1
deltachat-ios/Controller/GroupChatDetailViewController.swift

@@ -229,7 +229,14 @@ class GroupChatDetailViewController: UIViewController {
     }
 
     private func showGallery() {
-        presentPreview(for: DC_MSG_IMAGE, messageType2: DC_MSG_GIF, messageType3: DC_MSG_VIDEO)
+        let messageIds = dcContext.getChatMedia(
+            chatId: chatId,
+            messageType: DC_MSG_IMAGE,
+            messageType2: DC_MSG_GIF,
+            messageType3: DC_MSG_VIDEO
+        )
+        let galleryController = GalleryViewController(mediaMessageIds: messageIds)
+        navigationController?.pushViewController(galleryController, animated: true)
     }
 
     private func presentPreview(for messageType: Int32, messageType2: Int32, messageType3: Int32) {

+ 71 - 0
deltachat-ios/Extensions/Date+Extension.swift

@@ -0,0 +1,71 @@
+import Foundation
+
+
+extension Date {
+
+    var galleryLocalizedDescription: String {
+        if isInToday {
+            return String.localized("today")
+        }
+        if isInYesterday {
+            return String.localized("yesterday")
+        }
+        if isInThisWeek {
+            return String.localized("this_week")
+        }
+        if isInLastWeek {
+            return String.localized("last_week")
+        }
+        if isInThisMonth {
+            return String.localized("this_month")
+        }
+        if isInLastMonth {
+            return String.localized("last_month")
+        }
+
+        let monthName = DateFormatter().monthSymbols[month - 1]
+        let yearName: String = isInSameYear(as: Date()) ? "" : " \(year)"
+        return "\(monthName)\(yearName)"
+    }
+
+}
+
+private extension Date {
+
+    func isEqual(
+        to date: Date,
+        toGranularity component: Calendar.Component,
+        in calendar: Calendar = .current
+    ) -> Bool {
+        calendar.isDate(self, equalTo: date, toGranularity: component)
+    }
+
+    var isInToday: Bool { Calendar.current.isDateInToday(self) }
+    var isInYesterday: Bool { Calendar.current.isDateInYesterday(self) }
+    var isInThisWeek: Bool { isInSameWeek(as: Date()) }
+    var isInLastWeek: Bool {
+        guard let lastWeekDate = Calendar.current.date(byAdding: .weekOfYear, value: -1, to: Date()) else {
+            return false
+        }
+        return isEqual(to: lastWeekDate, toGranularity: .weekOfYear)
+    }
+    var isInThisMonth: Bool { isInSameMonth(as: Date()) }
+    var isInLastMonth: Bool {
+        guard let lastMonthDate = Calendar.current.date(byAdding: .month, value: -1, to: Date()) else {
+            return false
+        }
+        return isEqual(to: lastMonthDate, toGranularity: .month)
+    }
+
+    var month: Int {
+       return Calendar.current.component(.month, from: self)
+    }
+
+    var year: Int {
+        return Calendar.current.component(.year, from: self)
+    }
+
+    func isInSameMonth(as date: Date) -> Bool { isEqual(to: date, toGranularity: .month) }
+    func isInSameWeek(as date: Date) -> Bool { isEqual(to: date, toGranularity: .weekOfYear) }
+    func isInSameYear(as date: Date) -> Bool { isEqual(to: date, toGranularity: .year) }
+}

+ 89 - 0
deltachat-ios/Helper/GridCollectionViewFlowLayout.swift

@@ -0,0 +1,89 @@
+import UIKit
+
+enum GridItemFormat {
+    case square
+    case rect(ratio: CGFloat)
+}
+
+enum GridDisplay {
+    case list
+    case grid(columns: Int)
+}
+
+extension GridDisplay: Equatable {
+
+    public static func == (lhs: GridDisplay, rhs: GridDisplay) -> Bool {
+        switch (lhs, rhs) {
+        case (.list, .list):
+            return true
+        case (.grid(let lColumn), .grid(let rColumn)):
+            return lColumn == rColumn
+
+        default:
+            return false
+        }
+    }
+}
+
+// MARK: - GridCollectionViewFlowLayout
+class GridCollectionViewFlowLayout: UICollectionViewFlowLayout {
+
+    var display: GridDisplay = .list {
+        didSet {
+            if display != oldValue {
+                self.invalidateLayout()
+            }
+        }
+    }
+
+    var containerWidth: CGFloat = 0.0 {
+        didSet {
+            if containerWidth != oldValue {
+                self.invalidateLayout()
+            }
+        }
+    }
+
+    var format: GridItemFormat = .square {
+        didSet {
+            self.invalidateLayout()
+        }
+    }
+
+    convenience init(display: GridDisplay, containerWidth: CGFloat, format: GridItemFormat) {
+        self.init()
+        self.display = display
+        self.containerWidth = containerWidth
+        self.format = format
+        self.configLayout()
+    }
+
+    private func configLayout() {
+        switch display {
+        case .grid(let column):
+            self.scrollDirection = .vertical
+            let spacing = CGFloat(column - 1) * minimumLineSpacing
+            let optimisedWidth = (containerWidth - spacing) / CGFloat(column)
+            let height = calculateHeight(width: optimisedWidth)
+            self.itemSize = CGSize(width: optimisedWidth, height: height) // keep as square
+        case .list:
+            self.scrollDirection = .vertical
+            let height = calculateHeight(width: containerWidth)
+            self.itemSize = CGSize(width: containerWidth, height: height)
+        }
+    }
+
+    private func calculateHeight(width: CGFloat) -> CGFloat {
+        switch format {
+        case .square:
+            return width
+        case .rect(let ratio):
+            return width * ratio
+        }
+    }
+
+    override func invalidateLayout() {
+        super.invalidateLayout()
+        self.configLayout()
+    }
+}

+ 2 - 0
deltachat-ios/Helper/Utils.swift

@@ -65,3 +65,5 @@ struct Utils {
         return String(lang)
     }
 }
+
+

+ 73 - 0
deltachat-ios/View/Cell/GalleryCell.swift

@@ -0,0 +1,73 @@
+import UIKit
+import DcCore
+import SDWebImage
+
+
+class GalleryCell: UICollectionViewCell {
+    static let reuseIdentifier = "gallery_cell"
+
+    var imageView: UIImageView = {
+        let view = UIImageView()
+        view.contentMode = .scaleAspectFill
+        view.clipsToBounds = true
+        return view
+    }()
+
+    private lazy var playButtonView: PlayButtonView = {
+        let playButtonView = PlayButtonView()
+        playButtonView.isHidden = true
+        return playButtonView
+    }()
+
+
+    override init(frame: CGRect) {
+        super.init(frame: frame)
+        setupSubviews()
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    private func setupSubviews() {
+        contentView.addSubview(imageView)
+        imageView.translatesAutoresizingMaskIntoConstraints = false
+        imageView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 0).isActive = true
+        imageView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 0).isActive = true
+        imageView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: 0).isActive = true
+        imageView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: 0).isActive = true
+
+        contentView.addSubview(playButtonView)
+        playButtonView.translatesAutoresizingMaskIntoConstraints = false
+        playButtonView.centerInSuperview()
+        playButtonView.constraint(equalTo: CGSize(width: 50, height: 50))
+    }
+
+    func update(msg: DcMsg) {
+        guard let viewtype = msg.viewtype, let fileUrl = msg.fileURL else {
+            return
+        }
+
+        switch viewtype {
+        case .image:
+            imageView.image = msg.image
+            playButtonView.isHidden = true
+        case .video:
+            imageView.image = DcUtils.generateThumbnailFromVideo(url: fileUrl)
+            playButtonView.isHidden = false
+        case .gif:
+            imageView.sd_setImage(with: fileUrl, placeholderImage: nil)
+            playButtonView.isHidden = true
+        default:
+            safe_fatalError("unsupported viewtype - viewtype \(viewtype) not supported.")
+        }
+    }
+
+    override var isSelected: Bool {
+        willSet {
+            // to provide visual feedback on select events
+            contentView.backgroundColor = newValue ? DcColors.primary : .white
+            imageView.alpha = newValue ? 0.75 : 1.0
+        }
+    }
+}

+ 51 - 0
deltachat-ios/View/GalleryTimeLabel.swift

@@ -0,0 +1,51 @@
+import UIKit
+import DcCore
+
+class GalleryTimeLabel: UIView {
+
+    private lazy var label: UILabel = {
+        let label = UILabel()
+        label.textColor = .white
+        return label
+    }()
+
+    init() {
+        super.init(frame: .zero)
+        setupSubviews()
+        backgroundColor = DcColors.primary.withAlphaComponent(0.8)
+        layer.cornerRadius = 4
+        clipsToBounds = true
+    }
+
+    required init?(coder: NSCoder) {
+        fatalError("init(coder:) has not been implemented")
+    }
+
+    private func setupSubviews() {
+        addSubview(label)
+        label.translatesAutoresizingMaskIntoConstraints = false
+        label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8).isActive = true
+        label.topAnchor.constraint(equalTo: topAnchor, constant: 2).isActive = true
+        label.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8).isActive = true
+        label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2).isActive = true
+    }
+
+    func update(date: Date) {
+        let localizedDescription = date.galleryLocalizedDescription
+        if label.text != localizedDescription {
+            label.text = localizedDescription
+        }
+    }
+
+    func show(animated: Bool) {
+        UIView.animate(withDuration: animated ? 0.2 : 0) {
+            self.alpha = 1
+        }
+    }
+
+    func hide(animated: Bool) {
+        UIView.animate(withDuration: animated ? 0.2 : 0, delay: animated ? 0.2 : 0, options: .curveEaseInOut, animations: {
+            self.alpha = 0
+        })
+    }
+}

+ 1 - 1
deltachat-ios/en.lproj/Localizable.strings

@@ -653,4 +653,4 @@
 "a11y_voice_message_hint_ios" = "After recording double-tap to send. To discard the recording scrub left-right with two fingers.";
 "login_error_no_internet_connection" = "No internet connection, can\'t log in to your server.";
 "share_account_not_configured" = "Account is not configured.";
-
+"chat_gallery_empty_state" = "No media items in this chat.";

+ 1 - 1
tools/untranslated.xml

@@ -8,5 +8,5 @@
     <string name="a11y_voice_message_hint_ios">After recording double-tap to send. To discard the recording scrub left-right with two fingers.</string>
     <string name="login_error_no_internet_connection">No internet connection, can\'t log in to your server.</string>
     <string name="share_account_not_configured">Account is not configured.</string>
-
+    <string name="chat_gallery_empty_state">"No media items in this chat."</string>
 </resources>