Merge remote-tracking branch 'upstream/dev' into feature/job-runner-unit-tests

# Conflicts:
#	Session.xcodeproj/project.pbxproj
#	Session/Meta/Session-Prefix.pch
#	Session/Notifications/SyncPushTokensJob.swift
#	Session/Utilities/BackgroundPoller.swift
#	SessionMessagingKit/Configuration.swift
#	SessionMessagingKit/Database/Models/Profile.swift
#	SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift
#	SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift
#	SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift
#	SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift
#	SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift
#	SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift
#	SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift
#	SessionMessagingKit/Jobs/Types/MessageSendJob.swift
#	SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift
#	SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift
#	SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift
#	SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift
#	SessionMessagingKit/Sending & Receiving/MessageSender.swift
#	SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift
#	SessionMessagingKit/Utilities/AppReadiness.m
#	SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift
#	SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift
#	SessionShareExtension/ShareNavController.swift
#	SessionSnodeKit/Jobs/GetSnodePoolJob.swift
#	SessionUtilitiesKit/Configuration.swift
#	SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift
#	SessionUtilitiesKit/JobRunner/JobRunner.swift
#	SignalUtilitiesKit/Meta/SignalUtilitiesKit.h
#	SignalUtilitiesKit/Utilities/SSKAsserts.h
This commit is contained in:
Morgan Pretty 2023-07-18 10:02:51 +10:00
commit e768bebe6d
599 changed files with 37046 additions and 20575 deletions

134
.drone.jsonnet Normal file
View File

@ -0,0 +1,134 @@
// Intentionally doing a depth of 2 as libSession-util has it's own submodules (and libLokinet likely will as well)
local clone_submodules = {
name: 'Clone Submodules',
commands: ['git fetch --tags', 'git submodule update --init --recursive --depth=2']
};
// cmake options for static deps mirror
local ci_dep_mirror(want_mirror) = (if want_mirror then ' -DLOCAL_MIRROR=https://oxen.rocks/deps ' else '');
// Cocoapods
//
// Unfortunately Cocoapods has a dumb restriction which requires you to use UTF-8 for the
// 'LANG' env var so we need to work around the with https://github.com/CocoaPods/CocoaPods/issues/6333
local install_cocoapods = {
name: 'Install CocoaPods',
commands: ['LANG=en_US.UTF-8 pod install']
};
// Load from the cached CocoaPods directory (to speed up the build)
local load_cocoapods_cache = {
name: 'Load CocoaPods Cache',
commands: [
|||
while test -e /Users/drone/.cocoapods_cache.lock; do
sleep 1
done
|||,
'touch /Users/drone/.cocoapods_cache.lock',
|||
if [[ -d /Users/drone/.cocoapods_cache ]]; then
cp -r /Users/drone/.cocoapods_cache ./Pods
fi
|||,
'rm /Users/drone/.cocoapods_cache.lock'
]
};
// Override the cached CocoaPods directory (to speed up the next build)
local update_cocoapods_cache = {
name: 'Update CocoaPods Cache',
commands: [
|||
while test -e /Users/drone/.cocoapods_cache.lock; do
sleep 1
done
|||,
'touch /Users/drone/.cocoapods_cache.lock',
|||
if [[ -d ./Pods ]]; then
rm -rf /Users/drone/.cocoapods_cache
cp -r ./Pods /Users/drone/.cocoapods_cache
fi
|||,
'rm /Users/drone/.cocoapods_cache.lock'
]
};
[
// Unit tests
{
kind: 'pipeline',
type: 'exec',
name: 'Unit Tests',
platform: { os: 'darwin', arch: 'amd64' },
steps: [
clone_submodules,
load_cocoapods_cache,
install_cocoapods,
{
name: 'Run Unit Tests',
commands: [
'mkdir build',
'NSUnbufferedIO=YES set -o pipefail && xcodebuild test -workspace Session.xcworkspace -scheme Session -destination "platform=iOS Simulator,name=iPhone 14" -destination "platform=iOS Simulator,name=iPhone 14 Pro Max" -parallel-testing-enabled YES -test-timeouts-enabled YES -maximum-test-execution-time-allowance 2 -collect-test-diagnostics never 2>&1 | ./Pods/xcbeautify/xcbeautify --is-ci --report junit --report-path ./build/reports --junit-report-filename junit2.xml'
],
},
update_cocoapods_cache
],
},
// Simulator build
{
kind: 'pipeline',
type: 'exec',
name: 'Simulator Build',
platform: { os: 'darwin', arch: 'amd64' },
steps: [
clone_submodules,
load_cocoapods_cache,
install_cocoapods,
{
name: 'Build',
commands: [
'mkdir build',
'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphonesimulator -archivePath ./build/Session_sim.xcarchive -destination "generic/platform=iOS Simulator" | ./Pods/xcbeautify/xcbeautify --is-ci'
],
},
update_cocoapods_cache,
{
name: 'Upload artifacts',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' } },
commands: [
'./Scripts/drone-static-upload.sh'
]
},
],
},
// AppStore build (generate an archive to be signed later)
{
kind: 'pipeline',
type: 'exec',
name: 'AppStore Build',
platform: { os: 'darwin', arch: 'amd64' },
steps: [
clone_submodules,
load_cocoapods_cache,
install_cocoapods,
{
name: 'Build',
commands: [
'mkdir build',
'xcodebuild archive -workspace Session.xcworkspace -scheme Session -configuration "App Store Release" -sdk iphoneos -archivePath ./build/Session.xcarchive -destination "generic/platform=iOS" -allowProvisioningUpdates'
],
},
update_cocoapods_cache,
{
name: 'Upload artifacts',
environment: { SSH_KEY: { from_secret: 'SSH_KEY' } },
commands: [
'./Scripts/drone-static-upload.sh'
]
},
],
},
]

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "LibSession-Util"]
path = LibSession-Util
url = https://github.com/oxen-io/libsession-util.git

1
LibSession-Util Submodule

@ -0,0 +1 @@
Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2

50
Podfile
View File

@ -1,30 +1,31 @@
platform :ios, '13.0'
source 'https://github.com/CocoaPods/Specs.git'
use_frameworks!
inhibit_all_warnings!
install! 'cocoapods', :warn_for_unused_master_specs_repo => false
# CI Dependencies
pod 'xcbeautify'
# Dependencies to be included in the app and all extensions/frameworks
abstract_target 'GlobalDependencies' do
pod 'PromiseKit'
pod 'CryptoSwift'
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
pod 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
pod 'GRDB.swift/SQLCipher'
# FIXME: Would be nice to migrate from CocoaPods to SwiftPackageManager (should allow us to speed up build time), haven't gone through all of the dependencies but currently unfortunately SQLCipher doesn't support SPM (for more info see: https://github.com/sqlcipher/sqlcipher/issues/371)
pod 'SQLCipher', '~> 4.5.3'
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
pod 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
pod 'WebRTC-lib'
pod 'SocketRocket', '~> 0.5.1'
target 'Session' do
pod 'AFNetworking'
pod 'Reachability'
pod 'PureLayout', '~> 3.1.8'
pod 'NVActivityIndicatorView'
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
pod 'ZXingObjC'
pod 'DifferenceKit'
target 'SessionTests' do
@ -45,7 +46,6 @@ abstract_target 'GlobalDependencies' do
# Dependencies that are shared across a number of extensions/frameworks but not all
abstract_target 'ExtendedDependencies' do
pod 'AFNetworking'
pod 'PureLayout', '~> 3.1.8'
target 'SessionShareExtension' do
@ -97,28 +97,13 @@ abstract_target 'GlobalDependencies' do
target 'SessionUIKit' do
pod 'GRDB.swift/SQLCipher'
pod 'DifferenceKit'
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
end
end
# Actions to perform post-install
post_install do |installer|
enable_whole_module_optimization_for_crypto_swift(installer)
set_minimum_deployment_target(installer)
enable_fts5_support(installer)
#FIXME: Remove this workaround once an official fix is released (hopefully Cocoapods 1.12.1)
xcode_14_3_workaround(installer)
end
def enable_whole_module_optimization_for_crypto_swift(installer)
installer.pods_project.targets.each do |target|
if target.name.end_with? "CryptoSwift"
target.build_configurations.each do |config|
config.build_settings['GCC_OPTIMIZATION_LEVEL'] = 'fast'
config.build_settings['SWIFT_OPTIMIZATION_LEVEL'] = '-O'
end
end
end
end
def set_minimum_deployment_target(installer)
@ -128,22 +113,3 @@ def set_minimum_deployment_target(installer)
end
end
end
# This is to ensure we enable support for FastTextSearch5 (might not be enabled by default)
# For more info see https://github.com/groue/GRDB.swift/blob/master/Documentation/FullTextSearch.md#enabling-fts5-support
def enable_fts5_support(installer)
installer.pods_project.targets.select { |target| target.name == "GRDB.swift" }.each do |target|
target.build_configurations.each do |config|
config.build_settings['OTHER_SWIFT_FLAGS'] = "$(inherited) -D SQLITE_ENABLE_FTS5"
end
end
end
# Workaround for Xcode 14.3:
# Sourced from https://github.com/flutter/flutter/issues/123852#issuecomment-1493232105
def xcode_14_3_workaround(installer)
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\'')
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks.sh\'')
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\'')
system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\'')
end

View File

@ -1,23 +1,7 @@
PODS:
- AFNetworking (4.0.1):
- AFNetworking/NSURLSession (= 4.0.1)
- AFNetworking/Reachability (= 4.0.1)
- AFNetworking/Security (= 4.0.1)
- AFNetworking/Serialization (= 4.0.1)
- AFNetworking/UIKit (= 4.0.1)
- AFNetworking/NSURLSession (4.0.1):
- AFNetworking/Reachability
- AFNetworking/Security
- AFNetworking/Serialization
- AFNetworking/Reachability (4.0.1)
- AFNetworking/Security (4.0.1)
- AFNetworking/Serialization (4.0.1)
- AFNetworking/UIKit (4.0.1):
- AFNetworking/NSURLSession
- CocoaLumberjack (3.8.0):
- CocoaLumberjack/Core (= 3.8.0)
- CocoaLumberjack/Core (3.8.0)
- CryptoSwift (1.4.2)
- Curve25519Kit (2.1.0):
- CocoaLumberjack
- SignalCoreKit
@ -43,15 +27,6 @@ PODS:
- NVActivityIndicatorView/Base (= 5.1.1)
- NVActivityIndicatorView/Base (5.1.1)
- OpenSSL-Universal (1.1.1300)
- PromiseKit (6.15.3):
- PromiseKit/CorePromise (= 6.15.3)
- PromiseKit/Foundation (= 6.15.3)
- PromiseKit/UIKit (= 6.15.3)
- PromiseKit/CorePromise (6.15.3)
- PromiseKit/Foundation (6.15.3):
- PromiseKit/CorePromise
- PromiseKit/UIKit (6.15.3):
- PromiseKit/CorePromise
- PureLayout (3.1.9)
- Quick (5.0.1)
- Reachability (3.2)
@ -59,7 +34,6 @@ PODS:
- SignalCoreKit (1.0.0):
- CocoaLumberjack
- OpenSSL-Universal
- SocketRocket (0.5.1)
- Sodium (0.9.1)
- SQLCipher (4.5.3):
- SQLCipher/standard (= 4.5.3)
@ -67,7 +41,8 @@ PODS:
- SQLCipher/standard (4.5.3):
- SQLCipher/common
- SwiftProtobuf (1.5.0)
- WebRTC-lib (96.0.0)
- WebRTC-lib (114.0.0)
- xcbeautify (0.17.0)
- YapDatabase/SQLCipher (3.1.1):
- YapDatabase/SQLCipher/Core (= 3.1.1)
- YapDatabase/SQLCipher/Extensions (= 3.1.1)
@ -134,54 +109,44 @@ PODS:
- YYImage/libwebp (1.0.4):
- libwebp
- YYImage/Core
- ZXingObjC (3.6.5):
- ZXingObjC/All (= 3.6.5)
- ZXingObjC/All (3.6.5)
DEPENDENCIES:
- AFNetworking
- CryptoSwift
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
- DifferenceKit
- GRDB.swift/SQLCipher
- Nimble
- NVActivityIndicatorView
- PromiseKit
- PureLayout (~> 3.1.8)
- Quick
- Reachability
- SAMKeychain
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
- SocketRocket (~> 0.5.1)
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
- SQLCipher (~> 4.5.3)
- SwiftProtobuf (~> 1.5.0)
- WebRTC-lib
- xcbeautify
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
- YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
- ZXingObjC
SPEC REPOS:
https://github.com/CocoaPods/Specs.git:
- AFNetworking
- CocoaLumberjack
- CryptoSwift
- DifferenceKit
- GRDB.swift
- libwebp
- Nimble
- NVActivityIndicatorView
- OpenSSL-Universal
- PromiseKit
- PureLayout
- Quick
- Reachability
- SAMKeychain
- SocketRocket
- SQLCipher
- SwiftProtobuf
- WebRTC-lib
- ZXingObjC
trunk:
- xcbeautify
EXTERNAL SOURCES:
Curve25519Kit:
@ -217,9 +182,7 @@ CHECKOUT OPTIONS:
:git: https://github.com/signalapp/YYImage
SPEC CHECKSUMS:
AFNetworking: 3bd23d814e976cd148d7d44c3ab78017b744cd58
CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca
GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78
@ -227,21 +190,19 @@ SPEC CHECKSUMS:
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
SQLCipher: 57fa9f863fa4a3ed9dd3c90ace52315db8c0fdca
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
WebRTC-lib: d83df8976fa608b980f1d85796b3de66d60a1953
xcbeautify: 6e2f57af5c3a86d490376d5758030a8dcc201c1b
YapDatabase: b418a4baa6906e8028748938f9159807fd039af4
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
PODFILE CHECKSUM: e9443a8235dbff1fc342aa9bf08bbc66923adf68
PODFILE CHECKSUM: dd814a5a92577bb2a94dac6a1cc482f193721cdf
COCOAPODS: 1.11.3
COCOAPODS: 1.12.1

View File

@ -0,0 +1,20 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import CryptoKit
let arguments = CommandLine.arguments
// First argument is the file name
if arguments.count == 3 {
let encryptedData = Data(base64Encoded: arguments[1].data(using: .utf8)!)!
let hash: SHA256.Digest = SHA256.hash(data: arguments[2].data(using: .utf8)!)
let key: SymmetricKey = SymmetricKey(data: Data(hash.makeIterator()))
let sealedBox = try! ChaChaPoly.SealedBox(combined: encryptedData)
let decryptedData = try! ChaChaPoly.open(sealedBox, using: key)
print(Array(decryptedData).map { String(format: "%02x", $0) }.joined())
}
else {
print("Please provide the base64 encoded 'encrypted key' and plain text 'password' as arguments")
}

View File

@ -1,11 +1,6 @@
#!/usr/bin/xcrun --sdk macosx swift
//
// ListLocalizableStrings.swift
// Archa
//
// Created by Morgan Pretty on 18/5/20.
// Copyright © 2020 Archa. All rights reserved.
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
//
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
// is canges to the localized usage regex
@ -19,35 +14,46 @@ let currentPath = (
/// List of files in currentPath - recursive
var pathFiles: [String] = {
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
fatalError("Could not locate files in path directory: \(currentPath)")
}
guard
let enumerator: FileManager.DirectoryEnumerator = fileManager.enumerator(
at: URL(fileURLWithPath: currentPath),
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
),
let fileUrls: [URL] = enumerator.allObjects as? [URL]
else { fatalError("Could not locate files in path directory: \(currentPath)") }
return files
return fileUrls
.filter {
((try? $0.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == false) && // No directories
!$0.path.contains("build/") && // Exclude files under the build folder (CI)
!$0.path.contains("Pods/") && // Exclude files under the pods folder
!$0.path.contains(".xcassets") && // Exclude asset bundles
!$0.path.contains(".app/") && // Exclude files in the app build directories
!$0.path.contains(".appex/") && // Exclude files in the extension build directories
!$0.path.localizedCaseInsensitiveContains("tests/") && // Exclude files under test directories
!$0.path.localizedCaseInsensitiveContains("external/") && ( // Exclude files under external directories
// Only include relevant files
$0.path.hasSuffix("Localizable.strings") ||
NSString(string: $0.path).pathExtension == "swift" ||
NSString(string: $0.path).pathExtension == "m"
)
}
.map { $0.path }
}()
/// List of localizable files - not including Localizable files in the Pods
var localizableFiles: [String] = {
return pathFiles
.filter {
$0.hasSuffix("Localizable.strings") &&
!$0.contains(".app/") && // Exclude Built Localizable.strings files
!$0.contains("Pods") // Exclude Pods
}
return pathFiles.filter { $0.hasSuffix("Localizable.strings") }
}()
/// List of executable files
var executableFiles: [String] = {
return pathFiles.filter {
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
!$0.contains(".app/") && // Exclude Built Localizable.strings files
!$0.contains("Pods") && // Exclude Pods
(
NSString(string: $0).pathExtension == "swift" ||
NSString(string: $0).pathExtension == "m"
)
$0.hasSuffix(".swift") ||
$0.hasSuffix(".m")
}
}()
@ -56,7 +62,6 @@ var executableFiles: [String] = {
/// - Parameter path: path of file
/// - Returns: content in file
func contents(atPath path: String) -> String {
print("Path: \(path)")
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
fatalError("Could not read from path: \(path)")
}
@ -109,8 +114,6 @@ func localizedStringsInCode() -> [LocalizationCodeFile] {
///
/// - Parameter files: list of localizable files to validate
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
print("------------ Validating keys match in all localizable files ------------")
guard let base = files.first, files.count > 1 else { return }
let files = Array(files.dropFirst())
@ -128,8 +131,6 @@ func validateMatchKeys(_ files: [LocalizationStringsFile]) {
/// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for missing keys -----------")
guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file")
}
@ -150,8 +151,6 @@ func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles:
/// - codeFiles: Array of LocalizationCodeFile
/// - localizationFiles: Array of LocalizableStringFiles
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
print("------------ Checking for any dead keys in localizable file -----------")
guard let baseFile = localizationFiles.first else {
fatalError("Could not locate base localization file")
}
@ -174,14 +173,18 @@ protocol Pathable {
struct LocalizationStringsFile: Pathable {
let path: String
let kv: [String: String]
let duplicates: [(key: String, path: String)]
var keys: [String] {
return Array(kv.keys)
}
init(path: String) {
let result = ContentParser.parse(path)
self.path = path
self.kv = ContentParser.parse(path)
self.kv = result.kv
self.duplicates = result.duplicates
}
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
@ -204,9 +207,7 @@ struct ContentParser {
///
/// - Parameter path: Localizable file paths
/// - Returns: localizable key and value for content at path
static func parse(_ path: String) -> [String: String] {
print("------------ Checking for duplicate keys: \(path) ------------")
static func parse(_ path: String) -> (kv: [String: String], duplicates: [(key: String, path: String)]) {
let content = contents(atPath: path)
let trimmed = content
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
@ -218,13 +219,18 @@ struct ContentParser {
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
}
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
if results[keyValue.0] != nil {
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
abort()
var duplicates: [(key: String, path: String)] = []
let kv: [String: String] = zip(keys, values)
.reduce(into: [:]) { results, keyValue in
guard results[keyValue.0] == nil else {
duplicates.append((keyValue.0, path))
return
}
results[keyValue.0] = keyValue.1
}
results[keyValue.0] = keyValue.1
}
return (kv, duplicates)
}
}
@ -232,20 +238,27 @@ func printPretty(_ string: String) {
print(string.replacingOccurrences(of: "\\", with: ""))
}
let stringFiles = create()
// MARK: - Processing
let stringFiles: [LocalizationStringsFile] = create()
if !stringFiles.isEmpty {
print("------------ Found \(stringFiles.count) file(s) ------------")
print("------------ Found \(stringFiles.count) file(s) - checking for duplicate, extra, missing and dead keys ------------")
stringFiles.forEach { file in
file.duplicates.forEach { key, path in
printPretty("error: Found duplicate key: \(key) in file: \(path)")
}
}
stringFiles.forEach { print($0.path) }
validateMatchKeys(stringFiles)
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
// stringFiles.forEach { $0.cleanWrite() }
let codeFiles = localizedStringsInCode()
let codeFiles: [LocalizationCodeFile] = localizedStringsInCode()
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
}
print("------------ SUCCESS ------------")
print("------------ Complete ------------")

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import os
@ -202,10 +202,18 @@ class BaseContext(object):
return 'UInt32'
elif field.proto_type == 'fixed64':
return 'UInt64'
elif field.proto_type == 'int64':
return 'Int64'
elif field.proto_type == 'int32':
return 'Int32'
elif field.proto_type == 'bool':
return 'Bool'
elif field.proto_type == 'bytes':
return 'Data'
elif field.proto_type == 'double':
return 'Double'
elif field.proto_type == 'float':
return 'Float'
else:
matching_context = self.context_for_proto_type(field)
if matching_context is not None:
@ -236,7 +244,11 @@ class BaseContext(object):
return field.proto_type in ('uint64',
'uint32',
'fixed64',
'bool', )
'int64',
'int32',
'bool',
'double',
'float', )
def can_field_be_optional(self, field):
if self.is_field_primitive(field):
@ -288,8 +300,16 @@ class BaseContext(object):
return '0'
elif field.proto_type == 'fixed64':
return '0'
elif field.proto_type == 'int64':
return '0'
elif field.proto_type == 'int32':
return '0'
elif field.proto_type == 'bool':
return 'false'
elif field.proto_type == 'double':
return '0'
elif field.proto_type == 'float':
return '0'
elif self.is_field_an_enum(field):
# TODO: Assert that rules is empty.
enum_context = self.context_for_proto_type(field)

261
Scripts/build_libSession_util.sh Executable file
View File

@ -0,0 +1,261 @@
#!/bin/bash
# XCode will error during it's dependency graph construction (which happens before the build
# stage starts and any target "Run Script" phases are triggered)
#
# In order to avoid this error we need to build the framework before actually getting to the
# build stage so XCode is able to build the dependency graph
#
# XCode's Pre-action scripts don't output anything into XCode so the only way to emit a useful
# error is to **return a success status** and have the project detect and log the error itself
# then log it, stopping the build at that point
#
# The other step to get this to work properly is to ensure the framework in "Link Binary with
# Libraries" isn't using a relative directory, unfortunately there doesn't seem to be a good
# way to do this directly so we need to modify the '.pbxproj' file directly, updating the
# framework entry to have the following (on a single line):
# {
# isa = PBXFileReference;
# explicitFileType = wrapper.xcframework;
# includeInIndex = 0;
# path = "{FRAMEWORK NAME GOES HERE}";
# sourceTree = BUILD_DIR;
# };
#
# Note: We might one day be able to replace this with a local podspec if this GitHub feature
# request ever gets implemented: https://github.com/CocoaPods/CocoaPods/issues/8464
# Need to set the path or we won't find cmake
PATH=${PATH}:/usr/local/bin:/opt/homebrew/bin:/sbin/md5
exec 3>&1 # Save original stdout
# Ensure the build directory exists (in case we need it before XCode creates it)
mkdir -p "${TARGET_BUILD_DIR}/libSessionUtil"
# Remove any old build errors
rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log"
# Restore stdout and stderr and redirect it to the 'libsession_util_output.log' file
exec &> "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log"
# Define a function to echo a message.
function echo_message() {
exec 1>&3 # Restore stdout
echo "$1"
exec >> "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" # Redirect all output to the log file
}
echo_message "info: Validating build requirements"
set -x
# Ensure the build directory exists (in case we need it before XCode creates it)
mkdir -p "${TARGET_BUILD_DIR}"
if ! which cmake > /dev/null; then
echo_message "error: cmake is required to build, please install (can install via homebrew with 'brew install cmake')."
exit 0
fi
# Check if we have the `LibSession-Util` submodule checked out and if not (depending on the 'SHOULD_AUTO_INIT_SUBMODULES' argument) perform the checkout
if [ ! -d "${SRCROOT}/LibSession-Util" ] || [ ! -d "${SRCROOT}/LibSession-Util/src" ] || [ ! "$(ls -A "${SRCROOT}/LibSession-Util")" ]; then
echo_message "error: Need to fetch LibSession-Util submodule (git submodule update --init --recursive)."
exit 0
else
are_submodules_valid() {
local PARENT_PATH=$1
local RELATIVE_PATH=$2
# Change into the path to check for it's submodules
cd "${PARENT_PATH}"
local SUB_MODULE_PATHS=($(git config --file .gitmodules --get-regexp path | awk '{ print $2 }'))
# If there are no submodules then return success based on whether the folder has any content
if [ ${#SUB_MODULE_PATHS[@]} -eq 0 ]; then
if [[ ! -z "$(ls -A "${PARENT_PATH}")" ]]; then
return 0
else
return 1
fi
fi
# Loop through the child submodules and check if they are valid
for i in "${!SUB_MODULE_PATHS[@]}"; do
local CHILD_PATH="${SUB_MODULE_PATHS[$i]}"
# If the child path doesn't exist then it's invalid
if [ ! -d "${PARENT_PATH}/${CHILD_PATH}" ]; then
echo_message "info: Submodule '${RELATIVE_PATH}/${CHILD_PATH}' doesn't exist."
return 1
fi
are_submodules_valid "${PARENT_PATH}/${CHILD_PATH}" "${RELATIVE_PATH}/${CHILD_PATH}"
local RESULT=$?
if [ "${RESULT}" -eq 1 ]; then
echo_message "info: Submodule '${RELATIVE_PATH}/${CHILD_PATH}' is in an invalid state."
return 1
fi
done
return 0
}
# Validate the state of the submodules
are_submodules_valid "${SRCROOT}/LibSession-Util" "LibSession-Util"
HAS_INVALID_SUBMODULE=$?
if [ "${HAS_INVALID_SUBMODULE}" -eq 1 ]; then
echo_message "error: Submodules are in an invalid state, please delete 'LibSession-Util' and run 'git submodule update --init --recursive'."
exit 0
fi
fi
# Generate a hash of the libSession-util source files and check if they differ from the last hash
echo "info: Checking for changes to source"
NEW_SOURCE_HASH=$(find "${SRCROOT}/LibSession-Util/src" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}')
NEW_HEADER_HASH=$(find "${SRCROOT}/LibSession-Util/include" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}')
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log" ]; then
read -r OLD_SOURCE_HASH < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log"
fi
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log" ]; then
read -r OLD_HEADER_HASH < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log"
fi
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log" ]; then
read -r OLD_ARCHS < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log"
fi
# If all of the hashes match, the archs match and there is a library file then we can just stop here
if [ "${NEW_SOURCE_HASH}" == "${OLD_SOURCE_HASH}" ] && [ "${NEW_HEADER_HASH}" == "${OLD_HEADER_HASH}" ] && [ "${ARCHS[*]}" == "${OLD_ARCHS}" ] && [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" ]; then
echo_message "info: Build is up-to-date"
exit 0
fi
# If any of the above differ then we need to rebuild
echo_message "info: Build is not up-to-date - creating new build"
# Import settings from XCode (defaulting values if not present)
VALID_SIM_ARCHS=(arm64 x86_64)
VALID_DEVICE_ARCHS=(arm64)
VALID_SIM_ARCH_PLATFORMS=(SIMULATORARM64 SIMULATOR64)
VALID_DEVICE_ARCH_PLATFORMS=(OS64)
OUTPUT_DIR="${TARGET_BUILD_DIR}"
IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET}
ENABLE_BITCODE=${ENABLE_BITCODE}
# Generate the target architectures we want to build for
TARGET_ARCHS=()
TARGET_PLATFORMS=()
TARGET_SIM_ARCHS=()
TARGET_DEVICE_ARCHS=()
if [ -z $PLATFORM_NAME ] || [ $PLATFORM_NAME = "iphonesimulator" ]; then
for i in "${!VALID_SIM_ARCHS[@]}"; do
ARCH="${VALID_SIM_ARCHS[$i]}"
ARCH_PLATFORM="${VALID_SIM_ARCH_PLATFORMS[$i]}"
if [[ " ${ARCHS[*]} " =~ " ${ARCH} " ]]; then
TARGET_ARCHS+=("sim-${ARCH}")
TARGET_PLATFORMS+=("${ARCH_PLATFORM}")
TARGET_SIM_ARCHS+=("sim-${ARCH}")
fi
done
fi
if [ -z $PLATFORM_NAME ] || [ $PLATFORM_NAME = "iphoneos" ]; then
for i in "${!VALID_DEVICE_ARCHS[@]}"; do
ARCH="${VALID_DEVICE_ARCHS[$i]}"
ARCH_PLATFORM="${VALID_DEVICE_ARCH_PLATFORMS[$i]}"
if [[ " ${ARCHS[*]} " =~ " ${ARCH} " ]]; then
TARGET_ARCHS+=("ios-${ARCH}")
TARGET_PLATFORMS+=("${ARCH_PLATFORM}")
TARGET_DEVICE_ARCHS+=("ios-${ARCH}")
fi
done
fi
# Build the individual architectures
for i in "${!TARGET_ARCHS[@]}"; do
build="${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_ARCHS[$i]}"
platform="${TARGET_PLATFORMS[$i]}"
echo_message "Building ${TARGET_ARCHS[$i]} for $platform in $build"
cd "${SRCROOT}/LibSession-Util"
./utils/static-bundle.sh "$build" "" \
-DCMAKE_TOOLCHAIN_FILE="${SRCROOT}/LibSession-Util/external/ios-cmake/ios.toolchain.cmake" \
-DPLATFORM=$platform \
-DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \
-DENABLE_BITCODE=$ENABLE_BITCODE
if [ $? -ne 0 ]; then
LAST_OUTPUT=$(tail -n 4 "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" | head -n 1)
echo_message "error: $LAST_OUTPUT"
exit 1
fi
done
# Remove the old static library file
rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/Headers"
# If needed combine simulator builds into a multi-arch lib
if [ "${#TARGET_SIM_ARCHS[@]}" -eq "1" ]; then
# Single device build
cp "${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_SIM_ARCHS[0]}/libsession-util.a" "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
elif [ "${#TARGET_SIM_ARCHS[@]}" -gt "1" ]; then
# Combine multiple device builds into a multi-arch lib
echo_message "info: Built multiple architectures, merging into single static library"
lipo -create "${TARGET_BUILD_DIR}/libSessionUtil"/sim-*/libsession-util.a -output "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
fi
# If needed combine device builds into a multi-arch lib
if [ "${#TARGET_DEVICE_ARCHS[@]}" -eq "1" ]; then
cp "${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_DEVICE_ARCHS[0]}/libsession-util.a" "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
elif [ "${#TARGET_DEVICE_ARCHS[@]}" -gt "1" ]; then
# Combine multiple device builds into a multi-arch lib
echo_message "info: Built multiple architectures, merging into single static library"
lipo -create "${TARGET_BUILD_DIR}/libSessionUtil"/ios-*/libsession-util.a -output "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
fi
# Save the updated hashes to disk to prevent rebuilds when there were no changes
echo "${NEW_SOURCE_HASH}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log"
echo "${NEW_HEADER_HASH}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log"
echo "${ARCHS[*]}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log"
echo_message "info: Build complete"
# Copy the headers across
echo_message "info: Copy headers and prepare modulemap"
mkdir -p "${TARGET_BUILD_DIR}/libSessionUtil/Headers"
cp -r "${SRCROOT}/LibSession-Util/include/session" "${TARGET_BUILD_DIR}/libSessionUtil/Headers"
# The 'module.modulemap' is needed for XCode to be able to find the headers
modmap="${TARGET_BUILD_DIR}/libSessionUtil/Headers/module.modulemap"
echo "module SessionUtil {" >"$modmap"
echo " module capi {" >>"$modmap"
for x in $(cd include && find session -name '*.h'); do
echo " header \"$x\"" >>"$modmap"
done
echo -e " export *\n }" >>"$modmap"
if false; then
# If we include the cpp headers like this then Xcode will try to load them as C headers (which
# of course breaks) and doesn't provide any way to only load the ones you need (because this is
# Apple land, why would anything useful be available?). So we include the headers in the
# archive but can't let xcode discover them because it will do it wrong.
echo -e "\n module cppapi {" >>"$modmap"
for x in $(cd include && find session -name '*.hpp'); do
echo " header \"$x\"" >>"$modmap"
done
echo -e " export *\n }" >>"$modmap"
fi
echo "}" >>"$modmap"
# Output to XCode just so the output is good
echo_message "info: libSessionUtil Ready"

76
Scripts/drone-static-upload.sh Executable file
View File

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Script used with Drone CI to upload build artifacts (because specifying all this in
# .drone.jsonnet is too painful).
set -o errexit
if [ -z "$SSH_KEY" ]; then
echo -e "\n\n\n\e[31;1mUnable to upload artifact: SSH_KEY not set\e[0m"
# Just warn but don't fail, so that this doesn't trigger a build failure for untrusted builds
exit 0
fi
echo "$SSH_KEY" >ssh_key
set -o xtrace # Don't start tracing until *after* we write the ssh key
chmod 600 ssh_key
if [ -n "$DRONE_TAG" ]; then
# For a tag build use something like `session-ios-v1.2.3`
base="session-ios-$DRONE_TAG"
else
# Otherwise build a length name from the datetime and commit hash, such as:
# session-ios-20200522T212342Z-04d7dcc54
base="session-ios-$(date --date=@$DRONE_BUILD_CREATED +%Y%m%dT%H%M%SZ)-${DRONE_COMMIT:0:9}"
fi
mkdir -v "$base"
# Copy over the build products
prod_path="build/Session.xcarchive"
sim_path="build/Session_sim.xcarchive/Products/Applications/Session.app"
mkdir build
echo "Test" > "build/test.txt"
if [ ! -d $prod_path ]; then
cp -av $prod_path "$base"
else if [ ! -d $sim_path ]; then
cp -av $sim_path "$base"
else
echo "Expected a file to upload, found none" >&2
exit 1
fi
# tar dat shiz up yo
archive="$base.tar.xz"
tar cJvf "$archive" "$base"
upload_to="oxen.rocks/${DRONE_REPO// /_}/${DRONE_BRANCH// /_}"
# sftp doesn't have any equivalent to mkdir -p, so we have to split the above up into a chain of
# -mkdir a/, -mkdir a/b/, -mkdir a/b/c/, ... commands. The leading `-` allows the command to fail
# without error.
upload_dirs=(${upload_to//\// })
put_debug=
mkdirs=
dir_tmp=""
for p in "${upload_dirs[@]}"; do
dir_tmp="$dir_tmp$p/"
mkdirs="$mkdirs
-mkdir $dir_tmp"
done
sftp -i ssh_key -b - -o StrictHostKeyChecking=off drone@oxen.rocks <<SFTP
$mkdirs
put $archive $upload_to
$put_debug
SFTP
set +o xtrace
echo -e "\n\n\n\n\e[32;1mUploaded to https://${upload_to}/${archive}\e[0m\n\n\n"

File diff suppressed because it is too large Load Diff

View File

@ -1,121 +0,0 @@
{
"DVTSourceControlWorkspaceBlueprintPrimaryRemoteRepositoryKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE",
"DVTSourceControlWorkspaceBlueprintWorkingCopyRepositoryLocationsKey" : {
},
"DVTSourceControlWorkspaceBlueprintWorkingCopyStatesKey" : {
"8176314449001F06FB0E5B588C62133EAA2FE911+++72E8629" : 9223372036854775807,
"01DE8628B025BC69C8C7D8B4612D57BE2C08B62C+++6A1C9FC" : 0,
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++0BB03DB" : 0,
"ABB939127996C66F7E852A780552ADEEF03C6B13+++69179A3" : 0,
"90530B99EB0008E7A50951FDFBE02169118FA649+++EF2C0B3" : 0,
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++ED4C31A" : 0,
"D74FB800F048CB516BB4BC70047F7CC676D291B9+++375B249" : 0,
"8176314449001F06FB0E5B588C62133EAA2FE911+++692B8E4" : 9223372036854775807,
"37054CE35CE656680D6FFFA9EE19249E0D149C5E+++901E7D4" : 0,
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE" : 0,
"8176314449001F06FB0E5B588C62133EAA2FE911+++E19D6E3" : 9223372036854775807,
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++03D0758" : 0,
"37054CE35CE656680D6FFFA9EE19249E0D149C5E+++3F8B703" : 9223372036854775807,
"37054CE35CE656680D6FFFA9EE19249E0D149C5E+++E57A04A" : 0,
"8176314449001F06FB0E5B588C62133EAA2FE911+++31C7255" : 9223372036854775807
},
"DVTSourceControlWorkspaceBlueprintIdentifierKey" : "D0F297E7-A82D-4657-A941-96B268F80ABC",
"DVTSourceControlWorkspaceBlueprintWorkingCopyPathsKey" : {
"8176314449001F06FB0E5B588C62133EAA2FE911+++72E8629" : "Signal-iOS-2\/Carthage\/",
"01DE8628B025BC69C8C7D8B4612D57BE2C08B62C+++6A1C9FC" : "SignalProtocolKit\/",
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++0BB03DB" : "Signal-iOS-2\/",
"ABB939127996C66F7E852A780552ADEEF03C6B13+++69179A3" : "SocketRocket\/",
"90530B99EB0008E7A50951FDFBE02169118FA649+++EF2C0B3" : "JSQMessagesViewController\/",
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++ED4C31A" : "Signal-iOS\/",
"D74FB800F048CB516BB4BC70047F7CC676D291B9+++375B249" : "Signal-iOS\/Pods\/",
"8176314449001F06FB0E5B588C62133EAA2FE911+++692B8E4" : "Signal-iOS-4\/Carthage\/",
"37054CE35CE656680D6FFFA9EE19249E0D149C5E+++901E7D4" : "SignalServiceKit\/",
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE" : "Signal-iOS-4\/",
"8176314449001F06FB0E5B588C62133EAA2FE911+++E19D6E3" : "Signal-iOS\/Carthage\/",
"5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++03D0758" : "Signal-iOS-5\/",
"37054CE35CE656680D6FFFA9EE19249E0D149C5E+++3F8B703" : "SignalServiceKit-2\/",
"37054CE35CE656680D6FFFA9EE19249E0D149C5E+++E57A04A" : "SignalServiceKit\/",
"8176314449001F06FB0E5B588C62133EAA2FE911+++31C7255" : "Signal-iOS-5\/Carthage\/"
},
"DVTSourceControlWorkspaceBlueprintNameKey" : "Signal",
"DVTSourceControlWorkspaceBlueprintVersion" : 204,
"DVTSourceControlWorkspaceBlueprintRelativePathToProjectKey" : "Signal.xcworkspace",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoriesKey" : [
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SignalProtocolKit.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "01DE8628B025BC69C8C7D8B4612D57BE2C08B62C+++6A1C9FC"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SignalServiceKit.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++3F8B703"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SignalProtocolKit.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++901E7D4"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:FredericJacobs\/TextSecureKit.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "37054CE35CE656680D6FFFA9EE19249E0D149C5E+++E57A04A"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++03D0758"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++0BB03DB"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++2D5CBAE"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-iOS.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "5D79A077E31B3FE97A3C6613CBFFDD71C314D14C+++ED4C31A"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:michaelkirk\/Signal-Carthage.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++31C7255"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/WhisperSystems\/Signal-Carthage.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++692B8E4"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/WhisperSystems\/Signal-Carthage.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++72E8629"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/Signal-Carthage.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "8176314449001F06FB0E5B588C62133EAA2FE911+++E19D6E3"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/JSQMessagesViewController.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "90530B99EB0008E7A50951FDFBE02169118FA649+++EF2C0B3"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "github.com:WhisperSystems\/SocketRocket.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "ABB939127996C66F7E852A780552ADEEF03C6B13+++69179A3"
},
{
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryURLKey" : "https:\/\/github.com\/FredericJacobs\/Precompiled-Signal-Dependencies.git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositorySystemKey" : "com.apple.dt.Xcode.sourcecontrol.Git",
"DVTSourceControlWorkspaceBlueprintRemoteRepositoryIdentifierKey" : "D74FB800F048CB516BB4BC70047F7CC676D291B9+++375B249"
}
]
}

View File

@ -1,10 +1,12 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import UIKit
import YYImage
import Combine
import CallKit
import GRDB
import WebRTC
import PromiseKit
import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
@ -25,6 +27,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
let contactName: String
let profilePicture: UIImage
let animatedProfilePicture: YYImage?
// MARK: - Control
@ -151,10 +154,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
self.isOutgoing = outgoing
let avatarData: Data? = ProfileManager.profileAvatar(db, id: sessionId)
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
self.profilePicture = avatarData
.map { UIImage(data: $0) }
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
.defaulting(to: PlaceholderIcon.generate(seed: sessionId, text: self.contactName, size: 300))
self.animatedProfilePicture = avatarData
.map { data in
switch data.guessedImageFormat {
case .gif, .webp: return YYImage(data: data)
default: return nil
}
}
WebRTCSession.current = self.webRTCSession
self.webRTCSession.delegate = self
@ -206,6 +217,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
else { return }
let webRTCSession: WebRTCSession = self.webRTCSession
let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
let message: CallMessage = CallMessage(
uuid: self.uuid,
@ -224,21 +236,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
.inserted(db)
self.callInteractionId = interaction?.id
try? self.webRTCSession
try? webRTCSession
.sendPreOffer(
db,
message: message,
interactionId: interaction?.id,
in: thread
)
.done { [weak self] _ in
Storage.shared.writeAsync { db in
self?.webRTCSession.sendOffer(db, to: sessionId)
}
self?.setupTimeoutTimer()
}
.retainUntilComplete()
// Start the timeout timer for the call
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
.flatMap { _ in webRTCSession.sendOffer(to: thread) }
.sinkUntilComplete()
}
func answerSessionCall() {
@ -418,9 +427,14 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
let sessionId: String = self.sessionId
let webRTCSession: WebRTCSession = self.webRTCSession
Storage.shared
.read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) }
.retainUntilComplete()
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else {
return
}
webRTCSession
.sendOffer(to: thread, isRestartingICEConnection: true)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.sinkUntilComplete()
}
// MARK: - Timeout

View File

@ -2,8 +2,8 @@
import Foundation
import CallKit
import SignalCoreKit
import SessionUtilitiesKit
import SignalCoreKit
extension SessionCallManager: CXProviderDelegate {
public func providerDidReset(_ provider: CXProvider) {

View File

@ -4,6 +4,8 @@ import UIKit
import CallKit
import GRDB
import SessionMessagingKit
import SignalCoreKit
import SignalUtilitiesKit
public final class SessionCallManager: NSObject, CallManagerProtocol {
let provider: CXProvider?
@ -205,9 +207,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
return
}
guard CurrentAppContext().isMainAppAndActive else { return }
DispatchQueue.main.async {
guard CurrentAppContext().isMainAppAndActive else { return }
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
preconditionFailure() // FIXME: Handle more gracefully
}

View File

@ -1,15 +1,16 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import YYImage
import MediaPlayer
import WebRTC
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
final class CallVC: UIViewController, VideoPreviewDelegate {
static let floatingVideoViewWidth: CGFloat = UIDevice.current.isIPad ? 160 : 80
static let floatingVideoViewHeight: CGFloat = UIDevice.current.isIPad ? 346: 173
private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120)
private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80)
private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173)
let call: SessionCall
var latestKnownAudioOutputDeviceName: String?
@ -129,17 +130,29 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
private lazy var profilePictureView: UIImageView = {
let result = UIImageView()
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
result.image = self.call.profilePicture
result.set(.width, to: radius * 2)
result.set(.height, to: radius * 2)
result.layer.cornerRadius = radius
result.set(.width, to: CallVC.avatarRadius * 2)
result.set(.height, to: CallVC.avatarRadius * 2)
result.layer.cornerRadius = CallVC.avatarRadius
result.layer.masksToBounds = true
result.contentMode = .scaleAspectFill
return result
}()
private lazy var animatedImageView: YYAnimatedImageView = {
let result: YYAnimatedImageView = YYAnimatedImageView()
result.image = self.call.animatedProfilePicture
result.set(.width, to: CallVC.avatarRadius * 2)
result.set(.height, to: CallVC.avatarRadius * 2)
result.layer.cornerRadius = CallVC.avatarRadius
result.layer.masksToBounds = true
result.contentMode = .scaleAspectFill
result.isHidden = (self.call.animatedProfilePicture == nil)
return result
}()
private lazy var minimizeButton: UIButton = {
let result = UIButton(type: .custom)
result.setImage(
@ -486,7 +499,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
profilePictureContainer.addSubview(profilePictureView)
profilePictureContainer.addSubview(animatedImageView)
profilePictureView.center(in: profilePictureContainer)
animatedImageView.center(in: profilePictureContainer)
// Call info label
let callInfoLabelContainer = UIView()

View File

@ -1,7 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import WebRTC
import SessionUIKit
public protocol VideoPreviewDelegate: AnyObject {

View File

@ -1,6 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import WebRTC
import Foundation
import SessionUtilitiesKit
import SignalCoreKit
#if targetEnvironment(simulator)
// Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead
@ -27,7 +30,7 @@ class RemoteVideoView: TargetView {
return
}
DispatchMainThreadSafe {
Threading.dispatchMainThreadSafe {
let frameRatio = Double(frame.height) / Double(frame.width)
let frameRotation = frame.rotation
let deviceRotation = UIDevice.current.orientation
@ -90,7 +93,8 @@ class LocalVideoView: TargetView {
override func renderFrame(_ frame: RTCVideoFrame?) {
super.renderFrame(frame)
DispatchMainThreadSafe {
Threading.dispatchMainThreadSafe {
// This is a workaround for a weird issue that
// sometimes the rotationOverride is not working
// if it is only set once on initialization

View File

@ -1,9 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import WebRTC
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
private static let swipeToOperateThreshold: CGFloat = 60
@ -20,14 +20,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
return result
}()
private lazy var profilePictureView: ProfilePictureView = {
let result = ProfilePictureView()
let size: CGFloat = 60
result.size = size
result.set(.width, to: size)
result.set(.height, to: size)
return result
}()
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
private lazy var displayNameLabel: UILabel = {
let result = UILabel()
@ -118,8 +111,10 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
profilePictureView.update(
publicKey: call.sessionId,
profile: Profile.fetchOrCreate(id: call.sessionId),
threadVariant: .contact
threadVariant: .contact,
customImageData: nil,
profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) },
additionalProfile: nil
)
displayNameLabel.text = call.contactName

View File

@ -1,9 +1,9 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import GRDB
import DifferenceKit
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
@ -18,6 +18,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
private let threadId: String
private let threadVariant: SessionThread.Variant
private var originalName: String = ""
private var originalMembersAndZombieIds: Set<String> = []
private var name: String = ""
@ -82,8 +83,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
// MARK: - Lifecycle
init(threadId: String) {
init(threadId: String, threadVariant: SessionThread.Variant) {
self.threadId = threadId
self.threadVariant = threadVariant
super.init(nibName: nil, bundle: nil)
}
@ -220,7 +222,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
cell.update(
with: SessionCell.Info(
id: displayInfo,
leftAccessory: .profile(displayInfo.profileId, displayInfo.profile),
position: Position.with(indexPath.row, count: membersAndZombies.count),
leftAccessory: .profile(id: displayInfo.profileId, profile: displayInfo.profile),
title: (
displayInfo.profile?.displayName() ??
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
@ -231,10 +234,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
.withRenderingMode(.alwaysTemplate),
customTint: .textSecondary
)
)
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: membersAndZombies.count)
),
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
)
)
return cell
@ -244,12 +246,26 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
return adminIds.contains(userPublicKey)
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let profileId: String = self.membersAndZombies[indexPath.row].profileId
let delete: UIContextualAction = UIContextualAction(
style: .destructive,
title: "GROUP_ACTION_REMOVE".localized()
title: "GROUP_ACTION_REMOVE".localized(),
icon: UIImage(named: "icon_bin"),
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
self?.adminIds.remove(profileId)
self?.membersAndZombies.remove(at: indexPath.row)
@ -257,7 +273,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
completionHandler(true)
}
delete.themeBackgroundColor = .conversationButton_swipeDestructive
return UISwipeActionsConfiguration(actions: [ delete ])
}
@ -286,7 +301,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
}
private func handleMembersChanged() {
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 78
tableView.reloadData()
}
@ -333,7 +348,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
guard !updatedName.isEmpty else {
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
}
guard updatedName.count < 64 else {
guard updatedName.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else {
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
}
@ -449,32 +464,40 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
Storage.shared
.writeAsync { db in
if !updatedMemberIds.contains(userPublicKey) {
try MessageSender.leave(
db,
groupPublicKey: threadId,
deleteThread: false
)
return Promise.value(())
}
return try MessageSender.update(
.writePublisher { db in
// If the user is no longer a member then leave the group
guard !updatedMemberIds.contains(userPublicKey) else { return }
try MessageSender.leave(
db,
groupPublicKey: threadId,
deleteThread: true
)
}
.flatMap {
MessageSender.update(
groupPublicKey: threadId,
with: updatedMemberIds,
name: updatedName
)
}
.done(on: DispatchQueue.main) { [weak self] in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
popToConversationVC(self)
}
.catch(on: DispatchQueue.main) { [weak self] error in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription)
}
.retainUntilComplete()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
switch result {
case .finished: popToConversationVC(self)
case .failure(let error):
self?.showError(
title: "GROUP_UPDATE_ERROR_TITLE".localized(),
message: error.localizedDescription
)
}
}
)
}
}

View File

@ -3,7 +3,6 @@
import UIKit
import GRDB
import DifferenceKit
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
@ -208,15 +207,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
cell.update(
with: SessionCell.Info(
id: profile,
leftAccessory: .profile(profile.id, profile),
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count),
leftAccessory: .profile(id: profile.id, profile: profile),
title: profile.displayName(),
rightAccessory: .radio(isSelected: { [weak self] in
self?.selectedContacts.contains(profile.id) == true
}),
accessibilityIdentifier: "Contact"
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
accessibility: Accessibility(
identifier: "Contact"
)
)
)
return cell
@ -319,7 +320,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
else {
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
}
guard name.count < 30 else {
guard name.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else {
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
}
guard selectedContacts.count >= 1 else {
@ -331,33 +332,38 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
let selectedContacts = self.selectedContacts
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
Storage.shared
.writeAsync { db in
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
}
.done(on: DispatchQueue.main) { thread in
Storage.shared.writeAsync { db in
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
self?.presentingViewController?.dismiss(animated: true, completion: nil)
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
}
.catch(on: DispatchQueue.main) { [weak self] _ in
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "GROUP_CREATION_ERROR_TITLE".localized(),
body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
MessageSender
.createClosedGroup(name: name, members: selectedContacts)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure:
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "GROUP_CREATION_ERROR_TITLE".localized(),
body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
)
self?.present(modal, animated: true)
}
},
receiveValue: { thread in
SessionApp.presentConversationCreatingIfNeeded(
for: thread.id,
variant: thread.variant,
dismissing: self?.presentingViewController,
animated: false
)
)
self?.present(modal, animated: true)
}
.retainUntilComplete()
}
)
}
}
}

View File

@ -131,12 +131,23 @@ extension ContextMenuVC {
) { delegate?.contextMenuDismissed() }
}
}
static func viewModelCanReply(_ cellViewModel: MessageViewModel) -> Bool {
return (
cellViewModel.variant == .standardIncoming || (
cellViewModel.variant == .standardOutgoing &&
cellViewModel.state != .failed &&
cellViewModel.state != .sending
)
)
}
static func actions(
for cellViewModel: MessageViewModel,
recentEmojis: [EmojiWithSkinTones],
currentUserPublicKey: String,
currentUserBlindedPublicKey: String?,
currentUserBlinded15PublicKey: String?,
currentUserBlinded25PublicKey: String?,
currentUserIsOpenGroupModerator: Bool,
currentThreadIsMessageRequest: Bool,
delegate: ContextMenuActionDelegate?
@ -161,12 +172,6 @@ extension ContextMenuVC {
)
)
)
let canReply: Bool = (
cellViewModel.variant != .standardOutgoing || (
cellViewModel.state != .failed &&
cellViewModel.state != .sending
)
)
let canCopy: Bool = (
cellViewModel.cellType == .textOnlyMessage || (
(
@ -194,23 +199,27 @@ extension ContextMenuVC {
)
let canCopySessionId: Bool = (
cellViewModel.variant == .standardIncoming &&
cellViewModel.threadVariant != .openGroup
cellViewModel.threadVariant != .community
)
let canDelete: Bool = (
cellViewModel.threadVariant != .openGroup ||
cellViewModel.threadVariant != .community ||
currentUserIsOpenGroupModerator ||
cellViewModel.authorId == currentUserPublicKey ||
cellViewModel.authorId == currentUserBlindedPublicKey ||
cellViewModel.authorId == currentUserBlinded15PublicKey ||
cellViewModel.authorId == currentUserBlinded25PublicKey ||
cellViewModel.state == .failed
)
let canBan: Bool = (
cellViewModel.threadVariant == .openGroup &&
cellViewModel.threadVariant == .community &&
currentUserIsOpenGroupModerator
)
let shouldShowEmojiActions: Bool = {
if cellViewModel.threadVariant == .openGroup {
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
if cellViewModel.threadVariant == .community {
return OpenGroupManager.doesOpenGroupSupport(
capability: .reactions,
on: cellViewModel.threadOpenGroupServer
)
}
return !currentThreadIsMessageRequest
}()
@ -219,7 +228,7 @@ extension ContextMenuVC {
let generatedActions: [Action] = [
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
(canReply ? Action.reply(cellViewModel, delegate) : nil),
(viewModelCanReply(cellViewModel) ? Action.reply(cellViewModel, delegate) : nil),
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
(canSave ? Action.save(cellViewModel, delegate) : nil),
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),

View File

@ -71,10 +71,12 @@ final class ContextMenuVC: UIViewController {
private lazy var fallbackTimestampLabel: UILabel = {
let result: UILabel = UILabel()
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
result.font = .systemFont(ofSize: Values.verySmallFontSize)
result.text = cellViewModel.dateForUI.formattedForDisplay
result.themeTextColor = .textPrimary
result.alpha = 0
result.numberOfLines = 2
return result
}()
@ -189,10 +191,14 @@ final class ContextMenuVC: UIViewController {
fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight)
if cellViewModel.variant == .standardOutgoing {
fallbackTimestampLabel.textAlignment = .right
fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing)
fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing)
}
else {
fallbackTimestampLabel.textAlignment = .left
fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing)
fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing)
}
// Constrains

View File

@ -1,3 +1,6 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
final class ContextMenuWindow : UIWindow {

View File

@ -3,6 +3,8 @@
import UIKit
import GRDB
import SignalUtilitiesKit
import SignalCoreKit
import SessionUIKit
public class StyledSearchController: UISearchController {
public override var preferredStatusBarStyle: UIStatusBarStyle {
@ -83,7 +85,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
let threadId: String = self.threadId
DispatchQueue.global(qos: .default).async { [weak self] in
let results: [Int64]? = Storage.shared.read { db -> [Int64] in
let results: [Interaction.TimestampInfo]? = Storage.shared.read { db -> [Interaction.TimestampInfo] in
self?.resultsBar.willStartSearching(readConnection: db)
return try Interaction.idsForTermWithin(
@ -96,7 +98,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
// If we didn't get results back then we most likely interrupted the query so
// should ignore the results (if there are no results we would succeed and get
// an empty array back)
guard let results: [Int64] = results else { return }
guard let results: [Interaction.TimestampInfo] = results else { return }
DispatchQueue.main.async {
guard let strongSelf = self else { return }
@ -115,11 +117,11 @@ extension ConversationSearchController: SearchResultsBarDelegate {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Int64]
results: [Interaction.TimestampInfo]
) {
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
guard let interactionInfo: Interaction.TimestampInfo = results[safe: currentIndex] else { return }
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
self.delegate?.conversationSearchController(self, didSelectInteractionInfo: interactionInfo)
}
}
@ -127,13 +129,13 @@ protocol SearchResultsBarDelegate: AnyObject {
func searchResultsBar(
_ searchResultsBar: SearchResultsBar,
setCurrentIndex currentIndex: Int,
results: [Int64]
results: [Interaction.TimestampInfo]
)
}
public final class SearchResultsBar: UIView {
private var readConnection: Atomic<Database?> = Atomic(nil)
private var results: Atomic<[Int64]?> = Atomic(nil)
private var results: Atomic<[Interaction.TimestampInfo]?> = Atomic(nil)
var currentIndex: Int?
weak var resultsBarDelegate: SearchResultsBarDelegate?
@ -248,7 +250,7 @@ public final class SearchResultsBar: UIView {
// MARK: - Actions
@objc public func handleUpButtonTapped() {
guard let results: [Int64] = results.wrappedValue else { return }
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
guard let currentIndex: Int = currentIndex else { return }
guard currentIndex + 1 < results.count else { return }
@ -260,7 +262,7 @@ public final class SearchResultsBar: UIView {
@objc public func handleDownButtonTapped() {
Logger.debug("")
guard let results: [Int64] = results.wrappedValue else { return }
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
let newIndex = currentIndex - 1
@ -287,12 +289,12 @@ public final class SearchResultsBar: UIView {
self.readConnection.mutate { $0 = readConnection }
}
func updateResults(results: [Int64]?) {
func updateResults(results: [Interaction.TimestampInfo]?) {
// We want to ignore search results that don't match the current searchId (this
// will happen when searching large threads with short terms as the shorter terms
// will take much longer to resolve than the longer terms)
currentIndex = {
guard let results: [Int64] = results, !results.isEmpty else { return nil }
guard let results: [Interaction.TimestampInfo] = results, !results.isEmpty else { return nil }
if let currentIndex: Int = currentIndex {
return max(0, min(currentIndex, results.count - 1))
@ -312,10 +314,11 @@ public final class SearchResultsBar: UIView {
}
func updateBarItems() {
guard let results: [Int64] = results.wrappedValue else {
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else {
label.text = ""
downButton.isEnabled = false
upButton.isEnabled = false
stopLoading()
return
}
@ -362,6 +365,6 @@ public final class SearchResultsBar: UIView {
// MARK: - ConversationSearchControllerDelegate
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?)
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo: Interaction.TimestampInfo)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import GRDB
import DifferenceKit
import SessionMessagingKit
@ -9,6 +10,13 @@ import SessionUtilitiesKit
public class ConversationViewModel: OWSAudioPlayerDelegate {
public typealias SectionModel = ArraySection<Section, MessageViewModel>
// MARK: - FocusBehaviour
public enum FocusBehaviour {
case none
case highlight
}
// MARK: - Action
public enum Action {
@ -34,7 +42,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
public let initialThreadVariant: SessionThread.Variant
public var sentMessageBeforeUpdate: Bool = false
public var lastSearchedText: String?
public let focusedInteractionId: Int64? // Note: This is used for global search
public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search
public let focusBehaviour: FocusBehaviour
private let initialUnreadInteractionId: Int64?
private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject()
private var markAsReadPublisher: AnyPublisher<Void, Never>?
public lazy var blockedBannerMessage: String = {
switch self.threadData.threadVariant {
@ -52,66 +64,93 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Initialization
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
typealias InitialData = (
targetInteractionId: Int64?,
currentUserPublicKey: String,
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
threadIsBlocked: Bool,
currentUserIsClosedGroupMember: Bool?,
openGroupPermissions: OpenGroup.Permissions?,
blindedKey: String?
blinded15Key: String?,
blinded25Key: String?
)
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
// unread interaction and start focused around that one
let targetInteractionId: Int64? = (focusedInteractionId != nil ? focusedInteractionId :
try Interaction
.select(.id)
.filter(interaction[.wasRead] == false)
.filter(interaction[.threadId] == threadId)
.order(interaction[.timestampMs].asc)
.asRequest(of: Int64.self)
let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction
.select(.id, .timestampMs)
.filter(interaction[.wasRead] == false)
.filter(interaction[.threadId] == threadId)
.order(interaction[.timestampMs].asc)
.asRequest(of: Interaction.TimestampInfo.self)
.fetchOne(db)
let threadIsBlocked: Bool = (threadVariant != .contact ? false :
try Contact
.filter(id: threadId)
.select(.isBlocked)
.asRequest(of: Bool.self)
.fetchOne(db)
.defaulting(to: false)
)
let currentUserIsClosedGroupMember: Bool? = (threadVariant != .closedGroup ? nil :
try GroupMember
let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
GroupMember
.filter(groupMember[.groupId] == threadId)
.filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db))
.filter(groupMember[.profileId] == currentUserPublicKey)
.filter(groupMember[.role] == GroupMember.Role.standard)
.isNotEmpty(db)
)
let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .openGroup ? nil :
let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil :
try OpenGroup
.filter(id: threadId)
.select(.permissions)
.asRequest(of: OpenGroup.Permissions.self)
.fetchOne(db)
)
let blindedKey: String? = SessionThread.getUserHexEncodedBlindedKey(
let blinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey(
db,
threadId: threadId,
threadVariant: threadVariant
threadVariant: threadVariant,
blindingPrefix: .blinded15
)
let blinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey(
db,
threadId: threadId,
threadVariant: threadVariant,
blindingPrefix: .blinded25
)
return (
targetInteractionId,
currentUserPublicKey,
initialUnreadInteractionInfo,
threadIsBlocked,
currentUserIsClosedGroupMember,
openGroupPermissions,
blindedKey
blinded15Key,
blinded25Key
)
}
self.threadId = threadId
self.initialThreadVariant = threadVariant
self.focusedInteractionId = initialData?.targetInteractionId
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
self.threadData = SessionThreadViewModel(
threadId: threadId,
threadVariant: threadVariant,
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
threadIsBlocked: initialData?.threadIsBlocked,
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
openGroupPermissions: initialData?.openGroupPermissions
).populatingCurrentUserBlindedKey(currentUserBlindedPublicKeyForThisThread: initialData?.blindedKey)
).populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
)
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
@ -120,23 +159,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// distinct stutter)
self.pagedDataObserver = self.setupPagedObserver(
for: threadId,
userPublicKey: getUserHexEncodedPublicKey(),
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
threadId: threadId,
threadVariant: threadVariant
)
userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()),
blinded15PublicKey: initialData?.blinded15Key,
blinded25PublicKey: initialData?.blinded25Key
)
// Run the initial query on a background thread so we don't block the push transition
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
// If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query
// from a `0` offset)
guard let initialFocusedId: Int64 = initialData?.targetInteractionId else {
guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else {
self?.pagedDataObserver?.load(.pageBefore)
return
}
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedInfo.id))
}
}
@ -155,9 +192,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public lazy var observableThreadData: ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> = setupObservableThreadData(for: self.threadId)
public typealias ThreadObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>>>
public lazy var observableThreadData: ThreadObservation = setupObservableThreadData(for: self.threadId)
private func setupObservableThreadData(for threadId: String) -> ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<SessionThreadViewModel?>>> {
private func setupObservableThreadData(for threadId: String) -> ThreadObservation {
return ValueObservation
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
@ -169,13 +207,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
return threadViewModel
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKey(
viewModel.populatingCurrentUserBlindedKeys(
db,
currentUserBlindedPublicKeyForThisThread: self?.threadData.currentUserBlindedPublicKey
currentUserBlinded15PublicKeyForThisThread: self?.threadData.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: self?.threadData.currentUserBlinded25PublicKey
)
}
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") })
}
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
@ -184,7 +224,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// MARK: - Interaction Data
private var lastInteractionIdMarkedAsRead: Int64?
private var lastInteractionIdMarkedAsRead: Int64? = nil
private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var interactionData: [SectionModel] = []
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
@ -194,14 +235,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1)
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onInteractionChange
switch Thread.isMainThread {
case true: performChange?(changes.0, changes.1)
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
}
self.unobservedInteractionDataChanges = nil
}
}
}
private func setupPagedObserver(for threadId: String, userPublicKey: String, blindedPublicKey: String?) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
private func setupPagedObserver(
for threadId: String,
userPublicKey: String,
blinded15PublicKey: String?,
blinded25PublicKey: String?
) -> PagedDatabaseObserver<Interaction, MessageViewModel> {
return PagedDatabaseObserver(
pagedTable: Interaction.self,
pageSize: ConversationViewModel.pageSize,
@ -220,7 +272,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
}()
),
PagedData.ObservedChanges(
@ -230,7 +282,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
}()
),
PagedData.ObservedChanges(
@ -249,7 +301,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
orderSQL: MessageViewModel.orderSQL,
dataQuery: MessageViewModel.baseQuery(
userPublicKey: userPublicKey,
blindedPublicKey: blindedPublicKey,
blinded15PublicKey: blinded15PublicKey,
blinded25PublicKey: blinded25PublicKey,
orderSQL: MessageViewModel.orderSQL,
groupSQL: MessageViewModel.groupSQL
),
@ -293,22 +346,39 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
)
],
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
self?.resolveOptimisticUpdates(with: updatedData)
PagedData.processAndTriggerUpdates(
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
updatedData: self?.process(
data: updatedData,
for: updatedPageInfo,
optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values)
.map { Array($0) },
initialUnreadInteractionId: self?.initialUnreadInteractionId
),
currentDataRetriever: { self?.interactionData },
onDataChange: self?.onInteractionChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedInteractionDataChanges = (updatedData, changeset)
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
)
}
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
private func process(
data: [MessageViewModel],
for pageInfo: PagedData.PageInfo,
optimisticMessages: [MessageViewModel]?,
initialUnreadInteractionId: Int64?
) -> [SectionModel] {
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
let sortedData: [MessageViewModel] = data
.filter { $0.isTypingIndicator != true }
.appending(contentsOf: (optimisticMessages ?? []))
.filter { !$0.cellType.isPostProcessed }
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
// We load messages from newest to oldest so having a pageOffset larger than zero means
@ -338,20 +408,31 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
cellViewModel.id == sortedData
.filter {
$0.authorId == threadData.currentUserPublicKey ||
$0.authorId == threadData.currentUserBlindedPublicKey
$0.authorId == threadData.currentUserBlinded15PublicKey ||
$0.authorId == threadData.currentUserBlinded25PublicKey
}
.last?
.id
),
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
currentUserBlinded15PublicKey: threadData.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: threadData.currentUserBlinded25PublicKey
)
}
.reduce([]) { result, message in
let updatedResult: [MessageViewModel] = result
.appending(initialUnreadInteractionId == nil || message.id != initialUnreadInteractionId ?
nil :
MessageViewModel(
timestampMs: message.timestampMs,
cellType: .unreadMarker
)
)
guard message.shouldShowDateHeader else {
return result.appending(message)
return updatedResult.appending(message)
}
return result
return updatedResult
.appending(
MessageViewModel(
timestampMs: message.timestampMs,
@ -374,12 +455,152 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self.interactionData = updatedData
}
public func expandReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.insert(interactionId)
// MARK: - Optimistic Message Handling
public typealias OptimisticMessageData = (
id: UUID,
interaction: Interaction,
attachmentData: Attachment.PreparedData?,
linkPreviewAttachment: Attachment?
)
private var optimisticallyInsertedMessages: Atomic<[UUID: MessageViewModel]> = Atomic([:])
private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:])
public func optimisticallyAppendOutgoingMessage(
text: String?,
sentTimestampMs: Int64,
attachments: [SignalAttachment]?,
linkPreviewDraft: LinkPreviewDraft?,
quoteModel: QuotedReplyModel?
) -> OptimisticMessageData {
// Generate the optimistic data
let optimisticMessageId: UUID = UUID()
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
let interaction: Interaction = Interaction(
threadId: threadData.threadId,
authorId: (threadData.currentUserBlinded15PublicKey ?? threadData.currentUserPublicKey),
variant: .standardOutgoing,
body: text,
timestampMs: sentTimestampMs,
hasMention: Interaction.isUserMentioned(
publicKeysToCheck: [
threadData.currentUserPublicKey,
threadData.currentUserBlinded15PublicKey,
threadData.currentUserBlinded25PublicKey
].compactMap { $0 },
body: text
),
expiresInSeconds: threadData.disappearingMessagesConfiguration
.map { disappearingConfig in
guard disappearingConfig.isEnabled else { return nil }
return disappearingConfig.durationSeconds
},
linkPreviewUrl: linkPreviewDraft?.urlString
)
let optimisticAttachments: Attachment.PreparedData? = attachments
.map { Attachment.prepare(attachments: $0) }
let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
try? LinkPreview.generateAttachmentIfPossible(
imageData: draft.jpegImageData,
mimeType: OWSMimeTypeImageJpeg
)
}
let optimisticData: OptimisticMessageData = (
optimisticMessageId,
interaction,
optimisticAttachments,
linkPreviewAttachment
)
// Generate the actual 'MessageViewModel'
let messageViewModel: MessageViewModel = MessageViewModel(
threadId: threadData.threadId,
threadVariant: threadData.threadVariant,
threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false),
threadOpenGroupServer: threadData.openGroupServer,
threadOpenGroupPublicKey: threadData.openGroupPublicKey,
threadContactNameInternal: threadData.threadContactName(),
timestampMs: interaction.timestampMs,
receivedAtTimestampMs: interaction.receivedAtTimestampMs,
authorId: interaction.authorId,
authorNameInternal: currentUserProfile.displayName(),
body: interaction.body,
expiresStartedAtMs: interaction.expiresStartedAtMs,
expiresInSeconds: interaction.expiresInSeconds,
isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
threadData.currentUserPublicKey,
for: threadData.openGroupRoomToken,
on: threadData.openGroupServer
),
currentUserProfile: currentUserProfile,
quote: quoteModel.map { model in
// Don't care about this optimistic quote (the proper one will be generated in the database)
Quote(
interactionId: -1, // Can't save to db optimistically
authorId: model.authorId,
timestampMs: model.timestampMs,
body: model.body,
attachmentId: model.attachment?.id
)
},
quoteAttachment: quoteModel?.attachment,
linkPreview: linkPreviewDraft.map { draft in
LinkPreview(
url: draft.urlString,
title: draft.title,
attachmentId: nil // Can't save to db optimistically
)
},
linkPreviewAttachment: linkPreviewAttachment,
attachments: optimisticAttachments?.attachments
)
optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = messageViewModel }
// If we can't get the current page data then don't bother trying to update (it's not going to work)
guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else {
return optimisticData
}
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData)
PagedData.processAndTriggerUpdates(
updatedData: process(
data: (currentData.first(where: { $0.model == .messages })?.elements ?? []),
for: currentPageInfo,
optimisticMessages: Array(optimisticallyInsertedMessages.wrappedValue.values),
initialUnreadInteractionId: initialUnreadInteractionId
),
currentDataRetriever: { [weak self] in self?.interactionData },
onDataChange: self.onInteractionChange,
onUnobservedDataChange: { [weak self] updatedData, changeset in
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
return optimisticData
}
public func collapseReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.remove(interactionId)
/// Record an association between an `optimisticMessageId` and a specific `interactionId`
public func associate(optimisticMessageId: UUID, to interactionId: Int64?) {
guard let interactionId: Int64 = interactionId else { return }
optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId }
}
/// Remove any optimisticUpdate entries which have an associated interactionId in the provided data
private func resolveOptimisticUpdates(with data: [MessageViewModel]) {
let interactionIds: [Int64] = data.map { $0.id }
let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds
.mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } }
optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } }
}
// MARK: - Mentions
@ -391,7 +612,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.read { db -> [MentionInfo] in
let userPublicKey: String = getUserHexEncodedPublicKey(db)
let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self)
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .openGroup ?
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .community ?
nil :
try? Capability
.select(.variant)
@ -400,9 +621,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
.fetchSet(db)
)
.defaulting(to: [])
let targetPrefix: SessionId.Prefix = (capabilities.contains(.blind) ?
.blinded :
.standard
let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ?
[.blinded15, .blinded25] :
[.standard]
)
return (try MentionInfo
@ -410,7 +631,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
userPublicKey: userPublicKey,
threadId: threadData.threadId,
threadVariant: threadData.threadVariant,
targetPrefix: targetPrefix,
targetPrefixes: targetPrefixes,
pattern: pattern
)?
.fetchAll(db))
@ -443,37 +664,53 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
}
}
/// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
/// the thread will be marked as read
public func markAsRead(beforeInclusive interactionId: Int64?) {
/// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
public func markAsRead(
target: SessionThreadViewModel.ReadTarget,
timestampMs: Int64?
) {
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
/// write queue when it isn't needed, in order to do this we:
/// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list)
/// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent
/// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue
///
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
/// `threadData.threadUnreadCount` to always be accurate)
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
guard
(self.threadData.threadUnreadCount ?? 0) > 0,
let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
self.lastInteractionIdMarkedAsRead != targetInteractionId
else { return }
let threadId: String = self.threadData.threadId
let threadVariant: SessionThread.Variant = self.threadData.threadVariant
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
self.lastInteractionIdMarkedAsRead = targetInteractionId
Storage.shared.writeAsync { db in
try Interaction.markAsRead(
db,
interactionId: targetInteractionId,
threadId: threadId,
threadVariant: threadVariant,
includingOlder: true,
trySendReadReceipt: trySendReadReceipt
)
/// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read
if markAsReadPublisher == nil {
markAsReadPublisher = markAsReadTrigger
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.global(qos: .userInitiated), latest: true)
.handleEvents(
receiveOutput: { [weak self] target, timestampMs in
switch target {
case .thread: self?.threadData.markAsRead(target: target)
case .threadAndInteractions(let interactionId):
guard
timestampMs == nil ||
(self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) ||
(self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0)
else {
self?.threadData.markAsRead(target: .thread)
return
}
// If we were given a timestamp then update the 'lastInteractionTimestampMsMarkedAsRead'
// to avoid needless updates
if let timestampMs: Int64 = timestampMs {
self?.lastInteractionTimestampMsMarkedAsRead = timestampMs
}
self?.lastInteractionIdMarkedAsRead = (interactionId ?? self?.threadData.interactionId)
self?.threadData.markAsRead(target: target)
}
}
)
.map { _ in () }
.eraseToAnyPublisher()
markAsReadPublisher?.sinkUntilComplete()
}
markAsReadTrigger.send((target, timestampMs))
}
public func swapToThread(updatedThreadId: String) {
@ -489,7 +726,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
self.pagedDataObserver = self.setupPagedObserver(
for: updatedThreadId,
userPublicKey: getUserHexEncodedPublicKey(),
blindedPublicKey: nil
blinded15PublicKey: nil,
blinded25PublicKey: nil
)
// Try load everything up to the initial visible message, fallback to just the initial page of messages
@ -539,14 +777,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
Storage.shared.writeAsync { db in
try Contact
.filter(id: threadId)
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
try MessageSender
.syncConfiguration(db, forceSyncNow: true)
.retainUntilComplete()
.updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false))
}
}
public func expandReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.insert(interactionId)
}
public func collapseReactions(for interactionId: Int64) {
reactionExpandedInteractionIds.remove(interactionId)
}
// MARK: - Audio Playback
public struct PlaybackInfo {
@ -648,6 +890,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
// Then setup the state for the new audio
currentPlayingInteraction.mutate { $0 = viewModel.id }
let currentPlaybackTime: TimeInterval? = playbackInfo.wrappedValue[viewModel.id]?.progress
audioPlayer.mutate { [weak self] player in
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
@ -660,7 +903,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
delegate: self
)
audioPlayer.play()
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
audioPlayer.setCurrentTime(currentPlaybackTime ?? 0)
player = audioPlayer
}
}

