Merge remote-tracking branch 'upstream/dev' into feature/dev-tweaks

pull/1061/head
Morgan Pretty 1 month ago
commit d031d62240

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

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

@ -161,7 +161,7 @@ extension ContextMenuVC {
}
@objc private func handleTap() {
action.work()
action.work() {}
dismissWithTimerInvalidationIfNeeded()
}

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

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

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

@ -473,6 +473,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
inputTextView.resignFirstResponder()
}
@discardableResult
override func becomeFirstResponder() -> Bool {
inputTextView.becomeFirstResponder()
}

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

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

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

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

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

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

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

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

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

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

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

@ -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..<message.endIndex).count
return min(1.5 + Double(wordCount - 1) * 0.1 , 5)
}()
DispatchQueue.main.asyncAfter(deadline: .now() + duration, execute: task)
}
private func dismissToast() {
@ -61,24 +72,18 @@ public struct ToastView_SwiftUI: View {
VStack(
spacing: 0
) {
ZStack {
Capsule()
.foregroundColor(themeColor: .toast_background)
Text(message)
.font(.system(size: Values.verySmallFontSize))
.foregroundColor(themeColor: .textPrimary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity)
.padding(.horizontal, Values.mediumSpacing)
}
.frame(
width: Self.width,
height: Self.height
)
Text(message)
.font(.system(size: Values.mediumFontSize))
.foregroundColor(themeColor: .textPrimary)
.multilineTextAlignment(.center)
.padding(.horizontal, Values.largeSpacing)
.frame(height: Self.height)
.background(
Capsule()
.foregroundColor(themeColor: .toast_background)
)
}
.frame(
maxWidth: .infinity,
maxHeight: .infinity,
alignment: .bottom
)

@ -178,6 +178,16 @@ public extension Dependencies {
.eraseToAnyPublisher()
}
func hasSet<T: FeatureOption>(feature: FeatureConfig<T>) -> Bool {
return threadSafeChange(for: feature.identifier, of: .feature) {
guard let instance: Feature<T> = getValue(feature.identifier, of: .feature) else {
return false
}
return instance.hasStoredValue(using: self)
}
}
func set<T: FeatureOption>(feature: FeatureConfig<T>, to updatedFeature: T?) {
threadSafeChange(for: feature.identifier, of: .feature) {
/// Update the cached & in-memory values

@ -28,14 +28,6 @@ public extension FeatureStorage {
identifier: "debugDisappearingMessageDurations"
)
static let updatedDisappearingMessages: FeatureConfig<Bool> = Dependencies.create(
identifier: "updatedDisappearingMessages",
automaticChangeBehaviour: Feature<Bool>.ChangeBehaviour(
value: true,
condition: .after(timestamp: 1710284400)
)
)
static let forceSlowDatabaseQueries: FeatureConfig<Bool> = Dependencies.create(
identifier: "forceSlowDatabaseQueries"
)
@ -155,6 +147,10 @@ public struct Feature<T: FeatureOption>: 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

Loading…
Cancel
Save