diff --git a/README.md b/README.md index a95bbb2e2..4af3274d8 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Session integrates directly with [Oxen Service Nodes](https://docs.oxen.io/about ## Want to contribute? Found a bug or have a feature request? -Please search for any [existing issues](https://github.com/loki-project/session-ios/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our dev branch. If you don't know where to start contributing, try reading the Github issues page for ideas. +Please search for any [existing issues](https://github.com/session-foundation/session-ios/issues) that describe your bugs in order to avoid duplicate submissions. Submissions can be made by making a pull request to our dev branch. If you don't know where to start contributing, try reading the Github issues page for ideas. ## Build instructions @@ -18,7 +18,46 @@ Build instructions can be found in [BUILDING.md](BUILDING.md). ## Translations -Want to help us translate Session into your language? You can do so at https://crowdin.com/project/session-ios! +Want to help us translate Session into your language? You can do so at https://getsession.org/translate ! + +## Verifying signatures + +**Step 1:** + +Add Jason's GPG key. Jason Rhinelander, a member of the [Session Technology Foundation](https://session.foundation/) and is the current signer for all Session iOS releases. His GPG key can be found on his GitHub and other sources. + +```sh +wget https://github.com/jagerman.gpg +gpg --import jagerman.gpg +``` + +**Step 2:** + +Get the signed hashes for this release. `SESSION_VERSION` needs to be updated for the release you want to verify. + +```sh +export SESSION_VERSION=2.9.1 +wget https://github.com/session-foundation/session-ios/releases/download/$SESSION_VERSION/signature.asc +``` + +**Step 3:** + +Verify the signature of the hashes of the files. + +```sh +gpg --verify signature.asc 2>&1 |grep "Good signature from" +``` + +The command above should print "`Good signature from "Jason Rhinelander...`". If it does, the hashes are valid but we still have to make the sure the signed hashes match the downloaded files. + +**Step 4:** + +Make sure the two commands below return the same hash for the file you are checking. If they do, file is valid. + +``` +sha256sum session-$SESSION_VERSION.ipa +grep .ipa signature.asc +``` ## License diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index c2e284003..aafdf8808 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -15,11 +15,13 @@ extension ContextMenuVC { struct Action { let icon: UIImage? let title: String + let feedback: String? let expirationInfo: ExpirationInfo? let themeColor: ThemeValue let actionType: ActionType + let shouldDismissInfoScreen: Bool let accessibilityLabel: String? - let work: () -> Void + let work: ((() -> Void)?) -> Void enum ActionType { case emoji @@ -33,17 +35,21 @@ extension ContextMenuVC { init( icon: UIImage? = nil, title: String = "", + feedback: String? = nil, expirationInfo: ExpirationInfo? = nil, themeColor: ThemeValue = .textPrimary, actionType: ActionType = .generic, + shouldDismissInfoScreen: Bool = false, accessibilityLabel: String? = nil, - work: @escaping () -> Void + work: @escaping ((() -> Void)?) -> Void ) { self.icon = icon self.title = title + self.feedback = feedback self.expirationInfo = expirationInfo self.themeColor = themeColor self.actionType = actionType + self.shouldDismissInfoScreen = shouldDismissInfoScreen self.accessibilityLabel = accessibilityLabel self.work = work } @@ -55,7 +61,7 @@ extension ContextMenuVC { icon: UIImage(named: "ic_info"), title: "info".localized(), accessibilityLabel: "Message info" - ) { delegate?.info(cellViewModel) } + ) { _ in delegate?.info(cellViewModel) } } static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -66,31 +72,34 @@ extension ContextMenuVC { "resend".localized() ), accessibilityLabel: (cellViewModel.state == .failedToSync ? "Resync message" : "Resend message") - ) { delegate?.retry(cellViewModel) } + ) { completion in delegate?.retry(cellViewModel, completion: completion) } } static func reply(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_reply"), title: "reply".localized(), + shouldDismissInfoScreen: true, accessibilityLabel: "Reply to message" - ) { delegate?.reply(cellViewModel) } + ) { completion in delegate?.reply(cellViewModel, completion: completion) } } static func copy(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "copy".localized(), + feedback: "copied".localized(), accessibilityLabel: "Copy text" - ) { delegate?.copy(cellViewModel) } + ) { completion in delegate?.copy(cellViewModel, completion: completion) } } static func copySessionID(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_copy"), title: "accountIDCopy".localized(), + feedback: "copied".localized(), accessibilityLabel: "Copy Session ID" - ) { delegate?.copySessionID(cellViewModel) } + ) { completion in delegate?.copySessionID(cellViewModel, completion: completion) } } static func delete(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -102,16 +111,18 @@ extension ContextMenuVC { expiresInSeconds: cellViewModel.expiresInSeconds ), themeColor: .danger, + shouldDismissInfoScreen: true, accessibilityLabel: "Delete message" - ) { delegate?.delete(cellViewModel) } + ) { completion in delegate?.delete(cellViewModel, completion: completion) } } static func save(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(named: "ic_download"), title: "save".localized(), + feedback: "saved".localized(), accessibilityLabel: "Save attachment" - ) { delegate?.save(cellViewModel) } + ) { completion in delegate?.save(cellViewModel, completion: completion) } } static func ban(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -120,7 +131,7 @@ extension ContextMenuVC { title: "banUser".localized(), themeColor: .danger, accessibilityLabel: "Ban user" - ) { delegate?.ban(cellViewModel) } + ) { completion in delegate?.ban(cellViewModel, completion: completion) } } static func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { @@ -128,28 +139,29 @@ extension ContextMenuVC { icon: UIImage(named: "ic_block"), title: "banDeleteAll".localized(), themeColor: .danger, + shouldDismissInfoScreen: true, accessibilityLabel: "Ban user and delete" - ) { delegate?.banAndDeleteAllMessages(cellViewModel) } + ) { completion in delegate?.banAndDeleteAllMessages(cellViewModel, completion: completion) } } static func react(_ cellViewModel: MessageViewModel, _ emoji: EmojiWithSkinTones, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( title: emoji.rawValue, actionType: .emoji - ) { delegate?.react(cellViewModel, with: emoji) } + ) { _ in delegate?.react(cellViewModel, with: emoji) } } static func emojiPlusButton(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( actionType: .emojiPlus, accessibilityLabel: "Add emoji" - ) { delegate?.showFullEmojiKeyboard(cellViewModel) } + ) { _ in delegate?.showFullEmojiKeyboard(cellViewModel) } } static func dismiss(_ delegate: ContextMenuActionDelegate?) -> Action { return Action( actionType: .dismiss - ) { delegate?.contextMenuDismissed() } + ) { _ in delegate?.contextMenuDismissed() } } } @@ -295,14 +307,14 @@ extension ContextMenuVC { protocol ContextMenuActionDelegate { func info(_ cellViewModel: MessageViewModel) - func retry(_ cellViewModel: MessageViewModel) - func reply(_ cellViewModel: MessageViewModel) - func copy(_ cellViewModel: MessageViewModel) - func copySessionID(_ cellViewModel: MessageViewModel) - func delete(_ cellViewModel: MessageViewModel) - func save(_ cellViewModel: MessageViewModel) - func ban(_ cellViewModel: MessageViewModel) - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) + func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) func react(_ cellViewModel: MessageViewModel, with emoji: EmojiWithSkinTones) func showFullEmojiKeyboard(_ cellViewModel: MessageViewModel) func contextMenuDismissed() diff --git a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift index 83678e02e..4bff5f8e6 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+ActionView.swift @@ -161,7 +161,7 @@ extension ContextMenuVC { } @objc private func handleTap() { - action.work() + action.work() {} dismissWithTimerInvalidationIfNeeded() } diff --git a/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift b/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift index 5cedd297f..1a855eafc 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+EmojiReactsView.swift @@ -48,7 +48,7 @@ extension ContextMenuVC { // MARK: - Interaction @objc private func handleTap() { - action.work() + action.work() {} dismiss() } } @@ -106,7 +106,7 @@ extension ContextMenuVC { dismiss() DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: { [weak self] in - self?.action?.work() + self?.action?.work() {} }) } } diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index 6560785fb..a1251f40e 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -415,7 +415,7 @@ final class ContextMenuVC: UIViewController { }, completion: { [weak self] _ in self?.dismiss() - self?.actions.first(where: { $0.actionType == .dismiss })?.work() + self?.actions.first(where: { $0.actionType == .dismiss })?.work(){} } ) } diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index fd8c7968b..a2e24f569 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -1309,7 +1309,7 @@ extension ConversationVC: } func handleReplyButtonTapped(for cellViewModel: MessageViewModel) { - reply(cellViewModel) + reply(cellViewModel, completion: nil) } func startThread( @@ -1883,7 +1883,7 @@ extension ConversationVC: } } - func retry(_ cellViewModel: MessageViewModel) { + func retry(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.id != MessageViewModel.optimisticUpdateId else { guard let optimisticMessageId: UUID = cellViewModel.optimisticMessageId, @@ -1895,7 +1895,10 @@ extension ConversationVC: title: "theError".localized(), body: .text("shareExtensionDatabaseError".localized()), cancelTitle: "okay".localized(), - cancelStyle: .alert_text + cancelStyle: .alert_text, + afterClosed: { + completion?() + } ) ) @@ -1905,6 +1908,7 @@ extension ConversationVC: // Try to send the optimistic message again sendMessage(optimisticData: optimisticMessageData) + completion?() return } @@ -1953,9 +1957,11 @@ extension ConversationVC: using: dependencies ) } + + completion?() } - func reply(_ cellViewModel: MessageViewModel) { + func reply(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { let maybeQuoteDraft: QuotedReplyModel? = QuotedReplyModel.quotedReplyForSending( threadId: self.viewModel.threadData.threadId, authorId: cellViewModel.authorId, @@ -1976,9 +1982,10 @@ extension ConversationVC: isOutgoing: (cellViewModel.variant == .standardOutgoing) ) _ = snInputView.becomeFirstResponder() + completion?() } - func copy(_ cellViewModel: MessageViewModel) { + func copy(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { switch cellViewModel.cellType { case .typingIndicator, .dateHeader, .unreadMarker: break @@ -2006,15 +2013,35 @@ extension ConversationVC: UIPasteboard.general.setData(data, forPasteboardType: type.identifier) } + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "copied".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + + completion?() } - func copySessionID(_ cellViewModel: MessageViewModel) { + func copySessionID(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.variant == .standardIncoming else { return } UIPasteboard.general.string = cellViewModel.authorId + + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "copied".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + + completion?() } - func delete(_ cellViewModel: MessageViewModel) { + func delete(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { /// Retrieve the deletion actions for the selected message(s) of there are any let messagesToDelete: [MessageViewModel] = [cellViewModel] @@ -2098,6 +2125,7 @@ extension ConversationVC: inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) } + completion?() } } ) @@ -2115,7 +2143,7 @@ extension ConversationVC: } } - func save(_ cellViewModel: MessageViewModel) { + func save(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.cellType == .mediaMessage else { return } let mediaAttachments: [(Attachment, String)] = (cellViewModel.attachments ?? []) @@ -2155,7 +2183,15 @@ extension ConversationVC: ) } }, - completionHandler: { _, _ in } + completionHandler: { _, _ in + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(ContextMenuVC.dismissDurationPartOne * 1000))) { [weak self] in + self?.viewModel.showToast( + text: "saved".localized(), + backgroundColor: .toast_background, + inset: Values.largeSpacing + (self?.inputAccessoryView?.frame.height ?? 0) + ) + } + } ) } @@ -2166,9 +2202,11 @@ extension ConversationVC: self?.sendDataExtraction(kind: .mediaSaved(timestamp: UInt64(cellViewModel.timestampMs))) } + + completion?() } - func ban(_ cellViewModel: MessageViewModel) { + func ban(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2201,36 +2239,38 @@ extension ConversationVC: .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in - switch result { - case .finished: - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: self?.viewModel.showToast( text: "banUserBanned".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } - case .failure: - DispatchQueue.main.async { [weak self] in + case .failure: self?.viewModel.showToast( text: "banErrorFailed".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } + } + completion?() } } ) self?.becomeFirstResponder() }, - afterClosed: { [weak self] in self?.becomeFirstResponder() } + afterClosed: { [weak self] in + completion?() + self?.becomeFirstResponder() + } ) ) self.present(modal, animated: true) } - func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel) { + func banAndDeleteAllMessages(_ cellViewModel: MessageViewModel, completion: (() -> Void)?) { guard cellViewModel.threadVariant == .community else { return } let threadId: String = self.viewModel.threadData.threadId @@ -2263,30 +2303,31 @@ extension ConversationVC: .receive(on: DispatchQueue.main, using: dependencies) .sinkUntilComplete( receiveCompletion: { result in - switch result { - case .finished: - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.async { [weak self] in + switch result { + case .finished: self?.viewModel.showToast( text: "banUserBanned".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } - case .failure: - DispatchQueue.main.async { [weak self] in + case .failure: self?.viewModel.showToast( text: "banErrorFailed".localized(), backgroundColor: .backgroundSecondary, inset: (self?.inputAccessoryView?.frame.height ?? Values.mediumSpacing) + Values.smallSpacing ) - } + } + completion?() } } ) self?.becomeFirstResponder() }, - afterClosed: { [weak self] in self?.becomeFirstResponder() } + afterClosed: { [weak self] in + self?.becomeFirstResponder() + } ) ) self.present(modal, animated: true) diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index cf8e1b612..23af8758c 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -473,6 +473,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M inputTextView.resignFirstResponder() } + @discardableResult override func becomeFirstResponder() -> Bool { inputTextView.becomeFirstResponder() } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index ca3a17c52..d49b94ac6 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -668,29 +668,20 @@ class ThreadSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, Ob label: "Block" ), confirmationInfo: ConfirmationModal.Info( - title: { - guard threadViewModel.threadIsBlocked == true else { - return String( - format: "block".localized(), - threadViewModel.displayName - ) - } - - return String( - format: "blockUnblock".localized(), - threadViewModel.displayName - ) - }(), + title: (threadViewModel.threadIsBlocked == true ? + "blockUnblock".localized() : + "block".localized() + ), body: (threadViewModel.threadIsBlocked == true ? .attributedText( "blockUnblockName" .put(key: "name", value: threadViewModel.displayName) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) : .attributedText( "blockDescription" .put(key: "name", value: threadViewModel.displayName) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) ) ), confirmTitle: (threadViewModel.threadIsBlocked == true ? diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 1a09450cd..11e36a725 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -20,7 +20,7 @@ private extension Log.Category { class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource { fileprivate typealias SectionModel = ArraySection - fileprivate struct SearchResultData { + fileprivate struct SearchResultData: Equatable { var state: SearchResultsState var data: [SectionModel] } @@ -50,70 +50,31 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI // MARK: - Variables private let dependencies: Dependencies - private lazy var defaultSearchResults: SearchResultData = { - let nonalphabeticNameTitle: String = "#" // stringlint:ignore - let contacts: [SessionThreadViewModel] = dependencies[singleton: .storage].read { [dependencies] db -> [SessionThreadViewModel]? in + private var defaultSearchResults: SearchResultData = SearchResultData(state: .none, data: []) { + didSet { + guard searchText.isEmpty else { return } + + /// If we have no search term then the contact list should be showing, so update the results and reload the table + self.searchResultSet = defaultSearchResults + + switch Thread.isMainThread { + case true: self.tableView.reloadData() + case false: DispatchQueue.main.async { self.tableView.reloadData() } + } + } + } + private lazy var defaultSearchResultsObservation = ValueObservation + .trackingConstantRegion { [dependencies] db -> [SessionThreadViewModel] in try SessionThreadViewModel .defaultContactsQuery(using: dependencies) .fetchAll(db) } - .defaulting(to: []) - .sorted { - $0.displayName.lowercased() < $1.displayName.lowercased() - } - - var groupedContacts: [String: SectionModel] = [:] - contacts.forEach { contactViewModel in - guard !contactViewModel.threadIsNoteToSelf else { - groupedContacts[""] = SectionModel( - model: .groupedContacts(title: ""), - elements: [contactViewModel] - ) - return - } - - let displayName = NSMutableString(string: contactViewModel.displayName) - CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) - CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) - - let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") - let section: String = initialCharacter.capitalized.isSingleAlphabet ? - initialCharacter.capitalized : - nonalphabeticNameTitle - - if groupedContacts[section] == nil { - groupedContacts[section] = SectionModel( - model: .groupedContacts(title: section), - elements: [] - ) - } - groupedContacts[section]?.elements.append(contactViewModel) - } - - return SearchResultData( - state: .defaultContacts, - data: groupedContacts.values.sorted { sectionModel0, sectionModel1 in - let title0: String = { - switch sectionModel0.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - let title1: String = { - switch sectionModel1.model { - case .groupedContacts(let title): return title - default: return "" - } - }() - - if ![title0, title1].contains(nonalphabeticNameTitle) { - return title0 < title1 - } - - return title1 == nonalphabeticNameTitle - } - ) - }() + .map { GlobalSearchViewController.processDefaultSearchResults($0) } + .removeDuplicates() + .handleEvents(didFail: { Log.error(.cat, "Observation failed with error: \($0)") }) + private var defaultDataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } @ThreadSafeObject private var currentSearchCancellable: AnyCancellable? = nil private lazy var searchResultSet: SearchResultData = defaultSearchResults @@ -186,6 +147,18 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI navigationItem.hidesBackButton = true setupNavigationBar() } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + defaultDataChangeObservable = dependencies[singleton: .storage].start( + defaultSearchResultsObservation, + onError: { _ in }, + onChange: { [weak self] updatedDefaultResults in + self?.defaultSearchResults = updatedDefaultResults + } + ) + } public override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) @@ -195,6 +168,8 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) + self.defaultDataChangeObservable = nil + UIView.performWithoutAnimation { searchBar.resignFirstResponder() } @@ -240,6 +215,64 @@ class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UI } // MARK: - Update Search Results + + private static func processDefaultSearchResults(_ contacts: [SessionThreadViewModel]) -> SearchResultData { + let nonalphabeticNameTitle: String = "#" // stringlint:ignore + + return SearchResultData( + state: .defaultContacts, + data: contacts + .sorted { lhs, rhs in lhs.displayName.lowercased() < rhs.displayName.lowercased() } + .reduce(into: [String: SectionModel]()) { result, next in + guard !next.threadIsNoteToSelf else { + result[""] = SectionModel( + model: .groupedContacts(title: ""), + elements: [next] + ) + return + } + + let displayName = NSMutableString(string: next.displayName) + CFStringTransform(displayName, nil, kCFStringTransformToLatin, false) + CFStringTransform(displayName, nil, kCFStringTransformStripDiacritics, false) + + let initialCharacter: String = (displayName.length > 0 ? displayName.substring(to: 1) : "") + let section: String = (initialCharacter.capitalized.isSingleAlphabet ? + initialCharacter.capitalized : + nonalphabeticNameTitle + ) + + if result[section] == nil { + result[section] = SectionModel( + model: .groupedContacts(title: section), + elements: [] + ) + } + result[section]?.elements.append(next) + } + .values + .sorted { sectionModel0, sectionModel1 in + let title0: String = { + switch sectionModel0.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + let title1: String = { + switch sectionModel1.model { + case .groupedContacts(let title): return title + default: return "" + } + }() + + if ![title0, title1].contains(nonalphabeticNameTitle) { + return title0 < title1 + } + + return title1 == nonalphabeticNameTitle + } + ) + } private func refreshSearchResults() { refreshTimer?.invalidate() @@ -381,6 +414,32 @@ extension GlobalSearchViewController { ) } } + + public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { + let section: SectionModel = self.searchResultSet.data[indexPath.section] + + switch section.model { + case .contactsAndGroups, .messages: return nil + case .groupedContacts: + let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row] + + /// No actions for `Note to Self` + guard !threadViewModel.threadIsNoteToSelf else { return nil } + + return UIContextualAction.configuration( + for: UIContextualAction.generateSwipeActions( + [.block, .deleteContact], + for: .trailing, + indexPath: indexPath, + tableView: tableView, + threadViewModel: threadViewModel, + viewController: self, + navigatableStateHolder: nil, + using: dependencies + ) + ) + } + } private func show( threadId: String, diff --git a/Session/Media Viewing & Editing/MessageInfoScreen.swift b/Session/Media Viewing & Editing/MessageInfoScreen.swift index ef2b6bd58..4f9a09884 100644 --- a/Session/Media Viewing & Editing/MessageInfoScreen.swift +++ b/Session/Media Viewing & Editing/MessageInfoScreen.swift @@ -10,6 +10,7 @@ struct MessageInfoScreen: View { @EnvironmentObject var host: HostWrapper @State var index = 1 + @State var feedbackMessage: String? = nil static private let cornerRadius: CGFloat = 17 @@ -32,6 +33,9 @@ struct MessageInfoScreen: View { messageViewModel: messageViewModel, dependencies: dependencies ) + .clipShape( + RoundedRectangle(cornerRadius: Self.cornerRadius) + ) .background( RoundedRectangle(cornerRadius: Self.cornerRadius) .fill( @@ -50,7 +54,6 @@ struct MessageInfoScreen: View { .padding(.bottom, Values.verySmallSpacing) .padding(.horizontal, Values.largeSpacing) - if isMessageFailed { let (image, statusText, tintColor) = messageViewModel.state.statusIconInfo( variant: messageViewModel.variant, @@ -309,8 +312,17 @@ struct MessageInfoScreen: View { let tintColor: ThemeValue = actions[index].themeColor Button( action: { - actions[index].work() - dismiss() + actions[index].work() { + switch (actions[index].shouldDismissInfoScreen, actions[index].feedback) { + case (false, _): break + case (true, .some): + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: { + dismiss() + }) + default: dismiss() + } + } + feedbackMessage = actions[index].feedback }, label: { HStack(spacing: Values.largeSpacing) { @@ -353,6 +365,7 @@ struct MessageInfoScreen: View { } } .backgroundColor(themeColor: .backgroundPrimary) + .toastView(message: $feedbackMessage) } private func showMediaFullScreen(attachment: Attachment) { diff --git a/Session/Meta/BUILDING.md b/Session/Meta/BUILDING.md deleted file mode 100644 index 4e3571979..000000000 --- a/Session/Meta/BUILDING.md +++ /dev/null @@ -1,91 +0,0 @@ -# Building - -We typically develop against the latest stable version of Xcode. - -As of this writing, that's Xcode 12.4 - -## Prerequistes - -Install [CocoaPods](https://guides.cocoapods.org/using/getting-started.html). - -## 1. Clone - -Clone the repo to a working directory: - -``` -git clone https://github.com/oxen-io/session-ios.git -``` - -**Recommendation:** - -We recommend you fork the repo on GitHub, then clone your fork: - -``` -git clone https://github.com//session-ios.git -``` - -You can then add the Session repo to sync with upstream changes: - -``` -git remote add upstream https://github.com/oxen-io/session-ios -``` - -## 2. Submodules - -Session requires a number of submodules to build, these can be retrieved by navigating to the project directory and running: - -``` -git submodule update --init --recursive -``` - -## 3. libSession build dependencies - -The iOS project has a share C++ library called `libSession` which is built as one of the project dependencies, in order for this to compile the following dependencies need to be installed: -- cmake -- m4 -- pkg-config - -These can be installed with Homebrew via `brew install cmake m4 pkg-config` - -Additionally `xcode-select` needs to be setup correctly (depending on the order of installation it can point to the wrong directory and result in a build error similar to `tool '{name}' requires Xcode, but active developer directory '/Library/Developer/CommandLineTools' is a command line tools instance`), this can be setup correctly by running: - -`sudo xcode-select -s /Applications/Xcode.app/Contents/Developer` - -## 4. Xcode - -Open the `Session.xcodeproj` in Xcode. - -``` -open Session.xcodeproj -``` - -In the TARGETS area of the General tab, change the Team dropdown to -your own. You will need to do that for all the listed targets, e.g. -Session, SessionShareExtension, and SessionNotificationServiceExtension. You -will need an Apple Developer account for this. - -On the Capabilities tab, turn off Push Notifications and Data Protection, -while keeping Background Modes on. The App Groups capability will need to -remain on in order to access the shared data storage. - -Build and Run and you are ready to go! - -## Known issues - -### Address & Undefined Behaviour Sanitizer Linker Errors -It seems that there is an open issue with Swift Package Manager (https://github.com/swiftlang/swift-package-manager/issues/4407) where some packages (in our case `libwebp`) run into issues when the Address Sanitizer or Undefined Behaviour Sanitizer are enabled within the scheme, if you see linker errors like the below when building this is likely the issue and can be resolved by disabling these sanitisers. - -In order to still benefit from these settings they are explicitly set as `Other C Flags` for the `SessionUtil` target when building in debug mode to enable better debugging of `libSession`. -``` -Undefined symbol: ___asan_init -Undefined symbol: ___ubsan_handle_add_overflow -``` - -### Third-party Installation -The database for the app is stored within an `App Group` directory which is based on the app identifier, unfortunately the identifier cannot be retrieved at runtime so it's currently hard-coded in the code. In order to be able to run session on a device you will need to update the `UserDefaults.applicationGroup` variable in `SessionUtilitiesKit/General/SNUserDefaults` to match the value provided (You may also need to create the `App Group` on your Apple Developer account). - -### Push Notifications -Features related to push notifications are known to be not working for -third-party contributors since Apple's Push Notification service pushes -will only work with the Session production code signing -certificate. diff --git a/Session/Meta/Translations/Localizable.xcstrings b/Session/Meta/Translations/Localizable.xcstrings index 2c548ec67..5471ad081 100644 --- a/Session/Meta/Translations/Localizable.xcstrings +++ b/Session/Meta/Translations/Localizable.xcstrings @@ -74979,12 +74979,6 @@ "value" : "Permeteu telefonades de veu i de vídeo a i des d'altres usuaris." } }, - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zapne hlasové a video hovory k ostatním uživatelům i od nich." - } - }, "cy" : { "stringUnit" : { "state" : "translated", @@ -233331,12 +233325,6 @@ "legacyGroupBeforeDeprecationAdmin" : { "extractionState" : "manual", "localizations" : { - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skupiny byly aktualizovány! Znovu vytvořte tuto skupinu pro zvýšení spolehlivosti. Tato skupina se stane pouze pro čtení dne {date}." - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -233354,12 +233342,6 @@ "legacyGroupBeforeDeprecationMember" : { "extractionState" : "manual", "localizations" : { - "cs" : { - "stringUnit" : { - "state" : "translated", - "value" : "Skupiny byly aktualizovány! Požádejte správce skupiny o znovuvytvoření této skupiny, aby se zvýšila její spolehlivost. Tato skupina se stane pouze pro čtení dne {date}." - } - }, "en" : { "stringUnit" : { "state" : "translated", @@ -326696,6 +326678,17 @@ } } }, + "permissionsCameraChangeDescriptionIos" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Camera access is currently enabled. To disable it, toggle the \"Camera\" permission in Settings." + } + } + } + }, "permissionsCameraDenied" : { "extractionState" : "manual", "localizations" : { @@ -329130,7 +329123,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Local Network access is currently enabled. To disable it, toggle the “Local Network” permission in Settings." + "value" : "Local Network access is currently enabled. To disable it, toggle the \"Local Network\" permission in Settings." } } } @@ -331084,6 +331077,17 @@ } } }, + "permissionsMicrophoneChangeDescriptionIos" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microphone access is currently enabled. To disable it, toggle the \"Microphone\" permission in Settings." + } + } + } + }, "permissionsMicrophoneDescription" : { "extractionState" : "manual", "localizations" : { diff --git a/Session/Settings/DeveloperSettingsViewModel.swift b/Session/Settings/DeveloperSettingsViewModel.swift index 2c33abf6e..c289f7f37 100644 --- a/Session/Settings/DeveloperSettingsViewModel.swift +++ b/Session/Settings/DeveloperSettingsViewModel.swift @@ -75,7 +75,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case resetSnodeCache case pushNotificationService - case updatedDisappearingMessages case debugDisappearingMessageDurations case updatedGroups @@ -114,7 +113,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .resetSnodeCache: return "resetSnodeCache" case .pushNotificationService: return "pushNotificationService" - case .updatedDisappearingMessages: return "updatedDisappearingMessages" case .debugDisappearingMessageDurations: return "debugDisappearingMessageDurations" case .updatedGroups: return "updatedGroups" @@ -156,7 +154,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .resetSnodeCache: result.append(.resetSnodeCache); fallthrough case .pushNotificationService: result.append(.pushNotificationService); fallthrough - case .updatedDisappearingMessages: result.append(.updatedDisappearingMessages); fallthrough case .debugDisappearingMessageDurations: result.append(.debugDisappearingMessageDurations); fallthrough case .updatedGroups: result.append(.updatedGroups); fallthrough @@ -199,7 +196,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, let pushNotificationService: PushNotificationAPI.Service let debugDisappearingMessageDurations: Bool - let updatedDisappearingMessages: Bool let updatedGroups: Bool let legacyGroupsDeprecated: Bool @@ -234,7 +230,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, pushNotificationService: dependencies[feature: .pushNotificationService], debugDisappearingMessageDurations: dependencies[feature: .debugDisappearingMessageDurations], - updatedDisappearingMessages: dependencies[feature: .updatedDisappearingMessages], updatedGroups: dependencies[feature: .updatedGroups], legacyGroupsDeprecated: dependencies[feature: .legacyGroupsDeprecated], @@ -494,23 +489,6 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, to: !current.debugDisappearingMessageDurations ) } - ), - SessionCell.Info( - id: .updatedDisappearingMessages, - title: "Use Updated Disappearing Messages", - subtitle: """ - Controls whether legacy or updated disappearing messages should be used. - """, - trailingAccessory: .toggle( - current.updatedDisappearingMessages, - oldValue: previous?.updatedDisappearingMessages - ), - onTap: { [weak self] in - self?.updateFlag( - for: .updatedDisappearingMessages, - to: !current.updatedDisappearingMessages - ) - } ) ] ) @@ -804,8 +782,15 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, TableItem.allCases.forEach { item in switch item { case .developerMode: break // Not a feature - case .animationsEnabled: updateFlag(for: .animationsEnabled, to: nil) - case .showStringKeys: updateFlag(for: .showStringKeys, to: nil) + case .animationsEnabled: + guard dependencies.hasSet(feature: .animationsEnabled) else { return } + + updateFlag(for: .animationsEnabled, to: nil) + + case .showStringKeys: + guard dependencies.hasSet(feature: .showStringKeys) else { return } + + updateFlag(for: .showStringKeys, to: nil) case .resetSnodeCache: break // Not a feature case .copyDatabasePath: break // Not a feature @@ -813,32 +798,92 @@ class DeveloperSettingsViewModel: SessionTableViewModel, NavigatableStateHolder, case .importDatabase: break // Not a feature case .advancedLogging: break // Not a feature - case .defaultLogLevel: updateDefaulLogLevel(to: nil) - case .loggingCategory: resetLoggingCategories() + case .defaultLogLevel: updateDefaulLogLevel(to: nil) // Always reset + case .loggingCategory: resetLoggingCategories() // Always reset - case .serviceNetwork: updateServiceNetwork(to: nil) - case .forceOffline: updateFlag(for: .forceOffline, to: nil) - case .pushNotificationService: updatePushNotificationService(to: nil) + case .serviceNetwork: + guard dependencies.hasSet(feature: .serviceNetwork) else { return } + + updateServiceNetwork(to: nil) + + case .forceOffline: + guard dependencies.hasSet(feature: .forceOffline) else { return } + + updateFlag(for: .forceOffline, to: nil) + + case .pushNotificationService: + guard dependencies.hasSet(feature: .pushNotificationService) else { return } + + updatePushNotificationService(to: nil) case .debugDisappearingMessageDurations: + guard dependencies.hasSet(feature: .debugDisappearingMessageDurations) else { return } + updateFlag(for: .debugDisappearingMessageDurations, to: nil) - case .updatedDisappearingMessages: updateFlag(for: .updatedDisappearingMessages, to: nil) + + case .updatedGroups: + guard dependencies.hasSet(feature: .updatedGroups) else { return } + + updateFlag(for: .updatedGroups, to: nil) + + case .legacyGroupsDeprecated: + guard dependencies.hasSet(feature: .legacyGroupsDeprecated) else { return } - case .updatedGroups: updateFlag(for: .updatedGroups, to: nil) - case .legacyGroupsDeprecated: updateLegacyGroupsDeprecated(to: nil) - case .updatedGroupsDisableAutoApprove: updateFlag(for: .updatedGroupsDisableAutoApprove, to: nil) - case .updatedGroupsRemoveMessagesOnKick: updateFlag(for: .updatedGroupsRemoveMessagesOnKick, to: nil) + updateLegacyGroupsDeprecated(to: nil) + + case .updatedGroupsDisableAutoApprove: + guard dependencies.hasSet(feature: .updatedGroupsDisableAutoApprove) else { return } + + updateFlag(for: .updatedGroupsDisableAutoApprove, to: nil) + + case .updatedGroupsRemoveMessagesOnKick: + guard dependencies.hasSet(feature: .updatedGroupsRemoveMessagesOnKick) else { return } + + updateFlag(for: .updatedGroupsRemoveMessagesOnKick, to: nil) + case .updatedGroupsAllowHistoricAccessOnInvite: + guard dependencies.hasSet(feature: .updatedGroupsAllowHistoricAccessOnInvite) else { + return + } + updateFlag(for: .updatedGroupsAllowHistoricAccessOnInvite, to: nil) - case .updatedGroupsAllowDisplayPicture: updateFlag(for: .updatedGroupsAllowDisplayPicture, to: nil) + + case .updatedGroupsAllowDisplayPicture: + guard dependencies.hasSet(feature: .updatedGroupsAllowDisplayPicture) else { return } + + updateFlag(for: .updatedGroupsAllowDisplayPicture, to: nil) + case .updatedGroupsAllowDescriptionEditing: + guard dependencies.hasSet(feature: .updatedGroupsAllowDescriptionEditing) else { return } + updateFlag(for: .updatedGroupsAllowDescriptionEditing, to: nil) - case .updatedGroupsAllowPromotions: updateFlag(for: .updatedGroupsAllowPromotions, to: nil) - case .updatedGroupsAllowInviteById: updateFlag(for: .updatedGroupsAllowInviteById, to: nil) - case .updatedGroupsDeleteBeforeNow: updateFlag(for: .updatedGroupsDeleteBeforeNow, to: nil) - case .updatedGroupsDeleteAttachmentsBeforeNow: updateFlag(for: .updatedGroupsDeleteAttachmentsBeforeNow, to: nil) - case .forceSlowDatabaseQueries: updateFlag(for: .forceSlowDatabaseQueries, to: nil) + case .updatedGroupsAllowPromotions: + guard dependencies.hasSet(feature: .updatedGroupsAllowPromotions) else { return } + + updateFlag(for: .updatedGroupsAllowPromotions, to: nil) + + case .updatedGroupsAllowInviteById: + guard dependencies.hasSet(feature: .updatedGroupsAllowInviteById) else { return } + + updateFlag(for: .updatedGroupsAllowInviteById, to: nil) + + case .updatedGroupsDeleteBeforeNow: + guard dependencies.hasSet(feature: .updatedGroupsDeleteBeforeNow) else { return } + + updateFlag(for: .updatedGroupsDeleteBeforeNow, to: nil) + + case .updatedGroupsDeleteAttachmentsBeforeNow: + guard dependencies.hasSet(feature: .updatedGroupsDeleteAttachmentsBeforeNow) else { + return + } + + updateFlag(for: .updatedGroupsDeleteAttachmentsBeforeNow, to: nil) + + case .forceSlowDatabaseQueries: + guard dependencies.hasSet(feature: .forceSlowDatabaseQueries) else { return } + + updateFlag(for: .forceSlowDatabaseQueries, to: nil) } } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 52255ebe0..dc9b721c7 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -13,7 +13,13 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC // MARK: - UI - private let accentLineView: UIView = UIView() + private let accentLineView: UIView = { + let result: UIView = UIView() + result.themeBackgroundColor = .conversationButton_unreadStripBackground + result.alpha = 0 + + return result + }() private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list) @@ -430,15 +436,7 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC self.themeBackgroundColor = themeBackgroundColor self.selectedBackgroundView?.themeBackgroundColor = .highlighted(themeBackgroundColor) - if cellViewModel.threadIsBlocked == true { - accentLineView.themeBackgroundColor = .danger - accentLineView.alpha = 1 - } - else { - accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground - accentLineView.alpha = (unreadCount > 0 ? 1 : 0) - } - + accentLineView.alpha = (unreadCount > 0 ? 1 : 0) isPinnedIcon.isHidden = (cellViewModel.threadPinnedPriority == 0) unreadCountView.isHidden = (unreadCount <= 0) unreadImageView.isHidden = (!unreadCountView.isHidden || !threadIsUnread) @@ -530,7 +528,6 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC public func optimisticUpdate( isMuted: Bool?, - isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool? ) { @@ -557,17 +554,6 @@ public final class FullConversationCell: UITableViewCell, SwipeActionOptimisticC } } - if let isBlocked: Bool = isBlocked { - if isBlocked { - accentLineView.themeBackgroundColor = .danger - accentLineView.alpha = 1 - } - else { - accentLineView.themeBackgroundColor = .conversationButton_unreadStripBackground - accentLineView.alpha = (!unreadCountView.isHidden || !unreadImageView.isHidden ? 1 : 0) - } - } - if let isPinned: Bool = isPinned { isPinnedIcon.isHidden = !isPinned } diff --git a/Session/Utilities/MentionUtilities.swift b/Session/Utilities/MentionUtilities.swift index 950076462..193d6a8c5 100644 --- a/Session/Utilities/MentionUtilities.swift +++ b/Session/Utilities/MentionUtilities.swift @@ -115,7 +115,12 @@ public enum MentionUtilities { result.addAttribute(.currentUserMentionBackgroundCornerRadius, value: (8 * sizeDiff), range: mention.range) result.addAttribute(.currentUserMentionBackgroundPadding, value: (3 * sizeDiff), range: mention.range) result.addAttribute(.currentUserMentionBackgroundColor, value: primaryColor.color, range: mention.range) - result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: mention.range.upperBound, length: 1)) + + // Only add the additional kern if the mention isn't at the end of the string (otherwise this + // would crash due to an index out of bounds exception) + if mention.range.upperBound < result.length { + result.addAttribute(.kern, value: (3 * sizeDiff), range: NSRange(location: mention.range.upperBound, length: 1)) + } } switch (location, mention.isCurrentUser, theme.interfaceStyle) { diff --git a/Session/Utilities/UIContextualAction+Utilities.swift b/Session/Utilities/UIContextualAction+Utilities.swift index 3265399c4..e094e5c89 100644 --- a/Session/Utilities/UIContextualAction+Utilities.swift +++ b/Session/Utilities/UIContextualAction+Utilities.swift @@ -8,24 +8,20 @@ import SessionUIKit import SessionUtilitiesKit protocol SwipeActionOptimisticCell { - func optimisticUpdate(isMuted: Bool?, isBlocked: Bool?, isPinned: Bool?, hasUnread: Bool?) + func optimisticUpdate(isMuted: Bool?, isPinned: Bool?, hasUnread: Bool?) } extension SwipeActionOptimisticCell { public func optimisticUpdate(isMuted: Bool) { - optimisticUpdate(isMuted: isMuted, isBlocked: nil, isPinned: nil, hasUnread: nil) - } - - public func optimisticUpdate(isBlocked: Bool) { - optimisticUpdate(isMuted: nil, isBlocked: isBlocked, isPinned: nil, hasUnread: nil) + optimisticUpdate(isMuted: isMuted, isPinned: nil, hasUnread: nil) } public func optimisticUpdate(isPinned: Bool) { - optimisticUpdate(isMuted: nil, isBlocked: nil, isPinned: isPinned, hasUnread: nil) + optimisticUpdate(isMuted: nil, isPinned: isPinned, hasUnread: nil) } public func optimisticUpdate(hasUnread: Bool) { - optimisticUpdate(isMuted: nil, isBlocked: nil, isPinned: nil, hasUnread: hasUnread) + optimisticUpdate(isMuted: nil, isPinned: nil, hasUnread: hasUnread) } } @@ -38,6 +34,7 @@ public extension UIContextualAction { case block case leave case delete + case deleteContact case clear } @@ -370,102 +367,110 @@ public extension UIContextualAction { (!threadIsContactMessageRequest ? nil : Contact.Columns.didApproveMe.set(to: true)), (!threadIsContactMessageRequest ? nil : Contact.Columns.isApproved.set(to: false)) ].compactMap { $0 } + let nameToUse: String = { + switch threadViewModel.threadVariant { + case .group: + return Profile.displayName( + for: .contact, + id: profileInfo.id, + name: profileInfo.profile?.name, + nickname: profileInfo.profile?.nickname, + suppressId: false + ) + + default: return threadViewModel.displayName + } + }() - let performBlock: (UIViewController?) -> () = { viewController in - (tableView.cellForRow(at: indexPath) as? SwipeActionOptimisticCell)? - .optimisticUpdate( - isBlocked: !threadIsBlocked - ) - completionHandler(true) - - // Delay the change to give the cell "unswipe" animation some time to complete - DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { - dependencies[singleton: .storage] - .writePublisher { db in - // Create the contact if it doesn't exist - switch threadViewModel.threadVariant { - case .contact: - try Contact - .fetchOrCreate(db, id: threadViewModel.threadId, using: dependencies) - .upsert(db) - try Contact - .filter(id: threadViewModel.threadId) - .updateAllAndConfig( - db, - contactChanges, - using: dependencies - ) + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: (threadIsBlocked ? + "blockUnblock".localized() : + "block".localized() + ), + body: (threadIsBlocked ? + .attributedText( + "blockUnblockName" + .put(key: "name", value: nameToUse) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) + ) : + .attributedText( + "blockDescription" + .put(key: "name", value: nameToUse) + .localizedFormatted(baseFont: ConfirmationModal.explanationFont) + ) + ), + confirmTitle: (threadIsBlocked ? + "blockUnblock".localized() : + "block".localized() + ), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + completionHandler(true) + + // Delay the change to give the cell "unswipe" animation some time to complete + DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) { + dependencies[singleton: .storage] + .writePublisher { db in + // Create the contact if it doesn't exist + switch threadViewModel.threadVariant { + case .contact: + try Contact + .fetchOrCreate( + db, + id: threadViewModel.threadId, + using: dependencies + ) + .upsert(db) + try Contact + .filter(id: threadViewModel.threadId) + .updateAllAndConfig( + db, + contactChanges, + using: dependencies + ) + + case .group: + try Contact + .fetchOrCreate( + db, + id: profileInfo.id, + using: dependencies + ) + .upsert(db) + try Contact + .filter(id: profileInfo.id) + .updateAllAndConfig( + db, + contactChanges, + using: dependencies + ) + + default: break + } - case .group: - try Contact - .fetchOrCreate(db, id: profileInfo.id, using: dependencies) - .upsert(db) - try Contact - .filter(id: profileInfo.id) - .updateAllAndConfig( + // Blocked message requests should be deleted + if threadViewModel.threadIsMessageRequest == true { + try SessionThread.deleteOrLeave( db, - contactChanges, + type: .deleteContactConversationAndMarkHidden, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, using: dependencies ) - - default: break - } - - // Blocked message requests should be deleted - if threadViewModel.threadIsMessageRequest == true { - try SessionThread.deleteOrLeave( - db, - type: .deleteContactConversationAndMarkHidden, - threadId: threadViewModel.threadId, - threadVariant: threadViewModel.threadVariant, - using: dependencies - ) - } - } - .subscribe(on: DispatchQueue.global(qos: .userInitiated)) - .sinkUntilComplete() - } - } - - switch threadViewModel.threadIsMessageRequest == true { - case false: performBlock(nil) - case true: - let nameToUse: String = { - switch threadViewModel.threadVariant { - case .group: - return Profile.displayName( - for: .contact, - id: profileInfo.id, - name: profileInfo.profile?.name, - nickname: profileInfo.profile?.nickname, - suppressId: false - ) - - default: return threadViewModel.displayName + } + } + .subscribe(on: DispatchQueue.global(qos: .userInitiated)) + .sinkUntilComplete() } - }() - - let confirmationModal: ConfirmationModal = ConfirmationModal( - info: ConfirmationModal.Info( - title: "block".localized(), - body: .attributedText( - "blockDescription" - .put(key: "name", value: nameToUse) - .localizedFormatted(baseFont: .systemFont(ofSize: Values.smallFontSize)) - ), - confirmTitle: "block".localized(), - confirmStyle: .danger, - cancelStyle: .alert_text, - dismissOnConfirm: true, - onConfirm: { _ in - performBlock(viewController) - }, - afterClosed: { completionHandler(false) } - ) - ) - - viewController?.present(confirmationModal, animated: true, completion: nil) - } + }, + afterClosed: { completionHandler(false) } + ) + ) + + viewController?.present(confirmationModal, animated: true, completion: nil) } // MARK: -- leave @@ -671,6 +676,52 @@ public extension UIContextualAction { ) ) + viewController?.present(confirmationModal, animated: true, completion: nil) + } + + // MARK: -- deleteContact + + case .deleteContact: + return UIContextualAction( + title: "contactDelete".localized(), + icon: Lucide.image(icon: .trash2, size: 24, color: .white), + themeTintColor: .white, + themeBackgroundColor: themeBackgroundColor, + accessibility: Accessibility(identifier: "Delete button"), + side: side, + actionIndex: targetIndex, + indexPath: indexPath, + tableView: tableView + ) { [weak viewController] _, _, completionHandler in + let confirmationModal: ConfirmationModal = ConfirmationModal( + info: ConfirmationModal.Info( + title: "contactDelete".localized(), + body: .attributedText( + "contactDeleteDescription" + .put(key: "name", value: threadViewModel.displayName) + .localizedFormatted(baseFont: .boldSystemFont(ofSize: Values.smallFontSize)) + ), + confirmTitle: "delete".localized(), + confirmStyle: .danger, + cancelStyle: .alert_text, + dismissOnConfirm: true, + onConfirm: { _ in + dependencies[singleton: .storage].writeAsync { db in + try SessionThread.deleteOrLeave( + db, + type: .deleteContactConversationAndContact, + threadId: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + using: dependencies + ) + } + + completionHandler(true) + }, + afterClosed: { completionHandler(false) } + ) + ) + viewController?.present(confirmationModal, animated: true, completion: nil) } } diff --git a/SessionMessagingKit/Database/Models/SessionThread.swift b/SessionMessagingKit/Database/Models/SessionThread.swift index 1878106a9..9e0e19ebf 100644 --- a/SessionMessagingKit/Database/Models/SessionThread.swift +++ b/SessionMessagingKit/Database/Models/SessionThread.swift @@ -534,9 +534,18 @@ public extension SessionThread { try LibSession.hide(db, contactIds: Array(remainingThreadIds), using: dependencies) case .deleteContactConversationAndContact: - // Remove the contact from the config + // Remove the contact from the config (also need to clear the nickname since that's + // custom data for this contact) try LibSession.remove(db, contactIds: Array(remainingThreadIds), using: dependencies) + _ = try Profile + .filter(ids: remainingThreadIds) + .updateAll(db, Profile.Columns.nickname.set(to: nil)) + + _ = try Contact + .filter(ids: remainingThreadIds) + .deleteAll(db) + _ = try SessionThread .filter(ids: remainingThreadIds) .deleteAll(db) diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift index 0c4120a87..065b52a25 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+MessageRequests.swift @@ -131,6 +131,12 @@ extension MessageReceiver { } // Update the `didApproveMe` state of the sender + let senderHadAlreadyApprovedMe: Bool = (try? Contact + .select(.didApproveMe) + .filter(id: senderId) + .asRequest(of: Bool.self) + .fetchOne(db)) + .defaulting(to: false) try updateContactApprovalStatusIfNeeded( db, senderSessionId: senderId, @@ -154,23 +160,29 @@ extension MessageReceiver { ) } - // Notify the user of their approval (Note: This will always appear in the un-blinded thread) - // - // Note: We want to do this last as it'll mean the un-blinded thread gets updated and the - // contact approval status will have been updated at this point (which will mean the - // `isMessageRequest` will return correctly after this is saved) - _ = try Interaction( - serverHash: message.serverHash, - threadId: unblindedThread.id, - threadVariant: unblindedThread.variant, - authorId: senderId, - variant: .infoMessageRequestAccepted, - timestampMs: ( - message.sentTimestampMs.map { Int64($0) } ?? - dependencies[cache: .snodeAPI].currentOffsetTimestampMs() - ), - using: dependencies - ).inserted(db) + /// Notify the user of their approval + /// + /// We want to do this last as it'll mean the un-blinded thread gets updated and the contact approval status will have been + /// updated at this point (which will mean the `isMessageRequest` will return correctly after this is saved) + /// + /// **Notes:** + /// - We only want to add the control message if the sender hadn't already approved the current user (this is to prevent spam + /// if the sender deletes and re-accepts message requests from the current user) + /// - This will always appear in the un-blinded thread + if !senderHadAlreadyApprovedMe { + _ = try Interaction( + serverHash: message.serverHash, + threadId: unblindedThread.id, + threadVariant: unblindedThread.variant, + authorId: senderId, + variant: .infoMessageRequestAccepted, + timestampMs: ( + message.sentTimestampMs.map { Int64($0) } ?? + dependencies[cache: .snodeAPI].currentOffsetTimestampMs() + ), + using: dependencies + ).inserted(db) + } } internal static func updateContactApprovalStatusIfNeeded( diff --git a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift index 5e15c5757..ea74efebc 100644 --- a/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift +++ b/SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+Groups.swift @@ -71,7 +71,8 @@ extension MessageSender { try createdInfo.group.insert(db) try createdInfo.members.forEach { try $0.insert(db) } - /// Add a record of the initial invites going out + /// Add a record of the initial invites going out (default to being read as we don't want the creator of the group + /// to see the "Unread Messages" banner above this control message) _ = try? Interaction( threadId: createdInfo.group.id, threadVariant: .group, @@ -88,6 +89,7 @@ extension MessageSender { ) .infoString(using: dependencies), timestampMs: Int64(createdInfo.group.formationTimestamp * 1000), + wasRead: true, using: dependencies ).inserted(db) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 0e7f4615a..99e96e924 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -2085,6 +2085,7 @@ public extension SessionThreadViewModel { FROM \(Contact.self) LEFT JOIN \(thread) ON \(thread[.id]) = \(contact[.id]) LEFT JOIN \(contactProfile) ON \(contactProfile[.id]) = \(contact[.id]) + WHERE \(contact[.isBlocked]) = false """ // Add adapters which will group the various 'Profile' columns so they can be decoded diff --git a/SessionUIKit/Components/SwiftUI/Toast.swift b/SessionUIKit/Components/SwiftUI/Toast.swift index 715a99b50..a9aea9810 100644 --- a/SessionUIKit/Components/SwiftUI/Toast.swift +++ b/SessionUIKit/Components/SwiftUI/Toast.swift @@ -2,6 +2,7 @@ import SwiftUI import Combine +import NaturalLanguage public struct ToastModifier: ViewModifier { @Binding var message: String? @@ -34,7 +35,17 @@ public struct ToastModifier: ViewModifier { } workItem = task - DispatchQueue.main.asyncAfter(deadline: .now() + 5, execute: task) + + let duration: TimeInterval = { + guard let message: String = message else { return 1.5 } + + let tokenizer = NLTokenizer(unit: .word) + tokenizer.string = message + let wordCount = tokenizer.tokens(for: message.startIndex..(feature: FeatureConfig) -> Bool { + return threadSafeChange(for: feature.identifier, of: .feature) { + guard let instance: Feature = getValue(feature.identifier, of: .feature) else { + return false + } + + return instance.hasStoredValue(using: self) + } + } + func set(feature: FeatureConfig, to updatedFeature: T?) { threadSafeChange(for: feature.identifier, of: .feature) { /// Update the cached & in-memory values diff --git a/SessionUtilitiesKit/General/Feature.swift b/SessionUtilitiesKit/General/Feature.swift index 02ed03420..9545e1e3a 100644 --- a/SessionUtilitiesKit/General/Feature.swift +++ b/SessionUtilitiesKit/General/Feature.swift @@ -28,14 +28,6 @@ public extension FeatureStorage { identifier: "debugDisappearingMessageDurations" ) - static let updatedDisappearingMessages: FeatureConfig = Dependencies.create( - identifier: "updatedDisappearingMessages", - automaticChangeBehaviour: Feature.ChangeBehaviour( - value: true, - condition: .after(timestamp: 1710284400) - ) - ) - static let forceSlowDatabaseQueries: FeatureConfig = Dependencies.create( identifier: "forceSlowDatabaseQueries" ) @@ -155,6 +147,10 @@ public struct Feature: FeatureType { // MARK: - Functions + internal func hasStoredValue(using dependencies: Dependencies) -> Bool { + return (dependencies[defaults: .appGroup].object(forKey: identifier) != nil) + } + internal func currentValue(using dependencies: Dependencies) -> T { let maybeSelectedOption: T? = { // `Int` defaults to `0` and `Bool` defaults to `false` so rather than those (in case we want