View File

@ -3,6 +3,7 @@
import UIKit
import SessionUIKit
import SessionUtilitiesKit
import SignalCoreKit
protocol EmojiPickerCollectionViewDelegate: AnyObject {
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones)

View File

@ -177,9 +177,6 @@ class EmojiPickerSheet: BaseVC {
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
UIView.animate(
withDuration: duration,
delay: 0,

View File

@ -2,6 +2,8 @@
import UIKit
import SessionUIKit
import SignalCoreKit
import SignalUtilitiesKit
class EmojiSkinTonePicker: UIView {
let emoji: Emoji

View File

@ -50,7 +50,7 @@ public final class InputTextView: UITextView, UITextViewDelegate {
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(paste(_:)) {
if let _ = UIPasteboard.general.image {
if UIPasteboard.general.hasImages {
return true
}
}

View File

@ -1,15 +1,18 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
// MARK: - Variables
private static let linkPreviewViewInset: CGFloat = 6
private var disposables: Set<AnyCancellable> = Set()
private let threadVariant: SessionThread.Variant
private weak var delegate: InputViewDelegate?
@ -89,7 +92,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
let result: UIView = UIView()
result.accessibilityLabel = "Mentions list"
result.accessibilityIdentifier = "Mentions list"
result.isAccessibilityElement = true
result.alpha = 0
let backgroundView = UIView()
@ -263,7 +265,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
quotedText: quoteDraftInfo.model.body,
threadVariant: threadVariant,
currentUserPublicKey: quoteDraftInfo.model.currentUserPublicKey,
currentUserBlindedPublicKey: quoteDraftInfo.model.currentUserBlindedPublicKey,
currentUserBlinded15PublicKey: quoteDraftInfo.model.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: quoteDraftInfo.model.currentUserBlinded25PublicKey,
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
attachment: quoteDraftInfo.model.attachment,
hInset: hInset,
@ -330,19 +333,27 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
// Build the link preview
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.done { [weak self] draft in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
}
.catch { [weak self] _ in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
.retainUntilComplete()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure:
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
},
receiveValue: { [weak self] draft in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
}
)
.store(in: &disposables)
}
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
@ -491,7 +502,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
func showMentionsUI(for candidates: [MentionInfo]) {
mentionsView.candidates = candidates
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
let mentionCellHeight = (ProfilePictureView.Size.message.viewSize + 2 * Values.smallSpacing)
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
layoutIfNeeded()

View File

@ -92,6 +92,11 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
),
isLast: (indexPath.row == (candidates.count - 1))
)
cell.accessibilityIdentifier = "Contact"
cell.accessibilityLabel = candidates[indexPath.row].profile.displayName(
for: candidates[indexPath.row].threadVariant
)
cell.isAccessibilityElement = true
return cell
}
@ -111,9 +116,7 @@ private extension MentionSelectionView {
final class Cell: UITableViewCell {
// MARK: - UI
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message)
private lazy var displayNameLabel: UILabel = {
let result: UILabel = UILabel()
@ -155,18 +158,12 @@ private extension MentionSelectionView {
selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground)
self.selectedBackgroundView = selectedBackgroundView
// Profile picture image view
let profilePictureViewSize = Values.smallProfilePictureSize
profilePictureView.set(.width, to: profilePictureViewSize)
profilePictureView.set(.height, to: profilePictureViewSize)
profilePictureView.size = profilePictureViewSize
// Main stack view
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
mainStackView.axis = .horizontal
mainStackView.alignment = .center
mainStackView.spacing = Values.mediumSpacing
mainStackView.set(.height, to: profilePictureViewSize)
mainStackView.set(.height, to: ProfilePictureView.Size.message.viewSize)
contentView.addSubview(mainStackView)
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing)
@ -174,13 +171,6 @@ private extension MentionSelectionView {
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
// Moderator icon image view
moderatorIconImageView.set(.width, to: 20)
moderatorIconImageView.set(.height, to: 20)
contentView.addSubview(moderatorIconImageView)
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
// Separator
addSubview(separator)
separator.pin(.leading, to: .leading, of: self)
@ -199,10 +189,11 @@ private extension MentionSelectionView {
displayNameLabel.text = profile.displayName(for: threadVariant)
profilePictureView.update(
publicKey: profile.id,
threadVariant: .contact, // Always show the display picture in 'contact' mode
customImageData: nil,
profile: profile,
threadVariant: threadVariant
profileIcon: (isUserModeratorOrAdmin ? .crown : .none)
)
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
separator.isHidden = isLast
}
}

View File

@ -46,7 +46,7 @@ final class DocumentView: UIView {
// Size label
let sizeLabel = UILabel()
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
sizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount)
sizeLabel.text = Format.fileSize(attachment.byteCount)
sizeLabel.themeTextColor = textColor
sizeLabel.lineBreakMode = .byTruncatingTail

View File

@ -2,6 +2,7 @@
import UIKit
import SessionMessagingKit
import SignalCoreKit
protocol LinkPreviewState {
var isLoaded: Bool { get }

View File

@ -2,6 +2,7 @@
import UIKit
import SessionMessagingKit
import SignalCoreKit
public class MediaAlbumView: UIStackView {
private let items: [Attachment]
@ -110,11 +111,10 @@ public class MediaAlbumView: UIStackView {
tintView.autoPinEdgesToSuperviewEdges()
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
let moreText = String(
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
moreCountText
"\(moreCount)"
)
let moreLabel: UILabel = UILabel()
moreLabel.font = .systemFont(ofSize: 24)

View File

@ -4,6 +4,8 @@ import UIKit
import YYImage
import SessionUIKit
import SessionMessagingKit
import SignalCoreKit
import SignalUtilitiesKit
public class MediaView: UIView {
static let contentMode: UIView.ContentMode = .scaleAspectFill

View File

@ -30,7 +30,8 @@ final class QuoteView: UIView {
quotedText: String?,
threadVariant: SessionThread.Variant,
currentUserPublicKey: String?,
currentUserBlindedPublicKey: String?,
currentUserBlinded15PublicKey: String?,
currentUserBlinded25PublicKey: String?,
direction: Direction,
attachment: Attachment?,
hInset: CGFloat,
@ -47,7 +48,8 @@ final class QuoteView: UIView {
quotedText: quotedText,
threadVariant: threadVariant,
currentUserPublicKey: currentUserPublicKey,
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
direction: direction,
attachment: attachment,
hInset: hInset,
@ -69,7 +71,8 @@ final class QuoteView: UIView {
quotedText: String?,
threadVariant: SessionThread.Variant,
currentUserPublicKey: String?,
currentUserBlindedPublicKey: String?,
currentUserBlinded15PublicKey: String?,
currentUserBlinded25PublicKey: String?,
direction: Direction,
attachment: Attachment?,
hInset: CGFloat,
@ -119,17 +122,6 @@ final class QuoteView: UIView {
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
// Line view
let lineColor: ThemeValue = {
switch mode {
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
case .draft: return .primary
}
}()
let lineView = UIView()
lineView.themeBackgroundColor = lineColor
lineView.set(.width, to: Values.accentLineThickness)
if let attachment: Attachment = attachment {
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
@ -181,13 +173,26 @@ final class QuoteView: UIView {
}
}
else {
// Line view
let lineColor: ThemeValue = {
switch mode {
case .regular: return (direction == .outgoing ? .messageBubble_outgoingText : .primary)
case .draft: return .primary
}
}()
let lineView = UIView()
lineView.themeBackgroundColor = lineColor
mainStackView.addArrangedSubview(lineView)
lineView.pin(.top, to: .top, of: mainStackView)
lineView.pin(.bottom, to: .bottom, of: mainStackView)
lineView.set(.width, to: Values.accentLineThickness)
}
// Body label
let bodyLabel = TappableLabel()
bodyLabel.numberOfLines = 0
bodyLabel.lineBreakMode = .byTruncatingTail
bodyLabel.numberOfLines = 2
let targetThemeColor: ThemeValue = {
switch mode {
@ -209,7 +214,8 @@ final class QuoteView: UIView {
in: $0,
threadVariant: threadVariant,
currentUserPublicKey: currentUserPublicKey,
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
isOutgoingMessage: (direction == .outgoing),
textColor: textColor,
theme: theme,
@ -229,11 +235,11 @@ final class QuoteView: UIView {
// Label stack view
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
var authorLabelHeight: CGFloat?
let isCurrentUser: Bool = [
currentUserPublicKey,
currentUserBlindedPublicKey,
currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey
]
.compactMap { $0 }
.asSet()
@ -259,16 +265,12 @@ final class QuoteView: UIView {
authorLabel.themeTextColor = targetThemeColor
authorLabel.lineBreakMode = .byTruncatingTail
authorLabel.isHidden = (authorLabel.text == nil)
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
authorLabel.set(.height, to: authorLabelSize.height)
authorLabelHeight = authorLabelSize.height
authorLabel.numberOfLines = 1
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
labelStackView.axis = .vertical
labelStackView.spacing = labelStackViewSpacing
labelStackView.distribution = .equalCentering
labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width))
labelStackView.isLayoutMarginsRelativeArrangement = true
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
mainStackView.addArrangedSubview(labelStackView)
@ -277,29 +279,6 @@ final class QuoteView: UIView {
contentView.addSubview(mainStackView)
mainStackView.pin(to: contentView)
if threadVariant != .openGroup && threadVariant != .closedGroup {
bodyLabel.set(.width, to: bodyLabelSize.width)
}
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
let contentViewHeight: CGFloat
if attachment != nil {
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
bodyLabel.set(.height, to: 18) // Experimentally determined
}
else {
if let authorLabelHeight = authorLabelHeight { // Group thread
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
}
else {
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
}
}
contentView.set(.height, to: contentViewHeight)
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
if mode == .draft {
// Cancel button
let cancelButton = UIButton(type: .custom)

View File

@ -9,6 +9,13 @@ final class ReactionContainerView: UIView {
private static let arrowSize: CGSize = CGSize(width: 15, height: 13)
private static let arrowSpacing: CGFloat = Values.verySmallSpacing
// We have explicit limits on the number of emoji which should be displayed before they
// automatically get collapsed, these values are consistent across platforms so are set
// here (even though the logic will automatically calculate and limit to a single line
// of reactions dynamically for the size of the view)
private static let numCollapsedEmoji: Int = 4
private static let maxEmojiBeforeCollapse: Int = 6
private var maxWidth: CGFloat = 0
private var collapsedCount: Int = 0
private var showingAllReactions: Bool = false
@ -173,7 +180,10 @@ final class ReactionContainerView: UIView {
numReactions += 1
}
return numReactions
return (numReactions > ReactionContainerView.maxEmojiBeforeCollapse ?
ReactionContainerView.numCollapsedEmoji :
numReactions
)
}()
self.showNumbers = showNumbers
self.reactionViews = []

View File

@ -2,6 +2,7 @@
import UIKit
import SessionUIKit
import SignalCoreKit
@objc class TypingIndicatorView: UIStackView {
// This represents the spacing between the dots

View File

@ -4,6 +4,7 @@ import UIKit
import SignalUtilitiesKit
import SessionUtilitiesKit
import SessionMessagingKit
import SessionUIKit
final class DateHeaderCell: MessageCell {
// MARK: - UI

View File

@ -6,7 +6,7 @@ import SessionMessagingKit
final class InfoMessageCell: MessageCell {
private static let iconSize: CGFloat = 16
private static let inset = Values.mediumSpacing
public static let inset = Values.mediumSpacing
private var isHandlingLongPress: Bool = false

View File

@ -65,6 +65,7 @@ public class MessageCell: UITableViewCell {
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
guard viewModel.cellType != .dateHeader else { return DateHeaderCell.self }
guard viewModel.cellType != .unreadMarker else { return UnreadMarkerCell.self }
switch viewModel.variant {
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:

View File

@ -0,0 +1,74 @@
// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SignalUtilitiesKit
import SessionUtilitiesKit
import SessionMessagingKit
import SessionUIKit
final class UnreadMarkerCell: MessageCell {
public static let height: CGFloat = 32
// MARK: - UI
private let leftLine: UIView = {
let result: UIView = UIView()
result.themeBackgroundColor = .unreadMarker
result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness'
return result
}()
private lazy var titleLabel: UILabel = {
let result = UILabel()
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
result.text = "UNREAD_MESSAGES".localized()
result.themeTextColor = .unreadMarker
result.textAlignment = .center
return result
}()
private let rightLine: UIView = {
let result: UIView = UIView()
result.themeBackgroundColor = .unreadMarker
result.set(.height, to: 1) // Intentionally 1 instead of 'separatorThickness'
return result
}()
// MARK: - Initialization
override func setUpViewHierarchy() {
super.setUpViewHierarchy()
addSubview(leftLine)
addSubview(titleLabel)
addSubview(rightLine)
leftLine.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing)
leftLine.pin(.trailing, to: .leading, of: titleLabel, withInset: -Values.smallSpacing)
leftLine.center(.vertical, in: self)
titleLabel.center(.horizontal, in: self)
titleLabel.center(.vertical, in: self)
titleLabel.pin(.top, to: .top, of: self, withInset: Values.smallSpacing)
titleLabel.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
rightLine.pin(.leading, to: .trailing, of: titleLabel, withInset: Values.smallSpacing)
rightLine.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing)
rightLine.center(.vertical, in: self)
}
// MARK: - Updating
override func update(
with cellViewModel: MessageViewModel,
mediaCache: NSCache<NSString, AnyObject>,
playbackInfo: ConversationViewModel.PlaybackInfo?,
showExpandedReactions: Bool,
lastSearchText: String?
) {
guard cellViewModel.cellType == .unreadMarker else { return }
}
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {}
}

View File

@ -22,7 +22,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
private lazy var profilePictureViewLeadingConstraint = profilePictureView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var profilePictureViewWidthConstraint = profilePictureView.set(.width, to: Values.verySmallProfilePictureSize)
private lazy var contentViewLeadingConstraint1 = snContentView.pin(.leading, to: .trailing, of: profilePictureView, withInset: VisibleMessageCell.groupThreadHSpacing)
private lazy var contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize)
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
@ -51,22 +50,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
private lazy var viewsToMoveForReply: [UIView] = [
snContentView,
profilePictureView,
moderatorIconImageView,
replyButton,
timerView,
messageStatusImageView,
reactionContainerView
]
private lazy var profilePictureView: ProfilePictureView = {
let result: ProfilePictureView = ProfilePictureView()
result.set(.height, to: Values.verySmallProfilePictureSize)
result.size = Values.verySmallProfilePictureSize
return result
}()
private lazy var moderatorIconImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message)
lazy var bubbleBackgroundView: UIView = {
let result = UIView()
@ -176,7 +166,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
private static let messageStatusImageViewSize: CGFloat = 12
private static let authorLabelBottomSpacing: CGFloat = 4
private static let groupThreadHSpacing: CGFloat = 12
private static let profilePictureSize = Values.verySmallProfilePictureSize
private static let authorLabelInset: CGFloat = 12
private static let replyButtonSize: CGFloat = 24
private static let maxBubbleTranslationX: CGFloat = 40
@ -186,7 +175,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
static let contactThreadHSpacing = Values.mediumSpacing
static var gutterSize: CGFloat = {
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
var result = groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing
if UIDevice.current.isIPad {
result += 168
@ -195,7 +184,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
return result
}()
static var leftGutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
static var leftGutterSize: CGFloat { groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing }
// MARK: Direction & Position
@ -214,21 +203,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
// Profile picture view
addSubview(profilePictureView)
profilePictureViewLeadingConstraint.isActive = true
profilePictureViewWidthConstraint.isActive = true
// Moderator icon image view
moderatorIconImageView.set(.width, to: 20)
moderatorIconImageView.set(.height, to: 20)
addSubview(moderatorIconImageView)
moderatorIconImageView.pin(.trailing, to: .trailing, of: profilePictureView, withInset: 1)
moderatorIconImageView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: 4.5)
// Content view
addSubview(snContentView)
contentViewLeadingConstraint1.isActive = true
contentViewTopConstraint.isActive = true
contentViewTrailingConstraint1.isActive = true
snContentView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: -1)
snContentView.pin(.bottom, to: .bottom, of: profilePictureView)
// Bubble background view
bubbleBackgroundView.addSubview(bubbleView)
@ -301,9 +282,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
lastSearchText: String?
) {
self.viewModel = cellViewModel
self.bubbleView.accessibilityIdentifier = "Message Body"
self.bubbleView.isAccessibilityElement = true
self.bubbleView.accessibilityLabel = cellViewModel.body
// We want to add spacing between "clusters" of messages to indicate that time has
// passed (even if there wasn't enough time to warrant showing a date header)
let shouldAddTopInset: Bool = (
@ -313,18 +292,28 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
cellViewModel.isOnlyMessageInCluster
)
)
let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup)
let isGroupThread: Bool = (
cellViewModel.threadVariant == .community ||
cellViewModel.threadVariant == .legacyGroup ||
cellViewModel.threadVariant == .group
)
// Profile picture view
// Profile picture view (should always be handled as a standard 'contact' profile picture)
let profileShouldBeVisible: Bool = (
cellViewModel.canHaveProfile &&
cellViewModel.shouldShowProfile &&
cellViewModel.profile != nil
)
profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0)
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
profilePictureView.isHidden = !cellViewModel.canHaveProfile
profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0)
profilePictureView.update(
publicKey: cellViewModel.authorId,
threadVariant: .contact, // Always show the display picture in 'contact' mode
customImageData: nil,
profile: cellViewModel.profile,
threadVariant: cellViewModel.threadVariant
profileIcon: (cellViewModel.isSenderOpenGroupModerator ? .crown : .none)
)
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
// Bubble view
contentViewLeadingConstraint1.isActive = (
@ -356,6 +345,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
lastSearchText: lastSearchText
)
bubbleView.accessibilityIdentifier = "Message Body"
bubbleView.accessibilityLabel = bodyTappableLabel?.attributedText?.string
bubbleView.isAccessibilityElement = true
// Author label
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
authorLabel.isHidden = (cellViewModel.senderName == nil)
@ -392,11 +385,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
)
// Swipe to reply
if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoCall {
removeGestureRecognizer(panGestureRecognizer)
if ContextMenuVC.viewModelCanReply(cellViewModel) {
addGestureRecognizer(panGestureRecognizer)
}
else {
addGestureRecognizer(panGestureRecognizer)
removeGestureRecognizer(panGestureRecognizer)
}
// Under bubble content
@ -496,7 +489,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
}
switch cellViewModel.cellType {
case .typingIndicator, .dateHeader: break
case .typingIndicator, .dateHeader, .unreadMarker: break
case .textOnlyMessage:
let inset: CGFloat = 12
@ -549,7 +542,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
quotedText: quote.body,
threadVariant: cellViewModel.threadVariant,
currentUserPublicKey: cellViewModel.currentUserPublicKey,
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
direction: (cellViewModel.variant == .standardOutgoing ?
.outgoing :
.incoming
@ -718,8 +712,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
maxWidth: maxWidth,
showingAllReactions: showExpandedReactions,
showNumbers: (
cellViewModel.threadVariant == .closedGroup ||
cellViewModel.threadVariant == .openGroup
cellViewModel.threadVariant == .legacyGroup ||
cellViewModel.threadVariant == .group ||
cellViewModel.threadVariant == .community
)
)
}
@ -873,8 +868,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile {
// For open groups only attempt to start a conversation if the author has a blinded id
guard cellViewModel.threadVariant != .openGroup else {
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
guard cellViewModel.threadVariant != .community else {
// FIXME: Add in support for opening a conversation with a 'blinded25' id
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded15 else { return }
delegate?.startThread(
with: cellViewModel.authorId,
@ -1083,8 +1079,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
case .standardIncoming, .standardIncomingDeleted:
let isGroupThread = (
cellViewModel.threadVariant == .openGroup ||
cellViewModel.threadVariant == .closedGroup
cellViewModel.threadVariant == .community ||
cellViewModel.threadVariant == .legacyGroup ||
cellViewModel.threadVariant == .group
)
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
@ -1123,7 +1120,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
in: (cellViewModel.body ?? ""),
threadVariant: cellViewModel.threadVariant,
currentUserPublicKey: cellViewModel.currentUserPublicKey,
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
isOutgoingMessage: isOutgoing,
textColor: actualTextColor,
theme: theme,

View File

@ -6,6 +6,7 @@
#import "OWSMath.h"
#import "UIView+OWS.h"
#import <QuartzCore/QuartzCore.h>
#import <SignalCoreKit/OWSAsserts.h>
#import <PureLayout/PureLayout.h>
#import <SignalCoreKit/NSDate+OWS.h>
#import <SessionUtilitiesKit/NSTimer+Proxying.h>

View File

@ -7,8 +7,9 @@ import DifferenceKit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SessionSnodeKit
class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappearingMessagesViewModel.NavButton, ThreadDisappearingMessagesViewModel.Section, ThreadDisappearingMessagesViewModel.Item> {
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadDisappearingMessagesSettingsViewModel.NavButton, ThreadDisappearingMessagesSettingsViewModel.Section, ThreadDisappearingMessagesSettingsViewModel.Item> {
// MARK: - Config
enum NavButton: Equatable {
@ -30,6 +31,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
private let dependencies: Dependencies
private let threadId: String
private let threadVariant: SessionThread.Variant
private let config: DisappearingMessagesConfiguration
private var storedSelection: TimeInterval
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
@ -39,10 +41,12 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
init(
dependencies: Dependencies = Dependencies(),
threadId: String,
threadVariant: SessionThread.Variant,
config: DisappearingMessagesConfiguration
) {
self.dependencies = dependencies
self.threadId = threadId
self.threadVariant = threadVariant
self.config = config
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
self.currentSelection = CurrentValueSubject(self.storedSelection)
@ -85,10 +89,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
override var title: String { "DISAPPEARING_MESSAGES".localized() }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -97,7 +98,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, config, dependencies, threadId = self.threadId] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
@ -115,7 +116,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
isSelected: { (self?.currentSelection.value == 0) }
),
isEnabled: (
maybeThreadViewModel?.threadVariant != .closedGroup ||
(
maybeThreadViewModel?.threadVariant != .legacyGroup &&
maybeThreadViewModel?.threadVariant != .group
) ||
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
),
onTap: { self?.currentSelection.send(0) }
@ -132,7 +136,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
isSelected: { (self?.currentSelection.value == duration) }
),
isEnabled: (
maybeThreadViewModel?.threadVariant != .closedGroup ||
(
maybeThreadViewModel?.threadVariant != .legacyGroup &&
maybeThreadViewModel?.threadVariant != .group
) ||
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
),
onTap: { self?.currentSelection.send(duration) }
@ -143,16 +150,15 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadDisappearingMessageSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
private func saveChanges() {
let threadId: String = self.threadId
let threadVariant: SessionThread.Variant = self.threadVariant
let currentSelection: TimeInterval = self.currentSelection.value
let updatedConfig: DisappearingMessagesConfiguration = self.config
.with(
@ -163,10 +169,6 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
guard self.config != updatedConfig else { return }
dependencies.storage.writeAsync { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
return
}
let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
.fetchOne(db, id: threadId)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
@ -192,8 +194,22 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
duration: UInt32(floor(updatedConfig.isEnabled ? updatedConfig.durationSeconds : 0))
),
interactionId: interaction.id,
in: thread
threadId: threadId,
threadVariant: threadVariant
)
// Legacy closed groups
switch threadVariant {
case .legacyGroup:
try SessionUtil
.update(
db,
groupPublicKey: threadId,
disappearingConfig: updatedConfig
)
default: break
}
}
}
}

View File

@ -30,7 +30,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
public enum Setting: Differentiable {
case threadInfo
case avatar
case nickname
case sessionId
case copyThreadId
case allMedia
case searchConversation
@ -81,10 +84,22 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
// MARK: - Navigation
lazy var navState: AnyPublisher<NavState, Never> = {
isEditing
.map { isEditing in (isEditing ? .editing : .standard) }
Publishers
.CombineLatest(
isEditing,
textChanged
.handleEvents(
receiveOutput: { [weak self] value, _ in
self?.editedDisplayName = value
}
)
.filter { _ in false }
.prepend((nil, .nickname))
)
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
.removeDuplicates()
.prepend(.standard) // Initial value
.shareReplay(1)
.eraseToAnyPublisher()
}()
@ -138,7 +153,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
dependencies.storage.writeAsync { db in
try Profile
.filter(id: threadId)
.updateAll(
.updateAllAndConfig(
db,
Profile.Columns.nickname
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
@ -166,14 +181,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
override var title: String {
switch threadVariant {
case .contact: return "vc_settings_title".localized()
case .closedGroup, .openGroup: return "vc_group_settings_title".localized()
case .legacyGroup, .group, .community: return "vc_group_settings_title".localized()
}
}
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
public override var observableTableData: ObservableData { _observableTableData }
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
@ -182,14 +194,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
private lazy var _observableSettingsData: ObservableData = ValueObservation
private lazy var _observableTableData: ObservableData = ValueObservation
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
.fetchOne(db)
guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else { return [] }
// If we don't get a `SessionThreadViewModel` then it means the thread was probably deleted
// so dismiss the screen
guard let threadViewModel: SessionThreadViewModel = maybeThreadViewModel else {
self?.dismissScreen(type: .popToRoot)
return []
}
// Additional Queries
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
@ -204,68 +221,148 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.fetchOne(db, id: threadId)
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
let currentUserIsClosedGroupMember: Bool = (
threadVariant == .closedGroup &&
(
threadVariant == .legacyGroup ||
threadVariant == .group
) &&
threadViewModel.currentUserIsClosedGroupMember == true
)
let currentUserIsClosedGroupAdmin: Bool = (
threadVariant == .closedGroup &&
(
threadVariant == .legacyGroup ||
threadVariant == .group
) &&
threadViewModel.currentUserIsClosedGroupAdmin == true
)
let editIcon: UIImage? = UIImage(named: "icon_edit")
return [
SectionModel(
model: .conversationInfo,
elements: [
SessionCell.Info(
id: .threadInfo,
leftAccessory: .threadInfo(
threadViewModel: threadViewModel,
avatarTapped: { [weak self] in
self?.updateProfilePicture(threadViewModel: threadViewModel)
},
titleTapped: { [weak self] in self?.setIsEditing(true) },
titleChanged: { [weak self] text in self?.editedDisplayName = text }
id: .avatar,
accessory: .profile(
id: threadViewModel.id,
size: .hero,
threadVariant: threadVariant,
customImageData: threadViewModel.openGroupProfilePictureData,
profile: threadViewModel.profile,
profileIcon: .none,
additionalProfile: threadViewModel.additionalProfile,
additionalProfileIcon: .none,
accessibility: nil
),
title: threadViewModel.displayName,
shouldHaveBackground: false
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
backgroundStyle: .noBackground
),
onTap: { self?.viewProfilePicture(threadViewModel: threadViewModel) }
),
SessionCell.Info(
id: .nickname,
leftAccessory: (threadVariant != .contact ? nil :
.icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .fit,
customTint: .textSecondary
)
),
title: SessionCell.TextInfo(
threadViewModel.displayName,
font: .titleLarge,
alignment: .center,
editingPlaceholder: "CONTACT_NICKNAME_PLACEHOLDER".localized(),
interaction: (threadVariant == .contact ? .editable : .none)
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
trailing: (threadVariant != .contact ?
nil :
-(((editIcon?.size.width ?? 0) + (Values.smallSpacing * 2)) / 2)
),
bottom: (threadVariant != .contact ?
nil :
Values.smallSpacing
),
interItem: 0
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Username",
label: threadViewModel.displayName
),
onTap: {
self?.textChanged(self?.oldDisplayName, for: .nickname)
self?.setIsEditing(true)
}
),
(threadVariant != .contact ? nil :
SessionCell.Info(
id: .sessionId,
subtitle: SessionCell.TextInfo(
threadViewModel.id,
font: .monoSmall,
alignment: .center,
interaction: .copy
),
styling: SessionCell.StyleInfo(
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
bottom: Values.largeSpacing
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Session ID",
label: threadViewModel.id
)
)
)
]
].compactMap { $0 }
),
SectionModel(
model: .content,
elements: [
(threadVariant == .closedGroup ? nil :
(threadVariant == .legacyGroup || threadVariant == .group ? nil :
SessionCell.Info(
id: .copyThreadId,
leftAccessory: .icon(
UIImage(named: "ic_copy")?
.withRenderingMode(.alwaysTemplate)
),
title: (threadVariant == .openGroup ?
title: (threadVariant == .community ?
"COPY_GROUP_URL".localized() :
"vc_conversation_settings_copy_session_id_button_title".localized()
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
accessibilityLabel: "Copy Session ID",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
label: "Copy Session ID"
),
onTap: {
switch threadVariant {
case .contact, .closedGroup:
case .contact, .legacyGroup, .group:
UIPasteboard.general.string = threadId
case .openGroup:
case .community:
guard
let server: String = threadViewModel.openGroupServer,
let roomToken: String = threadViewModel.openGroupRoomToken,
let publicKey: String = threadViewModel.openGroupPublicKey
else { return }
UIPasteboard.general.string = OpenGroup.urlFor(
UIPasteboard.general.string = SessionUtil.communityUrlFor(
server: server,
roomToken: roomToken,
publicKey: publicKey
)
}
self?.showToast(
text: "copied".localized(),
backgroundColor: .backgroundSecondary
@ -273,7 +370,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
)
),
SessionCell.Info(
id: .allMedia,
leftAccessory: .icon(
@ -281,8 +378,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: MediaStrings.allMedia,
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
accessibilityLabel: "All media",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).all_media",
label: "All media"
),
onTap: { [weak self] in
self?.transitionToScreen(
MediaGalleryViewModel.createAllMediaViewController(
@ -293,7 +392,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
)
}
),
SessionCell.Info(
id: .searchConversation,
leftAccessory: .icon(
@ -301,14 +400,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
accessibilityLabel: "Search",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).search",
label: "Search"
),
onTap: { [weak self] in
self?.didTriggerSearch()
}
),
(threadVariant != .openGroup ? nil :
(threadVariant != .community ? nil :
SessionCell.Info(
id: .addToOpenGroup,
leftAccessory: .icon(
@ -316,7 +417,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "vc_conversation_settings_invite_button_title".localized(),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
),
onTap: { [weak self] in
self?.transitionToScreen(
UserSelectionVC(
@ -332,8 +435,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
)
),
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
(threadVariant == .community || threadViewModel.threadIsBlocked == true ? nil :
SessionCell.Info(
id: .disappearingMessages,
leftAccessory: .icon(
@ -342,7 +445,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
"ic_timer" :
"ic_timer_disabled"
)
)?.withRenderingMode(.alwaysTemplate)
)?.withRenderingMode(.alwaysTemplate),
accessibility: Accessibility(
label: "Timer icon"
)
),
title: "DISAPPEARING_MESSAGES".localized(),
subtitle: (disappearingMessagesConfig.isEnabled ?
@ -352,14 +458,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
) :
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
accessibilityLabel: "Disappearing messages",
leftAccessoryAccessibilityLabel: "Timer icon",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
label: "Disappearing messages"
),
onTap: { [weak self] in
self?.transitionToScreen(
SessionTableViewController(
viewModel: ThreadDisappearingMessagesViewModel(
viewModel: ThreadDisappearingMessagesSettingsViewModel(
threadId: threadId,
threadVariant: threadVariant,
config: disappearingMessagesConfig
)
)
@ -367,7 +475,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
}
)
),
(!currentUserIsClosedGroupMember ? nil :
SessionCell.Info(
id: .editGroup,
@ -376,10 +484,14 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "EDIT_GROUP_ACTION".localized(),
accessibilityIdentifier: "Edit group",
accessibilityLabel: "Edit group",
accessibility: Accessibility(
identifier: "Edit group",
label: "Edit group"
),
onTap: { [weak self] in
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
self?.transitionToScreen(
EditClosedGroupVC(threadId: threadId, threadVariant: threadVariant)
)
}
)
),
@ -392,8 +504,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.withRenderingMode(.alwaysTemplate)
),
title: "LEAVE_GROUP_ACTION".localized(),
accessibilityIdentifier: "Leave group",
accessibilityLabel: "Leave group",
accessibility: Accessibility(
identifier: "Leave group",
label: "Leave group"
),
confirmationInfo: ConfirmationModal.Info(
title: "leave_group_confirmation_alert_title".localized(),
body: .attributedText({
@ -419,8 +533,14 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
cancelStyle: .alert_text
),
onTap: { [weak self] in
dependencies.storage.writeAsync { db in
try MessageSender.leave(db, groupPublicKey: threadId, deleteThread: false)
dependencies.storage.write { db in
try SessionThread.deleteOrLeave(
db,
threadId: threadId,
threadVariant: threadVariant,
groupLeaveType: .standard,
calledFromConfigHandling: false
)
}
}
)
@ -460,11 +580,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.boolValue(threadViewModel.threadOnlyNotifyForMentions == true)
),
isEnabled: (
threadViewModel.threadVariant != .closedGroup ||
(
threadViewModel.threadVariant != .legacyGroup &&
threadViewModel.threadVariant != .group
) ||
currentUserIsClosedGroupMember
),
accessibilityIdentifier: "Mentions only notification setting",
accessibilityLabel: "Mentions only",
accessibility: Accessibility(
identifier: "Mentions only notification setting",
label: "Mentions only"
),
onTap: {
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
@ -493,11 +618,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.boolValue(threadViewModel.threadMutedUntilTimestamp != nil)
),
isEnabled: (
threadViewModel.threadVariant != .closedGroup ||
(
threadViewModel.threadVariant != .legacyGroup &&
threadViewModel.threadVariant != .group
) ||
currentUserIsClosedGroupMember
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
accessibilityLabel: "Mute notifications",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).mute",
label: "Mute notifications"
),
onTap: {
dependencies.storage.writeAsync { db in
let currentValue: TimeInterval? = try SessionThread
@ -533,8 +663,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
rightAccessory: .toggle(
.boolValue(threadViewModel.threadIsBlocked == true)
),
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
accessibilityLabel: "Block",
accessibility: Accessibility(
identifier: "\(ThreadSettingsViewModel.self).block",
label: "Block"
),
confirmationInfo: ConfirmationModal.Info(
title: {
guard threadViewModel.threadIsBlocked == true else {
@ -556,7 +688,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
"BLOCK_LIST_BLOCK_BUTTON".localized()
),
confirmAccessibilityLabel: "Confirm block",
confirmAccessibility: Accessibility(identifier: "Confirm block"),
confirmStyle: .danger,
cancelStyle: .alert_text
),
@ -577,15 +709,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
]
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[ThreadSettingsViewModel] Observation failed with error: \($0)") })
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
.mapToSessionTableViewData(for: self)
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
private func viewProfilePicture(threadViewModel: SessionThreadViewModel) {
guard
threadViewModel.threadVariant == .contact,
let profile: Profile = threadViewModel.profile,
@ -619,18 +749,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
let publicKey: String = threadViewModel.openGroupPublicKey
else { return }
let communityUrl: String = SessionUtil.communityUrlFor(
server: server,
roomToken: roomToken,
publicKey: publicKey
)
dependencies.storage.writeAsync { db in
let urlString: String = OpenGroup.urlFor(
server: server,
roomToken: roomToken,
publicKey: publicKey
)
try selectedUsers.forEach { userId in
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: userId, variant: .contact)
let thread: SessionThread = try SessionThread
.fetchOrCreate(db, id: userId, variant: .contact, shouldBeVisible: nil)
try LinkPreview(
url: urlString,
url: communityUrl,
variant: .openGroupInvitation,
title: name
)
@ -647,14 +778,15 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
.asRequest(of: TimeInterval.self)
.fetchOne(db),
linkPreviewUrl: urlString
linkPreviewUrl: communityUrl
)
.inserted(db)
try MessageSender.send(
db,
interaction: interaction,
in: thread
threadId: thread.id,
threadVariant: thread.variant
)
}
}
@ -671,13 +803,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
dependencies.storage.writeAsync(
updates: { db in
try Contact
.fetchOrCreate(db, id: threadId)
.with(isBlocked: .updateTo(isBlocked))
.save(db)
.filter(id: threadId)
.updateAllAndConfig(
db,
Contact.Columns.isBlocked.set(to: isBlocked)
)
},
completion: { [weak self] db, _ in
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
DispatchQueue.main.async {
let modal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
@ -694,10 +826,12 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
displayName
)
)),
accessibilityLabel: oldBlockedState == false ? "User blocked" : "Confirm unblock",
accessibilityId: "Test_name",
accessibility: Accessibility(
identifier: "Test_name",
label: (oldBlockedState == false ? "User blocked" : "Confirm unblock")
),
cancelTitle: "BUTTON_OK".localized(),
cancelAccessibilityLabel: "OK_BUTTON",
cancelAccessibility: Accessibility(identifier: "OK_BUTTON"),
cancelStyle: .alert_text
)
)

View File

@ -71,10 +71,13 @@ final class ConversationTitleView: UIView {
// MARK: - Content
public func initialSetup(with threadVariant: SessionThread.Variant) {
public func initialSetup(
with threadVariant: SessionThread.Variant,
isNoteToSelf: Bool
) {
self.update(
with: " ",
isNoteToSelf: false,
isNoteToSelf: isNoteToSelf,
threadVariant: threadVariant,
mutedUntilTimestamp: nil,
onlyNotifyForMentions: false,
@ -139,9 +142,9 @@ final class ConversationTitleView: UIView {
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
subtitleLabel?.attributedText = NSAttributedString(
string: "\u{e067} ",
string: FullConversationCell.mutePrefix,
attributes: [
.font: UIFont.ows_elegantIconsFont(10),
.font: UIFont(name: "ElegantIcons", size: 10) as Any,
.foregroundColor: textPrimary
]
)
@ -168,12 +171,12 @@ final class ConversationTitleView: UIView {
switch threadVariant {
case .contact: break
case .closedGroup:
case .legacyGroup, .group:
subtitleLabel?.attributedText = NSAttributedString(
string: "\(userCount) member\(userCount == 1 ? "" : "s")"
)
case .openGroup:
case .community:
subtitleLabel?.attributedText = NSAttributedString(
string: "\(userCount) active member\(userCount == 1 ? "" : "s")"
)

View File

@ -431,7 +431,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
cell.update(
with: SessionCell.Info(
id: cellViewModel,
leftAccessory: .profile(authorId, cellViewModel.profile),
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count),
leftAccessory: .profile(id: authorId, profile: cellViewModel.profile),
title: (
cellViewModel.profile?.displayName() ??
Profile.truncated(
@ -446,10 +447,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
size: .fit
)
),
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
),
style: .edgeToEdge,
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
)
)
return cell

View File

@ -3,8 +3,8 @@
import UIKit
import SessionUIKit
final class ScrollToBottomButton: UIView {
private weak var delegate: ScrollToBottomButtonDelegate?
final class RoundIconButton: UIView {
private let onTap: () -> ()
// MARK: - Settings
@ -13,12 +13,12 @@ final class ScrollToBottomButton: UIView {
// MARK: - Lifecycle
init(delegate: ScrollToBottomButtonDelegate) {
self.delegate = delegate
init(image: UIImage?, onTap: @escaping () -> ()) {
self.onTap = onTap
super.init(frame: CGRect.zero)
setUpViewHierarchy()
setUpViewHierarchy(image: image)
}
override init(frame: CGRect) {
@ -29,7 +29,7 @@ final class ScrollToBottomButton: UIView {
preconditionFailure("Use init(delegate:) instead.")
}
private func setUpViewHierarchy() {
private func setUpViewHierarchy(image: UIImage?) {
// Background & blur
let backgroundView = UIView()
backgroundView.themeBackgroundColor = .backgroundSecondary
@ -49,9 +49,9 @@ final class ScrollToBottomButton: UIView {
}
// Size & shape
set(.width, to: ScrollToBottomButton.size)
set(.height, to: ScrollToBottomButton.size)
layer.cornerRadius = (ScrollToBottomButton.size / 2)
set(.width, to: RoundIconButton.size)
set(.height, to: RoundIconButton.size)
layer.cornerRadius = (RoundIconButton.size / 2)
layer.masksToBounds = true
// Border
@ -59,16 +59,13 @@ final class ScrollToBottomButton: UIView {
layer.borderWidth = Values.separatorThickness
// Icon
let iconImageView = UIImageView(
image: UIImage(named: "ic_chevron_down")?
.withRenderingMode(.alwaysTemplate)
)
let iconImageView = UIImageView(image: image)
iconImageView.themeTintColor = .textPrimary
iconImageView.contentMode = .scaleAspectFit
addSubview(iconImageView)
iconImageView.center(in: self)
iconImageView.set(.width, to: ScrollToBottomButton.iconSize)
iconImageView.set(.height, to: ScrollToBottomButton.iconSize)
iconImageView.set(.width, to: RoundIconButton.iconSize)
iconImageView.set(.height, to: RoundIconButton.iconSize)
// Gesture recognizer
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
@ -78,12 +75,6 @@ final class ScrollToBottomButton: UIView {
// MARK: - Interaction
@objc private func handleTap() {
delegate?.handleScrollToBottomButtonTapped()
onTap()
}
}
// MARK: - ScrollToBottomButtonDelegate
protocol ScrollToBottomButtonDelegate: AnyObject {
func handleScrollToBottomButtonTapped()
}

View File

@ -1,4 +1,5 @@
import Foundation
import SignalCoreKit
extension Emoji {
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])

View File

@ -5,6 +5,7 @@ import PureLayout
import SessionUIKit
import SessionUtilitiesKit
import NVActivityIndicatorView
import SignalCoreKit
class EmptySearchResultCell: UITableViewCell {
private lazy var messageLabel: UILabel = {

View File

@ -7,8 +7,9 @@ import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
import SignalCoreKit
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
// MARK: - SearchSection
@ -19,6 +20,15 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
case messages
}
// MARK: - SessionUtilRespondingViewController
let isConversationList: Bool = true
func forceRefreshIfNeeded() {
// Need to do this as the 'GlobalSearchViewController' doesn't observe database changes
updateSearchResults(searchText: searchText, force: true)
}
// MARK: - Variables
private lazy var defaultSearchResults: [SectionModel] = {
@ -150,7 +160,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
}
}
private func updateSearchResults(searchText rawSearchText: String) {
private func updateSearchResults(searchText rawSearchText: String, force: Bool = false) {
let searchText = rawSearchText.stripped
guard searchText.count > 0 else {
@ -161,7 +171,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
tableView.reloadData()
return
}
guard lastSearchText != searchText else { return }
guard force || lastSearchText != searchText else { return }
lastSearchText = searchText
@ -207,7 +217,14 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
self?.termForCurrentSearchResultSet = searchText
self?.searchResultSet = [
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
(hasResults ? nil : [
ArraySection(
model: .noResults,
elements: [
SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId)
]
)
]),
(hasResults ? sections : nil)
]
.compactMap { $0 }
@ -271,23 +288,46 @@ extension GlobalSearchViewController {
show(
threadId: section.elements[indexPath.row].threadId,
threadVariant: section.elements[indexPath.row].threadVariant,
focusedInteractionId: section.elements[indexPath.row].interactionId
focusedInteractionInfo: {
guard
let interactionId: Int64 = section.elements[indexPath.row].interactionId,
let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs
else { return nil }
return Interaction.TimestampInfo(
id: interactionId,
timestampMs: timestampMs
)
}()
)
}
}
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) {
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) {
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated)
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated)
}
return
}
// If it's a one-to-one thread then make sure the thread exists before pushing to it (in case the
// contact has been hidden)
if threadVariant == .contact {
Storage.shared.write { db in
try SessionThread.fetchOrCreate(
db,
id: threadId,
variant: threadVariant,
shouldBeVisible: nil // Don't change current state
)
}
}
let viewController: ConversationVC = ConversationVC(
threadId: threadId,
threadVariant: threadVariant,
focusedInteractionId: focusedInteractionId
focusedInteractionInfo: focusedInteractionInfo
)
self.navigationController?.pushViewController(viewController, animated: true)
}

View File

@ -8,18 +8,24 @@ import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
private static let loadingHeaderHeight: CGFloat = 40
public static let newConversationButtonSize: CGFloat = 60
private let viewModel: HomeViewModel = HomeViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var dataChangeObservable: DatabaseCancellable? {
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
}
private var hasLoadedInitialStateData: Bool = false
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
// MARK: - SessionUtilRespondingViewController
let isConversationList: Bool = true
// MARK: - Intialization
init() {
@ -222,7 +228,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
// Preparation
SessionApp.homeViewController.mutate { $0 = self }
updateNavBarButtons()
updateNavBarButtons(userProfile: self.viewModel.state.userProfile)
setUpNavBarSessionHeading()
// Recovery phrase reminder
@ -278,9 +284,12 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate {
appDelegate.startPollersIfNeeded()
// Do this only if we created a new Session ID, or if we already received the initial configuration message
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
appDelegate.syncConfigurationIfNeeded()
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
if !SessionUtil.userConfigsEnabled {
// Do this only if we created a new Session ID, or if we already received the initial configuration message
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
appDelegate.syncConfigurationIfNeeded()
}
}
}
@ -320,26 +329,31 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
// MARK: - Updating
private func startObservingChanges(didReturnFromBackground: Bool = false) {
// Start observing for data changes
public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) {
guard dataChangeObservable == nil else { return }
var runAndClearInitialChangeCallback: (() -> ())? = nil
runAndClearInitialChangeCallback = { [weak self] in
guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return }
onReceivedInitialChange?()
runAndClearInitialChangeCallback = nil
}
dataChangeObservable = Storage.shared.start(
viewModel.observableState,
// If we haven't done the initial load the trigger it immediately (blocking the main
// thread so we remain on the launch screen until it completes to be consistent with
// the old behaviour)
scheduling: (hasLoadedInitialStateData ?
.async(onQueue: .main) :
.immediate
),
onError: { _ in },
onChange: { [weak self] state in
// The default scheduler emits changes on the main thread
self?.handleUpdates(state)
runAndClearInitialChangeCallback?()
}
)
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
runAndClearInitialChangeCallback?()
}
// Note: When returning from the background we could have received notifications but the
@ -354,7 +368,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
private func stopObservingChanges() {
// Stop observing database changes
dataChangeObservable?.cancel()
self.dataChangeObservable = nil
self.viewModel.onThreadChange = nil
}
@ -368,7 +382,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
}
if updatedState.userProfile != self.viewModel.state.userProfile {
updateNavBarButtons()
updateNavBarButtons(userProfile: updatedState.userProfile)
}
// Update the 'view seed' UI
@ -395,8 +409,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true
UIView.performWithoutAnimation { [weak self] in
// Hide the 'loading conversations' label (now that we have received conversation data)
self?.loadingConversationsLabel.isHidden = true
@ -408,6 +420,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
)
self?.viewModel.updateThreadData(updatedData)
self?.tableView.reloadData()
self?.hasLoadedInitialThreadData = true
}
return
}
@ -446,7 +460,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
guard
self.hasLoadedInitialThreadData &&
!self.isAutoLoadingNextPage &&
!self.isLoadingMore
else { return }
self.isAutoLoadingNextPage = true
@ -475,21 +493,19 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
}
}
private func updateNavBarButtons() {
private func updateNavBarButtons(userProfile: Profile) {
// Profile picture view
let profilePictureSize = Values.verySmallProfilePictureSize
let profilePictureView = ProfilePictureView()
let profilePictureView = ProfilePictureView(size: .navigation)
profilePictureView.accessibilityIdentifier = "User settings"
profilePictureView.accessibilityLabel = "User settings"
profilePictureView.isAccessibilityElement = true
profilePictureView.size = profilePictureSize
profilePictureView.update(
publicKey: getUserHexEncodedPublicKey(),
profile: Profile.fetchOrCreateCurrentUser(),
threadVariant: .contact
publicKey: userProfile.id,
threadVariant: .contact,
customImageData: nil,
profile: userProfile,
additionalProfile: nil
)
profilePictureView.set(.width, to: profilePictureSize)
profilePictureView.set(.height, to: profilePictureSize)
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
@ -619,7 +635,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
variant: threadViewModel.threadVariant,
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
with: .none,
focusedInteractionId: nil,
focusedInteractionInfo: nil,
animated: true
)
@ -631,247 +647,106 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
return true
}
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
return nil
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
switch section.model {
case .messageRequests:
let hide: UIContextualAction = UIContextualAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _, completionHandler in
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
completionHandler(true)
}
hide.themeBackgroundColor = .conversationButton_swipeDestructive
return UISwipeActionsConfiguration(actions: [hide])
case .threads:
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
guard threadViewModel.interactionVariant != .infoClosedGroupCurrentUserLeaving else { return nil }
let pin: UIContextualAction = UIContextualAction(
title: (threadViewModel.threadIsPinned ? "UNPIN_BUTTON_TEXT".localized() : "PIN_BUTTON_TEXT".localized()),
icon: UIImage(systemName: "pin"),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 0,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
isPinned: !threadViewModel.threadIsPinned
// Cannot properly sync outgoing blinded message requests so don't provide the option
guard
threadViewModel.threadVariant != .contact ||
SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard
else { return nil }
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.toggleReadStatus],
for: .leading,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared.writeAsync { db in
try SessionThread
.filter(id: threadViewModel.threadId)
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned))
}
}
}
pin.themeBackgroundColor = .conversationButton_swipeTertiary
let mute: UIContextualAction = UIContextualAction(
title: ((threadViewModel.threadMutedUntilTimestamp != nil) ? "unmute_button_text".localized() : "mute_button_text".localized()),
icon: UIImage(systemName: "speaker.slash"),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 1,
indexPath: indexPath,
tableView: tableView
) { _, _, completionHandler in
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil)
)
completionHandler(true)
// Delay the change to give the cell "unswipe" animation some time to complete
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
Storage.shared.writeAsync { db in
let currentValue: TimeInterval? = try SessionThread
.filter(id: threadViewModel.threadId)
.select(.mutedUntilTimestamp)
.asRequest(of: TimeInterval.self)
.fetchOne(db)
try SessionThread
.filter(id: threadViewModel.threadId)
.updateAll(
db,
SessionThread.Columns.mutedUntilTimestamp.set(
to: (currentValue == nil ?
Date.distantFuture.timeIntervalSince1970 :
nil
)
)
)
}
}
}
mute.themeBackgroundColor = .conversationButton_swipeSecondary
switch (threadViewModel.threadVariant, threadViewModel.currentUserIsClosedGroupMember) {
case (.contact, _):
let delete: UIContextualAction = UIContextualAction(
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 2,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
let confirmationModalExplanation: NSAttributedString = {
let mutableAttributedString = NSMutableAttributedString(
string: String(
format: "delete_conversation_confirmation_alert_message".localized(),
threadViewModel.displayName
)
)
mutableAttributedString.addAttribute(
.font,
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
)
return mutableAttributedString
}()
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: "delete_conversation_confirmation_alert_title".localized(),
body: .attributedText(confirmationModalExplanation),
confirmTitle: "TXT_DELETE_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { [weak self] _ in
self?.viewModel.delete(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self?.dismiss(animated: true, completion: nil)
completionHandler(true)
},
afterClosed: { completionHandler(false) }
)
)
self?.present(confirmationModal, animated: true, completion: nil)
}
delete.themeBackgroundColor = .conversationButton_swipeDestructive
return UISwipeActionsConfiguration(actions: [ delete, mute, pin ])
case (.closedGroup, false):
let delete: UIContextualAction = UIContextualAction(
title: "TXT_DELETE_TITLE".localized(),
icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 2,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
self?.viewModel.delete(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant,
force: true
)
completionHandler(true)
}
return UISwipeActionsConfiguration(actions: [ delete, mute, pin ])
default:
let leave: UIContextualAction = UIContextualAction(
title: "LEAVE_BUTTON_TITLE".localized(),
icon: UIImage(systemName: "rectangle.portrait.and.arrow.right"),
iconHeight: Values.mediumFontSize,
themeTintColor: .white,
themeBackgroundColor: .conversationButton_swipeDestructive,
side: .trailing,
actionIndex: 2,
indexPath: indexPath,
tableView: tableView
) { [weak self] _, _, completionHandler in
let confirmationModalTitle: String = (threadViewModel.threadVariant == .closedGroup) ?
"leave_group_confirmation_alert_title".localized() :
"leave_community_confirmation_alert_title".localized()
let confirmationModalExplanation: NSAttributedString = {
if threadViewModel.threadVariant == .closedGroup && threadViewModel.currentUserIsClosedGroupAdmin == true {
return NSAttributedString(string: "admin_group_leave_warning".localized())
}
let mutableAttributedString = NSMutableAttributedString(
string: String(
format: "leave_community_confirmation_alert_message".localized(),
threadViewModel.displayName
)
)
mutableAttributedString.addAttribute(
.font,
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
)
return mutableAttributedString
}()
let confirmationModal: ConfirmationModal = ConfirmationModal(
info: ConfirmationModal.Info(
title: confirmationModalTitle,
body: .attributedText(confirmationModalExplanation),
confirmTitle: "LEAVE_BUTTON_TITLE".localized(),
confirmStyle: .danger,
cancelStyle: .alert_text,
dismissOnConfirm: true,
onConfirm: { [weak self] _ in
self?.viewModel.delete(
threadId: threadViewModel.threadId,
threadVariant: threadViewModel.threadVariant
)
self?.dismiss(animated: true, completion: nil)
completionHandler(true)
},
afterClosed: { completionHandler(false) }
)
)
self?.present(confirmationModal, animated: true, completion: nil)
}
leave.themeBackgroundColor = .conversationButton_swipeDestructive
return UISwipeActionsConfiguration(actions: [ leave, mute, pin ])
}
)
default: return nil
}
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
switch section.model {
case .messageRequests:
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[.hide],
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
case .threads:
let sessionIdPrefix: SessionId.Prefix? = SessionId(from: threadViewModel.threadId)?.prefix
// Cannot properly sync outgoing blinded message requests so only provide valid options
let shouldHavePinAction: Bool = (
sessionIdPrefix != .blinded15 &&
sessionIdPrefix != .blinded25
)
let shouldHaveMuteAction: Bool = {
switch threadViewModel.threadVariant {
case .contact: return (
!threadViewModel.threadIsNoteToSelf &&
sessionIdPrefix != .blinded15 &&
sessionIdPrefix != .blinded25
)
case .legacyGroup, .group: return (
threadViewModel.currentUserIsClosedGroupMember == true
)
case .community: return true
}
}()
let destructiveAction: UIContextualAction.SwipeAction = {
switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember) {
case (.contact, true, _): return .hide
case (.legacyGroup, _, true), (.group, _, true), (.community, _, _): return .leave
default: return .delete
}
}()
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(!shouldHavePinAction ? nil : .pin),
(!shouldHaveMuteAction ? nil : .mute),
destructiveAction
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
default: return nil
}
}
// MARK: - Interaction
@ -887,7 +762,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
variant: SessionThread.Variant,
isMessageRequest: Bool,
with action: ConversationViewModel.Action,
focusedInteractionId: Int64?,
focusedInteractionInfo: Interaction.TimestampInfo?,
animated: Bool
) {
if let presentedVC = self.presentedViewController {
@ -900,7 +775,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
ConversationVC(
threadId: threadId,
threadVariant: variant,
focusedInteractionId: focusedInteractionId
focusedInteractionInfo: focusedInteractionInfo
)
].compactMap { $0 }

View File

@ -4,6 +4,7 @@ import Foundation
import GRDB
import DifferenceKit
import SignalUtilitiesKit
import SessionMessagingKit
import SessionUtilitiesKit
public class HomeViewModel {
@ -19,38 +20,45 @@ public class HomeViewModel {
// MARK: - Variables
public static let pageSize: Int = 15
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
public struct State: Equatable {
let showViewedSeedBanner: Bool
let hasHiddenMessageRequests: Bool
let unreadMessageRequestThreadCount: Int
let userProfile: Profile?
init(
showViewedSeedBanner: Bool = !Storage.shared[.hasViewedSeed],
hasHiddenMessageRequests: Bool = Storage.shared[.hasHiddenMessageRequests],
unreadMessageRequestThreadCount: Int = 0,
userProfile: Profile? = nil
) {
self.showViewedSeedBanner = showViewedSeedBanner
self.hasHiddenMessageRequests = hasHiddenMessageRequests
self.unreadMessageRequestThreadCount = unreadMessageRequestThreadCount
self.userProfile = userProfile
}
let userProfile: Profile
}
// MARK: - Initialization
init() {
self.state = State()
typealias InitialData = (
showViewedSeedBanner: Bool,
hasHiddenMessageRequests: Bool,
profile: Profile
)
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
(
!db[.hasViewedSeed],
db[.hasHiddenMessageRequests],
Profile.fetchOrCreateCurrentUser(db)
)
}
self.state = State(
showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true),
hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false),
unreadMessageRequestThreadCount: 0,
userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser())
)
self.pagedDataObserver = nil
// Note: Since this references self we need to finish initializing before setting it, we
// also want to skip the initial query and trigger it async so that the push animation
// doesn't stutter (it should load basically immediately but without this there is a
// distinct stutter)
let userPublicKey: String = getUserHexEncodedPublicKey()
let userPublicKey: String = self.state.userProfile.id
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
self.pagedDataObserver = PagedDatabaseObserver(
pagedTable: SessionThread.self,
@ -62,9 +70,10 @@ public class HomeViewModel {
columns: [
.id,
.shouldBeVisible,
.isPinned,
.pinnedPriority,
.mutedUntilTimestamp,
.onlyNotifyForMentions
.onlyNotifyForMentions,
.markedAsUnread
]
),
PagedData.ObservedChanges(
@ -76,7 +85,7 @@ public class HomeViewModel {
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -85,7 +94,7 @@ public class HomeViewModel {
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -93,8 +102,53 @@ public class HomeViewModel {
columns: [.name, .nickname, .profilePictureFileName],
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group]
let targetRole: GroupMember.Role = GroupMember.Role.standard
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
return SQL("""
JOIN \(Profile.self) ON (
( -- Contact profile change
\(profile[.id]) = \(thread[.id]) AND
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)"))
) OR ( -- Closed group profile change
\(SQL("\(thread[.variant]) IN \(threadVariants)")) AND (
profile.id = ( -- Front profile
SELECT MIN(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(groupMember[.groupId]) = \(thread[.id]) AND
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) OR
profile.id = ( -- Back profile
SELECT MAX(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(groupMember[.groupId]) = \(thread[.id]) AND
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) OR ( -- Fallback profile
profile.id = \(userPublicKey) AND
(
SELECT COUNT(\(groupMember[.profileId]))
FROM \(GroupMember.self)
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
WHERE (
\(groupMember[.groupId]) = \(thread[.id]) AND
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
\(groupMember[.profileId]) != \(userPublicKey)
)
) = 1
)
)
)
)
""")
}()
),
PagedData.ObservedChanges(
@ -103,7 +157,7 @@ public class HomeViewModel {
joinToPagedType: {
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -112,7 +166,7 @@ public class HomeViewModel {
joinToPagedType: {
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -123,8 +177,8 @@ public class HomeViewModel {
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
),
@ -134,7 +188,7 @@ public class HomeViewModel {
joinToPagedType: {
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
}()
)
],
@ -155,15 +209,22 @@ public class HomeViewModel {
currentDataRetriever: { self?.threadData },
onDataChange: self?.onThreadChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedThreadDataChanges = (updatedData, changeset)
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
self?.hasReceivedInitialThreadData = true
}
)
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
// until we have data (Note: the `.pageBefore` will query from a `0` offset loading the first page)
self.pagedDataObserver?.load(.pageBefore)
// Run the initial query on a background thread so we don't block the main thread
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
// The `.pageBefore` will query from a `0` offset loading the first page
self?.pagedDataObserver?.load(.pageBefore)
}
}
// MARK: - State
@ -181,6 +242,7 @@ public class HomeViewModel {
public lazy var observableState = ValueObservation
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
.removeDuplicates()
.handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") })
private static func retrieveState(_ db: Database) throws -> State {
let hasViewedSeed: Bool = db[.hasViewedSeed]
@ -203,8 +265,10 @@ public class HomeViewModel {
let oldState: State = self.state
self.state = updatedState
// If the messageRequest content changed then we need to re-process the thread data
// If the messageRequest content changed then we need to re-process the thread data (assuming
// we've received the initial thread data)
guard
self.hasReceivedInitialThreadData,
(
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
@ -215,7 +279,7 @@ public class HomeViewModel {
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
let updatedThreadData: [SectionModel] = self.process(
data: currentData.flatMap { $0.elements },
data: (currentData.first(where: { $0.model == .threads })?.elements ?? []),
for: currentPageInfo
)
@ -223,14 +287,18 @@ public class HomeViewModel {
updatedData: updatedThreadData,
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
onDataChange: onThreadChange,
onUnobservedDataChange: { [weak self] updatedThreadData, changeset in
self?.unobservedThreadDataChanges = (updatedThreadData, changeset)
onUnobservedDataChange: { [weak self] updatedData, changeset in
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
// MARK: - Thread Data
private var hasReceivedInitialThreadData: Bool = false
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
public private(set) var threadData: [SectionModel] = []
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
@ -239,8 +307,14 @@ public class HomeViewModel {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
switch Thread.isMainThread {
case true: performChange?(changes.0, changes.1)
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
}
self.unobservedThreadDataChanges = nil
}
}
@ -264,7 +338,10 @@ public class HomeViewModel {
[SectionModel(
section: .messageRequests,
elements: [
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
SessionThreadViewModel(
threadId: SessionThreadViewModel.messageRequestsSectionId,
unreadCount: UInt(finalUnreadMessageRequestCount)
)
]
)]
),
@ -272,18 +349,25 @@ public class HomeViewModel {
SectionModel(
section: .threads,
elements: data
.filter { $0.id != SessionThreadViewModel.invalidId }
.filter { threadViewModel in
threadViewModel.id != SessionThreadViewModel.invalidId &&
threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId
}
.sorted { lhs, rhs -> Bool in
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else {
return lhs.threadPinnedPriority > rhs.threadPinnedPriority
}
return lhs.lastInteractionDate > rhs.lastInteractionDate
}
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKey(
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
viewModel.populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlindedPublicKey
.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlinded25PublicKey
)
}
)
@ -298,32 +382,4 @@ public class HomeViewModel {
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
// MARK: - Functions
public func delete(threadId: String, threadVariant: SessionThread.Variant, force: Bool = false) {
func delete(_ db: Database, threadId: String) throws {
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
}
Storage.shared.writeAsync { db in
switch (threadVariant, force) {
case (.closedGroup, false):
try MessageSender.leave(
db,
groupPublicKey: threadId,
deleteThread: true
)
case (.openGroup, _):
OpenGroupManager.shared.delete(db, openGroupId: threadId)
default:
try delete(db, threadId: threadId)
}
}
}
}

View File

@ -7,16 +7,19 @@ import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
private static let loadingHeaderHeight: CGFloat = 40
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
private var dataChangeObservable: DatabaseCancellable?
private var hasLoadedInitialThreadData: Bool = false
private var isLoadingMore: Bool = false
private var isAutoLoadingNextPage: Bool = false
private var viewHasAppeared: Bool = false
// MARK: - SessionUtilRespondingViewController
let isConversationList: Bool = true
// MARK: - Intialization
init() {
@ -103,6 +106,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
result.translatesAutoresizingMaskIntoConstraints = false
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
result.accessibilityIdentifier = "Clear all"
return result
}()
@ -157,8 +161,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
}
@objc func applicationDidBecomeActive(_ notification: Notification) {
@ -169,8 +172,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
}
// MARK: - Layout
@ -219,6 +221,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
}
}
private func stopObservingChanges() {
self.viewModel.onThreadChange = nil
}
private func handleThreadUpdates(
_ updatedData: [MessageRequestsViewModel.SectionModel],
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
@ -227,9 +233,18 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialThreadData else {
hasLoadedInitialThreadData = true
UIView.performWithoutAnimation {
handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true)
// Hide the 'loading conversations' label (now that we have received conversation data)
loadingConversationsLabel.isHidden = true
// Show the empty state if there is no data
clearAllButton.isHidden = !(updatedData.first?.elements.isEmpty == false)
emptyStateLabel.isHidden = !clearAllButton.isHidden
// Update the content
viewModel.updateThreadData(updatedData)
tableView.reloadData()
hasLoadedInitialThreadData = true
}
return
}
@ -266,7 +281,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
guard
self.hasLoadedInitialThreadData &&
!self.isAutoLoadingNextPage &&
!self.isLoadingMore
else { return }
self.isAutoLoadingNextPage = true
@ -393,31 +412,33 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
return true
}
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
}
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
switch section.model {
case .threads:
let threadId: String = section.elements[indexPath.row].threadId
let delete: UIContextualAction = UIContextualAction(
style: .destructive,
title: "TXT_DELETE_TITLE".localized()
) { [weak self] _, _, completionHandler in
self?.delete(threadId)
completionHandler(true)
}
delete.themeBackgroundColor = .conversationButton_swipeDestructive
let block: UIContextualAction = UIContextualAction(
style: .normal,
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
) { [weak self] _, _, completionHandler in
self?.block(threadId)
completionHandler(true)
}
block.themeBackgroundColor = .conversationButton_swipeSecondary
return UISwipeActionsConfiguration(actions: [ delete, block ])
return UIContextualAction.configuration(
for: UIContextualAction.generateSwipeActions(
[
(threadViewModel.threadVariant != .contact ? nil : .block),
.delete
].compactMap { $0 },
for: .trailing,
indexPath: indexPath,
tableView: tableView,
threadViewModel: threadViewModel,
viewController: self
)
)
default: return nil
}
@ -430,9 +451,16 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
return
}
let threadIds: [String] = (viewModel.threadData
let contactThreadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }?
.elements
.filter { $0.threadVariant == .contact }
.map { $0.threadId })
.defaulting(to: [])
let groupThreadIds: [String] = (viewModel.threadData
.first { $0.model == .threads }?
.elements
.filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group }
.map { $0.threadId })
.defaulting(to: [])
let alertVC: UIAlertController = UIAlertController(
@ -444,72 +472,14 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
style: .destructive
) { _ in
// Clear the requests
Storage.shared.write { db in
_ = try SessionThread
.filter(ids: threadIds)
.deleteAll(db)
}
MessageRequestsViewModel.clearAllRequests(
contactThreadIds: contactThreadIds,
groupThreadIds: groupThreadIds
)
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
self.present(alertVC, animated: true, completion: nil)
}
private func delete(_ threadId: String) {
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(
title: "TXT_DELETE_TITLE".localized(),
style: .destructive
) { _ in
Storage.shared.write { db in
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
}
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
self.present(alertVC, animated: true, completion: nil)
}
private func block(_ threadId: String) {
let alertVC: UIAlertController = UIAlertController(
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
message: nil,
preferredStyle: .actionSheet
)
alertVC.addAction(UIAlertAction(
title: "BLOCK_LIST_BLOCK_BUTTON".localized(),
style: .destructive
) { _ in
Storage.shared.write { db in
_ = try SessionThread
.filter(id: threadId)
.deleteAll(db)
_ = try Contact
.fetchOrCreate(db, id: threadId)
.with(
isApproved: false,
isBlocked: true
)
.saved(db)
// Force a config sync
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
}
})
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
self.present(alertVC, animated: true, completion: nil)
}
}

View File

@ -17,7 +17,7 @@ public class MessageRequestsViewModel {
// MARK: - Variables
public static let pageSize: Int = 15
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
// MARK: - Initialization
@ -51,7 +51,7 @@ public class MessageRequestsViewModel {
joinToPagedType: {
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -60,7 +60,7 @@ public class MessageRequestsViewModel {
joinToPagedType: {
let contact: TypedTableAlias<Contact> = TypedTableAlias()
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -69,7 +69,7 @@ public class MessageRequestsViewModel {
joinToPagedType: {
let profile: TypedTableAlias<Profile> = TypedTableAlias()
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
}()
),
PagedData.ObservedChanges(
@ -80,8 +80,8 @@ public class MessageRequestsViewModel {
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
return """
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
"""
}()
)
@ -103,7 +103,10 @@ public class MessageRequestsViewModel {
currentDataRetriever: { self?.threadData },
onDataChange: self?.onThreadChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedThreadDataChanges = (updatedData, changeset)
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
@ -126,8 +129,14 @@ public class MessageRequestsViewModel {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onThreadChange
switch Thread.isMainThread {
case true: performChange?(changes.0, changes.1)
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
}
self.unobservedThreadDataChanges = nil
}
}
@ -147,10 +156,13 @@ public class MessageRequestsViewModel {
elements: data
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
.map { viewModel -> SessionThreadViewModel in
viewModel.populatingCurrentUserBlindedKey(
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
viewModel.populatingCurrentUserBlindedKeys(
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlindedPublicKey
.currentUserBlinded15PublicKey,
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
.first?
.currentUserBlinded25PublicKey
)
}
)
@ -165,4 +177,32 @@ public class MessageRequestsViewModel {
public func updateThreadData(_ updatedData: [SectionModel]) {
self.threadData = updatedData
}
// MARK: - Functions
static func clearAllRequests(
contactThreadIds: [String],
groupThreadIds: [String]
) {
// Clear the requests
Storage.shared.write { db in
// Remove the one-to-one requests
try SessionThread.deleteOrLeave(
db,
threadIds: contactThreadIds,
threadVariant: .contact,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
// Remove the group requests
try SessionThread.deleteOrLeave(
db,
threadIds: groupThreadIds,
threadVariant: .group,
groupLeaveType: .silent,
calledFromConfigHandling: false
)
}
}
}

View File

@ -2,6 +2,7 @@
import UIKit
import SessionUIKit
import SignalUtilitiesKit
class MessageRequestsCell: UITableViewCell {
static let reuseIdentifier = "MessageRequestsCell"
@ -29,7 +30,7 @@ class MessageRequestsCell: UITableViewCell {
result.translatesAutoresizingMaskIntoConstraints = false
result.clipsToBounds = true
result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
result.layer.cornerRadius = (Values.mediumProfilePictureSize / 2)
result.layer.cornerRadius = (ProfilePictureView.Size.list.viewSize / 2)
return result
}()
@ -100,8 +101,8 @@ class MessageRequestsCell: UITableViewCell {
constant: (Values.accentLineThickness + Values.mediumSpacing)
),
iconContainerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
iconContainerView.widthAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize),
iconContainerView.heightAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize),
iconContainerView.widthAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize),
iconContainerView.heightAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize),
iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor),
iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor),

View File

@ -2,9 +2,9 @@
import UIKit
import GRDB
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource {
private let newConversationViewModel = NewConversationViewModel()
@ -143,13 +143,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
cell.update(
with: SessionCell.Info(
id: profile,
leftAccessory: .profile(profile.id, profile),
title: profile.displayName()
),
style: .edgeToEdge,
position: Position.with(
indexPath.row,
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
position: Position.with(
indexPath.row,
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
),
leftAccessory: .profile(id: profile.id, profile: profile),
title: profile.displayName(),
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
)
)
@ -179,15 +179,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
tableView.deselectRow(at: indexPath, animated: true)
let sessionId = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row].id
let maybeThread: SessionThread? = Storage.shared.write { db in
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
}
guard maybeThread != nil else { return }
self.navigationController?.dismiss(animated: true, completion: nil)
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
SessionApp.presentConversationCreatingIfNeeded(
for: sessionId,
variant: .contact,
dismissing: navigationController,
animated: false
)
}
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {

View File

@ -3,7 +3,6 @@
import UIKit
import AVFoundation
import GRDB
import Curve25519Kit
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
@ -166,16 +165,45 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
dismiss(animated: true, completion: nil)
}
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) {
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) {
let hexEncodedPublicKey = string
startNewDMIfPossible(with: hexEncodedPublicKey)
startNewDMIfPossible(with: hexEncodedPublicKey, onError: onError)
}
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String) {
fileprivate func startNewDMIfPossible(with onsNameOrPublicKey: String, onError: (() -> ())?) {
let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
startNewDM(with: onsNameOrPublicKey)
if KeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
switch maybeSessionId?.prefix {
case .standard:
startNewDM(with: onsNameOrPublicKey)
case .blinded15, .blinded25:
let modal: ConfirmationModal = ConfirmationModal(
targetView: self.view,
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text("DM_ERROR_DIRECT_BLINDED_ID".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text,
afterClosed: onError
)
)
self.present(modal, animated: true)
default:
let modal: ConfirmationModal = ConfirmationModal(
targetView: self.view,
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text("DM_ERROR_INVALID".localized()),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text,
afterClosed: onError
)
)
self.present(modal, animated: true)
}
return
}
@ -183,58 +211,64 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
ModalActivityIndicatorViewController
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
SnodeAPI
.getSessionID(for: onsNameOrPublicKey)
.done { sessionID in
.getSessionID(for: onsNameOrPublicKey)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
modalActivityIndicator.dismiss {
var messageOrNil: String?
if let error = error as? SnodeAPIError {
switch error {
case .decryptionFailed, .hashingFailed, .validationFailed:
messageOrNil = error.errorDescription
default: break
}
}
let message: String = {
if let messageOrNil: String = messageOrNil {
return messageOrNil
}
return (maybeSessionId?.prefix == .blinded15 || maybeSessionId?.prefix == .blinded25 ?
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
"DM_ERROR_INVALID".localized()
)
}()
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text(message),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text,
afterClosed: onError
)
)
self?.present(modal, animated: true)
}
}
},
receiveValue: { sessionId in
modalActivityIndicator.dismiss {
self?.startNewDM(with: sessionID)
}
}
.catch { error in
modalActivityIndicator.dismiss {
var messageOrNil: String?
if let error = error as? SnodeAPIError {
switch error {
case .decryptionFailed, .hashingFailed, .validationFailed:
messageOrNil = error.errorDescription
default: break
}
}
let message: String = {
if let messageOrNil: String = messageOrNil {
return messageOrNil
}
return (maybeSessionId?.prefix == .blinded ?
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
"DM_ERROR_INVALID".localized()
)
}()
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "ALERT_ERROR_TITLE".localized(),
body: .text(message),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
)
self?.present(modal, animated: true)
self?.startNewDM(with: sessionId)
}
}
)
}
}
private func startNewDM(with sessionId: String) {
let maybeThread: SessionThread? = Storage.shared.write { db in
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
}
guard maybeThread != nil else { return }
presentingViewController?.dismiss(animated: true, completion: nil)
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
SessionApp.presentConversationCreatingIfNeeded(
for: sessionId,
variant: .contact,
dismissing: presentingViewController,
animated: false
)
}
}
@ -632,7 +666,7 @@ private final class EnterPublicKeyVC: UIViewController {
@objc fileprivate func startNewDMIfPossible() {
let text = publicKeyTextView.text?.trimmingCharacters(in: .whitespaces) ?? ""
NewDMVC.startNewDMIfPossible(with: text)
NewDMVC.startNewDMIfPossible(with: text, onError: nil)
}
}

View File

@ -6,6 +6,7 @@ import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
public class AllMediaViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)

View File

@ -1,11 +1,10 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import MediaPlayer
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
// This kind of view is tricky. I've tried to organize things in the
// simplest possible way.
@ -32,7 +31,7 @@ import SignalUtilitiesKit
let srcImage: UIImage
let successCompletion: ((UIImage) -> Void)
let successCompletion: ((Data) -> Void)
var imageView: UIView!
@ -79,7 +78,7 @@ import SignalUtilitiesKit
notImplemented()
}
@objc required init(srcImage: UIImage, successCompletion : @escaping (UIImage) -> Void) {
@objc required init(srcImage: UIImage, successCompletion : @escaping (Data) -> Void) {
// normalized() can be slightly expensive but in practice this is fine.
self.srcImage = srcImage.normalized()
self.successCompletion = successCompletion
@ -487,10 +486,9 @@ import SignalUtilitiesKit
@objc func donePressed(sender: UIButton) {
let successCompletion = self.successCompletion
dismiss(animated: true, completion: {
guard let dstImage = self.generateDstImage() else {
return
}
successCompletion(dstImage)
guard let dstImageData: Data = self.generateDstImageData() else { return }
successCompletion(dstImageData)
})
}
@ -517,4 +515,8 @@ import SignalUtilitiesKit
UIGraphicsEndImageContext()
return scaledImage
}
func generateDstImageData() -> Data? {
return generateDstImage().map { $0.pngData() }
}
}

View File

@ -6,6 +6,7 @@ import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
@ -152,7 +153,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage else { return }
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
self.isAutoLoadingNextPage = true
@ -203,11 +204,11 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
self.hasLoadedInitialData = true
self.viewModel.updateGalleryData(updatedGalleryData)
UIView.performWithoutAnimation {
self.tableView.reloadData()
self.hasLoadedInitialData = true
self.performInitialScrollIfNeeded()
}
return
@ -499,7 +500,7 @@ class DocumentCell: UITableViewCell {
func update(with item: MediaGalleryViewModel.Item) {
let attachment = item.attachment
titleLabel.text = (attachment.sourceFilename ?? "File")
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
detailLabel.text = "\(Format.fileSize(attachment.byteCount)))"
timeLabel.text = Date(
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
).formattedForDisplay

View File

@ -1,12 +1,10 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import PromiseKit
import SignalUtilitiesKit
import SignalUtilitiesKit
import Combine
import YYImage
import SignalUtilitiesKit
import SignalCoreKit
class GifPickerCell: UICollectionViewCell {
@ -222,7 +220,7 @@ class GifPickerCell: UICollectionViewCell {
self.themeBackgroundColor = nil
if self.isCellSelected {
let activityIndicator = UIActivityIndicatorView(style: .gray)
let activityIndicator = UIActivityIndicatorView(style: .medium)
self.activityIndicator = activityIndicator
addSubview(activityIndicator)
activityIndicator.autoCenterInSuperview()
@ -245,29 +243,27 @@ class GifPickerCell: UICollectionViewCell {
}
}
public func requestRenditionForSending() -> Promise<ProxiedContentAsset> {
public func requestRenditionForSending() -> AnyPublisher<ProxiedContentAsset, Error> {
guard let renditionForSending = self.renditionForSending else {
owsFailDebug("renditionForSending was unexpectedly nil")
return Promise(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil"))
return Fail(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil"))
.eraseToAnyPublisher()
}
let (promise, resolver) = Promise<ProxiedContentAsset>.pending()
// We don't retain a handle on the asset request, since there will only ever
// be one selected asset, and we never want to cancel it.
_ = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: renditionForSending,
priority: .high,
success: { _, asset in
resolver.fulfill(asset)
},
failure: { _ in
// TODO GiphyDownloader API should pass through a useful failing error
// so we can pass it through here
Logger.error("request failed")
resolver.reject(GiphyError.fetchFailure)
})
return promise
return GiphyDownloader.giphyDownloader
.requestAsset(
assetDescription: renditionForSending,
priority: .high
)
.mapError { _ -> Error in
// TODO: GiphyDownloader API should pass through a useful failing error so we can pass it through here
Logger.error("request failed")
return GiphyError.fetchFailure
}
.map { asset, _ in asset }
.eraseToAnyPublisher()
}
private func clearViewState() {

View File

@ -1,8 +1,7 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
import Foundation
import SignalCoreKit
protocol GifPickerLayoutDelegate: AnyObject {
func imageInfosForLayout() -> [GiphyImageInfo]

View File

@ -1,12 +1,11 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import Reachability
import SignalUtilitiesKit
import PromiseKit
import SessionUIKit
import SignalCoreKit
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
@ -36,12 +35,12 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
var activityIndicator: UIActivityIndicatorView?
var hasSelectedCell: Bool = false
var imageInfos = [GiphyImageInfo]()
var reachability: Reachability?
private let kCellReuseIdentifier = "kCellReuseIdentifier"
var progressiveSearchTimer: Timer?
private var disposables: Set<AnyCancellable> = Set()
// MARK: - Initialization
@ -114,7 +113,6 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
createViews()
reachability = Reachability.forInternetConnection()
NotificationCenter.default.addObserver(
self,
selector: #selector(reachabilityChanged),
@ -219,7 +217,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
private func createErrorLabel(text: String) -> UILabel {
let label: UILabel = UILabel()
label.font = .ows_mediumFont(withSize: 20)
label.font = UIFont.systemFont(ofSize: 20, weight: .medium)
label.text = text
label.themeTextColor = .textPrimary
label.textAlignment = .center
@ -359,47 +357,53 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
public func getFileForCell(_ cell: GifPickerCell) {
GiphyDownloader.giphyDownloader.cancelAllRequests()
firstly {
cell.requestRenditionForSending()
}.done { [weak self] (asset: ProxiedContentAsset) in
guard let strongSelf = self else {
Logger.info("ignoring send, since VC was dismissed before fetching finished.")
return
}
guard let rendition = asset.assetDescription as? GiphyRendition else {
owsFailDebug("Invalid asset description.")
return
}
let filePath = asset.filePath
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath,
shouldDeleteOnDeallocation: false) else {
owsFailDebug("couldn't load asset.")
return
}
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
strongSelf.dismiss(animated: true) {
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.
strongSelf.delegate?.gifPickerDidSelect(attachment: attachment)
}
}.catch { [weak self] error in
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(),
body: .text(error.localizedDescription),
confirmTitle: CommonStrings.retryButton,
cancelTitle: CommonStrings.dismissButton,
cancelStyle: .alert_text,
onConfirm: { _ in
self?.getFileForCell(cell)
cell
.requestRenditionForSending()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure(let error):
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(),
body: .text(error.localizedDescription),
confirmTitle: CommonStrings.retryButton,
cancelTitle: CommonStrings.dismissButton,
cancelStyle: .alert_text,
onConfirm: { _ in
self?.getFileForCell(cell)
}
)
)
self?.present(modal, animated: true)
}
)
},
receiveValue: { [weak self] asset in
guard let rendition = asset.assetDescription as? GiphyRendition else {
owsFailDebug("Invalid asset description.")
return
}
let filePath = asset.filePath
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath,
shouldDeleteOnDeallocation: false) else {
owsFailDebug("couldn't load asset.")
return
}
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
self?.dismiss(animated: true) {
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.
self?.delegate?.gifPickerDidSelect(attachment: attachment)
}
}
)
self?.present(modal, animated: true)
}.retainUntilComplete()
.store(in: &disposables)
}
public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
@ -486,22 +490,31 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
assert(progressiveSearchTimer == nil)
assert(searchBar.text == nil || searchBar.text?.count == 0)
GiphyAPI.sharedInstance.trending()
.done { [weak self] imageInfos in
Logger.info("showing trending")
if imageInfos.count > 0 {
self?.imageInfos = imageInfos
self?.viewMode = .results
GiphyAPI.trending()
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
// Don't both showing error UI feedback for default "trending" results.
Logger.error("error: \(error)")
}
},
receiveValue: { [weak self] imageInfos in
Logger.info("showing trending")
if imageInfos.count > 0 {
self?.imageInfos = imageInfos
self?.viewMode = .results
}
else {
owsFailDebug("trending results was unexpectedly empty")
}
}
else {
owsFailDebug("trending results was unexpectedly empty")
}
}
.catch { error in
// Don't both showing error UI feedback for default "trending" results.
Logger.error("error: \(error)")
}
)
.store(in: &disposables)
}
private func search(query: String) {
@ -514,10 +527,21 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
lastQuery = query
self.collectionView.contentOffset = CGPoint.zero
GiphyAPI.sharedInstance
.search(
query: query,
success: { [weak self] imageInfos in
GiphyAPI
.search(query: query)
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
.receive(on: DispatchQueue.main)
.sink(
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure:
Logger.info("search failed.")
// TODO: Present this error to the user.
self?.viewMode = .error
}
},
receiveValue: { [weak self] imageInfos in
Logger.info("search complete")
self?.imageInfos = imageInfos
@ -527,13 +551,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
else {
self?.viewMode = .noResults
}
},
failure: { [weak self] _ in
Logger.info("search failed.")
// TODO: Present this error to the user.
self?.viewMode = .error
}
)
.store(in: &disposables)
}
// MARK: - GifPickerLayoutDelegate

View File

@ -1,13 +1,11 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import AFNetworking
import Foundation
import PromiseKit
import Combine
import CoreServices
import SignalUtilitiesKit
import SessionUtilitiesKit
import SignalCoreKit
// There's no UTI type for webp!
enum GiphyFormat {
@ -18,13 +16,12 @@ enum GiphyError: Error {
case assertionError(description: String)
case fetchFailure
}
extension GiphyError: LocalizedError {
public var errorDescription: String? {
switch self {
case .assertionError:
return NSLocalizedString("GIF_PICKER_ERROR_GENERIC", comment: "Generic error displayed when picking a GIF")
case .fetchFailure:
return NSLocalizedString("GIF_PICKER_ERROR_FETCH_FAILURE", comment: "Error displayed when there is a failure fetching a GIF from the remote service.")
case .assertionError: return "GIF_PICKER_ERROR_GENERIC".localized()
case .fetchFailure: return "GIF_PICKER_ERROR_FETCH_FAILURE".localized()
}
}
}
@ -34,7 +31,7 @@ extension GiphyError: LocalizedError {
// They vary in content size (i.e. width, height),
// format (.jpg, .gif, .mp4, webp, etc.),
// quality, etc.
@objc class GiphyRendition: ProxiedContentAssetDescription {
class GiphyRendition: ProxiedContentAssetDescription {
let format: GiphyFormat
let name: String
let width: UInt
@ -93,7 +90,7 @@ extension GiphyError: LocalizedError {
}
// Represents a single Giphy image.
@objc class GiphyImageInfo: NSObject {
class GiphyImageInfo: NSObject {
let giphyId: String
let renditions: [GiphyRendition]
// We special-case the "original" rendition because it is the
@ -267,115 +264,109 @@ extension GiphyError: LocalizedError {
}
}
@objc class GiphyAPI: NSObject {
enum GiphyAPI {
private static let kGiphyBaseURL = "https://api.giphy.com"
private static let urlSession: URLSession = {
let configuration: URLSessionConfiguration = ContentProxy.sessionConfiguration()
// Don't use any caching to protect privacy of these requests.
configuration.urlCache = nil
configuration.requestCachePolicy = .reloadIgnoringCacheData
return URLSession(configuration: configuration)
}()
// MARK: - Properties
static let sharedInstance = GiphyAPI()
// Force usage as a singleton
override private init() {
super.init()
SwiftSingletons.register(self)
}
deinit {
NotificationCenter.default.removeObserver(self)
}
private let kGiphyBaseURL = "https://api.giphy.com/"
private func giphyAPISessionManager() -> AFHTTPSessionManager? {
return AFHTTPSessionManager(baseURL: URL(string: kGiphyBaseURL), sessionConfiguration: .ephemeral)
}
// MARK: Search
// This is the Signal iOS API key.
let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
let kGiphyPageSize = 20
// MARK: - Search
public func trending() -> Promise<[GiphyImageInfo]> {
guard let sessionManager = giphyAPISessionManager() else {
Logger.error("Couldn't create session manager.")
return Promise.value([])
}
// This is the Signal iOS API key.
private static let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
private static let kGiphyPageSize = 20
public static func trending() -> AnyPublisher<[GiphyImageInfo], Error> {
let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)"
let (promise, resolver) = Promise<[GiphyImageInfo]>.pending()
sessionManager.get(urlString,
parameters: [String: AnyObject](),
headers:nil,
progress: nil,
success: { _, value in
Logger.error("search request succeeded")
if let imageInfos = self.parseGiphyImages(responseJson: value) {
resolver.fulfill(imageInfos)
} else {
Logger.error("unable to parse trending images")
resolver.fulfill([])
}
},
failure: { _, error in
Logger.error("search request failed: \(error)")
resolver.reject(error)
})
return promise
guard let url: URL = URL(string: "\(kGiphyBaseURL)\(urlString)") else {
return Fail(error: HTTPError.invalidURL)
.eraseToAnyPublisher()
}
return urlSession
.dataTaskPublisher(for: url)
.mapError { urlError in
Logger.error("search request failed: \(urlError)")
// URLError codes are negative values
return HTTPError.generic
}
.map { data, _ in
Logger.debug("search request succeeded")
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
Logger.error("unable to parse trending images")
return []
}
return imageInfos
}
.eraseToAnyPublisher()
}
public func search(query: String, success: @escaping (([GiphyImageInfo]) -> Void), failure: @escaping ((NSError?) -> Void)) {
guard let sessionManager = giphyAPISessionManager() else {
Logger.error("Couldn't create session manager.")
failure(nil)
return
}
guard NSURL(string: kGiphyBaseURL) != nil else {
Logger.error("Invalid base URL.")
failure(nil)
return
}
public static func search(query: String) -> AnyPublisher<[GiphyImageInfo], Error> {
let kGiphyPageOffset = 0
guard let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
Logger.error("Could not URL encode query: \(query).")
failure(nil)
return
guard
let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
let url: URL = URL(
string: [
kGiphyBaseURL,
"/v1/gifs/search?api_key=\(kGiphyApiKey)",
"&offset=\(kGiphyPageOffset)",
"&limit=\(kGiphyPageSize)",
"&q=\(queryEncoded)"
].joined()
)
else {
return Fail(error: HTTPError.invalidURL)
.eraseToAnyPublisher()
}
let urlString = "/v1/gifs/search?api_key=\(kGiphyApiKey)&offset=\(kGiphyPageOffset)&limit=\(kGiphyPageSize)&q=\(queryEncoded)"
guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else {
var request: URLRequest = URLRequest(url: url)
guard ContentProxy.configureProxiedRequest(request: &request) else {
owsFailDebug("Could not configure query: \(query).")
failure(nil)
return
return Fail(error: HTTPError.generic)
.eraseToAnyPublisher()
}
sessionManager.get(urlString,
parameters: [String: AnyObject](),
headers: nil,
progress: nil,
success: { _, value in
Logger.error("search request succeeded")
guard let imageInfos = self.parseGiphyImages(responseJson: value) else {
failure(nil)
return
}
success(imageInfos)
},
failure: { _, error in
Logger.error("search request failed: \(error)")
failure(error as NSError)
})
return urlSession
.dataTaskPublisher(for: request)
.mapError { urlError in
Logger.error("search request failed: \(urlError)")
// URLError codes are negative values
return HTTPError.generic
}
.tryMap { data, _ -> [GiphyImageInfo] in
Logger.debug("search request succeeded")
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
throw HTTPError.invalidResponse
}
return imageInfos
}
.eraseToAnyPublisher()
}
// MARK: Parse API Responses
// MARK: - Parse API Responses
private func parseGiphyImages(responseJson: Any?) -> [GiphyImageInfo]? {
guard let responseJson = responseJson else {
private static func parseGiphyImages(responseData: Data?) -> [GiphyImageInfo]? {
guard let responseData: Data = responseData else {
Logger.error("Missing response.")
return nil
}
guard let responseDict = responseJson as? [String: Any] else {
guard let responseDict: [String: Any] = try? JSONSerialization
.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? [String: Any] else {
Logger.error("Invalid response.")
return nil
}
@ -389,7 +380,7 @@ extension GiphyError: LocalizedError {
}
// Giphy API results are often incomplete or malformed, so we need to be defensive.
private func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
private static func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
guard let giphyId = imageDict["id"] as? String else {
Logger.warn("Image dict missing id.")
return nil
@ -424,12 +415,14 @@ extension GiphyError: LocalizedError {
return nil
}
return GiphyImageInfo(giphyId: giphyId,
renditions: renditions,
originalRendition: originalRendition)
return GiphyImageInfo(
giphyId: giphyId,
renditions: renditions,
originalRendition: originalRendition
)
}
private func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
private static func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
for rendition in renditions where rendition.name == "original" {
return rendition
}
@ -439,8 +432,10 @@ extension GiphyError: LocalizedError {
// Giphy API results are often incomplete or malformed, so we need to be defensive.
//
// We should discard renditions which are missing or have invalid properties.
private func parseGiphyRendition(renditionName: String,
renditionDict: [String: Any]) -> GiphyRendition? {
private static func parseGiphyRendition(
renditionName: String,
renditionDict: [String: Any]
) -> GiphyRendition? {
guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else {
return nil
}
@ -488,7 +483,7 @@ extension GiphyError: LocalizedError {
)
}
private func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
private static func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
guard let value = dict[key] else {
return nil
}
@ -505,7 +500,7 @@ extension GiphyError: LocalizedError {
return parsedValue
}
private func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
private static func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
let defaultValue = UInt(0)
guard let value = dict[key] else {

View File

@ -1,15 +1,11 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import SignalUtilitiesKit
@objc
public class GiphyDownloader: ProxiedContentDownloader {
// MARK: - Properties
@objc
public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs")
}

View File

@ -1,19 +1,18 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import Photos
import PromiseKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
protocol ImagePickerGridControllerDelegate: AnyObject {
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController)
func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>)
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher<SignalAttachment, Error>)
func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset)
var isInBatchSelectMode: Bool { get }
@ -180,8 +179,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
return
}
let attachmentPromise: Promise<SignalAttachment> = photoCollectionContents.outgoingAttachment(for: asset)
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
delegate.imagePicker(
self,
didSelectAsset: asset,
attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset)
)
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
case .deselect:
delegate.imagePicker(self, didDeselectAsset: asset)
@ -201,8 +203,7 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
let scale = UIScreen.main.scale
let cellSize = collectionViewFlowLayout.itemSize
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
reloadDataAndRestoreSelection()
if !hasEverAppeared {
scrollToBottom(animated: false)
}
@ -289,30 +290,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
}
}
private func reloadDataAndRestoreSelection() {
guard let collectionView = collectionView else {
owsFailDebug("Missing collectionView.")
return
}
guard let delegate = delegate else {
owsFailDebug("delegate was unexpectedly nil")
return
}
collectionView.reloadData()
collectionView.layoutIfNeeded()
let count = photoCollectionContents.assetCount
for index in 0..<count {
let asset = photoCollectionContents.asset(at: index)
if delegate.imagePicker(self, isAssetSelected: asset) {
collectionView.selectItem(at: IndexPath(row: index, section: 0),
animated: false, scrollPosition: [])
}
}
}
// MARK: - Actions
@objc
@ -363,7 +340,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
}
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
reloadDataAndRestoreSelection()
}
func clearCollectionViewSelection() {
@ -400,7 +376,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
photoCollectionContents = photoCollection.contents()
reloadDataAndRestoreSelection()
}
// MARK: - PhotoCollectionPicker Presentation
@ -447,11 +422,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
// Initially position offscreen, we'll animate it in.
collectionPickerView.frame = collectionPickerView.frame.offsetBy(dx: 0, dy: collectionPickerView.frame.height)
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
UIView.animate(withDuration: 0.25) {
collectionPickerView.superview?.layoutIfNeeded()
self.titleView.rotateIcon(.up)
}.retainUntilComplete()
}
}
func hideCollectionPicker() {
@ -459,14 +434,18 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
assert(isShowingCollectionPickerController)
isShowingCollectionPickerController = false
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
self.collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height)
self.titleView.rotateIcon(.down)
}.done { _ in
self.collectionPickerController.view.removeFromSuperview()
self.collectionPickerController.removeFromParent()
}.retainUntilComplete()
UIView.animate(
withDuration: 0.25,
animations: {
self.collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height)
self.titleView.rotateIcon(.down)
},
completion: { [weak self] _ in
self?.collectionPickerController.view.removeFromSuperview()
self?.collectionPickerController.removeFromParent()
}
)
}
// MARK: - UICollectionView
@ -491,8 +470,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
}
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
let attachmentPromise: Promise<SignalAttachment> = photoCollectionContents.outgoingAttachment(for: asset)
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
delegate.imagePicker(
self,
didSelectAsset: asset,
attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset)
)
if !delegate.isInBatchSelectMode {
// Don't show "selected" badge unless we're in batch mode
@ -524,7 +506,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
cell.configure(item: assetItem)
cell.isAccessibilityElement = true
cell.accessibilityIdentifier = "\(assetItem.asset.modificationDate.map { "\($0)" } ?? "Unknown Date")"
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
return cell

View File

@ -5,6 +5,7 @@ import YYImage
import SessionUIKit
import SignalUtilitiesKit
import SessionMessagingKit
import SignalCoreKit
public enum MediaGalleryOption {
case sliderEnabled

View File

@ -48,8 +48,14 @@ public class MediaGalleryViewModel {
didSet {
// When starting to observe interaction changes we want to trigger a UI update just in case the
// data was changed while we weren't observing
if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1)
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
let performChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? = onGalleryChange
switch Thread.isMainThread {
case true: performChange?(changes.0, changes.1)
case false: DispatchQueue.main.async { performChange?(changes.0, changes.1) }
}
self.unobservedGalleryDataChanges = nil
}
}
@ -98,7 +104,10 @@ public class MediaGalleryViewModel {
currentDataRetriever: { self?.galleryData },
onDataChange: self?.onGalleryChange,
onUnobservedDataChange: { updatedData, changeset in
self?.unobservedGalleryDataChanges = (updatedData, changeset)
self?.unobservedGalleryDataChanges = (changeset.isEmpty ?
nil :
(updatedData, changeset)
)
}
)
}
@ -357,7 +366,7 @@ public class MediaGalleryViewModel {
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
public typealias AlbumObservation = ValueObservation<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>
public typealias AlbumObservation = ValueObservation<ValueReducers.Trace<ValueReducers.RemoveDuplicates<ValueReducers.Fetch<[Item]>>>>
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
@ -380,6 +389,7 @@ public class MediaGalleryViewModel {
.fetchAll(db)
}
.removeDuplicates()
.handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") })
}
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
@ -623,27 +633,3 @@ public class MediaGalleryViewModel {
)
}
}
// MARK: - Objective-C Support
// FIXME: Remove when we can
@objc(SNMediaGallery)
public class SNMediaGallery: NSObject {
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: UINavigationController) {
fromNavController.pushViewController(
MediaGalleryViewModel.createAllMediaViewController(
threadId: threadId,
threadVariant: {
if isClosedGroup { return .closedGroup }
if isOpenGroup { return .openGroup }
return .contact
}(),
focusedAttachmentId: nil
),
animated: true
)
}
}

View File

@ -172,6 +172,7 @@ extension MediaInfoVC {
}
// MARK: - Interaction
public func update(attachment: Attachment?) {
guard let attachment: Attachment = attachment else { return }
@ -179,7 +180,7 @@ extension MediaInfoVC {
fileIdLabel.text = attachment.serverId
fileTypeLabel.text = attachment.contentType
fileSizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount)
fileSizeLabel.text = Format.fileSize(attachment.byteCount)
resolutionLabel.text = {
guard let width = attachment.width, let height = attachment.height else { return "N/A" }
return "\(width)×\(height)"

View File

@ -2,10 +2,10 @@
import UIKit
import GRDB
import PromiseKit
import SessionUIKit
import SessionMessagingKit
import SignalUtilitiesKit
import SignalCoreKit
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController {
class DynamicallySizedView: UIView {
@ -15,7 +15,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
public let viewModel: MediaGalleryViewModel
private var dataChangeObservable: DatabaseCancellable?
private var dataChangeObservable: DatabaseCancellable? {
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
}
private var initialPage: MediaDetailViewController
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
@ -40,7 +42,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
)
// Swap out the database observer
dataChangeObservable?.cancel()
stopObservingChanges()
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
startObservingChanges()
@ -238,8 +240,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
resignFirstResponder()
}
@ -252,8 +253,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
}
@objc func applicationDidResignActive(_ notification: Notification) {
// Stop observing database changes
dataChangeObservable?.cancel()
stopObservingChanges()
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
@ -388,17 +388,23 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
// MARK: - Updating
private func startObservingChanges() {
guard dataChangeObservable == nil else { return }
// Start observing for data changes
dataChangeObservable = Storage.shared.start(
viewModel.observableAlbumData,
onError: { _ in },
onChange: { [weak self] albumData in
// The defaul scheduler emits changes on the main thread
// The default scheduler emits changes on the main thread
self?.handleUpdates(albumData)
}
)
}
private func stopObservingChanges() {
dataChangeObservable = nil
}
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
// Determine if we swapped albums (if so we don't need to do anything else)
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
@ -533,11 +539,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
self.viewModel.threadVariant == .contact
else { return }
let threadId: String = self.viewModel.threadId
let threadVariant: SessionThread.Variant = self.viewModel.threadVariant
Storage.shared.write { db in
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: self.viewModel.threadId) else {
return
}
try MessageSender.send(
db,
message: DataExtractionNotification(
@ -547,7 +552,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
),
interactionId: nil, // Show no interaction for the current user
in: thread
threadId: threadId,
threadVariant: threadVariant
)
}
}
@ -710,7 +716,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
}
// Swap out the database observer
dataChangeObservable?.cancel()
stopObservingChanges()
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
startObservingChanges()
@ -755,7 +761,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
}
// Swap out the database observer
dataChangeObservable?.cancel()
stopObservingChanges()
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
startObservingChanges()
@ -925,24 +931,19 @@ extension MediaGalleryViewModel.Item: GalleryRailItem {
let imageView: UIImageView = UIImageView()
imageView.contentMode = .scaleAspectFill
getRailImage()
.map { [weak imageView] image in
guard let imageView = imageView else { return }
imageView.image = image
self.thumbnailImage { [weak imageView] image in
DispatchQueue.main.async {
imageView?.image = image
}
.retainUntilComplete()
}
return imageView
}
public func getRailImage() -> Guarantee<UIImage> {
return Guarantee<UIImage> { fulfill in
self.thumbnailImage(async: { image in fulfill(image) })
}
}
public func isEqual(to other: GalleryRailItem?) -> Bool {
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else { return false }
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else {
return false
}
return (self == otherItem)
}

View File

@ -6,6 +6,7 @@ import GRDB
import DifferenceKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
@ -245,7 +246,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
}
private func autoLoadNextPageIfNeeded() {
guard !self.isAutoLoadingNextPage else { return }
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
self.isAutoLoadingNextPage = true
@ -306,12 +307,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
// Ensure the first load runs without animations (if we don't do this the cells will animate
// in from a frame of CGRect.zero)
guard hasLoadedInitialData else {
self.hasLoadedInitialData = true
self.viewModel.updateGalleryData(updatedGalleryData)
self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode)
UIView.performWithoutAnimation {
self.collectionView.reloadData()
self.hasLoadedInitialData = true
self.performInitialScrollIfNeeded()
}
return

View File

@ -3,6 +3,7 @@
//
import Foundation
import SignalUtilitiesKit
@objc class OWSImagePickerController: UIImagePickerController {

View File

@ -1,12 +1,12 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import AVFoundation
import PromiseKit
import CoreServices
import SessionMessagingKit
import SessionUtilitiesKit
import SignalCoreKit
protocol PhotoCaptureDelegate: AnyObject {
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment)
@ -83,77 +83,93 @@ class PhotoCapture: NSObject {
Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
}
func startCapture() -> Promise<Void> {
return sessionQueue.async(.promise) { [weak self] in
guard let self = self else { return }
func startCapture() -> AnyPublisher<Void, Error> {
return Just(())
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.setFailureType(to: Error.self)
.tryMap { [weak self] _ -> Void in
self?.session.beginConfiguration()
defer { self?.session.commitConfiguration() }
self.session.beginConfiguration()
defer { self.session.commitConfiguration() }
try self?.updateCurrentInput(position: .back)
try self.updateCurrentInput(position: .back)
guard let photoOutput = self.captureOutput.photoOutput else {
throw PhotoCaptureError.initializationFailed
}
guard self.session.canAddOutput(photoOutput) else {
throw PhotoCaptureError.initializationFailed
}
if let connection = photoOutput.connection(with: .video) {
if connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
guard
let photoOutput = self?.captureOutput.photoOutput,
self?.session.canAddOutput(photoOutput) == true
else {
throw PhotoCaptureError.initializationFailed
}
}
self.session.addOutput(photoOutput)
let movieOutput = self.captureOutput.movieOutput
if self.session.canAddOutput(movieOutput) {
self.session.addOutput(movieOutput)
self.session.sessionPreset = .high
if let connection = movieOutput.connection(with: .video) {
if let connection = photoOutput.connection(with: .video) {
if connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
}
}
self?.session.addOutput(photoOutput)
if
let movieOutput = self?.captureOutput.movieOutput,
self?.session.canAddOutput(movieOutput) == true
{
self?.session.addOutput(movieOutput)
self?.session.sessionPreset = .high
if let connection = movieOutput.connection(with: .video) {
if connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
}
}
}
return ()
}
}.done(on: sessionQueue) {
self.session.startRunning()
}
.handleEvents(
receiveCompletion: { [weak self] result in
switch result {
case .failure: break
case .finished: self?.session.startRunning()
}
}
)
.eraseToAnyPublisher()
}
func stopCapture() -> Guarantee<Void> {
return sessionQueue.async(.promise) {
self.session.stopRunning()
}
func stopCapture() -> AnyPublisher<Void, Never> {
return Just(())
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.handleEvents(
receiveOutput: { [weak self] in self?.session.stopRunning() }
)
.eraseToAnyPublisher()
}
func assertIsOnSessionQueue() {
assertOnQueue(sessionQueue)
}
func switchCamera() -> Promise<Void> {
func switchCamera() -> AnyPublisher<Void, Error> {
AssertIsOnMainThread()
let newPosition: AVCaptureDevice.Position
switch desiredPosition {
case .front:
newPosition = .back
case .back:
newPosition = .front
case .unspecified:
newPosition = .front
}
desiredPosition = newPosition
return sessionQueue.async(.promise) { [weak self] in
guard let self = self else { return }
self.session.beginConfiguration()
defer { self.session.commitConfiguration() }
try self.updateCurrentInput(position: newPosition)
}
desiredPosition = {
switch desiredPosition {
case .front: return .back
case .back: return .front
case .unspecified: return .front
}
}()
return Just(())
.setFailureType(to: Error.self)
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in
self?.session.beginConfiguration()
defer { self?.session.commitConfiguration() }
try self?.updateCurrentInput(position: newPosition)
return ()
}
.eraseToAnyPublisher()
}
// This method should be called on the serial queue,
@ -179,20 +195,29 @@ class PhotoCapture: NSObject {
resetFocusAndExposure()
}
func switchFlashMode() -> Guarantee<Void> {
return sessionQueue.async(.promise) {
switch self.captureOutput.flashMode {
case .auto:
Logger.debug("new flashMode: on")
self.captureOutput.flashMode = .on
case .on:
Logger.debug("new flashMode: off")
self.captureOutput.flashMode = .off
case .off:
Logger.debug("new flashMode: auto")
self.captureOutput.flashMode = .auto
}
}
func switchFlashMode() -> AnyPublisher<Void, Never> {
return Just(())
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.handleEvents(
receiveOutput: { [weak self] _ in
switch self?.captureOutput.flashMode {
case .auto:
Logger.debug("new flashMode: on")
self?.captureOutput.flashMode = .on
case .on:
Logger.debug("new flashMode: off")
self?.captureOutput.flashMode = .off
case .off:
Logger.debug("new flashMode: auto")
self?.captureOutput.flashMode = .auto
default: break
}
}
)
.eraseToAnyPublisher()
}
func focus(with focusMode: AVCaptureDevice.FocusMode,
@ -325,14 +350,23 @@ extension PhotoCapture: CaptureButtonDelegate {
AssertIsOnMainThread()
Logger.verbose("")
sessionQueue.async(.promise) {
try self.startAudioCapture()
self.captureOutput.beginVideo(delegate: self)
}.done {
self.delegate?.photoCaptureDidBeginVideo(self)
}.catch { error in
self.delegate?.photoCapture(self, processingDidError: error)
}.retainUntilComplete()
Just(())
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
guard let strongSelf = self else { return }
do {
try strongSelf.startAudioCapture()
strongSelf.captureOutput.beginVideo(delegate: strongSelf)
strongSelf.delegate?.photoCaptureDidBeginVideo(strongSelf)
}
catch {
strongSelf.delegate?.photoCapture(strongSelf, processingDidError: error)
}
}
)
}
func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) {

View File

@ -1,10 +1,11 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import AVFoundation
import PromiseKit
import SessionUIKit
import SignalUtilitiesKit
import SignalCoreKit
protocol PhotoCaptureViewControllerDelegate: AnyObject {
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
@ -39,9 +40,15 @@ class PhotoCaptureViewController: OWSViewController {
deinit {
UIDevice.current.endGeneratingDeviceOrientationNotifications()
if let photoCapture = photoCapture {
photoCapture.stopCapture().done {
Logger.debug("stopCapture completed")
}.retainUntilComplete()
photoCapture.stopCapture()
.sinkUntilComplete(
receiveCompletion: { result in
switch result {
case .failure: break
case .finished: Logger.debug("stopCapture completed")
}
}
)
}
}
@ -186,17 +193,29 @@ class PhotoCaptureViewController: OWSViewController {
let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
}
photoCapture.switchCamera().catch { error in
self.showFailureUI(error: error)
}.retainUntilComplete()
photoCapture.switchCamera()
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
switch result {
case .finished: break
case .failure(let error): self?.showFailureUI(error: error)
}
}
)
}
@objc
func didTapFlashMode() {
Logger.debug("")
photoCapture.switchFlashMode().done {
self.updateFlashModeControl()
}.retainUntilComplete()
photoCapture.switchFlashMode()
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { [weak self] _ in
self?.updateFlashModeControl()
}
)
}
@objc
@ -287,13 +306,15 @@ class PhotoCaptureViewController: OWSViewController {
previewView = CapturePreviewView(session: photoCapture.session)
photoCapture.startCapture()
.done { [weak self] in
self?.showCaptureUI()
}
.catch { [weak self] error in
self?.showFailureUI(error: error)
}
.retainUntilComplete()
.receive(on: DispatchQueue.main)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
switch result {
case .finished: self?.showCaptureUI()
case .failure(let error): self?.showFailureUI(error: error)
}
}
)
}
private func showCaptureUI() {
@ -580,7 +601,7 @@ class RecordingTimerView: UIView {
private lazy var label: UILabel = {
let label: UILabel = UILabel()
label.font = .ows_monospacedDigitFont(withSize: 20)
label.font = UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .regular)
label.themeTextColor = .textPrimary
label.textAlignment = .center
label.layer.shadowOffset = CGSize.zero

View File

@ -34,12 +34,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
// MARK: - Content
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
override var observableTableData: ObservableData { _observableTableData }
private var _settingsData: [SectionModel] = []
public override var settingsData: [SectionModel] { _settingsData }
public override var observableSettingsData: ObservableData { _observableSettingsData }
private lazy var _observableSettingsData: ObservableData = {
private lazy var _observableTableData: ObservableData = {
self.photoCollections
.map { collections in
[
@ -49,15 +46,15 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
let contents: PhotoCollectionContents = collection.contents()
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
thumbnailSize: CGSize(
width: IconSize.veryLarge.size,
height: IconSize.veryLarge.size
width: IconSize.extraLarge.size,
height: IconSize.extraLarge.size
)
)
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
return SessionCell.Info(
id: Item(id: collection.id),
leftAccessory: .iconAsync(size: .veryLarge, shouldFill: true) { imageView in
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
// be able to load the thumbnail
lastAssetItem?.asyncThumbnail { [weak imageView] image in
@ -76,14 +73,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
}
.removeDuplicates()
.eraseToAnyPublisher()
.mapToSessionTableViewData(for: self)
}()
// MARK: - Functions
public override func updateSettings(_ updatedSettings: [SectionModel]) {
self._settingsData = updatedSettings
}
// MARK: PhotoLibraryDelegate
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {

View File

@ -4,6 +4,7 @@
import UIKit
import SessionUIKit
import SignalCoreKit
public enum PhotoGridItemType {
case photo, animated, video

View File

@ -1,11 +1,11 @@
//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import Combine
import Photos
import PromiseKit
import CoreServices
import SignalUtilitiesKit
import SignalCoreKit
protocol PhotoLibraryDelegate: AnyObject {
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
@ -53,7 +53,7 @@ class PhotoPickerAssetItem: PhotoGridItem {
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
// already available.
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
DispatchMainThreadSafe({
Threading.dispatchMainThreadSafe {
// Once we've _successfully_ completed (e.g. invoked the completion with
// a non-nil image), don't invoke the completion again with a nil argument.
if !hasLoadedImage || image != nil {
@ -63,7 +63,7 @@ class PhotoPickerAssetItem: PhotoGridItem {
hasLoadedImage = true
}
}
})
}
}
}
}
@ -136,81 +136,96 @@ class PhotoCollectionContents {
_ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
}
private func requestImageDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
return Promise { resolver in
let options: PHImageRequestOptions = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
_ = imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in
guard let imageData = imageData else {
resolver.reject(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))
return
}
guard let dataUTI = dataUTI else {
resolver.reject(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))
return
}
guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else {
resolver.reject(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))
return
}
resolver.fulfill((dataSource: dataSource, dataUTI: dataUTI))
}
}
}
private func requestVideoDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
return Promise { resolver in
let options: PHVideoRequestOptions = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
_ = imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in
guard let exportSession = exportSession else {
resolver.reject(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))
return
}
exportSession.outputFileType = AVFileType.mp4
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4")
let exportURL = URL(fileURLWithPath: exportPath)
exportSession.outputURL = exportURL
Logger.debug("starting video export")
exportSession.exportAsynchronously {
Logger.debug("Completed video export")
guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else {
resolver.reject(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))
private func requestImageDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> {
return Deferred {
Future { [weak self] resolver in
let options: PHImageRequestOptions = PHImageRequestOptions()
options.isNetworkAccessAllowed = true
_ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in
guard let imageData = imageData else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")))
return
}
resolver.fulfill((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))
guard let dataUTI = dataUTI else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")))
return
}
guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil")))
return
}
resolver(Result.success((dataSource: dataSource, dataUTI: dataUTI)))
}
}
}
.eraseToAnyPublisher()
}
func outgoingAttachment(for asset: PHAsset) -> Promise<SignalAttachment> {
private func requestVideoDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> {
return Deferred {
Future { [weak self] resolver in
let options: PHVideoRequestOptions = PHVideoRequestOptions()
options.isNetworkAccessAllowed = true
_ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in
guard let exportSession = exportSession else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")))
return
}
exportSession.outputFileType = AVFileType.mp4
exportSession.metadataItemFilter = AVMetadataItemFilter.forSharing()
let exportPath = OWSFileSystem.temporaryFilePath(withFileExtension: "mp4")
let exportURL = URL(fileURLWithPath: exportPath)
exportSession.outputURL = exportURL
Logger.debug("starting video export")
exportSession.exportAsynchronously {
Logger.debug("Completed video export")
guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else {
resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL")))
return
}
resolver(Result.success((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)))
}
}
}
}
.eraseToAnyPublisher()
}
func outgoingAttachment(for asset: PHAsset) -> AnyPublisher<SignalAttachment, Error> {
switch asset.mediaType {
case .image:
return requestImageDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
}
case .video:
return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI)
}
default:
return Promise(error: PhotoLibraryError.unsupportedMediaType)
case .image:
return requestImageDataSource(for: asset)
.map { (dataSource: DataSource, dataUTI: String) in
SignalAttachment
.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
}
.eraseToAnyPublisher()
case .video:
return requestVideoDataSource(for: asset)
.map { (dataSource: DataSource, dataUTI: String) in
SignalAttachment
.attachment(dataSource: dataSource, dataUTI: dataUTI)
}
.eraseToAnyPublisher()
default:
return Fail(error: PhotoLibraryError.unsupportedMediaType)
.eraseToAnyPublisher()
}
}
}

View File

@ -1,9 +1,11 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import Combine
import Photos
import PromiseKit
import SignalUtilitiesKit
import SignalCoreKit
import SessionUIKit
class SendMediaNavigationController: UINavigationController {
public override var preferredStatusBarStyle: UIStatusBarStyle {
@ -15,6 +17,7 @@ class SendMediaNavigationController: UINavigationController {
static let bottomButtonsCenterOffset: CGFloat = -50
private let threadId: String
private var disposables: Set<AnyCancellable> = Set()
// MARK: - Initialization
@ -324,32 +327,40 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
func showApprovalAfterProcessingAnyMediaLibrarySelections() {
let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues
let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { modal in
let attachmentPromises: [Promise<MediaLibraryAttachment>] = mediaLibrarySelections.map { $0.promise }
when(fulfilled: attachmentPromises)
.map { attachments in
Logger.debug("built all attachments")
modal.dismiss {
self.attachmentDraftCollection.selectedFromPicker(attachments: attachments)
self.pushApprovalViewController()
let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { [weak self] modal in
guard let strongSelf = self else { return }
Publishers
.MergeMany(mediaLibrarySelections.map { $0.publisher })
.collect()
.sink(
receiveCompletion: { result in
switch result {
case .finished: break
case .failure(let error):
Logger.error("failed to prepare attachments. error: \(error)")
modal.dismiss { [weak self] in
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS".localized(),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
)
self?.present(modal, animated: true)
}
}
},
receiveValue: { attachments in
Logger.debug("built all attachments")
modal.dismiss {
self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments)
self?.pushApprovalViewController()
}
}
}
.catch { error in
Logger.error("failed to prepare attachments. error: \(error)")
modal.dismiss { [weak self] in
let modal: ConfirmationModal = ConfirmationModal(
targetView: self?.view,
info: ConfirmationModal.Info(
title: "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS".localized(),
cancelTitle: "BUTTON_OK".localized(),
cancelStyle: .alert_text
)
)
self?.present(modal, animated: true)
}
}
.retainUntilComplete()
)
.store(in: &strongSelf.disposables)
}
ModalActivityIndicatorViewController.present(
@ -363,10 +374,13 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
return mediaLibrarySelections.hasValue(forKey: asset)
}
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>) {
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher<SignalAttachment, Error>) {
guard !mediaLibrarySelections.hasValue(forKey: asset) else { return }
let libraryMedia = MediaLibrarySelection(asset: asset, signalAttachmentPromise: attachmentPromise)
let libraryMedia = MediaLibrarySelection(
asset: asset,
signalAttachmentPublisher: attachmentPublisher
)
mediaLibrarySelections.append(key: asset, value: libraryMedia)
updateButtons(topViewController: imagePicker)
}
@ -511,17 +525,17 @@ private final class AttachmentDraftCollection {
private struct MediaLibrarySelection: Hashable, Equatable {
let asset: PHAsset
let signalAttachmentPromise: Promise<SignalAttachment>
let signalAttachmentPublisher: AnyPublisher<SignalAttachment, Error>
var hashValue: Int {
return asset.hashValue
}
var promise: Promise<MediaLibraryAttachment> {
var publisher: AnyPublisher<MediaLibraryAttachment, Error> {
let asset = self.asset
return signalAttachmentPromise.map { signalAttachment in
return MediaLibraryAttachment(asset: asset, signalAttachment: signalAttachment)
}
return signalAttachmentPublisher
.map { MediaLibraryAttachment(asset: asset, signalAttachment: $0) }
.eraseToAnyPublisher()
}
static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool {
@ -583,7 +597,10 @@ private class DoneButton: UIView {
private lazy var badgeLabel: UILabel = {
let result: UILabel = UILabel()
result.font = .ows_dynamicTypeSubheadline.ows_monospaced()
result.font = UIFont.monospacedDigitSystemFont(
ofSize: UIFont.preferredFont(forTextStyle: .subheadline).pointSize,
weight: .regular
)
result.themeTextColor = .black // Will render on the primary color so should always be black
result.textAlignment = .center

View File

@ -1,7 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import PromiseKit
import SessionUIKit
class MediaDismissAnimationController: NSObject {
private let mediaItem: Media
@ -47,6 +47,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
switch fromVC {
case let contextProvider as MediaPresentationContextProvider:
fromContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
fromContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
@ -65,6 +77,19 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
case let contextProvider as MediaPresentationContextProvider:
toVC.view.layoutIfNeeded()
toContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
toVC.view.layoutIfNeeded()
toContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SignalUtilitiesKit
// MARK: - InteractivelyDismissableViewController

View File

@ -1,6 +1,7 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
class MediaZoomAnimationController: NSObject {
private let mediaItem: Media
@ -34,6 +35,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
switch fromVC {
case let contextProvider as MediaPresentationContextProvider:
fromContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
fromContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
@ -51,6 +64,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
switch toVC {
case let contextProvider as MediaPresentationContextProvider:
toContextProvider = contextProvider
case let topBannerController as TopBannerController:
guard
let firstChild: UIViewController = topBannerController.children.first,
let navController: UINavigationController = firstChild as? UINavigationController,
let contextProvider = navController.topViewController as? MediaPresentationContextProvider
else {
transitionContext.completeTransition(false)
return
}
toContextProvider = contextProvider
case let navController as UINavigationController:
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {

View File

@ -1,32 +1,35 @@
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import Foundation
import GRDB
import PromiseKit
import WebRTC
import SessionUIKit
import UIKit
import Combine
import UserNotifications
import GRDB
import SessionUIKit
import SessionMessagingKit
import SessionUtilitiesKit
import SessionUIKit
import UserNotifications
import UIKit
import SignalUtilitiesKit
import SignalCoreKit
@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10
var window: UIWindow?
var backgroundSnapshotBlockerWindow: UIWindow?
var appStartupWindow: UIWindow?
var initialLaunchFailed: Bool = false
var hasInitialRootViewController: Bool = false
var startTime: CFTimeInterval = 0
private var loadingViewController: LoadingViewController?
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
lazy var poller: Poller = Poller()
lazy var poller: CurrentUserPoller = CurrentUserPoller()
// MARK: - Lifecycle
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
startTime = CACurrentMediaTime()
// These should be the first things we do (the startup process can fail without them)
SetCurrentAppContext(MainAppContext())
verifyDBKeysAvailableBeforeBackgroundLaunch()
@ -44,9 +47,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds)
self.loadingViewController = LoadingViewController()
// Store a weak reference in the ThemeManager so it can properly apply themes as needed
ThemeManager.mainWindow = mainWindow
AppSetup.setupEnvironment(
appSpecificBlock: {
// Create AppEnvironment
@ -71,11 +71,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
},
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
self?.showFailedMigrationAlert(error: error)
DispatchQueue.main.async {
self?.initialLaunchFailed = true
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error))
}
return
}
self?.completePostMigrationSetup(needsConfigSync: needsConfigSync)
/// Store a weak reference in the ThemeManager so it can properly apply themes as needed
///
/// **Note:** Need to do this after the db migrations because theme preferences are stored in the database and
/// we don't want to access it until after the migrations run
ThemeManager.mainWindow = mainWindow
self?.completePostMigrationSetup(calledFrom: .finishLaunching, needsConfigSync: needsConfigSync)
}
)
@ -128,9 +136,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// Resume database
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
// Reset the 'startTime' (since it would be invalid from the last launch)
startTime = CACurrentMediaTime()
// If we've already completed migrations at least once this launch then check
// to see if any "delayed" migrations now need to run
if Storage.shared.hasCompletedMigrations {
SNLog("Checking for pending migrations")
let initialLaunchFailed: Bool = self.initialLaunchFailed
AppReadiness.invalidate()
// If the user went to the background too quickly then the database can be suspended before
// properly starting up, in this case an alert will be shown but we can recover from it so
// dismiss any alerts that were shown
if initialLaunchFailed {
self.window?.rootViewController?.dismiss(animated: false)
}
// Dispatch async so things can continue to be progressed if a migration does need to run
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { result, needsConfigSync in
if case .failure(let error) = result {
DispatchQueue.main.async {
self?.showFailedStartupAlert(
calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed),
error: .databaseError(error)
)
}
return
}
self?.completePostMigrationSetup(
calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed),
needsConfigSync: needsConfigSync
)
}
)
}
}
}
func applicationDidEnterBackground(_ application: UIApplication) {
if !hasInitialRootViewController { SNLog("Entered background before startup was completed") }
DDLog.flushLog()
// NOTE: Fix an edge case where user taps on the callkit notification
@ -160,7 +217,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
ensureRootViewController()
ensureRootViewController(calledFrom: .didBecomeActive)
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
self?.handleActivation()
@ -234,6 +291,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
BackgroundPoller.isValid = true
AppReadiness.runNowOrWhenAppDidBecomeReady {
// If the 'AppReadiness' process takes too long then it's possible for the user to open
// the app after this closure is registered but before it's actually triggered - this can
// result in the `BackgroundPoller` incorrectly getting called in the foreground, this check
// is here to prevent that
guard CurrentAppContext().isInBackground() else { return }
BackgroundPoller.poll { result in
guard BackgroundPoller.isValid else { return }
@ -252,101 +315,163 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// MARK: - App Readiness
private func completePostMigrationSetup(needsConfigSync: Bool) {
private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod, needsConfigSync: Bool) {
SNLog("Migrations completed, performing setup and ensuring rootViewController")
Configuration.performMainSetup()
JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens)
/// Setup the UI
///
/// **Note:** This **MUST** be run before calling:
/// - `AppReadiness.setAppIsReady()`:
/// If we are launching the app from a push notification the HomeVC won't be setup yet
/// and it won't open the related thread
///
/// - `JobRunner.appDidFinishLaunching()`:
/// The jobs which run on launch (eg. DisappearingMessages job) can impact the interactions
/// which get fetched to display on the home screen, if the PagedDatabaseObserver hasn't
/// been setup yet then the home screen can show stale (ie. deleted) interactions incorrectly
self.ensureRootViewController(isPreAppReadyCall: true)
// Trigger any launch-specific jobs and start the JobRunner
JobRunner.appDidFinishLaunching()
// Note that this does much more than set a flag;
// it will also run all deferred blocks (including the JobRunner
// 'appDidBecomeActive' method)
AppReadiness.setAppIsReady()
DeviceSleepManager.sharedInstance.removeBlock(blockObject: self)
AppVersion.sharedInstance().mainAppLaunchDidComplete()
Environment.shared?.audioSession.setup()
Environment.shared?.reachabilityManager.setup()
Storage.shared.writeAsync { db in
// Disable the SAE until the main app has successfully completed launch process
// at least once in the post-SAE world.
db[.isReadyForAppExtensions] = true
// Setup the UI if needed, then trigger any post-UI setup actions
self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] success in
// If we didn't successfully ensure the rootViewController then don't continue as
// the user is in an invalid state (and should have already been shown a modal)
guard success else { return }
if Identity.userExists(db) {
let appVersion: AppVersion = AppVersion.sharedInstance()
SNLog("RootViewController ready, readying remaining processes")
self?.initialLaunchFailed = false
/// Trigger any launch-specific jobs and start the JobRunner with `JobRunner.appDidFinishLaunching()` some
/// of these jobs (eg. DisappearingMessages job) can impact the interactions which get fetched to display on the home
/// screen, if the PagedDatabaseObserver hasn't been setup yet then the home screen can show stale (ie. deleted)
/// interactions incorrectly
if lifecycleMethod == .finishLaunching {
JobRunner.appDidFinishLaunching()
}
/// Flag that the app is ready via `AppReadiness.setAppIsReady()`
///
/// If we are launching the app from a push notification we need to ensure we wait until after the `HomeVC` is setup
/// otherwise it won't open the related thread
///
/// **Note:** This this does much more than set a flag - it will also run all deferred blocks (including the JobRunner
/// `appDidBecomeActive` method hence why it **must** also come after calling
/// `JobRunner.appDidFinishLaunching()`)
AppReadiness.setAppIsReady()
/// Remove the sleep blocking once the startup is done (needs to run on the main thread and sleeping while
/// doing the startup could suspend the database causing errors/crashes
DeviceSleepManager.sharedInstance.removeBlock(blockObject: self)
/// App launch hasn't really completed until the main screen is loaded so wait until then to register it
AppVersion.sharedInstance().mainAppLaunchDidComplete()
/// App won't be ready for extensions and no need to enqueue a config sync unless we successfully completed startup
Storage.shared.writeAsync { db in
// Increment the launch count (guaranteed to change which results in the write actually
// doing something and outputting and error if the DB is suspended)
db[.activeCounter] = ((db[.activeCounter] ?? 0) + 1)
// If the device needs to sync config or the user updated to a new version
if
needsConfigSync || (
(appVersion.lastAppVersion?.count ?? 0) > 0 &&
appVersion.lastAppVersion != appVersion.currentAppVersion
)
{
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
// Disable the SAE until the main app has successfully completed launch process
// at least once in the post-SAE world.
db[.isReadyForAppExtensions] = true
if Identity.userCompletedRequiredOnboarding(db) {
let appVersion: AppVersion = AppVersion.sharedInstance()
// If the device needs to sync config or the user updated to a new version
if
needsConfigSync || (
(appVersion.lastAppVersion?.count ?? 0) > 0 &&
appVersion.lastAppVersion != appVersion.currentAppVersion
)
{
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
}
}
}
// Add a log to track the proper startup time of the app so we know whether we need to
// improve it in the future from user logs
let endTime: CFTimeInterval = CACurrentMediaTime()
SNLog("\(lifecycleMethod.timingName) completed in \((self?.startTime).map { ceil((endTime - $0) * 1000) } ?? -1)ms")
}
// May as well run these on the background thread
Environment.shared?.audioSession.setup()
Environment.shared?.reachabilityManager.setup()
}
private func showFailedMigrationAlert(error: Error?) {
let alert = UIAlertController(
private func showFailedStartupAlert(
calledFrom lifecycleMethod: LifecycleMethod,
error: StartupError,
animated: Bool = true,
presentationCompletion: (() -> ())? = nil
) {
/// This **must** be a standard `UIAlertController` instead of a `ConfirmationModal` because we may not
/// have access to the database when displaying this so can't extract theme information for styling purposes
let alert: UIAlertController = UIAlertController(
title: "Session",
message: "DATABASE_MIGRATION_FAILED".localized(),
message: error.message,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in
HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in
self?.showFailedMigrationAlert(error: error)
// Don't bother showing the "Failed Startup" modal again if we happen to now
// have an initial view controller (this most likely means that the startup
// completed while the user was sharing logs so we can just let the user use
// the app)
guard self?.hasInitialRootViewController == false else { return }
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: error)
}
})
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] result, needsConfigSync in
if case .failure(let error) = result {
self?.showFailedMigrationAlert(error: error)
return
}
self?.completePostMigrationSetup(needsConfigSync: needsConfigSync)
}
)
})
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
switch error {
// Don't offer the 'Restore' option if it was a 'startupFailed' error as a restore is unlikely to
// resolve it (most likely the database is locked or the key was somehow lost - safer to get them
// to restart and manually reinstall/restore)
case .databaseError(StorageError.startupFailed): break
// Offer the 'Restore' option if it was a migration error
case .databaseError:
alert.addAction(UIAlertAction(title: "vc_restore_title".localized(), style: .destructive) { _ in
if SUKLegacy.hasLegacyDatabaseFile {
// Remove the legacy database and any message hashes that have been migrated to the new DB
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
Storage.shared.write { db in
try SnodeReceivedMessageInfo.deleteAll(db)
}
}
else {
// If we don't have a legacy database then reset the current database for a clean migration
Storage.resetForCleanMigration()
}
// Hide the top banner if there was one
TopBannerController.hide()
// The re-run the migration (should succeed since there is no data)
AppSetup.runPostSetupMigrations(
migrationProgressChanged: { [weak self] progress, minEstimatedTotalTime in
self?.loadingViewController?.updateProgress(
progress: progress,
minEstimatedTotalTime: minEstimatedTotalTime
)
},
migrationsCompletion: { [weak self] result, needsConfigSync in
switch result {
case .failure:
DispatchQueue.main.async {
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore)
}
case .success:
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
}
}
)
})
default: break
}
alert.addAction(UIAlertAction(title: "APP_STARTUP_EXIT".localized(), style: .default) { _ in
DDLog.flushLog()
exit(0)
})
self.window?.rootViewController?.present(alert, animated: true, completion: nil)
SNLog("Showing startup alert due to error: \(error.name)")
self.window?.rootViewController?.present(alert, animated: animated, completion: presentationCompletion)
}
/// The user must unlock the device once after reboot before the database encryption key can be accessed.
@ -387,7 +512,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
private func handleActivation() {
guard Identity.userExists() else { return }
/// There is a _fun_ behaviour here where if the user launches the app, sends it to the background at the right time and then
/// opens it again the `AppReadiness` closures can be triggered before `applicationDidBecomeActive` has been
/// called again - this can result in odd behaviours so hold off on running this logic until it's properly called again
guard
Identity.userExists() &&
UserDefaults.sharedLokiProject?[.isMainAppActive] == true
else { return }
enableBackgroundRefreshIfNecessary()
JobRunner.appDidBecomeActive()
@ -400,25 +531,110 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
}
private func ensureRootViewController(isPreAppReadyCall: Bool = false) {
guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else {
return
private func ensureRootViewController(
calledFrom lifecycleMethod: LifecycleMethod,
onComplete: @escaping ((Bool) -> ()) = { _ in }
) {
let hasInitialRootViewController: Bool = self.hasInitialRootViewController
// Always call the completion block and indicate whether we successfully created the UI
guard
Storage.shared.isValid &&
(
AppReadiness.isAppReady() ||
lifecycleMethod == .finishLaunching ||
lifecycleMethod == .enterForeground(initialLaunchFailed: true)
) &&
!hasInitialRootViewController
else { return DispatchQueue.main.async { onComplete(hasInitialRootViewController) } }
/// Start a timeout for the creation of the rootViewController setup process (if it takes too long then we want to give the user
/// the option to export their logs)
let populateHomeScreenTimer: Timer = Timer.scheduledTimerOnMainThread(
withTimeInterval: AppDelegate.maxRootViewControllerInitialQueryDuration,
repeats: false
) { [weak self] timer in
timer.invalidate()
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout)
}
self.hasInitialRootViewController = true
self.window?.rootViewController = StyledNavigationController(
rootViewController: (Identity.userExists() ?
HomeVC() :
LandingVC()
// All logic which needs to run after the 'rootViewController' is created
let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self] rootViewController in
let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController
let targetRootViewController: UIViewController = TopBannerController(
child: StyledNavigationController(rootViewController: rootViewController),
cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow]
.map { rawValue in TopBannerController.Warning(rawValue: rawValue) }
)
)
UIViewController.attemptRotationToDeviceOrientation()
/// Insert the `targetRootViewController` below the current view and trigger a layout without animation before properly
/// swapping the `rootViewController` over so we can avoid any weird initial layout behaviours
UIView.performWithoutAnimation {
self?.window?.rootViewController = targetRootViewController
}
self?.hasInitialRootViewController = true
UIViewController.attemptRotationToDeviceOrientation()
/// **Note:** There is an annoying case when starting the app by interacting with a push notification where
/// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController`
/// won't have been set - we set the value directly here to resolve this edge case
if let homeViewController: HomeVC = rootViewController as? HomeVC {
SessionApp.homeViewController.mutate { $0 = homeViewController }
}
/// If we were previously presenting a viewController but are no longer preseting it then present it again
///
/// **Note:** Looks like the OS will throw an exception if we try to present a screen which is already (or
/// was previously?) presented, even if it's not attached to the screen it seems...
switch presentedViewController {
case is UIAlertController, is ConfirmationModal:
/// If the viewController we were presenting happened to be the "failed startup" modal then we can dismiss it
/// automatically (while this seems redundant it's less jarring for the user than just instantly having it disappear)
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout, animated: false) {
self?.window?.rootViewController?.dismiss(animated: true)
}
case is UIActivityViewController: HelpViewModel.shareLogs(animated: false)
default: break
}
// Setup is completed so run any post-setup tasks
onComplete(true)
}
/// **Note:** There is an annoying case when starting the app by interacting with a push notification where
/// the `HomeVC` won't have completed loading it's view which means the `SessionApp.homeViewController`
/// won't have been set - we set the value directly here to resolve this edge case
if let homeViewController: HomeVC = (self.window?.rootViewController as? UINavigationController)?.viewControllers.first as? HomeVC {
SessionApp.homeViewController.mutate { $0 = homeViewController }
// Navigate to the approriate screen depending on the onboarding state
switch Onboarding.State.current {
case .newUser:
DispatchQueue.main.async {
let viewController: LandingVC = LandingVC()
populateHomeScreenTimer.invalidate()
rootViewControllerSetupComplete(viewController)
}
case .missingName:
DispatchQueue.main.async {
let viewController: DisplayNameVC = DisplayNameVC(flow: .register)
populateHomeScreenTimer.invalidate()
rootViewControllerSetupComplete(viewController)
}
case .completed:
DispatchQueue.main.async {
let viewController: HomeVC = HomeVC()
/// We want to start observing the changes for the 'HomeVC' and want to wait until we actually get data back before we
/// continue as we don't want to show a blank home screen
DispatchQueue.global(qos: .userInitiated).async {
viewController.startObservingChanges() {
populateHomeScreenTimer.invalidate()
DispatchQueue.main.async {
rootViewControllerSetupComplete(viewController)
}
}
}
}
}
}
@ -491,7 +707,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
AppReadiness.runNowOrWhenAppDidBecomeReady {
guard Identity.userExists() else { return }
guard Identity.userCompletedRequiredOnboarding() else { return }
SessionApp.homeViewController.wrappedValue?.createNewConversation()
completionHandler(true)
@ -567,17 +783,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
guard Identity.userExists() else { return }
poller.startIfNeeded()
guard shouldStartGroupPollers else { return }
ClosedGroupPoller.shared.start()
OpenGroupManager.shared.startPolling()
/// There is a fun issue where if you launch without any valid paths then the pollers are guaranteed to fail their first poll due to
/// trying and failing to build paths without having the `SnodeAPI.snodePool` populated, by waiting for the
/// `JobRunner.blockingQueue` to complete we can have more confidence that paths won't fail to build incorrectly
JobRunner.afterBlockingQueue { [weak self] in
self?.poller.start()
guard shouldStartGroupPollers else { return }
ClosedGroupPoller.shared.start()
OpenGroupManager.shared.startPolling()
}
}
public func stopPollers(shouldStopUserPoller: Bool = true) {
if shouldStopUserPoller {
poller.stop()
poller.stopAllPollers()
}
ClosedGroupPoller.shared.stopAllPollers()
@ -655,20 +876,81 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
// MARK: - Config Sync
func syncConfigurationIfNeeded() {
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
guard !SessionUtil.userConfigsEnabled else { return }
let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast)
guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days
Storage.shared
.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) }
.done {
// Only update the 'lastConfigurationSync' timestamp if we have done the
// first sync (Don't want a new device config sync to override config
// syncs from other devices)
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
UserDefaults.standard[.lastConfigurationSync] = Date()
.writeAsync(
updates: { db in
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
},
completion: { _, result in
switch result {
case .failure: break
case .success:
// Only update the 'lastConfigurationSync' timestamp if we have done the
// first sync (Don't want a new device config sync to override config
// syncs from other devices)
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
UserDefaults.standard[.lastConfigurationSync] = Date()
}
}
}
}
.retainUntilComplete()
)
}
}
// MARK: - LifecycleMethod
private enum LifecycleMethod: Equatable {
case finishLaunching
case enterForeground(initialLaunchFailed: Bool)
case didBecomeActive
var timingName: String {
switch self {
case .finishLaunching: return "Launch"
case .enterForeground: return "EnterForeground"
case .didBecomeActive: return "BecomeActive"
}
}
static func == (lhs: LifecycleMethod, rhs: LifecycleMethod) -> Bool {
switch (lhs, rhs) {
case (.finishLaunching, .finishLaunching): return true
case (.enterForeground(let lhsFailed), .enterForeground(let rhsFailed)): return (lhsFailed == rhsFailed)
case (.didBecomeActive, .didBecomeActive): return true
default: return false
}
}
}
// MARK: - StartupError
private enum StartupError: Error {
case databaseError(Error)
case failedToRestore
case startupTimeout
var name: String {
switch self {
case .databaseError(StorageError.startupFailed): return "Database startup failed"
case .failedToRestore: return "Failed to restore"
case .databaseError: return "Database error"
case .startupTimeout: return "Startup timeout"
}
}
var message: String {
switch self {
case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized()
case .databaseError: return "DATABASE_MIGRATION_FAILED".localized()
case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized()
}
}
}

View File

@ -3,6 +3,7 @@
import Foundation
import SessionUtilitiesKit
import SignalUtilitiesKit
import SignalCoreKit
public class AppEnvironment {

View File

@ -10,6 +10,8 @@ extern NSString *const ReportedApplicationStateDidChangeNotification;
@interface MainAppContext : NSObject <AppContext>
- (instancetype)init;
@end
NS_ASSUME_NONNULL_END

View File

@ -4,7 +4,7 @@
#import "MainAppContext.h"
#import "Session-Swift.h"
#import <SignalCoreKit/Threading.h>
#import <SignalCoreKit/OWSAsserts.h>
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
NS_ASSUME_NONNULL_BEGIN
@ -252,7 +252,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic
{
OWSAssertDebug(block);
DispatchMainThreadSafe(^{
[Threading dispatchMainThreadSafe:^{
if (self.isMainAppAndActive) {
// App active blocks typically will be used to safely access the
// shared data container, so use a background task to protect this
@ -266,7 +266,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic
}
[self.appActiveBlocks addObject:block];
});
}];
}
- (void)runAppActiveBlocks

View File

@ -2,13 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>BuildDetails</key>
<dict>
<key>CarthageVersion</key>
<string>0.36.0</string>
<key>OSXVersion</key>
<string>10.15.6</string>
</dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
@ -88,7 +81,7 @@
<key>NSCameraUsageDescription</key>
<string>Session needs camera access to take pictures and scan QR codes.</string>
<key>NSFaceIDUsageDescription</key>
<string>Session's Screen Lock feature uses Face ID.</string>
<string>Session&apos;s Screen Lock feature uses Face ID.</string>
<key>NSHumanReadableCopyright</key>
<string>com.loki-project.loki-messenger</string>
<key>NSMicrophoneUsageDescription</key>

View File

@ -1,14 +0,0 @@
//
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
//
#import <Availability.h>
#ifdef __OBJC__
#import <UIKit/UIKit.h>
#import <Foundation/Foundation.h>
#import <SignalCoreKit/NSObject+OWS.h>
#import <SignalCoreKit/OWSAsserts.h>
#import <SessionUIKit/SessionUIKit.h>
#endif

View File

@ -3,64 +3,110 @@
import Foundation
import SessionUtilitiesKit
import SessionMessagingKit
import SignalCoreKit
import SessionUIKit
public struct SessionApp {
// FIXME: Refactor this to be protocol based for unit testing (or even dynamic based on view hierarchy - do want to avoid needing to use the main thread to access them though)
static let homeViewController: Atomic<HomeVC?> = Atomic(nil)
static let currentlyOpenConversationViewController: Atomic<ConversationVC?> = Atomic(nil)
static var versionInfo: String {
let buildNumber: String = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String)
.map { " (\($0))" }
.defaulting(to: "")
let appVersion: String? = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String)
.map { "App: \($0)\(buildNumber)" }
#if DEBUG
let commitInfo: String? = (Bundle.main.infoDictionary?["GitCommitHash"] as? String).map { "Commit: \($0)" }
#else
let commitInfo: String? = nil
#endif
let versionInfo: [String] = [
"iOS \(UIDevice.current.systemVersion)",
appVersion,
"libSession: \(SessionUtil.libSessionVersion)",
commitInfo
].compactMap { $0 }
return versionInfo.joined(separator: ", ")
}
// MARK: - View Convenience Methods
public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) {
let maybeThreadInfo: (thread: SessionThread, isMessageRequest: Bool)? = Storage.shared.write { db in
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
return (thread, thread.isMessageRequest(db))
}
guard
let variant: SessionThread.Variant = maybeThreadInfo?.thread.variant,
let isMessageRequest: Bool = maybeThreadInfo?.isMessageRequest
else { return }
self.presentConversation(
for: threadId,
threadVariant: variant,
isMessageRequest: isMessageRequest,
action: action,
focusInteractionId: nil,
animated: animated
)
}
public static func presentConversation(
public static func presentConversationCreatingIfNeeded(
for threadId: String,
threadVariant: SessionThread.Variant,
isMessageRequest: Bool,
action: ConversationViewModel.Action,
focusInteractionId: Int64?,
variant: SessionThread.Variant,
action: ConversationViewModel.Action = .none,
dismissing presentingViewController: UIViewController?,
animated: Bool
) {
guard Thread.isMainThread else {
DispatchQueue.main.async {
self.presentConversation(
for: threadId,
threadVariant: threadVariant,
isMessageRequest: isMessageRequest,
action: action,
focusInteractionId: focusInteractionId,
animated: animated
)
let threadInfo: (threadExists: Bool, isMessageRequest: Bool)? = Storage.shared.read { db in
let isMessageRequest: Bool = {
switch variant {
case .contact:
return SessionThread
.isMessageRequest(
id: threadId,
variant: .contact,
currentUserPublicKey: getUserHexEncodedPublicKey(db),
shouldBeVisible: nil,
contactIsApproved: (try? Contact
.filter(id: threadId)
.select(.isApproved)
.asRequest(of: Bool.self)
.fetchOne(db))
.defaulting(to: false),
includeNonVisible: true
)
default: return false
}
}()
return (SessionThread.filter(id: threadId).isNotEmpty(db), isMessageRequest)
}
// Store the post-creation logic in a closure to avoid duplication
let afterThreadCreated: () -> () = {
presentingViewController?.dismiss(animated: true, completion: nil)
homeViewController.wrappedValue?.show(
threadId,
variant: variant,
isMessageRequest: (threadInfo?.isMessageRequest == true),
with: action,
focusedInteractionInfo: nil,
animated: animated
)
}
/// The thread should generally exist at the time of calling this method, but on the off change it doesn't then we need to `fetchOrCreate` it and
/// should do it on a background thread just in case something is keeping the DBWrite thread busy as in the past this could cause the app to hang
guard threadInfo?.threadExists == true else {
DispatchQueue.global(qos: .userInitiated).async {
Storage.shared.write { db in
try SessionThread.fetchOrCreate(db, id: threadId, variant: variant, shouldBeVisible: nil)
}
// Send back to main thread for UI transitions
DispatchQueue.main.async {
afterThreadCreated()
}
}
return
}
homeViewController.wrappedValue?.show(
threadId,
variant: threadVariant,
isMessageRequest: isMessageRequest,
with: action,
focusedInteractionId: focusInteractionId,
animated: animated
)
// Send to main thread if needed
guard Thread.isMainThread else {
DispatchQueue.main.async {
afterThreadCreated()
}
return
}
afterThreadCreated()
}
// MARK: - Functions
@ -69,7 +115,8 @@ public struct SessionApp {
// This _should_ be wiped out below.
Logger.error("")
DDLog.flushLog()
SessionUtil.clearMemoryState()
Storage.resetAllStorage()
ProfileManager.resetProfileStorage()
Attachment.resetAttachmentStorage()

File diff suppressed because it is too large Load Diff

View File

@ -4,5 +4,58 @@
<dict>
<key>StringsTable</key>
<string>Root</string>
<key>PreferenceSpecifiers</key>
<array>
<dict>
<key>Type</key>
<string>PSGroupSpecifier</string>
<key>Title</key>
<string>Group</string>
</dict>
<dict>
<key>Type</key>
<string>PSTextFieldSpecifier</string>
<key>Title</key>
<string>Name</string>
<key>Key</key>
<string>name_preference</string>
<key>DefaultValue</key>
<string></string>
<key>IsSecure</key>
<false/>
<key>KeyboardType</key>
<string>Alphabet</string>
<key>AutocapitalizationType</key>
<string>None</string>
<key>AutocorrectionType</key>
<string>No</string>
</dict>
<dict>
<key>Type</key>
<string>PSToggleSwitchSpecifier</string>
<key>Title</key>
<string>Enabled</string>
<key>Key</key>
<string>enabled_preference</string>
<key>DefaultValue</key>
<true/>
</dict>
<dict>
<key>Type</key>
<string>PSSliderSpecifier</string>
<key>Key</key>
<string>slider_preference</string>
<key>DefaultValue</key>
<real>0.5</real>
<key>MinimumValue</key>
<integer>0</integer>
<key>MaximumValue</key>
<integer>1</integer>
<key>MinimumValueImage</key>
<string></string>
<key>MaximumValueImage</key>
<string></string>
</dict>
</array>
</dict>
</plist>

File diff suppressed because one or more lines are too long

View File

@ -2,38 +2,9 @@
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>
#import <SessionUIKit/SessionUIKit.h>
// Separate iOS Frameworks from other imports.
#import "AVAudioSession+OWS.h"
#import "OWSAudioPlayer.h"
#import "OWSBezierPathView.h"
#import "OWSMessageTimerView.h"
#import "OWSWindowManager.h"
#import "MainAppContext.h"
#import <PureLayout/PureLayout.h>
#import <Reachability/Reachability.h>
#import <SignalCoreKit/Cryptography.h>
#import <SignalCoreKit/NSData+OWS.h>
#import <SignalCoreKit/NSDate+OWS.h>
#import <SignalCoreKit/OWSAsserts.h>
#import <SignalCoreKit/OWSLogs.h>
#import <SignalCoreKit/Threading.h>
#import <SessionMessagingKit/OWSAudioPlayer.h>
#import <SignalUtilitiesKit/OWSFormat.h>
#import <SignalUtilitiesKit/OWSViewController.h>
#import <SignalUtilitiesKit/UIFont+OWS.h>
#import <SessionUtilitiesKit/UIView+OWS.h>
#import <SignalUtilitiesKit/AppVersion.h>
#import <SessionUtilitiesKit/DataSource.h>
#import <SessionUtilitiesKit/MIMETypeUtil.h>
#import <SessionUtilitiesKit/NSData+Image.h>
#import <SessionUtilitiesKit/NSNotificationCenter+OWS.h>
#import <SessionUtilitiesKit/NSString+SSK.h>
#import <SignalUtilitiesKit/OWSDispatch.h>
#import <SignalUtilitiesKit/OWSError.h>
#import <SessionUtilitiesKit/OWSFileSystem.h>
#import <SessionUtilitiesKit/UIImage+OWS.h>
#import <YYImage/YYImage.h>

View File

@ -1,8 +0,0 @@
[main]
host = https://www.transifex.com
[signal-ios.localizablestrings-30]
file_filter = <lang>.lproj/Localizable.strings
source_file = en.lproj/Localizable.strings
source_lang = en

Some files were not shown because too many files have changed in this diff Show More