Merge remote-tracking branch 'upstream/dev' into feature/updated-push-server
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Meta/AppDelegate.swift # Session/Meta/Translations/de.lproj/Localizable.strings # Session/Meta/Translations/en.lproj/Localizable.strings # Session/Meta/Translations/es.lproj/Localizable.strings # Session/Meta/Translations/fa.lproj/Localizable.strings # Session/Meta/Translations/fi.lproj/Localizable.strings # Session/Meta/Translations/fr.lproj/Localizable.strings # Session/Meta/Translations/hi.lproj/Localizable.strings # Session/Meta/Translations/hr.lproj/Localizable.strings # Session/Meta/Translations/id-ID.lproj/Localizable.strings # Session/Meta/Translations/it.lproj/Localizable.strings # Session/Meta/Translations/ja.lproj/Localizable.strings # Session/Meta/Translations/nl.lproj/Localizable.strings # Session/Meta/Translations/pl.lproj/Localizable.strings # Session/Meta/Translations/pt_BR.lproj/Localizable.strings # Session/Meta/Translations/ru.lproj/Localizable.strings # Session/Meta/Translations/si.lproj/Localizable.strings # Session/Meta/Translations/sk.lproj/Localizable.strings # Session/Meta/Translations/sv.lproj/Localizable.strings # Session/Meta/Translations/th.lproj/Localizable.strings # Session/Meta/Translations/vi-VN.lproj/Localizable.strings # Session/Meta/Translations/zh-Hant.lproj/Localizable.strings # Session/Meta/Translations/zh_CN.lproj/Localizable.strings # Session/Notifications/SyncPushTokensJob.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageReceiver+ClosedGroups.swift # SessionMessagingKit/Sending & Receiving/Message Handling/MessageSender+ClosedGroups.swift # SessionShareExtension/ShareNavController.swift
This commit is contained in:
commit
1b0fda56ad
|
@ -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'
|
||||
]
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
|
@ -1,3 +1,3 @@
|
|||
[submodule "LibSession-Util"]
|
||||
path = LibSession-Util
|
||||
url = git@github.com:oxen-io/libsession-util.git
|
||||
url = https://github.com/oxen-io/libsession-util.git
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 97084c69f86e67c675095b48efacc86113ccebb0
|
||||
Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2
|
47
Podfile
47
Podfile
|
@ -1,28 +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 '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 'Reachability'
|
||||
pod 'PureLayout', '~> 3.1.8'
|
||||
pod 'NVActivityIndicatorView'
|
||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||
pod 'ZXingObjC'
|
||||
pod 'DifferenceKit'
|
||||
|
||||
target 'SessionTests' do
|
||||
|
@ -94,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)
|
||||
|
@ -125,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
|
||||
|
|
27
Podfile.lock
27
Podfile.lock
|
@ -2,7 +2,6 @@ PODS:
|
|||
- CocoaLumberjack (3.8.0):
|
||||
- CocoaLumberjack/Core (= 3.8.0)
|
||||
- CocoaLumberjack/Core (3.8.0)
|
||||
- CryptoSwift (1.4.2)
|
||||
- Curve25519Kit (2.1.0):
|
||||
- CocoaLumberjack
|
||||
- SignalCoreKit
|
||||
|
@ -35,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)
|
||||
|
@ -43,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)
|
||||
|
@ -110,12 +109,8 @@ PODS:
|
|||
- YYImage/libwebp (1.0.4):
|
||||
- libwebp
|
||||
- YYImage/Core
|
||||
- ZXingObjC (3.6.5):
|
||||
- ZXingObjC/All (= 3.6.5)
|
||||
- ZXingObjC/All (3.6.5)
|
||||
|
||||
DEPENDENCIES:
|
||||
- CryptoSwift
|
||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||
- DifferenceKit
|
||||
- GRDB.swift/SQLCipher
|
||||
|
@ -126,19 +121,17 @@ DEPENDENCIES:
|
|||
- 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:
|
||||
- CocoaLumberjack
|
||||
- CryptoSwift
|
||||
- DifferenceKit
|
||||
- GRDB.swift
|
||||
- libwebp
|
||||
|
@ -149,11 +142,11 @@ SPEC REPOS:
|
|||
- Quick
|
||||
- Reachability
|
||||
- SAMKeychain
|
||||
- SocketRocket
|
||||
- SQLCipher
|
||||
- SwiftProtobuf
|
||||
- WebRTC-lib
|
||||
- ZXingObjC
|
||||
trunk:
|
||||
- xcbeautify
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Curve25519Kit:
|
||||
|
@ -190,7 +183,6 @@ CHECKOUT OPTIONS:
|
|||
|
||||
SPEC CHECKSUMS:
|
||||
CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
|
||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||
DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca
|
||||
GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78
|
||||
|
@ -203,15 +195,14 @@ SPEC CHECKSUMS:
|
|||
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: f461937f78a0482496fea6fc4b2bb5d1351fe044
|
||||
PODFILE CHECKSUM: dd814a5a92577bb2a94dac6a1cc482f193721cdf
|
||||
|
||||
COCOAPODS: 1.11.3
|
||||
COCOAPODS: 1.12.1
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -14,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")
|
||||
}
|
||||
}()
|
||||
|
||||
|
|
|
@ -7,8 +7,8 @@
|
|||
# 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
|
||||
# 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
|
||||
|
@ -21,31 +21,96 @@
|
|||
# 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}"
|
||||
|
||||
# Remove any old build errors
|
||||
rm -rf "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
|
||||
# First ensure cmake is installed (store the error in a log and exit with a success status - xcode will output the error)
|
||||
echo "info: Validating build requirements"
|
||||
|
||||
if ! which cmake > /dev/null; then
|
||||
touch "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
echo "error: cmake is required to build, please install (can install via homebrew with 'brew install cmake')."
|
||||
echo "error: cmake is required to build, please install (can install via homebrew with 'brew install cmake')." > "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
echo_message "error: cmake is required to build, please install (can install via homebrew with 'brew install cmake')."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -d "${SRCROOT}/LibSession-Util" ] || [ ! -d "${SRCROOT}/LibSession-Util/src" ]; then
|
||||
touch "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
echo "error: Need to fetch LibSession-Util submodule."
|
||||
echo "error: Need to fetch LibSession-Util submodule." > "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
exit 1
|
||||
# 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
|
||||
|
@ -54,49 +119,143 @@ 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}/libsession_util_source_hash.log" ]; then
|
||||
read -r OLD_SOURCE_HASH < "${TARGET_BUILD_DIR}/libsession_util_source_hash.log"
|
||||
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}/libsession_util_header_hash.log" ]; then
|
||||
read -r OLD_HEADER_HASH < "${TARGET_BUILD_DIR}/libsession_util_header_hash.log"
|
||||
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}/libsession_util_archs.log" ]; then
|
||||
read -r OLD_ARCHS < "${TARGET_BUILD_DIR}/libsession_util_archs.log"
|
||||
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log" ]; then
|
||||
read -r OLD_ARCHS < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log"
|
||||
fi
|
||||
|
||||
# Start the libSession-util build if it doesn't already exists
|
||||
if [ "${NEW_SOURCE_HASH}" != "${OLD_SOURCE_HASH}" ] || [ "${NEW_HEADER_HASH}" != "${OLD_HEADER_HASH}" ] || [ "${ARCHS[*]}" != "${OLD_ARCHS}" ] || [ ! -d "${TARGET_BUILD_DIR}/libsession-util.xcframework" ]; then
|
||||
echo "info: Build is not up-to-date - creating new build"
|
||||
echo ""
|
||||
|
||||
# Remove any existing build files (just to be safe)
|
||||
rm -rf "${TARGET_BUILD_DIR}/libsession-util.a"
|
||||
rm -rf "${TARGET_BUILD_DIR}/libsession-util.xcframework"
|
||||
rm -rf "${BUILD_DIR}/libsession-util.xcframework"
|
||||
|
||||
# Trigger the new build
|
||||
cd "${SRCROOT}/LibSession-Util"
|
||||
result=$(./utils/ios.sh "libsession-util" false)
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
touch "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
echo "error: Failed to build libsession-util (See details in '${TARGET_BUILD_DIR}/pre-action-output.log')."
|
||||
echo "error: Failed to build libsession-util (See details in '${TARGET_BUILD_DIR}/pre-action-output.log')." > "${TARGET_BUILD_DIR}/libsession_util_error.log"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Save the updated source hash to disk to prevent rebuilds when there were no changes
|
||||
echo "${NEW_SOURCE_HASH}" > "${TARGET_BUILD_DIR}/libsession_util_source_hash.log"
|
||||
echo "${NEW_HEADER_HASH}" > "${TARGET_BUILD_DIR}/libsession_util_header_hash.log"
|
||||
echo "${ARCHS[*]}" > "${TARGET_BUILD_DIR}/libsession_util_archs.log"
|
||||
echo ""
|
||||
echo "info: Build complete"
|
||||
else
|
||||
echo "info: Build is up-to-date"
|
||||
# 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
|
||||
|
||||
# Move the target-specific libSession-util build to the parent build directory (so XCode can have a reference to a single build)
|
||||
rm -rf "${BUILD_DIR}/libsession-util.xcframework"
|
||||
cp -r "${TARGET_BUILD_DIR}/libsession-util.xcframework" "${BUILD_DIR}/libsession-util.xcframework"
|
||||
# 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"
|
|
@ -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
|
@ -5,24 +5,6 @@
|
|||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Build libSession"
|
||||
scriptText = ""${SRCROOT}/Scripts/build_libSession_util.sh" ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "D221A088169C9E5E00537ABF"
|
||||
BuildableName = "Session.app"
|
||||
BlueprintName = "Session"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -1,28 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Build libSession"
|
||||
scriptText = ""${SRCROOT}/Scripts/build_libSession_util.sh" ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A6EF25539DE700C340D1"
|
||||
BuildableName = "SessionMessagingKit.framework"
|
||||
BlueprintName = "SessionMessagingKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -6,24 +6,6 @@
|
|||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Build libSession"
|
||||
scriptText = ""${SRCROOT}/Scripts/build_libSession_util.sh" ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "7BC01A3A241F40AB00BC7C55"
|
||||
BuildableName = "SessionNotificationServiceExtension.appex"
|
||||
BlueprintName = "SessionNotificationServiceExtension"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -6,24 +6,6 @@
|
|||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Build libSession"
|
||||
scriptText = ""${SRCROOT}/Scripts/build_libSession_util.sh" ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "453518671FC635DD00210559"
|
||||
BuildableName = "SessionShareExtension.appex"
|
||||
BlueprintName = "SessionShareExtension"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -1,28 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Build libSession"
|
||||
scriptText = ""${SRCROOT}/Scripts/build_libSession_util.sh" ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C3C2A678255388CC00C340D1"
|
||||
BuildableName = "SessionUtilitiesKit.framework"
|
||||
BlueprintName = "SessionUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -1,28 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1400"
|
||||
version = "1.7">
|
||||
version = "1.3">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES">
|
||||
<PreActions>
|
||||
<ExecutionAction
|
||||
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||
<ActionContent
|
||||
title = "Build libSession"
|
||||
scriptText = ""${SRCROOT}/Scripts/build_libSession_util.sh" ">
|
||||
<EnvironmentBuildable>
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "C33FD9AA255A548A00E217F9"
|
||||
BuildableName = "SignalUtilitiesKit.framework"
|
||||
BlueprintName = "SignalUtilitiesKit"
|
||||
ReferencedContainer = "container:Session.xcodeproj">
|
||||
</BuildableReference>
|
||||
</EnvironmentBuildable>
|
||||
</ActionContent>
|
||||
</ExecutionAction>
|
||||
</PreActions>
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
|
@ -6,6 +6,7 @@ import Combine
|
|||
import CallKit
|
||||
import GRDB
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SignalUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
|
||||
|
@ -157,7 +158,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
||||
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 {
|
||||
|
@ -245,11 +246,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
)
|
||||
// Start the timeout timer for the call
|
||||
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
|
||||
.flatMap { _ in
|
||||
Storage.shared.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||
webRTCSession.sendOffer(db, to: sessionId)
|
||||
}
|
||||
}
|
||||
.flatMap { _ in webRTCSession.sendOffer(to: thread) }
|
||||
.sinkUntilComplete()
|
||||
}
|
||||
|
||||
|
@ -430,10 +427,12 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
|||
let sessionId: String = self.sessionId
|
||||
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||
|
||||
Storage.shared
|
||||
.readPublisherFlatMap { db in
|
||||
webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true)
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import CallKit
|
|||
import GRDB
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||
let provider: CXProvider?
|
||||
|
@ -187,7 +188,7 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
|||
if CurrentAppContext().isInBackground() {
|
||||
// Stop all jobs except for message sending and when completed suspend the database
|
||||
JobRunner.stopAndClearPendingJobs(exceptForVariant: .messageSend) {
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
Storage.suspendDatabaseAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -206,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
|
||||
}
|
||||
|
|
|
@ -3,7 +3,6 @@
|
|||
import UIKit
|
||||
import YYImage
|
||||
import MediaPlayer
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
|
||||
public protocol VideoPreviewDelegate: AnyObject {
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
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
|
||||
|
|
|
@ -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()
|
||||
|
@ -120,7 +113,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
|||
publicKey: call.sessionId,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
||||
profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) },
|
||||
additionalProfile: nil
|
||||
)
|
||||
displayNameLabel.text = call.contactName
|
||||
|
|
|
@ -301,7 +301,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
private func handleMembersChanged() {
|
||||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 72
|
||||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 78
|
||||
tableView.reloadData()
|
||||
}
|
||||
|
||||
|
@ -440,7 +440,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
}
|
||||
|
||||
let threadId: String = self.threadId
|
||||
let threadVariant: SessionThread.Variant = self.threadVariant
|
||||
let updatedName: String = self.name
|
||||
let userPublicKey: String = self.userPublicKey
|
||||
let updatedMemberIds: Set<String> = self.membersAndZombies
|
||||
|
@ -465,21 +464,19 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
|||
|
||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||
Storage.shared
|
||||
.writePublisherFlatMap { db -> AnyPublisher<Void, Error> in
|
||||
if !updatedMemberIds.contains(userPublicKey) {
|
||||
try MessageSender.leave(
|
||||
db,
|
||||
groupPublicKey: threadId,
|
||||
deleteThread: true
|
||||
)
|
||||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
return 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
|
||||
|
|
|
@ -332,10 +332,8 @@ 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
|
||||
.writePublisherFlatMap { db in
|
||||
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
||||
}
|
||||
MessageSender
|
||||
.createClosedGroup(name: name, members: selectedContacts)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
|
@ -358,8 +356,12 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
|||
}
|
||||
},
|
||||
receiveValue: { thread in
|
||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
||||
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
|
||||
SessionApp.presentConversationCreatingIfNeeded(
|
||||
for: thread.id,
|
||||
variant: thread.variant,
|
||||
dismissing: self?.presentingViewController,
|
||||
animated: false
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 || (
|
||||
(
|
||||
|
@ -200,7 +205,8 @@ extension ContextMenuVC {
|
|||
cellViewModel.threadVariant != .community ||
|
||||
currentUserIsOpenGroupModerator ||
|
||||
cellViewModel.authorId == currentUserPublicKey ||
|
||||
cellViewModel.authorId == currentUserBlindedPublicKey ||
|
||||
cellViewModel.authorId == currentUserBlinded15PublicKey ||
|
||||
cellViewModel.authorId == currentUserBlinded25PublicKey ||
|
||||
cellViewModel.state == .failed
|
||||
)
|
||||
let canBan: Bool = (
|
||||
|
@ -210,7 +216,10 @@ extension ContextMenuVC {
|
|||
|
||||
let shouldShowEmojiActions: Bool = {
|
||||
if cellViewModel.threadVariant == .community {
|
||||
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
|
||||
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),
|
||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
|||
import GRDB
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUIKit
|
||||
|
||||
public class StyledSearchController: UISearchController {
|
||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -13,7 +13,9 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
private static let loadingHeaderHeight: CGFloat = 40
|
||||
|
||||
internal let viewModel: ConversationViewModel
|
||||
private var dataChangeObservable: DatabaseCancellable?
|
||||
private var dataChangeObservable: DatabaseCancellable? {
|
||||
didSet { oldValue?.cancel() } // Cancel the old observable if there was one
|
||||
}
|
||||
private var hasLoadedInitialThreadData: Bool = false
|
||||
private var hasLoadedInitialInteractionData: Bool = false
|
||||
private var currentTargetOffset: CGPoint?
|
||||
|
@ -26,6 +28,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
private var hasReloadedThreadDataAfterDisappearance: Bool = true
|
||||
|
||||
var focusedInteractionInfo: Interaction.TimestampInfo?
|
||||
var focusBehaviour: ConversationViewModel.FocusBehaviour = .none
|
||||
var shouldHighlightNextScrollToInteraction: Bool = false
|
||||
|
||||
// Search
|
||||
|
@ -93,14 +96,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
return margin <= ConversationVC.scrollToBottomMargin
|
||||
}
|
||||
|
||||
lazy var mnemonic: String = {
|
||||
if let hexEncodedSeed: String = Identity.fetchHexEncodedSeed() {
|
||||
return Mnemonic.encode(hexEncodedString: hexEncodedSeed)
|
||||
}
|
||||
|
||||
// Legacy account
|
||||
return Mnemonic.encode(hexEncodedString: Identity.fetchUserPrivateKey()!.toHexString())
|
||||
}()
|
||||
lazy var mnemonic: String = { ((try? SeedVC.mnemonic()) ?? "") }()
|
||||
|
||||
// FIXME: Would be good to create a Swift-based cache and replace this
|
||||
lazy var mediaCache: NSCache<NSString, AnyObject> = {
|
||||
|
@ -157,6 +153,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
)
|
||||
result.registerHeaderFooterView(view: UITableViewHeaderFooterView.self)
|
||||
result.register(view: DateHeaderCell.self)
|
||||
result.register(view: UnreadMarkerCell.self)
|
||||
result.register(view: VisibleMessageCell.self)
|
||||
result.register(view: InfoMessageCell.self)
|
||||
result.register(view: TypingIndicatorCell.self)
|
||||
|
@ -182,6 +179,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize)
|
||||
result.set(.height, to: ConversationVC.unreadCountViewSize)
|
||||
result.isHidden = true
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
@ -253,7 +251,20 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
return result
|
||||
}()
|
||||
|
||||
lazy var scrollButton: ScrollToBottomButton = ScrollToBottomButton(delegate: self)
|
||||
lazy var scrollButton: RoundIconButton = {
|
||||
let result: RoundIconButton = RoundIconButton(
|
||||
image: UIImage(named: "ic_chevron_down")?
|
||||
.withRenderingMode(.alwaysTemplate)
|
||||
) { [weak self] in
|
||||
// The table view's content size is calculated by the estimated height of cells,
|
||||
// so the result may be inaccurate before all the cells are loaded. Use this
|
||||
// to scroll to the last row instead.
|
||||
self?.scrollToBottom(isAnimated: true)
|
||||
}
|
||||
result.alpha = 0
|
||||
|
||||
return result
|
||||
}()
|
||||
|
||||
lazy var messageRequestBackgroundView: UIView = {
|
||||
let result: UIView = UIView()
|
||||
|
@ -502,6 +513,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
/// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the
|
||||
/// main thread (we don't currently care if it's still in the nav stack though - so if a user is on a conversation settings screen this should
|
||||
/// get cleared within `viewWillDisappear`)
|
||||
///
|
||||
/// **Note:** We do this on an async queue because `Atomic<T>` can block if something else is mutating it and we want to avoid
|
||||
/// the risk of blocking the conversation transition
|
||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||
SessionApp.currentlyOpenConversationViewController.mutate { $0 = self }
|
||||
}
|
||||
|
||||
if delayFirstResponder || isShowingSearchUI {
|
||||
delayFirstResponder = false
|
||||
|
||||
|
@ -524,6 +545,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
/// When the `ConversationVC` is on the screen we want to store it so we can avoid sending notification without accessing the
|
||||
/// main thread (we don't currently care if it's still in the nav stack though - so if a user leaves a conversation settings screen we clear
|
||||
/// it, and if a user moves to a different `ConversationVC` this will get updated to that one within `viewDidAppear`)
|
||||
///
|
||||
/// **Note:** We do this on an async queue because `Atomic<T>` can block if something else is mutating it and we want to avoid
|
||||
/// the risk of blocking the conversation transition
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
SessionApp.currentlyOpenConversationViewController.mutate { $0 = nil }
|
||||
}
|
||||
|
||||
viewIsDisappearing = true
|
||||
|
||||
// Don't set the draft or resign the first responder if we are replacing the thread (want the keyboard
|
||||
|
@ -589,7 +620,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
// MARK: - Updating
|
||||
|
||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
||||
// Start observing for data changes
|
||||
guard dataChangeObservable == nil else { return }
|
||||
|
||||
dataChangeObservable = Storage.shared.start(
|
||||
viewModel.observableThreadData,
|
||||
onError: { _ in },
|
||||
|
@ -599,7 +631,10 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
// and need to swap over to the new one
|
||||
guard
|
||||
let sessionId: String = self?.viewModel.threadData.threadId,
|
||||
SessionId.Prefix(from: sessionId) == .blinded,
|
||||
(
|
||||
SessionId.Prefix(from: sessionId) == .blinded15 ||
|
||||
SessionId.Prefix(from: sessionId) == .blinded25
|
||||
),
|
||||
let blindedLookup: BlindedIdLookup = Storage.shared.read({ db in
|
||||
try BlindedIdLookup
|
||||
.filter(id: sessionId)
|
||||
|
@ -659,8 +694,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
}
|
||||
|
||||
func stopObservingChanges() {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
self.dataChangeObservable = nil
|
||||
self.viewModel.onInteractionChange = nil
|
||||
}
|
||||
|
||||
|
@ -837,7 +871,6 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
guard self.hasLoadedInitialInteractionData else {
|
||||
// Need to dispatch async to prevent this from causing glitches in the push animation
|
||||
DispatchQueue.main.async {
|
||||
self.hasLoadedInitialInteractionData = true
|
||||
self.viewModel.updateInteractionData(updatedData)
|
||||
|
||||
// Update the empty state
|
||||
|
@ -845,6 +878,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
|
||||
UIView.performWithoutAnimation {
|
||||
self.tableView.reloadData()
|
||||
self.hasLoadedInitialInteractionData = true
|
||||
self.performInitialScrollIfNeeded()
|
||||
}
|
||||
}
|
||||
|
@ -861,9 +895,34 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
|
||||
// Store the 'sentMessageBeforeUpdate' state locally
|
||||
let didSendMessageBeforeUpdate: Bool = self.viewModel.sentMessageBeforeUpdate
|
||||
let onlyReplacedOptimisticUpdate: Bool = {
|
||||
// Replacing an optimistic update means making a delete and an insert, which will be done
|
||||
// as separate changes at the same positions
|
||||
guard
|
||||
changeset.count > 1 &&
|
||||
changeset[changeset.count - 2].elementDeleted == changeset[changeset.count - 1].elementInserted
|
||||
else { return false }
|
||||
|
||||
let deletedModels: [MessageViewModel] = changeset[changeset.count - 2]
|
||||
.elementDeleted
|
||||
.map { self.viewModel.interactionData[$0.section].elements[$0.element] }
|
||||
let insertedModels: [MessageViewModel] = changeset[changeset.count - 1]
|
||||
.elementInserted
|
||||
.map { updatedData[$0.section].elements[$0.element] }
|
||||
|
||||
// Make sure all the deleted models were optimistic updates, the inserted models were not
|
||||
// optimistic updates and they have the same timestamps
|
||||
return (
|
||||
deletedModels.map { $0.id }.asSet() == [MessageViewModel.optimisticUpdateId] &&
|
||||
insertedModels.map { $0.id }.asSet() != [MessageViewModel.optimisticUpdateId] &&
|
||||
deletedModels.map { $0.timestampMs }.asSet() == insertedModels.map { $0.timestampMs }.asSet()
|
||||
)
|
||||
}()
|
||||
let wasOnlyUpdates: Bool = (
|
||||
changeset.count == 1 &&
|
||||
changeset[0].elementUpdated.count == changeset[0].changeCount
|
||||
onlyReplacedOptimisticUpdate || (
|
||||
changeset.count == 1 &&
|
||||
changeset[0].elementUpdated.count == changeset[0].changeCount
|
||||
)
|
||||
)
|
||||
self.viewModel.sentMessageBeforeUpdate = false
|
||||
|
||||
|
@ -880,6 +939,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
// We need to dispatch to the next run loop because it seems trying to scroll immediately after
|
||||
// triggering a 'reloadData' doesn't work
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.tableView.layoutIfNeeded()
|
||||
self?.scrollToBottom(isAnimated: false)
|
||||
|
||||
// Note: The scroll button alpha won't get set correctly in this case so we forcibly set it to
|
||||
|
@ -936,7 +996,6 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
.firstIndex(where: { item -> Bool in
|
||||
// Since the first item is probably a `DateHeaderCell` (which would likely
|
||||
// be removed when inserting items above it) we check if the id matches
|
||||
// either the first or second item
|
||||
let messages: [MessageViewModel] = self.viewModel
|
||||
.interactionData[oldSectionIndex]
|
||||
.elements
|
||||
|
@ -992,8 +1051,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
self?.searchController.resultsBar.stopLoading()
|
||||
self?.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionInfo,
|
||||
isAnimated: true,
|
||||
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
|
||||
focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none),
|
||||
isAnimated: true
|
||||
)
|
||||
|
||||
if wasLoadingMore {
|
||||
|
@ -1020,8 +1079,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
}
|
||||
else {
|
||||
// Need to update the scroll button alpha in case new messages were added but we didn't scroll
|
||||
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
self.updateScrollToBottom()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -1070,8 +1128,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
self?.searchController.resultsBar.stopLoading()
|
||||
self?.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionInfo,
|
||||
isAnimated: true,
|
||||
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
|
||||
focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none),
|
||||
isAnimated: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -1090,8 +1148,8 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
self?.searchController.resultsBar.stopLoading()
|
||||
self?.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionInfo,
|
||||
isAnimated: true,
|
||||
highlight: (self?.shouldHighlightNextScrollToInteraction == true)
|
||||
focusBehaviour: (self?.shouldHighlightNextScrollToInteraction == true ? .highlight : .none),
|
||||
isAnimated: true
|
||||
)
|
||||
|
||||
// Complete page loading
|
||||
|
@ -1132,14 +1190,17 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
// When the unread message count is more than the number of view items of a page,
|
||||
// the screen will scroll to the bottom instead of the first unread message
|
||||
if let focusedInteractionInfo: Interaction.TimestampInfo = self.viewModel.focusedInteractionInfo {
|
||||
self.scrollToInteractionIfNeeded(with: focusedInteractionInfo, isAnimated: false, highlight: true)
|
||||
self.scrollToInteractionIfNeeded(
|
||||
with: focusedInteractionInfo,
|
||||
focusBehaviour: self.viewModel.focusBehaviour,
|
||||
isAnimated: false
|
||||
)
|
||||
}
|
||||
else {
|
||||
self.scrollToBottom(isAnimated: false)
|
||||
}
|
||||
|
||||
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
self.updateScrollToBottom()
|
||||
self.hasPerformedInitialScroll = true
|
||||
|
||||
// Now that the data has loaded we need to check if either of the "load more" sections are
|
||||
|
@ -1151,7 +1212,11 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
guard
|
||||
self.hasLoadedInitialInteractionData &&
|
||||
!self.isAutoLoadingNextPage &&
|
||||
!self.isLoadingMore
|
||||
else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -1243,8 +1308,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
|
||||
switch threadData.threadVariant {
|
||||
case .contact:
|
||||
let profilePictureView = ProfilePictureView()
|
||||
profilePictureView.size = Values.verySmallProfilePictureSize
|
||||
let profilePictureView = ProfilePictureView(size: .navigation)
|
||||
profilePictureView.update(
|
||||
publicKey: threadData.threadId, // Contact thread uses the contactId
|
||||
threadVariant: threadData.threadVariant,
|
||||
|
@ -1252,9 +1316,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
profile: threadData.profile,
|
||||
additionalProfile: nil
|
||||
)
|
||||
|
||||
profilePictureView.set(.width, to: (44 - 16)) // Width of the standard back button
|
||||
profilePictureView.set(.height, to: Values.verySmallProfilePictureSize)
|
||||
profilePictureView.customWidth = (44 - 16) // Width of the standard back button
|
||||
|
||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
||||
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
||||
|
@ -1330,10 +1392,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
|
||||
self?.tableView.contentInset = newContentInset
|
||||
self?.tableView.contentOffset.y = newContentOffsetY
|
||||
|
||||
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
||||
self?.scrollButton.alpha = scrollButtonOpacity
|
||||
self?.unreadCountView.alpha = scrollButtonOpacity
|
||||
self?.updateScrollToBottom()
|
||||
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
|
@ -1375,10 +1434,7 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
animations: { [weak self] in
|
||||
self?.scrollButtonBottomConstraint?.constant = -(keyboardTop + 12)
|
||||
self?.messageRequestsViewBotomConstraint?.constant = -(keyboardTop + 12)
|
||||
|
||||
let scrollButtonOpacity: CGFloat = (self?.getScrollButtonOpacity() ?? 0)
|
||||
self?.scrollButton.alpha = scrollButtonOpacity
|
||||
self?.unreadCountView.alpha = scrollButtonOpacity
|
||||
self?.updateScrollToBottom()
|
||||
|
||||
self?.view.setNeedsLayout()
|
||||
self?.view.layoutIfNeeded()
|
||||
|
@ -1587,44 +1643,13 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
}
|
||||
|
||||
func scrollViewDidScroll(_ scrollView: UIScrollView) {
|
||||
self.scrollButton.alpha = self.getScrollButtonOpacity()
|
||||
self.unreadCountView.alpha = self.scrollButton.alpha
|
||||
self.updateScrollToBottom()
|
||||
|
||||
// We want to mark messages as read while we scroll, so grab the newest message and mark
|
||||
// everything older as read
|
||||
//
|
||||
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
|
||||
// the table content appears above the input view
|
||||
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
|
||||
// The initial scroll can trigger this logic but we already mark the initially focused message
|
||||
// as read so don't run the below until the user actually scrolls after the initial layout
|
||||
guard self.didFinishInitialLayout else { return }
|
||||
|
||||
if
|
||||
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
|
||||
let messagesSection: Int = visibleIndexPaths
|
||||
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
|
||||
.section,
|
||||
let newestCellViewModel: MessageViewModel = visibleIndexPaths
|
||||
.sorted()
|
||||
.filter({ $0.section == messagesSection })
|
||||
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
|
||||
guard let frame: CGRect = tableView.cellForRow(at: indexPath)?.frame else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (
|
||||
view.convert(frame, from: tableView),
|
||||
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
|
||||
)
|
||||
})
|
||||
// Exclude messages that are partially off the bottom of the screen
|
||||
.filter({ $0.frame.maxY <= tableVisualBottom })
|
||||
.last?
|
||||
.cellViewModel
|
||||
{
|
||||
self.viewModel.markAsRead(
|
||||
target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id),
|
||||
timestampMs: newestCellViewModel.timestampMs
|
||||
)
|
||||
}
|
||||
self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: nil)
|
||||
}
|
||||
|
||||
func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
|
||||
|
@ -1633,12 +1658,16 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
self.shouldHighlightNextScrollToInteraction
|
||||
else {
|
||||
self.focusedInteractionInfo = nil
|
||||
self.focusBehaviour = .none
|
||||
self.shouldHighlightNextScrollToInteraction = false
|
||||
return
|
||||
}
|
||||
|
||||
let behaviour: ConversationViewModel.FocusBehaviour = self.focusBehaviour
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id)
|
||||
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: focusedInteractionInfo)
|
||||
self?.highlightCellIfNeeded(interactionId: focusedInteractionInfo.id, behaviour: behaviour)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1649,12 +1678,29 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
unreadCountLabel.font = .boldSystemFont(ofSize: fontSize)
|
||||
unreadCountView.isHidden = (unreadCount == 0)
|
||||
}
|
||||
|
||||
func getScrollButtonOpacity() -> CGFloat {
|
||||
let contentOffsetY = tableView.contentOffset.y
|
||||
|
||||
public func updateScrollToBottom(force: Bool = false) {
|
||||
// Don't update the scroll button until we have actually setup the initial scroll position to avoid
|
||||
// any odd flickering or incorrect appearance
|
||||
guard self.didFinishInitialLayout || force else { return }
|
||||
|
||||
// If we have a 'loadNewer' item in the interaction data then there are subsequent pages and the
|
||||
// 'scrollToBottom' actions should always be visible to allow the user to jump to the bottom (without
|
||||
// this the button will fade out as the user gets close to the bottom of the current page)
|
||||
guard !self.viewModel.interactionData.contains(where: { $0.model == .loadNewer }) else {
|
||||
self.scrollButton.alpha = 1
|
||||
self.unreadCountView.alpha = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate the target opacity for the scroll button
|
||||
let contentOffsetY: CGFloat = tableView.contentOffset.y
|
||||
let x = (lastPageTop - ConversationVC.bottomInset - contentOffsetY).clamp(0, .greatestFiniteMagnitude)
|
||||
let a = 1 / (ConversationVC.scrollButtonFullVisibilityThreshold - ConversationVC.scrollButtonNoVisibilityThreshold)
|
||||
return max(0, min(1, a * x))
|
||||
let targetOpacity: CGFloat = max(0, min(1, a * x))
|
||||
|
||||
self.scrollButton.alpha = targetOpacity
|
||||
self.unreadCountView.alpha = targetOpacity
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
@ -1768,23 +1814,19 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
}
|
||||
|
||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo interactionInfo: Interaction.TimestampInfo) {
|
||||
scrollToInteractionIfNeeded(with: interactionInfo, highlight: true)
|
||||
scrollToInteractionIfNeeded(with: interactionInfo, focusBehaviour: .highlight)
|
||||
}
|
||||
|
||||
func scrollToInteractionIfNeeded(
|
||||
with interactionInfo: Interaction.TimestampInfo,
|
||||
focusBehaviour: ConversationViewModel.FocusBehaviour = .none,
|
||||
position: UITableView.ScrollPosition = .middle,
|
||||
isJumpingToLastInteraction: Bool = false,
|
||||
isAnimated: Bool = true,
|
||||
highlight: Bool = false
|
||||
isAnimated: Bool = true
|
||||
) {
|
||||
// Store the info incase we need to load more data (call will be re-triggered)
|
||||
self.focusedInteractionInfo = interactionInfo
|
||||
self.shouldHighlightNextScrollToInteraction = highlight
|
||||
self.viewModel.markAsRead(
|
||||
target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id),
|
||||
timestampMs: interactionInfo.timestampMs
|
||||
)
|
||||
self.shouldHighlightNextScrollToInteraction = (focusBehaviour == .highlight)
|
||||
|
||||
// Ensure the target interaction has been loaded
|
||||
guard
|
||||
|
@ -1818,34 +1860,62 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
return
|
||||
}
|
||||
|
||||
let targetIndexPath: IndexPath = IndexPath(
|
||||
row: targetMessageIndex,
|
||||
section: messageSectionIndex
|
||||
)
|
||||
// If it's before the initial layout and the index before the target is an 'UnreadMarker' then
|
||||
// we should scroll to that instead (will be better UX)
|
||||
let targetIndexPath: IndexPath = {
|
||||
guard
|
||||
!self.didFinishInitialLayout &&
|
||||
targetMessageIndex > 0 &&
|
||||
self.viewModel.interactionData[messageSectionIndex]
|
||||
.elements[targetMessageIndex - 1]
|
||||
.cellType == .unreadMarker
|
||||
else {
|
||||
return IndexPath(
|
||||
row: targetMessageIndex,
|
||||
section: messageSectionIndex
|
||||
)
|
||||
}
|
||||
|
||||
return IndexPath(
|
||||
row: (targetMessageIndex - 1),
|
||||
section: messageSectionIndex
|
||||
)
|
||||
}()
|
||||
let targetPosition: UITableView.ScrollPosition = {
|
||||
guard position == .middle else { return position }
|
||||
|
||||
// Make sure the target cell isn't too large for the screen (if it is then we want to scroll
|
||||
// it to the top rather than the middle
|
||||
let cellSize: CGSize = self.tableView(
|
||||
tableView,
|
||||
cellForRowAt: targetIndexPath
|
||||
).systemLayoutSizeFitting(view.bounds.size)
|
||||
|
||||
guard cellSize.height > tableView.frame.size.height else { return position }
|
||||
|
||||
return .top
|
||||
}()
|
||||
|
||||
// If we aren't animating or aren't highlighting then everything can be run immediately
|
||||
guard isAnimated && highlight else {
|
||||
guard isAnimated else {
|
||||
self.tableView.scrollToRow(
|
||||
at: targetIndexPath,
|
||||
at: position,
|
||||
at: targetPosition,
|
||||
animated: (self.didFinishInitialLayout && isAnimated)
|
||||
)
|
||||
|
||||
// Need to explicitly call 'scrollViewDidScroll' here as it won't get triggered
|
||||
// by 'scrollToRow' if a scroll doesn't occur (eg. if there is less than 1 screen
|
||||
// of messages)
|
||||
self.scrollViewDidScroll(self.tableView)
|
||||
|
||||
// If we haven't finished the initial layout then we want to delay the highlight slightly
|
||||
// so it doesn't look buggy with the push transition
|
||||
if highlight {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
|
||||
self?.highlightCellIfNeeded(interactionId: interactionInfo.id)
|
||||
}
|
||||
// If we haven't finished the initial layout then we want to delay the highlight/markRead slightly
|
||||
// so it doesn't look buggy with the push transition and we know for sure the correct visible cells
|
||||
// have been loaded
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(self.didFinishInitialLayout ? 0 : 150)) { [weak self] in
|
||||
self?.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
|
||||
self?.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
|
||||
self?.updateScrollToBottom(force: true)
|
||||
}
|
||||
|
||||
self.shouldHighlightNextScrollToInteraction = false
|
||||
self.focusedInteractionInfo = nil
|
||||
self.focusBehaviour = .none
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -1856,16 +1926,70 @@ final class ConversationVC: BaseVC, SessionUtilRespondingViewController, Convers
|
|||
let targetRect: CGRect = self.tableView.rectForRow(at: targetIndexPath)
|
||||
|
||||
guard !self.tableView.bounds.contains(targetRect) else {
|
||||
self.highlightCellIfNeeded(interactionId: interactionInfo.id)
|
||||
self.markFullyVisibleAndOlderCellsAsRead(interactionInfo: interactionInfo)
|
||||
self.highlightCellIfNeeded(interactionId: interactionInfo.id, behaviour: focusBehaviour)
|
||||
return
|
||||
}
|
||||
|
||||
self.tableView.scrollToRow(at: targetIndexPath, at: position, animated: true)
|
||||
self.tableView.scrollToRow(at: targetIndexPath, at: targetPosition, animated: true)
|
||||
}
|
||||
|
||||
func highlightCellIfNeeded(interactionId: Int64) {
|
||||
func markFullyVisibleAndOlderCellsAsRead(interactionInfo: Interaction.TimestampInfo?) {
|
||||
// We want to mark messages as read on load and while we scroll, so grab the newest message and mark
|
||||
// everything older as read
|
||||
//
|
||||
// Note: For the 'tableVisualBottom' we remove the 'Values.mediumSpacing' as that is the distance
|
||||
// the table content appears above the input view
|
||||
let tableVisualBottom: CGFloat = (tableView.frame.maxY - (tableView.contentInset.bottom - Values.mediumSpacing))
|
||||
|
||||
guard
|
||||
let visibleIndexPaths: [IndexPath] = self.tableView.indexPathsForVisibleRows,
|
||||
let messagesSection: Int = visibleIndexPaths
|
||||
.first(where: { self.viewModel.interactionData[$0.section].model == .messages })?
|
||||
.section,
|
||||
let newestCellViewModel: MessageViewModel = visibleIndexPaths
|
||||
.sorted()
|
||||
.filter({ $0.section == messagesSection })
|
||||
.compactMap({ indexPath -> (frame: CGRect, cellViewModel: MessageViewModel)? in
|
||||
guard let cell: VisibleMessageCell = tableView.cellForRow(at: indexPath) as? VisibleMessageCell else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (
|
||||
view.convert(cell.frame, from: tableView),
|
||||
self.viewModel.interactionData[indexPath.section].elements[indexPath.row]
|
||||
)
|
||||
})
|
||||
// Exclude messages that are partially off the bottom of the screen
|
||||
.filter({ $0.frame.maxY <= tableVisualBottom })
|
||||
.last?
|
||||
.cellViewModel
|
||||
else {
|
||||
// If we weren't able to get any visible cells for some reason then we should fall back to
|
||||
// marking the provided interactionInfo as read just in case
|
||||
if let interactionInfo: Interaction.TimestampInfo = interactionInfo {
|
||||
self.viewModel.markAsRead(
|
||||
target: .threadAndInteractions(interactionsBeforeInclusive: interactionInfo.id),
|
||||
timestampMs: interactionInfo.timestampMs
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Mark all interactions before the newest entirely-visible one as read
|
||||
self.viewModel.markAsRead(
|
||||
target: .threadAndInteractions(interactionsBeforeInclusive: newestCellViewModel.id),
|
||||
timestampMs: newestCellViewModel.timestampMs
|
||||
)
|
||||
}
|
||||
|
||||
func highlightCellIfNeeded(interactionId: Int64, behaviour: ConversationViewModel.FocusBehaviour) {
|
||||
self.shouldHighlightNextScrollToInteraction = false
|
||||
self.focusedInteractionInfo = nil
|
||||
self.focusBehaviour = .none
|
||||
|
||||
// Only trigger the highlight if that's the desired behaviour
|
||||
guard behaviour == .highlight else { return }
|
||||
|
||||
// Trigger on the next run loop incase we are still finishing some other animation
|
||||
DispatchQueue.main.async {
|
||||
|
|
|
@ -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 {
|
||||
|
@ -35,6 +43,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
public var sentMessageBeforeUpdate: Bool = false
|
||||
public var lastSearchedText: String?
|
||||
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 {
|
||||
|
@ -54,28 +66,29 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
|
||||
typealias InitialData = (
|
||||
targetInteractionInfo: Interaction.TimestampInfo?,
|
||||
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 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
|
||||
// unread interaction and start focused around that one
|
||||
let targetInteractionInfo: Interaction.TimestampInfo? = (focusedInteractionInfo != nil ? focusedInteractionInfo :
|
||||
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 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)
|
||||
|
@ -87,7 +100,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
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)
|
||||
)
|
||||
|
@ -98,32 +111,46 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
.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 (
|
||||
targetInteractionInfo,
|
||||
currentUserPublicKey,
|
||||
initialUnreadInteractionInfo,
|
||||
threadIsBlocked,
|
||||
currentUserIsClosedGroupMember,
|
||||
openGroupPermissions,
|
||||
blindedKey
|
||||
blinded15Key,
|
||||
blinded25Key
|
||||
)
|
||||
}
|
||||
|
||||
self.threadId = threadId
|
||||
self.initialThreadVariant = threadVariant
|
||||
self.focusedInteractionInfo = initialData?.targetInteractionInfo
|
||||
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
|
||||
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
|
||||
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
|
||||
self.threadData = SessionThreadViewModel(
|
||||
threadId: threadId,
|
||||
threadVariant: threadVariant,
|
||||
threadIsNoteToSelf: (self.threadId == getUserHexEncodedPublicKey()),
|
||||
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
|
||||
|
@ -132,18 +159,16 @@ 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 `initialFocusedInfo` then default to `.pageBefore` (it'll query
|
||||
// from a `0` offset)
|
||||
guard let initialFocusedInfo: Interaction.TimestampInfo = initialData?.targetInteractionInfo else {
|
||||
guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else {
|
||||
self?.pagedDataObserver?.load(.pageBefore)
|
||||
return
|
||||
}
|
||||
|
@ -167,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)
|
||||
|
@ -181,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) {
|
||||
|
@ -196,6 +224,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
|
||||
// MARK: - Interaction Data
|
||||
|
||||
private var lastInteractionIdMarkedAsRead: Int64? = nil
|
||||
private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0
|
||||
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||
public private(set) var interactionData: [SectionModel] = []
|
||||
|
@ -206,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,
|
||||
|
@ -261,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
|
||||
),
|
||||
|
@ -305,8 +346,16 @@ 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 { $0.map { $0.messageViewModel } },
|
||||
initialUnreadInteractionId: self?.initialUnreadInteractionId
|
||||
),
|
||||
currentDataRetriever: { self?.interactionData },
|
||||
onDataChange: self?.onInteractionChange,
|
||||
onUnobservedDataChange: { updatedData, changeset in
|
||||
|
@ -320,10 +369,17 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
)
|
||||
}
|
||||
|
||||
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 }
|
||||
.filter { $0.id != MessageViewModel.optimisticUpdateId } // Remove old optimistic updates
|
||||
.appending(contentsOf: (optimisticMessages ?? [])) // Insert latest optimistic updates
|
||||
.filter { !$0.cellType.isPostProcessed } // Remove headers and other
|
||||
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||
|
||||
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||
|
@ -353,20 +409,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,
|
||||
|
@ -389,12 +456,185 @@ 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,
|
||||
messageViewModel: MessageViewModel,
|
||||
interaction: Interaction,
|
||||
attachmentData: Attachment.PreparedData?,
|
||||
linkPreviewDraft: LinkPreviewDraft?,
|
||||
linkPreviewAttachment: Attachment?,
|
||||
quoteModel: QuotedReplyModel?
|
||||
)
|
||||
|
||||
private var optimisticallyInsertedMessages: Atomic<[UUID: OptimisticMessageData]> = 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
|
||||
)
|
||||
}
|
||||
|
||||
// Generate the actual 'MessageViewModel'
|
||||
let messageViewModel: MessageViewModel = MessageViewModel(
|
||||
optimisticMessageId: optimisticMessageId,
|
||||
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
|
||||
)
|
||||
let optimisticData: OptimisticMessageData = (
|
||||
optimisticMessageId,
|
||||
messageViewModel,
|
||||
interaction,
|
||||
optimisticAttachments,
|
||||
linkPreviewDraft,
|
||||
linkPreviewAttachment,
|
||||
quoteModel
|
||||
)
|
||||
|
||||
optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = optimisticData }
|
||||
forceUpdateDataIfPossible()
|
||||
|
||||
return optimisticData
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
public func failedToStoreOptimisticOutgoingMessage(id: UUID, error: Error) {
|
||||
optimisticallyInsertedMessages.mutate {
|
||||
$0[id] = $0[id].map {
|
||||
(
|
||||
$0.id,
|
||||
$0.messageViewModel.with(
|
||||
state: .failed,
|
||||
mostRecentFailureText: "FAILED_TO_STORE_OUTGOING_MESSAGE".localized()
|
||||
),
|
||||
$0.interaction,
|
||||
$0.attachmentData,
|
||||
$0.linkPreviewDraft,
|
||||
$0.linkPreviewAttachment,
|
||||
$0.quoteModel
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
forceUpdateDataIfPossible()
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
|
||||
public func optimisticMessageData(for optimisticMessageId: UUID) -> OptimisticMessageData? {
|
||||
return optimisticallyInsertedMessages.wrappedValue[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) } }
|
||||
}
|
||||
|
||||
private func forceUpdateDataIfPossible() {
|
||||
// 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 }
|
||||
|
||||
/// **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: optimisticallyInsertedMessages.wrappedValue.values.map { $0.messageViewModel },
|
||||
initialUnreadInteractionId: initialUnreadInteractionId
|
||||
),
|
||||
currentDataRetriever: { [weak self] in self?.interactionData },
|
||||
onDataChange: self.onInteractionChange,
|
||||
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
|
||||
nil :
|
||||
(updatedData, changeset)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Mentions
|
||||
|
@ -415,9 +655,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
|
||||
|
@ -425,7 +665,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
userPublicKey: userPublicKey,
|
||||
threadId: threadData.threadId,
|
||||
threadVariant: threadData.threadVariant,
|
||||
targetPrefix: targetPrefix,
|
||||
targetPrefixes: targetPrefixes,
|
||||
pattern: pattern
|
||||
)?
|
||||
.fetchAll(db))
|
||||
|
@ -464,29 +704,47 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
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 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)
|
||||
/// 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
|
||||
///
|
||||
/// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read
|
||||
switch target {
|
||||
case .thread: self.threadData.markAsRead(target: target)
|
||||
case .threadAndInteractions:
|
||||
guard
|
||||
timestampMs == nil ||
|
||||
self.lastInteractionTimestampMsMarkedAsRead < (timestampMs ?? 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.threadData.markAsRead(target: target)
|
||||
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) {
|
||||
|
@ -502,7 +760,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
|
||||
|
@ -556,6 +815,14 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
public func expandReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.insert(interactionId)
|
||||
}
|
||||
|
||||
public func collapseReactions(for interactionId: Int64) {
|
||||
reactionExpandedInteractionIds.remove(interactionId)
|
||||
}
|
||||
|
||||
// MARK: - Audio Playback
|
||||
|
||||
public struct PlaybackInfo {
|
||||
|
@ -657,6 +924,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
|
||||
|
@ -669,7 +937,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
|||
delegate: self
|
||||
)
|
||||
audioPlayer.play()
|
||||
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
|
||||
audioPlayer.setCurrentTime(currentPlaybackTime ?? 0)
|
||||
player = audioPlayer
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
import UIKit
|
||||
import SessionUIKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
class EmojiSkinTonePicker: UIView {
|
||||
let emoji: Emoji
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import Combine
|
|||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||
// MARK: - Variables
|
||||
|
@ -264,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,
|
||||
|
@ -331,6 +333,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
|||
|
||||
// Build the link preview
|
||||
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
@ -499,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()
|
||||
|
||||
|
|
|
@ -116,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()
|
||||
|
@ -160,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)
|
||||
|
@ -179,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)
|
||||
|
@ -204,12 +189,11 @@ private extension MentionSelectionView {
|
|||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||
profilePictureView.update(
|
||||
publicKey: profile.id,
|
||||
threadVariant: .contact,
|
||||
threadVariant: .contact, // Always show the display picture in 'contact' mode
|
||||
customImageData: nil,
|
||||
profile: profile,
|
||||
additionalProfile: nil
|
||||
profileIcon: (isUserModeratorOrAdmin ? .crown : .none)
|
||||
)
|
||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
||||
separator.isHidden = isLast
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,7 @@ import YYImage
|
|||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalCoreKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
public class MediaView: UIView {
|
||||
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
|
|
@ -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 == .contact {
|
||||
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)
|
||||
|
|
|
@ -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 = []
|
||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
|||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionMessagingKit
|
||||
import SessionUIKit
|
||||
|
||||
final class DateHeaderCell: MessageCell {
|
||||
// MARK: - UI
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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?) {}
|
||||
}
|
|
@ -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)
|
||||
|
@ -318,17 +299,21 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
|||
)
|
||||
|
||||
// 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, // Should always be '.contact'
|
||||
threadVariant: .contact, // Always show the display picture in 'contact' mode
|
||||
customImageData: nil,
|
||||
profile: cellViewModel.profile,
|
||||
additionalProfile: nil
|
||||
profileIcon: (cellViewModel.isSenderOpenGroupModerator ? .crown : .none)
|
||||
)
|
||||
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
||||
|
||||
// Bubble view
|
||||
contentViewLeadingConstraint1.isActive = (
|
||||
|
@ -400,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
|
||||
|
@ -504,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
|
||||
|
@ -557,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
|
||||
|
@ -883,7 +869,8 @@ 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 != .community else {
|
||||
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
|
||||
// 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,
|
||||
|
@ -1133,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,
|
||||
|
|
|
@ -7,6 +7,7 @@ import DifferenceKit
|
|||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SessionSnodeKit
|
||||
|
||||
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadDisappearingMessagesSettingsViewModel.NavButton, ThreadDisappearingMessagesSettingsViewModel.Section, ThreadDisappearingMessagesSettingsViewModel.Item> {
|
||||
// MARK: - Config
|
||||
|
@ -149,6 +150,7 @@ class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadD
|
|||
]
|
||||
}
|
||||
.removeDuplicates()
|
||||
.handleEvents(didFail: { SNLog("[ThreadDisappearingMessageSettingsViewModel] Observation failed with error: \($0)") })
|
||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||
.mapToSessionTableViewData(for: self)
|
||||
|
||||
|
|
|
@ -201,7 +201,12 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
.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]
|
||||
|
@ -239,12 +244,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
|||
id: .avatar,
|
||||
accessory: .profile(
|
||||
id: threadViewModel.id,
|
||||
size: .extraLarge,
|
||||
size: .hero,
|
||||
threadVariant: threadVariant,
|
||||
customImageData: threadViewModel.openGroupProfilePictureData,
|
||||
profile: threadViewModel.profile,
|
||||
profileIcon: .none,
|
||||
additionalProfile: threadViewModel.additionalProfile,
|
||||
cornerIcon: nil,
|
||||
additionalProfileIcon: .none,
|
||||
accessibility: nil
|
||||
),
|
||||
styling: SessionCell.StyleInfo(
|
||||
|
@ -703,6 +709,7 @@ 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)
|
||||
|
||||
|
|
|
@ -144,7 +144,7 @@ final class ConversationTitleView: UIView {
|
|||
subtitleLabel?.attributedText = NSAttributedString(
|
||||
string: FullConversationCell.mutePrefix,
|
||||
attributes: [
|
||||
.font: UIFont.ows_elegantIconsFont(10),
|
||||
.font: UIFont(name: "ElegantIcons", size: 10) as Any,
|
||||
.foregroundColor: textPrimary
|
||||
]
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -311,6 +311,19 @@ extension GlobalSearchViewController {
|
|||
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,
|
||||
|
|
|
@ -13,7 +13,9 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
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
|
||||
|
@ -226,7 +228,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
// Preparation
|
||||
SessionApp.homeViewController.mutate { $0 = self }
|
||||
|
||||
updateNavBarButtons()
|
||||
updateNavBarButtons(userProfile: self.viewModel.state.userProfile)
|
||||
setUpNavBarSessionHeading()
|
||||
|
||||
// Recovery phrase reminder
|
||||
|
@ -327,26 +329,31 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
|
||||
// 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
|
||||
|
@ -361,7 +368,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
|
||||
private func stopObservingChanges() {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
self.dataChangeObservable = nil
|
||||
self.viewModel.onThreadChange = nil
|
||||
}
|
||||
|
||||
|
@ -375,7 +382,7 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
}
|
||||
|
||||
if updatedState.userProfile != self.viewModel.state.userProfile {
|
||||
updateNavBarButtons()
|
||||
updateNavBarButtons(userProfile: updatedState.userProfile)
|
||||
}
|
||||
|
||||
// Update the 'view seed' UI
|
||||
|
@ -402,8 +409,6 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
// 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
|
||||
|
@ -415,6 +420,8 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
)
|
||||
|
||||
self?.viewModel.updateThreadData(updatedData)
|
||||
self?.tableView.reloadData()
|
||||
self?.hasLoadedInitialThreadData = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
@ -453,7 +460,11 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
guard
|
||||
self.hasLoadedInitialThreadData &&
|
||||
!self.isAutoLoadingNextPage &&
|
||||
!self.isLoadingMore
|
||||
else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -482,23 +493,19 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
publicKey: userProfile.id,
|
||||
threadVariant: .contact,
|
||||
customImageData: nil,
|
||||
profile: Profile.fetchOrCreateCurrentUser(),
|
||||
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)
|
||||
|
@ -655,9 +662,10 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
switch section.model {
|
||||
case .threads:
|
||||
// Cannot properly sync outgoing blinded message requests so don't provide the option
|
||||
guard SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard else {
|
||||
return nil
|
||||
}
|
||||
guard
|
||||
threadViewModel.threadVariant != .contact ||
|
||||
SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard
|
||||
else { return nil }
|
||||
|
||||
return UIContextualAction.configuration(
|
||||
for: UIContextualAction.generateSwipeActions(
|
||||
|
@ -696,13 +704,15 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
|
||||
// Cannot properly sync outgoing blinded message requests so only provide valid options
|
||||
let shouldHavePinAction: Bool = (
|
||||
sessionIdPrefix != .blinded
|
||||
sessionIdPrefix != .blinded15 &&
|
||||
sessionIdPrefix != .blinded25
|
||||
)
|
||||
let shouldHaveMuteAction: Bool = {
|
||||
switch threadViewModel.threadVariant {
|
||||
case .contact: return (
|
||||
!threadViewModel.threadIsNoteToSelf &&
|
||||
sessionIdPrefix != .blinded
|
||||
sessionIdPrefix != .blinded15 &&
|
||||
sessionIdPrefix != .blinded25
|
||||
)
|
||||
|
||||
case .legacyGroup, .group: return (
|
||||
|
@ -742,9 +752,22 @@ final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewData
|
|||
// MARK: - Interaction
|
||||
|
||||
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {
|
||||
let seedVC = SeedVC()
|
||||
let navigationController = StyledNavigationController(rootViewController: seedVC)
|
||||
present(navigationController, animated: true, completion: nil)
|
||||
let targetViewController: UIViewController = {
|
||||
if let seedVC: SeedVC = try? SeedVC() {
|
||||
return StyledNavigationController(rootViewController: seedVC)
|
||||
}
|
||||
|
||||
return ConfirmationModal(
|
||||
info: ConfirmationModal.Info(
|
||||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text("LOAD_RECOVERY_PASSWORD_ERROR".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
)
|
||||
)
|
||||
}()
|
||||
|
||||
present(targetViewController, animated: true, completion: nil)
|
||||
}
|
||||
|
||||
func show(
|
||||
|
|
|
@ -20,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,
|
||||
|
@ -208,12 +215,16 @@ public class HomeViewModel {
|
|||
)
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
|
@ -231,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]
|
||||
|
@ -253,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
|
||||
|
@ -271,11 +285,7 @@ public class HomeViewModel {
|
|||
|
||||
PagedData.processAndTriggerUpdates(
|
||||
updatedData: updatedThreadData,
|
||||
currentDataRetriever: { [weak self] in
|
||||
guard self?.hasProcessedInitialThreadData == true else { return nil }
|
||||
|
||||
return (self?.unobservedThreadDataChanges?.0 ?? self?.threadData)
|
||||
},
|
||||
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
|
||||
onDataChange: onThreadChange,
|
||||
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||
|
@ -288,19 +298,23 @@ public class HomeViewModel {
|
|||
|
||||
// MARK: - Thread Data
|
||||
|
||||
private var hasProcessedInitialThreadData: Bool = false
|
||||
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>?
|
||||
|
||||
public var onThreadChange: (([SectionModel], StagedChangeset<[SectionModel]>) -> ())? {
|
||||
didSet {
|
||||
self.hasProcessedInitialThreadData = (onThreadChange != nil || hasProcessedInitialThreadData)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
@ -347,10 +361,13 @@ public class HomeViewModel {
|
|||
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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -11,7 +11,6 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
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
|
||||
|
@ -107,6 +106,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
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
|
||||
}()
|
||||
|
@ -161,8 +161,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
override func viewWillDisappear(_ animated: Bool) {
|
||||
super.viewWillDisappear(animated)
|
||||
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||
|
@ -173,8 +172,7 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
}
|
||||
|
||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||
// Stop observing database changes
|
||||
dataChangeObservable?.cancel()
|
||||
stopObservingChanges()
|
||||
}
|
||||
|
||||
// MARK: - Layout
|
||||
|
@ -223,6 +221,10 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
}
|
||||
}
|
||||
|
||||
private func stopObservingChanges() {
|
||||
self.viewModel.onThreadChange = nil
|
||||
}
|
||||
|
||||
private func handleThreadUpdates(
|
||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
||||
|
@ -231,9 +233,18 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
// 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
|
||||
}
|
||||
|
@ -270,7 +281,11 @@ class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
||||
guard
|
||||
self.hasLoadedInitialThreadData &&
|
||||
!self.isAutoLoadingNextPage &&
|
||||
!self.isLoadingMore
|
||||
else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
@ -129,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
|
||||
}
|
||||
}
|
||||
|
@ -150,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
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
|||
import GRDB
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource {
|
||||
private let newConversationViewModel = NewConversationViewModel()
|
||||
|
@ -178,16 +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, shouldBeVisible: nil)
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
|
@ -165,12 +165,12 @@ 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 KeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
|
||||
|
@ -178,14 +178,15 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
case .standard:
|
||||
startNewDM(with: onsNameOrPublicKey)
|
||||
|
||||
case .blinded:
|
||||
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
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onError
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
|
@ -197,7 +198,8 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text("DM_ERROR_INVALID".localized()),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onError
|
||||
)
|
||||
)
|
||||
self.present(modal, animated: true)
|
||||
|
@ -210,6 +212,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||
SnodeAPI
|
||||
.getSessionID(for: onsNameOrPublicKey)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sinkUntilComplete(
|
||||
receiveCompletion: { result in
|
||||
|
@ -230,7 +233,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
return messageOrNil
|
||||
}
|
||||
|
||||
return (maybeSessionId?.prefix == .blinded ?
|
||||
return (maybeSessionId?.prefix == .blinded15 || maybeSessionId?.prefix == .blinded25 ?
|
||||
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
||||
"DM_ERROR_INVALID".localized()
|
||||
)
|
||||
|
@ -242,7 +245,8 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
title: "ALERT_ERROR_TITLE".localized(),
|
||||
body: .text(message),
|
||||
cancelTitle: "BUTTON_OK".localized(),
|
||||
cancelStyle: .alert_text
|
||||
cancelStyle: .alert_text,
|
||||
afterClosed: onError
|
||||
)
|
||||
)
|
||||
self?.present(modal, animated: true)
|
||||
|
@ -259,16 +263,12 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
|||
}
|
||||
|
||||
private func startNewDM(with sessionId: String) {
|
||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
||||
try SessionThread
|
||||
.fetchOrCreate(db, id: sessionId, variant: .contact, shouldBeVisible: nil)
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -666,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ import SignalCoreKit
|
|||
|
||||
let srcImage: UIImage
|
||||
|
||||
let successCompletion: ((UIImage) -> Void)
|
||||
let successCompletion: ((Data) -> Void)
|
||||
|
||||
var imageView: UIView!
|
||||
|
||||
|
@ -78,7 +78,7 @@ import SignalCoreKit
|
|||
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
|
||||
|
@ -486,10 +486,9 @@ import SignalCoreKit
|
|||
@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)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -516,4 +515,8 @@ import SignalCoreKit
|
|||
UIGraphicsEndImageContext()
|
||||
return scaledImage
|
||||
}
|
||||
|
||||
func generateDstImageData() -> Data? {
|
||||
return generateDstImage().map { $0.pngData() }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
// MARK: - UI
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .all
|
||||
}
|
||||
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
|
@ -153,7 +157,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage else { return }
|
||||
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -204,11 +208,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
|
||||
|
|
|
@ -4,6 +4,7 @@ import Foundation
|
|||
import Combine
|
||||
import YYImage
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
class GifPickerCell: UICollectionViewCell {
|
||||
|
||||
|
|
|
@ -217,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
|
||||
|
@ -360,6 +360,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
cell
|
||||
.requestRenditionForSending()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
@ -490,6 +491,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
assert(searchBar.text == nil || searchBar.text?.count == 0)
|
||||
|
||||
GiphyAPI.trending()
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { result in
|
||||
|
@ -527,6 +529,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
|||
|
||||
GiphyAPI
|
||||
.search(query: query)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(
|
||||
receiveCompletion: { [weak self] result in
|
||||
|
|
|
@ -5,6 +5,7 @@ import Combine
|
|||
import CoreServices
|
||||
import SignalUtilitiesKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
// There's no UTI type for webp!
|
||||
enum GiphyFormat {
|
||||
|
@ -291,7 +292,6 @@ enum GiphyAPI {
|
|||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: url)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.mapError { urlError in
|
||||
Logger.error("search request failed: \(urlError)")
|
||||
|
||||
|
@ -299,7 +299,7 @@ enum GiphyAPI {
|
|||
return HTTPError.generic
|
||||
}
|
||||
.map { data, _ in
|
||||
Logger.error("search request succeeded")
|
||||
Logger.debug("search request succeeded")
|
||||
|
||||
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
|
||||
Logger.error("unable to parse trending images")
|
||||
|
@ -340,7 +340,6 @@ enum GiphyAPI {
|
|||
|
||||
return urlSession
|
||||
.dataTaskPublisher(for: request)
|
||||
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||
.mapError { urlError in
|
||||
Logger.error("search request failed: \(urlError)")
|
||||
|
||||
|
@ -348,7 +347,7 @@ enum GiphyAPI {
|
|||
return HTTPError.generic
|
||||
}
|
||||
.tryMap { data, _ -> [GiphyImageInfo] in
|
||||
Logger.error("search request succeeded")
|
||||
Logger.debug("search request succeeded")
|
||||
|
||||
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
|
||||
throw HTTPError.invalidResponse
|
||||
|
|
|
@ -17,6 +17,7 @@ protocol ImagePickerGridControllerDelegate: AnyObject {
|
|||
|
||||
var isInBatchSelectMode: Bool { get }
|
||||
func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, failedToRetrieveAssetAt index: Int, forCount count: Int)
|
||||
}
|
||||
|
||||
class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegate {
|
||||
|
@ -127,31 +128,33 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
switch selectionPanGesture.state {
|
||||
case .possible:
|
||||
break
|
||||
case .began:
|
||||
collectionView.isUserInteractionEnabled = false
|
||||
collectionView.isScrollEnabled = false
|
||||
case .possible: break
|
||||
case .began:
|
||||
collectionView.isUserInteractionEnabled = false
|
||||
collectionView.isScrollEnabled = false
|
||||
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location) else {
|
||||
return
|
||||
}
|
||||
let asset = photoCollectionContents.asset(at: indexPath.item)
|
||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
||||
selectionPanGestureMode = .deselect
|
||||
} else {
|
||||
selectionPanGestureMode = .select
|
||||
}
|
||||
case .changed:
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location) else {
|
||||
return
|
||||
}
|
||||
tryToToggleBatchSelect(at: indexPath)
|
||||
case .cancelled, .ended, .failed:
|
||||
collectionView.isUserInteractionEnabled = true
|
||||
collectionView.isScrollEnabled = true
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard
|
||||
let indexPath: IndexPath = collectionView.indexPathForItem(at: location),
|
||||
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
|
||||
else { return }
|
||||
|
||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
||||
selectionPanGestureMode = .deselect
|
||||
}
|
||||
else {
|
||||
selectionPanGestureMode = .select
|
||||
}
|
||||
|
||||
case .changed:
|
||||
let location = selectionPanGesture.location(in: collectionView)
|
||||
guard let indexPath = collectionView.indexPathForItem(at: location) else { return }
|
||||
|
||||
tryToToggleBatchSelect(at: indexPath)
|
||||
|
||||
case .cancelled, .ended, .failed:
|
||||
collectionView.isUserInteractionEnabled = true
|
||||
collectionView.isScrollEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -171,7 +174,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let asset = photoCollectionContents.asset(at: indexPath.item)
|
||||
guard let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) else { return }
|
||||
|
||||
switch selectionPanGestureMode {
|
||||
case .select:
|
||||
guard delegate.imagePickerCanSelectAdditionalItems(self) else {
|
||||
|
@ -203,8 +207,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)
|
||||
}
|
||||
|
@ -291,30 +294,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
|
||||
|
@ -365,7 +344,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||
reloadDataAndRestoreSelection()
|
||||
}
|
||||
|
||||
func clearCollectionViewSelection() {
|
||||
|
@ -402,7 +380,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
|
||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||
photoCollectionContents = photoCollection.contents()
|
||||
reloadDataAndRestoreSelection()
|
||||
}
|
||||
|
||||
// MARK: - PhotoCollectionPicker Presentation
|
||||
|
@ -496,7 +473,12 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
|
||||
guard let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) else {
|
||||
SNLog("Failed to select cell for asset at \(indexPath.item)")
|
||||
delegate.imagePicker(self, failedToRetrieveAssetAt: indexPath.item, forCount: photoCollectionContents.assetCount)
|
||||
return
|
||||
}
|
||||
|
||||
delegate.imagePicker(
|
||||
self,
|
||||
didSelectAsset: asset,
|
||||
|
@ -517,7 +499,12 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
return
|
||||
}
|
||||
|
||||
let asset = photoCollectionContents.asset(at: indexPath.item)
|
||||
guard let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item) else {
|
||||
SNLog("Failed to deselect cell for asset at \(indexPath.item)")
|
||||
delegate.imagePicker(self, failedToRetrieveAssetAt: indexPath.item, forCount: photoCollectionContents.assetCount)
|
||||
return
|
||||
}
|
||||
|
||||
delegate.imagePicker(self, didDeselectAsset: asset)
|
||||
}
|
||||
|
||||
|
@ -531,7 +518,12 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
|||
}
|
||||
|
||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||
|
||||
guard let assetItem: PhotoPickerAssetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize) else {
|
||||
SNLog("Failed to style cell for asset at \(indexPath.item)")
|
||||
return cell
|
||||
}
|
||||
|
||||
cell.configure(item: assetItem)
|
||||
cell.isAccessibilityElement = true
|
||||
cell.accessibilityIdentifier = "\(assetItem.asset.modificationDate.map { "\($0)" } ?? "Unknown Date")"
|
||||
|
|
|
@ -44,6 +44,10 @@ class MediaGalleryNavigationController: UINavigationController {
|
|||
// MARK: - Orientation
|
||||
|
||||
public override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .all
|
||||
}
|
||||
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -360,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 {
|
||||
|
@ -383,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] {
|
||||
|
|
|
@ -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,6 +388,8 @@ 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,
|
||||
|
@ -399,6 +401,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
|||
)
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -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()
|
||||
|
||||
|
|
|
@ -54,6 +54,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
// MARK: - UI
|
||||
|
||||
override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .all
|
||||
}
|
||||
|
||||
return .allButUpsideDown
|
||||
}
|
||||
|
||||
|
@ -246,7 +250,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
|||
}
|
||||
|
||||
private func autoLoadNextPageIfNeeded() {
|
||||
guard !self.isAutoLoadingNextPage else { return }
|
||||
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
|
||||
|
||||
self.isAutoLoadingNextPage = true
|
||||
|
||||
|
@ -307,12 +311,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
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
//
|
||||
|
||||
import Foundation
|
||||
import SignalUtilitiesKit
|
||||
|
||||
@objc class OWSImagePickerController: UIImagePickerController {
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@ import AVFoundation
|
|||
import CoreServices
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
import SignalCoreKit
|
||||
|
||||
protocol PhotoCaptureDelegate: AnyObject {
|
||||
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment)
|
||||
|
@ -84,7 +85,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
func startCapture() -> AnyPublisher<Void, Error> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.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()
|
||||
|
@ -136,7 +137,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
func stopCapture() -> AnyPublisher<Void, Never> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] in self?.session.stopRunning() }
|
||||
)
|
||||
|
@ -160,7 +161,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
return Just(())
|
||||
.setFailureType(to: Error.self)
|
||||
.subscribe(on: sessionQueue)
|
||||
.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() }
|
||||
|
@ -196,7 +197,7 @@ class PhotoCapture: NSObject {
|
|||
|
||||
func switchFlashMode() -> AnyPublisher<Void, Never> {
|
||||
return Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||
.handleEvents(
|
||||
receiveOutput: { [weak self] _ in
|
||||
switch self?.captureOutput.flashMode {
|
||||
|
@ -350,22 +351,23 @@ extension PhotoCapture: CaptureButtonDelegate {
|
|||
|
||||
Logger.verbose("")
|
||||
|
||||
Just(())
|
||||
.subscribe(on: sessionQueue)
|
||||
.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)
|
||||
}
|
||||
sessionQueue.async { [weak self] in // Must run this on a specific queue to prevent crashes
|
||||
guard let strongSelf = self else { return }
|
||||
|
||||
do {
|
||||
try strongSelf.startAudioCapture()
|
||||
strongSelf.captureOutput.beginVideo(delegate: strongSelf)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
strongSelf.delegate?.photoCaptureDidBeginVideo(strongSelf)
|
||||
}
|
||||
)
|
||||
}
|
||||
catch {
|
||||
DispatchQueue.main.async {
|
||||
strongSelf.delegate?.photoCapture(strongSelf, processingDidError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) {
|
||||
|
|
|
@ -601,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
|
||||
|
|
|
@ -105,28 +105,29 @@ class PhotoCollectionContents {
|
|||
return asset(at: 0)
|
||||
}
|
||||
|
||||
func asset(at index: Int) -> PHAsset {
|
||||
func asset(at index: Int) -> PHAsset? {
|
||||
guard index >= 0 && index < fetchResult.count else { return nil }
|
||||
|
||||
return fetchResult.object(at: index)
|
||||
}
|
||||
|
||||
// MARK: - AssetItem Accessors
|
||||
|
||||
func assetItem(at index: Int, photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem {
|
||||
let mediaAsset = asset(at: index)
|
||||
func assetItem(at index: Int, photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? {
|
||||
guard let mediaAsset: PHAsset = asset(at: index) else { return nil }
|
||||
|
||||
return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize)
|
||||
}
|
||||
|
||||
func firstAssetItem(photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? {
|
||||
guard let mediaAsset = firstAsset else {
|
||||
return nil
|
||||
}
|
||||
guard let mediaAsset = firstAsset else { return nil }
|
||||
|
||||
return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize)
|
||||
}
|
||||
|
||||
func lastAssetItem(photoMediaSize: PhotoMediaSize) -> PhotoPickerAssetItem? {
|
||||
guard let mediaAsset = lastAsset else {
|
||||
return nil
|
||||
}
|
||||
guard let mediaAsset = lastAsset else { return nil }
|
||||
|
||||
return PhotoPickerAssetItem(asset: mediaAsset, photoCollectionContents: self, photoMediaSize: photoMediaSize)
|
||||
}
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@ import Combine
|
|||
import Photos
|
||||
import SignalUtilitiesKit
|
||||
import SignalCoreKit
|
||||
import SessionUIKit
|
||||
|
||||
class SendMediaNavigationController: UINavigationController {
|
||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||
|
@ -394,6 +395,18 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
|||
func imagePickerCanSelectAdditionalItems(_ imagePicker: ImagePickerGridController) -> Bool {
|
||||
return attachmentDraftCollection.count <= SignalAttachment.maxAttachmentsAllowed
|
||||
}
|
||||
|
||||
func imagePicker(_ imagePicker: ImagePickerGridController, failedToRetrieveAssetAt index: Int, forCount count: Int) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
extension SendMediaNavigationController: AttachmentApprovalViewControllerDelegate {
|
||||
|
@ -596,7 +609,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
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SessionUIKit
|
||||
|
||||
class MediaDismissAnimationController: NSObject {
|
||||
private let mediaItem: Media
|
||||
|
@ -46,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 {
|
||||
|
@ -64,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 {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||
|
||||
import UIKit
|
||||
import SignalUtilitiesKit
|
||||
|
||||
// MARK: - InteractivelyDismissableViewController
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -4,7 +4,6 @@ import UIKit
|
|||
import Combine
|
||||
import UserNotifications
|
||||
import GRDB
|
||||
import WebRTC
|
||||
import SessionUIKit
|
||||
import SessionMessagingKit
|
||||
import SessionUtilitiesKit
|
||||
|
@ -13,23 +12,24 @@ 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?
|
||||
|
||||
enum LifecycleMethod {
|
||||
case finishLaunching
|
||||
case enterForeground
|
||||
}
|
||||
|
||||
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
|
||||
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()
|
||||
|
@ -47,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
|
||||
|
@ -74,10 +71,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
},
|
||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||
if case .failure(let error) = result {
|
||||
self?.showDatabaseSetupFailureModal(calledFrom: .finishLaunching, error: error)
|
||||
DispatchQueue.main.async {
|
||||
self?.initialLaunchFailed = true
|
||||
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
)
|
||||
|
@ -129,33 +134,59 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
/// Apple's documentation on the matter)
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
|
||||
// Resume database
|
||||
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
||||
Storage.resumeDatabaseAccess()
|
||||
|
||||
// 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()
|
||||
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?.showDatabaseSetupFailureModal(calledFrom: .enterForeground, error: error)
|
||||
return
|
||||
|
||||
// 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
|
||||
)
|
||||
}
|
||||
|
||||
self?.completePostMigrationSetup(calledFrom: .enterForeground, 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
|
||||
|
@ -165,7 +196,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
// Stop all jobs except for message sending and when completed suspend the database
|
||||
JobRunner.stopAndClearPendingJobs(exceptForVariant: .messageSend) {
|
||||
if !self.hasCallOngoing() {
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
Storage.suspendDatabaseAccess()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -185,7 +216,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
|
||||
|
||||
ensureRootViewController()
|
||||
ensureRootViewController(calledFrom: .didBecomeActive)
|
||||
|
||||
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
|
||||
self?.handleActivation()
|
||||
|
@ -217,7 +248,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
|
||||
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
|
||||
if UIDevice.current.isIPad {
|
||||
return .allButUpsideDown
|
||||
return .all
|
||||
}
|
||||
|
||||
return .portrait
|
||||
|
@ -226,8 +257,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
// MARK: - Background Fetching
|
||||
|
||||
func application(_ application: UIApplication, performFetchWithCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
|
||||
// Resume database
|
||||
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
||||
Storage.resumeDatabaseAccess()
|
||||
|
||||
// Background tasks only last for a certain amount of time (which can result in a crash and a
|
||||
// prompt appearing for the user), we want to avoid this and need to make sure to suspend the
|
||||
|
@ -244,8 +274,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
BackgroundPoller.isValid = false
|
||||
|
||||
if CurrentAppContext().isInBackground() {
|
||||
// Suspend database
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
Storage.suspendDatabaseAccess()
|
||||
}
|
||||
|
||||
SNLog("Background poll failed due to manual timeout")
|
||||
|
@ -259,14 +288,19 @@ 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 }
|
||||
|
||||
BackgroundPoller.isValid = false
|
||||
|
||||
if CurrentAppContext().isInBackground() {
|
||||
// Suspend database
|
||||
NotificationCenter.default.post(name: Database.suspendNotification, object: self)
|
||||
Storage.suspendDatabaseAccess()
|
||||
}
|
||||
|
||||
cancelTimer.invalidate()
|
||||
|
@ -278,107 +312,162 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
// MARK: - App Readiness
|
||||
|
||||
private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod, needsConfigSync: Bool) {
|
||||
SNLog("Migrations completed, performing setup and ensuring rootViewController")
|
||||
Configuration.performMainSetup()
|
||||
JobRunner.add(executor: 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
|
||||
if lifecycleMethod == .finishLaunching {
|
||||
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.userCompletedRequiredOnboarding(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
|
||||
)
|
||||
{
|
||||
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
|
||||
// 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 showDatabaseSetupFailureModal(calledFrom lifecycleMethod: LifecycleMethod, 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: {
|
||||
switch (error as? StorageError) {
|
||||
case .databaseInvalid: return "DATABASE_SETUP_FAILED".localized()
|
||||
default: return "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?.showDatabaseSetupFailureModal(calledFrom: lifecycleMethod, 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?.showDatabaseSetupFailureModal(calledFrom: lifecycleMethod, error: error)
|
||||
return
|
||||
}
|
||||
|
||||
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, 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.
|
||||
|
@ -419,7 +508,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()
|
||||
|
@ -432,36 +527,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 = TopBannerController(
|
||||
child: StyledNavigationController(
|
||||
rootViewController: {
|
||||
guard Identity.userExists() else { return LandingVC() }
|
||||
guard !Profile.fetchOrCreateCurrentUser().name.isEmpty else {
|
||||
// If we have no display name then collect one (this can happen if the
|
||||
// app crashed during onboarding which would leave the user in an invalid
|
||||
// state with no display name)
|
||||
return DisplayNameVC(flow: .register)
|
||||
// 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) }
|
||||
)
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
return HomeVC()
|
||||
}()
|
||||
),
|
||||
cachedWarning: UserDefaults.sharedLokiProject?[.topBannerWarningToShow]
|
||||
.map { rawValue in TopBannerController.Warning(rawValue: rawValue) }
|
||||
)
|
||||
UIViewController.attemptRotationToDeviceOrientation()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -610,12 +779,17 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
||||
guard Identity.userExists() else { return }
|
||||
|
||||
poller.start()
|
||||
|
||||
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) {
|
||||
|
@ -725,3 +899,54 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,8 @@ extern NSString *const ReportedApplicationStateDidChangeNotification;
|
|||
|
||||
@interface MainAppContext : NSObject <AppContext>
|
||||
|
||||
- (instancetype)init;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
|
|
@ -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's Screen Lock feature uses Face ID.</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>com.loki-project.loki-messenger</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
@ -147,6 +140,7 @@
|
|||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
</array>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
|
|
|
@ -4,65 +4,109 @@ 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, shouldBeVisible: nil)
|
||||
|
||||
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,
|
||||
focusInteractionInfo: nil,
|
||||
animated: animated
|
||||
)
|
||||
}
|
||||
|
||||
public static func presentConversation(
|
||||
public static func presentConversationCreatingIfNeeded(
|
||||
for threadId: String,
|
||||
threadVariant: SessionThread.Variant,
|
||||
isMessageRequest: Bool,
|
||||
action: ConversationViewModel.Action,
|
||||
focusInteractionInfo: Interaction.TimestampInfo?,
|
||||
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,
|
||||
focusInteractionInfo: focusInteractionInfo,
|
||||
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,
|
||||
focusedInteractionInfo: focusInteractionInfo,
|
||||
animated: animated
|
||||
)
|
||||
// Send to main thread if needed
|
||||
guard Thread.isMainThread else {
|
||||
DispatchQueue.main.async {
|
||||
afterThreadCreated()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
afterThreadCreated()
|
||||
}
|
||||
|
||||
// MARK: - Functions
|
||||
|
@ -71,7 +115,8 @@ public struct SessionApp {
|
|||
// This _should_ be wiped out below.
|
||||
Logger.error("")
|
||||
DDLog.flushLog()
|
||||
|
||||
|
||||
SessionUtil.clearMemoryState()
|
||||
Storage.resetAllStorage()
|
||||
ProfileManager.resetProfileStorage()
|
||||
Attachment.resetAttachmentStorage()
|
||||
|
|
|
@ -2,32 +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 <SessionMessagingKit/OWSAudioPlayer.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>
|
||||
|
|
|
@ -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
|
||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue