Merge remote-tracking branch 'upstream/dev' into feature/job-runner-unit-tests
# Conflicts: # Session.xcodeproj/project.pbxproj # Session/Meta/Session-Prefix.pch # Session/Notifications/SyncPushTokensJob.swift # Session/Utilities/BackgroundPoller.swift # SessionMessagingKit/Configuration.swift # SessionMessagingKit/Database/Models/Profile.swift # SessionMessagingKit/Jobs/Types/AttachmentDownloadJob.swift # SessionMessagingKit/Jobs/Types/AttachmentUploadJob.swift # SessionMessagingKit/Jobs/Types/DisappearingMessagesJob.swift # SessionMessagingKit/Jobs/Types/FailedAttachmentDownloadsJob.swift # SessionMessagingKit/Jobs/Types/FailedMessageSendsJob.swift # SessionMessagingKit/Jobs/Types/GroupLeavingJob.swift # SessionMessagingKit/Jobs/Types/MessageReceiveJob.swift # SessionMessagingKit/Jobs/Types/MessageSendJob.swift # SessionMessagingKit/Jobs/Types/NotifyPushServerJob.swift # SessionMessagingKit/Jobs/Types/RetrieveDefaultOpenGroupRoomsJob.swift # SessionMessagingKit/Jobs/Types/SendReadReceiptsJob.swift # SessionMessagingKit/Jobs/Types/UpdateProfilePictureJob.swift # SessionMessagingKit/Sending & Receiving/MessageSender.swift # SessionMessagingKit/Sending & Receiving/Pollers/ClosedGroupPoller.swift # SessionMessagingKit/Utilities/AppReadiness.m # SessionMessagingKitTests/Open Groups/OpenGroupManagerSpec.swift # SessionMessagingKitTests/_TestUtilities/TestOnionRequestAPI.swift # SessionShareExtension/ShareNavController.swift # SessionSnodeKit/Jobs/GetSnodePoolJob.swift # SessionUtilitiesKit/Configuration.swift # SessionUtilitiesKit/Database/Utilities/Database+Utilities.swift # SessionUtilitiesKit/JobRunner/JobRunner.swift # SignalUtilitiesKit/Meta/SignalUtilitiesKit.h # SignalUtilitiesKit/Utilities/SSKAsserts.h
This commit is contained in:
commit
e768bebe6d
|
@ -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'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "LibSession-Util"]
|
||||||
|
path = LibSession-Util
|
||||||
|
url = https://github.com/oxen-io/libsession-util.git
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit d8f07fa92c12c5c2409774e03e03395d7847d1c2
|
50
Podfile
50
Podfile
|
@ -1,30 +1,31 @@
|
||||||
platform :ios, '13.0'
|
platform :ios, '13.0'
|
||||||
source 'https://github.com/CocoaPods/Specs.git'
|
|
||||||
|
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
inhibit_all_warnings!
|
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
|
# Dependencies to be included in the app and all extensions/frameworks
|
||||||
abstract_target 'GlobalDependencies' do
|
abstract_target 'GlobalDependencies' do
|
||||||
pod 'PromiseKit'
|
|
||||||
pod 'CryptoSwift'
|
|
||||||
# FIXME: If https://github.com/jedisct1/swift-sodium/pull/249 gets resolved then revert this back to the standard pod
|
# 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 'Sodium', :git => 'https://github.com/oxen-io/session-ios-swift-sodium.git', branch: 'session-build'
|
||||||
pod 'GRDB.swift/SQLCipher'
|
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'
|
pod 'SQLCipher', '~> 4.5.3'
|
||||||
|
|
||||||
# FIXME: We want to remove this once it's been long enough since the migration to GRDB
|
# 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 'YapDatabase/SQLCipher', :git => 'https://github.com/oxen-io/session-ios-yap-database.git', branch: 'signal-release'
|
||||||
pod 'WebRTC-lib'
|
pod 'WebRTC-lib'
|
||||||
pod 'SocketRocket', '~> 0.5.1'
|
|
||||||
|
|
||||||
target 'Session' do
|
target 'Session' do
|
||||||
pod 'AFNetworking'
|
|
||||||
pod 'Reachability'
|
pod 'Reachability'
|
||||||
pod 'PureLayout', '~> 3.1.8'
|
pod 'PureLayout', '~> 3.1.8'
|
||||||
pod 'NVActivityIndicatorView'
|
pod 'NVActivityIndicatorView'
|
||||||
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||||
pod 'ZXingObjC'
|
|
||||||
pod 'DifferenceKit'
|
pod 'DifferenceKit'
|
||||||
|
|
||||||
target 'SessionTests' do
|
target 'SessionTests' do
|
||||||
|
@ -45,7 +46,6 @@ abstract_target 'GlobalDependencies' do
|
||||||
|
|
||||||
# Dependencies that are shared across a number of extensions/frameworks but not all
|
# Dependencies that are shared across a number of extensions/frameworks but not all
|
||||||
abstract_target 'ExtendedDependencies' do
|
abstract_target 'ExtendedDependencies' do
|
||||||
pod 'AFNetworking'
|
|
||||||
pod 'PureLayout', '~> 3.1.8'
|
pod 'PureLayout', '~> 3.1.8'
|
||||||
|
|
||||||
target 'SessionShareExtension' do
|
target 'SessionShareExtension' do
|
||||||
|
@ -97,28 +97,13 @@ abstract_target 'GlobalDependencies' do
|
||||||
target 'SessionUIKit' do
|
target 'SessionUIKit' do
|
||||||
pod 'GRDB.swift/SQLCipher'
|
pod 'GRDB.swift/SQLCipher'
|
||||||
pod 'DifferenceKit'
|
pod 'DifferenceKit'
|
||||||
|
pod 'YYImage/libwebp', git: 'https://github.com/signalapp/YYImage'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Actions to perform post-install
|
# Actions to perform post-install
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
enable_whole_module_optimization_for_crypto_swift(installer)
|
|
||||||
set_minimum_deployment_target(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
|
end
|
||||||
|
|
||||||
def set_minimum_deployment_target(installer)
|
def set_minimum_deployment_target(installer)
|
||||||
|
@ -128,22 +113,3 @@ def set_minimum_deployment_target(installer)
|
||||||
end
|
end
|
||||||
end
|
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
|
|
||||||
|
|
57
Podfile.lock
57
Podfile.lock
|
@ -1,23 +1,7 @@
|
||||||
PODS:
|
PODS:
|
||||||
- AFNetworking (4.0.1):
|
|
||||||
- AFNetworking/NSURLSession (= 4.0.1)
|
|
||||||
- AFNetworking/Reachability (= 4.0.1)
|
|
||||||
- AFNetworking/Security (= 4.0.1)
|
|
||||||
- AFNetworking/Serialization (= 4.0.1)
|
|
||||||
- AFNetworking/UIKit (= 4.0.1)
|
|
||||||
- AFNetworking/NSURLSession (4.0.1):
|
|
||||||
- AFNetworking/Reachability
|
|
||||||
- AFNetworking/Security
|
|
||||||
- AFNetworking/Serialization
|
|
||||||
- AFNetworking/Reachability (4.0.1)
|
|
||||||
- AFNetworking/Security (4.0.1)
|
|
||||||
- AFNetworking/Serialization (4.0.1)
|
|
||||||
- AFNetworking/UIKit (4.0.1):
|
|
||||||
- AFNetworking/NSURLSession
|
|
||||||
- CocoaLumberjack (3.8.0):
|
- CocoaLumberjack (3.8.0):
|
||||||
- CocoaLumberjack/Core (= 3.8.0)
|
- CocoaLumberjack/Core (= 3.8.0)
|
||||||
- CocoaLumberjack/Core (3.8.0)
|
- CocoaLumberjack/Core (3.8.0)
|
||||||
- CryptoSwift (1.4.2)
|
|
||||||
- Curve25519Kit (2.1.0):
|
- Curve25519Kit (2.1.0):
|
||||||
- CocoaLumberjack
|
- CocoaLumberjack
|
||||||
- SignalCoreKit
|
- SignalCoreKit
|
||||||
|
@ -43,15 +27,6 @@ PODS:
|
||||||
- NVActivityIndicatorView/Base (= 5.1.1)
|
- NVActivityIndicatorView/Base (= 5.1.1)
|
||||||
- NVActivityIndicatorView/Base (5.1.1)
|
- NVActivityIndicatorView/Base (5.1.1)
|
||||||
- OpenSSL-Universal (1.1.1300)
|
- OpenSSL-Universal (1.1.1300)
|
||||||
- PromiseKit (6.15.3):
|
|
||||||
- PromiseKit/CorePromise (= 6.15.3)
|
|
||||||
- PromiseKit/Foundation (= 6.15.3)
|
|
||||||
- PromiseKit/UIKit (= 6.15.3)
|
|
||||||
- PromiseKit/CorePromise (6.15.3)
|
|
||||||
- PromiseKit/Foundation (6.15.3):
|
|
||||||
- PromiseKit/CorePromise
|
|
||||||
- PromiseKit/UIKit (6.15.3):
|
|
||||||
- PromiseKit/CorePromise
|
|
||||||
- PureLayout (3.1.9)
|
- PureLayout (3.1.9)
|
||||||
- Quick (5.0.1)
|
- Quick (5.0.1)
|
||||||
- Reachability (3.2)
|
- Reachability (3.2)
|
||||||
|
@ -59,7 +34,6 @@ PODS:
|
||||||
- SignalCoreKit (1.0.0):
|
- SignalCoreKit (1.0.0):
|
||||||
- CocoaLumberjack
|
- CocoaLumberjack
|
||||||
- OpenSSL-Universal
|
- OpenSSL-Universal
|
||||||
- SocketRocket (0.5.1)
|
|
||||||
- Sodium (0.9.1)
|
- Sodium (0.9.1)
|
||||||
- SQLCipher (4.5.3):
|
- SQLCipher (4.5.3):
|
||||||
- SQLCipher/standard (= 4.5.3)
|
- SQLCipher/standard (= 4.5.3)
|
||||||
|
@ -67,7 +41,8 @@ PODS:
|
||||||
- SQLCipher/standard (4.5.3):
|
- SQLCipher/standard (4.5.3):
|
||||||
- SQLCipher/common
|
- SQLCipher/common
|
||||||
- SwiftProtobuf (1.5.0)
|
- 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 (3.1.1):
|
||||||
- YapDatabase/SQLCipher/Core (= 3.1.1)
|
- YapDatabase/SQLCipher/Core (= 3.1.1)
|
||||||
- YapDatabase/SQLCipher/Extensions (= 3.1.1)
|
- YapDatabase/SQLCipher/Extensions (= 3.1.1)
|
||||||
|
@ -134,54 +109,44 @@ PODS:
|
||||||
- YYImage/libwebp (1.0.4):
|
- YYImage/libwebp (1.0.4):
|
||||||
- libwebp
|
- libwebp
|
||||||
- YYImage/Core
|
- YYImage/Core
|
||||||
- ZXingObjC (3.6.5):
|
|
||||||
- ZXingObjC/All (= 3.6.5)
|
|
||||||
- ZXingObjC/All (3.6.5)
|
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- AFNetworking
|
|
||||||
- CryptoSwift
|
|
||||||
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
- Curve25519Kit (from `https://github.com/oxen-io/session-ios-curve-25519-kit.git`, branch `session-version`)
|
||||||
- DifferenceKit
|
- DifferenceKit
|
||||||
- GRDB.swift/SQLCipher
|
- GRDB.swift/SQLCipher
|
||||||
- Nimble
|
- Nimble
|
||||||
- NVActivityIndicatorView
|
- NVActivityIndicatorView
|
||||||
- PromiseKit
|
|
||||||
- PureLayout (~> 3.1.8)
|
- PureLayout (~> 3.1.8)
|
||||||
- Quick
|
- Quick
|
||||||
- Reachability
|
- Reachability
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
- SignalCoreKit (from `https://github.com/oxen-io/session-ios-core-kit`, branch `session-version`)
|
- 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`)
|
- Sodium (from `https://github.com/oxen-io/session-ios-swift-sodium.git`, branch `session-build`)
|
||||||
- SQLCipher (~> 4.5.3)
|
- SQLCipher (~> 4.5.3)
|
||||||
- SwiftProtobuf (~> 1.5.0)
|
- SwiftProtobuf (~> 1.5.0)
|
||||||
- WebRTC-lib
|
- WebRTC-lib
|
||||||
|
- xcbeautify
|
||||||
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
- YapDatabase/SQLCipher (from `https://github.com/oxen-io/session-ios-yap-database.git`, branch `signal-release`)
|
||||||
- YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
|
- YYImage/libwebp (from `https://github.com/signalapp/YYImage`)
|
||||||
- ZXingObjC
|
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
https://github.com/CocoaPods/Specs.git:
|
https://github.com/CocoaPods/Specs.git:
|
||||||
- AFNetworking
|
|
||||||
- CocoaLumberjack
|
- CocoaLumberjack
|
||||||
- CryptoSwift
|
|
||||||
- DifferenceKit
|
- DifferenceKit
|
||||||
- GRDB.swift
|
- GRDB.swift
|
||||||
- libwebp
|
- libwebp
|
||||||
- Nimble
|
- Nimble
|
||||||
- NVActivityIndicatorView
|
- NVActivityIndicatorView
|
||||||
- OpenSSL-Universal
|
- OpenSSL-Universal
|
||||||
- PromiseKit
|
|
||||||
- PureLayout
|
- PureLayout
|
||||||
- Quick
|
- Quick
|
||||||
- Reachability
|
- Reachability
|
||||||
- SAMKeychain
|
- SAMKeychain
|
||||||
- SocketRocket
|
|
||||||
- SQLCipher
|
- SQLCipher
|
||||||
- SwiftProtobuf
|
- SwiftProtobuf
|
||||||
- WebRTC-lib
|
- WebRTC-lib
|
||||||
- ZXingObjC
|
trunk:
|
||||||
|
- xcbeautify
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
Curve25519Kit:
|
Curve25519Kit:
|
||||||
|
@ -217,9 +182,7 @@ CHECKOUT OPTIONS:
|
||||||
:git: https://github.com/signalapp/YYImage
|
:git: https://github.com/signalapp/YYImage
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
AFNetworking: 3bd23d814e976cd148d7d44c3ab78017b744cd58
|
|
||||||
CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
|
CocoaLumberjack: 78abfb691154e2a9df8ded4350d504ee19d90732
|
||||||
CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17
|
|
||||||
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6
|
||||||
DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca
|
DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca
|
||||||
GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78
|
GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78
|
||||||
|
@ -227,21 +190,19 @@ SPEC CHECKSUMS:
|
||||||
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84
|
||||||
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667
|
||||||
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
OpenSSL-Universal: e7311447fd2419f57420c79524b641537387eff2
|
||||||
PromiseKit: 3b2b6995e51a954c46dbc550ce3da44fbfb563c5
|
|
||||||
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
PureLayout: 5fb5e5429519627d60d079ccb1eaa7265ce7cf88
|
||||||
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
Quick: 749aa754fd1e7d984f2000fe051e18a3a9809179
|
||||||
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96
|
||||||
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
|
||||||
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
SignalCoreKit: 1fbd8732163ef76de16cd1107d1fa3684b607e5d
|
||||||
SocketRocket: d57c7159b83c3c6655745cd15302aa24b6bae531
|
|
||||||
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
Sodium: a7d42cb46e789d2630fa552d35870b416ed055ae
|
||||||
SQLCipher: 57fa9f863fa4a3ed9dd3c90ace52315db8c0fdca
|
SQLCipher: 57fa9f863fa4a3ed9dd3c90ace52315db8c0fdca
|
||||||
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
SwiftProtobuf: 241400280f912735c1e1b9fe675fdd2c6c4d42e2
|
||||||
WebRTC-lib: 508fe02efa0c1a3a8867082a77d24c9be5d29aeb
|
WebRTC-lib: d83df8976fa608b980f1d85796b3de66d60a1953
|
||||||
|
xcbeautify: 6e2f57af5c3a86d490376d5758030a8dcc201c1b
|
||||||
YapDatabase: b418a4baa6906e8028748938f9159807fd039af4
|
YapDatabase: b418a4baa6906e8028748938f9159807fd039af4
|
||||||
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331
|
||||||
ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb
|
|
||||||
|
|
||||||
PODFILE CHECKSUM: e9443a8235dbff1fc342aa9bf08bbc66923adf68
|
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")
|
||||||
|
}
|
|
@ -1,11 +1,6 @@
|
||||||
#!/usr/bin/xcrun --sdk macosx swift
|
#!/usr/bin/xcrun --sdk macosx swift
|
||||||
|
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// ListLocalizableStrings.swift
|
|
||||||
// Archa
|
|
||||||
//
|
|
||||||
// Created by Morgan Pretty on 18/5/20.
|
|
||||||
// Copyright © 2020 Archa. All rights reserved.
|
|
||||||
//
|
//
|
||||||
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
// This script is based on https://github.com/ginowu7/CleanSwiftLocalizableExample the main difference
|
||||||
// is canges to the localized usage regex
|
// is canges to the localized usage regex
|
||||||
|
@ -19,35 +14,46 @@ let currentPath = (
|
||||||
|
|
||||||
/// List of files in currentPath - recursive
|
/// List of files in currentPath - recursive
|
||||||
var pathFiles: [String] = {
|
var pathFiles: [String] = {
|
||||||
guard let enumerator = fileManager.enumerator(atPath: currentPath), let files = enumerator.allObjects as? [String] else {
|
guard
|
||||||
fatalError("Could not locate files in path directory: \(currentPath)")
|
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
|
/// List of localizable files - not including Localizable files in the Pods
|
||||||
var localizableFiles: [String] = {
|
var localizableFiles: [String] = {
|
||||||
return pathFiles
|
return pathFiles.filter { $0.hasSuffix("Localizable.strings") }
|
||||||
.filter {
|
|
||||||
$0.hasSuffix("Localizable.strings") &&
|
|
||||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
|
||||||
!$0.contains("Pods") // Exclude Pods
|
|
||||||
}
|
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
||||||
/// List of executable files
|
/// List of executable files
|
||||||
var executableFiles: [String] = {
|
var executableFiles: [String] = {
|
||||||
return pathFiles.filter {
|
return pathFiles.filter {
|
||||||
!$0.localizedCaseInsensitiveContains("test") && // Exclude test files
|
$0.hasSuffix(".swift") ||
|
||||||
!$0.contains(".app/") && // Exclude Built Localizable.strings files
|
$0.hasSuffix(".m")
|
||||||
!$0.contains("Pods") && // Exclude Pods
|
|
||||||
(
|
|
||||||
NSString(string: $0).pathExtension == "swift" ||
|
|
||||||
NSString(string: $0).pathExtension == "m"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -56,7 +62,6 @@ var executableFiles: [String] = {
|
||||||
/// - Parameter path: path of file
|
/// - Parameter path: path of file
|
||||||
/// - Returns: content in file
|
/// - Returns: content in file
|
||||||
func contents(atPath path: String) -> String {
|
func contents(atPath path: String) -> String {
|
||||||
print("Path: \(path)")
|
|
||||||
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
|
guard let data = fileManager.contents(atPath: path), let content = String(data: data, encoding: .utf8) else {
|
||||||
fatalError("Could not read from path: \(path)")
|
fatalError("Could not read from path: \(path)")
|
||||||
}
|
}
|
||||||
|
@ -109,8 +114,6 @@ func localizedStringsInCode() -> [LocalizationCodeFile] {
|
||||||
///
|
///
|
||||||
/// - Parameter files: list of localizable files to validate
|
/// - Parameter files: list of localizable files to validate
|
||||||
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
||||||
print("------------ Validating keys match in all localizable files ------------")
|
|
||||||
|
|
||||||
guard let base = files.first, files.count > 1 else { return }
|
guard let base = files.first, files.count > 1 else { return }
|
||||||
|
|
||||||
let files = Array(files.dropFirst())
|
let files = Array(files.dropFirst())
|
||||||
|
@ -128,8 +131,6 @@ func validateMatchKeys(_ files: [LocalizationStringsFile]) {
|
||||||
/// - codeFiles: Array of LocalizationCodeFile
|
/// - codeFiles: Array of LocalizationCodeFile
|
||||||
/// - localizationFiles: Array of LocalizableStringFiles
|
/// - localizationFiles: Array of LocalizableStringFiles
|
||||||
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||||
print("------------ Checking for missing keys -----------")
|
|
||||||
|
|
||||||
guard let baseFile = localizationFiles.first else {
|
guard let baseFile = localizationFiles.first else {
|
||||||
fatalError("Could not locate base localization file")
|
fatalError("Could not locate base localization file")
|
||||||
}
|
}
|
||||||
|
@ -150,8 +151,6 @@ func validateMissingKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles:
|
||||||
/// - codeFiles: Array of LocalizationCodeFile
|
/// - codeFiles: Array of LocalizationCodeFile
|
||||||
/// - localizationFiles: Array of LocalizableStringFiles
|
/// - localizationFiles: Array of LocalizableStringFiles
|
||||||
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
func validateDeadKeys(_ codeFiles: [LocalizationCodeFile], localizationFiles: [LocalizationStringsFile]) {
|
||||||
print("------------ Checking for any dead keys in localizable file -----------")
|
|
||||||
|
|
||||||
guard let baseFile = localizationFiles.first else {
|
guard let baseFile = localizationFiles.first else {
|
||||||
fatalError("Could not locate base localization file")
|
fatalError("Could not locate base localization file")
|
||||||
}
|
}
|
||||||
|
@ -174,14 +173,18 @@ protocol Pathable {
|
||||||
struct LocalizationStringsFile: Pathable {
|
struct LocalizationStringsFile: Pathable {
|
||||||
let path: String
|
let path: String
|
||||||
let kv: [String: String]
|
let kv: [String: String]
|
||||||
|
let duplicates: [(key: String, path: String)]
|
||||||
|
|
||||||
var keys: [String] {
|
var keys: [String] {
|
||||||
return Array(kv.keys)
|
return Array(kv.keys)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(path: String) {
|
init(path: String) {
|
||||||
|
let result = ContentParser.parse(path)
|
||||||
|
|
||||||
self.path = path
|
self.path = path
|
||||||
self.kv = ContentParser.parse(path)
|
self.kv = result.kv
|
||||||
|
self.duplicates = result.duplicates
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
|
/// Writes back to localizable file with sorted keys and removed whitespaces and new lines
|
||||||
|
@ -204,9 +207,7 @@ struct ContentParser {
|
||||||
///
|
///
|
||||||
/// - Parameter path: Localizable file paths
|
/// - Parameter path: Localizable file paths
|
||||||
/// - Returns: localizable key and value for content at path
|
/// - Returns: localizable key and value for content at path
|
||||||
static func parse(_ path: String) -> [String: String] {
|
static func parse(_ path: String) -> (kv: [String: String], duplicates: [(key: String, path: String)]) {
|
||||||
print("------------ Checking for duplicate keys: \(path) ------------")
|
|
||||||
|
|
||||||
let content = contents(atPath: path)
|
let content = contents(atPath: path)
|
||||||
let trimmed = content
|
let trimmed = content
|
||||||
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
|
.replacingOccurrences(of: "\n+", with: "", options: .regularExpression, range: nil)
|
||||||
|
@ -218,13 +219,18 @@ struct ContentParser {
|
||||||
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
|
fatalError("Error parsing contents: Make sure all keys and values are in correct format (this could be due to extra spaces between keys and values)")
|
||||||
}
|
}
|
||||||
|
|
||||||
return zip(keys, values).reduce(into: [String: String]()) { results, keyValue in
|
var duplicates: [(key: String, path: String)] = []
|
||||||
if results[keyValue.0] != nil {
|
let kv: [String: String] = zip(keys, values)
|
||||||
printPretty("error: Found duplicate key: \(keyValue.0) in file: \(path)")
|
.reduce(into: [:]) { results, keyValue in
|
||||||
abort()
|
guard results[keyValue.0] == nil else {
|
||||||
|
duplicates.append((keyValue.0, path))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
results[keyValue.0] = keyValue.1
|
results[keyValue.0] = keyValue.1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return (kv, duplicates)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -232,20 +238,27 @@ func printPretty(_ string: String) {
|
||||||
print(string.replacingOccurrences(of: "\\", with: ""))
|
print(string.replacingOccurrences(of: "\\", with: ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
let stringFiles = create()
|
// MARK: - Processing
|
||||||
|
|
||||||
|
let stringFiles: [LocalizationStringsFile] = create()
|
||||||
|
|
||||||
if !stringFiles.isEmpty {
|
if !stringFiles.isEmpty {
|
||||||
print("------------ Found \(stringFiles.count) file(s) ------------")
|
print("------------ Found \(stringFiles.count) file(s) - checking for duplicate, extra, missing and dead keys ------------")
|
||||||
|
|
||||||
|
stringFiles.forEach { file in
|
||||||
|
file.duplicates.forEach { key, path in
|
||||||
|
printPretty("error: Found duplicate key: \(key) in file: \(path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stringFiles.forEach { print($0.path) }
|
|
||||||
validateMatchKeys(stringFiles)
|
validateMatchKeys(stringFiles)
|
||||||
|
|
||||||
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
|
// Note: Uncomment the below file to clean out all comments from the localizable file (we don't want this because comments make it readable...)
|
||||||
// stringFiles.forEach { $0.cleanWrite() }
|
// stringFiles.forEach { $0.cleanWrite() }
|
||||||
|
|
||||||
let codeFiles = localizedStringsInCode()
|
let codeFiles: [LocalizationCodeFile] = localizedStringsInCode()
|
||||||
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
|
validateMissingKeys(codeFiles, localizationFiles: stringFiles)
|
||||||
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
|
validateDeadKeys(codeFiles, localizationFiles: stringFiles)
|
||||||
}
|
}
|
||||||
|
|
||||||
print("------------ SUCCESS ------------")
|
print("------------ Complete ------------")
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/usr/bin/env python
|
#!/usr/bin/env python3
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
@ -202,10 +202,18 @@ class BaseContext(object):
|
||||||
return 'UInt32'
|
return 'UInt32'
|
||||||
elif field.proto_type == 'fixed64':
|
elif field.proto_type == 'fixed64':
|
||||||
return 'UInt64'
|
return 'UInt64'
|
||||||
|
elif field.proto_type == 'int64':
|
||||||
|
return 'Int64'
|
||||||
|
elif field.proto_type == 'int32':
|
||||||
|
return 'Int32'
|
||||||
elif field.proto_type == 'bool':
|
elif field.proto_type == 'bool':
|
||||||
return 'Bool'
|
return 'Bool'
|
||||||
elif field.proto_type == 'bytes':
|
elif field.proto_type == 'bytes':
|
||||||
return 'Data'
|
return 'Data'
|
||||||
|
elif field.proto_type == 'double':
|
||||||
|
return 'Double'
|
||||||
|
elif field.proto_type == 'float':
|
||||||
|
return 'Float'
|
||||||
else:
|
else:
|
||||||
matching_context = self.context_for_proto_type(field)
|
matching_context = self.context_for_proto_type(field)
|
||||||
if matching_context is not None:
|
if matching_context is not None:
|
||||||
|
@ -236,7 +244,11 @@ class BaseContext(object):
|
||||||
return field.proto_type in ('uint64',
|
return field.proto_type in ('uint64',
|
||||||
'uint32',
|
'uint32',
|
||||||
'fixed64',
|
'fixed64',
|
||||||
'bool', )
|
'int64',
|
||||||
|
'int32',
|
||||||
|
'bool',
|
||||||
|
'double',
|
||||||
|
'float', )
|
||||||
|
|
||||||
def can_field_be_optional(self, field):
|
def can_field_be_optional(self, field):
|
||||||
if self.is_field_primitive(field):
|
if self.is_field_primitive(field):
|
||||||
|
@ -288,8 +300,16 @@ class BaseContext(object):
|
||||||
return '0'
|
return '0'
|
||||||
elif field.proto_type == 'fixed64':
|
elif field.proto_type == 'fixed64':
|
||||||
return '0'
|
return '0'
|
||||||
|
elif field.proto_type == 'int64':
|
||||||
|
return '0'
|
||||||
|
elif field.proto_type == 'int32':
|
||||||
|
return '0'
|
||||||
elif field.proto_type == 'bool':
|
elif field.proto_type == 'bool':
|
||||||
return 'false'
|
return 'false'
|
||||||
|
elif field.proto_type == 'double':
|
||||||
|
return '0'
|
||||||
|
elif field.proto_type == 'float':
|
||||||
|
return '0'
|
||||||
elif self.is_field_an_enum(field):
|
elif self.is_field_an_enum(field):
|
||||||
# TODO: Assert that rules is empty.
|
# TODO: Assert that rules is empty.
|
||||||
enum_context = self.context_for_proto_type(field)
|
enum_context = self.context_for_proto_type(field)
|
||||||
|
|
|
@ -0,0 +1,261 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# XCode will error during it's dependency graph construction (which happens before the build
|
||||||
|
# stage starts and any target "Run Script" phases are triggered)
|
||||||
|
#
|
||||||
|
# In order to avoid this error we need to build the framework before actually getting to the
|
||||||
|
# build stage so XCode is able to build the dependency graph
|
||||||
|
#
|
||||||
|
# XCode's Pre-action scripts don't output anything into XCode so the only way to emit a useful
|
||||||
|
# error is to **return a success status** and have the project detect and log the error itself
|
||||||
|
# then log it, stopping the build at that point
|
||||||
|
#
|
||||||
|
# The other step to get this to work properly is to ensure the framework in "Link Binary with
|
||||||
|
# Libraries" isn't using a relative directory, unfortunately there doesn't seem to be a good
|
||||||
|
# way to do this directly so we need to modify the '.pbxproj' file directly, updating the
|
||||||
|
# framework entry to have the following (on a single line):
|
||||||
|
# {
|
||||||
|
# isa = PBXFileReference;
|
||||||
|
# explicitFileType = wrapper.xcframework;
|
||||||
|
# includeInIndex = 0;
|
||||||
|
# path = "{FRAMEWORK NAME GOES HERE}";
|
||||||
|
# sourceTree = BUILD_DIR;
|
||||||
|
# };
|
||||||
|
#
|
||||||
|
# Note: We might one day be able to replace this with a local podspec if this GitHub feature
|
||||||
|
# request ever gets implemented: https://github.com/CocoaPods/CocoaPods/issues/8464
|
||||||
|
|
||||||
|
# Need to set the path or we won't find cmake
|
||||||
|
PATH=${PATH}:/usr/local/bin:/opt/homebrew/bin:/sbin/md5
|
||||||
|
|
||||||
|
exec 3>&1 # Save original stdout
|
||||||
|
|
||||||
|
# Ensure the build directory exists (in case we need it before XCode creates it)
|
||||||
|
mkdir -p "${TARGET_BUILD_DIR}/libSessionUtil"
|
||||||
|
|
||||||
|
# Remove any old build errors
|
||||||
|
rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log"
|
||||||
|
|
||||||
|
# Restore stdout and stderr and redirect it to the 'libsession_util_output.log' file
|
||||||
|
exec &> "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log"
|
||||||
|
|
||||||
|
# Define a function to echo a message.
|
||||||
|
function echo_message() {
|
||||||
|
exec 1>&3 # Restore stdout
|
||||||
|
echo "$1"
|
||||||
|
exec >> "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" # Redirect all output to the log file
|
||||||
|
}
|
||||||
|
|
||||||
|
echo_message "info: Validating build requirements"
|
||||||
|
|
||||||
|
set -x
|
||||||
|
|
||||||
|
# Ensure the build directory exists (in case we need it before XCode creates it)
|
||||||
|
mkdir -p "${TARGET_BUILD_DIR}"
|
||||||
|
|
||||||
|
if ! which cmake > /dev/null; then
|
||||||
|
echo_message "error: cmake is required to build, please install (can install via homebrew with 'brew install cmake')."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if we have the `LibSession-Util` submodule checked out and if not (depending on the 'SHOULD_AUTO_INIT_SUBMODULES' argument) perform the checkout
|
||||||
|
if [ ! -d "${SRCROOT}/LibSession-Util" ] || [ ! -d "${SRCROOT}/LibSession-Util/src" ] || [ ! "$(ls -A "${SRCROOT}/LibSession-Util")" ]; then
|
||||||
|
echo_message "error: Need to fetch LibSession-Util submodule (git submodule update --init --recursive)."
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
are_submodules_valid() {
|
||||||
|
local PARENT_PATH=$1
|
||||||
|
local RELATIVE_PATH=$2
|
||||||
|
|
||||||
|
# Change into the path to check for it's submodules
|
||||||
|
cd "${PARENT_PATH}"
|
||||||
|
local SUB_MODULE_PATHS=($(git config --file .gitmodules --get-regexp path | awk '{ print $2 }'))
|
||||||
|
|
||||||
|
# If there are no submodules then return success based on whether the folder has any content
|
||||||
|
if [ ${#SUB_MODULE_PATHS[@]} -eq 0 ]; then
|
||||||
|
if [[ ! -z "$(ls -A "${PARENT_PATH}")" ]]; then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Loop through the child submodules and check if they are valid
|
||||||
|
for i in "${!SUB_MODULE_PATHS[@]}"; do
|
||||||
|
local CHILD_PATH="${SUB_MODULE_PATHS[$i]}"
|
||||||
|
|
||||||
|
# If the child path doesn't exist then it's invalid
|
||||||
|
if [ ! -d "${PARENT_PATH}/${CHILD_PATH}" ]; then
|
||||||
|
echo_message "info: Submodule '${RELATIVE_PATH}/${CHILD_PATH}' doesn't exist."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
are_submodules_valid "${PARENT_PATH}/${CHILD_PATH}" "${RELATIVE_PATH}/${CHILD_PATH}"
|
||||||
|
local RESULT=$?
|
||||||
|
|
||||||
|
if [ "${RESULT}" -eq 1 ]; then
|
||||||
|
echo_message "info: Submodule '${RELATIVE_PATH}/${CHILD_PATH}' is in an invalid state."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Validate the state of the submodules
|
||||||
|
are_submodules_valid "${SRCROOT}/LibSession-Util" "LibSession-Util"
|
||||||
|
|
||||||
|
HAS_INVALID_SUBMODULE=$?
|
||||||
|
|
||||||
|
if [ "${HAS_INVALID_SUBMODULE}" -eq 1 ]; then
|
||||||
|
echo_message "error: Submodules are in an invalid state, please delete 'LibSession-Util' and run 'git submodule update --init --recursive'."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Generate a hash of the libSession-util source files and check if they differ from the last hash
|
||||||
|
echo "info: Checking for changes to source"
|
||||||
|
|
||||||
|
NEW_SOURCE_HASH=$(find "${SRCROOT}/LibSession-Util/src" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}')
|
||||||
|
NEW_HEADER_HASH=$(find "${SRCROOT}/LibSession-Util/include" -type f -exec md5 {} + | awk '{print $NF}' | sort | md5 | awk '{print $NF}')
|
||||||
|
|
||||||
|
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log" ]; then
|
||||||
|
read -r OLD_SOURCE_HASH < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log" ]; then
|
||||||
|
read -r OLD_HEADER_HASH < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log" ]; then
|
||||||
|
read -r OLD_ARCHS < "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If all of the hashes match, the archs match and there is a library file then we can just stop here
|
||||||
|
if [ "${NEW_SOURCE_HASH}" == "${OLD_SOURCE_HASH}" ] && [ "${NEW_HEADER_HASH}" == "${OLD_HEADER_HASH}" ] && [ "${ARCHS[*]}" == "${OLD_ARCHS}" ] && [ -f "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a" ]; then
|
||||||
|
echo_message "info: Build is up-to-date"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If any of the above differ then we need to rebuild
|
||||||
|
echo_message "info: Build is not up-to-date - creating new build"
|
||||||
|
|
||||||
|
# Import settings from XCode (defaulting values if not present)
|
||||||
|
VALID_SIM_ARCHS=(arm64 x86_64)
|
||||||
|
VALID_DEVICE_ARCHS=(arm64)
|
||||||
|
VALID_SIM_ARCH_PLATFORMS=(SIMULATORARM64 SIMULATOR64)
|
||||||
|
VALID_DEVICE_ARCH_PLATFORMS=(OS64)
|
||||||
|
|
||||||
|
OUTPUT_DIR="${TARGET_BUILD_DIR}"
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET=${IPHONEOS_DEPLOYMENT_TARGET}
|
||||||
|
ENABLE_BITCODE=${ENABLE_BITCODE}
|
||||||
|
|
||||||
|
# Generate the target architectures we want to build for
|
||||||
|
TARGET_ARCHS=()
|
||||||
|
TARGET_PLATFORMS=()
|
||||||
|
TARGET_SIM_ARCHS=()
|
||||||
|
TARGET_DEVICE_ARCHS=()
|
||||||
|
|
||||||
|
if [ -z $PLATFORM_NAME ] || [ $PLATFORM_NAME = "iphonesimulator" ]; then
|
||||||
|
for i in "${!VALID_SIM_ARCHS[@]}"; do
|
||||||
|
ARCH="${VALID_SIM_ARCHS[$i]}"
|
||||||
|
ARCH_PLATFORM="${VALID_SIM_ARCH_PLATFORMS[$i]}"
|
||||||
|
|
||||||
|
if [[ " ${ARCHS[*]} " =~ " ${ARCH} " ]]; then
|
||||||
|
TARGET_ARCHS+=("sim-${ARCH}")
|
||||||
|
TARGET_PLATFORMS+=("${ARCH_PLATFORM}")
|
||||||
|
TARGET_SIM_ARCHS+=("sim-${ARCH}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z $PLATFORM_NAME ] || [ $PLATFORM_NAME = "iphoneos" ]; then
|
||||||
|
for i in "${!VALID_DEVICE_ARCHS[@]}"; do
|
||||||
|
ARCH="${VALID_DEVICE_ARCHS[$i]}"
|
||||||
|
ARCH_PLATFORM="${VALID_DEVICE_ARCH_PLATFORMS[$i]}"
|
||||||
|
|
||||||
|
if [[ " ${ARCHS[*]} " =~ " ${ARCH} " ]]; then
|
||||||
|
TARGET_ARCHS+=("ios-${ARCH}")
|
||||||
|
TARGET_PLATFORMS+=("${ARCH_PLATFORM}")
|
||||||
|
TARGET_DEVICE_ARCHS+=("ios-${ARCH}")
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build the individual architectures
|
||||||
|
for i in "${!TARGET_ARCHS[@]}"; do
|
||||||
|
build="${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_ARCHS[$i]}"
|
||||||
|
platform="${TARGET_PLATFORMS[$i]}"
|
||||||
|
echo_message "Building ${TARGET_ARCHS[$i]} for $platform in $build"
|
||||||
|
|
||||||
|
cd "${SRCROOT}/LibSession-Util"
|
||||||
|
./utils/static-bundle.sh "$build" "" \
|
||||||
|
-DCMAKE_TOOLCHAIN_FILE="${SRCROOT}/LibSession-Util/external/ios-cmake/ios.toolchain.cmake" \
|
||||||
|
-DPLATFORM=$platform \
|
||||||
|
-DDEPLOYMENT_TARGET=$IPHONEOS_DEPLOYMENT_TARGET \
|
||||||
|
-DENABLE_BITCODE=$ENABLE_BITCODE
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
LAST_OUTPUT=$(tail -n 4 "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_output.log" | head -n 1)
|
||||||
|
echo_message "error: $LAST_OUTPUT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# Remove the old static library file
|
||||||
|
rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
|
||||||
|
rm -rf "${TARGET_BUILD_DIR}/libSessionUtil/Headers"
|
||||||
|
|
||||||
|
# If needed combine simulator builds into a multi-arch lib
|
||||||
|
if [ "${#TARGET_SIM_ARCHS[@]}" -eq "1" ]; then
|
||||||
|
# Single device build
|
||||||
|
cp "${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_SIM_ARCHS[0]}/libsession-util.a" "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
|
||||||
|
elif [ "${#TARGET_SIM_ARCHS[@]}" -gt "1" ]; then
|
||||||
|
# Combine multiple device builds into a multi-arch lib
|
||||||
|
echo_message "info: Built multiple architectures, merging into single static library"
|
||||||
|
lipo -create "${TARGET_BUILD_DIR}/libSessionUtil"/sim-*/libsession-util.a -output "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If needed combine device builds into a multi-arch lib
|
||||||
|
if [ "${#TARGET_DEVICE_ARCHS[@]}" -eq "1" ]; then
|
||||||
|
cp "${TARGET_BUILD_DIR}/libSessionUtil/${TARGET_DEVICE_ARCHS[0]}/libsession-util.a" "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
|
||||||
|
elif [ "${#TARGET_DEVICE_ARCHS[@]}" -gt "1" ]; then
|
||||||
|
# Combine multiple device builds into a multi-arch lib
|
||||||
|
echo_message "info: Built multiple architectures, merging into single static library"
|
||||||
|
lipo -create "${TARGET_BUILD_DIR}/libSessionUtil"/ios-*/libsession-util.a -output "${TARGET_BUILD_DIR}/libSessionUtil/libSessionUtil.a"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Save the updated hashes to disk to prevent rebuilds when there were no changes
|
||||||
|
echo "${NEW_SOURCE_HASH}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_source_hash.log"
|
||||||
|
echo "${NEW_HEADER_HASH}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_header_hash.log"
|
||||||
|
echo "${ARCHS[*]}" > "${TARGET_BUILD_DIR}/libSessionUtil/libsession_util_archs.log"
|
||||||
|
echo_message "info: Build complete"
|
||||||
|
|
||||||
|
# Copy the headers across
|
||||||
|
echo_message "info: Copy headers and prepare modulemap"
|
||||||
|
mkdir -p "${TARGET_BUILD_DIR}/libSessionUtil/Headers"
|
||||||
|
cp -r "${SRCROOT}/LibSession-Util/include/session" "${TARGET_BUILD_DIR}/libSessionUtil/Headers"
|
||||||
|
|
||||||
|
# The 'module.modulemap' is needed for XCode to be able to find the headers
|
||||||
|
modmap="${TARGET_BUILD_DIR}/libSessionUtil/Headers/module.modulemap"
|
||||||
|
echo "module SessionUtil {" >"$modmap"
|
||||||
|
echo " module capi {" >>"$modmap"
|
||||||
|
for x in $(cd include && find session -name '*.h'); do
|
||||||
|
echo " header \"$x\"" >>"$modmap"
|
||||||
|
done
|
||||||
|
echo -e " export *\n }" >>"$modmap"
|
||||||
|
if false; then
|
||||||
|
# If we include the cpp headers like this then Xcode will try to load them as C headers (which
|
||||||
|
# of course breaks) and doesn't provide any way to only load the ones you need (because this is
|
||||||
|
# Apple land, why would anything useful be available?). So we include the headers in the
|
||||||
|
# archive but can't let xcode discover them because it will do it wrong.
|
||||||
|
echo -e "\n module cppapi {" >>"$modmap"
|
||||||
|
for x in $(cd include && find session -name '*.hpp'); do
|
||||||
|
echo " header \"$x\"" >>"$modmap"
|
||||||
|
done
|
||||||
|
echo -e " export *\n }" >>"$modmap"
|
||||||
|
fi
|
||||||
|
echo "}" >>"$modmap"
|
||||||
|
|
||||||
|
# Output to XCode just so the output is good
|
||||||
|
echo_message "info: libSessionUtil Ready"
|
|
@ -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
|
@ -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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
|
@ -1,10 +1,12 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import UIKit
|
||||||
|
import YYImage
|
||||||
|
import Combine
|
||||||
import CallKit
|
import CallKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import WebRTC
|
import WebRTC
|
||||||
import PromiseKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
|
||||||
|
@ -25,6 +27,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
|
|
||||||
let contactName: String
|
let contactName: String
|
||||||
let profilePicture: UIImage
|
let profilePicture: UIImage
|
||||||
|
let animatedProfilePicture: YYImage?
|
||||||
|
|
||||||
// MARK: - Control
|
// MARK: - Control
|
||||||
|
|
||||||
|
@ -151,10 +154,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
|
self.webRTCSession = WebRTCSession.current ?? WebRTCSession(for: sessionId, with: uuid)
|
||||||
self.isOutgoing = outgoing
|
self.isOutgoing = outgoing
|
||||||
|
|
||||||
|
let avatarData: Data? = ProfileManager.profileAvatar(db, id: sessionId)
|
||||||
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
self.contactName = Profile.displayName(db, id: sessionId, threadVariant: .contact)
|
||||||
self.profilePicture = ProfileManager.profileAvatar(db, id: sessionId)
|
self.profilePicture = avatarData
|
||||||
.map { UIImage(data: $0) }
|
.map { UIImage(data: $0) }
|
||||||
.defaulting(to: Identicon.generatePlaceholderIcon(seed: sessionId, text: self.contactName, size: 300))
|
.defaulting(to: PlaceholderIcon.generate(seed: sessionId, text: self.contactName, size: 300))
|
||||||
|
self.animatedProfilePicture = avatarData
|
||||||
|
.map { data in
|
||||||
|
switch data.guessedImageFormat {
|
||||||
|
case .gif, .webp: return YYImage(data: data)
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
WebRTCSession.current = self.webRTCSession
|
WebRTCSession.current = self.webRTCSession
|
||||||
self.webRTCSession.delegate = self
|
self.webRTCSession.delegate = self
|
||||||
|
@ -206,6 +217,7 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
|
let thread: SessionThread = try? SessionThread.fetchOne(db, id: sessionId)
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
|
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||||
let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
let timestampMs: Int64 = SnodeAPI.currentOffsetTimestampMs()
|
||||||
let message: CallMessage = CallMessage(
|
let message: CallMessage = CallMessage(
|
||||||
uuid: self.uuid,
|
uuid: self.uuid,
|
||||||
|
@ -224,21 +236,18 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
.inserted(db)
|
.inserted(db)
|
||||||
|
|
||||||
self.callInteractionId = interaction?.id
|
self.callInteractionId = interaction?.id
|
||||||
try? self.webRTCSession
|
|
||||||
|
try? webRTCSession
|
||||||
.sendPreOffer(
|
.sendPreOffer(
|
||||||
db,
|
db,
|
||||||
message: message,
|
message: message,
|
||||||
interactionId: interaction?.id,
|
interactionId: interaction?.id,
|
||||||
in: thread
|
in: thread
|
||||||
)
|
)
|
||||||
.done { [weak self] _ in
|
// Start the timeout timer for the call
|
||||||
Storage.shared.writeAsync { db in
|
.handleEvents(receiveOutput: { [weak self] _ in self?.setupTimeoutTimer() })
|
||||||
self?.webRTCSession.sendOffer(db, to: sessionId)
|
.flatMap { _ in webRTCSession.sendOffer(to: thread) }
|
||||||
}
|
.sinkUntilComplete()
|
||||||
|
|
||||||
self?.setupTimeoutTimer()
|
|
||||||
}
|
|
||||||
.retainUntilComplete()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func answerSessionCall() {
|
func answerSessionCall() {
|
||||||
|
@ -418,9 +427,14 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate {
|
||||||
let sessionId: String = self.sessionId
|
let sessionId: String = self.sessionId
|
||||||
let webRTCSession: WebRTCSession = self.webRTCSession
|
let webRTCSession: WebRTCSession = self.webRTCSession
|
||||||
|
|
||||||
Storage.shared
|
guard let thread: SessionThread = Storage.shared.read({ db in try SessionThread.fetchOne(db, id: sessionId) }) else {
|
||||||
.read { db in webRTCSession.sendOffer(db, to: sessionId, isRestartingICEConnection: true) }
|
return
|
||||||
.retainUntilComplete()
|
}
|
||||||
|
|
||||||
|
webRTCSession
|
||||||
|
.sendOffer(to: thread, isRestartingICEConnection: true)
|
||||||
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
|
.sinkUntilComplete()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Timeout
|
// MARK: - Timeout
|
||||||
|
|
|
@ -2,8 +2,8 @@
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import CallKit
|
import CallKit
|
||||||
import SignalCoreKit
|
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
extension SessionCallManager: CXProviderDelegate {
|
extension SessionCallManager: CXProviderDelegate {
|
||||||
public func providerDidReset(_ provider: CXProvider) {
|
public func providerDidReset(_ provider: CXProvider) {
|
||||||
|
|
|
@ -4,6 +4,8 @@ import UIKit
|
||||||
import CallKit
|
import CallKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||||
let provider: CXProvider?
|
let provider: CXProvider?
|
||||||
|
@ -205,9 +207,9 @@ public final class SessionCallManager: NSObject, CallManagerProtocol {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
guard CurrentAppContext().isMainAppAndActive else { return }
|
guard CurrentAppContext().isMainAppAndActive else { return }
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
|
guard let presentingVC = CurrentAppContext().frontmostViewController() else {
|
||||||
preconditionFailure() // FIXME: Handle more gracefully
|
preconditionFailure() // FIXME: Handle more gracefully
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import YYImage
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
final class CallVC: UIViewController, VideoPreviewDelegate {
|
final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||||
static let floatingVideoViewWidth: CGFloat = UIDevice.current.isIPad ? 160 : 80
|
private static let avatarRadius: CGFloat = (isIPhone6OrSmaller ? 100 : 120)
|
||||||
static let floatingVideoViewHeight: CGFloat = UIDevice.current.isIPad ? 346: 173
|
private static let floatingVideoViewWidth: CGFloat = (UIDevice.current.isIPad ? 160 : 80)
|
||||||
|
private static let floatingVideoViewHeight: CGFloat = (UIDevice.current.isIPad ? 346: 173)
|
||||||
|
|
||||||
let call: SessionCall
|
let call: SessionCall
|
||||||
var latestKnownAudioOutputDeviceName: String?
|
var latestKnownAudioOutputDeviceName: String?
|
||||||
|
@ -129,17 +130,29 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||||
|
|
||||||
private lazy var profilePictureView: UIImageView = {
|
private lazy var profilePictureView: UIImageView = {
|
||||||
let result = UIImageView()
|
let result = UIImageView()
|
||||||
let radius: CGFloat = isIPhone6OrSmaller ? 100 : 120
|
|
||||||
result.image = self.call.profilePicture
|
result.image = self.call.profilePicture
|
||||||
result.set(.width, to: radius * 2)
|
result.set(.width, to: CallVC.avatarRadius * 2)
|
||||||
result.set(.height, to: radius * 2)
|
result.set(.height, to: CallVC.avatarRadius * 2)
|
||||||
result.layer.cornerRadius = radius
|
result.layer.cornerRadius = CallVC.avatarRadius
|
||||||
result.layer.masksToBounds = true
|
result.layer.masksToBounds = true
|
||||||
result.contentMode = .scaleAspectFill
|
result.contentMode = .scaleAspectFill
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
private lazy var animatedImageView: YYAnimatedImageView = {
|
||||||
|
let result: YYAnimatedImageView = YYAnimatedImageView()
|
||||||
|
result.image = self.call.animatedProfilePicture
|
||||||
|
result.set(.width, to: CallVC.avatarRadius * 2)
|
||||||
|
result.set(.height, to: CallVC.avatarRadius * 2)
|
||||||
|
result.layer.cornerRadius = CallVC.avatarRadius
|
||||||
|
result.layer.masksToBounds = true
|
||||||
|
result.contentMode = .scaleAspectFill
|
||||||
|
result.isHidden = (self.call.animatedProfilePicture == nil)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
private lazy var minimizeButton: UIButton = {
|
private lazy var minimizeButton: UIButton = {
|
||||||
let result = UIButton(type: .custom)
|
let result = UIButton(type: .custom)
|
||||||
result.setImage(
|
result.setImage(
|
||||||
|
@ -486,7 +499,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||||
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
profilePictureContainer.pin(.bottom, to: .top, of: operationPanel)
|
||||||
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
profilePictureContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: view)
|
||||||
profilePictureContainer.addSubview(profilePictureView)
|
profilePictureContainer.addSubview(profilePictureView)
|
||||||
|
profilePictureContainer.addSubview(animatedImageView)
|
||||||
profilePictureView.center(in: profilePictureContainer)
|
profilePictureView.center(in: profilePictureContainer)
|
||||||
|
animatedImageView.center(in: profilePictureContainer)
|
||||||
|
|
||||||
// Call info label
|
// Call info label
|
||||||
let callInfoLabelContainer = UIView()
|
let callInfoLabelContainer = UIView()
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
|
||||||
public protocol VideoPreviewDelegate: AnyObject {
|
public protocol VideoPreviewDelegate: AnyObject {
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import WebRTC
|
import WebRTC
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
// Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead
|
// Note: 'RTCMTLVideoView' doesn't seem to work on the simulator so use 'RTCEAGLVideoView' instead
|
||||||
|
@ -27,7 +30,7 @@ class RemoteVideoView: TargetView {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchMainThreadSafe {
|
Threading.dispatchMainThreadSafe {
|
||||||
let frameRatio = Double(frame.height) / Double(frame.width)
|
let frameRatio = Double(frame.height) / Double(frame.width)
|
||||||
let frameRotation = frame.rotation
|
let frameRotation = frame.rotation
|
||||||
let deviceRotation = UIDevice.current.orientation
|
let deviceRotation = UIDevice.current.orientation
|
||||||
|
@ -90,7 +93,8 @@ class LocalVideoView: TargetView {
|
||||||
|
|
||||||
override func renderFrame(_ frame: RTCVideoFrame?) {
|
override func renderFrame(_ frame: RTCVideoFrame?) {
|
||||||
super.renderFrame(frame)
|
super.renderFrame(frame)
|
||||||
DispatchMainThreadSafe {
|
|
||||||
|
Threading.dispatchMainThreadSafe {
|
||||||
// This is a workaround for a weird issue that
|
// This is a workaround for a weird issue that
|
||||||
// sometimes the rotationOverride is not working
|
// sometimes the rotationOverride is not working
|
||||||
// if it is only set once on initialization
|
// if it is only set once on initialization
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||||
private static let swipeToOperateThreshold: CGFloat = 60
|
private static let swipeToOperateThreshold: CGFloat = 60
|
||||||
|
@ -20,14 +20,7 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var profilePictureView: ProfilePictureView = {
|
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .list)
|
||||||
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 displayNameLabel: UILabel = {
|
private lazy var displayNameLabel: UILabel = {
|
||||||
let result = UILabel()
|
let result = UILabel()
|
||||||
|
@ -118,8 +111,10 @@ final class IncomingCallBanner: UIView, UIGestureRecognizerDelegate {
|
||||||
|
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: call.sessionId,
|
publicKey: call.sessionId,
|
||||||
profile: Profile.fetchOrCreate(id: call.sessionId),
|
threadVariant: .contact,
|
||||||
threadVariant: .contact
|
customImageData: nil,
|
||||||
|
profile: Storage.shared.read { db in Profile.fetchOrCreate(db, id: call.sessionId) },
|
||||||
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
displayNameLabel.text = call.contactName
|
displayNameLabel.text = call.contactName
|
||||||
|
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
@ -18,6 +18,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
private let threadId: String
|
private let threadId: String
|
||||||
|
private let threadVariant: SessionThread.Variant
|
||||||
private var originalName: String = ""
|
private var originalName: String = ""
|
||||||
private var originalMembersAndZombieIds: Set<String> = []
|
private var originalMembersAndZombieIds: Set<String> = []
|
||||||
private var name: String = ""
|
private var name: String = ""
|
||||||
|
@ -82,8 +83,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(threadId: String) {
|
init(threadId: String, threadVariant: SessionThread.Variant) {
|
||||||
self.threadId = threadId
|
self.threadId = threadId
|
||||||
|
self.threadVariant = threadVariant
|
||||||
|
|
||||||
super.init(nibName: nil, bundle: nil)
|
super.init(nibName: nil, bundle: nil)
|
||||||
}
|
}
|
||||||
|
@ -220,7 +222,8 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: displayInfo,
|
id: displayInfo,
|
||||||
leftAccessory: .profile(displayInfo.profileId, displayInfo.profile),
|
position: Position.with(indexPath.row, count: membersAndZombies.count),
|
||||||
|
leftAccessory: .profile(id: displayInfo.profileId, profile: displayInfo.profile),
|
||||||
title: (
|
title: (
|
||||||
displayInfo.profile?.displayName() ??
|
displayInfo.profile?.displayName() ??
|
||||||
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
Profile.truncated(id: displayInfo.profileId, threadVariant: .contact)
|
||||||
|
@ -231,10 +234,9 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
.withRenderingMode(.alwaysTemplate),
|
.withRenderingMode(.alwaysTemplate),
|
||||||
customTint: .textSecondary
|
customTint: .textSecondary
|
||||||
)
|
)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
style: .edgeToEdge,
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||||
position: Position.with(indexPath.row, count: membersAndZombies.count)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
@ -244,12 +246,26 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
return adminIds.contains(userPublicKey)
|
return adminIds.contains(userPublicKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||||
|
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||||
|
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
let profileId: String = self.membersAndZombies[indexPath.row].profileId
|
let profileId: String = self.membersAndZombies[indexPath.row].profileId
|
||||||
|
|
||||||
let delete: UIContextualAction = UIContextualAction(
|
let delete: UIContextualAction = UIContextualAction(
|
||||||
style: .destructive,
|
title: "GROUP_ACTION_REMOVE".localized(),
|
||||||
title: "GROUP_ACTION_REMOVE".localized()
|
icon: UIImage(named: "icon_bin"),
|
||||||
|
themeTintColor: .white,
|
||||||
|
themeBackgroundColor: .conversationButton_swipeDestructive,
|
||||||
|
side: .trailing,
|
||||||
|
actionIndex: 0,
|
||||||
|
indexPath: indexPath,
|
||||||
|
tableView: tableView
|
||||||
) { [weak self] _, _, completionHandler in
|
) { [weak self] _, _, completionHandler in
|
||||||
self?.adminIds.remove(profileId)
|
self?.adminIds.remove(profileId)
|
||||||
self?.membersAndZombies.remove(at: indexPath.row)
|
self?.membersAndZombies.remove(at: indexPath.row)
|
||||||
|
@ -257,7 +273,6 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
|
|
||||||
completionHandler(true)
|
completionHandler(true)
|
||||||
}
|
}
|
||||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
|
||||||
|
|
||||||
return UISwipeActionsConfiguration(actions: [ delete ])
|
return UISwipeActionsConfiguration(actions: [ delete ])
|
||||||
}
|
}
|
||||||
|
@ -286,7 +301,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleMembersChanged() {
|
private func handleMembersChanged() {
|
||||||
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 67
|
tableViewHeightConstraint.constant = CGFloat(membersAndZombies.count) * 78
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -333,7 +348,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
guard !updatedName.isEmpty else {
|
guard !updatedName.isEmpty else {
|
||||||
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
||||||
}
|
}
|
||||||
guard updatedName.count < 64 else {
|
guard updatedName.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else {
|
||||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -449,32 +464,40 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat
|
||||||
|
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController) { _ in
|
||||||
Storage.shared
|
Storage.shared
|
||||||
.writeAsync { db in
|
.writePublisher { db in
|
||||||
if !updatedMemberIds.contains(userPublicKey) {
|
// If the user is no longer a member then leave the group
|
||||||
|
guard !updatedMemberIds.contains(userPublicKey) else { return }
|
||||||
|
|
||||||
try MessageSender.leave(
|
try MessageSender.leave(
|
||||||
db,
|
db,
|
||||||
groupPublicKey: threadId,
|
groupPublicKey: threadId,
|
||||||
deleteThread: false
|
deleteThread: true
|
||||||
)
|
)
|
||||||
return Promise.value(())
|
|
||||||
}
|
|
||||||
|
|
||||||
return try MessageSender.update(
|
}
|
||||||
db,
|
.flatMap {
|
||||||
|
MessageSender.update(
|
||||||
groupPublicKey: threadId,
|
groupPublicKey: threadId,
|
||||||
with: updatedMemberIds,
|
with: updatedMemberIds,
|
||||||
name: updatedName
|
name: updatedName
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.done(on: DispatchQueue.main) { [weak self] in
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sinkUntilComplete(
|
||||||
|
receiveCompletion: { [weak self] result in
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||||
popToConversationVC(self)
|
|
||||||
|
switch result {
|
||||||
|
case .finished: popToConversationVC(self)
|
||||||
|
case .failure(let error):
|
||||||
|
self?.showError(
|
||||||
|
title: "GROUP_UPDATE_ERROR_TITLE".localized(),
|
||||||
|
message: error.localizedDescription
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.catch(on: DispatchQueue.main) { [weak self] error in
|
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
|
||||||
self?.showError(title: "GROUP_UPDATE_ERROR_TITLE".localized(), message: error.localizedDescription)
|
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
@ -208,15 +207,17 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: profile,
|
id: profile,
|
||||||
leftAccessory: .profile(profile.id, profile),
|
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count),
|
||||||
|
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||||
title: profile.displayName(),
|
title: profile.displayName(),
|
||||||
rightAccessory: .radio(isSelected: { [weak self] in
|
rightAccessory: .radio(isSelected: { [weak self] in
|
||||||
self?.selectedContacts.contains(profile.id) == true
|
self?.selectedContacts.contains(profile.id) == true
|
||||||
}),
|
}),
|
||||||
accessibilityIdentifier: "Contact"
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||||
),
|
accessibility: Accessibility(
|
||||||
style: .edgeToEdge,
|
identifier: "Contact"
|
||||||
position: Position.with(indexPath.row, count: data[indexPath.section].elements.count)
|
)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
@ -319,7 +320,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
||||||
else {
|
else {
|
||||||
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
return showError(title: "vc_create_closed_group_group_name_missing_error".localized())
|
||||||
}
|
}
|
||||||
guard name.count < 30 else {
|
guard name.utf8CString.count < SessionUtil.libSessionMaxGroupNameByteLength else {
|
||||||
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
return showError(title: "vc_create_closed_group_group_name_too_long_error".localized())
|
||||||
}
|
}
|
||||||
guard selectedContacts.count >= 1 else {
|
guard selectedContacts.count >= 1 else {
|
||||||
|
@ -331,19 +332,15 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
||||||
let selectedContacts = self.selectedContacts
|
let selectedContacts = self.selectedContacts
|
||||||
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
|
let message: String? = (selectedContacts.count > 20 ? "GROUP_CREATION_PLEASE_WAIT".localized() : nil)
|
||||||
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
ModalActivityIndicatorViewController.present(fromViewController: navigationController!, message: message) { [weak self] _ in
|
||||||
Storage.shared
|
MessageSender
|
||||||
.writeAsync { db in
|
.createClosedGroup(name: name, members: selectedContacts)
|
||||||
try MessageSender.createClosedGroup(db, name: name, members: selectedContacts)
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
}
|
.receive(on: DispatchQueue.main)
|
||||||
.done(on: DispatchQueue.main) { thread in
|
.sinkUntilComplete(
|
||||||
Storage.shared.writeAsync { db in
|
receiveCompletion: { result in
|
||||||
try? MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
switch result {
|
||||||
}
|
case .finished: break
|
||||||
|
case .failure:
|
||||||
self?.presentingViewController?.dismiss(animated: true, completion: nil)
|
|
||||||
SessionApp.presentConversation(for: thread.id, action: .compose, animated: false)
|
|
||||||
}
|
|
||||||
.catch(on: DispatchQueue.main) { [weak self] _ in
|
|
||||||
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
self?.dismiss(animated: true, completion: nil) // Dismiss the loader
|
||||||
|
|
||||||
let modal: ConfirmationModal = ConfirmationModal(
|
let modal: ConfirmationModal = ConfirmationModal(
|
||||||
|
@ -357,7 +354,16 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate
|
||||||
)
|
)
|
||||||
self?.present(modal, animated: true)
|
self?.present(modal, animated: true)
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
},
|
||||||
|
receiveValue: { thread in
|
||||||
|
SessionApp.presentConversationCreatingIfNeeded(
|
||||||
|
for: thread.id,
|
||||||
|
variant: thread.variant,
|
||||||
|
dismissing: self?.presentingViewController,
|
||||||
|
animated: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -132,11 +132,22 @@ extension ContextMenuVC {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func viewModelCanReply(_ cellViewModel: MessageViewModel) -> Bool {
|
||||||
|
return (
|
||||||
|
cellViewModel.variant == .standardIncoming || (
|
||||||
|
cellViewModel.variant == .standardOutgoing &&
|
||||||
|
cellViewModel.state != .failed &&
|
||||||
|
cellViewModel.state != .sending
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
static func actions(
|
static func actions(
|
||||||
for cellViewModel: MessageViewModel,
|
for cellViewModel: MessageViewModel,
|
||||||
recentEmojis: [EmojiWithSkinTones],
|
recentEmojis: [EmojiWithSkinTones],
|
||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
currentUserBlindedPublicKey: String?,
|
currentUserBlinded15PublicKey: String?,
|
||||||
|
currentUserBlinded25PublicKey: String?,
|
||||||
currentUserIsOpenGroupModerator: Bool,
|
currentUserIsOpenGroupModerator: Bool,
|
||||||
currentThreadIsMessageRequest: Bool,
|
currentThreadIsMessageRequest: Bool,
|
||||||
delegate: ContextMenuActionDelegate?
|
delegate: ContextMenuActionDelegate?
|
||||||
|
@ -161,12 +172,6 @@ extension ContextMenuVC {
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
let canReply: Bool = (
|
|
||||||
cellViewModel.variant != .standardOutgoing || (
|
|
||||||
cellViewModel.state != .failed &&
|
|
||||||
cellViewModel.state != .sending
|
|
||||||
)
|
|
||||||
)
|
|
||||||
let canCopy: Bool = (
|
let canCopy: Bool = (
|
||||||
cellViewModel.cellType == .textOnlyMessage || (
|
cellViewModel.cellType == .textOnlyMessage || (
|
||||||
(
|
(
|
||||||
|
@ -194,23 +199,27 @@ extension ContextMenuVC {
|
||||||
)
|
)
|
||||||
let canCopySessionId: Bool = (
|
let canCopySessionId: Bool = (
|
||||||
cellViewModel.variant == .standardIncoming &&
|
cellViewModel.variant == .standardIncoming &&
|
||||||
cellViewModel.threadVariant != .openGroup
|
cellViewModel.threadVariant != .community
|
||||||
)
|
)
|
||||||
let canDelete: Bool = (
|
let canDelete: Bool = (
|
||||||
cellViewModel.threadVariant != .openGroup ||
|
cellViewModel.threadVariant != .community ||
|
||||||
currentUserIsOpenGroupModerator ||
|
currentUserIsOpenGroupModerator ||
|
||||||
cellViewModel.authorId == currentUserPublicKey ||
|
cellViewModel.authorId == currentUserPublicKey ||
|
||||||
cellViewModel.authorId == currentUserBlindedPublicKey ||
|
cellViewModel.authorId == currentUserBlinded15PublicKey ||
|
||||||
|
cellViewModel.authorId == currentUserBlinded25PublicKey ||
|
||||||
cellViewModel.state == .failed
|
cellViewModel.state == .failed
|
||||||
)
|
)
|
||||||
let canBan: Bool = (
|
let canBan: Bool = (
|
||||||
cellViewModel.threadVariant == .openGroup &&
|
cellViewModel.threadVariant == .community &&
|
||||||
currentUserIsOpenGroupModerator
|
currentUserIsOpenGroupModerator
|
||||||
)
|
)
|
||||||
|
|
||||||
let shouldShowEmojiActions: Bool = {
|
let shouldShowEmojiActions: Bool = {
|
||||||
if cellViewModel.threadVariant == .openGroup {
|
if cellViewModel.threadVariant == .community {
|
||||||
return OpenGroupManager.isOpenGroupSupport(.reactions, on: cellViewModel.threadOpenGroupServer)
|
return OpenGroupManager.doesOpenGroupSupport(
|
||||||
|
capability: .reactions,
|
||||||
|
on: cellViewModel.threadOpenGroupServer
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return !currentThreadIsMessageRequest
|
return !currentThreadIsMessageRequest
|
||||||
}()
|
}()
|
||||||
|
@ -219,7 +228,7 @@ extension ContextMenuVC {
|
||||||
|
|
||||||
let generatedActions: [Action] = [
|
let generatedActions: [Action] = [
|
||||||
(canRetry ? Action.retry(cellViewModel, delegate) : nil),
|
(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),
|
(canCopy ? Action.copy(cellViewModel, delegate) : nil),
|
||||||
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
(canSave ? Action.save(cellViewModel, delegate) : nil),
|
||||||
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
|
(canCopySessionId ? Action.copySessionID(cellViewModel, delegate) : nil),
|
||||||
|
|
|
@ -71,10 +71,12 @@ final class ContextMenuVC: UIViewController {
|
||||||
|
|
||||||
private lazy var fallbackTimestampLabel: UILabel = {
|
private lazy var fallbackTimestampLabel: UILabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: UILabel = UILabel()
|
||||||
|
result.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||||
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
result.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
result.text = cellViewModel.dateForUI.formattedForDisplay
|
result.text = cellViewModel.dateForUI.formattedForDisplay
|
||||||
result.themeTextColor = .textPrimary
|
result.themeTextColor = .textPrimary
|
||||||
result.alpha = 0
|
result.alpha = 0
|
||||||
|
result.numberOfLines = 2
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -189,10 +191,14 @@ final class ContextMenuVC: UIViewController {
|
||||||
fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight)
|
fallbackTimestampLabel.set(.height, to: ContextMenuVC.actionViewHeight)
|
||||||
|
|
||||||
if cellViewModel.variant == .standardOutgoing {
|
if cellViewModel.variant == .standardOutgoing {
|
||||||
|
fallbackTimestampLabel.textAlignment = .right
|
||||||
fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing)
|
fallbackTimestampLabel.pin(.right, to: .left, of: menuView, withInset: -Values.mediumSpacing)
|
||||||
|
fallbackTimestampLabel.pin(.left, to: .left, of: view, withInset: Values.mediumSpacing)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
fallbackTimestampLabel.textAlignment = .left
|
||||||
fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing)
|
fallbackTimestampLabel.pin(.left, to: .right, of: menuView, withInset: Values.mediumSpacing)
|
||||||
|
fallbackTimestampLabel.pin(.right, to: .right, of: view, withInset: -Values.mediumSpacing)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Constrains
|
// Constrains
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import UIKit
|
||||||
|
|
||||||
final class ContextMenuWindow : UIWindow {
|
final class ContextMenuWindow : UIWindow {
|
||||||
|
|
||||||
|
|
|
@ -3,6 +3,8 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
public class StyledSearchController: UISearchController {
|
public class StyledSearchController: UISearchController {
|
||||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
@ -83,7 +85,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
|
||||||
let threadId: String = self.threadId
|
let threadId: String = self.threadId
|
||||||
|
|
||||||
DispatchQueue.global(qos: .default).async { [weak self] in
|
DispatchQueue.global(qos: .default).async { [weak self] in
|
||||||
let results: [Int64]? = Storage.shared.read { db -> [Int64] in
|
let results: [Interaction.TimestampInfo]? = Storage.shared.read { db -> [Interaction.TimestampInfo] in
|
||||||
self?.resultsBar.willStartSearching(readConnection: db)
|
self?.resultsBar.willStartSearching(readConnection: db)
|
||||||
|
|
||||||
return try Interaction.idsForTermWithin(
|
return try Interaction.idsForTermWithin(
|
||||||
|
@ -96,7 +98,7 @@ extension ConversationSearchController: UISearchResultsUpdating {
|
||||||
// If we didn't get results back then we most likely interrupted the query so
|
// If we didn't get results back then we most likely interrupted the query so
|
||||||
// should ignore the results (if there are no results we would succeed and get
|
// should ignore the results (if there are no results we would succeed and get
|
||||||
// an empty array back)
|
// an empty array back)
|
||||||
guard let results: [Int64] = results else { return }
|
guard let results: [Interaction.TimestampInfo] = results else { return }
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
guard let strongSelf = self else { return }
|
guard let strongSelf = self else { return }
|
||||||
|
@ -115,11 +117,11 @@ extension ConversationSearchController: SearchResultsBarDelegate {
|
||||||
func searchResultsBar(
|
func searchResultsBar(
|
||||||
_ searchResultsBar: SearchResultsBar,
|
_ searchResultsBar: SearchResultsBar,
|
||||||
setCurrentIndex currentIndex: Int,
|
setCurrentIndex currentIndex: Int,
|
||||||
results: [Int64]
|
results: [Interaction.TimestampInfo]
|
||||||
) {
|
) {
|
||||||
guard let interactionId: Int64 = results[safe: currentIndex] else { return }
|
guard let interactionInfo: Interaction.TimestampInfo = results[safe: currentIndex] else { return }
|
||||||
|
|
||||||
self.delegate?.conversationSearchController(self, didSelectInteractionId: interactionId)
|
self.delegate?.conversationSearchController(self, didSelectInteractionInfo: interactionInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,13 +129,13 @@ protocol SearchResultsBarDelegate: AnyObject {
|
||||||
func searchResultsBar(
|
func searchResultsBar(
|
||||||
_ searchResultsBar: SearchResultsBar,
|
_ searchResultsBar: SearchResultsBar,
|
||||||
setCurrentIndex currentIndex: Int,
|
setCurrentIndex currentIndex: Int,
|
||||||
results: [Int64]
|
results: [Interaction.TimestampInfo]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public final class SearchResultsBar: UIView {
|
public final class SearchResultsBar: UIView {
|
||||||
private var readConnection: Atomic<Database?> = Atomic(nil)
|
private var readConnection: Atomic<Database?> = Atomic(nil)
|
||||||
private var results: Atomic<[Int64]?> = Atomic(nil)
|
private var results: Atomic<[Interaction.TimestampInfo]?> = Atomic(nil)
|
||||||
|
|
||||||
var currentIndex: Int?
|
var currentIndex: Int?
|
||||||
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
weak var resultsBarDelegate: SearchResultsBarDelegate?
|
||||||
|
@ -248,7 +250,7 @@ public final class SearchResultsBar: UIView {
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
@objc public func handleUpButtonTapped() {
|
@objc public func handleUpButtonTapped() {
|
||||||
guard let results: [Int64] = results.wrappedValue else { return }
|
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
|
||||||
guard let currentIndex: Int = currentIndex else { return }
|
guard let currentIndex: Int = currentIndex else { return }
|
||||||
guard currentIndex + 1 < results.count else { return }
|
guard currentIndex + 1 < results.count else { return }
|
||||||
|
|
||||||
|
@ -260,7 +262,7 @@ public final class SearchResultsBar: UIView {
|
||||||
|
|
||||||
@objc public func handleDownButtonTapped() {
|
@objc public func handleDownButtonTapped() {
|
||||||
Logger.debug("")
|
Logger.debug("")
|
||||||
guard let results: [Int64] = results.wrappedValue else { return }
|
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else { return }
|
||||||
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
|
guard let currentIndex: Int = currentIndex, currentIndex > 0 else { return }
|
||||||
|
|
||||||
let newIndex = currentIndex - 1
|
let newIndex = currentIndex - 1
|
||||||
|
@ -287,12 +289,12 @@ public final class SearchResultsBar: UIView {
|
||||||
self.readConnection.mutate { $0 = readConnection }
|
self.readConnection.mutate { $0 = readConnection }
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateResults(results: [Int64]?) {
|
func updateResults(results: [Interaction.TimestampInfo]?) {
|
||||||
// We want to ignore search results that don't match the current searchId (this
|
// We want to ignore search results that don't match the current searchId (this
|
||||||
// will happen when searching large threads with short terms as the shorter terms
|
// will happen when searching large threads with short terms as the shorter terms
|
||||||
// will take much longer to resolve than the longer terms)
|
// will take much longer to resolve than the longer terms)
|
||||||
currentIndex = {
|
currentIndex = {
|
||||||
guard let results: [Int64] = results, !results.isEmpty else { return nil }
|
guard let results: [Interaction.TimestampInfo] = results, !results.isEmpty else { return nil }
|
||||||
|
|
||||||
if let currentIndex: Int = currentIndex {
|
if let currentIndex: Int = currentIndex {
|
||||||
return max(0, min(currentIndex, results.count - 1))
|
return max(0, min(currentIndex, results.count - 1))
|
||||||
|
@ -312,10 +314,11 @@ public final class SearchResultsBar: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateBarItems() {
|
func updateBarItems() {
|
||||||
guard let results: [Int64] = results.wrappedValue else {
|
guard let results: [Interaction.TimestampInfo] = results.wrappedValue else {
|
||||||
label.text = ""
|
label.text = ""
|
||||||
downButton.isEnabled = false
|
downButton.isEnabled = false
|
||||||
upButton.isEnabled = false
|
upButton.isEnabled = false
|
||||||
|
stopLoading()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -362,6 +365,6 @@ public final class SearchResultsBar: UIView {
|
||||||
// MARK: - ConversationSearchControllerDelegate
|
// MARK: - ConversationSearchControllerDelegate
|
||||||
|
|
||||||
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
public protocol ConversationSearchControllerDelegate: UISearchControllerDelegate {
|
||||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Int64]?, searchText: String?)
|
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didUpdateSearchResults results: [Interaction.TimestampInfo]?, searchText: String?)
|
||||||
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionId: Int64)
|
func conversationSearchController(_ conversationSearchController: ConversationSearchController, didSelectInteractionInfo: Interaction.TimestampInfo)
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
@ -9,6 +10,13 @@ import SessionUtilitiesKit
|
||||||
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
public typealias SectionModel = ArraySection<Section, MessageViewModel>
|
||||||
|
|
||||||
|
// MARK: - FocusBehaviour
|
||||||
|
|
||||||
|
public enum FocusBehaviour {
|
||||||
|
case none
|
||||||
|
case highlight
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Action
|
// MARK: - Action
|
||||||
|
|
||||||
public enum Action {
|
public enum Action {
|
||||||
|
@ -34,7 +42,11 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
public let initialThreadVariant: SessionThread.Variant
|
public let initialThreadVariant: SessionThread.Variant
|
||||||
public var sentMessageBeforeUpdate: Bool = false
|
public var sentMessageBeforeUpdate: Bool = false
|
||||||
public var lastSearchedText: String?
|
public var lastSearchedText: String?
|
||||||
public let focusedInteractionId: Int64? // Note: This is used for global search
|
public let focusedInteractionInfo: Interaction.TimestampInfo? // Note: This is used for global search
|
||||||
|
public let focusBehaviour: FocusBehaviour
|
||||||
|
private let initialUnreadInteractionId: Int64?
|
||||||
|
private let markAsReadTrigger: PassthroughSubject<(SessionThreadViewModel.ReadTarget, Int64?), Never> = PassthroughSubject()
|
||||||
|
private var markAsReadPublisher: AnyPublisher<Void, Never>?
|
||||||
|
|
||||||
public lazy var blockedBannerMessage: String = {
|
public lazy var blockedBannerMessage: String = {
|
||||||
switch self.threadData.threadVariant {
|
switch self.threadData.threadVariant {
|
||||||
|
@ -52,66 +64,93 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) {
|
init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo?) {
|
||||||
typealias InitialData = (
|
typealias InitialData = (
|
||||||
targetInteractionId: Int64?,
|
currentUserPublicKey: String,
|
||||||
|
initialUnreadInteractionInfo: Interaction.TimestampInfo?,
|
||||||
|
threadIsBlocked: Bool,
|
||||||
currentUserIsClosedGroupMember: Bool?,
|
currentUserIsClosedGroupMember: Bool?,
|
||||||
openGroupPermissions: OpenGroup.Permissions?,
|
openGroupPermissions: OpenGroup.Permissions?,
|
||||||
blindedKey: String?
|
blinded15Key: String?,
|
||||||
|
blinded25Key: String?
|
||||||
)
|
)
|
||||||
|
|
||||||
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
let initialData: InitialData? = Storage.shared.read { db -> InitialData in
|
||||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||||
|
let currentUserPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
|
|
||||||
// If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest
|
// If we have a specified 'focusedInteractionInfo' then use that, otherwise retrieve the oldest
|
||||||
// unread interaction and start focused around that one
|
// unread interaction and start focused around that one
|
||||||
let targetInteractionId: Int64? = (focusedInteractionId != nil ? focusedInteractionId :
|
let initialUnreadInteractionInfo: Interaction.TimestampInfo? = try Interaction
|
||||||
try Interaction
|
.select(.id, .timestampMs)
|
||||||
.select(.id)
|
|
||||||
.filter(interaction[.wasRead] == false)
|
.filter(interaction[.wasRead] == false)
|
||||||
.filter(interaction[.threadId] == threadId)
|
.filter(interaction[.threadId] == threadId)
|
||||||
.order(interaction[.timestampMs].asc)
|
.order(interaction[.timestampMs].asc)
|
||||||
.asRequest(of: Int64.self)
|
.asRequest(of: Interaction.TimestampInfo.self)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
|
let threadIsBlocked: Bool = (threadVariant != .contact ? false :
|
||||||
|
try Contact
|
||||||
|
.filter(id: threadId)
|
||||||
|
.select(.isBlocked)
|
||||||
|
.asRequest(of: Bool.self)
|
||||||
|
.fetchOne(db)
|
||||||
|
.defaulting(to: false)
|
||||||
)
|
)
|
||||||
let currentUserIsClosedGroupMember: Bool? = (threadVariant != .closedGroup ? nil :
|
let currentUserIsClosedGroupMember: Bool? = (![.legacyGroup, .group].contains(threadVariant) ? nil :
|
||||||
try GroupMember
|
GroupMember
|
||||||
.filter(groupMember[.groupId] == threadId)
|
.filter(groupMember[.groupId] == threadId)
|
||||||
.filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db))
|
.filter(groupMember[.profileId] == currentUserPublicKey)
|
||||||
.filter(groupMember[.role] == GroupMember.Role.standard)
|
.filter(groupMember[.role] == GroupMember.Role.standard)
|
||||||
.isNotEmpty(db)
|
.isNotEmpty(db)
|
||||||
)
|
)
|
||||||
let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .openGroup ? nil :
|
let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .community ? nil :
|
||||||
try OpenGroup
|
try OpenGroup
|
||||||
.filter(id: threadId)
|
.filter(id: threadId)
|
||||||
.select(.permissions)
|
.select(.permissions)
|
||||||
.asRequest(of: OpenGroup.Permissions.self)
|
.asRequest(of: OpenGroup.Permissions.self)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
)
|
)
|
||||||
let blindedKey: String? = SessionThread.getUserHexEncodedBlindedKey(
|
let blinded15Key: String? = SessionThread.getUserHexEncodedBlindedKey(
|
||||||
db,
|
db,
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
threadVariant: threadVariant
|
threadVariant: threadVariant,
|
||||||
|
blindingPrefix: .blinded15
|
||||||
|
)
|
||||||
|
let blinded25Key: String? = SessionThread.getUserHexEncodedBlindedKey(
|
||||||
|
db,
|
||||||
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
blindingPrefix: .blinded25
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
targetInteractionId,
|
currentUserPublicKey,
|
||||||
|
initialUnreadInteractionInfo,
|
||||||
|
threadIsBlocked,
|
||||||
currentUserIsClosedGroupMember,
|
currentUserIsClosedGroupMember,
|
||||||
openGroupPermissions,
|
openGroupPermissions,
|
||||||
blindedKey
|
blinded15Key,
|
||||||
|
blinded25Key
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.threadId = threadId
|
self.threadId = threadId
|
||||||
self.initialThreadVariant = threadVariant
|
self.initialThreadVariant = threadVariant
|
||||||
self.focusedInteractionId = initialData?.targetInteractionId
|
self.focusedInteractionInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo)
|
||||||
|
self.focusBehaviour = (focusedInteractionInfo == nil ? .none : .highlight)
|
||||||
|
self.initialUnreadInteractionId = initialData?.initialUnreadInteractionInfo?.id
|
||||||
self.threadData = SessionThreadViewModel(
|
self.threadData = SessionThreadViewModel(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
|
threadIsNoteToSelf: (initialData?.currentUserPublicKey == threadId),
|
||||||
|
threadIsBlocked: initialData?.threadIsBlocked,
|
||||||
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember,
|
||||||
openGroupPermissions: initialData?.openGroupPermissions
|
openGroupPermissions: initialData?.openGroupPermissions
|
||||||
).populatingCurrentUserBlindedKey(currentUserBlindedPublicKeyForThisThread: initialData?.blindedKey)
|
).populatingCurrentUserBlindedKeys(
|
||||||
|
currentUserBlinded15PublicKeyForThisThread: initialData?.blinded15Key,
|
||||||
|
currentUserBlinded25PublicKeyForThisThread: initialData?.blinded25Key
|
||||||
|
)
|
||||||
self.pagedDataObserver = nil
|
self.pagedDataObserver = nil
|
||||||
|
|
||||||
// Note: Since this references self we need to finish initializing before setting it, we
|
// Note: Since this references self we need to finish initializing before setting it, we
|
||||||
|
@ -120,23 +159,21 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
// distinct stutter)
|
// distinct stutter)
|
||||||
self.pagedDataObserver = self.setupPagedObserver(
|
self.pagedDataObserver = self.setupPagedObserver(
|
||||||
for: threadId,
|
for: threadId,
|
||||||
userPublicKey: getUserHexEncodedPublicKey(),
|
userPublicKey: (initialData?.currentUserPublicKey ?? getUserHexEncodedPublicKey()),
|
||||||
blindedPublicKey: SessionThread.getUserHexEncodedBlindedKey(
|
blinded15PublicKey: initialData?.blinded15Key,
|
||||||
threadId: threadId,
|
blinded25PublicKey: initialData?.blinded25Key
|
||||||
threadVariant: threadVariant
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run the initial query on a background thread so we don't block the push transition
|
// Run the initial query on a background thread so we don't block the push transition
|
||||||
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
// If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query
|
// If we don't have a `initialFocusedInfo` then default to `.pageBefore` (it'll query
|
||||||
// from a `0` offset)
|
// from a `0` offset)
|
||||||
guard let initialFocusedId: Int64 = initialData?.targetInteractionId else {
|
guard let initialFocusedInfo: Interaction.TimestampInfo = (focusedInteractionInfo ?? initialData?.initialUnreadInteractionInfo) else {
|
||||||
self?.pagedDataObserver?.load(.pageBefore)
|
self?.pagedDataObserver?.load(.pageBefore)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedId))
|
self?.pagedDataObserver?.load(.initialPageAround(id: initialFocusedInfo.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,9 +192,10 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
return ValueObservation
|
||||||
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
|
.trackingConstantRegion { [weak self] db -> SessionThreadViewModel? in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
|
@ -169,13 +207,15 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
return threadViewModel
|
return threadViewModel
|
||||||
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
.map { $0.with(recentReactionEmoji: recentReactionEmoji) }
|
||||||
.map { viewModel -> SessionThreadViewModel in
|
.map { viewModel -> SessionThreadViewModel in
|
||||||
viewModel.populatingCurrentUserBlindedKey(
|
viewModel.populatingCurrentUserBlindedKeys(
|
||||||
db,
|
db,
|
||||||
currentUserBlindedPublicKeyForThisThread: self?.threadData.currentUserBlindedPublicKey
|
currentUserBlinded15PublicKeyForThisThread: self?.threadData.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKeyForThisThread: self?.threadData.currentUserBlinded25PublicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
|
.handleEvents(didFail: { SNLog("[ConversationViewModel] Observation failed with error: \($0)") })
|
||||||
}
|
}
|
||||||
|
|
||||||
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
public func updateThreadData(_ updatedData: SessionThreadViewModel) {
|
||||||
|
@ -184,7 +224,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
|
|
||||||
// MARK: - Interaction Data
|
// MARK: - Interaction Data
|
||||||
|
|
||||||
private var lastInteractionIdMarkedAsRead: Int64?
|
private var lastInteractionIdMarkedAsRead: Int64? = nil
|
||||||
|
private var lastInteractionTimestampMsMarkedAsRead: Int64 = 0
|
||||||
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
public private(set) var unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||||
public private(set) var interactionData: [SectionModel] = []
|
public private(set) var interactionData: [SectionModel] = []
|
||||||
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
public private(set) var reactionExpandedInteractionIds: Set<Int64> = []
|
||||||
|
@ -194,14 +235,25 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// 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
|
// data was changed while we weren't observing
|
||||||
if let unobservedInteractionDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedInteractionDataChanges {
|
||||||
onInteractionChange?(unobservedInteractionDataChanges.0, unobservedInteractionDataChanges.1)
|
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
|
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(
|
return PagedDatabaseObserver(
|
||||||
pagedTable: Interaction.self,
|
pagedTable: Interaction.self,
|
||||||
pageSize: ConversationViewModel.pageSize,
|
pageSize: ConversationViewModel.pageSize,
|
||||||
|
@ -220,7 +272,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -230,7 +282,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -249,7 +301,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
orderSQL: MessageViewModel.orderSQL,
|
orderSQL: MessageViewModel.orderSQL,
|
||||||
dataQuery: MessageViewModel.baseQuery(
|
dataQuery: MessageViewModel.baseQuery(
|
||||||
userPublicKey: userPublicKey,
|
userPublicKey: userPublicKey,
|
||||||
blindedPublicKey: blindedPublicKey,
|
blinded15PublicKey: blinded15PublicKey,
|
||||||
|
blinded25PublicKey: blinded25PublicKey,
|
||||||
orderSQL: MessageViewModel.orderSQL,
|
orderSQL: MessageViewModel.orderSQL,
|
||||||
groupSQL: MessageViewModel.groupSQL
|
groupSQL: MessageViewModel.groupSQL
|
||||||
),
|
),
|
||||||
|
@ -293,22 +346,39 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in
|
||||||
|
self?.resolveOptimisticUpdates(with: updatedData)
|
||||||
|
|
||||||
PagedData.processAndTriggerUpdates(
|
PagedData.processAndTriggerUpdates(
|
||||||
updatedData: self?.process(data: updatedData, for: updatedPageInfo),
|
updatedData: self?.process(
|
||||||
|
data: updatedData,
|
||||||
|
for: updatedPageInfo,
|
||||||
|
optimisticMessages: (self?.optimisticallyInsertedMessages.wrappedValue.values)
|
||||||
|
.map { Array($0) },
|
||||||
|
initialUnreadInteractionId: self?.initialUnreadInteractionId
|
||||||
|
),
|
||||||
currentDataRetriever: { self?.interactionData },
|
currentDataRetriever: { self?.interactionData },
|
||||||
onDataChange: self?.onInteractionChange,
|
onDataChange: self?.onInteractionChange,
|
||||||
onUnobservedDataChange: { updatedData, changeset in
|
onUnobservedDataChange: { updatedData, changeset in
|
||||||
self?.unobservedInteractionDataChanges = (updatedData, changeset)
|
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
|
||||||
|
nil :
|
||||||
|
(updatedData, changeset)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func process(data: [MessageViewModel], for pageInfo: PagedData.PageInfo) -> [SectionModel] {
|
private func process(
|
||||||
|
data: [MessageViewModel],
|
||||||
|
for pageInfo: PagedData.PageInfo,
|
||||||
|
optimisticMessages: [MessageViewModel]?,
|
||||||
|
initialUnreadInteractionId: Int64?
|
||||||
|
) -> [SectionModel] {
|
||||||
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
let typingIndicator: MessageViewModel? = data.first(where: { $0.isTypingIndicator == true })
|
||||||
let sortedData: [MessageViewModel] = data
|
let sortedData: [MessageViewModel] = data
|
||||||
.filter { $0.isTypingIndicator != true }
|
.appending(contentsOf: (optimisticMessages ?? []))
|
||||||
|
.filter { !$0.cellType.isPostProcessed }
|
||||||
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
.sorted { lhs, rhs -> Bool in lhs.timestampMs < rhs.timestampMs }
|
||||||
|
|
||||||
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
// We load messages from newest to oldest so having a pageOffset larger than zero means
|
||||||
|
@ -338,20 +408,31 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
cellViewModel.id == sortedData
|
cellViewModel.id == sortedData
|
||||||
.filter {
|
.filter {
|
||||||
$0.authorId == threadData.currentUserPublicKey ||
|
$0.authorId == threadData.currentUserPublicKey ||
|
||||||
$0.authorId == threadData.currentUserBlindedPublicKey
|
$0.authorId == threadData.currentUserBlinded15PublicKey ||
|
||||||
|
$0.authorId == threadData.currentUserBlinded25PublicKey
|
||||||
}
|
}
|
||||||
.last?
|
.last?
|
||||||
.id
|
.id
|
||||||
),
|
),
|
||||||
currentUserBlindedPublicKey: threadData.currentUserBlindedPublicKey
|
currentUserBlinded15PublicKey: threadData.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey: threadData.currentUserBlinded25PublicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.reduce([]) { result, message in
|
.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 {
|
guard message.shouldShowDateHeader else {
|
||||||
return result.appending(message)
|
return updatedResult.appending(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return updatedResult
|
||||||
.appending(
|
.appending(
|
||||||
MessageViewModel(
|
MessageViewModel(
|
||||||
timestampMs: message.timestampMs,
|
timestampMs: message.timestampMs,
|
||||||
|
@ -374,12 +455,152 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
self.interactionData = updatedData
|
self.interactionData = updatedData
|
||||||
}
|
}
|
||||||
|
|
||||||
public func expandReactions(for interactionId: Int64) {
|
// MARK: - Optimistic Message Handling
|
||||||
reactionExpandedInteractionIds.insert(interactionId)
|
|
||||||
|
public typealias OptimisticMessageData = (
|
||||||
|
id: UUID,
|
||||||
|
interaction: Interaction,
|
||||||
|
attachmentData: Attachment.PreparedData?,
|
||||||
|
linkPreviewAttachment: Attachment?
|
||||||
|
)
|
||||||
|
|
||||||
|
private var optimisticallyInsertedMessages: Atomic<[UUID: MessageViewModel]> = Atomic([:])
|
||||||
|
private var optimisticMessageAssociatedInteractionIds: Atomic<[Int64: UUID]> = Atomic([:])
|
||||||
|
|
||||||
|
public func optimisticallyAppendOutgoingMessage(
|
||||||
|
text: String?,
|
||||||
|
sentTimestampMs: Int64,
|
||||||
|
attachments: [SignalAttachment]?,
|
||||||
|
linkPreviewDraft: LinkPreviewDraft?,
|
||||||
|
quoteModel: QuotedReplyModel?
|
||||||
|
) -> OptimisticMessageData {
|
||||||
|
// Generate the optimistic data
|
||||||
|
let optimisticMessageId: UUID = UUID()
|
||||||
|
let currentUserProfile: Profile = Profile.fetchOrCreateCurrentUser()
|
||||||
|
let interaction: Interaction = Interaction(
|
||||||
|
threadId: threadData.threadId,
|
||||||
|
authorId: (threadData.currentUserBlinded15PublicKey ?? threadData.currentUserPublicKey),
|
||||||
|
variant: .standardOutgoing,
|
||||||
|
body: text,
|
||||||
|
timestampMs: sentTimestampMs,
|
||||||
|
hasMention: Interaction.isUserMentioned(
|
||||||
|
publicKeysToCheck: [
|
||||||
|
threadData.currentUserPublicKey,
|
||||||
|
threadData.currentUserBlinded15PublicKey,
|
||||||
|
threadData.currentUserBlinded25PublicKey
|
||||||
|
].compactMap { $0 },
|
||||||
|
body: text
|
||||||
|
),
|
||||||
|
expiresInSeconds: threadData.disappearingMessagesConfiguration
|
||||||
|
.map { disappearingConfig in
|
||||||
|
guard disappearingConfig.isEnabled else { return nil }
|
||||||
|
|
||||||
|
return disappearingConfig.durationSeconds
|
||||||
|
},
|
||||||
|
linkPreviewUrl: linkPreviewDraft?.urlString
|
||||||
|
)
|
||||||
|
let optimisticAttachments: Attachment.PreparedData? = attachments
|
||||||
|
.map { Attachment.prepare(attachments: $0) }
|
||||||
|
let linkPreviewAttachment: Attachment? = linkPreviewDraft.map { draft in
|
||||||
|
try? LinkPreview.generateAttachmentIfPossible(
|
||||||
|
imageData: draft.jpegImageData,
|
||||||
|
mimeType: OWSMimeTypeImageJpeg
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let optimisticData: OptimisticMessageData = (
|
||||||
|
optimisticMessageId,
|
||||||
|
interaction,
|
||||||
|
optimisticAttachments,
|
||||||
|
linkPreviewAttachment
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate the actual 'MessageViewModel'
|
||||||
|
let messageViewModel: MessageViewModel = MessageViewModel(
|
||||||
|
threadId: threadData.threadId,
|
||||||
|
threadVariant: threadData.threadVariant,
|
||||||
|
threadHasDisappearingMessagesEnabled: (threadData.disappearingMessagesConfiguration?.isEnabled ?? false),
|
||||||
|
threadOpenGroupServer: threadData.openGroupServer,
|
||||||
|
threadOpenGroupPublicKey: threadData.openGroupPublicKey,
|
||||||
|
threadContactNameInternal: threadData.threadContactName(),
|
||||||
|
timestampMs: interaction.timestampMs,
|
||||||
|
receivedAtTimestampMs: interaction.receivedAtTimestampMs,
|
||||||
|
authorId: interaction.authorId,
|
||||||
|
authorNameInternal: currentUserProfile.displayName(),
|
||||||
|
body: interaction.body,
|
||||||
|
expiresStartedAtMs: interaction.expiresStartedAtMs,
|
||||||
|
expiresInSeconds: interaction.expiresInSeconds,
|
||||||
|
isSenderOpenGroupModerator: OpenGroupManager.isUserModeratorOrAdmin(
|
||||||
|
threadData.currentUserPublicKey,
|
||||||
|
for: threadData.openGroupRoomToken,
|
||||||
|
on: threadData.openGroupServer
|
||||||
|
),
|
||||||
|
currentUserProfile: currentUserProfile,
|
||||||
|
quote: quoteModel.map { model in
|
||||||
|
// Don't care about this optimistic quote (the proper one will be generated in the database)
|
||||||
|
Quote(
|
||||||
|
interactionId: -1, // Can't save to db optimistically
|
||||||
|
authorId: model.authorId,
|
||||||
|
timestampMs: model.timestampMs,
|
||||||
|
body: model.body,
|
||||||
|
attachmentId: model.attachment?.id
|
||||||
|
)
|
||||||
|
},
|
||||||
|
quoteAttachment: quoteModel?.attachment,
|
||||||
|
linkPreview: linkPreviewDraft.map { draft in
|
||||||
|
LinkPreview(
|
||||||
|
url: draft.urlString,
|
||||||
|
title: draft.title,
|
||||||
|
attachmentId: nil // Can't save to db optimistically
|
||||||
|
)
|
||||||
|
},
|
||||||
|
linkPreviewAttachment: linkPreviewAttachment,
|
||||||
|
attachments: optimisticAttachments?.attachments
|
||||||
|
)
|
||||||
|
|
||||||
|
optimisticallyInsertedMessages.mutate { $0[optimisticMessageId] = messageViewModel }
|
||||||
|
|
||||||
|
// If we can't get the current page data then don't bother trying to update (it's not going to work)
|
||||||
|
guard let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue else {
|
||||||
|
return optimisticData
|
||||||
}
|
}
|
||||||
|
|
||||||
public func collapseReactions(for interactionId: Int64) {
|
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||||
reactionExpandedInteractionIds.remove(interactionId)
|
let currentData: [SectionModel] = (unobservedInteractionDataChanges?.0 ?? interactionData)
|
||||||
|
|
||||||
|
PagedData.processAndTriggerUpdates(
|
||||||
|
updatedData: process(
|
||||||
|
data: (currentData.first(where: { $0.model == .messages })?.elements ?? []),
|
||||||
|
for: currentPageInfo,
|
||||||
|
optimisticMessages: Array(optimisticallyInsertedMessages.wrappedValue.values),
|
||||||
|
initialUnreadInteractionId: initialUnreadInteractionId
|
||||||
|
),
|
||||||
|
currentDataRetriever: { [weak self] in self?.interactionData },
|
||||||
|
onDataChange: self.onInteractionChange,
|
||||||
|
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||||
|
self?.unobservedInteractionDataChanges = (changeset.isEmpty ?
|
||||||
|
nil :
|
||||||
|
(updatedData, changeset)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return optimisticData
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an association between an `optimisticMessageId` and a specific `interactionId`
|
||||||
|
public func associate(optimisticMessageId: UUID, to interactionId: Int64?) {
|
||||||
|
guard let interactionId: Int64 = interactionId else { return }
|
||||||
|
|
||||||
|
optimisticMessageAssociatedInteractionIds.mutate { $0[interactionId] = optimisticMessageId }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove any optimisticUpdate entries which have an associated interactionId in the provided data
|
||||||
|
private func resolveOptimisticUpdates(with data: [MessageViewModel]) {
|
||||||
|
let interactionIds: [Int64] = data.map { $0.id }
|
||||||
|
let idsToRemove: [UUID] = optimisticMessageAssociatedInteractionIds
|
||||||
|
.mutate { associatedIds in interactionIds.compactMap { associatedIds.removeValue(forKey: $0) } }
|
||||||
|
|
||||||
|
optimisticallyInsertedMessages.mutate { messages in idsToRemove.forEach { messages.removeValue(forKey: $0) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Mentions
|
// MARK: - Mentions
|
||||||
|
@ -391,7 +612,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
.read { db -> [MentionInfo] in
|
.read { db -> [MentionInfo] in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
||||||
let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self)
|
let pattern: FTS5Pattern? = try? SessionThreadViewModel.pattern(db, searchTerm: query, forTable: Profile.self)
|
||||||
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .openGroup ?
|
let capabilities: Set<Capability.Variant> = (threadData.threadVariant != .community ?
|
||||||
nil :
|
nil :
|
||||||
try? Capability
|
try? Capability
|
||||||
.select(.variant)
|
.select(.variant)
|
||||||
|
@ -400,9 +621,9 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
.fetchSet(db)
|
.fetchSet(db)
|
||||||
)
|
)
|
||||||
.defaulting(to: [])
|
.defaulting(to: [])
|
||||||
let targetPrefix: SessionId.Prefix = (capabilities.contains(.blind) ?
|
let targetPrefixes: [SessionId.Prefix] = (capabilities.contains(.blind) ?
|
||||||
.blinded :
|
[.blinded15, .blinded25] :
|
||||||
.standard
|
[.standard]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (try MentionInfo
|
return (try MentionInfo
|
||||||
|
@ -410,7 +631,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
userPublicKey: userPublicKey,
|
userPublicKey: userPublicKey,
|
||||||
threadId: threadData.threadId,
|
threadId: threadData.threadId,
|
||||||
threadVariant: threadData.threadVariant,
|
threadVariant: threadData.threadVariant,
|
||||||
targetPrefix: targetPrefix,
|
targetPrefixes: targetPrefixes,
|
||||||
pattern: pattern
|
pattern: pattern
|
||||||
)?
|
)?
|
||||||
.fetchAll(db))
|
.fetchAll(db))
|
||||||
|
@ -443,37 +664,53 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This method will mark all interactions as read before the specified interaction id, if no id is provided then all interactions for
|
/// This method marks a thread as read and depending on the target may also update the interactions within a thread as read
|
||||||
/// the thread will be marked as read
|
public func markAsRead(
|
||||||
public func markAsRead(beforeInclusive interactionId: Int64?) {
|
target: SessionThreadViewModel.ReadTarget,
|
||||||
|
timestampMs: Int64?
|
||||||
|
) {
|
||||||
/// Since this method now gets triggered when scrolling we want to try to optimise it and avoid busying the database
|
/// 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:
|
/// write queue when it isn't needed, in order to do this we:
|
||||||
|
/// - Throttle the updates to 100ms (quick enough that users shouldn't notice, but will help the DB when the user flings the list)
|
||||||
|
/// - Only mark interactions as read if they have newer `timestampMs` or `id` values (ie. were sent later or were more-recent
|
||||||
|
/// entries in the database), **Note:** Old messages will be marked as read upon insertion so shouldn't be an issue
|
||||||
///
|
///
|
||||||
/// - Don't bother marking anything as read if there are no unread interactions (we can rely on the
|
/// The `ThreadViewModel.markAsRead` method also tries to avoid marking as read if a conversation is already fully read
|
||||||
/// `threadData.threadUnreadCount` to always be accurate)
|
if markAsReadPublisher == nil {
|
||||||
/// - Don't bother marking anything as read if this was called with the same `interactionId` that we
|
markAsReadPublisher = markAsReadTrigger
|
||||||
/// previously marked as read (ie. when scrolling and the last message hasn't changed)
|
.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
|
guard
|
||||||
(self.threadData.threadUnreadCount ?? 0) > 0,
|
timestampMs == nil ||
|
||||||
let targetInteractionId: Int64 = (interactionId ?? self.threadData.interactionId),
|
(self?.lastInteractionTimestampMsMarkedAsRead ?? 0) < (timestampMs ?? 0) ||
|
||||||
self.lastInteractionIdMarkedAsRead != targetInteractionId
|
(self?.lastInteractionIdMarkedAsRead ?? 0) < (interactionId ?? 0)
|
||||||
else { return }
|
else {
|
||||||
|
self?.threadData.markAsRead(target: .thread)
|
||||||
let threadId: String = self.threadData.threadId
|
return
|
||||||
let threadVariant: SessionThread.Variant = self.threadData.threadVariant
|
|
||||||
let trySendReadReceipt: Bool = (self.threadData.threadIsMessageRequest == false)
|
|
||||||
self.lastInteractionIdMarkedAsRead = targetInteractionId
|
|
||||||
|
|
||||||
Storage.shared.writeAsync { db in
|
|
||||||
try Interaction.markAsRead(
|
|
||||||
db,
|
|
||||||
interactionId: targetInteractionId,
|
|
||||||
threadId: threadId,
|
|
||||||
threadVariant: threadVariant,
|
|
||||||
includingOlder: true,
|
|
||||||
trySendReadReceipt: trySendReadReceipt
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
public func swapToThread(updatedThreadId: String) {
|
||||||
|
@ -489,7 +726,8 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
self.pagedDataObserver = self.setupPagedObserver(
|
self.pagedDataObserver = self.setupPagedObserver(
|
||||||
for: updatedThreadId,
|
for: updatedThreadId,
|
||||||
userPublicKey: getUserHexEncodedPublicKey(),
|
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
|
// Try load everything up to the initial visible message, fallback to just the initial page of messages
|
||||||
|
@ -539,14 +777,18 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
Storage.shared.writeAsync { db in
|
Storage.shared.writeAsync { db in
|
||||||
try Contact
|
try Contact
|
||||||
.filter(id: threadId)
|
.filter(id: threadId)
|
||||||
.updateAll(db, Contact.Columns.isBlocked.set(to: false))
|
.updateAllAndConfig(db, Contact.Columns.isBlocked.set(to: false))
|
||||||
|
|
||||||
try MessageSender
|
|
||||||
.syncConfiguration(db, forceSyncNow: true)
|
|
||||||
.retainUntilComplete()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public func expandReactions(for interactionId: Int64) {
|
||||||
|
reactionExpandedInteractionIds.insert(interactionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func collapseReactions(for interactionId: Int64) {
|
||||||
|
reactionExpandedInteractionIds.remove(interactionId)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Audio Playback
|
// MARK: - Audio Playback
|
||||||
|
|
||||||
public struct PlaybackInfo {
|
public struct PlaybackInfo {
|
||||||
|
@ -648,6 +890,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
// Then setup the state for the new audio
|
// Then setup the state for the new audio
|
||||||
currentPlayingInteraction.mutate { $0 = viewModel.id }
|
currentPlayingInteraction.mutate { $0 = viewModel.id }
|
||||||
|
|
||||||
|
let currentPlaybackTime: TimeInterval? = playbackInfo.wrappedValue[viewModel.id]?.progress
|
||||||
audioPlayer.mutate { [weak self] player in
|
audioPlayer.mutate { [weak self] player in
|
||||||
// Note: We clear the delegate and explicitly set to nil here as when the OWSAudioPlayer
|
// 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
|
// gets deallocated it triggers state changes which cause UI bugs when auto-playing
|
||||||
|
@ -660,7 +903,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate {
|
||||||
delegate: self
|
delegate: self
|
||||||
)
|
)
|
||||||
audioPlayer.play()
|
audioPlayer.play()
|
||||||
audioPlayer.setCurrentTime(playbackInfo.wrappedValue[viewModel.id]?.progress ?? 0)
|
audioPlayer.setCurrentTime(currentPlaybackTime ?? 0)
|
||||||
player = audioPlayer
|
player = audioPlayer
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
protocol EmojiPickerCollectionViewDelegate: AnyObject {
|
||||||
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones)
|
func emojiPicker(_ emojiPicker: EmojiPickerCollectionView?, didSelectEmoji emoji: EmojiWithSkinTones)
|
||||||
|
|
|
@ -177,9 +177,6 @@ class EmojiPickerSheet: BaseVC {
|
||||||
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
|
let curveValue: Int = ((userInfo[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int) ?? Int(UIView.AnimationOptions.curveEaseInOut.rawValue))
|
||||||
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
let options: UIView.AnimationOptions = UIView.AnimationOptions(rawValue: UInt(curveValue << 16))
|
||||||
|
|
||||||
let keyboardRect: CGRect = ((userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect) ?? CGRect.zero)
|
|
||||||
let keyboardTop = (UIScreen.main.bounds.height - keyboardRect.minY)
|
|
||||||
|
|
||||||
UIView.animate(
|
UIView.animate(
|
||||||
withDuration: duration,
|
withDuration: duration,
|
||||||
delay: 0,
|
delay: 0,
|
||||||
|
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
class EmojiSkinTonePicker: UIView {
|
class EmojiSkinTonePicker: UIView {
|
||||||
let emoji: Emoji
|
let emoji: Emoji
|
||||||
|
|
|
@ -50,7 +50,7 @@ public final class InputTextView: UITextView, UITextViewDelegate {
|
||||||
|
|
||||||
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
public override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||||
if action == #selector(paste(_:)) {
|
if action == #selector(paste(_:)) {
|
||||||
if let _ = UIPasteboard.general.image {
|
if UIPasteboard.general.hasImages {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
private static let linkPreviewViewInset: CGFloat = 6
|
private static let linkPreviewViewInset: CGFloat = 6
|
||||||
|
|
||||||
|
private var disposables: Set<AnyCancellable> = Set()
|
||||||
private let threadVariant: SessionThread.Variant
|
private let threadVariant: SessionThread.Variant
|
||||||
private weak var delegate: InputViewDelegate?
|
private weak var delegate: InputViewDelegate?
|
||||||
|
|
||||||
|
@ -89,7 +92,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
||||||
let result: UIView = UIView()
|
let result: UIView = UIView()
|
||||||
result.accessibilityLabel = "Mentions list"
|
result.accessibilityLabel = "Mentions list"
|
||||||
result.accessibilityIdentifier = "Mentions list"
|
result.accessibilityIdentifier = "Mentions list"
|
||||||
result.isAccessibilityElement = true
|
|
||||||
result.alpha = 0
|
result.alpha = 0
|
||||||
|
|
||||||
let backgroundView = UIView()
|
let backgroundView = UIView()
|
||||||
|
@ -263,7 +265,8 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
||||||
quotedText: quoteDraftInfo.model.body,
|
quotedText: quoteDraftInfo.model.body,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
currentUserPublicKey: quoteDraftInfo.model.currentUserPublicKey,
|
currentUserPublicKey: quoteDraftInfo.model.currentUserPublicKey,
|
||||||
currentUserBlindedPublicKey: quoteDraftInfo.model.currentUserBlindedPublicKey,
|
currentUserBlinded15PublicKey: quoteDraftInfo.model.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey: quoteDraftInfo.model.currentUserBlinded25PublicKey,
|
||||||
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
|
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
|
||||||
attachment: quoteDraftInfo.model.attachment,
|
attachment: quoteDraftInfo.model.attachment,
|
||||||
hInset: hInset,
|
hInset: hInset,
|
||||||
|
@ -330,19 +333,27 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
||||||
|
|
||||||
// Build the link preview
|
// Build the link preview
|
||||||
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
|
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
|
||||||
.done { [weak self] draft in
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink(
|
||||||
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
receiveCompletion: { [weak self] result in
|
||||||
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
|
switch result {
|
||||||
}
|
case .finished: break
|
||||||
.catch { [weak self] _ in
|
case .failure:
|
||||||
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||||
|
|
||||||
self?.linkPreviewInfo = nil
|
self?.linkPreviewInfo = nil
|
||||||
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
},
|
||||||
|
receiveValue: { [weak self] draft in
|
||||||
|
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
|
||||||
|
|
||||||
|
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
|
||||||
|
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &disposables)
|
||||||
}
|
}
|
||||||
|
|
||||||
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
|
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
|
||||||
|
@ -491,7 +502,7 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M
|
||||||
func showMentionsUI(for candidates: [MentionInfo]) {
|
func showMentionsUI(for candidates: [MentionInfo]) {
|
||||||
mentionsView.candidates = candidates
|
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
|
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
|
||||||
layoutIfNeeded()
|
layoutIfNeeded()
|
||||||
|
|
||||||
|
|
|
@ -92,6 +92,11 @@ final class MentionSelectionView: UIView, UITableViewDataSource, UITableViewDele
|
||||||
),
|
),
|
||||||
isLast: (indexPath.row == (candidates.count - 1))
|
isLast: (indexPath.row == (candidates.count - 1))
|
||||||
)
|
)
|
||||||
|
cell.accessibilityIdentifier = "Contact"
|
||||||
|
cell.accessibilityLabel = candidates[indexPath.row].profile.displayName(
|
||||||
|
for: candidates[indexPath.row].threadVariant
|
||||||
|
)
|
||||||
|
cell.isAccessibilityElement = true
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
|
@ -111,9 +116,7 @@ private extension MentionSelectionView {
|
||||||
final class Cell: UITableViewCell {
|
final class Cell: UITableViewCell {
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView()
|
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message)
|
||||||
|
|
||||||
private lazy var moderatorIconImageView: UIImageView = UIImageView(image: #imageLiteral(resourceName: "Crown"))
|
|
||||||
|
|
||||||
private lazy var displayNameLabel: UILabel = {
|
private lazy var displayNameLabel: UILabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: UILabel = UILabel()
|
||||||
|
@ -155,18 +158,12 @@ private extension MentionSelectionView {
|
||||||
selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
selectedBackgroundView.themeBackgroundColor = .highlighted(.settings_tabBackground)
|
||||||
self.selectedBackgroundView = selectedBackgroundView
|
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
|
// Main stack view
|
||||||
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
let mainStackView = UIStackView(arrangedSubviews: [ profilePictureView, displayNameLabel ])
|
||||||
mainStackView.axis = .horizontal
|
mainStackView.axis = .horizontal
|
||||||
mainStackView.alignment = .center
|
mainStackView.alignment = .center
|
||||||
mainStackView.spacing = Values.mediumSpacing
|
mainStackView.spacing = Values.mediumSpacing
|
||||||
mainStackView.set(.height, to: profilePictureViewSize)
|
mainStackView.set(.height, to: ProfilePictureView.Size.message.viewSize)
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
mainStackView.pin(.leading, to: .leading, of: contentView, withInset: Values.mediumSpacing)
|
||||||
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing)
|
mainStackView.pin(.top, to: .top, of: contentView, withInset: Values.smallSpacing)
|
||||||
|
@ -174,13 +171,6 @@ private extension MentionSelectionView {
|
||||||
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
contentView.pin(.bottom, to: .bottom, of: mainStackView, withInset: Values.smallSpacing)
|
||||||
mainStackView.set(.width, to: UIScreen.main.bounds.width - 2 * Values.mediumSpacing)
|
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
|
// Separator
|
||||||
addSubview(separator)
|
addSubview(separator)
|
||||||
separator.pin(.leading, to: .leading, of: self)
|
separator.pin(.leading, to: .leading, of: self)
|
||||||
|
@ -199,10 +189,11 @@ private extension MentionSelectionView {
|
||||||
displayNameLabel.text = profile.displayName(for: threadVariant)
|
displayNameLabel.text = profile.displayName(for: threadVariant)
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: profile.id,
|
publicKey: profile.id,
|
||||||
|
threadVariant: .contact, // Always show the display picture in 'contact' mode
|
||||||
|
customImageData: nil,
|
||||||
profile: profile,
|
profile: profile,
|
||||||
threadVariant: threadVariant
|
profileIcon: (isUserModeratorOrAdmin ? .crown : .none)
|
||||||
)
|
)
|
||||||
moderatorIconImageView.isHidden = !isUserModeratorOrAdmin
|
|
||||||
separator.isHidden = isLast
|
separator.isHidden = isLast
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ final class DocumentView: UIView {
|
||||||
// Size label
|
// Size label
|
||||||
let sizeLabel = UILabel()
|
let sizeLabel = UILabel()
|
||||||
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
sizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount)
|
sizeLabel.text = Format.fileSize(attachment.byteCount)
|
||||||
sizeLabel.themeTextColor = textColor
|
sizeLabel.themeTextColor = textColor
|
||||||
sizeLabel.lineBreakMode = .byTruncatingTail
|
sizeLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol LinkPreviewState {
|
protocol LinkPreviewState {
|
||||||
var isLoaded: Bool { get }
|
var isLoaded: Bool { get }
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public class MediaAlbumView: UIStackView {
|
public class MediaAlbumView: UIStackView {
|
||||||
private let items: [Attachment]
|
private let items: [Attachment]
|
||||||
|
@ -110,11 +111,10 @@ public class MediaAlbumView: UIStackView {
|
||||||
tintView.autoPinEdgesToSuperviewEdges()
|
tintView.autoPinEdgesToSuperviewEdges()
|
||||||
|
|
||||||
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
let moreCount = max(1, items.count - MediaAlbumView.kMaxItems)
|
||||||
let moreCountText = OWSFormat.formatInt(Int32(moreCount))
|
|
||||||
let moreText = String(
|
let moreText = String(
|
||||||
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
// Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.
|
||||||
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
format: "MEDIA_GALLERY_MORE_ITEMS_FORMAT".localized(),
|
||||||
moreCountText
|
"\(moreCount)"
|
||||||
)
|
)
|
||||||
let moreLabel: UILabel = UILabel()
|
let moreLabel: UILabel = UILabel()
|
||||||
moreLabel.font = .systemFont(ofSize: 24)
|
moreLabel.font = .systemFont(ofSize: 24)
|
||||||
|
|
|
@ -4,6 +4,8 @@ import UIKit
|
||||||
import YYImage
|
import YYImage
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
public class MediaView: UIView {
|
public class MediaView: UIView {
|
||||||
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
static let contentMode: UIView.ContentMode = .scaleAspectFill
|
||||||
|
|
|
@ -30,7 +30,8 @@ final class QuoteView: UIView {
|
||||||
quotedText: String?,
|
quotedText: String?,
|
||||||
threadVariant: SessionThread.Variant,
|
threadVariant: SessionThread.Variant,
|
||||||
currentUserPublicKey: String?,
|
currentUserPublicKey: String?,
|
||||||
currentUserBlindedPublicKey: String?,
|
currentUserBlinded15PublicKey: String?,
|
||||||
|
currentUserBlinded25PublicKey: String?,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
attachment: Attachment?,
|
attachment: Attachment?,
|
||||||
hInset: CGFloat,
|
hInset: CGFloat,
|
||||||
|
@ -47,7 +48,8 @@ final class QuoteView: UIView {
|
||||||
quotedText: quotedText,
|
quotedText: quotedText,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
currentUserPublicKey: currentUserPublicKey,
|
currentUserPublicKey: currentUserPublicKey,
|
||||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
|
||||||
direction: direction,
|
direction: direction,
|
||||||
attachment: attachment,
|
attachment: attachment,
|
||||||
hInset: hInset,
|
hInset: hInset,
|
||||||
|
@ -69,7 +71,8 @@ final class QuoteView: UIView {
|
||||||
quotedText: String?,
|
quotedText: String?,
|
||||||
threadVariant: SessionThread.Variant,
|
threadVariant: SessionThread.Variant,
|
||||||
currentUserPublicKey: String?,
|
currentUserPublicKey: String?,
|
||||||
currentUserBlindedPublicKey: String?,
|
currentUserBlinded15PublicKey: String?,
|
||||||
|
currentUserBlinded25PublicKey: String?,
|
||||||
direction: Direction,
|
direction: Direction,
|
||||||
attachment: Attachment?,
|
attachment: Attachment?,
|
||||||
hInset: CGFloat,
|
hInset: CGFloat,
|
||||||
|
@ -119,17 +122,6 @@ final class QuoteView: UIView {
|
||||||
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
contentView.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.VerticalEdge.bottom ], to: self)
|
||||||
contentView.rightAnchor.constraint(lessThanOrEqualTo: self.rightAnchor).isActive = true
|
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 {
|
if let attachment: Attachment = attachment {
|
||||||
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
let isAudio: Bool = MIMETypeUtil.isAudio(attachment.contentType)
|
||||||
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
|
let fallbackImageName: String = (isAudio ? "attachment_audio" : "actionsheet_document_black")
|
||||||
|
@ -181,13 +173,26 @@ final class QuoteView: UIView {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
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)
|
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
|
// Body label
|
||||||
let bodyLabel = TappableLabel()
|
let bodyLabel = TappableLabel()
|
||||||
bodyLabel.numberOfLines = 0
|
|
||||||
bodyLabel.lineBreakMode = .byTruncatingTail
|
bodyLabel.lineBreakMode = .byTruncatingTail
|
||||||
|
bodyLabel.numberOfLines = 2
|
||||||
|
|
||||||
let targetThemeColor: ThemeValue = {
|
let targetThemeColor: ThemeValue = {
|
||||||
switch mode {
|
switch mode {
|
||||||
|
@ -209,7 +214,8 @@ final class QuoteView: UIView {
|
||||||
in: $0,
|
in: $0,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
currentUserPublicKey: currentUserPublicKey,
|
currentUserPublicKey: currentUserPublicKey,
|
||||||
currentUserBlindedPublicKey: currentUserBlindedPublicKey,
|
currentUserBlinded15PublicKey: currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey: currentUserBlinded25PublicKey,
|
||||||
isOutgoingMessage: (direction == .outgoing),
|
isOutgoingMessage: (direction == .outgoing),
|
||||||
textColor: textColor,
|
textColor: textColor,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
|
@ -229,11 +235,11 @@ final class QuoteView: UIView {
|
||||||
|
|
||||||
// Label stack view
|
// Label stack view
|
||||||
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
let bodyLabelSize = bodyLabel.systemLayoutSizeFitting(availableSpace)
|
||||||
var authorLabelHeight: CGFloat?
|
|
||||||
|
|
||||||
let isCurrentUser: Bool = [
|
let isCurrentUser: Bool = [
|
||||||
currentUserPublicKey,
|
currentUserPublicKey,
|
||||||
currentUserBlindedPublicKey,
|
currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey
|
||||||
]
|
]
|
||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
.asSet()
|
.asSet()
|
||||||
|
@ -259,16 +265,12 @@ final class QuoteView: UIView {
|
||||||
authorLabel.themeTextColor = targetThemeColor
|
authorLabel.themeTextColor = targetThemeColor
|
||||||
authorLabel.lineBreakMode = .byTruncatingTail
|
authorLabel.lineBreakMode = .byTruncatingTail
|
||||||
authorLabel.isHidden = (authorLabel.text == nil)
|
authorLabel.isHidden = (authorLabel.text == nil)
|
||||||
|
authorLabel.numberOfLines = 1
|
||||||
let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace)
|
|
||||||
authorLabel.set(.height, to: authorLabelSize.height)
|
|
||||||
authorLabelHeight = authorLabelSize.height
|
|
||||||
|
|
||||||
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
|
let labelStackView = UIStackView(arrangedSubviews: [ authorLabel, bodyLabel ])
|
||||||
labelStackView.axis = .vertical
|
labelStackView.axis = .vertical
|
||||||
labelStackView.spacing = labelStackViewSpacing
|
labelStackView.spacing = labelStackViewSpacing
|
||||||
labelStackView.distribution = .equalCentering
|
labelStackView.distribution = .equalCentering
|
||||||
labelStackView.set(.width, to: max(bodyLabelSize.width, authorLabelSize.width))
|
|
||||||
labelStackView.isLayoutMarginsRelativeArrangement = true
|
labelStackView.isLayoutMarginsRelativeArrangement = true
|
||||||
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
labelStackView.layoutMargins = UIEdgeInsets(top: labelStackViewVMargin, left: 0, bottom: labelStackViewVMargin, right: 0)
|
||||||
mainStackView.addArrangedSubview(labelStackView)
|
mainStackView.addArrangedSubview(labelStackView)
|
||||||
|
@ -277,29 +279,6 @@ final class QuoteView: UIView {
|
||||||
contentView.addSubview(mainStackView)
|
contentView.addSubview(mainStackView)
|
||||||
mainStackView.pin(to: contentView)
|
mainStackView.pin(to: contentView)
|
||||||
|
|
||||||
if threadVariant != .openGroup && threadVariant != .closedGroup {
|
|
||||||
bodyLabel.set(.width, to: bodyLabelSize.width)
|
|
||||||
}
|
|
||||||
|
|
||||||
let bodyLabelHeight = bodyLabelSize.height.clamp(0, (mode == .regular ? 60 : 40))
|
|
||||||
let contentViewHeight: CGFloat
|
|
||||||
|
|
||||||
if attachment != nil {
|
|
||||||
contentViewHeight = thumbnailSize + 8 // Add a small amount of spacing above and below the thumbnail
|
|
||||||
bodyLabel.set(.height, to: 18) // Experimentally determined
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
if let authorLabelHeight = authorLabelHeight { // Group thread
|
|
||||||
contentViewHeight = bodyLabelHeight + (authorLabelHeight + labelStackViewSpacing) + 2 * labelStackViewVMargin
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
contentViewHeight = bodyLabelHeight + 2 * smallSpacing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
contentView.set(.height, to: contentViewHeight)
|
|
||||||
lineView.set(.height, to: contentViewHeight - 8) // Add a small amount of spacing above and below the line
|
|
||||||
|
|
||||||
if mode == .draft {
|
if mode == .draft {
|
||||||
// Cancel button
|
// Cancel button
|
||||||
let cancelButton = UIButton(type: .custom)
|
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 arrowSize: CGSize = CGSize(width: 15, height: 13)
|
||||||
private static let arrowSpacing: CGFloat = Values.verySmallSpacing
|
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 maxWidth: CGFloat = 0
|
||||||
private var collapsedCount: Int = 0
|
private var collapsedCount: Int = 0
|
||||||
private var showingAllReactions: Bool = false
|
private var showingAllReactions: Bool = false
|
||||||
|
@ -173,7 +180,10 @@ final class ReactionContainerView: UIView {
|
||||||
numReactions += 1
|
numReactions += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
return numReactions
|
return (numReactions > ReactionContainerView.maxEmojiBeforeCollapse ?
|
||||||
|
ReactionContainerView.numCollapsedEmoji :
|
||||||
|
numReactions
|
||||||
|
)
|
||||||
}()
|
}()
|
||||||
self.showNumbers = showNumbers
|
self.showNumbers = showNumbers
|
||||||
self.reactionViews = []
|
self.reactionViews = []
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
@objc class TypingIndicatorView: UIStackView {
|
@objc class TypingIndicatorView: UIStackView {
|
||||||
// This represents the spacing between the dots
|
// This represents the spacing between the dots
|
||||||
|
|
|
@ -4,6 +4,7 @@ import UIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
final class DateHeaderCell: MessageCell {
|
final class DateHeaderCell: MessageCell {
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
|
@ -6,7 +6,7 @@ import SessionMessagingKit
|
||||||
|
|
||||||
final class InfoMessageCell: MessageCell {
|
final class InfoMessageCell: MessageCell {
|
||||||
private static let iconSize: CGFloat = 16
|
private static let iconSize: CGFloat = 16
|
||||||
private static let inset = Values.mediumSpacing
|
public static let inset = Values.mediumSpacing
|
||||||
|
|
||||||
private var isHandlingLongPress: Bool = false
|
private var isHandlingLongPress: Bool = false
|
||||||
|
|
||||||
|
|
|
@ -65,6 +65,7 @@ public class MessageCell: UITableViewCell {
|
||||||
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
|
static func cellType(for viewModel: MessageViewModel) -> MessageCell.Type {
|
||||||
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
|
guard viewModel.cellType != .typingIndicator else { return TypingIndicatorCell.self }
|
||||||
guard viewModel.cellType != .dateHeader else { return DateHeaderCell.self }
|
guard viewModel.cellType != .dateHeader else { return DateHeaderCell.self }
|
||||||
|
guard viewModel.cellType != .unreadMarker else { return UnreadMarkerCell.self }
|
||||||
|
|
||||||
switch viewModel.variant {
|
switch viewModel.variant {
|
||||||
case .standardOutgoing, .standardIncoming, .standardIncomingDeleted:
|
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 authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
|
||||||
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
|
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 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 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 contentViewLeadingConstraint2 = snContentView.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: VisibleMessageCell.gutterSize)
|
||||||
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
|
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] = [
|
private lazy var viewsToMoveForReply: [UIView] = [
|
||||||
snContentView,
|
snContentView,
|
||||||
profilePictureView,
|
profilePictureView,
|
||||||
moderatorIconImageView,
|
|
||||||
replyButton,
|
replyButton,
|
||||||
timerView,
|
timerView,
|
||||||
messageStatusImageView,
|
messageStatusImageView,
|
||||||
reactionContainerView
|
reactionContainerView
|
||||||
]
|
]
|
||||||
|
|
||||||
private lazy var profilePictureView: ProfilePictureView = {
|
private lazy var profilePictureView: ProfilePictureView = ProfilePictureView(size: .message)
|
||||||
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"))
|
|
||||||
|
|
||||||
lazy var bubbleBackgroundView: UIView = {
|
lazy var bubbleBackgroundView: UIView = {
|
||||||
let result = UIView()
|
let result = UIView()
|
||||||
|
@ -176,7 +166,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
private static let messageStatusImageViewSize: CGFloat = 12
|
private static let messageStatusImageViewSize: CGFloat = 12
|
||||||
private static let authorLabelBottomSpacing: CGFloat = 4
|
private static let authorLabelBottomSpacing: CGFloat = 4
|
||||||
private static let groupThreadHSpacing: CGFloat = 12
|
private static let groupThreadHSpacing: CGFloat = 12
|
||||||
private static let profilePictureSize = Values.verySmallProfilePictureSize
|
|
||||||
private static let authorLabelInset: CGFloat = 12
|
private static let authorLabelInset: CGFloat = 12
|
||||||
private static let replyButtonSize: CGFloat = 24
|
private static let replyButtonSize: CGFloat = 24
|
||||||
private static let maxBubbleTranslationX: CGFloat = 40
|
private static let maxBubbleTranslationX: CGFloat = 40
|
||||||
|
@ -186,7 +175,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
static let contactThreadHSpacing = Values.mediumSpacing
|
static let contactThreadHSpacing = Values.mediumSpacing
|
||||||
|
|
||||||
static var gutterSize: CGFloat = {
|
static var gutterSize: CGFloat = {
|
||||||
var result = groupThreadHSpacing + profilePictureSize + groupThreadHSpacing
|
var result = groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing
|
||||||
|
|
||||||
if UIDevice.current.isIPad {
|
if UIDevice.current.isIPad {
|
||||||
result += 168
|
result += 168
|
||||||
|
@ -195,7 +184,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
static var leftGutterSize: CGFloat { groupThreadHSpacing + profilePictureSize + groupThreadHSpacing }
|
static var leftGutterSize: CGFloat { groupThreadHSpacing + ProfilePictureView.Size.message.viewSize + groupThreadHSpacing }
|
||||||
|
|
||||||
// MARK: Direction & Position
|
// MARK: Direction & Position
|
||||||
|
|
||||||
|
@ -214,21 +203,13 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
// Profile picture view
|
// Profile picture view
|
||||||
addSubview(profilePictureView)
|
addSubview(profilePictureView)
|
||||||
profilePictureViewLeadingConstraint.isActive = true
|
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
|
// Content view
|
||||||
addSubview(snContentView)
|
addSubview(snContentView)
|
||||||
contentViewLeadingConstraint1.isActive = true
|
contentViewLeadingConstraint1.isActive = true
|
||||||
contentViewTopConstraint.isActive = true
|
contentViewTopConstraint.isActive = true
|
||||||
contentViewTrailingConstraint1.isActive = true
|
contentViewTrailingConstraint1.isActive = true
|
||||||
snContentView.pin(.bottom, to: .bottom, of: profilePictureView, withInset: -1)
|
snContentView.pin(.bottom, to: .bottom, of: profilePictureView)
|
||||||
|
|
||||||
// Bubble background view
|
// Bubble background view
|
||||||
bubbleBackgroundView.addSubview(bubbleView)
|
bubbleBackgroundView.addSubview(bubbleView)
|
||||||
|
@ -301,9 +282,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
lastSearchText: String?
|
lastSearchText: String?
|
||||||
) {
|
) {
|
||||||
self.viewModel = cellViewModel
|
self.viewModel = cellViewModel
|
||||||
self.bubbleView.accessibilityIdentifier = "Message Body"
|
|
||||||
self.bubbleView.isAccessibilityElement = true
|
|
||||||
self.bubbleView.accessibilityLabel = cellViewModel.body
|
|
||||||
// We want to add spacing between "clusters" of messages to indicate that time has
|
// We want to add spacing between "clusters" of messages to indicate that time has
|
||||||
// passed (even if there wasn't enough time to warrant showing a date header)
|
// passed (even if there wasn't enough time to warrant showing a date header)
|
||||||
let shouldAddTopInset: Bool = (
|
let shouldAddTopInset: Bool = (
|
||||||
|
@ -313,18 +292,28 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
cellViewModel.isOnlyMessageInCluster
|
cellViewModel.isOnlyMessageInCluster
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
let isGroupThread: Bool = (cellViewModel.threadVariant == .openGroup || cellViewModel.threadVariant == .closedGroup)
|
let isGroupThread: Bool = (
|
||||||
|
cellViewModel.threadVariant == .community ||
|
||||||
|
cellViewModel.threadVariant == .legacyGroup ||
|
||||||
|
cellViewModel.threadVariant == .group
|
||||||
|
)
|
||||||
|
|
||||||
// Profile picture view
|
// Profile picture view (should always be handled as a standard 'contact' profile picture)
|
||||||
|
let profileShouldBeVisible: Bool = (
|
||||||
|
cellViewModel.canHaveProfile &&
|
||||||
|
cellViewModel.shouldShowProfile &&
|
||||||
|
cellViewModel.profile != nil
|
||||||
|
)
|
||||||
profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
|
profilePictureViewLeadingConstraint.constant = (isGroupThread ? VisibleMessageCell.groupThreadHSpacing : 0)
|
||||||
profilePictureViewWidthConstraint.constant = (isGroupThread ? VisibleMessageCell.profilePictureSize : 0)
|
profilePictureView.isHidden = !cellViewModel.canHaveProfile
|
||||||
profilePictureView.isHidden = (!cellViewModel.shouldShowProfile || cellViewModel.profile == nil)
|
profilePictureView.alpha = (profileShouldBeVisible ? 1 : 0)
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: cellViewModel.authorId,
|
publicKey: cellViewModel.authorId,
|
||||||
|
threadVariant: .contact, // Always show the display picture in 'contact' mode
|
||||||
|
customImageData: nil,
|
||||||
profile: cellViewModel.profile,
|
profile: cellViewModel.profile,
|
||||||
threadVariant: cellViewModel.threadVariant
|
profileIcon: (cellViewModel.isSenderOpenGroupModerator ? .crown : .none)
|
||||||
)
|
)
|
||||||
moderatorIconImageView.isHidden = (!cellViewModel.isSenderOpenGroupModerator || !cellViewModel.shouldShowProfile)
|
|
||||||
|
|
||||||
// Bubble view
|
// Bubble view
|
||||||
contentViewLeadingConstraint1.isActive = (
|
contentViewLeadingConstraint1.isActive = (
|
||||||
|
@ -356,6 +345,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
lastSearchText: lastSearchText
|
lastSearchText: lastSearchText
|
||||||
)
|
)
|
||||||
|
|
||||||
|
bubbleView.accessibilityIdentifier = "Message Body"
|
||||||
|
bubbleView.accessibilityLabel = bodyTappableLabel?.attributedText?.string
|
||||||
|
bubbleView.isAccessibilityElement = true
|
||||||
|
|
||||||
// Author label
|
// Author label
|
||||||
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
|
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
|
||||||
authorLabel.isHidden = (cellViewModel.senderName == nil)
|
authorLabel.isHidden = (cellViewModel.senderName == nil)
|
||||||
|
@ -392,11 +385,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
)
|
)
|
||||||
|
|
||||||
// Swipe to reply
|
// Swipe to reply
|
||||||
if cellViewModel.variant == .standardIncomingDeleted || cellViewModel.variant == .infoCall {
|
if ContextMenuVC.viewModelCanReply(cellViewModel) {
|
||||||
removeGestureRecognizer(panGestureRecognizer)
|
addGestureRecognizer(panGestureRecognizer)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
addGestureRecognizer(panGestureRecognizer)
|
removeGestureRecognizer(panGestureRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Under bubble content
|
// Under bubble content
|
||||||
|
@ -496,7 +489,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch cellViewModel.cellType {
|
switch cellViewModel.cellType {
|
||||||
case .typingIndicator, .dateHeader: break
|
case .typingIndicator, .dateHeader, .unreadMarker: break
|
||||||
|
|
||||||
case .textOnlyMessage:
|
case .textOnlyMessage:
|
||||||
let inset: CGFloat = 12
|
let inset: CGFloat = 12
|
||||||
|
@ -549,7 +542,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
quotedText: quote.body,
|
quotedText: quote.body,
|
||||||
threadVariant: cellViewModel.threadVariant,
|
threadVariant: cellViewModel.threadVariant,
|
||||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
|
||||||
direction: (cellViewModel.variant == .standardOutgoing ?
|
direction: (cellViewModel.variant == .standardOutgoing ?
|
||||||
.outgoing :
|
.outgoing :
|
||||||
.incoming
|
.incoming
|
||||||
|
@ -718,8 +712,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
showingAllReactions: showExpandedReactions,
|
showingAllReactions: showExpandedReactions,
|
||||||
showNumbers: (
|
showNumbers: (
|
||||||
cellViewModel.threadVariant == .closedGroup ||
|
cellViewModel.threadVariant == .legacyGroup ||
|
||||||
cellViewModel.threadVariant == .openGroup
|
cellViewModel.threadVariant == .group ||
|
||||||
|
cellViewModel.threadVariant == .community
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -873,8 +868,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
|
|
||||||
if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile {
|
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
|
// For open groups only attempt to start a conversation if the author has a blinded id
|
||||||
guard cellViewModel.threadVariant != .openGroup else {
|
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(
|
delegate?.startThread(
|
||||||
with: cellViewModel.authorId,
|
with: cellViewModel.authorId,
|
||||||
|
@ -1083,8 +1079,9 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
|
|
||||||
case .standardIncoming, .standardIncomingDeleted:
|
case .standardIncoming, .standardIncomingDeleted:
|
||||||
let isGroupThread = (
|
let isGroupThread = (
|
||||||
cellViewModel.threadVariant == .openGroup ||
|
cellViewModel.threadVariant == .community ||
|
||||||
cellViewModel.threadVariant == .closedGroup
|
cellViewModel.threadVariant == .legacyGroup ||
|
||||||
|
cellViewModel.threadVariant == .group
|
||||||
)
|
)
|
||||||
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
|
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
|
||||||
|
|
||||||
|
@ -1123,7 +1120,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
in: (cellViewModel.body ?? ""),
|
in: (cellViewModel.body ?? ""),
|
||||||
threadVariant: cellViewModel.threadVariant,
|
threadVariant: cellViewModel.threadVariant,
|
||||||
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
currentUserPublicKey: cellViewModel.currentUserPublicKey,
|
||||||
currentUserBlindedPublicKey: cellViewModel.currentUserBlindedPublicKey,
|
currentUserBlinded15PublicKey: cellViewModel.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKey: cellViewModel.currentUserBlinded25PublicKey,
|
||||||
isOutgoingMessage: isOutgoing,
|
isOutgoingMessage: isOutgoing,
|
||||||
textColor: actualTextColor,
|
textColor: actualTextColor,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
#import "OWSMath.h"
|
#import "OWSMath.h"
|
||||||
#import "UIView+OWS.h"
|
#import "UIView+OWS.h"
|
||||||
#import <QuartzCore/QuartzCore.h>
|
#import <QuartzCore/QuartzCore.h>
|
||||||
|
#import <SignalCoreKit/OWSAsserts.h>
|
||||||
#import <PureLayout/PureLayout.h>
|
#import <PureLayout/PureLayout.h>
|
||||||
#import <SignalCoreKit/NSDate+OWS.h>
|
#import <SignalCoreKit/NSDate+OWS.h>
|
||||||
#import <SessionUtilitiesKit/NSTimer+Proxying.h>
|
#import <SessionUtilitiesKit/NSTimer+Proxying.h>
|
||||||
|
|
|
@ -7,8 +7,9 @@ import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
import SessionSnodeKit
|
||||||
|
|
||||||
class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappearingMessagesViewModel.NavButton, ThreadDisappearingMessagesViewModel.Section, ThreadDisappearingMessagesViewModel.Item> {
|
class ThreadDisappearingMessagesSettingsViewModel: SessionTableViewModel<ThreadDisappearingMessagesSettingsViewModel.NavButton, ThreadDisappearingMessagesSettingsViewModel.Section, ThreadDisappearingMessagesSettingsViewModel.Item> {
|
||||||
// MARK: - Config
|
// MARK: - Config
|
||||||
|
|
||||||
enum NavButton: Equatable {
|
enum NavButton: Equatable {
|
||||||
|
@ -30,6 +31,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
|
|
||||||
private let dependencies: Dependencies
|
private let dependencies: Dependencies
|
||||||
private let threadId: String
|
private let threadId: String
|
||||||
|
private let threadVariant: SessionThread.Variant
|
||||||
private let config: DisappearingMessagesConfiguration
|
private let config: DisappearingMessagesConfiguration
|
||||||
private var storedSelection: TimeInterval
|
private var storedSelection: TimeInterval
|
||||||
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
|
private var currentSelection: CurrentValueSubject<TimeInterval, Never>
|
||||||
|
@ -39,10 +41,12 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
init(
|
init(
|
||||||
dependencies: Dependencies = Dependencies(),
|
dependencies: Dependencies = Dependencies(),
|
||||||
threadId: String,
|
threadId: String,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
config: DisappearingMessagesConfiguration
|
config: DisappearingMessagesConfiguration
|
||||||
) {
|
) {
|
||||||
self.dependencies = dependencies
|
self.dependencies = dependencies
|
||||||
self.threadId = threadId
|
self.threadId = threadId
|
||||||
|
self.threadVariant = threadVariant
|
||||||
self.config = config
|
self.config = config
|
||||||
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
|
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
|
||||||
self.currentSelection = CurrentValueSubject(self.storedSelection)
|
self.currentSelection = CurrentValueSubject(self.storedSelection)
|
||||||
|
@ -85,10 +89,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
|
|
||||||
override var title: String { "DISAPPEARING_MESSAGES".localized() }
|
override var title: String { "DISAPPEARING_MESSAGES".localized() }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||||
|
@ -97,7 +98,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||||
.trackingConstantRegion { [weak self, config, dependencies, threadId = self.threadId] db -> [SectionModel] in
|
.trackingConstantRegion { [weak self, config, dependencies, threadId = self.threadId] db -> [SectionModel] in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||||
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||||
|
@ -115,7 +116,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
isSelected: { (self?.currentSelection.value == 0) }
|
isSelected: { (self?.currentSelection.value == 0) }
|
||||||
),
|
),
|
||||||
isEnabled: (
|
isEnabled: (
|
||||||
maybeThreadViewModel?.threadVariant != .closedGroup ||
|
(
|
||||||
|
maybeThreadViewModel?.threadVariant != .legacyGroup &&
|
||||||
|
maybeThreadViewModel?.threadVariant != .group
|
||||||
|
) ||
|
||||||
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
|
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
|
||||||
),
|
),
|
||||||
onTap: { self?.currentSelection.send(0) }
|
onTap: { self?.currentSelection.send(0) }
|
||||||
|
@ -132,7 +136,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
isSelected: { (self?.currentSelection.value == duration) }
|
isSelected: { (self?.currentSelection.value == duration) }
|
||||||
),
|
),
|
||||||
isEnabled: (
|
isEnabled: (
|
||||||
maybeThreadViewModel?.threadVariant != .closedGroup ||
|
(
|
||||||
|
maybeThreadViewModel?.threadVariant != .legacyGroup &&
|
||||||
|
maybeThreadViewModel?.threadVariant != .group
|
||||||
|
) ||
|
||||||
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
|
maybeThreadViewModel?.currentUserIsClosedGroupMember == true
|
||||||
),
|
),
|
||||||
onTap: { self?.currentSelection.send(duration) }
|
onTap: { self?.currentSelection.send(duration) }
|
||||||
|
@ -143,16 +150,15 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
|
.handleEvents(didFail: { SNLog("[ThreadDisappearingMessageSettingsViewModel] Observation failed with error: \($0)") })
|
||||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
private func saveChanges() {
|
private func saveChanges() {
|
||||||
let threadId: String = self.threadId
|
let threadId: String = self.threadId
|
||||||
|
let threadVariant: SessionThread.Variant = self.threadVariant
|
||||||
let currentSelection: TimeInterval = self.currentSelection.value
|
let currentSelection: TimeInterval = self.currentSelection.value
|
||||||
let updatedConfig: DisappearingMessagesConfiguration = self.config
|
let updatedConfig: DisappearingMessagesConfiguration = self.config
|
||||||
.with(
|
.with(
|
||||||
|
@ -163,10 +169,6 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
guard self.config != updatedConfig else { return }
|
guard self.config != updatedConfig else { return }
|
||||||
|
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
|
let config: DisappearingMessagesConfiguration = try DisappearingMessagesConfiguration
|
||||||
.fetchOne(db, id: threadId)
|
.fetchOne(db, id: threadId)
|
||||||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
||||||
|
@ -192,8 +194,22 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
duration: UInt32(floor(updatedConfig.isEnabled ? updatedConfig.durationSeconds : 0))
|
duration: UInt32(floor(updatedConfig.isEnabled ? updatedConfig.durationSeconds : 0))
|
||||||
),
|
),
|
||||||
interactionId: interaction.id,
|
interactionId: interaction.id,
|
||||||
in: thread
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Legacy closed groups
|
||||||
|
switch threadVariant {
|
||||||
|
case .legacyGroup:
|
||||||
|
try SessionUtil
|
||||||
|
.update(
|
||||||
|
db,
|
||||||
|
groupPublicKey: threadId,
|
||||||
|
disappearingConfig: updatedConfig
|
||||||
|
)
|
||||||
|
|
||||||
|
default: break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -30,7 +30,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum Setting: Differentiable {
|
public enum Setting: Differentiable {
|
||||||
case threadInfo
|
case avatar
|
||||||
|
case nickname
|
||||||
|
case sessionId
|
||||||
|
|
||||||
case copyThreadId
|
case copyThreadId
|
||||||
case allMedia
|
case allMedia
|
||||||
case searchConversation
|
case searchConversation
|
||||||
|
@ -81,10 +84,22 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
lazy var navState: AnyPublisher<NavState, Never> = {
|
lazy var navState: AnyPublisher<NavState, Never> = {
|
||||||
isEditing
|
Publishers
|
||||||
.map { isEditing in (isEditing ? .editing : .standard) }
|
.CombineLatest(
|
||||||
|
isEditing,
|
||||||
|
textChanged
|
||||||
|
.handleEvents(
|
||||||
|
receiveOutput: { [weak self] value, _ in
|
||||||
|
self?.editedDisplayName = value
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.filter { _ in false }
|
||||||
|
.prepend((nil, .nickname))
|
||||||
|
)
|
||||||
|
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.prepend(.standard) // Initial value
|
.prepend(.standard) // Initial value
|
||||||
|
.shareReplay(1)
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
@ -138,7 +153,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
try Profile
|
try Profile
|
||||||
.filter(id: threadId)
|
.filter(id: threadId)
|
||||||
.updateAll(
|
.updateAllAndConfig(
|
||||||
db,
|
db,
|
||||||
Profile.Columns.nickname
|
Profile.Columns.nickname
|
||||||
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
|
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
|
||||||
|
@ -166,14 +181,11 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
override var title: String {
|
override var title: String {
|
||||||
switch threadVariant {
|
switch threadVariant {
|
||||||
case .contact: return "vc_settings_title".localized()
|
case .contact: return "vc_settings_title".localized()
|
||||||
case .closedGroup, .openGroup: return "vc_group_settings_title".localized()
|
case .legacyGroup, .group, .community: return "vc_group_settings_title".localized()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
public override var observableTableData: ObservableData { _observableTableData }
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
/// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise
|
||||||
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
/// performance https://github.com/groue/GRDB.swift#valueobservation-performance
|
||||||
|
@ -182,14 +194,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this
|
||||||
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
private lazy var _observableTableData: ObservableData = ValueObservation
|
||||||
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||||
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||||
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
|
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||||
.fetchOne(db)
|
.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
|
// Additional Queries
|
||||||
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
let fallbackSound: Preferences.Sound = db[.defaultNotificationSound]
|
||||||
|
@ -204,62 +221,142 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.fetchOne(db, id: threadId)
|
.fetchOne(db, id: threadId)
|
||||||
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
.defaulting(to: DisappearingMessagesConfiguration.defaultWith(threadId))
|
||||||
let currentUserIsClosedGroupMember: Bool = (
|
let currentUserIsClosedGroupMember: Bool = (
|
||||||
threadVariant == .closedGroup &&
|
(
|
||||||
|
threadVariant == .legacyGroup ||
|
||||||
|
threadVariant == .group
|
||||||
|
) &&
|
||||||
threadViewModel.currentUserIsClosedGroupMember == true
|
threadViewModel.currentUserIsClosedGroupMember == true
|
||||||
)
|
)
|
||||||
let currentUserIsClosedGroupAdmin: Bool = (
|
let currentUserIsClosedGroupAdmin: Bool = (
|
||||||
threadVariant == .closedGroup &&
|
(
|
||||||
|
threadVariant == .legacyGroup ||
|
||||||
|
threadVariant == .group
|
||||||
|
) &&
|
||||||
threadViewModel.currentUserIsClosedGroupAdmin == true
|
threadViewModel.currentUserIsClosedGroupAdmin == true
|
||||||
)
|
)
|
||||||
|
let editIcon: UIImage? = UIImage(named: "icon_edit")
|
||||||
|
|
||||||
return [
|
return [
|
||||||
SectionModel(
|
SectionModel(
|
||||||
model: .conversationInfo,
|
model: .conversationInfo,
|
||||||
elements: [
|
elements: [
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .threadInfo,
|
id: .avatar,
|
||||||
leftAccessory: .threadInfo(
|
accessory: .profile(
|
||||||
threadViewModel: threadViewModel,
|
id: threadViewModel.id,
|
||||||
avatarTapped: { [weak self] in
|
size: .hero,
|
||||||
self?.updateProfilePicture(threadViewModel: threadViewModel)
|
threadVariant: threadVariant,
|
||||||
},
|
customImageData: threadViewModel.openGroupProfilePictureData,
|
||||||
titleTapped: { [weak self] in self?.setIsEditing(true) },
|
profile: threadViewModel.profile,
|
||||||
titleChanged: { [weak self] text in self?.editedDisplayName = text }
|
profileIcon: .none,
|
||||||
|
additionalProfile: threadViewModel.additionalProfile,
|
||||||
|
additionalProfileIcon: .none,
|
||||||
|
accessibility: nil
|
||||||
),
|
),
|
||||||
title: threadViewModel.displayName,
|
styling: SessionCell.StyleInfo(
|
||||||
shouldHaveBackground: false
|
alignment: .centerHugging,
|
||||||
|
customPadding: SessionCell.Padding(bottom: Values.smallSpacing),
|
||||||
|
backgroundStyle: .noBackground
|
||||||
|
),
|
||||||
|
onTap: { self?.viewProfilePicture(threadViewModel: threadViewModel) }
|
||||||
|
),
|
||||||
|
SessionCell.Info(
|
||||||
|
id: .nickname,
|
||||||
|
leftAccessory: (threadVariant != .contact ? nil :
|
||||||
|
.icon(
|
||||||
|
editIcon?.withRenderingMode(.alwaysTemplate),
|
||||||
|
size: .fit,
|
||||||
|
customTint: .textSecondary
|
||||||
)
|
)
|
||||||
]
|
),
|
||||||
|
title: SessionCell.TextInfo(
|
||||||
|
threadViewModel.displayName,
|
||||||
|
font: .titleLarge,
|
||||||
|
alignment: .center,
|
||||||
|
editingPlaceholder: "CONTACT_NICKNAME_PLACEHOLDER".localized(),
|
||||||
|
interaction: (threadVariant == .contact ? .editable : .none)
|
||||||
|
),
|
||||||
|
styling: SessionCell.StyleInfo(
|
||||||
|
alignment: .centerHugging,
|
||||||
|
customPadding: SessionCell.Padding(
|
||||||
|
top: Values.smallSpacing,
|
||||||
|
trailing: (threadVariant != .contact ?
|
||||||
|
nil :
|
||||||
|
-(((editIcon?.size.width ?? 0) + (Values.smallSpacing * 2)) / 2)
|
||||||
|
),
|
||||||
|
bottom: (threadVariant != .contact ?
|
||||||
|
nil :
|
||||||
|
Values.smallSpacing
|
||||||
|
),
|
||||||
|
interItem: 0
|
||||||
|
),
|
||||||
|
backgroundStyle: .noBackground
|
||||||
|
),
|
||||||
|
accessibility: Accessibility(
|
||||||
|
identifier: "Username",
|
||||||
|
label: threadViewModel.displayName
|
||||||
|
),
|
||||||
|
onTap: {
|
||||||
|
self?.textChanged(self?.oldDisplayName, for: .nickname)
|
||||||
|
self?.setIsEditing(true)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
(threadVariant != .contact ? nil :
|
||||||
|
SessionCell.Info(
|
||||||
|
id: .sessionId,
|
||||||
|
subtitle: SessionCell.TextInfo(
|
||||||
|
threadViewModel.id,
|
||||||
|
font: .monoSmall,
|
||||||
|
alignment: .center,
|
||||||
|
interaction: .copy
|
||||||
|
),
|
||||||
|
styling: SessionCell.StyleInfo(
|
||||||
|
customPadding: SessionCell.Padding(
|
||||||
|
top: Values.smallSpacing,
|
||||||
|
bottom: Values.largeSpacing
|
||||||
|
),
|
||||||
|
backgroundStyle: .noBackground
|
||||||
|
),
|
||||||
|
accessibility: Accessibility(
|
||||||
|
identifier: "Session ID",
|
||||||
|
label: threadViewModel.id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
].compactMap { $0 }
|
||||||
),
|
),
|
||||||
SectionModel(
|
SectionModel(
|
||||||
model: .content,
|
model: .content,
|
||||||
elements: [
|
elements: [
|
||||||
(threadVariant == .closedGroup ? nil :
|
(threadVariant == .legacyGroup || threadVariant == .group ? nil :
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .copyThreadId,
|
id: .copyThreadId,
|
||||||
leftAccessory: .icon(
|
leftAccessory: .icon(
|
||||||
UIImage(named: "ic_copy")?
|
UIImage(named: "ic_copy")?
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: (threadVariant == .openGroup ?
|
title: (threadVariant == .community ?
|
||||||
"COPY_GROUP_URL".localized() :
|
"COPY_GROUP_URL".localized() :
|
||||||
"vc_conversation_settings_copy_session_id_button_title".localized()
|
"vc_conversation_settings_copy_session_id_button_title".localized()
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Copy Session ID",
|
identifier: "\(ThreadSettingsViewModel.self).copy_thread_id",
|
||||||
|
label: "Copy Session ID"
|
||||||
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
switch threadVariant {
|
switch threadVariant {
|
||||||
case .contact, .closedGroup:
|
case .contact, .legacyGroup, .group:
|
||||||
UIPasteboard.general.string = threadId
|
UIPasteboard.general.string = threadId
|
||||||
|
|
||||||
case .openGroup:
|
case .community:
|
||||||
guard
|
guard
|
||||||
let server: String = threadViewModel.openGroupServer,
|
let server: String = threadViewModel.openGroupServer,
|
||||||
let roomToken: String = threadViewModel.openGroupRoomToken,
|
let roomToken: String = threadViewModel.openGroupRoomToken,
|
||||||
let publicKey: String = threadViewModel.openGroupPublicKey
|
let publicKey: String = threadViewModel.openGroupPublicKey
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
UIPasteboard.general.string = OpenGroup.urlFor(
|
UIPasteboard.general.string = SessionUtil.communityUrlFor(
|
||||||
server: server,
|
server: server,
|
||||||
roomToken: roomToken,
|
roomToken: roomToken,
|
||||||
publicKey: publicKey
|
publicKey: publicKey
|
||||||
|
@ -281,8 +378,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: MediaStrings.allMedia,
|
title: MediaStrings.allMedia,
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).all_media",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "All media",
|
identifier: "\(ThreadSettingsViewModel.self).all_media",
|
||||||
|
label: "All media"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
MediaGalleryViewModel.createAllMediaViewController(
|
MediaGalleryViewModel.createAllMediaViewController(
|
||||||
|
@ -301,14 +400,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
title: "CONVERSATION_SETTINGS_SEARCH".localized(),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).search",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Search",
|
identifier: "\(ThreadSettingsViewModel.self).search",
|
||||||
|
label: "Search"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.didTriggerSearch()
|
self?.didTriggerSearch()
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
|
|
||||||
(threadVariant != .openGroup ? nil :
|
(threadVariant != .community ? nil :
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .addToOpenGroup,
|
id: .addToOpenGroup,
|
||||||
leftAccessory: .icon(
|
leftAccessory: .icon(
|
||||||
|
@ -316,7 +417,9 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "vc_conversation_settings_invite_button_title".localized(),
|
title: "vc_conversation_settings_invite_button_title".localized(),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).add_to_open_group",
|
accessibility: Accessibility(
|
||||||
|
identifier: "\(ThreadSettingsViewModel.self).add_to_open_group"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
UserSelectionVC(
|
UserSelectionVC(
|
||||||
|
@ -333,7 +436,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
|
||||||
(threadVariant == .openGroup || threadViewModel.threadIsBlocked == true ? nil :
|
(threadVariant == .community || threadViewModel.threadIsBlocked == true ? nil :
|
||||||
SessionCell.Info(
|
SessionCell.Info(
|
||||||
id: .disappearingMessages,
|
id: .disappearingMessages,
|
||||||
leftAccessory: .icon(
|
leftAccessory: .icon(
|
||||||
|
@ -342,7 +445,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
"ic_timer" :
|
"ic_timer" :
|
||||||
"ic_timer_disabled"
|
"ic_timer_disabled"
|
||||||
)
|
)
|
||||||
)?.withRenderingMode(.alwaysTemplate)
|
)?.withRenderingMode(.alwaysTemplate),
|
||||||
|
accessibility: Accessibility(
|
||||||
|
label: "Timer icon"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
title: "DISAPPEARING_MESSAGES".localized(),
|
title: "DISAPPEARING_MESSAGES".localized(),
|
||||||
subtitle: (disappearingMessagesConfig.isEnabled ?
|
subtitle: (disappearingMessagesConfig.isEnabled ?
|
||||||
|
@ -352,14 +458,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
) :
|
) :
|
||||||
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
"DISAPPEARING_MESSAGES_SUBTITLE_OFF".localized()
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Disappearing messages",
|
identifier: "\(ThreadSettingsViewModel.self).disappearing_messages",
|
||||||
leftAccessoryAccessibilityLabel: "Timer icon",
|
label: "Disappearing messages"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(
|
self?.transitionToScreen(
|
||||||
SessionTableViewController(
|
SessionTableViewController(
|
||||||
viewModel: ThreadDisappearingMessagesViewModel(
|
viewModel: ThreadDisappearingMessagesSettingsViewModel(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant,
|
||||||
config: disappearingMessagesConfig
|
config: disappearingMessagesConfig
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -376,10 +484,14 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "EDIT_GROUP_ACTION".localized(),
|
title: "EDIT_GROUP_ACTION".localized(),
|
||||||
accessibilityIdentifier: "Edit group",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Edit group",
|
identifier: "Edit group",
|
||||||
|
label: "Edit group"
|
||||||
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
self?.transitionToScreen(EditClosedGroupVC(threadId: threadId))
|
self?.transitionToScreen(
|
||||||
|
EditClosedGroupVC(threadId: threadId, threadVariant: threadVariant)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
@ -392,8 +504,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
),
|
),
|
||||||
title: "LEAVE_GROUP_ACTION".localized(),
|
title: "LEAVE_GROUP_ACTION".localized(),
|
||||||
accessibilityIdentifier: "Leave group",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Leave group",
|
identifier: "Leave group",
|
||||||
|
label: "Leave group"
|
||||||
|
),
|
||||||
confirmationInfo: ConfirmationModal.Info(
|
confirmationInfo: ConfirmationModal.Info(
|
||||||
title: "leave_group_confirmation_alert_title".localized(),
|
title: "leave_group_confirmation_alert_title".localized(),
|
||||||
body: .attributedText({
|
body: .attributedText({
|
||||||
|
@ -419,8 +533,14 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text
|
||||||
),
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage.write { db in
|
||||||
try MessageSender.leave(db, groupPublicKey: threadId, deleteThread: false)
|
try SessionThread.deleteOrLeave(
|
||||||
|
db,
|
||||||
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant,
|
||||||
|
groupLeaveType: .standard,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -460,11 +580,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.boolValue(threadViewModel.threadOnlyNotifyForMentions == true)
|
.boolValue(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||||
),
|
),
|
||||||
isEnabled: (
|
isEnabled: (
|
||||||
threadViewModel.threadVariant != .closedGroup ||
|
(
|
||||||
|
threadViewModel.threadVariant != .legacyGroup &&
|
||||||
|
threadViewModel.threadVariant != .group
|
||||||
|
) ||
|
||||||
currentUserIsClosedGroupMember
|
currentUserIsClosedGroupMember
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "Mentions only notification setting",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Mentions only",
|
identifier: "Mentions only notification setting",
|
||||||
|
label: "Mentions only"
|
||||||
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||||
|
|
||||||
|
@ -493,11 +618,16 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.boolValue(threadViewModel.threadMutedUntilTimestamp != nil)
|
.boolValue(threadViewModel.threadMutedUntilTimestamp != nil)
|
||||||
),
|
),
|
||||||
isEnabled: (
|
isEnabled: (
|
||||||
threadViewModel.threadVariant != .closedGroup ||
|
(
|
||||||
|
threadViewModel.threadVariant != .legacyGroup &&
|
||||||
|
threadViewModel.threadVariant != .group
|
||||||
|
) ||
|
||||||
currentUserIsClosedGroupMember
|
currentUserIsClosedGroupMember
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Mute notifications",
|
identifier: "\(ThreadSettingsViewModel.self).mute",
|
||||||
|
label: "Mute notifications"
|
||||||
|
),
|
||||||
onTap: {
|
onTap: {
|
||||||
dependencies.storage.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
let currentValue: TimeInterval? = try SessionThread
|
let currentValue: TimeInterval? = try SessionThread
|
||||||
|
@ -533,8 +663,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
rightAccessory: .toggle(
|
rightAccessory: .toggle(
|
||||||
.boolValue(threadViewModel.threadIsBlocked == true)
|
.boolValue(threadViewModel.threadIsBlocked == true)
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).block",
|
accessibility: Accessibility(
|
||||||
accessibilityLabel: "Block",
|
identifier: "\(ThreadSettingsViewModel.self).block",
|
||||||
|
label: "Block"
|
||||||
|
),
|
||||||
confirmationInfo: ConfirmationModal.Info(
|
confirmationInfo: ConfirmationModal.Info(
|
||||||
title: {
|
title: {
|
||||||
guard threadViewModel.threadIsBlocked == true else {
|
guard threadViewModel.threadIsBlocked == true else {
|
||||||
|
@ -556,7 +688,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
|
"BLOCK_LIST_UNBLOCK_BUTTON".localized() :
|
||||||
"BLOCK_LIST_BLOCK_BUTTON".localized()
|
"BLOCK_LIST_BLOCK_BUTTON".localized()
|
||||||
),
|
),
|
||||||
confirmAccessibilityLabel: "Confirm block",
|
confirmAccessibility: Accessibility(identifier: "Confirm block"),
|
||||||
confirmStyle: .danger,
|
confirmStyle: .danger,
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text
|
||||||
),
|
),
|
||||||
|
@ -577,15 +709,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
|
.handleEvents(didFail: { SNLog("[ThreadSettingsViewModel] Observation failed with error: \($0)") })
|
||||||
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
private func viewProfilePicture(threadViewModel: SessionThreadViewModel) {
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
private func updateProfilePicture(threadViewModel: SessionThreadViewModel) {
|
|
||||||
guard
|
guard
|
||||||
threadViewModel.threadVariant == .contact,
|
threadViewModel.threadVariant == .contact,
|
||||||
let profile: Profile = threadViewModel.profile,
|
let profile: Profile = threadViewModel.profile,
|
||||||
|
@ -619,18 +749,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
let publicKey: String = threadViewModel.openGroupPublicKey
|
let publicKey: String = threadViewModel.openGroupPublicKey
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
dependencies.storage.writeAsync { db in
|
let communityUrl: String = SessionUtil.communityUrlFor(
|
||||||
let urlString: String = OpenGroup.urlFor(
|
|
||||||
server: server,
|
server: server,
|
||||||
roomToken: roomToken,
|
roomToken: roomToken,
|
||||||
publicKey: publicKey
|
publicKey: publicKey
|
||||||
)
|
)
|
||||||
|
|
||||||
|
dependencies.storage.writeAsync { db in
|
||||||
try selectedUsers.forEach { userId in
|
try selectedUsers.forEach { userId in
|
||||||
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: userId, variant: .contact)
|
let thread: SessionThread = try SessionThread
|
||||||
|
.fetchOrCreate(db, id: userId, variant: .contact, shouldBeVisible: nil)
|
||||||
|
|
||||||
try LinkPreview(
|
try LinkPreview(
|
||||||
url: urlString,
|
url: communityUrl,
|
||||||
variant: .openGroupInvitation,
|
variant: .openGroupInvitation,
|
||||||
title: name
|
title: name
|
||||||
)
|
)
|
||||||
|
@ -647,14 +778,15 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
.filter(DisappearingMessagesConfiguration.Columns.isEnabled == true)
|
||||||
.asRequest(of: TimeInterval.self)
|
.asRequest(of: TimeInterval.self)
|
||||||
.fetchOne(db),
|
.fetchOne(db),
|
||||||
linkPreviewUrl: urlString
|
linkPreviewUrl: communityUrl
|
||||||
)
|
)
|
||||||
.inserted(db)
|
.inserted(db)
|
||||||
|
|
||||||
try MessageSender.send(
|
try MessageSender.send(
|
||||||
db,
|
db,
|
||||||
interaction: interaction,
|
interaction: interaction,
|
||||||
in: thread
|
threadId: thread.id,
|
||||||
|
threadVariant: thread.variant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -671,13 +803,13 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
dependencies.storage.writeAsync(
|
dependencies.storage.writeAsync(
|
||||||
updates: { db in
|
updates: { db in
|
||||||
try Contact
|
try Contact
|
||||||
.fetchOrCreate(db, id: threadId)
|
.filter(id: threadId)
|
||||||
.with(isBlocked: .updateTo(isBlocked))
|
.updateAllAndConfig(
|
||||||
.save(db)
|
db,
|
||||||
|
Contact.Columns.isBlocked.set(to: isBlocked)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
completion: { [weak self] db, _ in
|
completion: { [weak self] db, _ in
|
||||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
let modal: ConfirmationModal = ConfirmationModal(
|
let modal: ConfirmationModal = ConfirmationModal(
|
||||||
info: ConfirmationModal.Info(
|
info: ConfirmationModal.Info(
|
||||||
|
@ -694,10 +826,12 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
displayName
|
displayName
|
||||||
)
|
)
|
||||||
)),
|
)),
|
||||||
accessibilityLabel: oldBlockedState == false ? "User blocked" : "Confirm unblock",
|
accessibility: Accessibility(
|
||||||
accessibilityId: "Test_name",
|
identifier: "Test_name",
|
||||||
|
label: (oldBlockedState == false ? "User blocked" : "Confirm unblock")
|
||||||
|
),
|
||||||
cancelTitle: "BUTTON_OK".localized(),
|
cancelTitle: "BUTTON_OK".localized(),
|
||||||
cancelAccessibilityLabel: "OK_BUTTON",
|
cancelAccessibility: Accessibility(identifier: "OK_BUTTON"),
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
@ -71,10 +71,13 @@ final class ConversationTitleView: UIView {
|
||||||
|
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
|
|
||||||
public func initialSetup(with threadVariant: SessionThread.Variant) {
|
public func initialSetup(
|
||||||
|
with threadVariant: SessionThread.Variant,
|
||||||
|
isNoteToSelf: Bool
|
||||||
|
) {
|
||||||
self.update(
|
self.update(
|
||||||
with: " ",
|
with: " ",
|
||||||
isNoteToSelf: false,
|
isNoteToSelf: isNoteToSelf,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
mutedUntilTimestamp: nil,
|
mutedUntilTimestamp: nil,
|
||||||
onlyNotifyForMentions: false,
|
onlyNotifyForMentions: false,
|
||||||
|
@ -139,9 +142,9 @@ final class ConversationTitleView: UIView {
|
||||||
|
|
||||||
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
guard Date().timeIntervalSince1970 > (mutedUntilTimestamp ?? 0) else {
|
||||||
subtitleLabel?.attributedText = NSAttributedString(
|
subtitleLabel?.attributedText = NSAttributedString(
|
||||||
string: "\u{e067} ",
|
string: FullConversationCell.mutePrefix,
|
||||||
attributes: [
|
attributes: [
|
||||||
.font: UIFont.ows_elegantIconsFont(10),
|
.font: UIFont(name: "ElegantIcons", size: 10) as Any,
|
||||||
.foregroundColor: textPrimary
|
.foregroundColor: textPrimary
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
@ -168,12 +171,12 @@ final class ConversationTitleView: UIView {
|
||||||
switch threadVariant {
|
switch threadVariant {
|
||||||
case .contact: break
|
case .contact: break
|
||||||
|
|
||||||
case .closedGroup:
|
case .legacyGroup, .group:
|
||||||
subtitleLabel?.attributedText = NSAttributedString(
|
subtitleLabel?.attributedText = NSAttributedString(
|
||||||
string: "\(userCount) member\(userCount == 1 ? "" : "s")"
|
string: "\(userCount) member\(userCount == 1 ? "" : "s")"
|
||||||
)
|
)
|
||||||
|
|
||||||
case .openGroup:
|
case .community:
|
||||||
subtitleLabel?.attributedText = NSAttributedString(
|
subtitleLabel?.attributedText = NSAttributedString(
|
||||||
string: "\(userCount) active member\(userCount == 1 ? "" : "s")"
|
string: "\(userCount) active member\(userCount == 1 ? "" : "s")"
|
||||||
)
|
)
|
||||||
|
|
|
@ -431,7 +431,8 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: cellViewModel,
|
id: cellViewModel,
|
||||||
leftAccessory: .profile(authorId, cellViewModel.profile),
|
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count),
|
||||||
|
leftAccessory: .profile(id: authorId, profile: cellViewModel.profile),
|
||||||
title: (
|
title: (
|
||||||
cellViewModel.profile?.displayName() ??
|
cellViewModel.profile?.displayName() ??
|
||||||
Profile.truncated(
|
Profile.truncated(
|
||||||
|
@ -446,10 +447,9 @@ extension ReactionListSheet: UITableViewDelegate, UITableViewDataSource {
|
||||||
size: .fit
|
size: .fit
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge),
|
||||||
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
isEnabled: (authorId == self.messageViewModel.currentUserPublicKey)
|
||||||
),
|
)
|
||||||
style: .edgeToEdge,
|
|
||||||
position: Position.with(indexPath.row, count: self.selectedReactionUserList.count)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
|
@ -3,8 +3,8 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
|
||||||
final class ScrollToBottomButton: UIView {
|
final class RoundIconButton: UIView {
|
||||||
private weak var delegate: ScrollToBottomButtonDelegate?
|
private let onTap: () -> ()
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
|
@ -13,12 +13,12 @@ final class ScrollToBottomButton: UIView {
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(delegate: ScrollToBottomButtonDelegate) {
|
init(image: UIImage?, onTap: @escaping () -> ()) {
|
||||||
self.delegate = delegate
|
self.onTap = onTap
|
||||||
|
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy(image: image)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -29,7 +29,7 @@ final class ScrollToBottomButton: UIView {
|
||||||
preconditionFailure("Use init(delegate:) instead.")
|
preconditionFailure("Use init(delegate:) instead.")
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy(image: UIImage?) {
|
||||||
// Background & blur
|
// Background & blur
|
||||||
let backgroundView = UIView()
|
let backgroundView = UIView()
|
||||||
backgroundView.themeBackgroundColor = .backgroundSecondary
|
backgroundView.themeBackgroundColor = .backgroundSecondary
|
||||||
|
@ -49,9 +49,9 @@ final class ScrollToBottomButton: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size & shape
|
// Size & shape
|
||||||
set(.width, to: ScrollToBottomButton.size)
|
set(.width, to: RoundIconButton.size)
|
||||||
set(.height, to: ScrollToBottomButton.size)
|
set(.height, to: RoundIconButton.size)
|
||||||
layer.cornerRadius = (ScrollToBottomButton.size / 2)
|
layer.cornerRadius = (RoundIconButton.size / 2)
|
||||||
layer.masksToBounds = true
|
layer.masksToBounds = true
|
||||||
|
|
||||||
// Border
|
// Border
|
||||||
|
@ -59,16 +59,13 @@ final class ScrollToBottomButton: UIView {
|
||||||
layer.borderWidth = Values.separatorThickness
|
layer.borderWidth = Values.separatorThickness
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
let iconImageView = UIImageView(
|
let iconImageView = UIImageView(image: image)
|
||||||
image: UIImage(named: "ic_chevron_down")?
|
|
||||||
.withRenderingMode(.alwaysTemplate)
|
|
||||||
)
|
|
||||||
iconImageView.themeTintColor = .textPrimary
|
iconImageView.themeTintColor = .textPrimary
|
||||||
iconImageView.contentMode = .scaleAspectFit
|
iconImageView.contentMode = .scaleAspectFit
|
||||||
addSubview(iconImageView)
|
addSubview(iconImageView)
|
||||||
iconImageView.center(in: self)
|
iconImageView.center(in: self)
|
||||||
iconImageView.set(.width, to: ScrollToBottomButton.iconSize)
|
iconImageView.set(.width, to: RoundIconButton.iconSize)
|
||||||
iconImageView.set(.height, to: ScrollToBottomButton.iconSize)
|
iconImageView.set(.height, to: RoundIconButton.iconSize)
|
||||||
|
|
||||||
// Gesture recognizer
|
// Gesture recognizer
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
|
@ -78,12 +75,6 @@ final class ScrollToBottomButton: UIView {
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
@objc private func handleTap() {
|
@objc private func handleTap() {
|
||||||
delegate?.handleScrollToBottomButtonTapped()
|
onTap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - ScrollToBottomButtonDelegate
|
|
||||||
|
|
||||||
protocol ScrollToBottomButtonDelegate: AnyObject {
|
|
||||||
func handleScrollToBottomButtonTapped()
|
|
||||||
}
|
|
|
@ -1,4 +1,5 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
extension Emoji {
|
extension Emoji {
|
||||||
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])
|
private static let availableCache: Atomic<[Emoji:Bool]> = Atomic([:])
|
||||||
|
|
|
@ -5,6 +5,7 @@ import PureLayout
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import NVActivityIndicatorView
|
import NVActivityIndicatorView
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
class EmptySearchResultCell: UITableViewCell {
|
class EmptySearchResultCell: UITableViewCell {
|
||||||
private lazy var messageLabel: UILabel = {
|
private lazy var messageLabel: UILabel = {
|
||||||
|
|
|
@ -7,8 +7,9 @@ import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
class GlobalSearchViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
|
||||||
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
fileprivate typealias SectionModel = ArraySection<SearchSection, SessionThreadViewModel>
|
||||||
|
|
||||||
// MARK: - SearchSection
|
// MARK: - SearchSection
|
||||||
|
@ -19,6 +20,15 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
case messages
|
case messages
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - SessionUtilRespondingViewController
|
||||||
|
|
||||||
|
let isConversationList: Bool = true
|
||||||
|
|
||||||
|
func forceRefreshIfNeeded() {
|
||||||
|
// Need to do this as the 'GlobalSearchViewController' doesn't observe database changes
|
||||||
|
updateSearchResults(searchText: searchText, force: true)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
private lazy var defaultSearchResults: [SectionModel] = {
|
private lazy var defaultSearchResults: [SectionModel] = {
|
||||||
|
@ -150,7 +160,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateSearchResults(searchText rawSearchText: String) {
|
private func updateSearchResults(searchText rawSearchText: String, force: Bool = false) {
|
||||||
let searchText = rawSearchText.stripped
|
let searchText = rawSearchText.stripped
|
||||||
|
|
||||||
guard searchText.count > 0 else {
|
guard searchText.count > 0 else {
|
||||||
|
@ -161,7 +171,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard lastSearchText != searchText else { return }
|
guard force || lastSearchText != searchText else { return }
|
||||||
|
|
||||||
lastSearchText = searchText
|
lastSearchText = searchText
|
||||||
|
|
||||||
|
@ -207,7 +217,14 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo
|
||||||
|
|
||||||
self?.termForCurrentSearchResultSet = searchText
|
self?.termForCurrentSearchResultSet = searchText
|
||||||
self?.searchResultSet = [
|
self?.searchResultSet = [
|
||||||
(hasResults ? nil : [ArraySection(model: .noResults, elements: [SessionThreadViewModel(unreadCount: 0)])]),
|
(hasResults ? nil : [
|
||||||
|
ArraySection(
|
||||||
|
model: .noResults,
|
||||||
|
elements: [
|
||||||
|
SessionThreadViewModel(threadId: SessionThreadViewModel.invalidId)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]),
|
||||||
(hasResults ? sections : nil)
|
(hasResults ? sections : nil)
|
||||||
]
|
]
|
||||||
.compactMap { $0 }
|
.compactMap { $0 }
|
||||||
|
@ -271,23 +288,46 @@ extension GlobalSearchViewController {
|
||||||
show(
|
show(
|
||||||
threadId: section.elements[indexPath.row].threadId,
|
threadId: section.elements[indexPath.row].threadId,
|
||||||
threadVariant: section.elements[indexPath.row].threadVariant,
|
threadVariant: section.elements[indexPath.row].threadVariant,
|
||||||
focusedInteractionId: section.elements[indexPath.row].interactionId
|
focusedInteractionInfo: {
|
||||||
|
guard
|
||||||
|
let interactionId: Int64 = section.elements[indexPath.row].interactionId,
|
||||||
|
let timestampMs: Int64 = section.elements[indexPath.row].interactionTimestampMs
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return Interaction.TimestampInfo(
|
||||||
|
id: interactionId,
|
||||||
|
timestampMs: timestampMs
|
||||||
|
)
|
||||||
|
}()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64? = nil, animated: Bool = true) {
|
private func show(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionInfo: Interaction.TimestampInfo? = nil, animated: Bool = true) {
|
||||||
guard Thread.isMainThread else {
|
guard Thread.isMainThread else {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId, animated: animated)
|
self?.show(threadId: threadId, threadVariant: threadVariant, focusedInteractionInfo: focusedInteractionInfo, animated: animated)
|
||||||
}
|
}
|
||||||
return
|
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(
|
let viewController: ConversationVC = ConversationVC(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
threadVariant: threadVariant,
|
threadVariant: threadVariant,
|
||||||
focusedInteractionId: focusedInteractionId
|
focusedInteractionInfo: focusedInteractionInfo
|
||||||
)
|
)
|
||||||
self.navigationController?.pushViewController(viewController, animated: true)
|
self.navigationController?.pushViewController(viewController, animated: true)
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,18 +8,24 @@ import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
|
final class HomeVC: BaseVC, SessionUtilRespondingViewController, UITableViewDataSource, UITableViewDelegate, SeedReminderViewDelegate {
|
||||||
private static let loadingHeaderHeight: CGFloat = 40
|
private static let loadingHeaderHeight: CGFloat = 40
|
||||||
public static let newConversationButtonSize: CGFloat = 60
|
public static let newConversationButtonSize: CGFloat = 60
|
||||||
|
|
||||||
private let viewModel: HomeViewModel = HomeViewModel()
|
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 hasLoadedInitialStateData: Bool = false
|
||||||
private var hasLoadedInitialThreadData: Bool = false
|
private var hasLoadedInitialThreadData: Bool = false
|
||||||
private var isLoadingMore: Bool = false
|
private var isLoadingMore: Bool = false
|
||||||
private var isAutoLoadingNextPage: Bool = false
|
private var isAutoLoadingNextPage: Bool = false
|
||||||
private var viewHasAppeared: Bool = false
|
private var viewHasAppeared: Bool = false
|
||||||
|
|
||||||
|
// MARK: - SessionUtilRespondingViewController
|
||||||
|
|
||||||
|
let isConversationList: Bool = true
|
||||||
|
|
||||||
// MARK: - Intialization
|
// MARK: - Intialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
@ -222,7 +228,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
// Preparation
|
// Preparation
|
||||||
SessionApp.homeViewController.mutate { $0 = self }
|
SessionApp.homeViewController.mutate { $0 = self }
|
||||||
|
|
||||||
updateNavBarButtons()
|
updateNavBarButtons(userProfile: self.viewModel.state.userProfile)
|
||||||
setUpNavBarSessionHeading()
|
setUpNavBarSessionHeading()
|
||||||
|
|
||||||
// Recovery phrase reminder
|
// Recovery phrase reminder
|
||||||
|
@ -278,11 +284,14 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate {
|
if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate {
|
||||||
appDelegate.startPollersIfNeeded()
|
appDelegate.startPollersIfNeeded()
|
||||||
|
|
||||||
|
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
|
||||||
|
if !SessionUtil.userConfigsEnabled {
|
||||||
// Do this only if we created a new Session ID, or if we already received the initial configuration message
|
// Do this only if we created a new Session ID, or if we already received the initial configuration message
|
||||||
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
|
if UserDefaults.standard[.hasSyncedInitialConfiguration] {
|
||||||
appDelegate.syncConfigurationIfNeeded()
|
appDelegate.syncConfigurationIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Onion request path countries cache
|
// Onion request path countries cache
|
||||||
IP2Country.shared.populateCacheIfNeededAsync()
|
IP2Country.shared.populateCacheIfNeededAsync()
|
||||||
|
@ -320,26 +329,31 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
|
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges(didReturnFromBackground: Bool = false) {
|
public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) {
|
||||||
// Start observing for data changes
|
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(
|
dataChangeObservable = Storage.shared.start(
|
||||||
viewModel.observableState,
|
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 },
|
onError: { _ in },
|
||||||
onChange: { [weak self] state in
|
onChange: { [weak self] state in
|
||||||
// The default scheduler emits changes on the main thread
|
// The default scheduler emits changes on the main thread
|
||||||
self?.handleUpdates(state)
|
self?.handleUpdates(state)
|
||||||
|
runAndClearInitialChangeCallback?()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
self.viewModel.onThreadChange = { [weak self] updatedThreadData, changeset in
|
||||||
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
self?.handleThreadUpdates(updatedThreadData, changeset: changeset)
|
||||||
|
runAndClearInitialChangeCallback?()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: When returning from the background we could have received notifications but the
|
// Note: When returning from the background we could have received notifications but the
|
||||||
|
@ -354,7 +368,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
|
|
||||||
private func stopObservingChanges() {
|
private func stopObservingChanges() {
|
||||||
// Stop observing database changes
|
// Stop observing database changes
|
||||||
dataChangeObservable?.cancel()
|
self.dataChangeObservable = nil
|
||||||
self.viewModel.onThreadChange = nil
|
self.viewModel.onThreadChange = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -368,7 +382,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
}
|
}
|
||||||
|
|
||||||
if updatedState.userProfile != self.viewModel.state.userProfile {
|
if updatedState.userProfile != self.viewModel.state.userProfile {
|
||||||
updateNavBarButtons()
|
updateNavBarButtons(userProfile: updatedState.userProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the 'view seed' UI
|
// Update the 'view seed' UI
|
||||||
|
@ -395,8 +409,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
// in from a frame of CGRect.zero)
|
// in from a frame of CGRect.zero)
|
||||||
guard hasLoadedInitialThreadData else {
|
guard hasLoadedInitialThreadData else {
|
||||||
hasLoadedInitialThreadData = true
|
|
||||||
|
|
||||||
UIView.performWithoutAnimation { [weak self] in
|
UIView.performWithoutAnimation { [weak self] in
|
||||||
// Hide the 'loading conversations' label (now that we have received conversation data)
|
// Hide the 'loading conversations' label (now that we have received conversation data)
|
||||||
self?.loadingConversationsLabel.isHidden = true
|
self?.loadingConversationsLabel.isHidden = true
|
||||||
|
@ -408,6 +420,8 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
)
|
)
|
||||||
|
|
||||||
self?.viewModel.updateThreadData(updatedData)
|
self?.viewModel.updateThreadData(updatedData)
|
||||||
|
self?.tableView.reloadData()
|
||||||
|
self?.hasLoadedInitialThreadData = true
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
@ -446,7 +460,11 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoLoadNextPageIfNeeded() {
|
private func autoLoadNextPageIfNeeded() {
|
||||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
guard
|
||||||
|
self.hasLoadedInitialThreadData &&
|
||||||
|
!self.isAutoLoadingNextPage &&
|
||||||
|
!self.isLoadingMore
|
||||||
|
else { return }
|
||||||
|
|
||||||
self.isAutoLoadingNextPage = true
|
self.isAutoLoadingNextPage = true
|
||||||
|
|
||||||
|
@ -475,21 +493,19 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateNavBarButtons() {
|
private func updateNavBarButtons(userProfile: Profile) {
|
||||||
// Profile picture view
|
// Profile picture view
|
||||||
let profilePictureSize = Values.verySmallProfilePictureSize
|
let profilePictureView = ProfilePictureView(size: .navigation)
|
||||||
let profilePictureView = ProfilePictureView()
|
|
||||||
profilePictureView.accessibilityIdentifier = "User settings"
|
profilePictureView.accessibilityIdentifier = "User settings"
|
||||||
profilePictureView.accessibilityLabel = "User settings"
|
profilePictureView.accessibilityLabel = "User settings"
|
||||||
profilePictureView.isAccessibilityElement = true
|
profilePictureView.isAccessibilityElement = true
|
||||||
profilePictureView.size = profilePictureSize
|
|
||||||
profilePictureView.update(
|
profilePictureView.update(
|
||||||
publicKey: getUserHexEncodedPublicKey(),
|
publicKey: userProfile.id,
|
||||||
profile: Profile.fetchOrCreateCurrentUser(),
|
threadVariant: .contact,
|
||||||
threadVariant: .contact
|
customImageData: nil,
|
||||||
|
profile: userProfile,
|
||||||
|
additionalProfile: nil
|
||||||
)
|
)
|
||||||
profilePictureView.set(.width, to: profilePictureSize)
|
|
||||||
profilePictureView.set(.height, to: profilePictureSize)
|
|
||||||
|
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(openSettings))
|
||||||
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
profilePictureView.addGestureRecognizer(tapGestureRecognizer)
|
||||||
|
@ -619,7 +635,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
variant: threadViewModel.threadVariant,
|
variant: threadViewModel.threadVariant,
|
||||||
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
|
isMessageRequest: (threadViewModel.threadIsMessageRequest == true),
|
||||||
with: .none,
|
with: .none,
|
||||||
focusedInteractionId: nil,
|
focusedInteractionInfo: nil,
|
||||||
animated: true
|
animated: true
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -631,241 +647,6 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
|
||||||
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
|
||||||
let unswipeAnimationDelay: DispatchTimeInterval = .milliseconds(500)
|
|
||||||
|
|
||||||
switch section.model {
|
|
||||||
case .messageRequests:
|
|
||||||
let hide: UIContextualAction = UIContextualAction(style: .destructive, title: "TXT_HIDE_TITLE".localized()) { _, _, completionHandler in
|
|
||||||
Storage.shared.write { db in db[.hasHiddenMessageRequests] = true }
|
|
||||||
completionHandler(true)
|
|
||||||
}
|
|
||||||
hide.themeBackgroundColor = .conversationButton_swipeDestructive
|
|
||||||
|
|
||||||
return UISwipeActionsConfiguration(actions: [hide])
|
|
||||||
|
|
||||||
case .threads:
|
|
||||||
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
|
||||||
guard threadViewModel.interactionVariant != .infoClosedGroupCurrentUserLeaving else { return nil }
|
|
||||||
|
|
||||||
let pin: UIContextualAction = UIContextualAction(
|
|
||||||
title: (threadViewModel.threadIsPinned ? "UNPIN_BUTTON_TEXT".localized() : "PIN_BUTTON_TEXT".localized()),
|
|
||||||
icon: UIImage(systemName: "pin"),
|
|
||||||
iconHeight: Values.mediumFontSize,
|
|
||||||
themeTintColor: .white,
|
|
||||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
|
||||||
side: .trailing,
|
|
||||||
actionIndex: 0,
|
|
||||||
indexPath: indexPath,
|
|
||||||
tableView: tableView
|
|
||||||
) { _, _, completionHandler in
|
|
||||||
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
|
|
||||||
isPinned: !threadViewModel.threadIsPinned
|
|
||||||
)
|
|
||||||
completionHandler(true)
|
|
||||||
|
|
||||||
// Delay the change to give the cell "unswipe" animation some time to complete
|
|
||||||
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
|
|
||||||
Storage.shared.writeAsync { db in
|
|
||||||
try SessionThread
|
|
||||||
.filter(id: threadViewModel.threadId)
|
|
||||||
.updateAll(db, SessionThread.Columns.isPinned.set(to: !threadViewModel.threadIsPinned))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pin.themeBackgroundColor = .conversationButton_swipeTertiary
|
|
||||||
|
|
||||||
let mute: UIContextualAction = UIContextualAction(
|
|
||||||
title: ((threadViewModel.threadMutedUntilTimestamp != nil) ? "unmute_button_text".localized() : "mute_button_text".localized()),
|
|
||||||
icon: UIImage(systemName: "speaker.slash"),
|
|
||||||
iconHeight: Values.mediumFontSize,
|
|
||||||
themeTintColor: .white,
|
|
||||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
|
||||||
side: .trailing,
|
|
||||||
actionIndex: 1,
|
|
||||||
indexPath: indexPath,
|
|
||||||
tableView: tableView
|
|
||||||
) { _, _, completionHandler in
|
|
||||||
(tableView.cellForRow(at: indexPath) as? FullConversationCell)?.optimisticUpdate(
|
|
||||||
isMuted: !(threadViewModel.threadMutedUntilTimestamp != nil)
|
|
||||||
)
|
|
||||||
completionHandler(true)
|
|
||||||
|
|
||||||
// Delay the change to give the cell "unswipe" animation some time to complete
|
|
||||||
DispatchQueue.global(qos: .default).asyncAfter(deadline: .now() + unswipeAnimationDelay) {
|
|
||||||
Storage.shared.writeAsync { db in
|
|
||||||
let currentValue: TimeInterval? = try SessionThread
|
|
||||||
.filter(id: threadViewModel.threadId)
|
|
||||||
.select(.mutedUntilTimestamp)
|
|
||||||
.asRequest(of: TimeInterval.self)
|
|
||||||
.fetchOne(db)
|
|
||||||
|
|
||||||
try SessionThread
|
|
||||||
.filter(id: threadViewModel.threadId)
|
|
||||||
.updateAll(
|
|
||||||
db,
|
|
||||||
SessionThread.Columns.mutedUntilTimestamp.set(
|
|
||||||
to: (currentValue == nil ?
|
|
||||||
Date.distantFuture.timeIntervalSince1970 :
|
|
||||||
nil
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mute.themeBackgroundColor = .conversationButton_swipeSecondary
|
|
||||||
|
|
||||||
switch (threadViewModel.threadVariant, threadViewModel.currentUserIsClosedGroupMember) {
|
|
||||||
case (.contact, _):
|
|
||||||
let delete: UIContextualAction = UIContextualAction(
|
|
||||||
title: "TXT_DELETE_TITLE".localized(),
|
|
||||||
icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)),
|
|
||||||
iconHeight: Values.mediumFontSize,
|
|
||||||
themeTintColor: .white,
|
|
||||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
|
||||||
side: .trailing,
|
|
||||||
actionIndex: 2,
|
|
||||||
indexPath: indexPath,
|
|
||||||
tableView: tableView
|
|
||||||
) { [weak self] _, _, completionHandler in
|
|
||||||
let confirmationModalExplanation: NSAttributedString = {
|
|
||||||
let mutableAttributedString = NSMutableAttributedString(
|
|
||||||
string: String(
|
|
||||||
format: "delete_conversation_confirmation_alert_message".localized(),
|
|
||||||
threadViewModel.displayName
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mutableAttributedString.addAttribute(
|
|
||||||
.font,
|
|
||||||
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
|
|
||||||
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
|
|
||||||
)
|
|
||||||
return mutableAttributedString
|
|
||||||
}()
|
|
||||||
|
|
||||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
||||||
info: ConfirmationModal.Info(
|
|
||||||
title: "delete_conversation_confirmation_alert_title".localized(),
|
|
||||||
body: .attributedText(confirmationModalExplanation),
|
|
||||||
confirmTitle: "TXT_DELETE_TITLE".localized(),
|
|
||||||
confirmStyle: .danger,
|
|
||||||
cancelStyle: .alert_text,
|
|
||||||
dismissOnConfirm: true,
|
|
||||||
onConfirm: { [weak self] _ in
|
|
||||||
self?.viewModel.delete(
|
|
||||||
threadId: threadViewModel.threadId,
|
|
||||||
threadVariant: threadViewModel.threadVariant
|
|
||||||
)
|
|
||||||
self?.dismiss(animated: true, completion: nil)
|
|
||||||
|
|
||||||
completionHandler(true)
|
|
||||||
},
|
|
||||||
afterClosed: { completionHandler(false) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self?.present(confirmationModal, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
|
||||||
|
|
||||||
return UISwipeActionsConfiguration(actions: [ delete, mute, pin ])
|
|
||||||
|
|
||||||
case (.closedGroup, false):
|
|
||||||
let delete: UIContextualAction = UIContextualAction(
|
|
||||||
title: "TXT_DELETE_TITLE".localized(),
|
|
||||||
icon: UIImage(named: "icon_bin")?.resizedImage(to: CGSize(width: Values.mediumFontSize, height: Values.mediumFontSize)),
|
|
||||||
iconHeight: Values.mediumFontSize,
|
|
||||||
themeTintColor: .white,
|
|
||||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
|
||||||
side: .trailing,
|
|
||||||
actionIndex: 2,
|
|
||||||
indexPath: indexPath,
|
|
||||||
tableView: tableView
|
|
||||||
) { [weak self] _, _, completionHandler in
|
|
||||||
self?.viewModel.delete(
|
|
||||||
threadId: threadViewModel.threadId,
|
|
||||||
threadVariant: threadViewModel.threadVariant,
|
|
||||||
force: true
|
|
||||||
)
|
|
||||||
|
|
||||||
completionHandler(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
return UISwipeActionsConfiguration(actions: [ delete, mute, pin ])
|
|
||||||
|
|
||||||
default:
|
|
||||||
let leave: UIContextualAction = UIContextualAction(
|
|
||||||
title: "LEAVE_BUTTON_TITLE".localized(),
|
|
||||||
icon: UIImage(systemName: "rectangle.portrait.and.arrow.right"),
|
|
||||||
iconHeight: Values.mediumFontSize,
|
|
||||||
themeTintColor: .white,
|
|
||||||
themeBackgroundColor: .conversationButton_swipeDestructive,
|
|
||||||
side: .trailing,
|
|
||||||
actionIndex: 2,
|
|
||||||
indexPath: indexPath,
|
|
||||||
tableView: tableView
|
|
||||||
) { [weak self] _, _, completionHandler in
|
|
||||||
let confirmationModalTitle: String = (threadViewModel.threadVariant == .closedGroup) ?
|
|
||||||
"leave_group_confirmation_alert_title".localized() :
|
|
||||||
"leave_community_confirmation_alert_title".localized()
|
|
||||||
|
|
||||||
let confirmationModalExplanation: NSAttributedString = {
|
|
||||||
if threadViewModel.threadVariant == .closedGroup && threadViewModel.currentUserIsClosedGroupAdmin == true {
|
|
||||||
return NSAttributedString(string: "admin_group_leave_warning".localized())
|
|
||||||
}
|
|
||||||
|
|
||||||
let mutableAttributedString = NSMutableAttributedString(
|
|
||||||
string: String(
|
|
||||||
format: "leave_community_confirmation_alert_message".localized(),
|
|
||||||
threadViewModel.displayName
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mutableAttributedString.addAttribute(
|
|
||||||
.font,
|
|
||||||
value: UIFont.boldSystemFont(ofSize: Values.smallFontSize),
|
|
||||||
range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName)
|
|
||||||
)
|
|
||||||
return mutableAttributedString
|
|
||||||
}()
|
|
||||||
|
|
||||||
let confirmationModal: ConfirmationModal = ConfirmationModal(
|
|
||||||
info: ConfirmationModal.Info(
|
|
||||||
title: confirmationModalTitle,
|
|
||||||
body: .attributedText(confirmationModalExplanation),
|
|
||||||
confirmTitle: "LEAVE_BUTTON_TITLE".localized(),
|
|
||||||
confirmStyle: .danger,
|
|
||||||
cancelStyle: .alert_text,
|
|
||||||
dismissOnConfirm: true,
|
|
||||||
onConfirm: { [weak self] _ in
|
|
||||||
self?.viewModel.delete(
|
|
||||||
threadId: threadViewModel.threadId,
|
|
||||||
threadVariant: threadViewModel.threadVariant
|
|
||||||
)
|
|
||||||
self?.dismiss(animated: true, completion: nil)
|
|
||||||
|
|
||||||
completionHandler(true)
|
|
||||||
},
|
|
||||||
afterClosed: { completionHandler(false) }
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self?.present(confirmationModal, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
leave.themeBackgroundColor = .conversationButton_swipeDestructive
|
|
||||||
|
|
||||||
return UISwipeActionsConfiguration(actions: [ leave, mute, pin ])
|
|
||||||
}
|
|
||||||
|
|
||||||
default: return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||||
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||||
}
|
}
|
||||||
|
@ -874,6 +655,100 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||||
|
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||||
|
|
||||||
|
switch section.model {
|
||||||
|
case .threads:
|
||||||
|
// Cannot properly sync outgoing blinded message requests so don't provide the option
|
||||||
|
guard
|
||||||
|
threadViewModel.threadVariant != .contact ||
|
||||||
|
SessionId(from: section.elements[indexPath.row].threadId)?.prefix == .standard
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return UIContextualAction.configuration(
|
||||||
|
for: UIContextualAction.generateSwipeActions(
|
||||||
|
[.toggleReadStatus],
|
||||||
|
for: .leading,
|
||||||
|
indexPath: indexPath,
|
||||||
|
tableView: tableView,
|
||||||
|
threadViewModel: threadViewModel,
|
||||||
|
viewController: self
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
|
let section: HomeViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||||
|
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||||
|
|
||||||
|
switch section.model {
|
||||||
|
case .messageRequests:
|
||||||
|
return UIContextualAction.configuration(
|
||||||
|
for: UIContextualAction.generateSwipeActions(
|
||||||
|
[.hide],
|
||||||
|
for: .trailing,
|
||||||
|
indexPath: indexPath,
|
||||||
|
tableView: tableView,
|
||||||
|
threadViewModel: threadViewModel,
|
||||||
|
viewController: self
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
case .threads:
|
||||||
|
let sessionIdPrefix: SessionId.Prefix? = SessionId(from: threadViewModel.threadId)?.prefix
|
||||||
|
|
||||||
|
// Cannot properly sync outgoing blinded message requests so only provide valid options
|
||||||
|
let shouldHavePinAction: Bool = (
|
||||||
|
sessionIdPrefix != .blinded15 &&
|
||||||
|
sessionIdPrefix != .blinded25
|
||||||
|
)
|
||||||
|
let shouldHaveMuteAction: Bool = {
|
||||||
|
switch threadViewModel.threadVariant {
|
||||||
|
case .contact: return (
|
||||||
|
!threadViewModel.threadIsNoteToSelf &&
|
||||||
|
sessionIdPrefix != .blinded15 &&
|
||||||
|
sessionIdPrefix != .blinded25
|
||||||
|
)
|
||||||
|
|
||||||
|
case .legacyGroup, .group: return (
|
||||||
|
threadViewModel.currentUserIsClosedGroupMember == true
|
||||||
|
)
|
||||||
|
|
||||||
|
case .community: return true
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
let destructiveAction: UIContextualAction.SwipeAction = {
|
||||||
|
switch (threadViewModel.threadVariant, threadViewModel.threadIsNoteToSelf, threadViewModel.currentUserIsClosedGroupMember) {
|
||||||
|
case (.contact, true, _): return .hide
|
||||||
|
case (.legacyGroup, _, true), (.group, _, true), (.community, _, _): return .leave
|
||||||
|
default: return .delete
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return UIContextualAction.configuration(
|
||||||
|
for: UIContextualAction.generateSwipeActions(
|
||||||
|
[
|
||||||
|
(!shouldHavePinAction ? nil : .pin),
|
||||||
|
(!shouldHaveMuteAction ? nil : .mute),
|
||||||
|
destructiveAction
|
||||||
|
].compactMap { $0 },
|
||||||
|
for: .trailing,
|
||||||
|
indexPath: indexPath,
|
||||||
|
tableView: tableView,
|
||||||
|
threadViewModel: threadViewModel,
|
||||||
|
viewController: self
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
default: return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {
|
func handleContinueButtonTapped(from seedReminderView: SeedReminderView) {
|
||||||
|
@ -887,7 +762,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
variant: SessionThread.Variant,
|
variant: SessionThread.Variant,
|
||||||
isMessageRequest: Bool,
|
isMessageRequest: Bool,
|
||||||
with action: ConversationViewModel.Action,
|
with action: ConversationViewModel.Action,
|
||||||
focusedInteractionId: Int64?,
|
focusedInteractionInfo: Interaction.TimestampInfo?,
|
||||||
animated: Bool
|
animated: Bool
|
||||||
) {
|
) {
|
||||||
if let presentedVC = self.presentedViewController {
|
if let presentedVC = self.presentedViewController {
|
||||||
|
@ -900,7 +775,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi
|
||||||
ConversationVC(
|
ConversationVC(
|
||||||
threadId: threadId,
|
threadId: threadId,
|
||||||
threadVariant: variant,
|
threadVariant: variant,
|
||||||
focusedInteractionId: focusedInteractionId
|
focusedInteractionInfo: focusedInteractionInfo
|
||||||
)
|
)
|
||||||
].compactMap { $0 }
|
].compactMap { $0 }
|
||||||
|
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Foundation
|
||||||
import GRDB
|
import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
public class HomeViewModel {
|
public class HomeViewModel {
|
||||||
|
@ -19,38 +20,45 @@ public class HomeViewModel {
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
public static let pageSize: Int = 15
|
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||||
|
|
||||||
public struct State: Equatable {
|
public struct State: Equatable {
|
||||||
let showViewedSeedBanner: Bool
|
let showViewedSeedBanner: Bool
|
||||||
let hasHiddenMessageRequests: Bool
|
let hasHiddenMessageRequests: Bool
|
||||||
let unreadMessageRequestThreadCount: Int
|
let unreadMessageRequestThreadCount: Int
|
||||||
let userProfile: Profile?
|
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init() {
|
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
|
self.pagedDataObserver = nil
|
||||||
|
|
||||||
// Note: Since this references self we need to finish initializing before setting it, we
|
// 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
|
// 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
|
// doesn't stutter (it should load basically immediately but without this there is a
|
||||||
// distinct stutter)
|
// distinct stutter)
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey()
|
let userPublicKey: String = self.state.userProfile.id
|
||||||
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
let thread: TypedTableAlias<SessionThread> = TypedTableAlias()
|
||||||
self.pagedDataObserver = PagedDatabaseObserver(
|
self.pagedDataObserver = PagedDatabaseObserver(
|
||||||
pagedTable: SessionThread.self,
|
pagedTable: SessionThread.self,
|
||||||
|
@ -62,9 +70,10 @@ public class HomeViewModel {
|
||||||
columns: [
|
columns: [
|
||||||
.id,
|
.id,
|
||||||
.shouldBeVisible,
|
.shouldBeVisible,
|
||||||
.isPinned,
|
.pinnedPriority,
|
||||||
.mutedUntilTimestamp,
|
.mutedUntilTimestamp,
|
||||||
.onlyNotifyForMentions
|
.onlyNotifyForMentions,
|
||||||
|
.markedAsUnread
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -76,7 +85,7 @@ public class HomeViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -85,7 +94,7 @@ public class HomeViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -93,8 +102,53 @@ public class HomeViewModel {
|
||||||
columns: [.name, .nickname, .profilePictureFileName],
|
columns: [.name, .nickname, .profilePictureFileName],
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias()
|
||||||
|
let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group]
|
||||||
|
let targetRole: GroupMember.Role = GroupMember.Role.standard
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
return SQL("""
|
||||||
|
JOIN \(Profile.self) ON (
|
||||||
|
( -- Contact profile change
|
||||||
|
\(profile[.id]) = \(thread[.id]) AND
|
||||||
|
\(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)"))
|
||||||
|
) OR ( -- Closed group profile change
|
||||||
|
\(SQL("\(thread[.variant]) IN \(threadVariants)")) AND (
|
||||||
|
profile.id = ( -- Front profile
|
||||||
|
SELECT MIN(\(groupMember[.profileId]))
|
||||||
|
FROM \(GroupMember.self)
|
||||||
|
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
|
||||||
|
WHERE (
|
||||||
|
\(groupMember[.groupId]) = \(thread[.id]) AND
|
||||||
|
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
|
||||||
|
\(groupMember[.profileId]) != \(userPublicKey)
|
||||||
|
)
|
||||||
|
) OR
|
||||||
|
profile.id = ( -- Back profile
|
||||||
|
SELECT MAX(\(groupMember[.profileId]))
|
||||||
|
FROM \(GroupMember.self)
|
||||||
|
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
|
||||||
|
WHERE (
|
||||||
|
\(groupMember[.groupId]) = \(thread[.id]) AND
|
||||||
|
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
|
||||||
|
\(groupMember[.profileId]) != \(userPublicKey)
|
||||||
|
)
|
||||||
|
) OR ( -- Fallback profile
|
||||||
|
profile.id = \(userPublicKey) AND
|
||||||
|
(
|
||||||
|
SELECT COUNT(\(groupMember[.profileId]))
|
||||||
|
FROM \(GroupMember.self)
|
||||||
|
JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId])
|
||||||
|
WHERE (
|
||||||
|
\(groupMember[.groupId]) = \(thread[.id]) AND
|
||||||
|
\(SQL("\(groupMember[.role]) = \(targetRole)")) AND
|
||||||
|
\(groupMember[.profileId]) != \(userPublicKey)
|
||||||
|
)
|
||||||
|
) = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
""")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -103,7 +157,7 @@ public class HomeViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
let closedGroup: TypedTableAlias<ClosedGroup> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
|
return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -112,7 +166,7 @@ public class HomeViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
|
return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -123,8 +177,8 @@ public class HomeViewModel {
|
||||||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||||
|
|
||||||
return """
|
return """
|
||||||
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||||
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||||
"""
|
"""
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
|
@ -134,7 +188,7 @@ public class HomeViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
let typingIndicator: TypedTableAlias<ThreadTypingIndicator> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
|
return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
|
@ -155,15 +209,22 @@ public class HomeViewModel {
|
||||||
currentDataRetriever: { self?.threadData },
|
currentDataRetriever: { self?.threadData },
|
||||||
onDataChange: self?.onThreadChange,
|
onDataChange: self?.onThreadChange,
|
||||||
onUnobservedDataChange: { updatedData, changeset in
|
onUnobservedDataChange: { updatedData, changeset in
|
||||||
self?.unobservedThreadDataChanges = (updatedData, changeset)
|
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||||
}
|
nil :
|
||||||
|
(updatedData, changeset)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Run the initial query on the main thread so we prevent the app from leaving the loading screen
|
self?.hasReceivedInitialThreadData = true
|
||||||
// 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
|
// MARK: - State
|
||||||
|
@ -181,6 +242,7 @@ public class HomeViewModel {
|
||||||
public lazy var observableState = ValueObservation
|
public lazy var observableState = ValueObservation
|
||||||
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
|
.trackingConstantRegion { db -> State in try HomeViewModel.retrieveState(db) }
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
|
.handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") })
|
||||||
|
|
||||||
private static func retrieveState(_ db: Database) throws -> State {
|
private static func retrieveState(_ db: Database) throws -> State {
|
||||||
let hasViewedSeed: Bool = db[.hasViewedSeed]
|
let hasViewedSeed: Bool = db[.hasViewedSeed]
|
||||||
|
@ -203,8 +265,10 @@ public class HomeViewModel {
|
||||||
let oldState: State = self.state
|
let oldState: State = self.state
|
||||||
self.state = updatedState
|
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
|
guard
|
||||||
|
self.hasReceivedInitialThreadData,
|
||||||
(
|
(
|
||||||
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests ||
|
||||||
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount
|
||||||
|
@ -215,7 +279,7 @@ public class HomeViewModel {
|
||||||
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
/// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above
|
||||||
let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
|
let currentData: [SectionModel] = (self.unobservedThreadDataChanges?.0 ?? self.threadData)
|
||||||
let updatedThreadData: [SectionModel] = self.process(
|
let updatedThreadData: [SectionModel] = self.process(
|
||||||
data: currentData.flatMap { $0.elements },
|
data: (currentData.first(where: { $0.model == .threads })?.elements ?? []),
|
||||||
for: currentPageInfo
|
for: currentPageInfo
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -223,14 +287,18 @@ public class HomeViewModel {
|
||||||
updatedData: updatedThreadData,
|
updatedData: updatedThreadData,
|
||||||
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
|
currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges?.0 ?? self?.threadData) },
|
||||||
onDataChange: onThreadChange,
|
onDataChange: onThreadChange,
|
||||||
onUnobservedDataChange: { [weak self] updatedThreadData, changeset in
|
onUnobservedDataChange: { [weak self] updatedData, changeset in
|
||||||
self?.unobservedThreadDataChanges = (updatedThreadData, changeset)
|
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||||
|
nil :
|
||||||
|
(updatedData, changeset)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Thread Data
|
// MARK: - Thread Data
|
||||||
|
|
||||||
|
private var hasReceivedInitialThreadData: Bool = false
|
||||||
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
public private(set) var unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>)?
|
||||||
public private(set) var threadData: [SectionModel] = []
|
public private(set) var threadData: [SectionModel] = []
|
||||||
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
public private(set) var pagedDataObserver: PagedDatabaseObserver<SessionThread, SessionThreadViewModel>?
|
||||||
|
@ -239,8 +307,14 @@ public class HomeViewModel {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// 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
|
// data was changed while we weren't observing
|
||||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||||
onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
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
|
self.unobservedThreadDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -264,7 +338,10 @@ public class HomeViewModel {
|
||||||
[SectionModel(
|
[SectionModel(
|
||||||
section: .messageRequests,
|
section: .messageRequests,
|
||||||
elements: [
|
elements: [
|
||||||
SessionThreadViewModel(unreadCount: UInt(finalUnreadMessageRequestCount))
|
SessionThreadViewModel(
|
||||||
|
threadId: SessionThreadViewModel.messageRequestsSectionId,
|
||||||
|
unreadCount: UInt(finalUnreadMessageRequestCount)
|
||||||
|
)
|
||||||
]
|
]
|
||||||
)]
|
)]
|
||||||
),
|
),
|
||||||
|
@ -272,18 +349,25 @@ public class HomeViewModel {
|
||||||
SectionModel(
|
SectionModel(
|
||||||
section: .threads,
|
section: .threads,
|
||||||
elements: data
|
elements: data
|
||||||
.filter { $0.id != SessionThreadViewModel.invalidId }
|
.filter { threadViewModel in
|
||||||
|
threadViewModel.id != SessionThreadViewModel.invalidId &&
|
||||||
|
threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId
|
||||||
|
}
|
||||||
.sorted { lhs, rhs -> Bool in
|
.sorted { lhs, rhs -> Bool in
|
||||||
if lhs.threadIsPinned && !rhs.threadIsPinned { return true }
|
guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else {
|
||||||
if !lhs.threadIsPinned && rhs.threadIsPinned { return false }
|
return lhs.threadPinnedPriority > rhs.threadPinnedPriority
|
||||||
|
}
|
||||||
|
|
||||||
return lhs.lastInteractionDate > rhs.lastInteractionDate
|
return lhs.lastInteractionDate > rhs.lastInteractionDate
|
||||||
}
|
}
|
||||||
.map { viewModel -> SessionThreadViewModel in
|
.map { viewModel -> SessionThreadViewModel in
|
||||||
viewModel.populatingCurrentUserBlindedKey(
|
viewModel.populatingCurrentUserBlindedKeys(
|
||||||
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||||
.first?
|
.first?
|
||||||
.currentUserBlindedPublicKey
|
.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||||
|
.first?
|
||||||
|
.currentUserBlinded25PublicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -298,32 +382,4 @@ public class HomeViewModel {
|
||||||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||||
self.threadData = updatedData
|
self.threadData = updatedData
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public func delete(threadId: String, threadVariant: SessionThread.Variant, force: Bool = false) {
|
|
||||||
|
|
||||||
func delete(_ db: Database, threadId: String) throws {
|
|
||||||
_ = try SessionThread
|
|
||||||
.filter(id: threadId)
|
|
||||||
.deleteAll(db)
|
|
||||||
}
|
|
||||||
|
|
||||||
Storage.shared.writeAsync { db in
|
|
||||||
switch (threadVariant, force) {
|
|
||||||
case (.closedGroup, false):
|
|
||||||
try MessageSender.leave(
|
|
||||||
db,
|
|
||||||
groupPublicKey: threadId,
|
|
||||||
deleteThread: true
|
|
||||||
)
|
|
||||||
|
|
||||||
case (.openGroup, _):
|
|
||||||
OpenGroupManager.shared.delete(db, openGroupId: threadId)
|
|
||||||
|
|
||||||
default:
|
|
||||||
try delete(db, threadId: threadId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,16 +7,19 @@ import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDataSource {
|
class MessageRequestsViewController: BaseVC, SessionUtilRespondingViewController, UITableViewDelegate, UITableViewDataSource {
|
||||||
private static let loadingHeaderHeight: CGFloat = 40
|
private static let loadingHeaderHeight: CGFloat = 40
|
||||||
|
|
||||||
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
private let viewModel: MessageRequestsViewModel = MessageRequestsViewModel()
|
||||||
private var dataChangeObservable: DatabaseCancellable?
|
|
||||||
private var hasLoadedInitialThreadData: Bool = false
|
private var hasLoadedInitialThreadData: Bool = false
|
||||||
private var isLoadingMore: Bool = false
|
private var isLoadingMore: Bool = false
|
||||||
private var isAutoLoadingNextPage: Bool = false
|
private var isAutoLoadingNextPage: Bool = false
|
||||||
private var viewHasAppeared: Bool = false
|
private var viewHasAppeared: Bool = false
|
||||||
|
|
||||||
|
// MARK: - SessionUtilRespondingViewController
|
||||||
|
|
||||||
|
let isConversationList: Bool = true
|
||||||
|
|
||||||
// MARK: - Intialization
|
// MARK: - Intialization
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
@ -103,6 +106,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
result.setTitle("MESSAGE_REQUESTS_CLEAR_ALL".localized(), for: .normal)
|
||||||
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
result.addTarget(self, action: #selector(clearAllTapped), for: .touchUpInside)
|
||||||
|
result.accessibilityIdentifier = "Clear all"
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -157,8 +161,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
override func viewWillDisappear(_ animated: Bool) {
|
override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
@objc func applicationDidBecomeActive(_ notification: Notification) {
|
||||||
|
@ -169,8 +172,7 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Layout
|
// MARK: - Layout
|
||||||
|
@ -219,6 +221,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopObservingChanges() {
|
||||||
|
self.viewModel.onThreadChange = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func handleThreadUpdates(
|
private func handleThreadUpdates(
|
||||||
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
_ updatedData: [MessageRequestsViewModel.SectionModel],
|
||||||
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
changeset: StagedChangeset<[MessageRequestsViewModel.SectionModel]>,
|
||||||
|
@ -227,9 +233,18 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
// in from a frame of CGRect.zero)
|
// in from a frame of CGRect.zero)
|
||||||
guard hasLoadedInitialThreadData else {
|
guard hasLoadedInitialThreadData else {
|
||||||
hasLoadedInitialThreadData = true
|
|
||||||
UIView.performWithoutAnimation {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
@ -266,7 +281,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoLoadNextPageIfNeeded() {
|
private func autoLoadNextPageIfNeeded() {
|
||||||
guard !self.isAutoLoadingNextPage && !self.isLoadingMore else { return }
|
guard
|
||||||
|
self.hasLoadedInitialThreadData &&
|
||||||
|
!self.isAutoLoadingNextPage &&
|
||||||
|
!self.isLoadingMore
|
||||||
|
else { return }
|
||||||
|
|
||||||
self.isAutoLoadingNextPage = true
|
self.isAutoLoadingNextPage = true
|
||||||
|
|
||||||
|
@ -393,31 +412,33 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, willBeginEditingRowAt indexPath: IndexPath) {
|
||||||
|
UIContextualAction.willBeginEditing(indexPath: indexPath, tableView: tableView)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tableView(_ tableView: UITableView, didEndEditingRowAt indexPath: IndexPath?) {
|
||||||
|
UIContextualAction.didEndEditing(indexPath: indexPath, tableView: tableView)
|
||||||
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
|
||||||
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
let section: MessageRequestsViewModel.SectionModel = self.viewModel.threadData[indexPath.section]
|
||||||
|
let threadViewModel: SessionThreadViewModel = section.elements[indexPath.row]
|
||||||
|
|
||||||
switch section.model {
|
switch section.model {
|
||||||
case .threads:
|
case .threads:
|
||||||
let threadId: String = section.elements[indexPath.row].threadId
|
return UIContextualAction.configuration(
|
||||||
let delete: UIContextualAction = UIContextualAction(
|
for: UIContextualAction.generateSwipeActions(
|
||||||
style: .destructive,
|
[
|
||||||
title: "TXT_DELETE_TITLE".localized()
|
(threadViewModel.threadVariant != .contact ? nil : .block),
|
||||||
) { [weak self] _, _, completionHandler in
|
.delete
|
||||||
self?.delete(threadId)
|
].compactMap { $0 },
|
||||||
completionHandler(true)
|
for: .trailing,
|
||||||
}
|
indexPath: indexPath,
|
||||||
delete.themeBackgroundColor = .conversationButton_swipeDestructive
|
tableView: tableView,
|
||||||
|
threadViewModel: threadViewModel,
|
||||||
let block: UIContextualAction = UIContextualAction(
|
viewController: self
|
||||||
style: .normal,
|
)
|
||||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized()
|
)
|
||||||
) { [weak self] _, _, completionHandler in
|
|
||||||
self?.block(threadId)
|
|
||||||
completionHandler(true)
|
|
||||||
}
|
|
||||||
block.themeBackgroundColor = .conversationButton_swipeSecondary
|
|
||||||
|
|
||||||
return UISwipeActionsConfiguration(actions: [ delete, block ])
|
|
||||||
|
|
||||||
default: return nil
|
default: return nil
|
||||||
}
|
}
|
||||||
|
@ -430,9 +451,16 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let threadIds: [String] = (viewModel.threadData
|
let contactThreadIds: [String] = (viewModel.threadData
|
||||||
.first { $0.model == .threads }?
|
.first { $0.model == .threads }?
|
||||||
.elements
|
.elements
|
||||||
|
.filter { $0.threadVariant == .contact }
|
||||||
|
.map { $0.threadId })
|
||||||
|
.defaulting(to: [])
|
||||||
|
let groupThreadIds: [String] = (viewModel.threadData
|
||||||
|
.first { $0.model == .threads }?
|
||||||
|
.elements
|
||||||
|
.filter { $0.threadVariant == .legacyGroup || $0.threadVariant == .group }
|
||||||
.map { $0.threadId })
|
.map { $0.threadId })
|
||||||
.defaulting(to: [])
|
.defaulting(to: [])
|
||||||
let alertVC: UIAlertController = UIAlertController(
|
let alertVC: UIAlertController = UIAlertController(
|
||||||
|
@ -444,69 +472,11 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat
|
||||||
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
title: "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON".localized(),
|
||||||
style: .destructive
|
style: .destructive
|
||||||
) { _ in
|
) { _ in
|
||||||
// Clear the requests
|
MessageRequestsViewModel.clearAllRequests(
|
||||||
Storage.shared.write { db in
|
contactThreadIds: contactThreadIds,
|
||||||
_ = try SessionThread
|
groupThreadIds: groupThreadIds
|
||||||
.filter(ids: threadIds)
|
|
||||||
.deleteAll(db)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
|
||||||
|
|
||||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
|
||||||
self.present(alertVC, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func delete(_ threadId: String) {
|
|
||||||
let alertVC: UIAlertController = UIAlertController(
|
|
||||||
title: "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON".localized(),
|
|
||||||
message: nil,
|
|
||||||
preferredStyle: .actionSheet
|
|
||||||
)
|
)
|
||||||
alertVC.addAction(UIAlertAction(
|
|
||||||
title: "TXT_DELETE_TITLE".localized(),
|
|
||||||
style: .destructive
|
|
||||||
) { _ in
|
|
||||||
Storage.shared.write { db in
|
|
||||||
_ = try SessionThread
|
|
||||||
.filter(id: threadId)
|
|
||||||
.deleteAll(db)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
|
||||||
|
|
||||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
|
||||||
self.present(alertVC, animated: true, completion: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func block(_ threadId: String) {
|
|
||||||
let alertVC: UIAlertController = UIAlertController(
|
|
||||||
title: "MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON".localized(),
|
|
||||||
message: nil,
|
|
||||||
preferredStyle: .actionSheet
|
|
||||||
)
|
|
||||||
alertVC.addAction(UIAlertAction(
|
|
||||||
title: "BLOCK_LIST_BLOCK_BUTTON".localized(),
|
|
||||||
style: .destructive
|
|
||||||
) { _ in
|
|
||||||
Storage.shared.write { db in
|
|
||||||
_ = try SessionThread
|
|
||||||
.filter(id: threadId)
|
|
||||||
.deleteAll(db)
|
|
||||||
_ = try Contact
|
|
||||||
.fetchOrCreate(db, id: threadId)
|
|
||||||
.with(
|
|
||||||
isApproved: false,
|
|
||||||
isBlocked: true
|
|
||||||
)
|
|
||||||
.saved(db)
|
|
||||||
|
|
||||||
// Force a config sync
|
|
||||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
alertVC.addAction(UIAlertAction(title: "TXT_CANCEL_TITLE".localized(), style: .cancel, handler: nil))
|
||||||
|
|
||||||
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
Modal.setupForIPadIfNeeded(alertVC, targetView: self.view)
|
||||||
|
|
|
@ -17,7 +17,7 @@ public class MessageRequestsViewModel {
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
public static let pageSize: Int = 15
|
public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15)
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
@ -51,7 +51,7 @@ public class MessageRequestsViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
let interaction: TypedTableAlias<Interaction> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -60,7 +60,7 @@ public class MessageRequestsViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
let contact: TypedTableAlias<Contact> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -69,7 +69,7 @@ public class MessageRequestsViewModel {
|
||||||
joinToPagedType: {
|
joinToPagedType: {
|
||||||
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
let profile: TypedTableAlias<Profile> = TypedTableAlias()
|
||||||
|
|
||||||
return SQL("LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
return SQL("JOIN \(Profile.self) ON \(profile[.id]) = \(thread[.id])")
|
||||||
}()
|
}()
|
||||||
),
|
),
|
||||||
PagedData.ObservedChanges(
|
PagedData.ObservedChanges(
|
||||||
|
@ -80,8 +80,8 @@ public class MessageRequestsViewModel {
|
||||||
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias()
|
||||||
|
|
||||||
return """
|
return """
|
||||||
LEFT JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])
|
||||||
LEFT JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id])
|
||||||
"""
|
"""
|
||||||
}()
|
}()
|
||||||
)
|
)
|
||||||
|
@ -103,7 +103,10 @@ public class MessageRequestsViewModel {
|
||||||
currentDataRetriever: { self?.threadData },
|
currentDataRetriever: { self?.threadData },
|
||||||
onDataChange: self?.onThreadChange,
|
onDataChange: self?.onThreadChange,
|
||||||
onUnobservedDataChange: { updatedData, changeset in
|
onUnobservedDataChange: { updatedData, changeset in
|
||||||
self?.unobservedThreadDataChanges = (updatedData, changeset)
|
self?.unobservedThreadDataChanges = (changeset.isEmpty ?
|
||||||
|
nil :
|
||||||
|
(updatedData, changeset)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -126,8 +129,14 @@ public class MessageRequestsViewModel {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// 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
|
// data was changed while we weren't observing
|
||||||
if let unobservedThreadDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedThreadDataChanges {
|
||||||
self.onThreadChange?(unobservedThreadDataChanges.0, unobservedThreadDataChanges.1)
|
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
|
self.unobservedThreadDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -147,10 +156,13 @@ public class MessageRequestsViewModel {
|
||||||
elements: data
|
elements: data
|
||||||
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
|
.sorted { lhs, rhs -> Bool in lhs.lastInteractionDate > rhs.lastInteractionDate }
|
||||||
.map { viewModel -> SessionThreadViewModel in
|
.map { viewModel -> SessionThreadViewModel in
|
||||||
viewModel.populatingCurrentUserBlindedKey(
|
viewModel.populatingCurrentUserBlindedKeys(
|
||||||
currentUserBlindedPublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||||
.first?
|
.first?
|
||||||
.currentUserBlindedPublicKey
|
.currentUserBlinded15PublicKey,
|
||||||
|
currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]?
|
||||||
|
.first?
|
||||||
|
.currentUserBlinded25PublicKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
@ -165,4 +177,32 @@ public class MessageRequestsViewModel {
|
||||||
public func updateThreadData(_ updatedData: [SectionModel]) {
|
public func updateThreadData(_ updatedData: [SectionModel]) {
|
||||||
self.threadData = updatedData
|
self.threadData = updatedData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Functions
|
||||||
|
|
||||||
|
static func clearAllRequests(
|
||||||
|
contactThreadIds: [String],
|
||||||
|
groupThreadIds: [String]
|
||||||
|
) {
|
||||||
|
// Clear the requests
|
||||||
|
Storage.shared.write { db in
|
||||||
|
// Remove the one-to-one requests
|
||||||
|
try SessionThread.deleteOrLeave(
|
||||||
|
db,
|
||||||
|
threadIds: contactThreadIds,
|
||||||
|
threadVariant: .contact,
|
||||||
|
groupLeaveType: .silent,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
|
||||||
|
// Remove the group requests
|
||||||
|
try SessionThread.deleteOrLeave(
|
||||||
|
db,
|
||||||
|
threadIds: groupThreadIds,
|
||||||
|
threadVariant: .group,
|
||||||
|
groupLeaveType: .silent,
|
||||||
|
calledFromConfigHandling: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
class MessageRequestsCell: UITableViewCell {
|
class MessageRequestsCell: UITableViewCell {
|
||||||
static let reuseIdentifier = "MessageRequestsCell"
|
static let reuseIdentifier = "MessageRequestsCell"
|
||||||
|
@ -29,7 +30,7 @@ class MessageRequestsCell: UITableViewCell {
|
||||||
result.translatesAutoresizingMaskIntoConstraints = false
|
result.translatesAutoresizingMaskIntoConstraints = false
|
||||||
result.clipsToBounds = true
|
result.clipsToBounds = true
|
||||||
result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
|
result.themeBackgroundColor = .conversationButton_unreadBubbleBackground
|
||||||
result.layer.cornerRadius = (Values.mediumProfilePictureSize / 2)
|
result.layer.cornerRadius = (ProfilePictureView.Size.list.viewSize / 2)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -100,8 +101,8 @@ class MessageRequestsCell: UITableViewCell {
|
||||||
constant: (Values.accentLineThickness + Values.mediumSpacing)
|
constant: (Values.accentLineThickness + Values.mediumSpacing)
|
||||||
),
|
),
|
||||||
iconContainerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
iconContainerView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
|
||||||
iconContainerView.widthAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize),
|
iconContainerView.widthAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize),
|
||||||
iconContainerView.heightAnchor.constraint(equalToConstant: Values.mediumProfilePictureSize),
|
iconContainerView.heightAnchor.constraint(equalToConstant: ProfilePictureView.Size.list.viewSize),
|
||||||
|
|
||||||
iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor),
|
iconImageView.centerXAnchor.constraint(equalTo: iconContainerView.centerXAnchor),
|
||||||
iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor),
|
iconImageView.centerYAnchor.constraint(equalTo: iconContainerView.centerYAnchor),
|
||||||
|
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource {
|
final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UITableViewDataSource {
|
||||||
private let newConversationViewModel = NewConversationViewModel()
|
private let newConversationViewModel = NewConversationViewModel()
|
||||||
|
@ -143,13 +143,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
|
||||||
cell.update(
|
cell.update(
|
||||||
with: SessionCell.Info(
|
with: SessionCell.Info(
|
||||||
id: profile,
|
id: profile,
|
||||||
leftAccessory: .profile(profile.id, profile),
|
|
||||||
title: profile.displayName()
|
|
||||||
),
|
|
||||||
style: .edgeToEdge,
|
|
||||||
position: Position.with(
|
position: Position.with(
|
||||||
indexPath.row,
|
indexPath.row,
|
||||||
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
count: newConversationViewModel.sectionData[indexPath.section].contacts.count
|
||||||
|
),
|
||||||
|
leftAccessory: .profile(id: profile.id, profile: profile),
|
||||||
|
title: profile.displayName(),
|
||||||
|
styling: SessionCell.StyleInfo(backgroundStyle: .edgeToEdge)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -179,15 +179,13 @@ final class NewConversationVC: BaseVC, ThemedNavigation, UITableViewDelegate, UI
|
||||||
tableView.deselectRow(at: indexPath, animated: true)
|
tableView.deselectRow(at: indexPath, animated: true)
|
||||||
|
|
||||||
let sessionId = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row].id
|
let sessionId = newConversationViewModel.sectionData[indexPath.section].contacts[indexPath.row].id
|
||||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
|
||||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
|
||||||
}
|
|
||||||
|
|
||||||
guard maybeThread != nil else { return }
|
SessionApp.presentConversationCreatingIfNeeded(
|
||||||
|
for: sessionId,
|
||||||
self.navigationController?.dismiss(animated: true, completion: nil)
|
variant: .contact,
|
||||||
|
dismissing: navigationController,
|
||||||
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
animated: false
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
func tableView(_ tableView: UITableView, willDisplayHeaderView view: UIView, forSection section: Int) {
|
||||||
|
|
|
@ -3,7 +3,6 @@
|
||||||
import UIKit
|
import UIKit
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import GRDB
|
import GRDB
|
||||||
import Curve25519Kit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
@ -166,16 +165,45 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
||||||
dismiss(animated: true, completion: nil)
|
dismiss(animated: true, completion: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String) {
|
func controller(_ controller: QRCodeScanningViewController, didDetectQRCodeWith string: String, onError: (() -> ())?) {
|
||||||
let hexEncodedPublicKey = string
|
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)
|
let maybeSessionId: SessionId? = SessionId(from: onsNameOrPublicKey)
|
||||||
|
|
||||||
if ECKeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) && maybeSessionId?.prefix == .standard {
|
if KeyPair.isValidHexEncodedPublicKey(candidate: onsNameOrPublicKey) {
|
||||||
|
switch maybeSessionId?.prefix {
|
||||||
|
case .standard:
|
||||||
startNewDM(with: onsNameOrPublicKey)
|
startNewDM(with: onsNameOrPublicKey)
|
||||||
|
|
||||||
|
case .blinded15, .blinded25:
|
||||||
|
let modal: ConfirmationModal = ConfirmationModal(
|
||||||
|
targetView: self.view,
|
||||||
|
info: ConfirmationModal.Info(
|
||||||
|
title: "ALERT_ERROR_TITLE".localized(),
|
||||||
|
body: .text("DM_ERROR_DIRECT_BLINDED_ID".localized()),
|
||||||
|
cancelTitle: "BUTTON_OK".localized(),
|
||||||
|
cancelStyle: .alert_text,
|
||||||
|
afterClosed: onError
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.present(modal, animated: true)
|
||||||
|
|
||||||
|
default:
|
||||||
|
let modal: ConfirmationModal = ConfirmationModal(
|
||||||
|
targetView: self.view,
|
||||||
|
info: ConfirmationModal.Info(
|
||||||
|
title: "ALERT_ERROR_TITLE".localized(),
|
||||||
|
body: .text("DM_ERROR_INVALID".localized()),
|
||||||
|
cancelTitle: "BUTTON_OK".localized(),
|
||||||
|
cancelStyle: .alert_text,
|
||||||
|
afterClosed: onError
|
||||||
|
)
|
||||||
|
)
|
||||||
|
self.present(modal, animated: true)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -184,12 +212,13 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
||||||
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
.present(fromViewController: navigationController!, canCancel: false) { [weak self] modalActivityIndicator in
|
||||||
SnodeAPI
|
SnodeAPI
|
||||||
.getSessionID(for: onsNameOrPublicKey)
|
.getSessionID(for: onsNameOrPublicKey)
|
||||||
.done { sessionID in
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
modalActivityIndicator.dismiss {
|
.receive(on: DispatchQueue.main)
|
||||||
self?.startNewDM(with: sessionID)
|
.sinkUntilComplete(
|
||||||
}
|
receiveCompletion: { result in
|
||||||
}
|
switch result {
|
||||||
.catch { error in
|
case .finished: break
|
||||||
|
case .failure(let error):
|
||||||
modalActivityIndicator.dismiss {
|
modalActivityIndicator.dismiss {
|
||||||
var messageOrNil: String?
|
var messageOrNil: String?
|
||||||
if let error = error as? SnodeAPIError {
|
if let error = error as? SnodeAPIError {
|
||||||
|
@ -204,7 +233,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
||||||
return messageOrNil
|
return messageOrNil
|
||||||
}
|
}
|
||||||
|
|
||||||
return (maybeSessionId?.prefix == .blinded ?
|
return (maybeSessionId?.prefix == .blinded15 || maybeSessionId?.prefix == .blinded25 ?
|
||||||
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
"DM_ERROR_DIRECT_BLINDED_ID".localized() :
|
||||||
"DM_ERROR_INVALID".localized()
|
"DM_ERROR_INVALID".localized()
|
||||||
)
|
)
|
||||||
|
@ -216,25 +245,30 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle
|
||||||
title: "ALERT_ERROR_TITLE".localized(),
|
title: "ALERT_ERROR_TITLE".localized(),
|
||||||
body: .text(message),
|
body: .text(message),
|
||||||
cancelTitle: "BUTTON_OK".localized(),
|
cancelTitle: "BUTTON_OK".localized(),
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text,
|
||||||
|
afterClosed: onError
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self?.present(modal, animated: true)
|
self?.present(modal, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
receiveValue: { sessionId in
|
||||||
|
modalActivityIndicator.dismiss {
|
||||||
|
self?.startNewDM(with: sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func startNewDM(with sessionId: String) {
|
private func startNewDM(with sessionId: String) {
|
||||||
let maybeThread: SessionThread? = Storage.shared.write { db in
|
SessionApp.presentConversationCreatingIfNeeded(
|
||||||
try SessionThread.fetchOrCreate(db, id: sessionId, variant: .contact)
|
for: sessionId,
|
||||||
}
|
variant: .contact,
|
||||||
|
dismissing: presentingViewController,
|
||||||
guard maybeThread != nil else { return }
|
animated: false
|
||||||
|
)
|
||||||
presentingViewController?.dismiss(animated: true, completion: nil)
|
|
||||||
|
|
||||||
SessionApp.presentConversation(for: sessionId, action: .compose, animated: false)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -632,7 +666,7 @@ private final class EnterPublicKeyVC: UIViewController {
|
||||||
|
|
||||||
@objc fileprivate func startNewDMIfPossible() {
|
@objc fileprivate func startNewDMIfPossible() {
|
||||||
let text = publicKeyTextView.text?.trimmingCharacters(in: .whitespaces) ?? ""
|
let text = publicKeyTextView.text?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||||
NewDMVC.startNewDMIfPossible(with: text)
|
NewDMVC.startNewDMIfPossible(with: text, onError: nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@ import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public class AllMediaViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
public class AllMediaViewController: UIViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate {
|
||||||
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
private let pageVC = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import MediaPlayer
|
import MediaPlayer
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
// This kind of view is tricky. I've tried to organize things in the
|
// This kind of view is tricky. I've tried to organize things in the
|
||||||
// simplest possible way.
|
// simplest possible way.
|
||||||
|
@ -32,7 +31,7 @@ import SignalUtilitiesKit
|
||||||
|
|
||||||
let srcImage: UIImage
|
let srcImage: UIImage
|
||||||
|
|
||||||
let successCompletion: ((UIImage) -> Void)
|
let successCompletion: ((Data) -> Void)
|
||||||
|
|
||||||
var imageView: UIView!
|
var imageView: UIView!
|
||||||
|
|
||||||
|
@ -79,7 +78,7 @@ import SignalUtilitiesKit
|
||||||
notImplemented()
|
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.
|
// normalized() can be slightly expensive but in practice this is fine.
|
||||||
self.srcImage = srcImage.normalized()
|
self.srcImage = srcImage.normalized()
|
||||||
self.successCompletion = successCompletion
|
self.successCompletion = successCompletion
|
||||||
|
@ -487,10 +486,9 @@ import SignalUtilitiesKit
|
||||||
@objc func donePressed(sender: UIButton) {
|
@objc func donePressed(sender: UIButton) {
|
||||||
let successCompletion = self.successCompletion
|
let successCompletion = self.successCompletion
|
||||||
dismiss(animated: true, completion: {
|
dismiss(animated: true, completion: {
|
||||||
guard let dstImage = self.generateDstImage() else {
|
guard let dstImageData: Data = self.generateDstImageData() else { return }
|
||||||
return
|
|
||||||
}
|
successCompletion(dstImageData)
|
||||||
successCompletion(dstImage)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -517,4 +515,8 @@ import SignalUtilitiesKit
|
||||||
UIGraphicsEndImageContext()
|
UIGraphicsEndImageContext()
|
||||||
return scaledImage
|
return scaledImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func generateDstImageData() -> Data? {
|
||||||
|
return generateDstImage().map { $0.pngData() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
|
public class DocumentTileViewController: UIViewController, UITableViewDelegate, UITableViewDataSource {
|
||||||
|
|
||||||
|
@ -152,7 +153,7 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoLoadNextPageIfNeeded() {
|
private func autoLoadNextPageIfNeeded() {
|
||||||
guard !self.isAutoLoadingNextPage else { return }
|
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
|
||||||
|
|
||||||
self.isAutoLoadingNextPage = true
|
self.isAutoLoadingNextPage = true
|
||||||
|
|
||||||
|
@ -203,11 +204,11 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate,
|
||||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
// in from a frame of CGRect.zero)
|
// in from a frame of CGRect.zero)
|
||||||
guard hasLoadedInitialData else {
|
guard hasLoadedInitialData else {
|
||||||
self.hasLoadedInitialData = true
|
|
||||||
self.viewModel.updateGalleryData(updatedGalleryData)
|
self.viewModel.updateGalleryData(updatedGalleryData)
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
UIView.performWithoutAnimation {
|
||||||
self.tableView.reloadData()
|
self.tableView.reloadData()
|
||||||
|
self.hasLoadedInitialData = true
|
||||||
self.performInitialScrollIfNeeded()
|
self.performInitialScrollIfNeeded()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
@ -499,7 +500,7 @@ class DocumentCell: UITableViewCell {
|
||||||
func update(with item: MediaGalleryViewModel.Item) {
|
func update(with item: MediaGalleryViewModel.Item) {
|
||||||
let attachment = item.attachment
|
let attachment = item.attachment
|
||||||
titleLabel.text = (attachment.sourceFilename ?? "File")
|
titleLabel.text = (attachment.sourceFilename ?? "File")
|
||||||
detailLabel.text = "\(OWSFormat.formatFileSize(UInt(attachment.byteCount)))"
|
detailLabel.text = "\(Format.fileSize(attachment.byteCount)))"
|
||||||
timeLabel.text = Date(
|
timeLabel.text = Date(
|
||||||
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
timeIntervalSince1970: TimeInterval(item.interactionTimestampMs / 1000)
|
||||||
).formattedForDisplay
|
).formattedForDisplay
|
||||||
|
|
|
@ -1,12 +1,10 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PromiseKit
|
import Combine
|
||||||
import SignalUtilitiesKit
|
|
||||||
import SignalUtilitiesKit
|
|
||||||
import YYImage
|
import YYImage
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
class GifPickerCell: UICollectionViewCell {
|
class GifPickerCell: UICollectionViewCell {
|
||||||
|
|
||||||
|
@ -222,7 +220,7 @@ class GifPickerCell: UICollectionViewCell {
|
||||||
self.themeBackgroundColor = nil
|
self.themeBackgroundColor = nil
|
||||||
|
|
||||||
if self.isCellSelected {
|
if self.isCellSelected {
|
||||||
let activityIndicator = UIActivityIndicatorView(style: .gray)
|
let activityIndicator = UIActivityIndicatorView(style: .medium)
|
||||||
self.activityIndicator = activityIndicator
|
self.activityIndicator = activityIndicator
|
||||||
addSubview(activityIndicator)
|
addSubview(activityIndicator)
|
||||||
activityIndicator.autoCenterInSuperview()
|
activityIndicator.autoCenterInSuperview()
|
||||||
|
@ -245,29 +243,27 @@ class GifPickerCell: UICollectionViewCell {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func requestRenditionForSending() -> Promise<ProxiedContentAsset> {
|
public func requestRenditionForSending() -> AnyPublisher<ProxiedContentAsset, Error> {
|
||||||
guard let renditionForSending = self.renditionForSending else {
|
guard let renditionForSending = self.renditionForSending else {
|
||||||
owsFailDebug("renditionForSending was unexpectedly nil")
|
owsFailDebug("renditionForSending was unexpectedly nil")
|
||||||
return Promise(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil"))
|
return Fail(error: GiphyError.assertionError(description: "renditionForSending was unexpectedly nil"))
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
let (promise, resolver) = Promise<ProxiedContentAsset>.pending()
|
|
||||||
|
|
||||||
// We don't retain a handle on the asset request, since there will only ever
|
// We don't retain a handle on the asset request, since there will only ever
|
||||||
// be one selected asset, and we never want to cancel it.
|
// be one selected asset, and we never want to cancel it.
|
||||||
_ = GiphyDownloader.giphyDownloader.requestAsset(assetDescription: renditionForSending,
|
return GiphyDownloader.giphyDownloader
|
||||||
priority: .high,
|
.requestAsset(
|
||||||
success: { _, asset in
|
assetDescription: renditionForSending,
|
||||||
resolver.fulfill(asset)
|
priority: .high
|
||||||
},
|
)
|
||||||
failure: { _ in
|
.mapError { _ -> Error in
|
||||||
// TODO GiphyDownloader API should pass through a useful failing error
|
// TODO: GiphyDownloader API should pass through a useful failing error so we can pass it through here
|
||||||
// so we can pass it through here
|
|
||||||
Logger.error("request failed")
|
Logger.error("request failed")
|
||||||
resolver.reject(GiphyError.fetchFailure)
|
return GiphyError.fetchFailure
|
||||||
})
|
}
|
||||||
|
.map { asset, _ in asset }
|
||||||
return promise
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func clearViewState() {
|
private func clearViewState() {
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol GifPickerLayoutDelegate: AnyObject {
|
protocol GifPickerLayoutDelegate: AnyObject {
|
||||||
func imageInfosForLayout() -> [GiphyImageInfo]
|
func imageInfosForLayout() -> [GiphyImageInfo]
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import Reachability
|
import Reachability
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollectionViewDataSource, UICollectionViewDelegate, GifPickerLayoutDelegate {
|
||||||
|
|
||||||
|
@ -37,12 +36,12 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
var hasSelectedCell: Bool = false
|
var hasSelectedCell: Bool = false
|
||||||
var imageInfos = [GiphyImageInfo]()
|
var imageInfos = [GiphyImageInfo]()
|
||||||
|
|
||||||
var reachability: Reachability?
|
|
||||||
|
|
||||||
private let kCellReuseIdentifier = "kCellReuseIdentifier"
|
private let kCellReuseIdentifier = "kCellReuseIdentifier"
|
||||||
|
|
||||||
var progressiveSearchTimer: Timer?
|
var progressiveSearchTimer: Timer?
|
||||||
|
|
||||||
|
private var disposables: Set<AnyCancellable> = Set()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
@available(*, unavailable, message:"use other constructor instead.")
|
@available(*, unavailable, message:"use other constructor instead.")
|
||||||
|
@ -114,7 +113,6 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
|
|
||||||
createViews()
|
createViews()
|
||||||
|
|
||||||
reachability = Reachability.forInternetConnection()
|
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
self,
|
self,
|
||||||
selector: #selector(reachabilityChanged),
|
selector: #selector(reachabilityChanged),
|
||||||
|
@ -219,7 +217,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
|
|
||||||
private func createErrorLabel(text: String) -> UILabel {
|
private func createErrorLabel(text: String) -> UILabel {
|
||||||
let label: UILabel = UILabel()
|
let label: UILabel = UILabel()
|
||||||
label.font = .ows_mediumFont(withSize: 20)
|
label.font = UIFont.systemFont(ofSize: 20, weight: .medium)
|
||||||
label.text = text
|
label.text = text
|
||||||
label.themeTextColor = .textPrimary
|
label.themeTextColor = .textPrimary
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
|
@ -360,31 +358,15 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
public func getFileForCell(_ cell: GifPickerCell) {
|
public func getFileForCell(_ cell: GifPickerCell) {
|
||||||
GiphyDownloader.giphyDownloader.cancelAllRequests()
|
GiphyDownloader.giphyDownloader.cancelAllRequests()
|
||||||
|
|
||||||
firstly {
|
cell
|
||||||
cell.requestRenditionForSending()
|
.requestRenditionForSending()
|
||||||
}.done { [weak self] (asset: ProxiedContentAsset) in
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
guard let strongSelf = self else {
|
.receive(on: DispatchQueue.main)
|
||||||
Logger.info("ignoring send, since VC was dismissed before fetching finished.")
|
.sink(
|
||||||
return
|
receiveCompletion: { [weak self] result in
|
||||||
}
|
switch result {
|
||||||
guard let rendition = asset.assetDescription as? GiphyRendition else {
|
case .finished: break
|
||||||
owsFailDebug("Invalid asset description.")
|
case .failure(let error):
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let filePath = asset.filePath
|
|
||||||
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath,
|
|
||||||
shouldDeleteOnDeallocation: false) else {
|
|
||||||
owsFailDebug("couldn't load asset.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
|
|
||||||
|
|
||||||
strongSelf.dismiss(animated: true) {
|
|
||||||
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.
|
|
||||||
strongSelf.delegate?.gifPickerDidSelect(attachment: attachment)
|
|
||||||
}
|
|
||||||
}.catch { [weak self] error in
|
|
||||||
let modal: ConfirmationModal = ConfirmationModal(
|
let modal: ConfirmationModal = ConfirmationModal(
|
||||||
targetView: self?.view,
|
targetView: self?.view,
|
||||||
info: ConfirmationModal.Info(
|
info: ConfirmationModal.Info(
|
||||||
|
@ -399,7 +381,29 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self?.present(modal, animated: true)
|
self?.present(modal, animated: true)
|
||||||
}.retainUntilComplete()
|
}
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] asset in
|
||||||
|
guard let rendition = asset.assetDescription as? GiphyRendition else {
|
||||||
|
owsFailDebug("Invalid asset description.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let filePath = asset.filePath
|
||||||
|
guard let dataSource = DataSourcePath.dataSource(withFilePath: filePath,
|
||||||
|
shouldDeleteOnDeallocation: false) else {
|
||||||
|
owsFailDebug("couldn't load asset.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let attachment = SignalAttachment.attachment(dataSource: dataSource, dataUTI: rendition.utiType, imageQuality: .medium)
|
||||||
|
|
||||||
|
self?.dismiss(animated: true) {
|
||||||
|
// Delegate presents view controllers, so it's important that *this* controller be dismissed before that occurs.
|
||||||
|
self?.delegate?.gifPickerDidSelect(attachment: attachment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &disposables)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
|
||||||
|
@ -486,8 +490,19 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
assert(progressiveSearchTimer == nil)
|
assert(progressiveSearchTimer == nil)
|
||||||
assert(searchBar.text == nil || searchBar.text?.count == 0)
|
assert(searchBar.text == nil || searchBar.text?.count == 0)
|
||||||
|
|
||||||
GiphyAPI.sharedInstance.trending()
|
GiphyAPI.trending()
|
||||||
.done { [weak self] imageInfos in
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink(
|
||||||
|
receiveCompletion: { result in
|
||||||
|
switch result {
|
||||||
|
case .finished: break
|
||||||
|
case .failure(let error):
|
||||||
|
// Don't both showing error UI feedback for default "trending" results.
|
||||||
|
Logger.error("error: \(error)")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] imageInfos in
|
||||||
Logger.info("showing trending")
|
Logger.info("showing trending")
|
||||||
|
|
||||||
if imageInfos.count > 0 {
|
if imageInfos.count > 0 {
|
||||||
|
@ -498,10 +513,8 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
owsFailDebug("trending results was unexpectedly empty")
|
owsFailDebug("trending results was unexpectedly empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.catch { error in
|
)
|
||||||
// Don't both showing error UI feedback for default "trending" results.
|
.store(in: &disposables)
|
||||||
Logger.error("error: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func search(query: String) {
|
private func search(query: String) {
|
||||||
|
@ -514,10 +527,21 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
lastQuery = query
|
lastQuery = query
|
||||||
self.collectionView.contentOffset = CGPoint.zero
|
self.collectionView.contentOffset = CGPoint.zero
|
||||||
|
|
||||||
GiphyAPI.sharedInstance
|
GiphyAPI
|
||||||
.search(
|
.search(query: query)
|
||||||
query: query,
|
.subscribe(on: DispatchQueue.global(qos: .userInitiated))
|
||||||
success: { [weak self] imageInfos in
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink(
|
||||||
|
receiveCompletion: { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .finished: break
|
||||||
|
case .failure:
|
||||||
|
Logger.info("search failed.")
|
||||||
|
// TODO: Present this error to the user.
|
||||||
|
self?.viewMode = .error
|
||||||
|
}
|
||||||
|
},
|
||||||
|
receiveValue: { [weak self] imageInfos in
|
||||||
Logger.info("search complete")
|
Logger.info("search complete")
|
||||||
self?.imageInfos = imageInfos
|
self?.imageInfos = imageInfos
|
||||||
|
|
||||||
|
@ -527,13 +551,9 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect
|
||||||
else {
|
else {
|
||||||
self?.viewMode = .noResults
|
self?.viewMode = .noResults
|
||||||
}
|
}
|
||||||
},
|
|
||||||
failure: { [weak self] _ in
|
|
||||||
Logger.info("search failed.")
|
|
||||||
// TODO: Present this error to the user.
|
|
||||||
self?.viewMode = .error
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
.store(in: &disposables)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - GifPickerLayoutDelegate
|
// MARK: - GifPickerLayoutDelegate
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import AFNetworking
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import PromiseKit
|
import Combine
|
||||||
import CoreServices
|
import CoreServices
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
// There's no UTI type for webp!
|
// There's no UTI type for webp!
|
||||||
enum GiphyFormat {
|
enum GiphyFormat {
|
||||||
|
@ -18,13 +16,12 @@ enum GiphyError: Error {
|
||||||
case assertionError(description: String)
|
case assertionError(description: String)
|
||||||
case fetchFailure
|
case fetchFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
extension GiphyError: LocalizedError {
|
extension GiphyError: LocalizedError {
|
||||||
public var errorDescription: String? {
|
public var errorDescription: String? {
|
||||||
switch self {
|
switch self {
|
||||||
case .assertionError:
|
case .assertionError: return "GIF_PICKER_ERROR_GENERIC".localized()
|
||||||
return NSLocalizedString("GIF_PICKER_ERROR_GENERIC", comment: "Generic error displayed when picking a GIF")
|
case .fetchFailure: return "GIF_PICKER_ERROR_FETCH_FAILURE".localized()
|
||||||
case .fetchFailure:
|
|
||||||
return NSLocalizedString("GIF_PICKER_ERROR_FETCH_FAILURE", comment: "Error displayed when there is a failure fetching a GIF from the remote service.")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -34,7 +31,7 @@ extension GiphyError: LocalizedError {
|
||||||
// They vary in content size (i.e. width, height),
|
// They vary in content size (i.e. width, height),
|
||||||
// format (.jpg, .gif, .mp4, webp, etc.),
|
// format (.jpg, .gif, .mp4, webp, etc.),
|
||||||
// quality, etc.
|
// quality, etc.
|
||||||
@objc class GiphyRendition: ProxiedContentAssetDescription {
|
class GiphyRendition: ProxiedContentAssetDescription {
|
||||||
let format: GiphyFormat
|
let format: GiphyFormat
|
||||||
let name: String
|
let name: String
|
||||||
let width: UInt
|
let width: UInt
|
||||||
|
@ -93,7 +90,7 @@ extension GiphyError: LocalizedError {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Represents a single Giphy image.
|
// Represents a single Giphy image.
|
||||||
@objc class GiphyImageInfo: NSObject {
|
class GiphyImageInfo: NSObject {
|
||||||
let giphyId: String
|
let giphyId: String
|
||||||
let renditions: [GiphyRendition]
|
let renditions: [GiphyRendition]
|
||||||
// We special-case the "original" rendition because it is the
|
// We special-case the "original" rendition because it is the
|
||||||
|
@ -267,115 +264,109 @@ extension GiphyError: LocalizedError {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc class GiphyAPI: NSObject {
|
enum GiphyAPI {
|
||||||
|
private static let kGiphyBaseURL = "https://api.giphy.com"
|
||||||
|
private static let urlSession: URLSession = {
|
||||||
|
let configuration: URLSessionConfiguration = ContentProxy.sessionConfiguration()
|
||||||
|
|
||||||
// MARK: - Properties
|
// Don't use any caching to protect privacy of these requests.
|
||||||
|
configuration.urlCache = nil
|
||||||
|
configuration.requestCachePolicy = .reloadIgnoringCacheData
|
||||||
|
|
||||||
static let sharedInstance = GiphyAPI()
|
return URLSession(configuration: configuration)
|
||||||
|
}()
|
||||||
|
|
||||||
// Force usage as a singleton
|
// MARK: - Search
|
||||||
override private init() {
|
|
||||||
super.init()
|
|
||||||
|
|
||||||
SwiftSingletons.register(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
deinit {
|
|
||||||
NotificationCenter.default.removeObserver(self)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let kGiphyBaseURL = "https://api.giphy.com/"
|
|
||||||
|
|
||||||
private func giphyAPISessionManager() -> AFHTTPSessionManager? {
|
|
||||||
return AFHTTPSessionManager(baseURL: URL(string: kGiphyBaseURL), sessionConfiguration: .ephemeral)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: Search
|
|
||||||
// This is the Signal iOS API key.
|
// This is the Signal iOS API key.
|
||||||
let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
|
private static let kGiphyApiKey = "ZsUpUm2L6cVbvei347EQNp7HrROjbOdc"
|
||||||
let kGiphyPageSize = 20
|
private static let kGiphyPageSize = 20
|
||||||
|
|
||||||
public func trending() -> Promise<[GiphyImageInfo]> {
|
public static func trending() -> AnyPublisher<[GiphyImageInfo], Error> {
|
||||||
guard let sessionManager = giphyAPISessionManager() else {
|
|
||||||
Logger.error("Couldn't create session manager.")
|
|
||||||
return Promise.value([])
|
|
||||||
}
|
|
||||||
let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)"
|
let urlString = "/v1/gifs/trending?api_key=\(kGiphyApiKey)&limit=\(kGiphyPageSize)"
|
||||||
let (promise, resolver) = Promise<[GiphyImageInfo]>.pending()
|
|
||||||
sessionManager.get(urlString,
|
guard let url: URL = URL(string: "\(kGiphyBaseURL)\(urlString)") else {
|
||||||
parameters: [String: AnyObject](),
|
return Fail(error: HTTPError.invalidURL)
|
||||||
headers:nil,
|
.eraseToAnyPublisher()
|
||||||
progress: nil,
|
}
|
||||||
success: { _, value in
|
|
||||||
Logger.error("search request succeeded")
|
return urlSession
|
||||||
if let imageInfos = self.parseGiphyImages(responseJson: value) {
|
.dataTaskPublisher(for: url)
|
||||||
resolver.fulfill(imageInfos)
|
.mapError { urlError in
|
||||||
} else {
|
Logger.error("search request failed: \(urlError)")
|
||||||
|
|
||||||
|
// URLError codes are negative values
|
||||||
|
return HTTPError.generic
|
||||||
|
}
|
||||||
|
.map { data, _ in
|
||||||
|
Logger.debug("search request succeeded")
|
||||||
|
|
||||||
|
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
|
||||||
Logger.error("unable to parse trending images")
|
Logger.error("unable to parse trending images")
|
||||||
resolver.fulfill([])
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
},
|
return imageInfos
|
||||||
failure: { _, error in
|
}
|
||||||
Logger.error("search request failed: \(error)")
|
.eraseToAnyPublisher()
|
||||||
resolver.reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
return promise
|
|
||||||
}
|
|
||||||
|
|
||||||
public func search(query: String, success: @escaping (([GiphyImageInfo]) -> Void), failure: @escaping ((NSError?) -> Void)) {
|
|
||||||
guard let sessionManager = giphyAPISessionManager() else {
|
|
||||||
Logger.error("Couldn't create session manager.")
|
|
||||||
failure(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard NSURL(string: kGiphyBaseURL) != nil else {
|
|
||||||
Logger.error("Invalid base URL.")
|
|
||||||
failure(nil)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func search(query: String) -> AnyPublisher<[GiphyImageInfo], Error> {
|
||||||
let kGiphyPageOffset = 0
|
let kGiphyPageOffset = 0
|
||||||
guard let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) else {
|
|
||||||
Logger.error("Could not URL encode query: \(query).")
|
|
||||||
failure(nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let urlString = "/v1/gifs/search?api_key=\(kGiphyApiKey)&offset=\(kGiphyPageOffset)&limit=\(kGiphyPageSize)&q=\(queryEncoded)"
|
|
||||||
|
|
||||||
guard ContentProxy.configureSessionManager(sessionManager: sessionManager, forUrl: urlString) else {
|
guard
|
||||||
|
let queryEncoded = query.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
|
||||||
|
let url: URL = URL(
|
||||||
|
string: [
|
||||||
|
kGiphyBaseURL,
|
||||||
|
"/v1/gifs/search?api_key=\(kGiphyApiKey)",
|
||||||
|
"&offset=\(kGiphyPageOffset)",
|
||||||
|
"&limit=\(kGiphyPageSize)",
|
||||||
|
"&q=\(queryEncoded)"
|
||||||
|
].joined()
|
||||||
|
)
|
||||||
|
else {
|
||||||
|
return Fail(error: HTTPError.invalidURL)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
|
var request: URLRequest = URLRequest(url: url)
|
||||||
|
|
||||||
|
guard ContentProxy.configureProxiedRequest(request: &request) else {
|
||||||
owsFailDebug("Could not configure query: \(query).")
|
owsFailDebug("Could not configure query: \(query).")
|
||||||
failure(nil)
|
return Fail(error: HTTPError.generic)
|
||||||
return
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionManager.get(urlString,
|
return urlSession
|
||||||
parameters: [String: AnyObject](),
|
.dataTaskPublisher(for: request)
|
||||||
headers: nil,
|
.mapError { urlError in
|
||||||
progress: nil,
|
Logger.error("search request failed: \(urlError)")
|
||||||
success: { _, value in
|
|
||||||
Logger.error("search request succeeded")
|
// URLError codes are negative values
|
||||||
guard let imageInfos = self.parseGiphyImages(responseJson: value) else {
|
return HTTPError.generic
|
||||||
failure(nil)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
success(imageInfos)
|
.tryMap { data, _ -> [GiphyImageInfo] in
|
||||||
},
|
Logger.debug("search request succeeded")
|
||||||
failure: { _, error in
|
|
||||||
Logger.error("search request failed: \(error)")
|
guard let imageInfos = self.parseGiphyImages(responseData: data) else {
|
||||||
failure(error as NSError)
|
throw HTTPError.invalidResponse
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: Parse API Responses
|
return imageInfos
|
||||||
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
private func parseGiphyImages(responseJson: Any?) -> [GiphyImageInfo]? {
|
// MARK: - Parse API Responses
|
||||||
guard let responseJson = responseJson else {
|
|
||||||
|
private static func parseGiphyImages(responseData: Data?) -> [GiphyImageInfo]? {
|
||||||
|
guard let responseData: Data = responseData else {
|
||||||
Logger.error("Missing response.")
|
Logger.error("Missing response.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
guard let responseDict = responseJson as? [String: Any] else {
|
guard let responseDict: [String: Any] = try? JSONSerialization
|
||||||
|
.jsonObject(with: responseData, options: [ .fragmentsAllowed ]) as? [String: Any] else {
|
||||||
Logger.error("Invalid response.")
|
Logger.error("Invalid response.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -389,7 +380,7 @@ extension GiphyError: LocalizedError {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Giphy API results are often incomplete or malformed, so we need to be defensive.
|
// Giphy API results are often incomplete or malformed, so we need to be defensive.
|
||||||
private func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
|
private static func parseGiphyImage(imageDict: [String: Any]) -> GiphyImageInfo? {
|
||||||
guard let giphyId = imageDict["id"] as? String else {
|
guard let giphyId = imageDict["id"] as? String else {
|
||||||
Logger.warn("Image dict missing id.")
|
Logger.warn("Image dict missing id.")
|
||||||
return nil
|
return nil
|
||||||
|
@ -424,12 +415,14 @@ extension GiphyError: LocalizedError {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return GiphyImageInfo(giphyId: giphyId,
|
return GiphyImageInfo(
|
||||||
|
giphyId: giphyId,
|
||||||
renditions: renditions,
|
renditions: renditions,
|
||||||
originalRendition: originalRendition)
|
originalRendition: originalRendition
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
|
private static func findOriginalRendition(renditions: [GiphyRendition]) -> GiphyRendition? {
|
||||||
for rendition in renditions where rendition.name == "original" {
|
for rendition in renditions where rendition.name == "original" {
|
||||||
return rendition
|
return rendition
|
||||||
}
|
}
|
||||||
|
@ -439,8 +432,10 @@ extension GiphyError: LocalizedError {
|
||||||
// Giphy API results are often incomplete or malformed, so we need to be defensive.
|
// Giphy API results are often incomplete or malformed, so we need to be defensive.
|
||||||
//
|
//
|
||||||
// We should discard renditions which are missing or have invalid properties.
|
// We should discard renditions which are missing or have invalid properties.
|
||||||
private func parseGiphyRendition(renditionName: String,
|
private static func parseGiphyRendition(
|
||||||
renditionDict: [String: Any]) -> GiphyRendition? {
|
renditionName: String,
|
||||||
|
renditionDict: [String: Any]
|
||||||
|
) -> GiphyRendition? {
|
||||||
guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else {
|
guard let width = parsePositiveUInt(dict: renditionDict, key: "width", typeName: "rendition") else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -488,7 +483,7 @@ extension GiphyError: LocalizedError {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
|
private static func parsePositiveUInt(dict: [String: Any], key: String, typeName: String) -> UInt? {
|
||||||
guard let value = dict[key] else {
|
guard let value = dict[key] else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
@ -505,7 +500,7 @@ extension GiphyError: LocalizedError {
|
||||||
return parsedValue
|
return parsedValue
|
||||||
}
|
}
|
||||||
|
|
||||||
private func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
|
private static func parseLenientUInt(dict: [String: Any], key: String) -> UInt {
|
||||||
let defaultValue = UInt(0)
|
let defaultValue = UInt(0)
|
||||||
|
|
||||||
guard let value = dict[key] else {
|
guard let value = dict[key] else {
|
||||||
|
|
|
@ -1,15 +1,11 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc
|
|
||||||
public class GiphyDownloader: ProxiedContentDownloader {
|
public class GiphyDownloader: ProxiedContentDownloader {
|
||||||
|
|
||||||
// MARK: - Properties
|
// MARK: - Properties
|
||||||
|
|
||||||
@objc
|
|
||||||
public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs")
|
public static let giphyDownloader = GiphyDownloader(downloadFolderName: "GIFs")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,19 +1,18 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import Photos
|
import Photos
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol ImagePickerGridControllerDelegate: AnyObject {
|
protocol ImagePickerGridControllerDelegate: AnyObject {
|
||||||
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
func imagePickerDidCompleteSelection(_ imagePicker: ImagePickerGridController)
|
||||||
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController)
|
func imagePickerDidCancel(_ imagePicker: ImagePickerGridController)
|
||||||
|
|
||||||
func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool
|
func imagePicker(_ imagePicker: ImagePickerGridController, isAssetSelected asset: PHAsset) -> Bool
|
||||||
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>)
|
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher<SignalAttachment, Error>)
|
||||||
func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset)
|
func imagePicker(_ imagePicker: ImagePickerGridController, didDeselectAsset asset: PHAsset)
|
||||||
|
|
||||||
var isInBatchSelectMode: Bool { get }
|
var isInBatchSelectMode: Bool { get }
|
||||||
|
@ -180,8 +179,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let attachmentPromise: Promise<SignalAttachment> = photoCollectionContents.outgoingAttachment(for: asset)
|
delegate.imagePicker(
|
||||||
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
|
self,
|
||||||
|
didSelectAsset: asset,
|
||||||
|
attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset)
|
||||||
|
)
|
||||||
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
|
collectionView.selectItem(at: indexPath, animated: true, scrollPosition: [])
|
||||||
case .deselect:
|
case .deselect:
|
||||||
delegate.imagePicker(self, didDeselectAsset: asset)
|
delegate.imagePicker(self, didDeselectAsset: asset)
|
||||||
|
@ -202,7 +204,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
let cellSize = collectionViewFlowLayout.itemSize
|
let cellSize = collectionViewFlowLayout.itemSize
|
||||||
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
photoMediaSize.thumbnailSize = CGSize(width: cellSize.width * scale, height: cellSize.height * scale)
|
||||||
|
|
||||||
reloadDataAndRestoreSelection()
|
|
||||||
if !hasEverAppeared {
|
if !hasEverAppeared {
|
||||||
scrollToBottom(animated: false)
|
scrollToBottom(animated: false)
|
||||||
}
|
}
|
||||||
|
@ -289,30 +290,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func reloadDataAndRestoreSelection() {
|
|
||||||
guard let collectionView = collectionView else {
|
|
||||||
owsFailDebug("Missing collectionView.")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let delegate = delegate else {
|
|
||||||
owsFailDebug("delegate was unexpectedly nil")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
collectionView.reloadData()
|
|
||||||
collectionView.layoutIfNeeded()
|
|
||||||
|
|
||||||
let count = photoCollectionContents.assetCount
|
|
||||||
for index in 0..<count {
|
|
||||||
let asset = photoCollectionContents.asset(at: index)
|
|
||||||
if delegate.imagePicker(self, isAssetSelected: asset) {
|
|
||||||
collectionView.selectItem(at: IndexPath(row: index, section: 0),
|
|
||||||
animated: false, scrollPosition: [])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
|
@ -363,7 +340,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
collectionView.allowsMultipleSelection = delegate.isInBatchSelectMode
|
||||||
reloadDataAndRestoreSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearCollectionViewSelection() {
|
func clearCollectionViewSelection() {
|
||||||
|
@ -400,7 +376,6 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
|
|
||||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||||
photoCollectionContents = photoCollection.contents()
|
photoCollectionContents = photoCollection.contents()
|
||||||
reloadDataAndRestoreSelection()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - PhotoCollectionPicker Presentation
|
// MARK: - PhotoCollectionPicker Presentation
|
||||||
|
@ -448,10 +423,10 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
// Initially position offscreen, we'll animate it in.
|
// Initially position offscreen, we'll animate it in.
|
||||||
collectionPickerView.frame = collectionPickerView.frame.offsetBy(dx: 0, dy: collectionPickerView.frame.height)
|
collectionPickerView.frame = collectionPickerView.frame.offsetBy(dx: 0, dy: collectionPickerView.frame.height)
|
||||||
|
|
||||||
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
|
UIView.animate(withDuration: 0.25) {
|
||||||
collectionPickerView.superview?.layoutIfNeeded()
|
collectionPickerView.superview?.layoutIfNeeded()
|
||||||
self.titleView.rotateIcon(.up)
|
self.titleView.rotateIcon(.up)
|
||||||
}.retainUntilComplete()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func hideCollectionPicker() {
|
func hideCollectionPicker() {
|
||||||
|
@ -460,13 +435,17 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
assert(isShowingCollectionPickerController)
|
assert(isShowingCollectionPickerController)
|
||||||
isShowingCollectionPickerController = false
|
isShowingCollectionPickerController = false
|
||||||
|
|
||||||
UIView.animate(.promise, duration: 0.25, delay: 0, options: .curveEaseInOut) {
|
UIView.animate(
|
||||||
|
withDuration: 0.25,
|
||||||
|
animations: {
|
||||||
self.collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height)
|
self.collectionPickerController.view.frame = self.view.frame.offsetBy(dx: 0, dy: self.view.frame.height)
|
||||||
self.titleView.rotateIcon(.down)
|
self.titleView.rotateIcon(.down)
|
||||||
}.done { _ in
|
},
|
||||||
self.collectionPickerController.view.removeFromSuperview()
|
completion: { [weak self] _ in
|
||||||
self.collectionPickerController.removeFromParent()
|
self?.collectionPickerController.view.removeFromSuperview()
|
||||||
}.retainUntilComplete()
|
self?.collectionPickerController.removeFromParent()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - UICollectionView
|
// MARK: - UICollectionView
|
||||||
|
@ -491,8 +470,11 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
}
|
}
|
||||||
|
|
||||||
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
|
let asset: PHAsset = photoCollectionContents.asset(at: indexPath.item)
|
||||||
let attachmentPromise: Promise<SignalAttachment> = photoCollectionContents.outgoingAttachment(for: asset)
|
delegate.imagePicker(
|
||||||
delegate.imagePicker(self, didSelectAsset: asset, attachmentPromise: attachmentPromise)
|
self,
|
||||||
|
didSelectAsset: asset,
|
||||||
|
attachmentPublisher: photoCollectionContents.outgoingAttachment(for: asset)
|
||||||
|
)
|
||||||
|
|
||||||
if !delegate.isInBatchSelectMode {
|
if !delegate.isInBatchSelectMode {
|
||||||
// Don't show "selected" badge unless we're in batch mode
|
// Don't show "selected" badge unless we're in batch mode
|
||||||
|
@ -524,7 +506,8 @@ class ImagePickerGridController: UICollectionViewController, PhotoLibraryDelegat
|
||||||
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
let cell: PhotoGridViewCell = collectionView.dequeue(type: PhotoGridViewCell.self, for: indexPath)
|
||||||
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
let assetItem = photoCollectionContents.assetItem(at: indexPath.item, photoMediaSize: photoMediaSize)
|
||||||
cell.configure(item: assetItem)
|
cell.configure(item: assetItem)
|
||||||
|
cell.isAccessibilityElement = true
|
||||||
|
cell.accessibilityIdentifier = "\(assetItem.asset.modificationDate.map { "\($0)" } ?? "Unknown Date")"
|
||||||
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
cell.isSelected = delegate.imagePicker(self, isAssetSelected: assetItem.asset)
|
||||||
|
|
||||||
return cell
|
return cell
|
||||||
|
|
|
@ -5,6 +5,7 @@ import YYImage
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public enum MediaGalleryOption {
|
public enum MediaGalleryOption {
|
||||||
case sliderEnabled
|
case sliderEnabled
|
||||||
|
|
|
@ -48,8 +48,14 @@ public class MediaGalleryViewModel {
|
||||||
didSet {
|
didSet {
|
||||||
// When starting to observe interaction changes we want to trigger a UI update just in case the
|
// 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
|
// data was changed while we weren't observing
|
||||||
if let unobservedGalleryDataChanges: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
if let changes: ([SectionModel], StagedChangeset<[SectionModel]>) = self.unobservedGalleryDataChanges {
|
||||||
onGalleryChange?(unobservedGalleryDataChanges.0, unobservedGalleryDataChanges.1)
|
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
|
self.unobservedGalleryDataChanges = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -98,7 +104,10 @@ public class MediaGalleryViewModel {
|
||||||
currentDataRetriever: { self?.galleryData },
|
currentDataRetriever: { self?.galleryData },
|
||||||
onDataChange: self?.onGalleryChange,
|
onDataChange: self?.onGalleryChange,
|
||||||
onUnobservedDataChange: { updatedData, changeset in
|
onUnobservedDataChange: { updatedData, changeset in
|
||||||
self?.unobservedGalleryDataChanges = (updatedData, changeset)
|
self?.unobservedGalleryDataChanges = (changeset.isEmpty ?
|
||||||
|
nil :
|
||||||
|
(updatedData, changeset)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -357,7 +366,7 @@ public class MediaGalleryViewModel {
|
||||||
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
/// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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)
|
public lazy var observableAlbumData: AlbumObservation = buildAlbumObservation(for: nil)
|
||||||
|
|
||||||
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
private func buildAlbumObservation(for interactionId: Int64?) -> AlbumObservation {
|
||||||
|
@ -380,6 +389,7 @@ public class MediaGalleryViewModel {
|
||||||
.fetchAll(db)
|
.fetchAll(db)
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
|
.handleEvents(didFail: { SNLog("[MediaGalleryViewModel] Observation failed with error: \($0)") })
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
|
@discardableResult public func loadAndCacheAlbumData(for interactionId: Int64, in threadId: String) -> [Item] {
|
||||||
|
@ -623,27 +633,3 @@ public class MediaGalleryViewModel {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Objective-C Support
|
|
||||||
|
|
||||||
// FIXME: Remove when we can
|
|
||||||
|
|
||||||
@objc(SNMediaGallery)
|
|
||||||
public class SNMediaGallery: NSObject {
|
|
||||||
@objc(pushTileViewWithSliderEnabledForThreadId:isClosedGroup:isOpenGroup:fromNavController:)
|
|
||||||
static func pushTileView(threadId: String, isClosedGroup: Bool, isOpenGroup: Bool, fromNavController: UINavigationController) {
|
|
||||||
fromNavController.pushViewController(
|
|
||||||
MediaGalleryViewModel.createAllMediaViewController(
|
|
||||||
threadId: threadId,
|
|
||||||
threadVariant: {
|
|
||||||
if isClosedGroup { return .closedGroup }
|
|
||||||
if isOpenGroup { return .openGroup }
|
|
||||||
|
|
||||||
return .contact
|
|
||||||
}(),
|
|
||||||
focusedAttachmentId: nil
|
|
||||||
),
|
|
||||||
animated: true
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -172,6 +172,7 @@ extension MediaInfoVC {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Interaction
|
// MARK: - Interaction
|
||||||
|
|
||||||
public func update(attachment: Attachment?) {
|
public func update(attachment: Attachment?) {
|
||||||
guard let attachment: Attachment = attachment else { return }
|
guard let attachment: Attachment = attachment else { return }
|
||||||
|
|
||||||
|
@ -179,7 +180,7 @@ extension MediaInfoVC {
|
||||||
|
|
||||||
fileIdLabel.text = attachment.serverId
|
fileIdLabel.text = attachment.serverId
|
||||||
fileTypeLabel.text = attachment.contentType
|
fileTypeLabel.text = attachment.contentType
|
||||||
fileSizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount)
|
fileSizeLabel.text = Format.fileSize(attachment.byteCount)
|
||||||
resolutionLabel.text = {
|
resolutionLabel.text = {
|
||||||
guard let width = attachment.width, let height = attachment.height else { return "N/A" }
|
guard let width = attachment.width, let height = attachment.height else { return "N/A" }
|
||||||
return "\(width)×\(height)"
|
return "\(width)×\(height)"
|
||||||
|
|
|
@ -2,10 +2,10 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import GRDB
|
import GRDB
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController {
|
class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSource, UIPageViewControllerDelegate, MediaDetailViewControllerDelegate, InteractivelyDismissableViewController {
|
||||||
class DynamicallySizedView: UIView {
|
class DynamicallySizedView: UIView {
|
||||||
|
@ -15,7 +15,9 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
|
fileprivate var mediaInteractiveDismiss: MediaInteractiveDismiss?
|
||||||
|
|
||||||
public let viewModel: MediaGalleryViewModel
|
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 initialPage: MediaDetailViewController
|
||||||
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
|
private var cachedPages: [Int64: [MediaGalleryViewModel.Item: MediaDetailViewController]] = [:]
|
||||||
|
|
||||||
|
@ -40,7 +42,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
)
|
)
|
||||||
|
|
||||||
// Swap out the database observer
|
// Swap out the database observer
|
||||||
dataChangeObservable?.cancel()
|
stopObservingChanges()
|
||||||
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
viewModel.replaceAlbumObservation(toObservationFor: item.interactionId)
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
|
|
||||||
|
@ -238,8 +240,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
public override func viewWillDisappear(_ animated: Bool) {
|
public override func viewWillDisappear(_ animated: Bool) {
|
||||||
super.viewWillDisappear(animated)
|
super.viewWillDisappear(animated)
|
||||||
|
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
|
|
||||||
resignFirstResponder()
|
resignFirstResponder()
|
||||||
}
|
}
|
||||||
|
@ -252,8 +253,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc func applicationDidResignActive(_ notification: Notification) {
|
@objc func applicationDidResignActive(_ notification: Notification) {
|
||||||
// Stop observing database changes
|
stopObservingChanges()
|
||||||
dataChangeObservable?.cancel()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||||
|
@ -388,17 +388,23 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
private func startObservingChanges() {
|
private func startObservingChanges() {
|
||||||
|
guard dataChangeObservable == nil else { return }
|
||||||
|
|
||||||
// Start observing for data changes
|
// Start observing for data changes
|
||||||
dataChangeObservable = Storage.shared.start(
|
dataChangeObservable = Storage.shared.start(
|
||||||
viewModel.observableAlbumData,
|
viewModel.observableAlbumData,
|
||||||
onError: { _ in },
|
onError: { _ in },
|
||||||
onChange: { [weak self] albumData in
|
onChange: { [weak self] albumData in
|
||||||
// The defaul scheduler emits changes on the main thread
|
// The default scheduler emits changes on the main thread
|
||||||
self?.handleUpdates(albumData)
|
self?.handleUpdates(albumData)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func stopObservingChanges() {
|
||||||
|
dataChangeObservable = nil
|
||||||
|
}
|
||||||
|
|
||||||
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
|
private func handleUpdates(_ updatedViewData: [MediaGalleryViewModel.Item]) {
|
||||||
// Determine if we swapped albums (if so we don't need to do anything else)
|
// Determine if we swapped albums (if so we don't need to do anything else)
|
||||||
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
|
guard updatedViewData.contains(where: { $0.interactionId == currentItem.interactionId }) else {
|
||||||
|
@ -533,11 +539,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
self.viewModel.threadVariant == .contact
|
self.viewModel.threadVariant == .contact
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
Storage.shared.write { db in
|
let threadId: String = self.viewModel.threadId
|
||||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: self.viewModel.threadId) else {
|
let threadVariant: SessionThread.Variant = self.viewModel.threadVariant
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Storage.shared.write { db in
|
||||||
try MessageSender.send(
|
try MessageSender.send(
|
||||||
db,
|
db,
|
||||||
message: DataExtractionNotification(
|
message: DataExtractionNotification(
|
||||||
|
@ -547,7 +552,8 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
|
sentTimestamp: UInt64(SnodeAPI.currentOffsetTimestampMs())
|
||||||
),
|
),
|
||||||
interactionId: nil, // Show no interaction for the current user
|
interactionId: nil, // Show no interaction for the current user
|
||||||
in: thread
|
threadId: threadId,
|
||||||
|
threadVariant: threadVariant
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -710,7 +716,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap out the database observer
|
// Swap out the database observer
|
||||||
dataChangeObservable?.cancel()
|
stopObservingChanges()
|
||||||
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
|
viewModel.replaceAlbumObservation(toObservationFor: interactionIdAfter)
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
|
|
||||||
|
@ -755,7 +761,7 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swap out the database observer
|
// Swap out the database observer
|
||||||
dataChangeObservable?.cancel()
|
stopObservingChanges()
|
||||||
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
|
viewModel.replaceAlbumObservation(toObservationFor: interactionIdBefore)
|
||||||
startObservingChanges()
|
startObservingChanges()
|
||||||
|
|
||||||
|
@ -925,24 +931,19 @@ extension MediaGalleryViewModel.Item: GalleryRailItem {
|
||||||
let imageView: UIImageView = UIImageView()
|
let imageView: UIImageView = UIImageView()
|
||||||
imageView.contentMode = .scaleAspectFill
|
imageView.contentMode = .scaleAspectFill
|
||||||
|
|
||||||
getRailImage()
|
self.thumbnailImage { [weak imageView] image in
|
||||||
.map { [weak imageView] image in
|
DispatchQueue.main.async {
|
||||||
guard let imageView = imageView else { return }
|
imageView?.image = image
|
||||||
imageView.image = image
|
}
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
|
||||||
|
|
||||||
return imageView
|
return imageView
|
||||||
}
|
}
|
||||||
|
|
||||||
public func getRailImage() -> Guarantee<UIImage> {
|
|
||||||
return Guarantee<UIImage> { fulfill in
|
|
||||||
self.thumbnailImage(async: { image in fulfill(image) })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func isEqual(to other: GalleryRailItem?) -> Bool {
|
public func isEqual(to other: GalleryRailItem?) -> Bool {
|
||||||
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else { return false }
|
guard let otherItem: MediaGalleryViewModel.Item = other as? MediaGalleryViewModel.Item else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
return (self == otherItem)
|
return (self == otherItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,6 +6,7 @@ import GRDB
|
||||||
import DifferenceKit
|
import DifferenceKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
public class MediaTileViewController: UIViewController, UICollectionViewDataSource, UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {
|
||||||
|
|
||||||
|
@ -245,7 +246,7 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
||||||
}
|
}
|
||||||
|
|
||||||
private func autoLoadNextPageIfNeeded() {
|
private func autoLoadNextPageIfNeeded() {
|
||||||
guard !self.isAutoLoadingNextPage else { return }
|
guard self.hasLoadedInitialData && !self.isAutoLoadingNextPage else { return }
|
||||||
|
|
||||||
self.isAutoLoadingNextPage = true
|
self.isAutoLoadingNextPage = true
|
||||||
|
|
||||||
|
@ -306,12 +307,12 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour
|
||||||
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
// Ensure the first load runs without animations (if we don't do this the cells will animate
|
||||||
// in from a frame of CGRect.zero)
|
// in from a frame of CGRect.zero)
|
||||||
guard hasLoadedInitialData else {
|
guard hasLoadedInitialData else {
|
||||||
self.hasLoadedInitialData = true
|
|
||||||
self.viewModel.updateGalleryData(updatedGalleryData)
|
self.viewModel.updateGalleryData(updatedGalleryData)
|
||||||
self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode)
|
self.updateSelectButton(updatedData: updatedGalleryData, inBatchSelectMode: isInBatchSelectMode)
|
||||||
|
|
||||||
UIView.performWithoutAnimation {
|
UIView.performWithoutAnimation {
|
||||||
self.collectionView.reloadData()
|
self.collectionView.reloadData()
|
||||||
|
self.hasLoadedInitialData = true
|
||||||
self.performInitialScrollIfNeeded()
|
self.performInitialScrollIfNeeded()
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
@objc class OWSImagePickerController: UIImagePickerController {
|
@objc class OWSImagePickerController: UIImagePickerController {
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import PromiseKit
|
|
||||||
import CoreServices
|
import CoreServices
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol PhotoCaptureDelegate: AnyObject {
|
protocol PhotoCaptureDelegate: AnyObject {
|
||||||
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment)
|
func photoCapture(_ photoCapture: PhotoCapture, didFinishProcessingAttachment attachment: SignalAttachment)
|
||||||
|
@ -83,20 +83,20 @@ class PhotoCapture: NSObject {
|
||||||
Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
|
Environment.shared?.audioSession.endAudioActivity(recordingAudioActivity)
|
||||||
}
|
}
|
||||||
|
|
||||||
func startCapture() -> Promise<Void> {
|
func startCapture() -> AnyPublisher<Void, Error> {
|
||||||
return sessionQueue.async(.promise) { [weak self] in
|
return Just(())
|
||||||
guard let self = self else { return }
|
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||||
|
.setFailureType(to: Error.self)
|
||||||
|
.tryMap { [weak self] _ -> Void in
|
||||||
|
self?.session.beginConfiguration()
|
||||||
|
defer { self?.session.commitConfiguration() }
|
||||||
|
|
||||||
self.session.beginConfiguration()
|
try self?.updateCurrentInput(position: .back)
|
||||||
defer { self.session.commitConfiguration() }
|
|
||||||
|
|
||||||
try self.updateCurrentInput(position: .back)
|
guard
|
||||||
|
let photoOutput = self?.captureOutput.photoOutput,
|
||||||
guard let photoOutput = self.captureOutput.photoOutput else {
|
self?.session.canAddOutput(photoOutput) == true
|
||||||
throw PhotoCaptureError.initializationFailed
|
else {
|
||||||
}
|
|
||||||
|
|
||||||
guard self.session.canAddOutput(photoOutput) else {
|
|
||||||
throw PhotoCaptureError.initializationFailed
|
throw PhotoCaptureError.initializationFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,54 +106,70 @@ class PhotoCapture: NSObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.session.addOutput(photoOutput)
|
self?.session.addOutput(photoOutput)
|
||||||
|
|
||||||
let movieOutput = self.captureOutput.movieOutput
|
if
|
||||||
|
let movieOutput = self?.captureOutput.movieOutput,
|
||||||
|
self?.session.canAddOutput(movieOutput) == true
|
||||||
|
{
|
||||||
|
self?.session.addOutput(movieOutput)
|
||||||
|
self?.session.sessionPreset = .high
|
||||||
|
|
||||||
if self.session.canAddOutput(movieOutput) {
|
|
||||||
self.session.addOutput(movieOutput)
|
|
||||||
self.session.sessionPreset = .high
|
|
||||||
if let connection = movieOutput.connection(with: .video) {
|
if let connection = movieOutput.connection(with: .video) {
|
||||||
if connection.isVideoStabilizationSupported {
|
if connection.isVideoStabilizationSupported {
|
||||||
connection.preferredVideoStabilizationMode = .auto
|
connection.preferredVideoStabilizationMode = .auto
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.done(on: sessionQueue) {
|
|
||||||
self.session.startRunning()
|
return ()
|
||||||
}
|
}
|
||||||
|
.handleEvents(
|
||||||
|
receiveCompletion: { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .failure: break
|
||||||
|
case .finished: self?.session.startRunning()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func stopCapture() -> Guarantee<Void> {
|
func stopCapture() -> AnyPublisher<Void, Never> {
|
||||||
return sessionQueue.async(.promise) {
|
return Just(())
|
||||||
self.session.stopRunning()
|
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||||
}
|
.handleEvents(
|
||||||
|
receiveOutput: { [weak self] in self?.session.stopRunning() }
|
||||||
|
)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func assertIsOnSessionQueue() {
|
func assertIsOnSessionQueue() {
|
||||||
assertOnQueue(sessionQueue)
|
assertOnQueue(sessionQueue)
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchCamera() -> Promise<Void> {
|
func switchCamera() -> AnyPublisher<Void, Error> {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
let newPosition: AVCaptureDevice.Position
|
|
||||||
|
desiredPosition = {
|
||||||
switch desiredPosition {
|
switch desiredPosition {
|
||||||
case .front:
|
case .front: return .back
|
||||||
newPosition = .back
|
case .back: return .front
|
||||||
case .back:
|
case .unspecified: return .front
|
||||||
newPosition = .front
|
|
||||||
case .unspecified:
|
|
||||||
newPosition = .front
|
|
||||||
}
|
}
|
||||||
desiredPosition = newPosition
|
}()
|
||||||
|
|
||||||
return sessionQueue.async(.promise) { [weak self] in
|
return Just(())
|
||||||
guard let self = self else { return }
|
.setFailureType(to: Error.self)
|
||||||
|
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||||
|
.tryMap { [weak self, newPosition = self.desiredPosition] _ -> Void in
|
||||||
|
self?.session.beginConfiguration()
|
||||||
|
defer { self?.session.commitConfiguration() }
|
||||||
|
|
||||||
self.session.beginConfiguration()
|
try self?.updateCurrentInput(position: newPosition)
|
||||||
defer { self.session.commitConfiguration() }
|
return ()
|
||||||
try self.updateCurrentInput(position: newPosition)
|
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method should be called on the serial queue,
|
// This method should be called on the serial queue,
|
||||||
|
@ -179,20 +195,29 @@ class PhotoCapture: NSObject {
|
||||||
resetFocusAndExposure()
|
resetFocusAndExposure()
|
||||||
}
|
}
|
||||||
|
|
||||||
func switchFlashMode() -> Guarantee<Void> {
|
func switchFlashMode() -> AnyPublisher<Void, Never> {
|
||||||
return sessionQueue.async(.promise) {
|
return Just(())
|
||||||
switch self.captureOutput.flashMode {
|
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||||
|
.handleEvents(
|
||||||
|
receiveOutput: { [weak self] _ in
|
||||||
|
switch self?.captureOutput.flashMode {
|
||||||
case .auto:
|
case .auto:
|
||||||
Logger.debug("new flashMode: on")
|
Logger.debug("new flashMode: on")
|
||||||
self.captureOutput.flashMode = .on
|
self?.captureOutput.flashMode = .on
|
||||||
|
|
||||||
case .on:
|
case .on:
|
||||||
Logger.debug("new flashMode: off")
|
Logger.debug("new flashMode: off")
|
||||||
self.captureOutput.flashMode = .off
|
self?.captureOutput.flashMode = .off
|
||||||
|
|
||||||
case .off:
|
case .off:
|
||||||
Logger.debug("new flashMode: auto")
|
Logger.debug("new flashMode: auto")
|
||||||
self.captureOutput.flashMode = .auto
|
self?.captureOutput.flashMode = .auto
|
||||||
|
|
||||||
|
default: break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
func focus(with focusMode: AVCaptureDevice.FocusMode,
|
func focus(with focusMode: AVCaptureDevice.FocusMode,
|
||||||
|
@ -325,14 +350,23 @@ extension PhotoCapture: CaptureButtonDelegate {
|
||||||
AssertIsOnMainThread()
|
AssertIsOnMainThread()
|
||||||
|
|
||||||
Logger.verbose("")
|
Logger.verbose("")
|
||||||
sessionQueue.async(.promise) {
|
|
||||||
try self.startAudioCapture()
|
Just(())
|
||||||
self.captureOutput.beginVideo(delegate: self)
|
.subscribe(on: sessionQueue) // Must run this on a specific queue to prevent crashes
|
||||||
}.done {
|
.sinkUntilComplete(
|
||||||
self.delegate?.photoCaptureDidBeginVideo(self)
|
receiveCompletion: { [weak self] _ in
|
||||||
}.catch { error in
|
guard let strongSelf = self else { return }
|
||||||
self.delegate?.photoCapture(self, processingDidError: error)
|
|
||||||
}.retainUntilComplete()
|
do {
|
||||||
|
try strongSelf.startAudioCapture()
|
||||||
|
strongSelf.captureOutput.beginVideo(delegate: strongSelf)
|
||||||
|
strongSelf.delegate?.photoCaptureDidBeginVideo(strongSelf)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
strongSelf.delegate?.photoCapture(strongSelf, processingDidError: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) {
|
func didCompleteLongPressCaptureButton(_ captureButton: CaptureButton) {
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import AVFoundation
|
import AVFoundation
|
||||||
import PromiseKit
|
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol PhotoCaptureViewControllerDelegate: AnyObject {
|
protocol PhotoCaptureViewControllerDelegate: AnyObject {
|
||||||
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
|
func photoCaptureViewController(_ photoCaptureViewController: PhotoCaptureViewController, didFinishProcessingAttachment attachment: SignalAttachment)
|
||||||
|
@ -39,9 +40,15 @@ class PhotoCaptureViewController: OWSViewController {
|
||||||
deinit {
|
deinit {
|
||||||
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
UIDevice.current.endGeneratingDeviceOrientationNotifications()
|
||||||
if let photoCapture = photoCapture {
|
if let photoCapture = photoCapture {
|
||||||
photoCapture.stopCapture().done {
|
photoCapture.stopCapture()
|
||||||
Logger.debug("stopCapture completed")
|
.sinkUntilComplete(
|
||||||
}.retainUntilComplete()
|
receiveCompletion: { result in
|
||||||
|
switch result {
|
||||||
|
case .failure: break
|
||||||
|
case .finished: Logger.debug("stopCapture completed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -186,17 +193,29 @@ class PhotoCaptureViewController: OWSViewController {
|
||||||
let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
|
let epsilonToForceCounterClockwiseRotation: CGFloat = 0.00001
|
||||||
self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
|
self.switchCameraControl.button.transform = self.switchCameraControl.button.transform.rotate(.pi + epsilonToForceCounterClockwiseRotation)
|
||||||
}
|
}
|
||||||
photoCapture.switchCamera().catch { error in
|
|
||||||
self.showFailureUI(error: error)
|
photoCapture.switchCamera()
|
||||||
}.retainUntilComplete()
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sinkUntilComplete(
|
||||||
|
receiveCompletion: { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .finished: break
|
||||||
|
case .failure(let error): self?.showFailureUI(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
func didTapFlashMode() {
|
func didTapFlashMode() {
|
||||||
Logger.debug("")
|
Logger.debug("")
|
||||||
photoCapture.switchFlashMode().done {
|
photoCapture.switchFlashMode()
|
||||||
self.updateFlashModeControl()
|
.receive(on: DispatchQueue.main)
|
||||||
}.retainUntilComplete()
|
.sinkUntilComplete(
|
||||||
|
receiveCompletion: { [weak self] _ in
|
||||||
|
self?.updateFlashModeControl()
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc
|
@objc
|
||||||
|
@ -287,13 +306,15 @@ class PhotoCaptureViewController: OWSViewController {
|
||||||
previewView = CapturePreviewView(session: photoCapture.session)
|
previewView = CapturePreviewView(session: photoCapture.session)
|
||||||
|
|
||||||
photoCapture.startCapture()
|
photoCapture.startCapture()
|
||||||
.done { [weak self] in
|
.receive(on: DispatchQueue.main)
|
||||||
self?.showCaptureUI()
|
.sinkUntilComplete(
|
||||||
|
receiveCompletion: { [weak self] result in
|
||||||
|
switch result {
|
||||||
|
case .finished: self?.showCaptureUI()
|
||||||
|
case .failure(let error): self?.showFailureUI(error: error)
|
||||||
}
|
}
|
||||||
.catch { [weak self] error in
|
|
||||||
self?.showFailureUI(error: error)
|
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showCaptureUI() {
|
private func showCaptureUI() {
|
||||||
|
@ -580,7 +601,7 @@ class RecordingTimerView: UIView {
|
||||||
|
|
||||||
private lazy var label: UILabel = {
|
private lazy var label: UILabel = {
|
||||||
let label: UILabel = UILabel()
|
let label: UILabel = UILabel()
|
||||||
label.font = .ows_monospacedDigitFont(withSize: 20)
|
label.font = UIFont.monospacedDigitSystemFont(ofSize: 20, weight: .regular)
|
||||||
label.themeTextColor = .textPrimary
|
label.themeTextColor = .textPrimary
|
||||||
label.textAlignment = .center
|
label.textAlignment = .center
|
||||||
label.layer.shadowOffset = CGSize.zero
|
label.layer.shadowOffset = CGSize.zero
|
||||||
|
|
|
@ -34,12 +34,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
||||||
// MARK: - Content
|
// MARK: - Content
|
||||||
|
|
||||||
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
override var title: String { "NOTIFICATIONS_STYLE_SOUND_TITLE".localized() }
|
||||||
|
override var observableTableData: ObservableData { _observableTableData }
|
||||||
|
|
||||||
private var _settingsData: [SectionModel] = []
|
private lazy var _observableTableData: ObservableData = {
|
||||||
public override var settingsData: [SectionModel] { _settingsData }
|
|
||||||
public override var observableSettingsData: ObservableData { _observableSettingsData }
|
|
||||||
|
|
||||||
private lazy var _observableSettingsData: ObservableData = {
|
|
||||||
self.photoCollections
|
self.photoCollections
|
||||||
.map { collections in
|
.map { collections in
|
||||||
[
|
[
|
||||||
|
@ -49,15 +46,15 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
||||||
let contents: PhotoCollectionContents = collection.contents()
|
let contents: PhotoCollectionContents = collection.contents()
|
||||||
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
let photoMediaSize: PhotoMediaSize = PhotoMediaSize(
|
||||||
thumbnailSize: CGSize(
|
thumbnailSize: CGSize(
|
||||||
width: IconSize.veryLarge.size,
|
width: IconSize.extraLarge.size,
|
||||||
height: IconSize.veryLarge.size
|
height: IconSize.extraLarge.size
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
let lastAssetItem: PhotoPickerAssetItem? = contents.lastAssetItem(photoMediaSize: photoMediaSize)
|
||||||
|
|
||||||
return SessionCell.Info(
|
return SessionCell.Info(
|
||||||
id: Item(id: collection.id),
|
id: Item(id: collection.id),
|
||||||
leftAccessory: .iconAsync(size: .veryLarge, shouldFill: true) { imageView in
|
leftAccessory: .iconAsync(size: .extraLarge, shouldFill: true) { imageView in
|
||||||
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
// Note: We need to capture 'lastAssetItem' otherwise it'll be released and we won't
|
||||||
// be able to load the thumbnail
|
// be able to load the thumbnail
|
||||||
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
lastAssetItem?.asyncThumbnail { [weak imageView] image in
|
||||||
|
@ -76,14 +73,9 @@ class PhotoCollectionPickerViewModel: SessionTableViewModel<NoNav, PhotoCollecti
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
.mapToSessionTableViewData(for: self)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// MARK: - Functions
|
|
||||||
|
|
||||||
public override func updateSettings(_ updatedSettings: [SectionModel]) {
|
|
||||||
self._settingsData = updatedSettings
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: PhotoLibraryDelegate
|
// MARK: PhotoLibraryDelegate
|
||||||
|
|
||||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public enum PhotoGridItemType {
|
public enum PhotoGridItemType {
|
||||||
case photo, animated, video
|
case photo, animated, video
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
//
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import Combine
|
||||||
import Photos
|
import Photos
|
||||||
import PromiseKit
|
|
||||||
import CoreServices
|
import CoreServices
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
protocol PhotoLibraryDelegate: AnyObject {
|
protocol PhotoLibraryDelegate: AnyObject {
|
||||||
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
func photoLibraryDidChange(_ photoLibrary: PhotoLibrary)
|
||||||
|
@ -53,7 +53,7 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
||||||
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
|
// Surprisingly, iOS will opportunistically run the completion block sync if the image is
|
||||||
// already available.
|
// already available.
|
||||||
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
photoCollectionContents.requestThumbnail(for: self.asset, thumbnailSize: photoMediaSize.thumbnailSize) { image, _ in
|
||||||
DispatchMainThreadSafe({
|
Threading.dispatchMainThreadSafe {
|
||||||
// Once we've _successfully_ completed (e.g. invoked the completion with
|
// Once we've _successfully_ completed (e.g. invoked the completion with
|
||||||
// a non-nil image), don't invoke the completion again with a nil argument.
|
// a non-nil image), don't invoke the completion again with a nil argument.
|
||||||
if !hasLoadedImage || image != nil {
|
if !hasLoadedImage || image != nil {
|
||||||
|
@ -63,7 +63,7 @@ class PhotoPickerAssetItem: PhotoGridItem {
|
||||||
hasLoadedImage = true
|
hasLoadedImage = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -136,44 +136,48 @@ class PhotoCollectionContents {
|
||||||
_ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
|
_ = imageManager.requestImage(for: asset, targetSize: thumbnailSize, contentMode: .aspectFill, options: nil, resultHandler: resultHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func requestImageDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
|
private func requestImageDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> {
|
||||||
return Promise { resolver in
|
return Deferred {
|
||||||
|
Future { [weak self] resolver in
|
||||||
|
|
||||||
let options: PHImageRequestOptions = PHImageRequestOptions()
|
let options: PHImageRequestOptions = PHImageRequestOptions()
|
||||||
options.isNetworkAccessAllowed = true
|
options.isNetworkAccessAllowed = true
|
||||||
|
|
||||||
_ = imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in
|
_ = self?.imageManager.requestImageData(for: asset, options: options) { imageData, dataUTI, orientation, info in
|
||||||
|
|
||||||
guard let imageData = imageData else {
|
guard let imageData = imageData else {
|
||||||
resolver.reject(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil"))
|
resolver(Result.failure(PhotoLibraryError.assertionError(description: "imageData was unexpectedly nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let dataUTI = dataUTI else {
|
guard let dataUTI = dataUTI else {
|
||||||
resolver.reject(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil"))
|
resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataUTI was unexpectedly nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else {
|
guard let dataSource = DataSourceValue.dataSource(with: imageData, utiType: dataUTI) else {
|
||||||
resolver.reject(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil"))
|
resolver(Result.failure(PhotoLibraryError.assertionError(description: "dataSource was unexpectedly nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resolver.fulfill((dataSource: dataSource, dataUTI: dataUTI))
|
resolver(Result.success((dataSource: dataSource, dataUTI: dataUTI)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
private func requestVideoDataSource(for asset: PHAsset) -> Promise<(dataSource: DataSource, dataUTI: String)> {
|
private func requestVideoDataSource(for asset: PHAsset) -> AnyPublisher<(dataSource: DataSource, dataUTI: String), Error> {
|
||||||
return Promise { resolver in
|
return Deferred {
|
||||||
|
Future { [weak self] resolver in
|
||||||
|
|
||||||
let options: PHVideoRequestOptions = PHVideoRequestOptions()
|
let options: PHVideoRequestOptions = PHVideoRequestOptions()
|
||||||
options.isNetworkAccessAllowed = true
|
options.isNetworkAccessAllowed = true
|
||||||
|
|
||||||
_ = imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in
|
_ = self?.imageManager.requestExportSession(forVideo: asset, options: options, exportPreset: AVAssetExportPresetMediumQuality) { exportSession, foo in
|
||||||
|
|
||||||
guard let exportSession = exportSession else {
|
guard let exportSession = exportSession else {
|
||||||
resolver.reject(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil"))
|
resolver(Result.failure(PhotoLibraryError.assertionError(description: "exportSession was unexpectedly nil")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,28 +193,39 @@ class PhotoCollectionContents {
|
||||||
Logger.debug("Completed video export")
|
Logger.debug("Completed video export")
|
||||||
|
|
||||||
guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else {
|
guard let dataSource = DataSourcePath.dataSource(with: exportURL, shouldDeleteOnDeallocation: true) else {
|
||||||
resolver.reject(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL"))
|
resolver(Result.failure(PhotoLibraryError.assertionError(description: "Failed to build data source for exported video URL")))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
resolver.fulfill((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String))
|
resolver(Result.success((dataSource: dataSource, dataUTI: kUTTypeMPEG4 as String)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
}
|
||||||
|
|
||||||
func outgoingAttachment(for asset: PHAsset) -> Promise<SignalAttachment> {
|
func outgoingAttachment(for asset: PHAsset) -> AnyPublisher<SignalAttachment, Error> {
|
||||||
switch asset.mediaType {
|
switch asset.mediaType {
|
||||||
case .image:
|
case .image:
|
||||||
return requestImageDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
|
return requestImageDataSource(for: asset)
|
||||||
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
|
.map { (dataSource: DataSource, dataUTI: String) in
|
||||||
|
SignalAttachment
|
||||||
|
.attachment(dataSource: dataSource, dataUTI: dataUTI, imageQuality: .medium)
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
case .video:
|
case .video:
|
||||||
return requestVideoDataSource(for: asset).map { (dataSource: DataSource, dataUTI: String) in
|
return requestVideoDataSource(for: asset)
|
||||||
return SignalAttachment.attachment(dataSource: dataSource, dataUTI: dataUTI)
|
.map { (dataSource: DataSource, dataUTI: String) in
|
||||||
|
SignalAttachment
|
||||||
|
.attachment(dataSource: dataSource, dataUTI: dataUTI)
|
||||||
}
|
}
|
||||||
|
.eraseToAnyPublisher()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return Promise(error: PhotoLibraryError.unsupportedMediaType)
|
return Fail(error: PhotoLibraryError.unsupportedMediaType)
|
||||||
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
import Photos
|
import Photos
|
||||||
import PromiseKit
|
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
class SendMediaNavigationController: UINavigationController {
|
class SendMediaNavigationController: UINavigationController {
|
||||||
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
public override var preferredStatusBarStyle: UIStatusBarStyle {
|
||||||
|
@ -15,6 +17,7 @@ class SendMediaNavigationController: UINavigationController {
|
||||||
static let bottomButtonsCenterOffset: CGFloat = -50
|
static let bottomButtonsCenterOffset: CGFloat = -50
|
||||||
|
|
||||||
private let threadId: String
|
private let threadId: String
|
||||||
|
private var disposables: Set<AnyCancellable> = Set()
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
@ -324,18 +327,17 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
||||||
func showApprovalAfterProcessingAnyMediaLibrarySelections() {
|
func showApprovalAfterProcessingAnyMediaLibrarySelections() {
|
||||||
let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues
|
let mediaLibrarySelections: [MediaLibrarySelection] = self.mediaLibrarySelections.orderedValues
|
||||||
|
|
||||||
let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { modal in
|
let backgroundBlock: (ModalActivityIndicatorViewController) -> Void = { [weak self] modal in
|
||||||
let attachmentPromises: [Promise<MediaLibraryAttachment>] = mediaLibrarySelections.map { $0.promise }
|
guard let strongSelf = self else { return }
|
||||||
|
|
||||||
when(fulfilled: attachmentPromises)
|
Publishers
|
||||||
.map { attachments in
|
.MergeMany(mediaLibrarySelections.map { $0.publisher })
|
||||||
Logger.debug("built all attachments")
|
.collect()
|
||||||
modal.dismiss {
|
.sink(
|
||||||
self.attachmentDraftCollection.selectedFromPicker(attachments: attachments)
|
receiveCompletion: { result in
|
||||||
self.pushApprovalViewController()
|
switch result {
|
||||||
}
|
case .finished: break
|
||||||
}
|
case .failure(let error):
|
||||||
.catch { error in
|
|
||||||
Logger.error("failed to prepare attachments. error: \(error)")
|
Logger.error("failed to prepare attachments. error: \(error)")
|
||||||
modal.dismiss { [weak self] in
|
modal.dismiss { [weak self] in
|
||||||
let modal: ConfirmationModal = ConfirmationModal(
|
let modal: ConfirmationModal = ConfirmationModal(
|
||||||
|
@ -349,7 +351,16 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
||||||
self?.present(modal, animated: true)
|
self?.present(modal, animated: true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
},
|
||||||
|
receiveValue: { attachments in
|
||||||
|
Logger.debug("built all attachments")
|
||||||
|
modal.dismiss {
|
||||||
|
self?.attachmentDraftCollection.selectedFromPicker(attachments: attachments)
|
||||||
|
self?.pushApprovalViewController()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.store(in: &strongSelf.disposables)
|
||||||
}
|
}
|
||||||
|
|
||||||
ModalActivityIndicatorViewController.present(
|
ModalActivityIndicatorViewController.present(
|
||||||
|
@ -363,10 +374,13 @@ extension SendMediaNavigationController: ImagePickerGridControllerDelegate {
|
||||||
return mediaLibrarySelections.hasValue(forKey: asset)
|
return mediaLibrarySelections.hasValue(forKey: asset)
|
||||||
}
|
}
|
||||||
|
|
||||||
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPromise: Promise<SignalAttachment>) {
|
func imagePicker(_ imagePicker: ImagePickerGridController, didSelectAsset asset: PHAsset, attachmentPublisher: AnyPublisher<SignalAttachment, Error>) {
|
||||||
guard !mediaLibrarySelections.hasValue(forKey: asset) else { return }
|
guard !mediaLibrarySelections.hasValue(forKey: asset) else { return }
|
||||||
|
|
||||||
let libraryMedia = MediaLibrarySelection(asset: asset, signalAttachmentPromise: attachmentPromise)
|
let libraryMedia = MediaLibrarySelection(
|
||||||
|
asset: asset,
|
||||||
|
signalAttachmentPublisher: attachmentPublisher
|
||||||
|
)
|
||||||
mediaLibrarySelections.append(key: asset, value: libraryMedia)
|
mediaLibrarySelections.append(key: asset, value: libraryMedia)
|
||||||
updateButtons(topViewController: imagePicker)
|
updateButtons(topViewController: imagePicker)
|
||||||
}
|
}
|
||||||
|
@ -511,17 +525,17 @@ private final class AttachmentDraftCollection {
|
||||||
|
|
||||||
private struct MediaLibrarySelection: Hashable, Equatable {
|
private struct MediaLibrarySelection: Hashable, Equatable {
|
||||||
let asset: PHAsset
|
let asset: PHAsset
|
||||||
let signalAttachmentPromise: Promise<SignalAttachment>
|
let signalAttachmentPublisher: AnyPublisher<SignalAttachment, Error>
|
||||||
|
|
||||||
var hashValue: Int {
|
var hashValue: Int {
|
||||||
return asset.hashValue
|
return asset.hashValue
|
||||||
}
|
}
|
||||||
|
|
||||||
var promise: Promise<MediaLibraryAttachment> {
|
var publisher: AnyPublisher<MediaLibraryAttachment, Error> {
|
||||||
let asset = self.asset
|
let asset = self.asset
|
||||||
return signalAttachmentPromise.map { signalAttachment in
|
return signalAttachmentPublisher
|
||||||
return MediaLibraryAttachment(asset: asset, signalAttachment: signalAttachment)
|
.map { MediaLibraryAttachment(asset: asset, signalAttachment: $0) }
|
||||||
}
|
.eraseToAnyPublisher()
|
||||||
}
|
}
|
||||||
|
|
||||||
static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool {
|
static func ==(lhs: MediaLibrarySelection, rhs: MediaLibrarySelection) -> Bool {
|
||||||
|
@ -583,7 +597,10 @@ private class DoneButton: UIView {
|
||||||
|
|
||||||
private lazy var badgeLabel: UILabel = {
|
private lazy var badgeLabel: UILabel = {
|
||||||
let result: UILabel = 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.themeTextColor = .black // Will render on the primary color so should always be black
|
||||||
result.textAlignment = .center
|
result.textAlignment = .center
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import PromiseKit
|
import SessionUIKit
|
||||||
|
|
||||||
class MediaDismissAnimationController: NSObject {
|
class MediaDismissAnimationController: NSObject {
|
||||||
private let mediaItem: Media
|
private let mediaItem: Media
|
||||||
|
@ -48,6 +48,18 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
|
||||||
case let contextProvider as MediaPresentationContextProvider:
|
case let contextProvider as MediaPresentationContextProvider:
|
||||||
fromContextProvider = contextProvider
|
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:
|
case let navController as UINavigationController:
|
||||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||||
transitionContext.completeTransition(false)
|
transitionContext.completeTransition(false)
|
||||||
|
@ -66,6 +78,19 @@ extension MediaDismissAnimationController: UIViewControllerAnimatedTransitioning
|
||||||
toVC.view.layoutIfNeeded()
|
toVC.view.layoutIfNeeded()
|
||||||
toContextProvider = contextProvider
|
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:
|
case let navController as UINavigationController:
|
||||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||||
transitionContext.completeTransition(false)
|
transitionContext.completeTransition(false)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
// MARK: - InteractivelyDismissableViewController
|
// MARK: - InteractivelyDismissableViewController
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
class MediaZoomAnimationController: NSObject {
|
class MediaZoomAnimationController: NSObject {
|
||||||
private let mediaItem: Media
|
private let mediaItem: Media
|
||||||
|
@ -35,6 +36,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
|
||||||
case let contextProvider as MediaPresentationContextProvider:
|
case let contextProvider as MediaPresentationContextProvider:
|
||||||
fromContextProvider = contextProvider
|
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:
|
case let navController as UINavigationController:
|
||||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||||
transitionContext.completeTransition(false)
|
transitionContext.completeTransition(false)
|
||||||
|
@ -52,6 +65,18 @@ extension MediaZoomAnimationController: UIViewControllerAnimatedTransitioning {
|
||||||
case let contextProvider as MediaPresentationContextProvider:
|
case let contextProvider as MediaPresentationContextProvider:
|
||||||
toContextProvider = contextProvider
|
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:
|
case let navController as UINavigationController:
|
||||||
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
guard let contextProvider = navController.topViewController as? MediaPresentationContextProvider else {
|
||||||
transitionContext.completeTransition(false)
|
transitionContext.completeTransition(false)
|
||||||
|
|
|
@ -1,32 +1,35 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
|
||||||
import GRDB
|
|
||||||
import PromiseKit
|
|
||||||
import WebRTC
|
|
||||||
import SessionUIKit
|
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import Combine
|
||||||
|
import UserNotifications
|
||||||
|
import GRDB
|
||||||
|
import SessionUIKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SessionUIKit
|
|
||||||
import UserNotifications
|
|
||||||
import UIKit
|
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
||||||
|
private static let maxRootViewControllerInitialQueryDuration: TimeInterval = 10
|
||||||
|
|
||||||
var window: UIWindow?
|
var window: UIWindow?
|
||||||
var backgroundSnapshotBlockerWindow: UIWindow?
|
var backgroundSnapshotBlockerWindow: UIWindow?
|
||||||
var appStartupWindow: UIWindow?
|
var appStartupWindow: UIWindow?
|
||||||
|
var initialLaunchFailed: Bool = false
|
||||||
var hasInitialRootViewController: Bool = false
|
var hasInitialRootViewController: Bool = false
|
||||||
|
var startTime: CFTimeInterval = 0
|
||||||
private var loadingViewController: LoadingViewController?
|
private var loadingViewController: LoadingViewController?
|
||||||
|
|
||||||
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
|
/// This needs to be a lazy variable to ensure it doesn't get initialized before it actually needs to be used
|
||||||
lazy var poller: Poller = Poller()
|
lazy var poller: CurrentUserPoller = CurrentUserPoller()
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
|
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)
|
// These should be the first things we do (the startup process can fail without them)
|
||||||
SetCurrentAppContext(MainAppContext())
|
SetCurrentAppContext(MainAppContext())
|
||||||
verifyDBKeysAvailableBeforeBackgroundLaunch()
|
verifyDBKeysAvailableBeforeBackgroundLaunch()
|
||||||
|
@ -44,9 +47,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds)
|
let mainWindow: UIWindow = TraitObservingWindow(frame: UIScreen.main.bounds)
|
||||||
self.loadingViewController = LoadingViewController()
|
self.loadingViewController = LoadingViewController()
|
||||||
|
|
||||||
// Store a weak reference in the ThemeManager so it can properly apply themes as needed
|
|
||||||
ThemeManager.mainWindow = mainWindow
|
|
||||||
|
|
||||||
AppSetup.setupEnvironment(
|
AppSetup.setupEnvironment(
|
||||||
appSpecificBlock: {
|
appSpecificBlock: {
|
||||||
// Create AppEnvironment
|
// Create AppEnvironment
|
||||||
|
@ -71,11 +71,19 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
},
|
},
|
||||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||||
if case .failure(let error) = result {
|
if case .failure(let error) = result {
|
||||||
self?.showFailedMigrationAlert(error: error)
|
DispatchQueue.main.async {
|
||||||
|
self?.initialLaunchFailed = true
|
||||||
|
self?.showFailedStartupAlert(calledFrom: .finishLaunching, error: .databaseError(error))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.completePostMigrationSetup(needsConfigSync: needsConfigSync)
|
/// Store a weak reference in the ThemeManager so it can properly apply themes as needed
|
||||||
|
///
|
||||||
|
/// **Note:** Need to do this after the db migrations because theme preferences are stored in the database and
|
||||||
|
/// we don't want to access it until after the migrations run
|
||||||
|
ThemeManager.mainWindow = mainWindow
|
||||||
|
self?.completePostMigrationSetup(calledFrom: .finishLaunching, needsConfigSync: needsConfigSync)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -128,9 +136,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
|
|
||||||
// Resume database
|
// Resume database
|
||||||
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
NotificationCenter.default.post(name: Database.resumeNotification, object: self)
|
||||||
|
|
||||||
|
// Reset the 'startTime' (since it would be invalid from the last launch)
|
||||||
|
startTime = CACurrentMediaTime()
|
||||||
|
|
||||||
|
// If we've already completed migrations at least once this launch then check
|
||||||
|
// to see if any "delayed" migrations now need to run
|
||||||
|
if Storage.shared.hasCompletedMigrations {
|
||||||
|
SNLog("Checking for pending migrations")
|
||||||
|
let initialLaunchFailed: Bool = self.initialLaunchFailed
|
||||||
|
|
||||||
|
AppReadiness.invalidate()
|
||||||
|
|
||||||
|
// If the user went to the background too quickly then the database can be suspended before
|
||||||
|
// properly starting up, in this case an alert will be shown but we can recover from it so
|
||||||
|
// dismiss any alerts that were shown
|
||||||
|
if initialLaunchFailed {
|
||||||
|
self.window?.rootViewController?.dismiss(animated: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch async so things can continue to be progressed if a migration does need to run
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
AppSetup.runPostSetupMigrations(
|
||||||
|
migrationProgressChanged: { progress, minEstimatedTotalTime in
|
||||||
|
self?.loadingViewController?.updateProgress(
|
||||||
|
progress: progress,
|
||||||
|
minEstimatedTotalTime: minEstimatedTotalTime
|
||||||
|
)
|
||||||
|
},
|
||||||
|
migrationsCompletion: { result, needsConfigSync in
|
||||||
|
if case .failure(let error) = result {
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
self?.showFailedStartupAlert(
|
||||||
|
calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed),
|
||||||
|
error: .databaseError(error)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.completePostMigrationSetup(
|
||||||
|
calledFrom: .enterForeground(initialLaunchFailed: initialLaunchFailed),
|
||||||
|
needsConfigSync: needsConfigSync
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationDidEnterBackground(_ application: UIApplication) {
|
func applicationDidEnterBackground(_ application: UIApplication) {
|
||||||
|
if !hasInitialRootViewController { SNLog("Entered background before startup was completed") }
|
||||||
|
|
||||||
DDLog.flushLog()
|
DDLog.flushLog()
|
||||||
|
|
||||||
// NOTE: Fix an edge case where user taps on the callkit notification
|
// NOTE: Fix an edge case where user taps on the callkit notification
|
||||||
|
@ -160,7 +217,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
|
|
||||||
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
|
UserDefaults.sharedLokiProject?[.isMainAppActive] = true
|
||||||
|
|
||||||
ensureRootViewController()
|
ensureRootViewController(calledFrom: .didBecomeActive)
|
||||||
|
|
||||||
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
|
AppReadiness.runNowOrWhenAppDidBecomeReady { [weak self] in
|
||||||
self?.handleActivation()
|
self?.handleActivation()
|
||||||
|
@ -234,6 +291,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
BackgroundPoller.isValid = true
|
BackgroundPoller.isValid = true
|
||||||
|
|
||||||
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
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
|
BackgroundPoller.poll { result in
|
||||||
guard BackgroundPoller.isValid else { return }
|
guard BackgroundPoller.isValid else { return }
|
||||||
|
|
||||||
|
@ -252,42 +315,56 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
|
|
||||||
// MARK: - App Readiness
|
// MARK: - App Readiness
|
||||||
|
|
||||||
private func completePostMigrationSetup(needsConfigSync: Bool) {
|
private func completePostMigrationSetup(calledFrom lifecycleMethod: LifecycleMethod, needsConfigSync: Bool) {
|
||||||
|
SNLog("Migrations completed, performing setup and ensuring rootViewController")
|
||||||
Configuration.performMainSetup()
|
Configuration.performMainSetup()
|
||||||
JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens)
|
JobRunner.setExecutor(SyncPushTokensJob.self, for: .syncPushTokens)
|
||||||
|
|
||||||
/// Setup the UI
|
// Setup the UI if needed, then trigger any post-UI setup actions
|
||||||
///
|
self.ensureRootViewController(calledFrom: lifecycleMethod) { [weak self] success in
|
||||||
/// **Note:** This **MUST** be run before calling:
|
// If we didn't successfully ensure the rootViewController then don't continue as
|
||||||
/// - `AppReadiness.setAppIsReady()`:
|
// the user is in an invalid state (and should have already been shown a modal)
|
||||||
/// If we are launching the app from a push notification the HomeVC won't be setup yet
|
guard success else { return }
|
||||||
/// 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
|
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()
|
JobRunner.appDidFinishLaunching()
|
||||||
|
}
|
||||||
|
|
||||||
// Note that this does much more than set a flag;
|
/// Flag that the app is ready via `AppReadiness.setAppIsReady()`
|
||||||
// it will also run all deferred blocks (including the JobRunner
|
///
|
||||||
// 'appDidBecomeActive' method)
|
/// 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()
|
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)
|
DeviceSleepManager.sharedInstance.removeBlock(blockObject: self)
|
||||||
AppVersion.sharedInstance().mainAppLaunchDidComplete()
|
|
||||||
Environment.shared?.audioSession.setup()
|
|
||||||
Environment.shared?.reachabilityManager.setup()
|
|
||||||
|
|
||||||
|
/// 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
|
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)
|
||||||
|
|
||||||
// Disable the SAE until the main app has successfully completed launch process
|
// Disable the SAE until the main app has successfully completed launch process
|
||||||
// at least once in the post-SAE world.
|
// at least once in the post-SAE world.
|
||||||
db[.isReadyForAppExtensions] = true
|
db[.isReadyForAppExtensions] = true
|
||||||
|
|
||||||
if Identity.userExists(db) {
|
if Identity.userCompletedRequiredOnboarding(db) {
|
||||||
let appVersion: AppVersion = AppVersion.sharedInstance()
|
let appVersion: AppVersion = AppVersion.sharedInstance()
|
||||||
|
|
||||||
// If the device needs to sync config or the user updated to a new version
|
// If the device needs to sync config or the user updated to a new version
|
||||||
|
@ -297,30 +374,71 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
appVersion.lastAppVersion != appVersion.currentAppVersion
|
appVersion.lastAppVersion != appVersion.currentAppVersion
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
try MessageSender.syncConfiguration(db, forceSyncNow: true).retainUntilComplete()
|
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func showFailedMigrationAlert(error: Error?) {
|
// Add a log to track the proper startup time of the app so we know whether we need to
|
||||||
let alert = UIAlertController(
|
// 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 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",
|
title: "Session",
|
||||||
message: "DATABASE_MIGRATION_FAILED".localized(),
|
message: error.message,
|
||||||
preferredStyle: .alert
|
preferredStyle: .alert
|
||||||
)
|
)
|
||||||
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in
|
alert.addAction(UIAlertAction(title: "HELP_REPORT_BUG_ACTION_TITLE".localized(), style: .default) { _ in
|
||||||
HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in
|
HelpViewModel.shareLogs(viewControllerToDismiss: alert) { [weak self] in
|
||||||
self?.showFailedMigrationAlert(error: error)
|
// Don't bother showing the "Failed Startup" modal again if we happen to now
|
||||||
|
// have an initial view controller (this most likely means that the startup
|
||||||
|
// completed while the user was sharing logs so we can just let the user use
|
||||||
|
// the app)
|
||||||
|
guard self?.hasInitialRootViewController == false else { return }
|
||||||
|
|
||||||
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: error)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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
|
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
|
// Remove the legacy database and any message hashes that have been migrated to the new DB
|
||||||
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
try? SUKLegacy.deleteLegacyDatabaseFilesAndKey()
|
||||||
|
|
||||||
Storage.shared.write { db in
|
Storage.shared.write { db in
|
||||||
try SnodeReceivedMessageInfo.deleteAll(db)
|
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)
|
// The re-run the migration (should succeed since there is no data)
|
||||||
AppSetup.runPostSetupMigrations(
|
AppSetup.runPostSetupMigrations(
|
||||||
|
@ -331,22 +449,29 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
migrationsCompletion: { [weak self] result, needsConfigSync in
|
migrationsCompletion: { [weak self] result, needsConfigSync in
|
||||||
if case .failure(let error) = result {
|
switch result {
|
||||||
self?.showFailedMigrationAlert(error: error)
|
case .failure:
|
||||||
return
|
DispatchQueue.main.async {
|
||||||
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .failedToRestore)
|
||||||
}
|
}
|
||||||
|
|
||||||
self?.completePostMigrationSetup(needsConfigSync: needsConfigSync)
|
case .success:
|
||||||
|
self?.completePostMigrationSetup(calledFrom: lifecycleMethod, needsConfigSync: needsConfigSync)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
alert.addAction(UIAlertAction(title: "Close", style: .default) { _ in
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.addAction(UIAlertAction(title: "APP_STARTUP_EXIT".localized(), style: .default) { _ in
|
||||||
DDLog.flushLog()
|
DDLog.flushLog()
|
||||||
exit(0)
|
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.
|
/// The user must unlock the device once after reboot before the database encryption key can be accessed.
|
||||||
|
@ -387,7 +512,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleActivation() {
|
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()
|
enableBackgroundRefreshIfNecessary()
|
||||||
JobRunner.appDidBecomeActive()
|
JobRunner.appDidBecomeActive()
|
||||||
|
@ -400,26 +531,111 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func ensureRootViewController(isPreAppReadyCall: Bool = false) {
|
private func ensureRootViewController(
|
||||||
guard (AppReadiness.isAppReady() || isPreAppReadyCall) && Storage.shared.isValid && !hasInitialRootViewController else {
|
calledFrom lifecycleMethod: LifecycleMethod,
|
||||||
return
|
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
|
// All logic which needs to run after the 'rootViewController' is created
|
||||||
self.window?.rootViewController = StyledNavigationController(
|
let rootViewControllerSetupComplete: (UIViewController) -> () = { [weak self] rootViewController in
|
||||||
rootViewController: (Identity.userExists() ?
|
let presentedViewController: UIViewController? = self?.window?.rootViewController?.presentedViewController
|
||||||
HomeVC() :
|
let targetRootViewController: UIViewController = TopBannerController(
|
||||||
LandingVC()
|
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()
|
UIViewController.attemptRotationToDeviceOrientation()
|
||||||
|
|
||||||
/// **Note:** There is an annoying case when starting the app by interacting with a push notification where
|
/// **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`
|
/// 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
|
/// 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 {
|
if let homeViewController: HomeVC = rootViewController as? HomeVC {
|
||||||
SessionApp.homeViewController.mutate { $0 = homeViewController }
|
SessionApp.homeViewController.mutate { $0 = homeViewController }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// If we were previously presenting a viewController but are no longer preseting it then present it again
|
||||||
|
///
|
||||||
|
/// **Note:** Looks like the OS will throw an exception if we try to present a screen which is already (or
|
||||||
|
/// was previously?) presented, even if it's not attached to the screen it seems...
|
||||||
|
switch presentedViewController {
|
||||||
|
case is UIAlertController, is ConfirmationModal:
|
||||||
|
/// If the viewController we were presenting happened to be the "failed startup" modal then we can dismiss it
|
||||||
|
/// automatically (while this seems redundant it's less jarring for the user than just instantly having it disappear)
|
||||||
|
self?.showFailedStartupAlert(calledFrom: lifecycleMethod, error: .startupTimeout, animated: false) {
|
||||||
|
self?.window?.rootViewController?.dismiss(animated: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
case is UIActivityViewController: HelpViewModel.shareLogs(animated: false)
|
||||||
|
default: break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup is completed so run any post-setup tasks
|
||||||
|
onComplete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Notifications
|
// MARK: - Notifications
|
||||||
|
@ -491,7 +707,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
|
|
||||||
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
func application(_ application: UIApplication, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
|
||||||
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
AppReadiness.runNowOrWhenAppDidBecomeReady {
|
||||||
guard Identity.userExists() else { return }
|
guard Identity.userCompletedRequiredOnboarding() else { return }
|
||||||
|
|
||||||
SessionApp.homeViewController.wrappedValue?.createNewConversation()
|
SessionApp.homeViewController.wrappedValue?.createNewConversation()
|
||||||
completionHandler(true)
|
completionHandler(true)
|
||||||
|
@ -567,17 +783,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
public func startPollersIfNeeded(shouldStartGroupPollers: Bool = true) {
|
||||||
guard Identity.userExists() else { return }
|
guard Identity.userExists() else { return }
|
||||||
|
|
||||||
poller.startIfNeeded()
|
/// 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 }
|
guard shouldStartGroupPollers else { return }
|
||||||
|
|
||||||
ClosedGroupPoller.shared.start()
|
ClosedGroupPoller.shared.start()
|
||||||
OpenGroupManager.shared.startPolling()
|
OpenGroupManager.shared.startPolling()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
public func stopPollers(shouldStopUserPoller: Bool = true) {
|
||||||
if shouldStopUserPoller {
|
if shouldStopUserPoller {
|
||||||
poller.stop()
|
poller.stopAllPollers()
|
||||||
}
|
}
|
||||||
|
|
||||||
ClosedGroupPoller.shared.stopAllPollers()
|
ClosedGroupPoller.shared.stopAllPollers()
|
||||||
|
@ -655,13 +876,22 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
// MARK: - Config Sync
|
// MARK: - Config Sync
|
||||||
|
|
||||||
func syncConfigurationIfNeeded() {
|
func syncConfigurationIfNeeded() {
|
||||||
|
// FIXME: Remove this once `useSharedUtilForUserConfig` is permanent
|
||||||
|
guard !SessionUtil.userConfigsEnabled else { return }
|
||||||
|
|
||||||
let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast)
|
let lastSync: Date = (UserDefaults.standard[.lastConfigurationSync] ?? .distantPast)
|
||||||
|
|
||||||
guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days
|
guard Date().timeIntervalSince(lastSync) > (7 * 24 * 60 * 60) else { return } // Sync every 2 days
|
||||||
|
|
||||||
Storage.shared
|
Storage.shared
|
||||||
.writeAsync { db in try MessageSender.syncConfiguration(db, forceSyncNow: false) }
|
.writeAsync(
|
||||||
.done {
|
updates: { db in
|
||||||
|
ConfigurationSyncJob.enqueue(db, publicKey: getUserHexEncodedPublicKey(db))
|
||||||
|
},
|
||||||
|
completion: { _, result in
|
||||||
|
switch result {
|
||||||
|
case .failure: break
|
||||||
|
case .success:
|
||||||
// Only update the 'lastConfigurationSync' timestamp if we have done the
|
// Only update the 'lastConfigurationSync' timestamp if we have done the
|
||||||
// first sync (Don't want a new device config sync to override config
|
// first sync (Don't want a new device config sync to override config
|
||||||
// syncs from other devices)
|
// syncs from other devices)
|
||||||
|
@ -669,6 +899,58 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
|
||||||
UserDefaults.standard[.lastConfigurationSync] = Date()
|
UserDefaults.standard[.lastConfigurationSync] = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.retainUntilComplete()
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - LifecycleMethod
|
||||||
|
|
||||||
|
private enum LifecycleMethod: Equatable {
|
||||||
|
case finishLaunching
|
||||||
|
case enterForeground(initialLaunchFailed: Bool)
|
||||||
|
case didBecomeActive
|
||||||
|
|
||||||
|
var timingName: String {
|
||||||
|
switch self {
|
||||||
|
case .finishLaunching: return "Launch"
|
||||||
|
case .enterForeground: return "EnterForeground"
|
||||||
|
case .didBecomeActive: return "BecomeActive"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: LifecycleMethod, rhs: LifecycleMethod) -> Bool {
|
||||||
|
switch (lhs, rhs) {
|
||||||
|
case (.finishLaunching, .finishLaunching): return true
|
||||||
|
case (.enterForeground(let lhsFailed), .enterForeground(let rhsFailed)): return (lhsFailed == rhsFailed)
|
||||||
|
case (.didBecomeActive, .didBecomeActive): return true
|
||||||
|
default: return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - StartupError
|
||||||
|
|
||||||
|
private enum StartupError: Error {
|
||||||
|
case databaseError(Error)
|
||||||
|
case failedToRestore
|
||||||
|
case startupTimeout
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .databaseError(StorageError.startupFailed): return "Database startup failed"
|
||||||
|
case .failedToRestore: return "Failed to restore"
|
||||||
|
case .databaseError: return "Database error"
|
||||||
|
case .startupTimeout: return "Startup timeout"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var message: String {
|
||||||
|
switch self {
|
||||||
|
case .databaseError(StorageError.startupFailed): return "DATABASE_STARTUP_FAILED".localized()
|
||||||
|
case .failedToRestore: return "DATABASE_RESTORE_FAILED".localized()
|
||||||
|
case .databaseError: return "DATABASE_MIGRATION_FAILED".localized()
|
||||||
|
case .startupTimeout: return "APP_STARTUP_TIMEOUT".localized()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
import SignalCoreKit
|
||||||
|
|
||||||
public class AppEnvironment {
|
public class AppEnvironment {
|
||||||
|
|
||||||
|
|
|
@ -10,6 +10,8 @@ extern NSString *const ReportedApplicationStateDidChangeNotification;
|
||||||
|
|
||||||
@interface MainAppContext : NSObject <AppContext>
|
@interface MainAppContext : NSObject <AppContext>
|
||||||
|
|
||||||
|
- (instancetype)init;
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_END
|
NS_ASSUME_NONNULL_END
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
#import "MainAppContext.h"
|
#import "MainAppContext.h"
|
||||||
#import "Session-Swift.h"
|
#import "Session-Swift.h"
|
||||||
#import <SignalCoreKit/Threading.h>
|
#import <SignalCoreKit/OWSAsserts.h>
|
||||||
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
#import <SignalUtilitiesKit/SignalUtilitiesKit-Swift.h>
|
||||||
|
|
||||||
NS_ASSUME_NONNULL_BEGIN
|
NS_ASSUME_NONNULL_BEGIN
|
||||||
|
@ -252,7 +252,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic
|
||||||
{
|
{
|
||||||
OWSAssertDebug(block);
|
OWSAssertDebug(block);
|
||||||
|
|
||||||
DispatchMainThreadSafe(^{
|
[Threading dispatchMainThreadSafe:^{
|
||||||
if (self.isMainAppAndActive) {
|
if (self.isMainAppAndActive) {
|
||||||
// App active blocks typically will be used to safely access the
|
// App active blocks typically will be used to safely access the
|
||||||
// shared data container, so use a background task to protect this
|
// shared data container, so use a background task to protect this
|
||||||
|
@ -266,7 +266,7 @@ NSString *const ReportedApplicationStateDidChangeNotification = @"ReportedApplic
|
||||||
}
|
}
|
||||||
|
|
||||||
[self.appActiveBlocks addObject:block];
|
[self.appActiveBlocks addObject:block];
|
||||||
});
|
}];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)runAppActiveBlocks
|
- (void)runAppActiveBlocks
|
||||||
|
|
|
@ -2,13 +2,6 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<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>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>en</string>
|
<string>en</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
|
@ -88,7 +81,7 @@
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>Session needs camera access to take pictures and scan QR codes.</string>
|
<string>Session needs camera access to take pictures and scan QR codes.</string>
|
||||||
<key>NSFaceIDUsageDescription</key>
|
<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>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>com.loki-project.loki-messenger</string>
|
<string>com.loki-project.loki-messenger</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
//
|
|
||||||
// Copyright (c) 2018 Open Whisper Systems. All rights reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
#import <Availability.h>
|
|
||||||
|
|
||||||
#ifdef __OBJC__
|
|
||||||
#import <UIKit/UIKit.h>
|
|
||||||
#import <Foundation/Foundation.h>
|
|
||||||
|
|
||||||
#import <SignalCoreKit/NSObject+OWS.h>
|
|
||||||
#import <SignalCoreKit/OWSAsserts.h>
|
|
||||||
#import <SessionUIKit/SessionUIKit.h>
|
|
||||||
#endif
|
|
|
@ -3,64 +3,110 @@
|
||||||
import Foundation
|
import Foundation
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
import SessionMessagingKit
|
import SessionMessagingKit
|
||||||
|
import SignalCoreKit
|
||||||
|
import SessionUIKit
|
||||||
|
|
||||||
public struct SessionApp {
|
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 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
|
// MARK: - View Convenience Methods
|
||||||
|
|
||||||
public static func presentConversation(for threadId: String, action: ConversationViewModel.Action = .none, animated: Bool) {
|
public static func presentConversationCreatingIfNeeded(
|
||||||
let maybeThreadInfo: (thread: SessionThread, isMessageRequest: Bool)? = Storage.shared.write { db in
|
|
||||||
let thread: SessionThread = try SessionThread.fetchOrCreate(db, id: threadId, variant: .contact)
|
|
||||||
|
|
||||||
return (thread, thread.isMessageRequest(db))
|
|
||||||
}
|
|
||||||
|
|
||||||
guard
|
|
||||||
let variant: SessionThread.Variant = maybeThreadInfo?.thread.variant,
|
|
||||||
let isMessageRequest: Bool = maybeThreadInfo?.isMessageRequest
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
self.presentConversation(
|
|
||||||
for: threadId,
|
|
||||||
threadVariant: variant,
|
|
||||||
isMessageRequest: isMessageRequest,
|
|
||||||
action: action,
|
|
||||||
focusInteractionId: nil,
|
|
||||||
animated: animated
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func presentConversation(
|
|
||||||
for threadId: String,
|
for threadId: String,
|
||||||
threadVariant: SessionThread.Variant,
|
variant: SessionThread.Variant,
|
||||||
isMessageRequest: Bool,
|
action: ConversationViewModel.Action = .none,
|
||||||
action: ConversationViewModel.Action,
|
dismissing presentingViewController: UIViewController?,
|
||||||
focusInteractionId: Int64?,
|
|
||||||
animated: Bool
|
animated: Bool
|
||||||
) {
|
) {
|
||||||
guard Thread.isMainThread else {
|
let threadInfo: (threadExists: Bool, isMessageRequest: Bool)? = Storage.shared.read { db in
|
||||||
DispatchQueue.main.async {
|
let isMessageRequest: Bool = {
|
||||||
self.presentConversation(
|
switch variant {
|
||||||
for: threadId,
|
case .contact:
|
||||||
threadVariant: threadVariant,
|
return SessionThread
|
||||||
isMessageRequest: isMessageRequest,
|
.isMessageRequest(
|
||||||
action: action,
|
id: threadId,
|
||||||
focusInteractionId: focusInteractionId,
|
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
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
homeViewController.wrappedValue?.show(
|
// Send to main thread if needed
|
||||||
threadId,
|
guard Thread.isMainThread else {
|
||||||
variant: threadVariant,
|
DispatchQueue.main.async {
|
||||||
isMessageRequest: isMessageRequest,
|
afterThreadCreated()
|
||||||
with: action,
|
}
|
||||||
focusedInteractionId: focusInteractionId,
|
return
|
||||||
animated: animated
|
}
|
||||||
)
|
|
||||||
|
afterThreadCreated()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
@ -70,6 +116,7 @@ public struct SessionApp {
|
||||||
Logger.error("")
|
Logger.error("")
|
||||||
DDLog.flushLog()
|
DDLog.flushLog()
|
||||||
|
|
||||||
|
SessionUtil.clearMemoryState()
|
||||||
Storage.resetAllStorage()
|
Storage.resetAllStorage()
|
||||||
ProfileManager.resetProfileStorage()
|
ProfileManager.resetProfileStorage()
|
||||||
Attachment.resetAttachmentStorage()
|
Attachment.resetAttachmentStorage()
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -4,5 +4,58 @@
|
||||||
<dict>
|
<dict>
|
||||||
<key>StringsTable</key>
|
<key>StringsTable</key>
|
||||||
<string>Root</string>
|
<string>Root</string>
|
||||||
|
<key>PreferenceSpecifiers</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>Type</key>
|
||||||
|
<string>PSGroupSpecifier</string>
|
||||||
|
<key>Title</key>
|
||||||
|
<string>Group</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>Type</key>
|
||||||
|
<string>PSTextFieldSpecifier</string>
|
||||||
|
<key>Title</key>
|
||||||
|
<string>Name</string>
|
||||||
|
<key>Key</key>
|
||||||
|
<string>name_preference</string>
|
||||||
|
<key>DefaultValue</key>
|
||||||
|
<string></string>
|
||||||
|
<key>IsSecure</key>
|
||||||
|
<false/>
|
||||||
|
<key>KeyboardType</key>
|
||||||
|
<string>Alphabet</string>
|
||||||
|
<key>AutocapitalizationType</key>
|
||||||
|
<string>None</string>
|
||||||
|
<key>AutocorrectionType</key>
|
||||||
|
<string>No</string>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>Type</key>
|
||||||
|
<string>PSToggleSwitchSpecifier</string>
|
||||||
|
<key>Title</key>
|
||||||
|
<string>Enabled</string>
|
||||||
|
<key>Key</key>
|
||||||
|
<string>enabled_preference</string>
|
||||||
|
<key>DefaultValue</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
<dict>
|
||||||
|
<key>Type</key>
|
||||||
|
<string>PSSliderSpecifier</string>
|
||||||
|
<key>Key</key>
|
||||||
|
<string>slider_preference</string>
|
||||||
|
<key>DefaultValue</key>
|
||||||
|
<real>0.5</real>
|
||||||
|
<key>MinimumValue</key>
|
||||||
|
<integer>0</integer>
|
||||||
|
<key>MaximumValue</key>
|
||||||
|
<integer>1</integer>
|
||||||
|
<key>MinimumValueImage</key>
|
||||||
|
<string></string>
|
||||||
|
<key>MaximumValueImage</key>
|
||||||
|
<string></string>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -2,38 +2,9 @@
|
||||||
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
|
// 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 "AVAudioSession+OWS.h"
|
||||||
#import "OWSAudioPlayer.h"
|
#import "OWSAudioPlayer.h"
|
||||||
#import "OWSBezierPathView.h"
|
#import "OWSBezierPathView.h"
|
||||||
#import "OWSMessageTimerView.h"
|
#import "OWSMessageTimerView.h"
|
||||||
#import "OWSWindowManager.h"
|
#import "OWSWindowManager.h"
|
||||||
#import "MainAppContext.h"
|
#import "MainAppContext.h"
|
||||||
#import <PureLayout/PureLayout.h>
|
|
||||||
#import <Reachability/Reachability.h>
|
|
||||||
#import <SignalCoreKit/Cryptography.h>
|
|
||||||
#import <SignalCoreKit/NSData+OWS.h>
|
|
||||||
#import <SignalCoreKit/NSDate+OWS.h>
|
|
||||||
#import <SignalCoreKit/OWSAsserts.h>
|
|
||||||
#import <SignalCoreKit/OWSLogs.h>
|
|
||||||
#import <SignalCoreKit/Threading.h>
|
|
||||||
#import <SessionMessagingKit/OWSAudioPlayer.h>
|
|
||||||
#import <SignalUtilitiesKit/OWSFormat.h>
|
|
||||||
#import <SignalUtilitiesKit/OWSViewController.h>
|
|
||||||
#import <SignalUtilitiesKit/UIFont+OWS.h>
|
|
||||||
#import <SessionUtilitiesKit/UIView+OWS.h>
|
|
||||||
#import <SignalUtilitiesKit/AppVersion.h>
|
|
||||||
#import <SessionUtilitiesKit/DataSource.h>
|
|
||||||
#import <SessionUtilitiesKit/MIMETypeUtil.h>
|
|
||||||
#import <SessionUtilitiesKit/NSData+Image.h>
|
|
||||||
#import <SessionUtilitiesKit/NSNotificationCenter+OWS.h>
|
|
||||||
#import <SessionUtilitiesKit/NSString+SSK.h>
|
|
||||||
#import <SignalUtilitiesKit/OWSDispatch.h>
|
|
||||||
#import <SignalUtilitiesKit/OWSError.h>
|
|
||||||
#import <SessionUtilitiesKit/OWSFileSystem.h>
|
|
||||||
#import <SessionUtilitiesKit/UIImage+OWS.h>
|
|
||||||
#import <YYImage/YYImage.h>
|
|
||||||
|
|
|
@ -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.
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue