Merge pull request #386 from mpretty-cyro/feature/contact-block-and-delete

Added block and delete functionality to contact list
pull/1061/head
Morgan Pretty 3 days ago committed by GitHub
commit 9d45fb429e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -669,29 +669,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 ?

@ -20,7 +20,7 @@ private extension Log.Category {
class GlobalSearchViewController: BaseVC, LibSessionRespondingViewController, UITableViewDelegate, UITableViewDataSource {
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
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,

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

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

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

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

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

Loading…
Cancel
Save