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:
Morgan Pretty 2022-05-17 17:47:56 +10:00
parent 5bcc124388
commit a6c7e252a7
31 changed files with 1803 additions and 1156 deletions

View File

@ -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 */,

View File

@ -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)
}

View File

@ -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

View File

@ -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
}
}
}

View File

@ -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()
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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
}
}

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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 {

View File

@ -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
}

View File

@ -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.

View File

@ -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 }

View File

@ -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
)

View File

@ -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
)
}

View File

@ -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'?

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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];

View File

@ -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;

View File

@ -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.

View File

@ -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

View File

@ -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) {

View File

@ -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 = {

View File

@ -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