Added global search back
Removed the logic for 'oversizedText' (not sent by either iOS or Android and not handled at all by desktop) Updated the HomeViewModel (and ConversationCell) to use the same query model as Global Search Added an 'albumIndex' property to the InteractionAttachment so we can enforce a correct order (apparently SQLite doesn't do this by default) Updated the YDB to GRDB migration to avoid creating GroupMembers if the current user isn't a member of a ClosedGroup (be consistent with the running logic) Updated the attachment description logic to be consistent throughout Cleaned up the Interaction preview generation logic
This commit is contained in:
parent
5bcc124388
commit
a6c7e252a7
|
@ -755,6 +755,7 @@
|
|||
FD3AABE928306BBD00E5099A /* ThreadPickerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */; };
|
||||
FD3C907327E8387300CD579F /* OpenGroupServerIdLookupMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */; };
|
||||
FD3C907527E83AC200CD579F /* OpenGroupServerIdLookup.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */; };
|
||||
FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD4B200B283367410034334B /* ConversationCellViewModel.swift */; };
|
||||
FD5D200F27AA2B6000FEA984 /* MessageRequestResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */; };
|
||||
FD5D201127AA331F00FEA984 /* ConfigurationMessage+Convenience.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */; };
|
||||
FD659AC027A7649600F12C02 /* MessageRequestsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */; };
|
||||
|
@ -1810,6 +1811,7 @@
|
|||
FD3AABE828306BBD00E5099A /* ThreadPickerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerViewModel.swift; sourceTree = "<group>"; };
|
||||
FD3C907227E8387300CD579F /* OpenGroupServerIdLookupMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookupMigration.swift; sourceTree = "<group>"; };
|
||||
FD3C907427E83AC200CD579F /* OpenGroupServerIdLookup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenGroupServerIdLookup.swift; sourceTree = "<group>"; };
|
||||
FD4B200B283367410034334B /* ConversationCellViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationCellViewModel.swift; sourceTree = "<group>"; };
|
||||
FD5D200E27AA2B6000FEA984 /* MessageRequestResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestResponse.swift; sourceTree = "<group>"; };
|
||||
FD5D201027AA331F00FEA984 /* ConfigurationMessage+Convenience.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ConfigurationMessage+Convenience.swift"; sourceTree = "<group>"; };
|
||||
FD659ABF27A7649600F12C02 /* MessageRequestsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRequestsViewController.swift; sourceTree = "<group>"; };
|
||||
|
@ -2431,6 +2433,7 @@
|
|||
B8CCF63B239757C10091D419 /* Shared */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD4B200A283367350034334B /* Models */,
|
||||
4CA46F4B219CCC630038ABDE /* CaptionView.swift */,
|
||||
4C4AEC4420EC343B0020E72B /* DismissableTextField.swift */,
|
||||
45E5A6981F61E6DD001E4A8A /* MarqueeLabel.swift */,
|
||||
|
@ -3716,6 +3719,14 @@
|
|||
path = LegacyDatabase;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD4B200A283367350034334B /* Models */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FD4B200B283367410034334B /* ConversationCellViewModel.swift */,
|
||||
);
|
||||
path = Models;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
FD659ABE27A7648200F12C02 /* Message Requests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
@ -5036,6 +5047,7 @@
|
|||
B82B408A2399EC0600A248E7 /* FakeChatView.swift in Sources */,
|
||||
B82B40882399EB0E00A248E7 /* LandingVC.swift in Sources */,
|
||||
EF764C351DB67CC5000D9A87 /* UIViewController+Permissions.m in Sources */,
|
||||
FD4B200C283367410034334B /* ConversationCellViewModel.swift in Sources */,
|
||||
45CD81EF1DC030E7004C9430 /* SyncPushTokensJob.swift in Sources */,
|
||||
B83524A525C3BA4B0089A44F /* InfoMessageCell.swift in Sources */,
|
||||
B84A89BC25DE328A0040017D /* ProfilePictureVC.swift in Sources */,
|
||||
|
|
|
@ -1,67 +1,32 @@
|
|||
//
|
||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||
//
|
||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import Foundation
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
|
||||
@objc
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
|
||||
didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?)
|
||||
|
||||
@objc
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController,
|
||||
didSelectMessageId: String)
|
||||
}
|
||||
|
||||
@objc
|
||||
public class ConversationSearchController : NSObject {
|
||||
|
||||
@objc
|
||||
public class ConversationSearchController: NSObject {
|
||||
public static let kMinimumSearchTextLength: UInt = 2
|
||||
|
||||
@objc
|
||||
public let uiSearchController = UISearchController(searchResultsController: nil)
|
||||
|
||||
@objc
|
||||
public weak var delegate: ConversationSearchControllerDelegate?
|
||||
|
||||
let thread: TSThread
|
||||
|
||||
@objc
|
||||
public let uiSearchController: UISearchController = UISearchController(searchResultsController: nil)
|
||||
public let resultsBar: SearchResultsBar = SearchResultsBar()
|
||||
|
||||
// MARK: Initializer
|
||||
|
||||
@objc
|
||||
required public init(thread: TSThread) {
|
||||
self.thread = thread
|
||||
override public init() {
|
||||
super.init()
|
||||
|
||||
|
||||
resultsBar.resultsBarDelegate = self
|
||||
uiSearchController.delegate = self
|
||||
uiSearchController.searchResultsUpdater = self
|
||||
|
||||
uiSearchController.hidesNavigationBarDuringPresentation = false
|
||||
if #available(iOS 13, *) {
|
||||
// Do nothing
|
||||
} else {
|
||||
uiSearchController.dimsBackgroundDuringPresentation = false
|
||||
}
|
||||
uiSearchController.searchBar.inputAccessoryView = resultsBar
|
||||
}
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var dbReadConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().dbReadConnection
|
||||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : UISearchControllerDelegate {
|
||||
|
||||
// MARK: - UISearchControllerDelegate
|
||||
|
||||
extension ConversationSearchController: UISearchControllerDelegate {
|
||||
public func didPresentSearchController(_ searchController: UISearchController) {
|
||||
Logger.verbose("")
|
||||
delegate?.didPresentSearchController?(searchController)
|
||||
|
@ -73,8 +38,9 @@ extension ConversationSearchController : UISearchControllerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : UISearchResultsUpdating {
|
||||
|
||||
// MARK: - UISearchResultsUpdating
|
||||
|
||||
extension ConversationSearchController: UISearchResultsUpdating {
|
||||
var dbSearcher: FullTextSearcher {
|
||||
return FullTextSearcher.shared
|
||||
}
|
||||
|
@ -111,29 +77,33 @@ extension ConversationSearchController : UISearchResultsUpdating {
|
|||
}
|
||||
}
|
||||
|
||||
extension ConversationSearchController : SearchResultsBarDelegate {
|
||||
|
||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet) {
|
||||
// MARK: - SearchResultsBarDelegate
|
||||
|
||||
extension ConversationSearchController: SearchResultsBarDelegate {
|
||||
func searchResultsBar(
|
||||
_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet
|
||||
) {
|
||||
guard let searchResult = resultSet.messages[safe: currentIndex] else {
|
||||
owsFailDebug("messageId was unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
BenchEventStart(title: "Conversation Search Nav", eventId: "Conversation Search Nav: \(searchResult.messageId)")
|
||||
self.delegate?.conversationSearchController(self, didSelectMessageId: searchResult.messageId)
|
||||
self.delegate?.conversationSearchController(self, didSelectInteractionId: searchResult.messageId)
|
||||
}
|
||||
}
|
||||
|
||||
protocol SearchResultsBarDelegate : AnyObject {
|
||||
|
||||
func searchResultsBar(_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet)
|
||||
protocol SearchResultsBarDelegate: AnyObject {
|
||||
func searchResultsBar(
|
||||
_ searchResultsBar: SearchResultsBar,
|
||||
setCurrentIndex currentIndex: Int,
|
||||
resultSet: ConversationScreenSearchResultSet
|
||||
)
|
||||
}
|
||||
|
||||
public final class SearchResultsBar : UIView {
|
||||
public final class SearchResultsBar: UIView {
|
||||
private var resultSet: ConversationScreenSearchResultSet?
|
||||
var currentIndex: Int?
|
||||
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
||||
|
@ -313,3 +283,10 @@ public final class SearchResultsBar : UIView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ConversationSearchControllerDelegate
|
||||
|
||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?)
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
|
||||
}
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import PureLayout
|
||||
import SessionUIKit
|
||||
import SessionUtilitiesKit
|
||||
import NVActivityIndicatorView
|
||||
|
||||
class EmptySearchResultCell: UITableViewCell {
|
||||
static let reuseIdentifier = "EmptySearchResultCell"
|
||||
|
||||
private lazy var messageLabel: UILabel = {
|
||||
let result = UILabel()
|
||||
result.textAlignment = .center
|
||||
|
|
|
@ -1,10 +1,21 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
@objc
|
||||
import UIKit
|
||||
import GRDB
|
||||
import DifferenceKit
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
||||
private struct SearchResultSet {
|
||||
let contactsAndGroups: [ConversationCell.ViewModel]
|
||||
let messages: [ConversationCell.ViewModel]
|
||||
}
|
||||
|
||||
let isRecentSearchResultsEnabled = false
|
||||
|
||||
|
||||
@objc public var searchText = "" {
|
||||
didSet {
|
||||
AssertIsOnMainThread()
|
||||
|
@ -12,55 +23,54 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
refreshSearchResults()
|
||||
}
|
||||
}
|
||||
var recentSearchResults: [String] = Array(Storage.shared.getRecentSearchResults().reversed())
|
||||
var defaultSearchResults: HomeScreenSearchResultSet = HomeScreenSearchResultSet.noteToSelfOnly
|
||||
var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty
|
||||
|
||||
var searchResultSet: [ArraySection<SearchSection, ConversationCell.ViewModel>] = []
|
||||
private var termForCurrentSearchResultSet: String = ""
|
||||
|
||||
|
||||
private var lastSearchText: String?
|
||||
var searcher: FullTextSearcher {
|
||||
return FullTextSearcher.shared
|
||||
}
|
||||
var isLoading = false
|
||||
|
||||
enum SearchSection: Int {
|
||||
enum SearchSection: Int, Differentiable {
|
||||
case noResults
|
||||
case contacts
|
||||
case contactsAndGroups
|
||||
case messages
|
||||
case recent
|
||||
}
|
||||
|
||||
// MARK: UI Components
|
||||
|
||||
|
||||
// MARK: - UI Components
|
||||
|
||||
internal lazy var searchBar: SearchBar = {
|
||||
let result = SearchBar()
|
||||
let result: SearchBar = SearchBar()
|
||||
result.tintColor = Colors.text
|
||||
result.delegate = self
|
||||
result.showsCancelButton = true
|
||||
return result
|
||||
}()
|
||||
|
||||
|
||||
internal lazy var tableView: UITableView = {
|
||||
let result = UITableView(frame: .zero, style: .grouped)
|
||||
let result: UITableView = UITableView(frame: .zero, style: .grouped)
|
||||
result.rowHeight = UITableView.automaticDimension
|
||||
result.estimatedRowHeight = 60
|
||||
result.separatorStyle = .none
|
||||
result.keyboardDismissMode = .onDrag
|
||||
result.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
|
||||
result.register(ConversationCell.self, forCellReuseIdentifier: ConversationCell.reuseIdentifier)
|
||||
result.register(view: EmptySearchResultCell.self)
|
||||
result.register(view: ConversationCell.self)
|
||||
result.showsVerticalScrollIndicator = false
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
// MARK: Dependencies
|
||||
|
||||
var dbReadConnection: YapDatabaseConnection {
|
||||
return OWSPrimaryStorage.shared().dbReadConnection
|
||||
}
|
||||
// MARK: - View Lifecycle
|
||||
|
||||
// MARK: View Lifecycle
|
||||
public override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
setUpGradientBackground()
|
||||
|
||||
setUpGradientBackground()
|
||||
|
||||
tableView.dataSource = self
|
||||
tableView.delegate = self
|
||||
view.addSubview(tableView)
|
||||
|
@ -72,22 +82,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
navigationItem.hidesBackButton = true
|
||||
setupNavigationBar()
|
||||
}
|
||||
|
||||
|
||||
public override func viewWillAppear(_ animated: Bool) {
|
||||
super.viewWillAppear(animated)
|
||||
searchBar.becomeFirstResponder()
|
||||
}
|
||||
|
||||
|
||||
public override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
searchBar.resignFirstResponder()
|
||||
}
|
||||
|
||||
|
||||
private func setupNavigationBar() {
|
||||
// This is a workaround for a UI issue that the navigation bar can be a bit higher if
|
||||
// the search bar is put directly to be the titleView. And this can cause the tableView
|
||||
// in home screen doing a weird scrolling when going back to home screen.
|
||||
let searchBarContainer = UIView()
|
||||
let searchBarContainer: UIView = UIView()
|
||||
searchBarContainer.layoutMargins = UIEdgeInsets.zero
|
||||
searchBar.sizeToFit()
|
||||
searchBar.layoutMargins = UIEdgeInsets.zero
|
||||
|
@ -97,23 +107,22 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
searchBar.autoPinEdgesToSuperviewMargins()
|
||||
navigationItem.titleView = searchBarContainer
|
||||
}
|
||||
|
||||
|
||||
private func reloadTableData() {
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
// MARK: Update Search Results
|
||||
|
||||
// MARK: - Update Search Results
|
||||
|
||||
var refreshTimer: Timer?
|
||||
|
||||
|
||||
private func refreshSearchResults() {
|
||||
refreshTimer?.invalidate()
|
||||
refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
self.updateSearchResults(searchText: self.searchText)
|
||||
self?.updateSearchResults(searchText: (self?.searchText ?? ""))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func updateSearchResults(searchText rawSearchText: String) {
|
||||
|
||||
let searchText = rawSearchText.stripped
|
||||
|
@ -127,52 +136,73 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
|||
|
||||
lastSearchText = searchText
|
||||
|
||||
var searchResults: HomeScreenSearchResultSet?
|
||||
self.dbReadConnection.asyncRead({[weak self] transaction in
|
||||
guard let self = self else { return }
|
||||
self.isLoading = true
|
||||
// The max search result count is set according to the keyword length. This is just a workaround for performance issue.
|
||||
// The longer and more accurate the keyword is, the less search results should there be.
|
||||
searchResults = self.searcher.searchForHomeScreen(searchText: searchText, maxSearchResults: 500, transaction: transaction)
|
||||
}, completionBlock: { [weak self] in
|
||||
AssertIsOnMainThread()
|
||||
guard let self = self, let results = searchResults, self.lastSearchText == searchText else { return }
|
||||
self.searchResultSet = results
|
||||
self.isLoading = false
|
||||
self.reloadTableData()
|
||||
self.refreshTimer = nil
|
||||
})
|
||||
GRDBStorage.shared
|
||||
.read { db -> Result<SearchResultSet, Error> in
|
||||
do {
|
||||
let contactsAndGroupsResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel
|
||||
.contactsAndGroupsQuery(
|
||||
userPublicKey: getUserHexEncodedPublicKey(db),
|
||||
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText),
|
||||
searchTerm: searchText
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
let messageResults: [ConversationCell.ViewModel] = try ConversationCell.ViewModel
|
||||
.messagesQuery(
|
||||
userPublicKey: getUserHexEncodedPublicKey(db),
|
||||
pattern: try ConversationCell.ViewModel.pattern(db, searchTerm: searchText)
|
||||
)
|
||||
.fetchAll(db)
|
||||
|
||||
return .success(SearchResultSet(
|
||||
contactsAndGroups: contactsAndGroupsResults,
|
||||
messages: messageResults
|
||||
))
|
||||
}
|
||||
catch {
|
||||
return .failure(error)
|
||||
}
|
||||
}
|
||||
.map { [weak self] result in
|
||||
switch result {
|
||||
case .success(let resultSet):
|
||||
self?.termForCurrentSearchResultSet = searchText
|
||||
self?.searchResultSet = [
|
||||
ArraySection(model: .contactsAndGroups, elements: resultSet.contactsAndGroups),
|
||||
ArraySection(model: .messages, elements: resultSet.messages)
|
||||
]
|
||||
self?.isLoading = false
|
||||
self?.reloadTableData()
|
||||
self?.refreshTimer = nil
|
||||
|
||||
|
||||
case .failure: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Interaction
|
||||
@objc func clearRecentSearchResults() {
|
||||
recentSearchResults = []
|
||||
tableView.reloadSections([ SearchSection.recent.rawValue ], with: .top)
|
||||
Storage.shared.clearRecentSearchResults()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - UISearchBarDelegate
|
||||
|
||||
extension GlobalSearchViewController: UISearchBarDelegate {
|
||||
public func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
|
||||
self.updateSearchText()
|
||||
}
|
||||
|
||||
|
||||
public func searchBarTextDidEndEditing(_ searchBar: UISearchBar) {
|
||||
self.updateSearchText()
|
||||
}
|
||||
|
||||
|
||||
public func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
|
||||
self.updateSearchText()
|
||||
}
|
||||
|
||||
|
||||
public func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
|
||||
searchBar.text = nil
|
||||
searchBar.resignFirstResponder()
|
||||
self.navigationController?.popViewController(animated: true)
|
||||
}
|
||||
|
||||
|
||||
func updateSearchText() {
|
||||
guard let searchText = searchBar.text?.ows_stripped() else { return }
|
||||
self.searchText = searchText
|
||||
|
@ -180,36 +210,29 @@ extension GlobalSearchViewController: UISearchBarDelegate {
|
|||
}
|
||||
|
||||
// MARK: - UITableViewDelegate & UITableViewDataSource
|
||||
|
||||
extension GlobalSearchViewController {
|
||||
|
||||
// MARK: UITableViewDelegate
|
||||
|
||||
|
||||
// MARK: - UITableViewDelegate
|
||||
|
||||
public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: false)
|
||||
|
||||
guard let searchSection = SearchSection(rawValue: indexPath.section) else { return }
|
||||
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
SNLog("shouldn't be able to tap 'no results' section")
|
||||
case .contacts:
|
||||
let sectionResults = searchResultSet.conversations
|
||||
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
|
||||
show(searchResult.thread.threadRecord, highlightedMessageID: nil, animated: true)
|
||||
case .messages:
|
||||
let sectionResults = searchResultSet.messages
|
||||
guard let searchResult = sectionResults[safe: indexPath.row] else { return }
|
||||
show(searchResult.thread.threadRecord, highlightedMessageID: searchResult.message?.uniqueId, animated: true)
|
||||
case .recent:
|
||||
guard let threadId = recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId) else { return }
|
||||
show(thread, highlightedMessageID: nil, animated: true, isFromRecent: true)
|
||||
case .noResults:
|
||||
SNLog("shouldn't be able to tap 'no results' section")
|
||||
|
||||
case .contactsAndGroups:
|
||||
break
|
||||
|
||||
case .messages:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func show(_ thread: TSThread, highlightedMessageID: String?, animated: Bool, isFromRecent: Bool = false) {
|
||||
if let threadId = thread.uniqueId {
|
||||
recentSearchResults = Array(Storage.shared.addSearchResults(threadID: threadId).reversed())
|
||||
}
|
||||
|
||||
DispatchMainThreadSafe {
|
||||
if let presentedVC = self.presentedViewController {
|
||||
presentedVC.dismiss(animated: false, completion: nil)
|
||||
}
|
||||
|
@ -221,12 +244,12 @@ extension GlobalSearchViewController {
|
|||
}
|
||||
}
|
||||
|
||||
// MARK: UITableViewDataSource
|
||||
|
||||
// MARK: - UITableViewDataSource
|
||||
|
||||
public func numberOfSections(in tableView: UITableView) -> Int {
|
||||
return 4
|
||||
return self.searchResultSet.count
|
||||
}
|
||||
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
|
||||
UIView()
|
||||
}
|
||||
|
@ -239,80 +262,40 @@ extension GlobalSearchViewController {
|
|||
guard nil != self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
return .leastNonzeroMagnitude
|
||||
}
|
||||
|
||||
return UITableView.automaticDimension
|
||||
}
|
||||
|
||||
|
||||
public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
||||
|
||||
guard let title = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
guard let title: String = self.tableView(tableView, titleForHeaderInSection: section) else {
|
||||
return UIView()
|
||||
}
|
||||
|
||||
|
||||
let titleLabel = UILabel()
|
||||
titleLabel.text = title
|
||||
titleLabel.textColor = Colors.text
|
||||
titleLabel.font = .boldSystemFont(ofSize: Values.mediumFontSize)
|
||||
|
||||
|
||||
let container = UIView()
|
||||
container.backgroundColor = Colors.cellBackground
|
||||
container.layoutMargins = UIEdgeInsets(top: Values.smallSpacing, left: Values.mediumSpacing, bottom: Values.smallSpacing, right: Values.mediumSpacing)
|
||||
container.addSubview(titleLabel)
|
||||
titleLabel.autoPinEdgesToSuperviewMargins()
|
||||
|
||||
if searchSection == .recent {
|
||||
let clearButton = UIButton()
|
||||
clearButton.setTitle("Clear", for: .normal)
|
||||
clearButton.setTitleColor(Colors.text, for: UIControl.State.normal)
|
||||
clearButton.titleLabel!.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||
clearButton.addTarget(self, action: #selector(clearRecentSearchResults), for: .touchUpInside)
|
||||
container.addSubview(clearButton)
|
||||
clearButton.autoPinTrailingToSuperviewMargin()
|
||||
clearButton.autoVCenterInSuperview()
|
||||
}
|
||||
|
||||
return container
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return nil }
|
||||
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
return nil
|
||||
case .contacts:
|
||||
if searchResultSet.conversations.count > 0 {
|
||||
return NSLocalizedString("SEARCH_SECTION_CONTACTS", comment: "")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
case .messages:
|
||||
if searchResultSet.messages.count > 0 {
|
||||
return NSLocalizedString("SEARCH_SECTION_MESSAGES", comment: "")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
case .recent:
|
||||
if recentSearchResults.count > 0 && searchText.isEmpty && isRecentSearchResultsEnabled {
|
||||
return NSLocalizedString("SEARCH_SECTION_RECENT", comment: "")
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
let section: ArraySection<SearchSection, ConversationCell.ViewModel> = self.searchResultSet[section]
|
||||
switch section.model {
|
||||
case .noResults: return nil
|
||||
case .contactsAndGroups: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_CONTACTS".localized())
|
||||
case .messages: return (section.elements.isEmpty ? nil : "SEARCH_SECTION_MESSAGES".localized())
|
||||
}
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||||
guard let searchSection = SearchSection(rawValue: section) else { return 0 }
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
return (searchText.count > 0 && searchResultSet.isEmpty) ? 1 : 0
|
||||
case .contacts:
|
||||
return searchResultSet.conversations.count
|
||||
case .messages:
|
||||
return searchResultSet.messages.count
|
||||
case .recent:
|
||||
return searchText.isEmpty && isRecentSearchResultsEnabled ? recentSearchResults.count : 0
|
||||
}
|
||||
return self.searchResultSet[section].elements.count
|
||||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
|
||||
|
@ -320,41 +303,23 @@ extension GlobalSearchViewController {
|
|||
}
|
||||
|
||||
public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||||
|
||||
guard let searchSection = SearchSection(rawValue: indexPath.section) else {
|
||||
return UITableViewCell()
|
||||
}
|
||||
|
||||
switch searchSection {
|
||||
case .noResults:
|
||||
guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell, indexPath.row == 0 else { return UITableViewCell() }
|
||||
cell.configure(isLoading: isLoading)
|
||||
return cell
|
||||
case .contacts:
|
||||
let sectionResults = searchResultSet.conversations
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.isShowingGlobalSearchResult = true
|
||||
let searchResult = sectionResults[safe: indexPath.row]
|
||||
cell.threadViewModel = searchResult?.thread
|
||||
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText)
|
||||
return cell
|
||||
case .messages:
|
||||
let sectionResults = searchResultSet.messages
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.isShowingGlobalSearchResult = true
|
||||
let searchResult = sectionResults[safe: indexPath.row]
|
||||
cell.threadViewModel = searchResult?.thread
|
||||
cell.configure(snippet: searchResult?.snippet, searchText: searchResultSet.searchText, message: searchResult?.message)
|
||||
return cell
|
||||
case .recent:
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: ConversationCell.reuseIdentifier) as! ConversationCell
|
||||
cell.isShowingGlobalSearchResult = true
|
||||
dbReadConnection.read { transaction in
|
||||
guard let threadId = self.recentSearchResults[safe: indexPath.row], let thread = TSThread.fetch(uniqueId: threadId, transaction: transaction) else { return }
|
||||
cell.threadViewModel = ThreadViewModel(thread: thread, transaction: transaction)
|
||||
}
|
||||
cell.configureForRecent()
|
||||
return cell
|
||||
let section: ArraySection<SearchSection, ConversationCell.ViewModel> = self.searchResultSet[indexPath.section]
|
||||
|
||||
switch section.model {
|
||||
case .noResults:
|
||||
let cell: EmptySearchResultCell = tableView.dequeue(type: EmptySearchResultCell.self, for: indexPath)
|
||||
cell.configure(isLoading: isLoading)
|
||||
return cell
|
||||
|
||||
case .contactsAndGroups:
|
||||
let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath)
|
||||
cell.updateForContactAndGroupSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||
return cell
|
||||
|
||||
case .messages:
|
||||
let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath)
|
||||
cell.updateForMessageSearchResult(with: section.elements[indexPath.row], searchText: self.termForCurrentSearchResultSet)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,7 +9,7 @@ import SignalUtilitiesKit
|
|||
|
||||
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConversationButtonSetDelegate, SeedReminderViewDelegate {
|
||||
typealias Section = HomeViewModel.Section
|
||||
typealias Item = HomeViewModel.Item
|
||||
typealias Item = ConversationCell.ViewModel
|
||||
|
||||
private let viewModel: HomeViewModel = HomeViewModel()
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
|
@ -346,12 +346,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
switch section.model {
|
||||
case .messageRequests:
|
||||
let cell: MessageRequestsCell = tableView.dequeue(type: MessageRequestsCell.self, for: indexPath)
|
||||
cell.update(with: section.elements[indexPath.row].unreadCount)
|
||||
cell.update(with: Int(section.elements[indexPath.row].threadUnreadCount ?? 0))
|
||||
return cell
|
||||
|
||||
case .threads:
|
||||
let cell: ConversationCell = tableView.dequeue(type: ConversationCell.self, for: indexPath)
|
||||
cell.update(with: section.elements[indexPath.row].threadInfo)
|
||||
cell.update(with: section.elements[indexPath.row])
|
||||
return cell
|
||||
}
|
||||
}
|
||||
|
@ -369,7 +369,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
self.navigationController?.pushViewController(viewController, animated: true)
|
||||
|
||||
case .threads:
|
||||
let threadId: String = section.elements[indexPath.row].threadInfo.id
|
||||
let threadId: String = section.elements[indexPath.row].threadId
|
||||
show(threadId, with: .none, highlightedInteractionId: nil, animated: true)
|
||||
}
|
||||
}
|
||||
|
@ -396,12 +396,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
return [hide]
|
||||
|
||||
case .threads:
|
||||
let threadInfo: HomeViewModel.ThreadInfo = section.elements[indexPath.row].threadInfo
|
||||
let cellViewModel: ConversationCell.ViewModel = section.elements[indexPath.row]
|
||||
let delete: UITableViewRowAction = UITableViewRowAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
) { [weak self] _, _ in
|
||||
let message = (threadInfo.isGroupAdmin ?
|
||||
let message = (cellViewModel.currentUserIsClosedGroupAdmin == true ?
|
||||
"admin_group_leave_warning".localized() :
|
||||
"CONVERSATION_DELETE_CONFIRMATION_ALERT_MESSAGE".localized()
|
||||
)
|
||||
|
@ -416,20 +416,20 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
style: .destructive
|
||||
) { _ in
|
||||
GRDBStorage.shared.write { db in
|
||||
switch threadInfo.variant {
|
||||
switch cellViewModel.threadVariant {
|
||||
case .closedGroup:
|
||||
try MessageSender
|
||||
.leave(db, groupPublicKey: threadInfo.id)
|
||||
.leave(db, groupPublicKey: cellViewModel.threadId)
|
||||
.retainUntilComplete()
|
||||
|
||||
case .openGroup:
|
||||
OpenGroupManagerV2.shared.delete(db, openGroupId: threadInfo.id)
|
||||
OpenGroupManagerV2.shared.delete(db, openGroupId: cellViewModel.threadId)
|
||||
|
||||
default: break
|
||||
}
|
||||
|
||||
_ = try SessionThread
|
||||
.filter(id: threadInfo.id)
|
||||
.filter(id: cellViewModel.threadId)
|
||||
.deleteAll(db)
|
||||
}
|
||||
})
|
||||
|
@ -444,33 +444,41 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, NewConve
|
|||
|
||||
let pin: UITableViewRowAction = UITableViewRowAction(
|
||||
style: .normal,
|
||||
title: (threadInfo.isPinned ?
|
||||
"PIN_BUTTON_TEXT".localized() :
|
||||
"UNPIN_BUTTON_TEXT".localized()
|
||||
title: (cellViewModel.threadIsPinned ?
|
||||
"UNPIN_BUTTON_TEXT".localized() :
|
||||
"PIN_BUTTON_TEXT".localized()
|
||||
)
|
||||
) { _, _ in
|
||||
GRDBStorage.shared.write { db in
|
||||
try SessionThread
|
||||
.filter(id: threadInfo.id)
|
||||
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadInfo.isPinned))
|
||||
.filter(id: cellViewModel.threadId)
|
||||
.updateAll(db, SessionThread.Columns.isPinned.set(to: !cellViewModel.threadIsPinned))
|
||||
}
|
||||
}
|
||||
|
||||
guard threadInfo.variant == .contact && !threadInfo.isNoteToSelf else {
|
||||
guard cellViewModel.threadVariant == .contact && !cellViewModel.threadIsNoteToSelf else {
|
||||
return [ delete, pin ]
|
||||
}
|
||||
|
||||
let block: UITableViewRowAction = UITableViewRowAction(
|
||||
style: .normal,
|
||||
title: (threadInfo.isBlocked ?
|
||||
title: (cellViewModel.threadIsBlocked == true ?
|
||||
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
|
||||
"BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||
)
|
||||
) { _, _ in
|
||||
GRDBStorage.shared.write { db in
|
||||
try Contact
|
||||
.filter(id: threadInfo.id)
|
||||
.updateAll(db, Contact.Columns.isBlocked.set(to: !threadInfo.isBlocked))
|
||||
.filter(id: cellViewModel.threadId)
|
||||
.updateAll(
|
||||
db,
|
||||
Contact.Columns.isBlocked.set(
|
||||
to: (cellViewModel.threadIsBlocked == false ?
|
||||
true:
|
||||
false
|
||||
)
|
||||
)
|
||||
)
|
||||
try MessageSender.syncConfiguration(db, forceSyncNow: true)
|
||||
.retainUntilComplete()
|
||||
}
|
||||
|
|
|
@ -11,385 +11,8 @@ public class HomeViewModel {
|
|||
case threads
|
||||
}
|
||||
|
||||
public struct ObservedInfo: Equatable {
|
||||
let unreadMessageRequestCount: Int
|
||||
let threadInfo: [ThreadInfo]
|
||||
}
|
||||
|
||||
public struct ThreadInfo: FetchableRecord, Decodable, Equatable, Differentiable {
|
||||
public struct GroupMemberInfo: FetchableRecord, Decodable, Equatable {
|
||||
public let profile: Profile
|
||||
}
|
||||
public struct InteractionInfo: FetchableRecord, Decodable, Equatable {
|
||||
public struct AuthorInfo: FetchableRecord, Decodable, Equatable {
|
||||
public let id: String
|
||||
public let displayName: String
|
||||
public let nickname: String?
|
||||
}
|
||||
|
||||
fileprivate static let timestampMsKey = CodingKeys.timestampMs.stringValue
|
||||
fileprivate static let threadVariantKey = CodingKeys.threadVariant.stringValue
|
||||
fileprivate static let authorInfoKey = CodingKeys.authorInfo.stringValue
|
||||
fileprivate static let isOpenGroupInvitationKey = CodingKeys.isOpenGroupInvitation.stringValue
|
||||
fileprivate static let recipientStatesKey = CodingKeys.recipientStates.stringValue
|
||||
|
||||
public let id: Int64?
|
||||
public let variant: Interaction.Variant
|
||||
public let timestampMs: Double
|
||||
|
||||
private let threadVariant: SessionThread.Variant
|
||||
private let body: String?
|
||||
private let attachments: [Attachment]?
|
||||
private let authorId: String
|
||||
private let authorInfo: AuthorInfo?
|
||||
private let isOpenGroupInvitation: Bool
|
||||
private let recipientStates: [RecipientState.State]?
|
||||
|
||||
public var authorName: String {
|
||||
return Profile.displayName(
|
||||
for: threadVariant,
|
||||
id: (authorInfo?.id ?? authorId),
|
||||
name: authorInfo?.displayName,
|
||||
nickname: authorInfo?.nickname,
|
||||
customFallback: (threadVariant == .contact && variant == .standardIncoming ?
|
||||
"Anonymous" :
|
||||
nil
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public var text: String {
|
||||
return Interaction.previewText(
|
||||
variant: variant,
|
||||
body: body,
|
||||
authorDisplayName: authorName,
|
||||
attachments: (attachments ?? []),
|
||||
isOpenGroupInvitation: (isOpenGroupInvitation == true)
|
||||
)
|
||||
}
|
||||
|
||||
public var state: RecipientState.State {
|
||||
return Interaction.state(for: (recipientStates ?? []))
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate static let contactIsTypingKey = CodingKeys.contactIsTyping.stringValue
|
||||
fileprivate static let closedGroupNameKey = CodingKeys.closedGroupName.stringValue
|
||||
fileprivate static let openGroupNameKey = CodingKeys.openGroupName.stringValue
|
||||
fileprivate static let openGroupProfilePictureDataKey = CodingKeys.openGroupProfilePictureData.stringValue
|
||||
fileprivate static let currentUserProfileKey = CodingKeys.currentUserProfile.stringValue
|
||||
fileprivate static let contactProfileKey = CodingKeys.contactProfile.stringValue
|
||||
fileprivate static let closedGroupAvatarProfilesKey = CodingKeys.closedGroupAvatarProfiles.stringValue
|
||||
fileprivate static let contactIsBlockedKey = CodingKeys.contactIsBlocked.stringValue
|
||||
fileprivate static let isNoteToSelfKey = CodingKeys.isNoteToSelf.stringValue
|
||||
fileprivate static let currentUserIsClosedGroupAdminKey = CodingKeys.currentUserIsClosedGroupAdmin.stringValue
|
||||
fileprivate static let threadUnreadCountKey = CodingKeys.threadUnreadCount.stringValue
|
||||
fileprivate static let threadUnreadMentionCountKey = CodingKeys.threadUnreadMentionCount.stringValue
|
||||
fileprivate static let lastInteractionInfoKey = CodingKeys.lastInteractionInfo.stringValue
|
||||
|
||||
public var differenceIdentifier: String { id }
|
||||
|
||||
public let id: String
|
||||
public let variant: SessionThread.Variant
|
||||
private let creationDateTimestamp: TimeInterval
|
||||
|
||||
public let contactIsTyping: Bool
|
||||
public let closedGroupName: String?
|
||||
public let openGroupName: String?
|
||||
public let openGroupProfilePictureData: Data?
|
||||
private let currentUserProfile: Profile
|
||||
private let contactProfile: Profile?
|
||||
private let closedGroupAvatarProfiles: [GroupMemberInfo]?
|
||||
|
||||
public let mutedUntilTimestamp: TimeInterval?
|
||||
public let onlyNotifyForMentions: Bool
|
||||
public let isPinned: Bool
|
||||
|
||||
/// A flag indicating whether the contact is blocked (will be null for non-contact threads)
|
||||
private let contactIsBlocked: Bool?
|
||||
|
||||
public let isNoteToSelf: Bool
|
||||
private let currentUserIsClosedGroupAdmin: Bool?
|
||||
|
||||
private let threadUnreadCount: UInt?
|
||||
private let threadUnreadMentionCount: UInt?
|
||||
|
||||
public let lastInteractionInfo: InteractionInfo?
|
||||
|
||||
public var displayName: String {
|
||||
return SessionThread.displayName(
|
||||
threadId: id,
|
||||
variant: variant,
|
||||
closedGroupName: closedGroupName,
|
||||
openGroupName: openGroupName,
|
||||
isNoteToSelf: isNoteToSelf,
|
||||
profile: contactProfile
|
||||
)
|
||||
}
|
||||
|
||||
public var profile: Profile? {
|
||||
switch variant {
|
||||
case .contact: return contactProfile
|
||||
case .openGroup: return nil
|
||||
case .closedGroup:
|
||||
// If there is only a single user in the group then we want to use the current user
|
||||
// profile at the back
|
||||
if closedGroupAvatarProfiles?.count == 1 {
|
||||
return currentUserProfile
|
||||
}
|
||||
|
||||
return closedGroupAvatarProfiles?.first?.profile
|
||||
}
|
||||
}
|
||||
|
||||
public var additionalProfile: Profile? {
|
||||
switch variant {
|
||||
case .closedGroup: return closedGroupAvatarProfiles?.last?.profile
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
public var lastInteractionDate: Date {
|
||||
guard let lastInteractionInfo: InteractionInfo = lastInteractionInfo else {
|
||||
return Date(timeIntervalSince1970: creationDateTimestamp)
|
||||
}
|
||||
|
||||
return Date(timeIntervalSince1970: (lastInteractionInfo.timestampMs / 1000))
|
||||
}
|
||||
|
||||
/// A flag indicating whether the thread is blocked (only contact threads can be blocked)
|
||||
public var isBlocked: Bool {
|
||||
return (contactIsBlocked == true)
|
||||
}
|
||||
|
||||
public var isGroupAdmin: Bool {
|
||||
return (currentUserIsClosedGroupAdmin == true)
|
||||
}
|
||||
|
||||
public var unreadCount: UInt {
|
||||
return (threadUnreadCount ?? 0)
|
||||
}
|
||||
|
||||
public var unreadMentionCount: UInt {
|
||||
return (threadUnreadMentionCount ?? 0)
|
||||
}
|
||||
|
||||
fileprivate init() {
|
||||
self.id = "FALLBACK"
|
||||
self.variant = .contact
|
||||
self.creationDateTimestamp = 0
|
||||
self.contactIsTyping = false
|
||||
self.closedGroupName = nil
|
||||
self.openGroupName = nil
|
||||
self.openGroupProfilePictureData = nil
|
||||
self.currentUserProfile = Profile(id: "", name: "")
|
||||
self.contactProfile = nil
|
||||
self.closedGroupAvatarProfiles = nil
|
||||
self.mutedUntilTimestamp = nil
|
||||
self.onlyNotifyForMentions = false
|
||||
self.isPinned = false
|
||||
self.contactIsBlocked = nil
|
||||
self.isNoteToSelf = false
|
||||
self.currentUserIsClosedGroupAdmin = nil
|
||||
self.threadUnreadCount = nil
|
||||
self.threadUnreadMentionCount = nil
|
||||
self.lastInteractionInfo = nil
|
||||
}
|
||||
|
||||
// MARK: - Query
|
||||
|
||||
public static func query(userPublicKey: String) -> QueryInterfaceRequest<ThreadInfo> {
|
||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||
let closedGroupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||
let unreadInteractions: TableAlias = TableAlias()
|
||||
let unreadMentions: TableAlias = TableAlias()
|
||||
let lastInteraction: TableAlias = TableAlias()
|
||||
let lastInteractionThread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||
let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias()
|
||||
|
||||
let currentUserProfileExpression: CommonTableExpression = CommonTableExpression(
|
||||
named: ThreadInfo.currentUserProfileKey,
|
||||
request: Profile.filter(id: userPublicKey)
|
||||
)
|
||||
let unreadInteractionExpression: CommonTableExpression = CommonTableExpression(
|
||||
named: ThreadInfo.threadUnreadCountKey,
|
||||
request: Interaction
|
||||
.select(
|
||||
count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadCountKey),
|
||||
Interaction.Columns.threadId
|
||||
)
|
||||
.filter(Interaction.Columns.wasRead == false)
|
||||
.group(Interaction.Columns.threadId)
|
||||
)
|
||||
let unreadMentionsExpression: CommonTableExpression = CommonTableExpression(
|
||||
named: ThreadInfo.threadUnreadMentionCountKey,
|
||||
request: Interaction
|
||||
.select(
|
||||
count(Interaction.Columns.id).forKey(ThreadInfo.threadUnreadMentionCountKey),
|
||||
Interaction.Columns.threadId
|
||||
)
|
||||
.filter(Interaction.Columns.wasRead == false)
|
||||
.filter(Interaction.Columns.hasMention == true)
|
||||
.group(Interaction.Columns.threadId)
|
||||
)
|
||||
let lastInteractionExpression: CommonTableExpression = CommonTableExpression(
|
||||
named: ThreadInfo.lastInteractionInfoKey,
|
||||
request: Interaction
|
||||
.select(
|
||||
Interaction.Columns.id,
|
||||
Interaction.Columns.threadId,
|
||||
Interaction.Columns.variant,
|
||||
|
||||
// 'max()' to get the latest
|
||||
max(Interaction.Columns.timestampMs).forKey(ThreadInfo.InteractionInfo.timestampMsKey),
|
||||
|
||||
lastInteractionThread[.variant].forKey(ThreadInfo.InteractionInfo.threadVariantKey),
|
||||
Interaction.Columns.body,
|
||||
Interaction.Columns.authorId,
|
||||
(linkPreview[.url] != nil).forKey(ThreadInfo.InteractionInfo.isOpenGroupInvitationKey)
|
||||
)
|
||||
.joining(required: Interaction.thread.aliased(lastInteractionThread))
|
||||
.joining(
|
||||
optional: Interaction.linkPreview
|
||||
.filter(literal: Interaction.linkPreviewFilterLiteral)
|
||||
.filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation)
|
||||
)
|
||||
.including(all: Interaction.attachments)
|
||||
.including(
|
||||
all: Interaction.recipientStates
|
||||
.select(RecipientState.Columns.state)
|
||||
.forKey(ThreadInfo.InteractionInfo.recipientStatesKey)
|
||||
)
|
||||
.group(Interaction.Columns.threadId) // One interaction per thread
|
||||
)
|
||||
|
||||
return SessionThread
|
||||
.select(
|
||||
thread[.id],
|
||||
thread[.variant],
|
||||
thread[.creationDateTimestamp],
|
||||
|
||||
(typingIndicator[.threadId] != nil).forKey(ThreadInfo.contactIsTypingKey),
|
||||
closedGroup[.name].forKey(ThreadInfo.closedGroupNameKey),
|
||||
openGroup[.name].forKey(ThreadInfo.openGroupNameKey),
|
||||
openGroup[.imageData].forKey(ThreadInfo.openGroupProfilePictureDataKey),
|
||||
|
||||
thread[.mutedUntilTimestamp],
|
||||
thread[.onlyNotifyForMentions],
|
||||
thread[.isPinned],
|
||||
contact[.isBlocked].forKey(ThreadInfo.contactIsBlockedKey),
|
||||
SessionThread.isNoteToSelf(userPublicKey: userPublicKey).forKey(ThreadInfo.isNoteToSelfKey),
|
||||
(closedGroupMember[.profileId] != nil).forKey(ThreadInfo.currentUserIsClosedGroupAdminKey),
|
||||
|
||||
unreadInteractions[ThreadInfo.threadUnreadCountKey],
|
||||
unreadMentions[ThreadInfo.threadUnreadMentionCountKey]
|
||||
)
|
||||
.aliased(thread)
|
||||
.joining(
|
||||
optional: SessionThread.contact
|
||||
.aliased(contact)
|
||||
.including(
|
||||
optional: Contact.profile
|
||||
.forKey(ThreadInfo.contactProfileKey)
|
||||
)
|
||||
)
|
||||
.joining(optional: SessionThread.typingIndicator.aliased(typingIndicator))
|
||||
.joining(
|
||||
optional: SessionThread.closedGroup
|
||||
.aliased(closedGroup)
|
||||
.including(
|
||||
all: ClosedGroup.members
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.standard)
|
||||
.filter(GroupMember.Columns.profileId != userPublicKey)
|
||||
.order(GroupMember.Columns.profileId) // Sort to provide a level of stability
|
||||
.limit(2)
|
||||
.including(required: GroupMember.profile)
|
||||
.forKey(ThreadInfo.closedGroupAvatarProfilesKey)
|
||||
)
|
||||
.joining(
|
||||
optional: ClosedGroup.members
|
||||
.aliased(closedGroupMember)
|
||||
.filter(GroupMember.Columns.role == GroupMember.Role.admin)
|
||||
.filter(GroupMember.Columns.profileId == userPublicKey)
|
||||
)
|
||||
)
|
||||
.joining(optional: SessionThread.openGroup.aliased(openGroup))
|
||||
.with(currentUserProfileExpression)
|
||||
.including(
|
||||
required: SessionThread.association(to: currentUserProfileExpression)
|
||||
.forKey(ThreadInfo.currentUserProfileKey)
|
||||
)
|
||||
.with(unreadInteractionExpression)
|
||||
.joining(
|
||||
optional: SessionThread
|
||||
.association(
|
||||
to: unreadInteractionExpression,
|
||||
on: { thread, unreadGroup in
|
||||
thread[SessionThread.Columns.id] == unreadGroup[Interaction.Columns.threadId]
|
||||
}
|
||||
)
|
||||
.aliased(unreadInteractions)
|
||||
)
|
||||
.with(unreadMentionsExpression)
|
||||
.joining(
|
||||
optional: SessionThread
|
||||
.association(
|
||||
to: unreadMentionsExpression,
|
||||
on: { thread, unreadMentions in
|
||||
thread[SessionThread.Columns.id] == unreadMentions[Interaction.Columns.threadId]
|
||||
}
|
||||
)
|
||||
.aliased(unreadMentions)
|
||||
)
|
||||
.with(lastInteractionExpression)
|
||||
.including(
|
||||
optional: SessionThread
|
||||
.association(
|
||||
to: lastInteractionExpression,
|
||||
on: { thread, lastInteraction in
|
||||
thread[SessionThread.Columns.id] == lastInteraction[Interaction.Columns.threadId]
|
||||
}
|
||||
)
|
||||
.aliased(lastInteraction)
|
||||
.forKey(ThreadInfo.lastInteractionInfoKey)
|
||||
.including(
|
||||
optional: lastInteractionExpression
|
||||
.association(
|
||||
to: CommonTableExpression(
|
||||
named: Profile.databaseTableName,
|
||||
request: Profile.select(.id, .name, .nickname)
|
||||
),
|
||||
on: { lastInteraction, profile in
|
||||
lastInteraction[Interaction.Columns.authorId] == profile[Profile.Columns.id]
|
||||
}
|
||||
)
|
||||
.forKey(ThreadInfo.InteractionInfo.authorInfoKey)
|
||||
)
|
||||
)
|
||||
.order(
|
||||
lastInteraction[Interaction.Columns.timestampMs].desc,
|
||||
thread[.creationDateTimestamp].desc
|
||||
)
|
||||
.asRequest(of: ThreadInfo.self)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public struct Item: Equatable, Differentiable {
|
||||
public var differenceIdentifier: String {
|
||||
return threadInfo.id
|
||||
}
|
||||
|
||||
let unreadCount: Int
|
||||
let threadInfo: ThreadInfo
|
||||
}
|
||||
|
||||
/// This value is the current state of the view
|
||||
public private(set) var viewData: [ArraySection<Section, Item>] = []
|
||||
public private(set) var viewData: [ArraySection<Section, ConversationCell.ViewModel>] = []
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
|
@ -397,7 +20,7 @@ public class HomeViewModel {
|
|||
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
public lazy var observableViewData = ValueObservation
|
||||
.trackingConstantRegion { db -> ObservedInfo in
|
||||
.trackingConstantRegion { db -> [ArraySection<Section, ConversationCell.ViewModel>] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
let unreadMessageRequestCount: Int = try SessionThread
|
||||
.filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
|
||||
|
@ -408,53 +31,34 @@ public class HomeViewModel {
|
|||
)
|
||||
.group(SessionThread.Columns.id)
|
||||
.fetchCount(db)
|
||||
let finalUnreadMessageRequestCount: Int = (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount)
|
||||
|
||||
return ObservedInfo(
|
||||
unreadMessageRequestCount: (db[.hasHiddenMessageRequests] ? 0 : unreadMessageRequestCount),
|
||||
threadInfo: try ThreadInfo
|
||||
.query(userPublicKey: userPublicKey)
|
||||
.filter(SessionThread.Columns.shouldBeVisible == true)
|
||||
.filter(SessionThread.isNotMessageRequest(userPublicKey: userPublicKey))
|
||||
.filter(
|
||||
// Only show the Note to Self if it has a lastInteraction
|
||||
SessionThread.Columns.id != userPublicKey ||
|
||||
SQL(stringLiteral: "\(ThreadInfo.lastInteractionInfoKey).id IS NOT NULL")
|
||||
)
|
||||
.fetchAll(db)
|
||||
)
|
||||
}
|
||||
.removeDuplicates()
|
||||
.map { observedInfo -> [ArraySection<Section, Item>] in
|
||||
return [
|
||||
ArraySection(
|
||||
model: .messageRequests,
|
||||
elements: [
|
||||
// If there are no unread message requests then hide the message request banner
|
||||
(observedInfo.unreadMessageRequestCount == 0 ?
|
||||
(finalUnreadMessageRequestCount == 0 ?
|
||||
nil :
|
||||
Item(
|
||||
unreadCount: observedInfo.unreadMessageRequestCount,
|
||||
threadInfo: ThreadInfo() // Won't be used
|
||||
ConversationCell.ViewModel(
|
||||
unreadCount: UInt(finalUnreadMessageRequestCount)
|
||||
)
|
||||
)
|
||||
].compactMap { $0 }
|
||||
),
|
||||
ArraySection(
|
||||
model: .threads,
|
||||
elements: observedInfo.threadInfo
|
||||
.map { info in
|
||||
Item(
|
||||
unreadCount: Int(info.unreadCount),
|
||||
threadInfo: info
|
||||
)
|
||||
}
|
||||
),
|
||||
elements: try ConversationCell.ViewModel
|
||||
.homeQuery(userPublicKey: userPublicKey)
|
||||
.fetchAll(db)
|
||||
)
|
||||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateData(_ updatedData: [ArraySection<Section, Item>]) {
|
||||
public func updateData(_ updatedData: [ArraySection<Section, ConversationCell.ViewModel>]) {
|
||||
self.viewData = updatedData
|
||||
}
|
||||
}
|
||||
|
|
|
@ -173,7 +173,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
)
|
||||
}
|
||||
|
||||
private func handleUpdates(_ updatedViewData: [HomeViewModel.ThreadInfo]) {
|
||||
private func handleUpdates(_ updatedViewData: [ConversationCell.ViewModel]) {
|
||||
// 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 {
|
||||
|
@ -221,7 +221,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||||
tableView.deselectRow(at: indexPath, animated: true)
|
||||
|
||||
guard let conversationVC: ConversationVC = ConversationVC(threadId: viewModel.viewData[indexPath.row].id) else {
|
||||
guard let conversationVC: ConversationVC = ConversationVC(threadId: viewModel.viewData[indexPath.row].threadId) else {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -233,7 +233,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
}
|
||||
|
||||
func tableView(_ tableView: UITableView, editActionsForRowAt indexPath: IndexPath) -> [UITableViewRowAction]? {
|
||||
let threadId: String = viewModel.viewData[indexPath.row].id
|
||||
let threadId: String = viewModel.viewData[indexPath.row].threadId
|
||||
let delete = UITableViewRowAction(
|
||||
style: .destructive,
|
||||
title: "TXT_DELETE_TITLE".localized()
|
||||
|
@ -250,7 +250,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
|||
@objc private func clearAllTapped() {
|
||||
guard !viewModel.viewData.isEmpty else { return }
|
||||
|
||||
let threadIds: [String] = viewModel.viewData.map { $0.id }
|
||||
let threadIds: [String] = viewModel.viewData.map { $0.threadId }
|
||||
let alertVC: UIAlertController = UIAlertController(
|
||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE".localized(),
|
||||
message: nil,
|
||||
|
|
|
@ -7,7 +7,7 @@ import SignalUtilitiesKit
|
|||
|
||||
public class MessageRequestsViewModel {
|
||||
/// This value is the current state of the view
|
||||
public private(set) var viewData: [HomeViewModel.ThreadInfo] = []
|
||||
public private(set) var viewData: [ConversationCell.ViewModel] = []
|
||||
|
||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||
|
@ -15,19 +15,18 @@ public class MessageRequestsViewModel {
|
|||
/// **Note:** The 'trackingConstantRegion' is optimised in such a way that the request needs to be static
|
||||
/// otherwise there may be situations where it doesn't get updates, this means we can't have conditional queries
|
||||
public lazy var observableViewData = ValueObservation
|
||||
.trackingConstantRegion { db -> [HomeViewModel.ThreadInfo] in
|
||||
.trackingConstantRegion { db -> [ConversationCell.ViewModel] in
|
||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
return try HomeViewModel.ThreadInfo
|
||||
.query(userPublicKey: userPublicKey)
|
||||
.filter(SessionThread.isMessageRequest(userPublicKey: userPublicKey))
|
||||
return try ConversationCell.ViewModel
|
||||
.messageRequestsQuery(userPublicKey: userPublicKey)
|
||||
.fetchAll(db)
|
||||
}
|
||||
.removeDuplicates()
|
||||
|
||||
// MARK: - Functions
|
||||
|
||||
public func updateData(_ updatedData: [HomeViewModel.ThreadInfo]) {
|
||||
public func updateData(_ updatedData: [ConversationCell.ViewModel]) {
|
||||
self.viewData = updatedData
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ import UIKit
|
|||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class ConversationCell: UITableViewCell {
|
||||
public final class ConversationCell: UITableViewCell {
|
||||
// MARK: - UI
|
||||
|
||||
private let accentLineView: UIView = UIView()
|
||||
|
@ -231,155 +231,125 @@ final class ConversationCell: UITableViewCell {
|
|||
|
||||
// MARK: - Content
|
||||
|
||||
public func update(with threadInfo: HomeViewModel.ThreadInfo, isGlobalSearchResult: Bool = false) {
|
||||
guard !isGlobalSearchResult else {
|
||||
updateForSearchResult(threadInfo)
|
||||
return
|
||||
}
|
||||
|
||||
update(threadInfo)
|
||||
}
|
||||
|
||||
// MARK: - Updating for search results
|
||||
// MARK: --Search Results
|
||||
|
||||
private func updateForSearchResult(_ threadInfo: HomeViewModel.ThreadInfo) {
|
||||
public func updateForMessageSearchResult(with cellViewModel: ViewModel, searchText: String) {
|
||||
profilePictureView.update(
|
||||
publicKey: threadInfo.id,
|
||||
profile: threadInfo.profile,
|
||||
additionalProfile: threadInfo.additionalProfile,
|
||||
threadVariant: threadInfo.variant,
|
||||
openGroupProfilePicture: threadInfo.openGroupProfilePictureData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (threadInfo.variant == .openGroup && threadInfo.openGroupProfilePictureData == nil)
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
|
||||
)
|
||||
|
||||
isPinnedIcon.isHidden = true
|
||||
unreadCountView.isHidden = true
|
||||
hasMentionView.isHidden = true
|
||||
}
|
||||
|
||||
public func configureForRecent(_ threadInfo: HomeViewModel.ThreadInfo) {
|
||||
displayNameLabel.attributedText = NSMutableAttributedString(
|
||||
string: threadInfo.displayName,
|
||||
attributes: [ .foregroundColor: Colors.text ]
|
||||
string: cellViewModel.displayName,
|
||||
attributes: [ .foregroundColor: Colors.text]
|
||||
)
|
||||
timestampLabel.isHidden = false
|
||||
timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate)
|
||||
bottomLabelStackView.isHidden = false
|
||||
|
||||
let snippet = String(
|
||||
format: "RECENT_SEARCH_LAST_MESSAGE_DATETIME".localized(),
|
||||
DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate)
|
||||
snippetLabel.attributedText = getHighlightedSnippet(
|
||||
content: (cellViewModel.interactionBody ?? ""),
|
||||
authorName: (cellViewModel.authorId != cellViewModel.currentUserPublicKey ?
|
||||
cellViewModel.authorName(for: .contact) :
|
||||
nil
|
||||
),
|
||||
searchText: searchText.lowercased(),
|
||||
fontSize: Values.smallFontSize
|
||||
)
|
||||
snippetLabel.attributedText = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)])
|
||||
timestampLabel.isHidden = true
|
||||
}
|
||||
|
||||
public func configure(snippet: String?, searchText: String, message: TSMessage? = nil) {
|
||||
let normalizedSearchText = searchText.lowercased()
|
||||
if let messageTimestamp = message?.timestamp, let snippet = snippet {
|
||||
// Message
|
||||
let messageDate = NSDate.ows_date(withMillisecondsSince1970: messageTimestamp)
|
||||
displayNameLabel.attributedText = NSMutableAttributedString(string: getDisplayName(), attributes: [.foregroundColor:Colors.text])
|
||||
timestampLabel.isHidden = false
|
||||
timestampLabel.text = DateUtil.formatDate(forDisplay: messageDate)
|
||||
bottomLabelStackView.isHidden = false
|
||||
var rawSnippet = snippet
|
||||
if let message = message, let name = getMessageAuthorName(message: message) {
|
||||
rawSnippet = "\(name): \(snippet)"
|
||||
}
|
||||
snippetLabel.attributedText = getHighlightedSnippet(snippet: rawSnippet, searchText: normalizedSearchText, fontSize: Values.smallFontSize)
|
||||
} else {
|
||||
// Contact
|
||||
if threadViewModel.isGroupThread, let thread = threadViewModel.threadRecord as? TSGroupThread {
|
||||
displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayName(), searchText: normalizedSearchText, fontSize: Values.mediumFontSize)
|
||||
var rawSnippet: String = ""
|
||||
thread.groupModel.groupMemberIds.forEach { id in
|
||||
if let displayName = Profile.displayNameNoFallback(for: id, thread: thread) {
|
||||
if !rawSnippet.isEmpty {
|
||||
rawSnippet += ", \(displayName)"
|
||||
}
|
||||
if displayName.lowercased().contains(normalizedSearchText) {
|
||||
rawSnippet = displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSnippet.isEmpty {
|
||||
bottomLabelStackView.isHidden = true
|
||||
} else {
|
||||
bottomLabelStackView.isHidden = false
|
||||
snippetLabel.attributedText = getHighlightedSnippet(snippet: rawSnippet, searchText: normalizedSearchText, fontSize: Values.smallFontSize)
|
||||
}
|
||||
} else {
|
||||
displayNameLabel.attributedText = getHighlightedSnippet(snippet: getDisplayNameForSearch(threadViewModel.contactSessionID!), searchText: normalizedSearchText, fontSize: Values.mediumFontSize)
|
||||
bottomLabelStackView.isHidden = true
|
||||
}
|
||||
timestampLabel.isHidden = true
|
||||
}
|
||||
}
|
||||
|
||||
private func getHighlightedSnippet(snippet: String, searchText: String, fontSize: CGFloat) -> NSMutableAttributedString {
|
||||
guard snippet != "NOTE_TO_SELF".localized() else {
|
||||
return NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text])
|
||||
}
|
||||
|
||||
let result = NSMutableAttributedString(string: snippet, attributes: [.foregroundColor:Colors.text.withAlphaComponent(Values.lowOpacity)])
|
||||
let normalizedSnippet = snippet.lowercased() as NSString
|
||||
|
||||
guard normalizedSnippet.contains(searchText) else { return result }
|
||||
|
||||
let range = normalizedSnippet.range(of: searchText)
|
||||
result.addAttribute(.foregroundColor, value: Colors.text, range: range)
|
||||
result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: range)
|
||||
return result
|
||||
}
|
||||
|
||||
// MARK: - Updating
|
||||
|
||||
private func update(_ threadInfo: HomeViewModel.ThreadInfo) {
|
||||
backgroundColor = (threadInfo.isPinned ? Colors.cellPinned : Colors.cellBackground)
|
||||
public func updateForContactAndGroupSearchResult(with cellViewModel: ViewModel, searchText: String) {
|
||||
profilePictureView.update(
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (cellViewModel.threadVariant == .openGroup && cellViewModel.openGroupProfilePictureData == nil)
|
||||
)
|
||||
|
||||
if threadInfo.isBlocked {
|
||||
isPinnedIcon.isHidden = true
|
||||
unreadCountView.isHidden = true
|
||||
hasMentionView.isHidden = true
|
||||
timestampLabel.isHidden = true
|
||||
displayNameLabel.attributedText = getHighlightedSnippet(
|
||||
content: cellViewModel.displayName,
|
||||
searchText: searchText.lowercased(),
|
||||
fontSize: Values.mediumFontSize
|
||||
)
|
||||
|
||||
switch cellViewModel.threadVariant {
|
||||
case .contact, .openGroup: bottomLabelStackView.isHidden = true
|
||||
|
||||
case .closedGroup:
|
||||
bottomLabelStackView.isHidden = (cellViewModel.threadMemberNames ?? "").isEmpty
|
||||
snippetLabel.attributedText = getHighlightedSnippet(
|
||||
content: (cellViewModel.threadMemberNames ?? ""),
|
||||
searchText: searchText.lowercased(),
|
||||
fontSize: Values.smallFontSize
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: --Standard
|
||||
|
||||
public func update(with cellViewModel: ViewModel) {
|
||||
let unreadCount: UInt = (cellViewModel.threadUnreadCount ?? 0)
|
||||
backgroundColor = (cellViewModel.threadIsPinned ? Colors.cellPinned : Colors.cellBackground)
|
||||
|
||||
if cellViewModel.threadIsBlocked == true {
|
||||
accentLineView.backgroundColor = Colors.destructive
|
||||
accentLineView.alpha = 1
|
||||
}
|
||||
else {
|
||||
accentLineView.backgroundColor = Colors.accent
|
||||
accentLineView.alpha = (threadInfo.unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12
|
||||
accentLineView.alpha = (unreadCount > 0 ? 1 : 0.0001) // Setting the alpha to exactly 0 causes an issue on iOS 12
|
||||
}
|
||||
|
||||
isPinnedIcon.isHidden = !threadInfo.isPinned
|
||||
unreadCountView.isHidden = (threadInfo.unreadCount <= 0)
|
||||
unreadCountLabel.text = (threadInfo.unreadCount < 10000 ? "\(threadInfo.unreadCount)" : "9999+")
|
||||
isPinnedIcon.isHidden = !cellViewModel.threadIsPinned
|
||||
unreadCountView.isHidden = (unreadCount <= 0)
|
||||
unreadCountLabel.text = (unreadCount < 10000 ? "\(unreadCount)" : "9999+")
|
||||
unreadCountLabel.font = .boldSystemFont(
|
||||
ofSize: (threadInfo.unreadCount < 10000 ? Values.verySmallFontSize : 8)
|
||||
ofSize: (unreadCount < 10000 ? Values.verySmallFontSize : 8)
|
||||
)
|
||||
hasMentionView.isHidden = !(
|
||||
(threadInfo.unreadMentionCount > 0) &&
|
||||
(threadInfo.variant == .closedGroup || threadInfo.variant == .openGroup)
|
||||
((cellViewModel.threadUnreadMentionCount ?? 0) > 0) &&
|
||||
(cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup)
|
||||
)
|
||||
profilePictureView.update(
|
||||
publicKey: threadInfo.id,
|
||||
profile: threadInfo.profile,
|
||||
additionalProfile: threadInfo.additionalProfile,
|
||||
threadVariant: threadInfo.variant,
|
||||
openGroupProfilePicture: threadInfo.openGroupProfilePictureData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (threadInfo.variant == .openGroup && threadInfo.openGroupProfilePictureData == nil)
|
||||
publicKey: cellViewModel.threadId,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: cellViewModel.additionalProfile,
|
||||
threadVariant: cellViewModel.threadVariant,
|
||||
openGroupProfilePicture: cellViewModel.openGroupProfilePictureData.map { UIImage(data: $0) },
|
||||
useFallbackPicture: (
|
||||
cellViewModel.threadVariant == .openGroup &&
|
||||
cellViewModel.openGroupProfilePictureData == nil
|
||||
)
|
||||
)
|
||||
displayNameLabel.text = threadInfo.displayName
|
||||
timestampLabel.text = DateUtil.formatDate(forDisplay: threadInfo.lastInteractionDate)
|
||||
displayNameLabel.text = cellViewModel.displayName
|
||||
timestampLabel.text = DateUtil.formatDate(forDisplay: cellViewModel.lastInteractionDate)
|
||||
|
||||
if threadInfo.contactIsTyping {
|
||||
if cellViewModel.threadContactIsTyping == true {
|
||||
snippetLabel.text = ""
|
||||
typingIndicatorView.isHidden = false
|
||||
typingIndicatorView.startAnimation()
|
||||
}
|
||||
else {
|
||||
snippetLabel.attributedText = getSnippet(threadInfo: threadInfo)
|
||||
snippetLabel.attributedText = getSnippet(cellViewModel: cellViewModel)
|
||||
typingIndicatorView.isHidden = true
|
||||
typingIndicatorView.stopAnimation()
|
||||
}
|
||||
|
||||
statusIndicatorView.backgroundColor = nil
|
||||
|
||||
switch (threadInfo.lastInteractionInfo?.variant, threadInfo.lastInteractionInfo?.state) {
|
||||
switch (cellViewModel.interactionVariant, cellViewModel.interactionState) {
|
||||
case (.standardOutgoing, .sending):
|
||||
statusIndicatorView.image = #imageLiteral(resourceName: "CircleDotDotDot").withRenderingMode(.alwaysTemplate)
|
||||
statusIndicatorView.tintColor = Colors.text
|
||||
|
@ -399,40 +369,13 @@ final class ConversationCell: UITableViewCell {
|
|||
statusIndicatorView.isHidden = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Snippet generation
|
||||
|
||||
private func getDisplayNameForSearch(_ sessionID: String) -> String {
|
||||
if threadViewModel.threadRecord.isNoteToSelf() {
|
||||
return NSLocalizedString("NOTE_TO_SELF", comment: "")
|
||||
}
|
||||
|
||||
return [
|
||||
Profile.displayName(id: sessionID),
|
||||
Profile.fetchOrCreate(id: sessionID).nickname.map { "(\($0)" }
|
||||
]
|
||||
.compactMap { $0 }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
|
||||
private func getDisplayName(for thread: SessionThread) -> String {
|
||||
if thread.variant == .closedGroup || thread.variant == .openGroup {
|
||||
return GRDBStorage.shared.read({ db in thread.name(db) })
|
||||
.defaulting(to: "Unknown Group")
|
||||
}
|
||||
|
||||
if GRDBStorage.shared.read({ db in thread.isNoteToSelf(db) }) == true {
|
||||
return "NOTE_TO_SELF".localized()
|
||||
}
|
||||
|
||||
let hexEncodedPublicKey: String = thread.id
|
||||
let middleTruncatedHexKey: String = "\(hexEncodedPublicKey.prefix(4))...\(hexEncodedPublicKey.suffix(4))"
|
||||
|
||||
return Profile.displayName(id: hexEncodedPublicKey, customFallback: middleTruncatedHexKey)
|
||||
}
|
||||
|
||||
private func getSnippet(threadInfo: HomeViewModel.ThreadInfo) -> NSMutableAttributedString {
|
||||
private func getSnippet(cellViewModel: ViewModel) -> NSMutableAttributedString {
|
||||
let result = NSMutableAttributedString()
|
||||
|
||||
if Date().timeIntervalSince1970 < (threadInfo.mutedUntilTimestamp ?? 0) {
|
||||
if Date().timeIntervalSince1970 < (cellViewModel.threadMutedUntilTimestamp ?? 0) {
|
||||
result.append(NSAttributedString(
|
||||
string: "\u{e067} ",
|
||||
attributes: [
|
||||
|
@ -441,7 +384,7 @@ final class ConversationCell: UITableViewCell {
|
|||
]
|
||||
))
|
||||
}
|
||||
else if threadInfo.onlyNotifyForMentions {
|
||||
else if cellViewModel.threadOnlyNotifyForMentions == true {
|
||||
let imageAttachment = NSTextAttachment()
|
||||
imageAttachment.image = UIImage(named: "NotifyMentions.png")?.asTintedImage(color: Colors.unimportant)
|
||||
imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize)
|
||||
|
@ -457,15 +400,14 @@ final class ConversationCell: UITableViewCell {
|
|||
))
|
||||
}
|
||||
|
||||
let font: UIFont = (threadInfo.unreadCount > 0 ?
|
||||
let font: UIFont = ((cellViewModel.threadUnreadCount ?? 0) > 0 ?
|
||||
.boldSystemFont(ofSize: Values.smallFontSize) :
|
||||
.systemFont(ofSize: Values.smallFontSize)
|
||||
)
|
||||
|
||||
if
|
||||
(threadInfo.variant == .closedGroup || threadInfo.variant == .openGroup),
|
||||
let authorName: String = threadInfo.lastInteractionInfo?.authorName
|
||||
{
|
||||
if cellViewModel.threadVariant == .closedGroup || cellViewModel.threadVariant == .openGroup {
|
||||
let authorName: String = cellViewModel.authorName(for: cellViewModel.threadVariant)
|
||||
|
||||
result.append(NSAttributedString(
|
||||
string: "\(authorName): ",
|
||||
attributes: [
|
||||
|
@ -474,20 +416,138 @@ final class ConversationCell: UITableViewCell {
|
|||
]
|
||||
))
|
||||
}
|
||||
|
||||
if let rawSnippet: String = threadInfo.lastInteractionInfo?.text {
|
||||
result.append(NSAttributedString(
|
||||
string: MentionUtilities.highlightMentions(
|
||||
in: rawSnippet,
|
||||
threadVariant: threadInfo.variant
|
||||
|
||||
result.append(NSAttributedString(
|
||||
string: MentionUtilities.highlightMentions(
|
||||
in: Interaction.previewText(
|
||||
variant: (cellViewModel.interactionVariant ?? .standardIncoming),
|
||||
body: cellViewModel.interactionBody,
|
||||
authorDisplayName: cellViewModel.authorName(for: cellViewModel.threadVariant),
|
||||
attachmentDescriptionInfo: cellViewModel.interactionAttachmentDescriptionInfo,
|
||||
attachmentCount: cellViewModel.interactionAttachmentCount,
|
||||
isOpenGroupInvitation: (cellViewModel.interactionIsOpenGroupInvitation == true)
|
||||
),
|
||||
attributes: [
|
||||
.font: font,
|
||||
.foregroundColor: Colors.text
|
||||
]
|
||||
))
|
||||
}
|
||||
threadVariant: cellViewModel.threadVariant
|
||||
),
|
||||
attributes: [
|
||||
.font: font,
|
||||
.foregroundColor: Colors.text
|
||||
]
|
||||
))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private func getHighlightedSnippet(
|
||||
content: String,
|
||||
authorName: String? = nil,
|
||||
searchText: String,
|
||||
fontSize: CGFloat
|
||||
) -> NSAttributedString {
|
||||
guard !content.isEmpty, content != "NOTE_TO_SELF".localized() else {
|
||||
return NSMutableAttributedString(
|
||||
string: (authorName != nil && authorName?.isEmpty != true ?
|
||||
"\(authorName ?? ""): \(content)" :
|
||||
content
|
||||
),
|
||||
attributes: [ .foregroundColor: Colors.text ]
|
||||
)
|
||||
}
|
||||
|
||||
// Replace mentions in the content
|
||||
//
|
||||
// Note: The 'threadVariant' is used for profile context but in the search results
|
||||
// we don't want to include the truncated id as part of the name so we exclude it
|
||||
let mentionReplacedContent: String = MentionUtilities.highlightMentions(
|
||||
in: content,
|
||||
threadVariant: .contact
|
||||
)
|
||||
let result: NSMutableAttributedString = NSMutableAttributedString(
|
||||
string: mentionReplacedContent,
|
||||
attributes: [
|
||||
.foregroundColor: Colors.text
|
||||
.withAlphaComponent(Values.lowOpacity)
|
||||
]
|
||||
)
|
||||
|
||||
// Bold each part of the searh term which matched
|
||||
let normalizedSnippet: String = mentionReplacedContent.lowercased()
|
||||
var firstMatchRange: Range<String.Index>?
|
||||
|
||||
ConversationCell.ViewModel.searchTermParts(searchText)
|
||||
.map { part -> String in
|
||||
guard part.hasPrefix("\"") && part.hasSuffix("\"") else { return part }
|
||||
|
||||
return String(part[part.index(after: part.startIndex)..<part.endIndex])
|
||||
}
|
||||
.forEach { part in
|
||||
guard
|
||||
normalizedSnippet.contains(part.lowercased()),
|
||||
let range: Range<String.Index> = normalizedSnippet.range(of: part.lowercased())
|
||||
else { return }
|
||||
|
||||
// Store the range of the first match so we can focus it in the content displayed
|
||||
if firstMatchRange == nil {
|
||||
firstMatchRange = range
|
||||
}
|
||||
|
||||
let legacyRange: NSRange = NSRange(range, in: normalizedSnippet)
|
||||
result.addAttribute(.foregroundColor, value: Colors.text, range: legacyRange)
|
||||
result.addAttribute(.font, value: UIFont.boldSystemFont(ofSize: fontSize), range: legacyRange)
|
||||
}
|
||||
|
||||
// We then want to truncate the content so the first metching term is visible
|
||||
let startOfSnippet: String.Index = (
|
||||
firstMatchRange.map {
|
||||
max(
|
||||
mentionReplacedContent.startIndex,
|
||||
mentionReplacedContent
|
||||
.index(
|
||||
$0.lowerBound,
|
||||
offsetBy: -10,
|
||||
limitedBy: mentionReplacedContent.startIndex
|
||||
)
|
||||
.defaulting(to: mentionReplacedContent.startIndex)
|
||||
)
|
||||
} ??
|
||||
mentionReplacedContent.startIndex
|
||||
)
|
||||
|
||||
// This method determines if the content is probably too long and returns the truncated or untruncated
|
||||
// content accordingly
|
||||
func truncatingIfNeeded(approxWidth: CGFloat, content: NSAttributedString) -> NSAttributedString {
|
||||
let approxFullWidth: CGFloat = (approxWidth + profilePictureView.size + (Values.mediumSpacing * 3))
|
||||
|
||||
guard ((bounds.width - approxFullWidth) < 0) else { return content }
|
||||
|
||||
return content.attributedSubstring(
|
||||
from: NSRange(startOfSnippet..<normalizedSnippet.endIndex, in: normalizedSnippet)
|
||||
)
|
||||
}
|
||||
|
||||
// Now that we have generated the focused snippet add the author name as a prefix (if provided)
|
||||
return authorName
|
||||
.map { authorName -> NSAttributedString? in
|
||||
guard !authorName.isEmpty else { return nil }
|
||||
|
||||
let authorPrefix: NSAttributedString = NSAttributedString(
|
||||
string: "\(authorName): ...",
|
||||
attributes: [ .foregroundColor: Colors.text ]
|
||||
)
|
||||
|
||||
return authorPrefix
|
||||
.appending(
|
||||
truncatingIfNeeded(
|
||||
approxWidth: (authorPrefix.size().width + result.size().width),
|
||||
content: result
|
||||
)
|
||||
)
|
||||
}
|
||||
.defaulting(
|
||||
to: truncatingIfNeeded(
|
||||
approxWidth: result.size().width,
|
||||
content: result
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -8,6 +8,10 @@ enum _001_InitialSetupMigration: Migration {
|
|||
static let identifier: String = "initialSetup"
|
||||
|
||||
static func migrate(_ db: Database) throws {
|
||||
// Define the tokenizer to be used in all the FTS tables
|
||||
// https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#fts5-tokenizers
|
||||
let fullTextSearchTokenizer: FTS5TokenizerDescriptor = .porter(wrapping: .unicode61())
|
||||
|
||||
try db.create(table: Contact.self) { t in
|
||||
t.column(.id, .text)
|
||||
.notNull()
|
||||
|
@ -40,6 +44,15 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.column(.profileEncryptionKey, .blob)
|
||||
}
|
||||
|
||||
/// Create a full-text search table synchronized with the Profile table
|
||||
try db.create(virtualTable: Profile.fullTextSearchTableName, using: FTS5()) { t in
|
||||
t.synchronize(withTable: Profile.databaseTableName)
|
||||
t.tokenizer = fullTextSearchTokenizer
|
||||
|
||||
t.column(Profile.Columns.nickname.name)
|
||||
t.column(Profile.Columns.name.name)
|
||||
}
|
||||
|
||||
try db.create(table: SessionThread.self) { t in
|
||||
t.column(.id, .text)
|
||||
.notNull()
|
||||
|
@ -78,6 +91,14 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.column(.formationTimestamp, .double).notNull()
|
||||
}
|
||||
|
||||
/// Create a full-text search table synchronized with the ClosedGroup table
|
||||
try db.create(virtualTable: ClosedGroup.fullTextSearchTableName, using: FTS5()) { t in
|
||||
t.synchronize(withTable: ClosedGroup.databaseTableName)
|
||||
t.tokenizer = fullTextSearchTokenizer
|
||||
|
||||
t.column(ClosedGroup.Columns.name.name)
|
||||
}
|
||||
|
||||
try db.create(table: ClosedGroupKeyPair.self) { t in
|
||||
t.column(.threadId, .text)
|
||||
.notNull()
|
||||
|
@ -108,6 +129,14 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.column(.infoUpdates, .integer).notNull()
|
||||
}
|
||||
|
||||
/// Create a full-text search table synchronized with the OpenGroup table
|
||||
try db.create(virtualTable: OpenGroup.fullTextSearchTableName, using: FTS5()) { t in
|
||||
t.synchronize(withTable: OpenGroup.databaseTableName)
|
||||
t.tokenizer = fullTextSearchTokenizer
|
||||
|
||||
t.column(OpenGroup.Columns.name.name)
|
||||
}
|
||||
|
||||
try db.create(table: Capability.self) { t in
|
||||
t.column(.openGroupId, .text)
|
||||
.notNull()
|
||||
|
@ -190,6 +219,14 @@ enum _001_InitialSetupMigration: Migration {
|
|||
t.uniqueKey([.threadId, .openGroupServerMessageId])
|
||||
}
|
||||
|
||||
/// Create a full-text search table synchronized with the Interaction table
|
||||
try db.create(virtualTable: Interaction.fullTextSearchTableName, using: FTS5()) { t in
|
||||
t.synchronize(withTable: Interaction.databaseTableName)
|
||||
t.tokenizer = fullTextSearchTokenizer
|
||||
|
||||
t.column(Interaction.Columns.body.name)
|
||||
}
|
||||
|
||||
try db.create(table: RecipientState.self) { t in
|
||||
t.column(.interactionId, .integer)
|
||||
.notNull()
|
||||
|
@ -241,6 +278,7 @@ enum _001_InitialSetupMigration: Migration {
|
|||
}
|
||||
|
||||
try db.create(table: InteractionAttachment.self) { t in
|
||||
t.column(.albumIndex, .integer).notNull()
|
||||
t.column(.interactionId, .integer)
|
||||
.notNull()
|
||||
.indexed() // Quicker querying
|
||||
|
|
|
@ -210,6 +210,8 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
|
||||
// Insert the data into GRDB
|
||||
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
// MARK: - Insert Contacts
|
||||
|
||||
try autoreleasepool {
|
||||
|
@ -374,28 +376,33 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
).insert(db)
|
||||
}
|
||||
|
||||
try groupModel.groupMemberIds.forEach { memberId in
|
||||
try GroupMember(
|
||||
groupId: threadId,
|
||||
profileId: memberId,
|
||||
role: .standard
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try groupModel.groupAdminIds.forEach { adminId in
|
||||
try GroupMember(
|
||||
groupId: threadId,
|
||||
profileId: adminId,
|
||||
role: .admin
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in
|
||||
try GroupMember(
|
||||
groupId: threadId,
|
||||
profileId: zombieId,
|
||||
role: .zombie
|
||||
).insert(db)
|
||||
// Only create the 'GroupMember' models if the current user is actually a member
|
||||
// of the group (if the user has left the group or been removed from it we now
|
||||
// delete all of these records so want this to behave the same way)
|
||||
if groupModel.groupMemberIds.contains(currentUserPublicKey) {
|
||||
try groupModel.groupMemberIds.forEach { memberId in
|
||||
try GroupMember(
|
||||
groupId: threadId,
|
||||
profileId: memberId,
|
||||
role: .standard
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try groupModel.groupAdminIds.forEach { adminId in
|
||||
try GroupMember(
|
||||
groupId: threadId,
|
||||
profileId: adminId,
|
||||
role: .admin
|
||||
).insert(db)
|
||||
}
|
||||
|
||||
try (closedGroupZombieMemberIds[legacyThreadId] ?? []).forEach { zombieId in
|
||||
try GroupMember(
|
||||
groupId: threadId,
|
||||
profileId: zombieId,
|
||||
role: .zombie
|
||||
).insert(db)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -421,8 +428,6 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
}
|
||||
|
||||
try autoreleasepool {
|
||||
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||
|
||||
try interactions[legacyThreadId]?
|
||||
.sorted(by: { lhs, rhs in lhs.timestamp < rhs.timestamp }) // Maintain sort order
|
||||
.forEach { legacyInteraction in
|
||||
|
@ -785,7 +790,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
|
||||
// Handle any attachments
|
||||
|
||||
try attachmentIds.forEach { legacyAttachmentId in
|
||||
try attachmentIds.enumerated().forEach { index, legacyAttachmentId in
|
||||
guard let attachmentId: String = try attachmentId(
|
||||
db,
|
||||
for: legacyAttachmentId,
|
||||
|
@ -799,6 +804,7 @@ enum _003_YDBToGRDBMigration: Migration {
|
|||
|
||||
// Link the attachment to the interaction and add to the id lookup
|
||||
try InteractionAttachment(
|
||||
albumIndex: index,
|
||||
interactionId: interactionId,
|
||||
attachmentId: attachmentId
|
||||
).insert(db)
|
||||
|
|
|
@ -245,16 +245,55 @@ public struct Attachment: Codable, Identifiable, Equatable, Hashable, FetchableR
|
|||
// MARK: - CustomStringConvertible
|
||||
|
||||
extension Attachment: CustomStringConvertible {
|
||||
public static func description(for variant: Variant, contentType: String, sourceFilename: String?) -> String {
|
||||
if MIMETypeUtil.isAudio(contentType) {
|
||||
public struct DescriptionInfo: FetchableRecord, Decodable, Equatable, ColumnExpressible {
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression, CaseIterable {
|
||||
case variant
|
||||
case contentType
|
||||
case sourceFilename
|
||||
}
|
||||
|
||||
let variant: Attachment.Variant
|
||||
let contentType: String
|
||||
let sourceFilename: String?
|
||||
|
||||
public init(
|
||||
variant: Attachment.Variant,
|
||||
contentType: String,
|
||||
sourceFilename: String?
|
||||
) {
|
||||
self.variant = variant
|
||||
self.contentType = contentType
|
||||
self.sourceFilename = sourceFilename
|
||||
}
|
||||
}
|
||||
|
||||
public static func description(for descriptionInfo: DescriptionInfo?, count: Int?) -> String? {
|
||||
guard let descriptionInfo: DescriptionInfo = descriptionInfo else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return description(for: descriptionInfo, count: (count ?? 1))
|
||||
}
|
||||
|
||||
public static func description(for descriptionInfo: DescriptionInfo, count: Int) -> String {
|
||||
// We only support multi-attachment sending of images so we can just default to the image attachment
|
||||
// if there were multiple attachments
|
||||
guard count == 1 else { return "\("ATTACHMENT".localized()) \(emoji(for: OWSMimeTypeImageJpeg))" }
|
||||
|
||||
if MIMETypeUtil.isAudio(descriptionInfo.contentType) {
|
||||
// a missing filename is the legacy way to determine if an audio attachment is
|
||||
// a voice note vs. other arbitrary audio attachments.
|
||||
if variant == .voiceMessage || sourceFilename == nil || (sourceFilename?.count ?? 0) == 0 {
|
||||
if
|
||||
descriptionInfo.variant == .voiceMessage ||
|
||||
descriptionInfo.sourceFilename == nil ||
|
||||
(descriptionInfo.sourceFilename?.count ?? 0) == 0
|
||||
{
|
||||
return "🎙️ \("ATTACHMENT_TYPE_VOICE_MESSAGE".localized())"
|
||||
}
|
||||
}
|
||||
|
||||
return "\("ATTACHMENT".localized()) \(emoji(for: contentType))"
|
||||
return "\("ATTACHMENT".localized()) \(emoji(for: descriptionInfo.contentType))"
|
||||
}
|
||||
|
||||
public static func emoji(for contentType: String) -> String {
|
||||
|
@ -276,17 +315,20 @@ extension Attachment: CustomStringConvertible {
|
|||
|
||||
public var description: String {
|
||||
return Attachment.description(
|
||||
for: variant,
|
||||
contentType: contentType,
|
||||
sourceFilename: sourceFilename
|
||||
for: DescriptionInfo(
|
||||
variant: variant,
|
||||
contentType: contentType,
|
||||
sourceFilename: sourceFilename
|
||||
),
|
||||
count: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mutation
|
||||
|
||||
public extension Attachment {
|
||||
func with(
|
||||
extension Attachment {
|
||||
public func with(
|
||||
serverId: String? = nil,
|
||||
state: State? = nil,
|
||||
creationTimestamp: TimeInterval? = nil,
|
||||
|
@ -337,8 +379,8 @@ public extension Attachment {
|
|||
|
||||
// MARK: - Protobuf
|
||||
|
||||
public extension Attachment {
|
||||
init(proto: SNProtoAttachmentPointer) {
|
||||
extension Attachment {
|
||||
public init(proto: SNProtoAttachmentPointer) {
|
||||
func inferContentType(from filename: String?) -> String {
|
||||
guard
|
||||
let fileName: String = filename,
|
||||
|
@ -382,7 +424,7 @@ public extension Attachment {
|
|||
self.caption = (proto.hasCaption ? proto.caption : nil)
|
||||
}
|
||||
|
||||
func buildProto() -> SNProtoAttachmentPointer? {
|
||||
public func buildProto() -> SNProtoAttachmentPointer? {
|
||||
guard let serverId: UInt64 = UInt64(self.serverId ?? "") else { return nil }
|
||||
|
||||
let builder = SNProtoAttachmentPointer.builder(id: serverId)
|
||||
|
@ -435,14 +477,14 @@ public extension Attachment {
|
|||
|
||||
// MARK: - GRDB Interactions
|
||||
|
||||
public extension Attachment {
|
||||
struct StateInfo: FetchableRecord, Decodable {
|
||||
extension Attachment {
|
||||
public struct StateInfo: FetchableRecord, Decodable {
|
||||
public let attachmentId: String
|
||||
public let interactionId: Int64
|
||||
public let state: Attachment.State
|
||||
}
|
||||
|
||||
static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest<Attachment.StateInfo> {
|
||||
public static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest<Attachment.StateInfo> {
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let quote: TypedTableAlias<Quote> = TypedTableAlias()
|
||||
|
@ -487,7 +529,7 @@ public extension Attachment {
|
|||
"""
|
||||
}
|
||||
|
||||
static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest<Attachment.StateInfo> {
|
||||
public static func stateInfo(interactionId: Int64, state: State? = nil) -> SQLRequest<Attachment.StateInfo> {
|
||||
let attachment: TypedTableAlias<Attachment> = TypedTableAlias()
|
||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||
let quote: TypedTableAlias<Quote> = TypedTableAlias()
|
||||
|
@ -533,7 +575,7 @@ public extension Attachment {
|
|||
|
||||
// MARK: - Convenience - Static
|
||||
|
||||
public extension Attachment {
|
||||
extension Attachment {
|
||||
private static let thumbnailDimensionSmall: UInt = 200
|
||||
private static let thumbnailDimensionMedium: UInt = 450
|
||||
|
||||
|
@ -588,7 +630,7 @@ public extension Attachment {
|
|||
return NSData.imageSize(forFilePath: originalFilePath, mimeType: contentType)
|
||||
}
|
||||
|
||||
static func videoStillImage(filePath: String) -> UIImage? {
|
||||
public static func videoStillImage(filePath: String) -> UIImage? {
|
||||
return try? OWSMediaUtils.thumbnail(
|
||||
forVideoAtPath: filePath,
|
||||
maxDimension: CGFloat(Attachment.thumbnailDimensionLarge)
|
||||
|
|
|
@ -631,57 +631,19 @@ public extension Interaction {
|
|||
let sourceFilename: String?
|
||||
}
|
||||
|
||||
var targetBody: String? = self.body
|
||||
|
||||
if self.body == nil || self.body?.isEmpty == true {
|
||||
let maybeTextInfo: AttachmentDescriptionInfo? = try? AttachmentDescriptionInfo
|
||||
.fetchOne(
|
||||
db,
|
||||
attachments
|
||||
.select(.id, .state, .variant, .contentType, .sourceFilename)
|
||||
.filter(Attachment.Columns.contentType == OWSMimeTypeOversizeTextMessage)
|
||||
.filter(Attachment.Columns.state == Attachment.State.downloaded)
|
||||
)
|
||||
|
||||
if
|
||||
let textInfo: AttachmentDescriptionInfo = maybeTextInfo,
|
||||
let filePath: String = Attachment.originalFilePath(
|
||||
id: textInfo.id,
|
||||
mimeType: textInfo.contentType,
|
||||
sourceFilename: textInfo.sourceFilename
|
||||
),
|
||||
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)),
|
||||
let dataString: String = String(data: data, encoding: .utf8)
|
||||
{
|
||||
targetBody = dataString.filterForDisplay
|
||||
}
|
||||
}
|
||||
|
||||
let attachmentDescription: String? = try? AttachmentDescriptionInfo
|
||||
.fetchOne(
|
||||
db,
|
||||
attachments
|
||||
.select(.id, .variant, .contentType, .sourceFilename)
|
||||
.filter(Attachment.Columns.contentType != OWSMimeTypeOversizeTextMessage)
|
||||
)
|
||||
.map { info -> String in
|
||||
Attachment.description(
|
||||
for: info.variant,
|
||||
contentType: info.contentType,
|
||||
sourceFilename: info.sourceFilename
|
||||
)
|
||||
}
|
||||
let isOpenGroupInvitation: Bool = (try? linkPreview
|
||||
.filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation)
|
||||
.isNotEmpty(db))
|
||||
.defaulting(to: false)
|
||||
|
||||
return Interaction.previewText(
|
||||
variant: self.variant,
|
||||
body: targetBody,
|
||||
attachments: [],
|
||||
customAttachmentDescription: attachmentDescription,
|
||||
isOpenGroupInvitation: isOpenGroupInvitation
|
||||
body: self.body,
|
||||
attachmentDescriptionInfo: try? attachments
|
||||
.select(.variant, .contentType, .sourceFilename)
|
||||
.asRequest(of: Attachment.DescriptionInfo.self)
|
||||
.fetchOne(db),
|
||||
attachmentCount: try? attachments.fetchCount(db),
|
||||
isOpenGroupInvitation: (try? linkPreview
|
||||
.filter(LinkPreview.Columns.variant == LinkPreview.Variant.openGroupInvitation)
|
||||
.isNotEmpty(db))
|
||||
.defaulting(to: false)
|
||||
)
|
||||
|
||||
case .infoMediaSavedNotification, .infoScreenshotNotification:
|
||||
|
@ -690,14 +652,12 @@ public extension Interaction {
|
|||
return Interaction.previewText(
|
||||
variant: self.variant,
|
||||
body: self.body,
|
||||
authorDisplayName: Profile.displayName(db, id: threadId),
|
||||
attachments: []
|
||||
authorDisplayName: Profile.displayName(db, id: threadId)
|
||||
)
|
||||
|
||||
default: return Interaction.previewText(
|
||||
variant: self.variant,
|
||||
body: self.body,
|
||||
attachments: []
|
||||
body: self.body
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -707,50 +667,34 @@ public extension Interaction {
|
|||
variant: Variant,
|
||||
body: String?,
|
||||
authorDisplayName: String = "",
|
||||
attachments: [Attachment],
|
||||
customAttachmentDescription: String? = nil,
|
||||
attachmentDescriptionInfo: Attachment.DescriptionInfo? = nil,
|
||||
attachmentCount: Int? = nil,
|
||||
isOpenGroupInvitation: Bool = false
|
||||
) -> String {
|
||||
switch variant {
|
||||
case .standardIncomingDeleted: return ""
|
||||
|
||||
case .standardIncoming, .standardOutgoing:
|
||||
var bodyDescription: String?
|
||||
let attachmentDescription: String? = (customAttachmentDescription ?? attachments
|
||||
.first(where: { $0.contentType != OWSMimeTypeOversizeTextMessage })?
|
||||
.description
|
||||
let attachmentDescription: String? = Attachment.description(
|
||||
for: attachmentDescriptionInfo,
|
||||
count: attachmentCount
|
||||
)
|
||||
|
||||
if let body: String = body, !body.isEmpty {
|
||||
bodyDescription = body
|
||||
}
|
||||
else if
|
||||
let textAttachment: Attachment = attachments.first(where: { attachment in
|
||||
attachment.state == .downloaded &&
|
||||
attachment.contentType == OWSMimeTypeOversizeTextMessage
|
||||
}),
|
||||
let filePath: String = textAttachment.originalFilePath,
|
||||
let data: Data = try? Data(contentsOf: URL(fileURLWithPath: filePath)),
|
||||
let dataString: String = String(data: data, encoding: .utf8)
|
||||
{
|
||||
bodyDescription = dataString.filterForDisplay
|
||||
}
|
||||
|
||||
if
|
||||
let attachmentDescription: String = attachmentDescription,
|
||||
let bodyDescription: String = bodyDescription,
|
||||
let body: String = body,
|
||||
!attachmentDescription.isEmpty,
|
||||
!bodyDescription.isEmpty
|
||||
!body.isEmpty
|
||||
{
|
||||
if CurrentAppContext().isRTL {
|
||||
return "\(bodyDescription): \(attachmentDescription)"
|
||||
return "\(body): \(attachmentDescription)"
|
||||
}
|
||||
|
||||
return "\(attachmentDescription): \(bodyDescription)"
|
||||
return "\(attachmentDescription): \(body)"
|
||||
}
|
||||
|
||||
if let bodyDescription: String = bodyDescription, !bodyDescription.isEmpty {
|
||||
return bodyDescription
|
||||
if let body: String = body, !body.isEmpty {
|
||||
return body
|
||||
}
|
||||
|
||||
if let attachmentDescription: String = attachmentDescription, !attachmentDescription.isEmpty {
|
||||
|
|
|
@ -13,10 +13,12 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord
|
|||
|
||||
public typealias Columns = CodingKeys
|
||||
public enum CodingKeys: String, CodingKey, ColumnExpression {
|
||||
case albumIndex
|
||||
case interactionId
|
||||
case attachmentId
|
||||
}
|
||||
|
||||
public let albumIndex: Int
|
||||
public let interactionId: Int64
|
||||
public let attachmentId: String
|
||||
|
||||
|
@ -33,9 +35,11 @@ public struct InteractionAttachment: Codable, FetchableRecord, PersistableRecord
|
|||
// MARK: - Initialization
|
||||
|
||||
public init(
|
||||
albumIndex: Int,
|
||||
interactionId: Int64,
|
||||
attachmentId: String
|
||||
) {
|
||||
self.albumIndex = albumIndex
|
||||
self.interactionId = interactionId
|
||||
self.attachmentId = attachmentId
|
||||
}
|
||||
|
|
|
@ -331,9 +331,6 @@ public class SignalAttachment: NSObject {
|
|||
}
|
||||
}
|
||||
}
|
||||
if isOversizeText {
|
||||
return OWSMimeTypeOversizeTextMessage
|
||||
}
|
||||
if dataUTI == kUnknownTestAttachmentUTI {
|
||||
return OWSMimeTypeUnknownForTests
|
||||
}
|
||||
|
@ -375,9 +372,6 @@ public class SignalAttachment: NSObject {
|
|||
return fileExtension.filterFilename()
|
||||
}
|
||||
}
|
||||
if isOversizeText {
|
||||
return kOversizeTextAttachmentFileExtension
|
||||
}
|
||||
if dataUTI == kUnknownTestAttachmentUTI {
|
||||
return "unknown"
|
||||
}
|
||||
|
@ -455,18 +449,11 @@ public class SignalAttachment: NSObject {
|
|||
return SignalAttachment.audioUTISet.contains(dataUTI)
|
||||
}
|
||||
|
||||
@objc
|
||||
public var isOversizeText: Bool {
|
||||
return dataUTI == kOversizeTextAttachmentUTI
|
||||
}
|
||||
|
||||
@objc
|
||||
public var isText: Bool {
|
||||
return (
|
||||
isConvertibleToTextMessage && (
|
||||
UTTypeConformsTo(dataUTI as CFString, kUTTypeText) ||
|
||||
isOversizeText
|
||||
)
|
||||
isConvertibleToTextMessage &&
|
||||
UTTypeConformsTo(dataUTI as CFString, kUTTypeText)
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -1056,20 +1043,6 @@ public class SignalAttachment: NSObject {
|
|||
maxFileSize: kMaxFileSizeAudio)
|
||||
}
|
||||
|
||||
// MARK: Oversize Text Attachments
|
||||
|
||||
// Factory method for oversize text attachments.
|
||||
//
|
||||
// NOTE: The attachment returned by this method may not be valid.
|
||||
// Check the attachment's error property.
|
||||
private class func oversizeTextAttachment(text: String?) -> SignalAttachment {
|
||||
let dataSource = DataSourceValue.dataSource(withOversizeText: text)
|
||||
return newAttachment(dataSource: dataSource,
|
||||
dataUTI: kOversizeTextAttachmentUTI,
|
||||
validUTISet: nil,
|
||||
maxFileSize: kMaxFileSizeGeneric)
|
||||
}
|
||||
|
||||
// MARK: Generic Attachments
|
||||
|
||||
// Factory method for generic attachments.
|
||||
|
|
|
@ -511,11 +511,13 @@ extension MessageReceiver {
|
|||
// they are invalid and we can ignore them
|
||||
return (attachment.downloadUrl != nil ? attachment : nil)
|
||||
}
|
||||
.map { attachment in
|
||||
.enumerated()
|
||||
.map { index, attachment in
|
||||
let savedAttachment: Attachment = try attachment.saved(db)
|
||||
|
||||
// Link the attachment to the interaction and add to the id lookup
|
||||
try InteractionAttachment(
|
||||
albumIndex: index,
|
||||
interactionId: interactionId,
|
||||
attachmentId: savedAttachment.id
|
||||
).insert(db)
|
||||
|
@ -1057,6 +1059,10 @@ extension MessageReceiver {
|
|||
if wasCurrentUserRemoved {
|
||||
ClosedGroupPoller.shared.stopPolling(for: id)
|
||||
|
||||
try closedGroup
|
||||
.allMembers
|
||||
.deleteAll(db)
|
||||
|
||||
_ = try closedGroup
|
||||
.keyPairs
|
||||
.deleteAll(db)
|
||||
|
@ -1067,14 +1073,15 @@ extension MessageReceiver {
|
|||
publicKey: userPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
// Remove the member from the group and it's zombies
|
||||
try closedGroup.members
|
||||
.filter(removedMembers.contains(GroupMember.Columns.profileId))
|
||||
.deleteAll(db)
|
||||
try closedGroup.zombies
|
||||
.filter(removedMembers.contains(GroupMember.Columns.profileId))
|
||||
.deleteAll(db)
|
||||
else {
|
||||
// Remove the member from the group and it's zombies
|
||||
try closedGroup.members
|
||||
.filter(removedMembers.contains(GroupMember.Columns.profileId))
|
||||
.deleteAll(db)
|
||||
try closedGroup.zombies
|
||||
.filter(removedMembers.contains(GroupMember.Columns.profileId))
|
||||
.deleteAll(db)
|
||||
}
|
||||
|
||||
// Notify the user if needed
|
||||
guard members != Set(groupMembers.map { $0.profileId }) else { return }
|
||||
|
|
|
@ -14,7 +14,7 @@ public final class MessageSender {
|
|||
signalAttachments: [SignalAttachment],
|
||||
for interactionId: Int64
|
||||
) throws {
|
||||
try signalAttachments.forEach { signalAttachment in
|
||||
try signalAttachments.enumerated().forEach { index, signalAttachment in
|
||||
let maybeAttachment: Attachment? = Attachment(
|
||||
variant: (signalAttachment.isVoiceMessage ?
|
||||
.voiceMessage :
|
||||
|
@ -29,6 +29,7 @@ public final class MessageSender {
|
|||
guard let attachment: Attachment = maybeAttachment else { return }
|
||||
|
||||
let interactionAttachment: InteractionAttachment = InteractionAttachment(
|
||||
albumIndex: index,
|
||||
interactionId: interactionId,
|
||||
attachmentId: attachment.id
|
||||
)
|
||||
|
|
|
@ -44,70 +44,17 @@ public struct QuotedReplyModel {
|
|||
attachments: [Attachment]?,
|
||||
linkPreview: LinkPreview?
|
||||
) -> QuotedReplyModel? {
|
||||
guard variant == .standardOutgoing || variant == .standardIncoming else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var quotedText: String? = body
|
||||
var quotedAttachment: Attachment? = attachments?.first
|
||||
|
||||
// If the attachment is "oversize text", try the quote as a reply to text, not as
|
||||
// a reply to an attachment
|
||||
if
|
||||
quotedText?.isEmpty == true,
|
||||
let attachment: Attachment = quotedAttachment,
|
||||
attachment.contentType == OWSMimeTypeOversizeTextMessage,
|
||||
(
|
||||
(variant == .standardIncoming && attachment.state == .downloaded) ||
|
||||
attachment.state != .failed
|
||||
),
|
||||
let originalFilePath: String = attachment.originalFilePath
|
||||
{
|
||||
quotedText = ""
|
||||
|
||||
if
|
||||
let textData: Data = try? Data(contentsOf: URL(fileURLWithPath: originalFilePath)),
|
||||
let oversizeText: String = String(data: textData, encoding: .utf8)
|
||||
{
|
||||
// The attachment is going to be sent as text instead
|
||||
quotedAttachment = nil
|
||||
|
||||
// We don't need to include the entire text body of the message, just
|
||||
// enough to render a snippet. kOversizeTextMessageSizeThreshold is our
|
||||
// limit on how long text should be in protos since they'll be stored in
|
||||
// the database. We apply this constant here for the same reasons.
|
||||
//
|
||||
// First, truncate to the rough max characters
|
||||
var truncatedText: String = oversizeText.substring(to: Int(Interaction.oversizeTextMessageSizeThreshold - 1))
|
||||
|
||||
// But kOversizeTextMessageSizeThreshold is in _bytes_, not characters,
|
||||
// so we need to continue to trim the string until it fits.
|
||||
while truncatedText.lengthOfBytes(using: .utf8) >= Interaction.oversizeTextMessageSizeThreshold {
|
||||
// A very coarse binary search by halving is acceptable, since
|
||||
// kOversizeTextMessageSizeThreshold is much longer than our target
|
||||
// length of "three short lines of text on any device we might
|
||||
// display this on.
|
||||
//
|
||||
// The search will always converge since in the worst case (namely
|
||||
// a single character which in utf-8 is >= 1024 bytes) the loop will
|
||||
// exit when the string is empty.
|
||||
truncatedText = truncatedText.substring(to: truncatedText.count / 2)
|
||||
}
|
||||
|
||||
if truncatedText.lengthOfBytes(using: .utf8) < Interaction.oversizeTextMessageSizeThreshold {
|
||||
quotedText = truncatedText
|
||||
}
|
||||
}
|
||||
}
|
||||
guard variant == .standardOutgoing || variant == .standardIncoming else { return nil }
|
||||
guard (body != nil && body?.isEmpty == false) || attachments?.isEmpty == false else { return nil }
|
||||
|
||||
return QuotedReplyModel(
|
||||
threadId: threadId,
|
||||
authorId: authorId,
|
||||
timestampMs: timestampMs,
|
||||
body: (quotedText == nil && quotedAttachment == nil ? "" : quotedText),
|
||||
attachment: quotedAttachment,
|
||||
contentType: quotedAttachment?.contentType,
|
||||
sourceFileName: quotedAttachment?.sourceFilename,
|
||||
body: body,
|
||||
attachment: attachments?.first,
|
||||
contentType: attachments?.first?.contentType,
|
||||
sourceFileName: attachments?.first?.sourceFilename,
|
||||
thumbnailDownloadFailed: false
|
||||
)
|
||||
}
|
||||
|
|
|
@ -14,6 +14,8 @@ public enum GRDBStorageError: Error { // TODO: Rename to `StorageError`
|
|||
case failedToSave
|
||||
case objectNotFound
|
||||
case objectNotSaved
|
||||
|
||||
case invalidSearchPattern
|
||||
}
|
||||
|
||||
// TODO: Protocol for storage (just need to have 'read' and 'write' methods and mock 'Database'?
|
||||
|
|
|
@ -6,3 +6,11 @@ import GRDB
|
|||
public protocol ColumnExpressible {
|
||||
associatedtype Columns: ColumnExpression
|
||||
}
|
||||
|
||||
public extension ColumnExpressible where Columns: CaseIterable {
|
||||
/// Note: Where possible the `TableRecord.numberOfSelectedColumns(_:)` function should be used instead as
|
||||
/// it has proper validation
|
||||
static func numberOfSelectedColumns() -> Int {
|
||||
return Self.Columns.allCases.count
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,4 +15,8 @@ public extension Database {
|
|||
try body(typedDefinition)
|
||||
}
|
||||
}
|
||||
|
||||
public func makeFTS5Pattern<T>(rawPattern: String, forTable table: T.Type) throws -> FTS5Pattern where T: TableRecord, T: ColumnExpressible {
|
||||
return try makeFTS5Pattern(rawPattern: rawPattern, forTable: table.databaseTableName)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,6 +4,8 @@ import Foundation
|
|||
import GRDB
|
||||
|
||||
public extension TableRecord where Self: ColumnExpressible {
|
||||
static var fullTextSearchTableName: String { "\(self.databaseTableName)_fts" }
|
||||
|
||||
static func select(_ selection: Columns...) -> QueryInterfaceRequest<Self> {
|
||||
return all().select(selection)
|
||||
}
|
||||
|
|
|
@ -41,8 +41,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
+ (nullable DataSource *)dataSourceWithData:(NSData *)data utiType:(NSString *)utiType;
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text;
|
||||
|
||||
+ (DataSource *)dataSourceWithSyncMessageData:(NSData *)data;
|
||||
|
||||
+ (DataSource *)emptyDataSource;
|
||||
|
|
|
@ -134,16 +134,6 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
return [self dataSourceWithData:data fileExtension:fileExtension];
|
||||
}
|
||||
|
||||
+ (nullable DataSource *)dataSourceWithOversizeText:(NSString *_Nullable)text
|
||||
{
|
||||
if (!text) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSData *data = [text.filterStringForDisplay dataUsingEncoding:NSUTF8StringEncoding];
|
||||
return [self dataSourceWithData:data fileExtension:kOversizeTextAttachmentFileExtension];
|
||||
}
|
||||
|
||||
+ (DataSource *)dataSourceWithSyncMessageData:(NSData *)data
|
||||
{
|
||||
return [self dataSourceWithData:data fileExtension:kSyncMessageFileExtension];
|
||||
|
|
|
@ -12,7 +12,6 @@ extern NSString *const OWSMimeTypeImageTiff1;
|
|||
extern NSString *const OWSMimeTypeImageTiff2;
|
||||
extern NSString *const OWSMimeTypeImageBmp1;
|
||||
extern NSString *const OWSMimeTypeImageBmp2;
|
||||
extern NSString *const OWSMimeTypeOversizeTextMessage;
|
||||
extern NSString *const OWSMimeTypeUnknownForTests;
|
||||
|
||||
extern NSString *const kOversizeTextAttachmentUTI;
|
||||
|
|
|
@ -19,12 +19,10 @@ NSString *const OWSMimeTypeImageTiff1 = @"image/tiff";
|
|||
NSString *const OWSMimeTypeImageTiff2 = @"image/x-tiff";
|
||||
NSString *const OWSMimeTypeImageBmp1 = @"image/bmp";
|
||||
NSString *const OWSMimeTypeImageBmp2 = @"image/x-windows-bmp";
|
||||
NSString *const OWSMimeTypeOversizeTextMessage = @"text/x-signal-plain";
|
||||
NSString *const OWSMimeTypeUnknownForTests = @"unknown/mimetype";
|
||||
NSString *const OWSMimeTypeApplicationZip = @"application/zip";
|
||||
NSString *const OWSMimeTypeApplicationPdf = @"application/pdf";
|
||||
|
||||
NSString *const kOversizeTextAttachmentUTI = @"org.whispersystems.oversize-text-attachment";
|
||||
NSString *const kOversizeTextAttachmentFileExtension = @"txt";
|
||||
NSString *const kUnknownTestAttachmentUTI = @"org.whispersystems.unknown";
|
||||
NSString *const kSyncMessageFileExtension = @"bin";
|
||||
|
@ -409,12 +407,6 @@ NSString *const kSyncMessageFileExtension = @"bin";
|
|||
return [MIMETypeUtil filePathForAnimated:uniqueId ofMIMEType:contentType inFolder:folder];
|
||||
} else if ([self isBinaryData:contentType]) {
|
||||
return [MIMETypeUtil filePathForBinaryData:uniqueId ofMIMEType:contentType inFolder:folder];
|
||||
} else if ([contentType isEqualToString:OWSMimeTypeOversizeTextMessage]) {
|
||||
// We need to use a ".txt" file extension since this file extension is used
|
||||
// by UIActivityViewController to determine which kinds of sharing are
|
||||
// appropriate for this text.
|
||||
// be used outside the app.
|
||||
return [self filePathForData:uniqueId withFileExtension:@"txt" inFolder:folder];
|
||||
} else if ([contentType isEqualToString:OWSMimeTypeUnknownForTests]) {
|
||||
// This file extension is arbitrary - it should never be exposed to the user or
|
||||
// be used outside the app.
|
||||
|
|
|
@ -146,50 +146,47 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate {
|
|||
}
|
||||
|
||||
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||
let existingText: String = textView.text ?? ""
|
||||
let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text)
|
||||
|
||||
if !FeatureFlags.sendingMediaWithOversizeText {
|
||||
let existingText: String = textView.text ?? ""
|
||||
let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text)
|
||||
// Don't complicate things by mixing media attachments with oversize text attachments
|
||||
guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else {
|
||||
Logger.debug("long text was truncated")
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
|
||||
// Don't complicate things by mixing media attachments with oversize text attachments
|
||||
guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else {
|
||||
Logger.debug("long text was truncated")
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
// Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be
|
||||
// represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is
|
||||
// to just measure the utf8 encoded bytes of the replaced substring.
|
||||
let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count
|
||||
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
// Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be
|
||||
// represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is
|
||||
// to just measure the utf8 encoded bytes of the replaced substring.
|
||||
let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count
|
||||
|
||||
// Accept as much of the input as we can
|
||||
let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete
|
||||
if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) {
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
|
||||
return false
|
||||
// Accept as much of the input as we can
|
||||
let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete
|
||||
if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) {
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
self.lengthLimitLabel.isHidden = true
|
||||
|
||||
// After verifying the byte-length is sufficiently small, verify the character count is within bounds.
|
||||
guard proposedText.count < kMaxCaptionCharacterCount else {
|
||||
Logger.debug("hit attachment message body character count limit")
|
||||
return false
|
||||
}
|
||||
self.lengthLimitLabel.isHidden = true
|
||||
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
// After verifying the byte-length is sufficiently small, verify the character count is within bounds.
|
||||
guard proposedText.count < kMaxCaptionCharacterCount else {
|
||||
Logger.debug("hit attachment message body character count limit")
|
||||
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
|
||||
// Accept as much of the input as we can
|
||||
let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete
|
||||
if charBudget >= 0 {
|
||||
let acceptableNewText = String(text.prefix(charBudget))
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count
|
||||
|
||||
return false
|
||||
// Accept as much of the input as we can
|
||||
let charBudget: Int = Int(kMaxCaptionCharacterCount) - charsAfterDelete
|
||||
if charBudget >= 0 {
|
||||
let acceptableNewText = String(text.prefix(charBudget))
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button
|
||||
|
@ -197,9 +194,9 @@ class AttachmentCaptionToolbar: UIView, UITextViewDelegate {
|
|||
if text == "\n" {
|
||||
attachmentCaptionToolbarDelegate?.attachmentCaptionToolbarDidComplete()
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
|
|
@ -216,50 +216,47 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate {
|
|||
}
|
||||
|
||||
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
||||
let existingText: String = textView.text ?? ""
|
||||
let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text)
|
||||
|
||||
if !FeatureFlags.sendingMediaWithOversizeText {
|
||||
let existingText: String = textView.text ?? ""
|
||||
let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text)
|
||||
// Don't complicate things by mixing media attachments with oversize text attachments
|
||||
guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else {
|
||||
Logger.debug("long text was truncated")
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
|
||||
// Don't complicate things by mixing media attachments with oversize text attachments
|
||||
guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else {
|
||||
Logger.debug("long text was truncated")
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
// Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be
|
||||
// represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is
|
||||
// to just measure the utf8 encoded bytes of the replaced substring.
|
||||
let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count
|
||||
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
// Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be
|
||||
// represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is
|
||||
// to just measure the utf8 encoded bytes of the replaced substring.
|
||||
let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count
|
||||
|
||||
// Accept as much of the input as we can
|
||||
let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete
|
||||
if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) {
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
|
||||
return false
|
||||
// Accept as much of the input as we can
|
||||
let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete
|
||||
if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) {
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
self.lengthLimitLabel.isHidden = true
|
||||
|
||||
// After verifying the byte-length is sufficiently small, verify the character count is within bounds.
|
||||
guard proposedText.count < kMaxMessageBodyCharacterCount else {
|
||||
Logger.debug("hit attachment message body character count limit")
|
||||
return false
|
||||
}
|
||||
self.lengthLimitLabel.isHidden = true
|
||||
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
// After verifying the byte-length is sufficiently small, verify the character count is within bounds.
|
||||
guard proposedText.count < kMaxMessageBodyCharacterCount else {
|
||||
Logger.debug("hit attachment message body character count limit")
|
||||
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count
|
||||
self.lengthLimitLabel.isHidden = false
|
||||
|
||||
// Accept as much of the input as we can
|
||||
let charBudget: Int = Int(kMaxMessageBodyCharacterCount) - charsAfterDelete
|
||||
if charBudget >= 0 {
|
||||
let acceptableNewText = String(text.prefix(charBudget))
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
// `range` represents the section of the existing text we will replace. We can re-use that space.
|
||||
let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count
|
||||
|
||||
return false
|
||||
// Accept as much of the input as we can
|
||||
let charBudget: Int = Int(kMaxMessageBodyCharacterCount) - charsAfterDelete
|
||||
if charBudget >= 0 {
|
||||
let acceptableNewText = String(text.prefix(charBudget))
|
||||
textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button
|
||||
|
@ -267,9 +264,9 @@ class AttachmentTextToolbar: UIView, UITextViewDelegate {
|
|||
if text == "\n" {
|
||||
textView.resignFirstResponder()
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
|
|
|
@ -344,7 +344,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate {
|
|||
|
||||
private func setupViews() {
|
||||
// Plain text will just be put in the 'message' input so do nothing
|
||||
guard !attachment.isText && !attachment.isOversizeText else { return }
|
||||
guard !attachment.isText else { return }
|
||||
|
||||
// Setup the view hierarchy
|
||||
addSubview(stackView)
|
||||
|
@ -411,7 +411,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate {
|
|||
|
||||
private func setupLayout() {
|
||||
// Plain text will just be put in the 'message' input so do nothing
|
||||
guard !attachment.isText && !attachment.isOversizeText else { return }
|
||||
guard !attachment.isText else { return }
|
||||
|
||||
// Sizing calculations
|
||||
let clampedRatio: CGFloat = {
|
||||
|
|
|
@ -14,17 +14,6 @@ public class FeatureFlags: NSObject {
|
|||
return false
|
||||
}
|
||||
|
||||
/// iOS has long supported sending oversized text as a sidecar attachment. The other clients
|
||||
/// simply displayed it as a text attachment. As part of the new cross-client long-text feature,
|
||||
/// we want to be able to display long text with attachments as well. Existing iOS clients
|
||||
/// won't properly display this, so we'll need to wait a while for rollout.
|
||||
/// The stakes aren't __too__ high, because legacy clients won't lose data - they just won't
|
||||
/// see the media attached to a long text message until they update their client.
|
||||
@objc
|
||||
public static var sendingMediaWithOversizeText: Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
@objc
|
||||
public static var useCustomPhotoCapture: Bool {
|
||||
return true
|
||||
|
|
Loading…
Reference in New Issue