mirror of https://github.com/oxen-io/session-ios
Merge remote-tracking branch 'upstream/dev' into feature/theming
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Conversations/ConversationVC+Interaction.swift # Session/Conversations/ConversationVC.swift # Session/Conversations/ConversationViewModel.swift # Session/Conversations/Message Cells/VisibleMessageCell.swift # Session/Home/HomeVC.swift # Session/Home/Message Requests/MessageRequestsViewController.swift # Session/Media Viewing & Editing/MediaTileViewController.swift # Session/Meta/AppDelegate.swift # Session/Meta/Translations/de.lproj/Localizable.strings # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Meta/Translations/es.lproj/Localizable.strings # Session/Meta/Translations/fa.lproj/Localizable.strings # Session/Meta/Translations/fi.lproj/Localizable.strings # Session/Meta/Translations/fr.lproj/Localizable.strings # Session/Meta/Translations/hi.lproj/Localizable.strings # Session/Meta/Translations/hr.lproj/Localizable.strings # Session/Meta/Translations/id-ID.lproj/Localizable.strings # Session/Meta/Translations/it.lproj/Localizable.strings # Session/Meta/Translations/ja.lproj/Localizable.strings # Session/Meta/Translations/nl.lproj/Localizable.strings # Session/Meta/Translations/pl.lproj/Localizable.strings # Session/Meta/Translations/pt_BR.lproj/Localizable.strings # Session/Meta/Translations/ru.lproj/Localizable.strings # Session/Meta/Translations/si.lproj/Localizable.strings # Session/Meta/Translations/sk.lproj/Localizable.strings # Session/Meta/Translations/sv.lproj/Localizable.strings # Session/Meta/Translations/th.lproj/Localizable.strings # Session/Meta/Translations/vi-VN.lproj/Localizable.strings # Session/Meta/Translations/zh-Hant.lproj/Localizable.strings # Session/Meta/Translations/zh_CN.lproj/Localizable.strings # Session/Onboarding/LandingVC.swift # SessionMessagingKitTests/_TestUtilities/MockGeneralCache.swiftpull/672/head
commit
1bc6b0bdba
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,220 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import QuartzCore
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class AllMediaViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||
private var pages: [UIViewController] = []
|
||||
private var targetVCIndex: Int?
|
||||
|
||||
// MARK: Components
|
||||
private lazy var tabBar: TabBar = {
|
||||
let tabs = [
|
||||
TabBar.Tab(title: MediaStrings.media) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[0] ], direction: .forward, animated: false, completion: nil)
|
||||
self.updateSelectButton(updatedData: self.mediaTitleViewController.viewModel.galleryData, inBatchSelectMode: self.mediaTitleViewController.isInBatchSelectMode)
|
||||
},
|
||||
TabBar.Tab(title: MediaStrings.document) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.pageVC.setViewControllers([ self.pages[1] ], direction: .forward, animated: false, completion: nil)
|
||||
self.endSelectMode()
|
||||
self.navigationItem.rightBarButtonItem = nil
|
||||
}
|
||||
]
|
||||
return TabBar(tabs: tabs)
|
||||
}()
|
||||
|
||||
private var mediaTitleViewController: MediaTileViewController
|
||||
private var documentTitleViewController: DocumentTileViewController
|
||||
|
||||
init(mediaTitleViewController: MediaTileViewController, documentTitleViewController: DocumentTileViewController) {
|
||||
self.mediaTitleViewController = mediaTitleViewController
|
||||
self.documentTitleViewController = documentTitleViewController
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.mediaTitleViewController.delegate = self
|
||||
self.documentTitleViewController.delegate = self
|
||||
|
||||
addChild(self.mediaTitleViewController)
|
||||
addChild(self.documentTitleViewController)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
// MARK: Lifecycle
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
view.themeBackgroundColor = .backgroundPrimary
|
||||
|
||||
// Add a custom back button if this is the only view controller
|
||||
if self.navigationController?.viewControllers.first == self {
|
||||
let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton))
|
||||
self.navigationItem.leftBarButtonItem = backButton
|
||||
}
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: MediaStrings.allMedia,
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
// Set up page VC
|
||||
pages = [ mediaTitleViewController, documentTitleViewController ]
|
||||
pageVC.dataSource = self
|
||||
pageVC.delegate = self
|
||||
pageVC.setViewControllers([ mediaTitleViewController ], direction: .forward, animated: false, completion: nil)
|
||||
addChild(pageVC)
|
||||
|
||||
// Set up tab bar
|
||||
view.addSubview(tabBar)
|
||||
tabBar.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: view)
|
||||
// Set up page VC constraints
|
||||
let pageVCView = pageVC.view!
|
||||
view.addSubview(pageVCView)
|
||||
pageVCView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.bottom ], to: view)
|
||||
pageVCView.pin(.top, to: .bottom, of: tabBar)
|
||||
}
|
||||
|
||||
// MARK: General
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
|
||||
guard let index = pages.firstIndex(of: viewController), index != 0 else { return nil }
|
||||
return pages[index - 1]
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
|
||||
guard let index = pages.firstIndex(of: viewController), index != (pages.count - 1) else { return nil }
|
||||
return pages[index + 1]
|
||||
}
|
||||
|
||||
// MARK: Updating
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
|
||||
guard let targetVC = pendingViewControllers.first, let index = pages.firstIndex(of: targetVC) else { return }
|
||||
targetVCIndex = index
|
||||
}
|
||||
|
||||
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating isFinished: Bool, previousViewControllers: [UIViewController], transitionCompleted isCompleted: Bool) {
|
||||
guard isCompleted, let index = targetVCIndex else { return }
|
||||
tabBar.selectTab(at: index)
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc public func didPressDismissButton() {
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: Batch Selection
|
||||
@objc func didTapSelect(_ sender: Any) {
|
||||
self.mediaTitleViewController.didTapSelect(sender)
|
||||
|
||||
// Don't allow the user to leave mid-selection, so they realized they have
|
||||
// to cancel (lose) their selection if they leave.
|
||||
self.navigationItem.hidesBackButton = true
|
||||
}
|
||||
|
||||
@objc func didCancelSelect(_ sender: Any) {
|
||||
endSelectMode()
|
||||
}
|
||||
|
||||
func endSelectMode() {
|
||||
self.mediaTitleViewController.endSelectMode()
|
||||
self.navigationItem.hidesBackButton = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIDocumentInteractionControllerDelegate
|
||||
|
||||
extension AllMediaViewController: UIDocumentInteractionControllerDelegate {
|
||||
public func documentInteractionControllerViewControllerForPreview(_ controller: UIDocumentInteractionController) -> UIViewController {
|
||||
return self
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DocumentTitleViewControllerDelegate
|
||||
|
||||
extension AllMediaViewController: DocumentTileViewControllerDelegate {
|
||||
public func share(fileUrl: URL) {
|
||||
let shareVC = UIActivityViewController(activityItems: [ fileUrl ], applicationActivities: nil)
|
||||
|
||||
if UIDevice.current.isIPad {
|
||||
shareVC.excludedActivityTypes = []
|
||||
shareVC.popoverPresentationController?.permittedArrowDirections = []
|
||||
shareVC.popoverPresentationController?.sourceView = self.view
|
||||
shareVC.popoverPresentationController?.sourceRect = self.view.bounds
|
||||
}
|
||||
|
||||
navigationController?.present(shareVC, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
public func preview(fileUrl: URL) {
|
||||
let interactionController: UIDocumentInteractionController = UIDocumentInteractionController(url: fileUrl)
|
||||
interactionController.delegate = self
|
||||
interactionController.presentPreview(animated: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DocumentTitleViewControllerDelegate
|
||||
|
||||
extension AllMediaViewController: MediaTileViewControllerDelegate {
|
||||
public func presentdetailViewController(_ detailViewController: UIViewController, animated: Bool) {
|
||||
self.present(detailViewController, animated: animated)
|
||||
}
|
||||
|
||||
public func updateSelectButton(updatedData: [MediaGalleryViewModel.SectionModel], inBatchSelectMode: Bool) {
|
||||
guard !updatedData.isEmpty else {
|
||||
self.navigationItem.rightBarButtonItem = nil
|
||||
return
|
||||
}
|
||||
|
||||
if inBatchSelectMode {
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
barButtonSystemItem: .cancel,
|
||||
target: self,
|
||||
action: #selector(didCancelSelect)
|
||||
)
|
||||
}
|
||||
else {
|
||||
self.navigationItem.rightBarButtonItem = UIBarButtonItem(
|
||||
title: "BUTTON_SELECT".localized(),
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(didTapSelect)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIViewControllerTransitioningDelegate
|
||||
|
||||
extension AllMediaViewController: UIViewControllerTransitioningDelegate {
|
||||
public func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return self.mediaTitleViewController.animationController(forPresented: presented, presenting: presenting, source: source)
|
||||
}
|
||||
|
||||
public func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
return self.mediaTitleViewController.animationController(forDismissed: dismissed)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - MediaPresentationContextProvider
|
||||
|
||||
extension AllMediaViewController: MediaPresentationContextProvider {
|
||||
func mediaPresentationContext(mediaItem: Media, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
|
||||
return self.mediaTitleViewController.mediaPresentationContext(mediaItem: mediaItem, in: coordinateSpace)
|
||||
}
|
||||
|
||||
func snapshotOverlayView(in coordinateSpace: UICoordinateSpace) -> (UIView, CGRect)? {
|
||||
return self.mediaTitleViewController.snapshotOverlayView(in: coordinateSpace)
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,503 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import QuartzCore
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
|
||||
|
||||
/// This should be larger than one screen size so we don't have to call it multiple times in rapid succession, but not
|
||||
/// so large that loading get's really chopping
|
||||
static let itemPageSize: Int = Int(11 * itemsPerPortraitRow)
|
||||
static let itemsPerPortraitRow: CGFloat = 4
|
||||
static let interItemSpacing: CGFloat = 2
|
||||
static let footerBarHeight: CGFloat = 40
|
||||
static let loadMoreHeaderHeight: CGFloat = 100
|
||||
|
||||
private let viewModel: MediaGalleryViewModel
|
||||
private var hasLoadedInitialData: Bool = false
|
||||
private var didFinishInitialLayout: Bool = false
|
||||
private var isAutoLoadingNextPage: Bool = false
|
||||
private var currentTargetOffset: CGPoint?
|
||||
|
||||
public var delegate: DocumentTileViewControllerDelegate?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
init(viewModel: MediaGalleryViewModel) {
|
||||
self.viewModel = viewModel
|
||||
Storage.shared.addObserver(viewModel.pagedDataObserver)
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
deinit {
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
lazy var tableView: UITableView = {
|
||||
let result = UITableView(frame: .zero, style: .grouped)
|
||||
result.backgroundColor = Colors.navigationBarBackground
|
||||
result.separatorStyle = .none
|
||||
result.showsVerticalScrollIndicator = false
|
||||
result.register(view: DocumentCell.self)
|
||||
result.delegate = self
|
||||
result.dataSource = self
|
||||
// Feels a bit weird to have content smashed all the way to the bottom edge.
|
||||
result.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 20, right: 0)
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
override public func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
|
||||
// Add a custom back button if this is the only view controller
|
||||
if self.navigationController?.viewControllers.first == self {
|
||||
let backButton = OWSViewController.createOWSBackButton(withTarget: self, selector: #selector(didPressDismissButton))
|
||||
self.navigationItem.leftBarButtonItem = backButton
|
||||
}
|
||||
|
||||
ViewControllerUtilities.setUpDefaultSessionStyle(
|
||||
for: self,
|
||||
title: MediaStrings.document,
|
||||
hasCustomBackButton: false
|
||||
)
|
||||
|
||||
view.addSubview(self.tableView)
|
||||
tableView.autoPin(toEdgesOf: view)
|
||||
|
||||
// Notifications
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidBecomeActive(_:)),
|
||||
name: UIApplication.didBecomeActiveNotification,
|
||||
object: nil
|
||||
)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(applicationDidResignActive(_:)),
|
||||
name: UIApplication.didEnterBackgroundNotification, object: nil
|
||||
)
|
||||
}
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
public override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
self.didFinishInitialLayout = true
|
||||
}
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
startObservingChanges()
|
||||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func performInitialScrollIfNeeded() {
|
||||
// Ensure this hasn't run before and that we have data (The 'galleryData' will always
|
||||
// contain something as the 'empty' state is a section within 'galleryData')
|
||||
guard !self.didFinishInitialLayout && self.hasLoadedInitialData else { return }
|
||||
|
||||
// If we have a focused item then we want to scroll to it
|
||||
guard let focusedIndexPath: IndexPath = self.viewModel.focusedIndexPath else { return }
|
||||
|
||||
Logger.debug("scrolling to focused item at indexPath: \(focusedIndexPath)")
|
||||
self.view.layoutIfNeeded()
|
||||
self.tableView.scrollToRow(at: focusedIndexPath, at: .middle, animated: false)
|
||||
|
||||
// Now that the data has loaded we need to check if either of the "load more" sections are
|
||||
// visible and trigger them if so
|
||||
//
|
||||
// Note: We do it this way as we want to trigger the load behaviour for the first section
|
||||
// if it has one before trying to trigger the load behaviour for the last section
|
||||
self.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in
|
||||
self?.isAutoLoadingNextPage = false
|
||||
|
||||
// Note: We sort the headers as we want to prioritise loading newer pages over older ones
|
||||
let sortedVisibleIndexPaths: [IndexPath] = (self?.tableView.indexPathsForVisibleRows ?? []).sorted()
|
||||
|
||||
for headerIndexPath in sortedVisibleIndexPaths {
|
||||
let section: MediaGalleryViewModel.SectionModel? = self?.viewModel.galleryData[safe: headerIndexPath.section]
|
||||
|
||||
switch section?.model {
|
||||
case .loadNewer, .loadOlder:
|
||||
// Attachments are loaded in descending order so 'loadOlder' actually corresponds with
|
||||
// 'pageAfter' in this case
|
||||
self?.viewModel.pagedDataObserver?.load(section?.model == .loadOlder ?
|
||||
.pageAfter :
|
||||
.pageBefore
|
||||
)
|
||||
return
|
||||
|
||||
default: continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startObservingChanges() {
|
||||
// Start observing for data changes (will callback on the main thread)
|
||||
self.viewModel.onGalleryChange = { [weak self] updatedGalleryData in
|
||||
self?.handleUpdates(updatedGalleryData)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
// Note: The 'pagedDataObserver' will continue to get changes but
|
||||
// we don't want to trigger any UI updates
|
||||
self.viewModel.onGalleryChange = nil
|
||||
}
|
||||
|
||||
private func handleUpdates(_ updatedGalleryData: [MediaGalleryViewModel.SectionModel]) {
|
||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||
// in from a frame of CGRect.zero)
|
||||
guard hasLoadedInitialData else {
|
||||
self.hasLoadedInitialData = true
|
||||
self.viewModel.updateGalleryData(updatedGalleryData)
|
||||
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadData()
|
||||
self.performInitialScrollIfNeeded()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let isInsertingAtTop: Bool = {
|
||||
let oldFirstSectionIsLoadMore: Bool = (
|
||||
self.viewModel.galleryData.first?.model == .loadNewer ||
|
||||
self.viewModel.galleryData.first?.model == .loadOlder
|
||||
)
|
||||
let oldTargetSectionIndex: Int = (oldFirstSectionIsLoadMore ? 1 : 0)
|
||||
|
||||
guard
|
||||
let newTargetSectionIndex = updatedGalleryData
|
||||
.firstIndex(where: { $0.model == self.viewModel.galleryData[safe: oldTargetSectionIndex]?.model }),
|
||||
let oldFirstItem: MediaGalleryViewModel.Item = self.viewModel.galleryData[safe: oldTargetSectionIndex]?.elements.first,
|
||||
let newFirstItemIndex = updatedGalleryData[safe: newTargetSectionIndex]?.elements.firstIndex(of: oldFirstItem)
|
||||
else { return false }
|
||||
|
||||
return (newTargetSectionIndex > oldTargetSectionIndex || newFirstItemIndex > 0)
|
||||
}()
|
||||
|
||||
CATransaction.begin()
|
||||
|
||||
if isInsertingAtTop { CATransaction.setDisableActions(true) }
|
||||
|
||||
self.tableView.reload(
|
||||
using: StagedChangeset(source: self.viewModel.galleryData, target: updatedGalleryData),
|
||||
with: .automatic,
|
||||
interrupt: { $0.changeCount > MediaTileViewController.itemPageSize }
|
||||
) { [weak self] updatedData in
|
||||
self?.viewModel.updateGalleryData(updatedData)
|
||||
}
|
||||
|
||||
CATransaction.setCompletionBlock { [weak self] in
|
||||
// If one of the "load more" sections is still visible once the animation completes then
|
||||
// trigger another "load more" (after a small delay to minimize animation bugginess)
|
||||
self?.autoLoadNextPageIfNeeded()
|
||||
}
|
||||
CATransaction.commit()
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Interactions
|
||||
|
||||
@objc public func didPressDismissButton() {
|
||||
let presentedNavController: UINavigationController? = (self.presentingViewController as? UINavigationController)
|
||||
let mediaPageViewController: MediaPageViewController? = (
|
||||
(presentedNavController?.viewControllers.last as? MediaPageViewController) ??
|
||||
(self.presentingViewController as? MediaPageViewController)
|
||||
)
|
||||
|
||||
// If the album was presented from a 'MediaPageViewController' and it has no more data (ie.
|
||||
// all album items had been deleted) then dismiss to the screen before that one
|
||||
guard mediaPageViewController?.viewModel.albumData.isEmpty != true else {
|
||||
presentedNavController?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
return
|
||||
}
|
||||
|
||||
dismiss(animated: true, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
public func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return self.viewModel.galleryData.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
return self.viewModel.galleryData[section].elements.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
let cell: DocumentCell = tableView.dequeue(type: DocumentCell.self, for: indexPath)
|
||||
cell.update(with: self.viewModel.galleryData[indexPath.section].elements[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section]
|
||||
|
||||
switch section.model {
|
||||
case .emptyGallery, .loadOlder, .loadNewer:
|
||||
let headerView: DocumentStaticHeaderView = DocumentStaticHeaderView()
|
||||
headerView.configure(
|
||||
title: {
|
||||
switch section.model {
|
||||
case .emptyGallery: return "DOCUMENT_TILES_EMPTY_DOCUMENT".localized()
|
||||
case .loadOlder: return "DOCUMENT_TILES_LOADING_OLDER_LABEL".localized()
|
||||
case .loadNewer: return "DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL".localized()
|
||||
case .galleryMonth: return "" // Impossible case
|
||||
}
|
||||
}()
|
||||
)
|
||||
return headerView
|
||||
|
||||
case .galleryMonth(let date):
|
||||
let headerView: DocumentSectionHeaderView = DocumentSectionHeaderView()
|
||||
headerView.configure(title: date.localizedString)
|
||||
return headerView
|
||||
}
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
|
||||
let section: MediaGalleryViewModel.SectionModel = self.viewModel.galleryData[section]
|
||||
|
||||
switch section.model {
|
||||
case .emptyGallery, .loadOlder, .loadNewer:
|
||||
return MediaTileViewController.loadMoreHeaderHeight
|
||||
|
||||
case .galleryMonth:
|
||||
return 50
|
||||
}
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
let attachment: Attachment = self.viewModel.galleryData[indexPath.section].elements[indexPath.row].attachment
|
||||
guard let originalFilePath: String = attachment.originalFilePath else { return }
|
||||
|
||||
let fileUrl: URL = URL(fileURLWithPath: originalFilePath)
|
||||
|
||||
// Open a preview of the document for text, pdf or microsoft files
|
||||
if
|
||||
attachment.isText ||
|
||||
attachment.isMicrosoftDoc ||
|
||||
attachment.contentType == OWSMimeTypeApplicationPdf
|
||||
{
|
||||
|
||||
delegate?.preview(fileUrl: fileUrl)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise share the file
|
||||
delegate?.share(fileUrl: fileUrl)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - View
|
||||
|
||||
class DocumentCell: UITableViewCell {
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: style, reuseIdentifier: reuseIdentifier)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
|
||||
setUpViewHierarchy()
|
||||
setupLayout()
|
||||
}
|
||||
|
||||
// MARK: - UI
|
||||
|
||||
private static let iconImageViewSize: CGSize = CGSize(width: 31, height: 40)
|
||||
|
||||
private let iconImageView: UIImageView = {
|
||||
let result: UIImageView = UIImageView(image: #imageLiteral(resourceName: "File").withRenderingMode(.alwaysTemplate))
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.tintColor = Colors.text
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let titleLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private let detailLabel: UILabel = {
|
||||
let result: UILabel = UILabel()
|
||||
result.translatesAutoresizingMaskIntoConstraints = false
|
||||
result.setContentHuggingPriority(.defaultHigh, for: .horizontal)
|
||||
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
result.font = .systemFont(ofSize: Values.smallFontSize)
|
||||
result.textColor = Colors.text
|
||||
result.lineBreakMode = .byTruncatingTail
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
private func setUpViewHierarchy() {
|
||||
backgroundColor = Colors.cellBackground
|
||||
selectedBackgroundView = UIView()
|
||||
selectedBackgroundView?.backgroundColor = Colors.cellSelected
|
||||
|
||||
|
||||
contentView.addSubview(iconImageView)
|
||||
contentView.addSubview(titleLabel)
|
||||
contentView.addSubview(detailLabel)
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
private func setupLayout() {
|
||||
NSLayoutConstraint.activate([
|
||||
contentView.heightAnchor.constraint(equalToConstant: 68),
|
||||
|
||||
iconImageView.leftAnchor.constraint(equalTo: contentView.leftAnchor, constant: Values.mediumSpacing),
|
||||
iconImageView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||
iconImageView.widthAnchor.constraint(equalToConstant: Self.iconImageViewSize.width),
|
||||
iconImageView.heightAnchor.constraint(equalToConstant: Self.iconImageViewSize.height),
|
||||
|
||||
titleLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing),
|
||||
titleLabel.rightAnchor.constraint(lessThanOrEqualTo: contentView.rightAnchor, constant: -Values.mediumSpacing),
|
||||
titleLabel.topAnchor.constraint(equalTo: iconImageView.topAnchor),
|
||||
|
||||
detailLabel.leftAnchor.constraint(equalTo: iconImageView.rightAnchor, constant: Values.mediumSpacing),
|
||||
detailLabel.rightAnchor.constraint(lessThanOrEqualTo: contentView.rightAnchor, constant: -Values.mediumSpacing),
|
||||
detailLabel.bottomAnchor.constraint(equalTo: iconImageView.bottomAnchor),
|
||||
])
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
func update(with item: MediaGalleryViewModel.Item) {
|
||||
let attachment = item.attachment
|
||||
titleLabel.text = attachment.sourceFilename ?? "File"
|
||||
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentSectionHeaderView: UIView {
|
||||
|
||||
let label: UILabel
|
||||
|
||||
override init(frame: CGRect) {
|
||||
label = UILabel()
|
||||
label.textColor = Colors.text
|
||||
|
||||
let blurEffect = UIBlurEffect(style: .dark)
|
||||
let blurEffectView = UIVisualEffectView(effect: blurEffect)
|
||||
|
||||
blurEffectView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
|
||||
super.init(frame: frame)
|
||||
|
||||
self.backgroundColor = isLightMode ? Colors.cellBackground : UIColor.ows_black.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor)
|
||||
|
||||
self.addSubview(blurEffectView)
|
||||
self.addSubview(label)
|
||||
|
||||
blurEffectView.autoPinEdgesToSuperviewEdges()
|
||||
blurEffectView.isHidden = isLightMode
|
||||
label.autoPinEdge(toSuperviewMargin: .trailing)
|
||||
label.autoPinEdge(toSuperviewMargin: .leading)
|
||||
label.autoVCenterInSuperview()
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Unimplemented")
|
||||
required init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
public func configure(title: String) {
|
||||
self.label.text = title
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentStaticHeaderView: UIView {
|
||||
|
||||
let label = UILabel()
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
|
||||
addSubview(label)
|
||||
|
||||
label.textColor = Colors.text
|
||||
label.textAlignment = .center
|
||||
label.numberOfLines = 0
|
||||
label.autoPinEdgesToSuperviewMargins(with: UIEdgeInsets(top: 0, leading: Values.largeSpacing, bottom: 0, trailing: Values.largeSpacing))
|
||||
}
|
||||
|
||||
@available(*, unavailable, message: "Unimplemented")
|
||||
required public init?(coder aDecoder: NSCoder) {
|
||||
notImplemented()
|
||||
}
|
||||
|
||||
public func configure(title: String) {
|
||||
self.label.text = title
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - DocumentTitleViewControllerDelegate
|
||||
|
||||
public protocol DocumentTileViewControllerDelegate: AnyObject {
|
||||
func share(fileUrl: URL)
|
||||
func preview(fileUrl: URL)
|
||||
}
|
Binary file not shown.
@ -0,0 +1,47 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension OpenGroupAPI {
|
||||
public struct PendingChange: Equatable {
|
||||
public enum ChangeType {
|
||||
case reaction
|
||||
}
|
||||
|
||||
public enum ReactAction: Equatable {
|
||||
case add
|
||||
case remove
|
||||
case removeAll
|
||||
}
|
||||
|
||||
enum Metadata {
|
||||
case reaction(messageId: Int64, emoji: String, action: ReactAction)
|
||||
}
|
||||
|
||||
let server: String
|
||||
let room: String
|
||||
let changeType: ChangeType
|
||||
var seqNo: Int64?
|
||||
let metadata: Metadata
|
||||
|
||||
public static func == (lhs: OpenGroupAPI.PendingChange, rhs: OpenGroupAPI.PendingChange) -> Bool {
|
||||
guard lhs.server == rhs.server &&
|
||||
lhs.room == rhs.room &&
|
||||
lhs.changeType == rhs.changeType &&
|
||||
lhs.seqNo == rhs.seqNo
|
||||
else {
|
||||
return false
|
||||
}
|
||||
|
||||
switch lhs.changeType {
|
||||
case .reaction:
|
||||
if case .reaction(let lhsMessageId, let lhsEmoji, let lhsAction) = lhs.metadata,
|
||||
case .reaction(let rhsMessageId, let rhsEmoji, let rhsAction) = rhs.metadata {
|
||||
return lhsMessageId == rhsMessageId && lhsEmoji == rhsEmoji && lhsAction == rhsAction
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,44 @@
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
|
||||
extension OpenGroupAPI {
|
||||
public struct ReactionAddResponse: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case added
|
||||
case seqNo = "seqno"
|
||||
}
|
||||
|
||||
/// This field indicates whether the reaction was added (true) or already present (false).
|
||||
public let added: Bool
|
||||
|
||||
/// The seqNo after the reaction is added.
|
||||
public let seqNo: Int64?
|
||||
}
|
||||
|
||||
public struct ReactionRemoveResponse: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case removed
|
||||
case seqNo = "seqno"
|
||||
}
|
||||
|
||||
/// This field indicates whether the reaction was removed (true) or was not present to begin with (false).
|
||||
public let removed: Bool
|
||||
|
||||
/// The seqNo after the reaction is removed.
|
||||
public let seqNo: Int64?
|
||||
}
|
||||
|
||||
public struct ReactionRemoveAllResponse: Codable, Equatable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case removed
|
||||
case seqNo = "seqno"
|
||||
}
|
||||
|
||||
/// This field shows the total number of reactions that were deleted.
|
||||
public let removed: Int64
|
||||
|
||||
/// The seqNo after the reactions is all removed.
|
||||
public let seqNo: Int64?
|
||||
}
|
||||
}
|
@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x6D",
|
||||
"green" : "0x6D",
|
||||
"red" : "0x6D"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x2D",
|
||||
"green" : "0x2D",
|
||||
"red" : "0x2D"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue